@@ -16,6 +16,19 @@ import io.sentry.SpanStatus
1616 * Note: "Owner" below refers to the `LifecycleOwner` whose lifecycle events the candidate's
1717 * `NavScopes` relays.
1818 *
19+ * **Exception semantics**
20+ *
21+ * Every method in this class with an `IScope` parameter can throw arbitrary exceptions due to:
22+ *
23+ * 1. the [onPublishData][registerCandidate] and [onClearPublishedData][registerCandidate] callbacks
24+ * supplied at registration;
25+ * 2. the `IScope` the method is handed, whose mutators notify every observer registered through
26+ * `SentryOptions.addScopeObserver` – including the host app's – in an unguarded loop; and
27+ * 3. `finish()` on a retained nav transaction, which may run the host app's event processors.
28+ *
29+ * This class restores its own bookkeeping before the exception leaves, so the coordinator remains
30+ * usable.
31+ *
1932 * **Thread safety**
2033 *
2134 * This class is ***not*** thread-safe. All `NavScopes` instances using this coordinator must
@@ -83,6 +96,8 @@ internal class NavLeaseCoordinator {
8396 *
8497 * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the
8598 * candidate remains eligible for fallback once a scope becomes available.
99+ *
100+ * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc.
86101 */
87102 fun resumeCandidate (owner : Any , scope : IScope ? ) {
88103 findCandidate(owner)?.let {
@@ -104,6 +119,8 @@ internal class NavLeaseCoordinator {
104119 *
105120 * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the
106121 * candidate remains eligible for fallback once a scope becomes available.
122+ *
123+ * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc.
107124 */
108125 fun pauseCandidate (owner : Any , scope : IScope ? ) {
109126 findCandidate(owner)?.let {
@@ -132,6 +149,8 @@ internal class NavLeaseCoordinator {
132149 *
133150 * A `null` scope (e.g., the SDK is disabled) still records the lifecycle transition so the
134151 * candidate remains eligible for fallback once a scope becomes available.
152+ *
153+ * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc.
135154 */
136155 fun stopCandidate (owner : Any , scope : IScope ? ) {
137156 findCandidate(owner)?.let {
@@ -156,36 +175,34 @@ internal class NavLeaseCoordinator {
156175 * left to time out, its published data is left in place, and the lease is released rather than
157176 * handed off.
158177 *
159- * Coordinator references are released even if scope cleanup or lease handoff throws. The
160- * exception is left to the caller to handle; remaining scope cleanup may be incomplete .
178+ * Coordinator references are released even if scope cleanup or lease handoff throws. See
179+ * "Exception semantics" in the class KDoc. The remaining scope cleanup is then skipped .
161180 */
162181 fun unregisterCandidate (owner : Any , scope : IScope ? ) {
163- findCandidate(owner)?.let {
164- try {
165- finishTransaction(it, scope)
166- candidates.remove(it)
182+ val candidate = findCandidate(owner) ? : return
167183
168- if (publishedDataCandidate == = it) {
169- publishedDataCandidate = null
170- if (scope != null ) {
171- it.onClearPublishedData(scope)
172- }
173- }
184+ try {
185+ finishTransaction(candidate, scope)
186+ candidates.remove(candidate)
174187
175- if (leaseHolder == = it) {
176- handOffLease(mostRecentlyResumedCandidate(), scope)
177- }
178- } finally {
179- // Scope observers and transaction callbacks can throw. Never retain a departing owner
180- // just because cleanup failed, and preserve any successor selected during handoff.
181- candidates.remove(it)
182- it.transaction = null
183- if (publishedDataCandidate == = it) {
184- publishedDataCandidate = null
185- }
186- if (leaseHolder == = it) {
187- leaseHolder = null
188- }
188+ if (publishedDataCandidate == = candidate) {
189+ publishedDataCandidate = null
190+ scope?.let { candidate.onClearPublishedData(it) }
191+ }
192+
193+ if (leaseHolder == = candidate) {
194+ handOffLease(mostRecentlyResumedCandidate(), scope)
195+ }
196+ } finally {
197+ // Scope observers and transaction callbacks can throw. Never retain a departing owner
198+ // just because cleanup failed, and preserve any successor selected during handoff.
199+ candidates.remove(candidate)
200+ candidate.transaction = null
201+ if (publishedDataCandidate == = candidate) {
202+ publishedDataCandidate = null
203+ }
204+ if (leaseHolder == = candidate) {
205+ leaseHolder = null
189206 }
190207 }
191208 }
@@ -203,6 +220,8 @@ internal class NavLeaseCoordinator {
203220 * If the lease transfers, ownership and transaction binding are updated before the candidate's
204221 * staged nav data is published. Publication completes before this method returns, so the caller
205222 * can then apply the back-stack update. Later calls for the same [owner] do nothing.
223+ *
224+ * Can throw arbitrary exceptions. See "Exception semantics" in the class KDoc.
206225 */
207226 fun onBackStackUpdate (owner : Any , scope : IScope ) {
208227 val candidate = findCandidate(owner) ? : return
@@ -293,6 +312,14 @@ internal class NavLeaseCoordinator {
293312 }
294313 }
295314
315+ /* *
316+ * Hands the lease to [candidate], or releases it given `null`.
317+ *
318+ * Maintains the invariant that, while a lease is held, the leaseholder is also the candidate
319+ * recorded as having published the shared scope's nav data. The invariant holds even when a
320+ * publish or clear callback throws part way through the handoff. See "Exception semantics" in the
321+ * class KDoc.
322+ */
296323 private fun transferLeaseTo (candidate : Candidate ? , scope : IScope ) {
297324 if (leaseHolder == = candidate) {
298325 return
@@ -330,11 +357,20 @@ internal class NavLeaseCoordinator {
330357 // previous owner's values don't linger where the candidate has nothing staged to replace
331358 // them.
332359 val previousPublisher = publishedDataCandidate
333- if (previousPublisher != null && previousPublisher != = candidate) {
334- previousPublisher.onClearPublishedData(scope)
360+ try {
361+ if (previousPublisher != null && previousPublisher != = candidate) {
362+ previousPublisher.onClearPublishedData(scope)
363+ }
364+ publishedDataCandidate = candidate
365+ candidate.onPublishData(scope)
366+ } finally {
367+ // Scope observers can throw. A candidate owns the scope's nav data from the moment it
368+ // takes the lease, so record it as the publisher even if clearing the previous owner's
369+ // data threw. Leaving the previous owner recorded here would strand both owners' values
370+ // b/c the next handoff would clear data the candidate may have already replaced, and would
371+ // never clear the candidate's own.
372+ publishedDataCandidate = candidate
335373 }
336- publishedDataCandidate = candidate
337- candidate.onPublishData(scope)
338374 }
339375 }
340376
0 commit comments