Skip to content

Commit ed4534d

Browse files
ref(android): Route non-terminating session updates through withSession
Every writer to the live session now holds Scope.sessionLock, and only clones leave it, so the lazy serialization on the transport thread and the session file write can no longer race a later mutation. Persisting moves to the executor service instead of running on the calling thread, an already terminated session is no longer written back to the session file, and the broad catch is narrowed to the one call that forces it. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 32b70a7 commit ed4534d

3 files changed

Lines changed: 115 additions & 49 deletions

File tree

‎sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java‎

Lines changed: 73 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import java.util.List;
4242
import java.util.Locale;
4343
import java.util.Map;
44+
import java.util.concurrent.RejectedExecutionException;
4445
import java.util.concurrent.atomic.AtomicReference;
4546
import org.jetbrains.annotations.ApiStatus;
4647
import org.jetbrains.annotations.NotNull;
@@ -244,48 +245,72 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel
244245
return null;
245246
}
246247

248+
final @NotNull EnvelopeEventState eventState;
247249
try {
248-
final @NotNull ISerializer serializer = options.getSerializer();
249-
final @NotNull EnvelopeEventState eventState = eventStateOf(envelope, serializer);
250-
251-
if (eventState != EnvelopeEventState.NONE) {
252-
updateSessionNonTerminating(eventState == EnvelopeEventState.UNHANDLED);
253-
}
254-
255-
return scopes.captureEnvelope(envelope);
250+
eventState = eventStateOf(envelope, options.getSerializer());
256251
} catch (Exception e) {
257-
options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", e);
252+
// getEvent reads through a Callable, whose call() declares Exception
253+
options.getLogger().log(SentryLevel.ERROR, "Failed to inspect envelope events", e);
254+
return null;
258255
}
259-
return null;
256+
257+
if (eventState != EnvelopeEventState.NONE) {
258+
updateSessionNonTerminating(eventState == EnvelopeEventState.UNHANDLED);
259+
}
260+
261+
return scopes.captureEnvelope(envelope);
260262
}
261263

262264
/**
263-
* Mutates and persists the current session for a non-terminating hybrid error. The write stays
264-
* inside {@code withSession} so the mutation and the persist are one critical section. Persisting
265-
* outside it lets a concurrent caller's older snapshot land last and drop the unhandled marker.
265+
* Flags and persists the current session for a non-terminating hybrid error.
266266
*
267-
* @param crashed {@code true} if the error was unhandled ({@code mechanism.handled=false})
267+
* @param unhandled {@code true} if the error was unhandled ({@code mechanism.handled=false})
268268
*/
269-
private static void updateSessionNonTerminating(final boolean crashed) {
269+
private static void updateSessionNonTerminating(final boolean unhandled) {
270270
final @NotNull IScopes scopes = ScopesAdapter.getInstance();
271271
final @NotNull SentryOptions options = scopes.getOptions();
272272
scopes.configureScope(
273-
scope -> {
274-
scope.withSession(
275-
session -> {
276-
if (session != null) {
277-
final boolean updated =
278-
crashed
273+
scope ->
274+
scope.withSession(
275+
session -> {
276+
if (session == null) {
277+
options.getLogger().log(INFO, "Session is null on updateSessionNonTerminating");
278+
return;
279+
}
280+
if (session.isTerminated()) {
281+
options
282+
.getLogger()
283+
.log(INFO, "Session already terminated, not recording the error.");
284+
return;
285+
}
286+
final boolean recorded =
287+
unhandled
279288
? session.recordNonTerminatingUnhandledError()
280289
: session.update(null, null, true, null);
281-
if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) {
282-
((EnvelopeCache) options.getEnvelopeDiskCache()).persistCurrentSession(session);
290+
if (recorded) {
291+
schedulePersistSession(options, session.clone());
283292
}
284-
} else {
285-
options.getLogger().log(INFO, "Session is null on updateSessionNonTerminating");
286-
}
287-
});
288-
});
293+
}));
294+
}
295+
296+
/**
297+
* This function is mostly called from Flutter where capturing envelope is run in a background
298+
* thread.
299+
*
300+
* <p>Nonetheless we should run this in the same executor service as the deleteCurrentSessionFile
301+
* function for consistency.
302+
*/
303+
private static void schedulePersistSession(
304+
final @NotNull SentryOptions options, final @NotNull Session session) {
305+
if (!(options.getEnvelopeDiskCache() instanceof EnvelopeCache)) {
306+
return;
307+
}
308+
final @NotNull EnvelopeCache cache = (EnvelopeCache) options.getEnvelopeDiskCache();
309+
try {
310+
options.getExecutorService().submit(() -> cache.persistCurrentSession(session));
311+
} catch (RejectedExecutionException e) {
312+
options.getLogger().log(WARNING, "Submission of session persisting rejected.", e);
313+
}
289314
}
290315

291316
/** What the events inside an envelope amount to, from the session's point of view. */
@@ -320,11 +345,6 @@ private enum EnvelopeEventState {
320345
return errored ? EnvelopeEventState.ERRORED : EnvelopeEventState.NONE;
321346
}
322347

323-
/**
324-
* Reads an envelope from the given bytes. {@link io.sentry.IEnvelopeReader#read(InputStream)}
325-
* declares {@link IOException} and additionally rejects malformed payloads with an unchecked
326-
* {@link IllegalArgumentException}.
327-
*/
328348
private static @Nullable SentryEnvelope readEnvelope(
329349
final @NotNull SentryOptions options, final @NotNull byte[] envelopeData) {
330350
try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) {
@@ -427,22 +447,26 @@ private static Session updateSession(
427447
final @NotNull AtomicReference<Session> sessionRef = new AtomicReference<>();
428448
scopes.configureScope(
429449
scope -> {
430-
final @Nullable Session session = scope.getSession();
431-
if (session != null) {
432-
final boolean updated = session.update(status, null, crashedOrErrored, null);
433-
// if we have an uncaughtExceptionHint we can end the session.
434-
if (updated) {
435-
if (session.getStatus() == Session.State.Crashed) {
436-
session.end();
437-
// Session needs to be removed from the scope, otherwise it will be send twice
438-
// standalone and with the crash event
439-
scope.clearSession();
440-
}
441-
sessionRef.set(session);
442-
}
443-
} else {
444-
options.getLogger().log(INFO, "Session is null on updateSession");
445-
}
450+
scope.withSession(
451+
session -> {
452+
if (session != null) {
453+
final boolean updated = session.update(status, null, crashedOrErrored, null);
454+
// if we have an uncaughtExceptionHint we can end the session.
455+
if (updated) {
456+
if (session.getStatus() == Session.State.Crashed) {
457+
session.end();
458+
// Session needs to be removed from the scope, otherwise it will be send twice
459+
// standalone and with the crash event
460+
scope.clearSession();
461+
}
462+
// fromSession serializes lazily, on the transport thread, so handing out the
463+
// live session would race a later mutation
464+
sessionRef.set(session.clone());
465+
}
466+
} else {
467+
options.getLogger().log(INFO, "Session is null on updateSession");
468+
}
469+
});
446470
});
447471
return sessionRef.get();
448472
}

‎sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import io.sentry.protocol.Contexts
3030
import io.sentry.protocol.Mechanism
3131
import io.sentry.protocol.SentryId
3232
import io.sentry.protocol.User
33+
import io.sentry.test.ImmediateExecutorService
3334
import io.sentry.test.createTestScopes
3435
import io.sentry.transport.ITransport
3536
import io.sentry.transport.RateLimiter
@@ -61,6 +62,8 @@ class InternalSentrySdkTest {
6162
initForTest(context) { options ->
6263
this@Fixture.options = options
6364
options.dsn = "https://key@host/proj"
65+
// the non-terminating path persists the session off the calling thread
66+
options.executorService = ImmediateExecutorService()
6467
options.setTransportFactory { _, _ ->
6568
object : ITransport {
6669
override fun close(isRestarting: Boolean) {
@@ -136,6 +139,17 @@ class InternalSentrySdkTest {
136139
}
137140
}
138141

142+
fun createSentryEventWithHandledException(): SentryEvent {
143+
return SentryEvent(RuntimeException()).apply {
144+
val mechanism = Mechanism()
145+
mechanism.isHandled = true
146+
147+
val factory = SentryExceptionFactory(mock())
148+
exceptions =
149+
factory.getSentryExceptions(ExceptionMechanismException(mechanism, Throwable(), Thread()))
150+
}
151+
}
152+
139153
fun mockFinishedAppStart() {
140154
val metrics = AppStartMetrics.getInstance()
141155

@@ -504,6 +518,33 @@ class InternalSentrySdkTest {
504518
assertThat(persistedSession.sessionId).isEqualTo(originalSid.get())
505519
}
506520

521+
@Test
522+
fun `captureEnvelopeNonTerminating does not record onto an already terminated session`() {
523+
val fixture = Fixture()
524+
fixture.init(context)
525+
526+
// given a session already finalized on the scope, as an ANR leaves it
527+
val terminatedSession = AtomicReference<Session>()
528+
Sentry.configureScope { scope ->
529+
scope.withSession { session ->
530+
session!!.update(Session.State.Abnormal, null, false, "anr_foreground")
531+
session.end()
532+
}
533+
terminatedSession.set(scope.session)
534+
}
535+
val errorCountBefore = terminatedSession.get().errorCount()
536+
val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!)
537+
sessionFile.delete()
538+
539+
// when a handled error arrives through the non-terminating API
540+
fixture.captureEnvelopeNonTerminatingWithEvent(fixture.createSentryEventWithHandledException())
541+
542+
// then the finalized session is left alone and never written back to the session file
543+
assertThat(terminatedSession.get().status).isEqualTo(Session.State.Abnormal)
544+
assertThat(terminatedSession.get().errorCount()).isEqualTo(errorCountBefore)
545+
assertThat(sessionFile.exists()).isFalse()
546+
}
547+
507548
@Test
508549
fun `captureEnvelopeNonTerminating then endSession finalizes the session as unhandled`() {
509550
val fixture = Fixture()

‎sentry/src/main/java/io/sentry/Session.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ public boolean update(
307307
final boolean addErrorsCount,
308308
final @Nullable String abnormalMechanism) {
309309
try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) {
310+
// TODO(buenaflor): should we reject updates if we are already in a terminal status?
310311
boolean sessionHasBeenUpdated = false;
311312
if (status != null) {
312313
this.status = status;

0 commit comments

Comments
 (0)