From d9def2ef666cd7f69c0c14644863a3f01008be94 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:25:04 +0200 Subject: [PATCH 01/12] fix(core): Make session cache writes session-id aware SessionEnd previously deleted session.json unconditionally and SessionStart always rotated it. A delayed end or start could therefore drop a newer session snapshot. Both paths now compare session ids and start times before deleting or rotating, and a new persistCurrentSession lets callers flush the active session to disk. Co-authored-by: Cursor --- sentry/api/sentry.api | 1 + .../java/io/sentry/cache/EnvelopeCache.java | 80 +++++- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 268 +++++++++++++++++- 3 files changed, 337 insertions(+), 12 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index b01ffbd0ed..cbe6081241 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4861,6 +4861,7 @@ public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache { public static fun getPreviousSessionFile (Ljava/lang/String;)Ljava/io/File; public fun iterator ()Ljava/util/Iterator; public fun movePreviousSession (Ljava/io/File;Ljava/io/File;)V + public fun persistCurrentSession (Lio/sentry/Session;)V public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V public fun storeEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Z public fun waitPreviousSessionFlush ()Z diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 618de65547..62cb9d70dc 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -106,6 +106,7 @@ public boolean storeEnvelope(final @NotNull SentryEnvelope envelope, final @NotN return storeInternal(envelope, hint); } + @SuppressWarnings("JavaUtilDate") private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) { Objects.requireNonNull(envelope, "Envelope is required."); @@ -118,8 +119,22 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not final File previousSessionFile = getPreviousSessionFile(directoryPath); if (HintUtils.hasType(hint, SessionEnd.class)) { - if (!currentSessionFile.delete()) { - options.getLogger().log(WARNING, "Current envelope doesn't exist."); + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + final @Nullable Session endingSession = readSessionFromEnvelope(envelope); + final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); + final boolean preservePendingSession = + endingSession != null + && currentSession != null + && currentSession.isPendingUnhandled() + && endingSession.getSessionId() != null + && currentSession.getSessionId() != null + && !Objects.equals(endingSession.getSessionId(), currentSession.getSessionId()) + && endingSession.getStarted() != null + && currentSession.getStarted() != null + && currentSession.getStarted().after(endingSession.getStarted()); + if (!preservePendingSession && !currentSessionFile.delete()) { + options.getLogger().log(WARNING, "Current envelope doesn't exist."); + } } } @@ -129,8 +144,22 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not } if (HintUtils.hasType(hint, SessionStart.class)) { - movePreviousSession(currentSessionFile, previousSessionFile); - updateCurrentSession(currentSessionFile, envelope); + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + final @Nullable Session startingSession = readSessionFromEnvelope(envelope); + if (startingSession != null) { + final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); + if (currentSession != null && hasSameSessionId(currentSession, startingSession)) { + if (!isNewerPendingOrErrorSnapshot(currentSession, startingSession)) { + writeSessionToDisk(currentSessionFile, startingSession); + } + } else { + movePreviousSession(currentSessionFile, previousSessionFile); + writeSessionToDisk(currentSessionFile, startingSession); + } + } else { + movePreviousSession(currentSessionFile, previousSessionFile); + } + } boolean crashedLastRun = false; final File crashMarkerFile = new File(options.getCacheDirPath(), NATIVE_CRASH_MARKER_FILE); @@ -274,8 +303,7 @@ private void writeCrashMarkerFile() { } } - private void updateCurrentSession( - final @NotNull File currentSessionFile, final @NotNull SentryEnvelope envelope) { + private @Nullable Session readSessionFromEnvelope(final @NotNull SentryEnvelope envelope) { final Iterable items = envelope.getItems(); // we know that an envelope with a SessionStart hint has a single item inside @@ -295,7 +323,7 @@ private void updateCurrentSession( "Item of type %s returned null by the parser.", item.getHeader().getType()); } else { - writeSessionToDisk(currentSessionFile, session); + return session; } } catch (Throwable e) { options.getLogger().log(ERROR, "Item failed to process.", e); @@ -309,10 +337,35 @@ private void updateCurrentSession( item.getHeader().getType()); } } else { - options - .getLogger() - .log(INFO, "Current envelope %s is empty", currentSessionFile.getAbsolutePath()); + options.getLogger().log(INFO, "Current envelope is empty."); } + return null; + } + + private @Nullable Session readSessionFromDisk(final @NotNull File sessionFile) { + if (!sessionFile.exists()) { + return null; + } + try (final Reader reader = + new BufferedReader(new InputStreamReader(new FileInputStream(sessionFile), UTF_8))) { + return serializer.getValue().deserialize(reader, Session.class); + } catch (Exception e) { + options.getLogger().log(ERROR, "Failed to read session from disk.", e); + return null; + } + } + + private boolean isNewerPendingOrErrorSnapshot( + final @NotNull Session currentSession, final @NotNull Session startingSession) { + return (currentSession.isPendingUnhandled() && !startingSession.isPendingUnhandled()) + || currentSession.errorCount() > startingSession.errorCount(); + } + + private boolean hasSameSessionId( + final @NotNull Session firstSession, final @NotNull Session secondSession) { + return firstSession.getSessionId() != null + && secondSession.getSessionId() != null + && Objects.equals(firstSession.getSessionId(), secondSession.getSessionId()); } private boolean writeEnvelopeToDisk( @@ -352,6 +405,13 @@ private void writeSessionToDisk(final @NotNull File file, final @NotNull Session } } + @ApiStatus.Internal + public void persistCurrentSession(final @NotNull Session session) { + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + writeSessionToDisk(getCurrentSessionFile(directory.getOrCreate().getAbsolutePath()), session); + } + } + @Override public void discard(final @NotNull SentryEnvelope envelope) { Objects.requireNonNull(envelope, "Envelope is required."); diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index dda06ee7e6..d2ab0c1a7b 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -1,5 +1,6 @@ package io.sentry.cache +import com.google.common.truth.Truth.assertThat import io.sentry.DateUtils import io.sentry.Hint import io.sentry.ILogger @@ -160,6 +161,213 @@ class EnvelopeCacheTest { assertTrue(didStore) } + @Test + fun `delayed same SID SessionStart preserves newer pending snapshot`() { + val cache = fixture.getSUT() + val sid = SentryUUID.generateSentryId() + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val newerSession = createSession(sessionId = sid) + newerSession.markPendingUnhandled() + cache.persistCurrentSession(newerSession) + + val delayedStart = createSession(sessionId = sid) + val envelope = SentryEnvelope.from(fixture.options.serializer, delayedStart, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint())) + + val persistedSession = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedSession.sessionId).isEqualTo(sid) + assertThat(persistedSession.isPendingUnhandled).isTrue() + assertThat(persistedSession.errorCount()).isEqualTo(1) + assertThat(previousSessionFile.exists()).isFalse() + } + + @Test + fun `delayed same SID SessionStart preserves newer error count snapshot`() { + val cache = fixture.getSUT() + val sid = SentryUUID.generateSentryId() + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val newerSession = createSession(sessionId = sid) + newerSession.update(null, null, true) + cache.persistCurrentSession(newerSession) + + val delayedStart = createSession(sessionId = sid) + val envelope = SentryEnvelope.from(fixture.options.serializer, delayedStart, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint())) + + val persistedSession = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedSession.sessionId).isEqualTo(sid) + assertThat(persistedSession.isPendingUnhandled).isFalse() + assertThat(persistedSession.errorCount()).isEqualTo(1) + assertThat(previousSessionFile.exists()).isFalse() + } + + @Test + fun `null SIDs on SessionStart rotate instead of preserving as same session`() { + val cache = fixture.getSUT() + val currentSession = createSession(sessionId = null) + currentSession.update(null, null, true) + cache.persistCurrentSession(currentSession) + val startingSession = createSession(sessionId = null) + + val envelope = SentryEnvelope.from(fixture.options.serializer, startingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint())) + + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val persistedCurrent = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + val persistedPrevious = + fixture.options.serializer.deserialize( + previousSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedCurrent.sessionId).isNull() + assertThat(persistedCurrent.errorCount()).isEqualTo(0) + assertThat(persistedPrevious.sessionId).isNull() + assertThat(persistedPrevious.errorCount()).isEqualTo(1) + } + + @Test + fun `different SID SessionStart rotates current session`() { + val cache = fixture.getSUT() + val currentSession = createSession() + cache.persistCurrentSession(currentSession) + val nextSession = createSession() + + val envelope = SentryEnvelope.from(fixture.options.serializer, nextSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint())) + + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val persistedCurrent = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + val persistedPrevious = + fixture.options.serializer.deserialize( + previousSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedCurrent.sessionId).isEqualTo(nextSession.sessionId) + assertThat(persistedPrevious.sessionId).isEqualTo(currentSession.sessionId) + } + + @Test + fun `matching SessionEnd deletes current session`() { + val cache = fixture.getSUT() + val session = createSession() + cache.persistCurrentSession(session) + + val envelope = SentryEnvelope.from(fixture.options.serializer, session, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + + @Test + fun `mismatching SessionEnd preserves newer pending current session`() { + val cache = fixture.getSUT() + val endingSession = createSession(started = Date(1_000)) + val currentSession = createSession(started = Date(2_000)) + currentSession.markPendingUnhandled() + cache.persistCurrentSession(currentSession) + + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val persistedSession = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedSession.sessionId).isEqualTo(currentSession.sessionId) + } + + @Test + fun `mismatching newer SessionEnd deletes stale pending current session`() { + val cache = fixture.getSUT() + val currentSession = createSession(started = Date(1_000)) + currentSession.markPendingUnhandled() + cache.persistCurrentSession(currentSession) + val endingSession = createSession(started = Date(2_000)) + + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + + @Test + fun `mismatching SessionEnd deletes non-pending current session`() { + val cache = fixture.getSUT() + val currentSession = createSession() + cache.persistCurrentSession(currentSession) + val endingSession = createSession() + + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + + @Test + fun `null SIDs on SessionEnd delete current session`() { + val cache = fixture.getSUT() + val currentSession = createSession(sessionId = null) + cache.persistCurrentSession(currentSession) + val endingSession = createSession(sessionId = null) + + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + + @Test + fun `malformed SessionEnd deletes current session`() { + val cache = fixture.getSUT() + val currentSession = createSession() + cache.persistCurrentSession(currentSession) + + val envelope = SentryEnvelope(null, null, emptyList()) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) + .isFalse() + } + + @Test + fun `unreadable current session on SessionEnd is deleted`() { + val cache = fixture.getSUT() + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + currentSessionFile.writeText("not-a-session") + + val endingSession = createSession() + val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) + + assertThat(currentSessionFile.exists()).isFalse() + } + @Test fun `updates current file on session update and read it back`() { val cache = fixture.getSUT() @@ -346,6 +554,36 @@ class EnvelopeCacheTest { assertEquals(sessionExitedWithAbnormal, updatedSession!!.timestamp!!.time) } + @Test + fun `AbnormalExit hint keeps persisted pending session as abnormal`() { + val cache = fixture.getSUT() + + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val session = createSession().apply { setPendingUnhandled(true) } + fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) + + val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) + val abnormalHint = + object : AbnormalExit { + override fun mechanism(): String = "abnormal_mechanism" + + override fun ignoreCurrentThread(): Boolean = false + + override fun timestamp(): Long = session.started!!.time + TimeUnit.HOURS.toMillis(1) + } + val hints = HintUtils.createWithTypeCheckHint(abnormalHint) + cache.storeEnvelope(envelope, hints) + + val updatedSession = + fixture.options.serializer.deserialize( + previousSessionFile.bufferedReader(), + Session::class.java, + ) + assertEquals(State.Abnormal, updatedSession!!.status) + assertEquals("abnormal_mechanism", updatedSession.abnormalMechanism) + assertTrue(updatedSession.isPendingUnhandled) + } + @Test fun `when AbnormalExit happened before previous session start, does not mark as abnormal`() { val cache = fixture.getSUT() @@ -400,6 +638,29 @@ class EnvelopeCacheTest { assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time) } + @Test + fun `NativeCrashExit hint keeps persisted pending session as crashed`() { + val cache = fixture.getSUT() + + val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) + val session = createSession().apply { setPendingUnhandled(true) } + fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) + + val nativeCrashTimestamp = session.started!!.time + TimeUnit.HOURS.toMillis(1) + val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) + val hints = HintUtils.createWithTypeCheckHint(NativeCrashExit { nativeCrashTimestamp }) + cache.storeEnvelope(envelope, hints) + + val updatedSession = + fixture.options.serializer.deserialize( + previousSessionFile.bufferedReader(), + Session::class.java, + ) + assertEquals(State.Crashed, updatedSession!!.status) + assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time) + assertFalse(updatedSession.isPendingUnhandled) + } + @Test fun `when NativeCrashExit happened before previous session start, does not mark as crashed`() { val cache = fixture.getSUT() @@ -438,14 +699,17 @@ class EnvelopeCacheTest { assertFalse(didStore) } - private fun createSession(started: Date? = null): Session = + private fun createSession( + started: Date? = null, + sessionId: String? = SentryUUID.generateSentryId(), + ): Session = Session( Ok, started ?: DateUtils.getCurrentDateTime(), DateUtils.getCurrentDateTime(), 0, "dis", - SentryUUID.generateSentryId(), + sessionId, true, null, null, From 17f792342a2e694af051b0a8f7ea63ca84573faf Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:38:55 +0200 Subject: [PATCH 02/12] ref: follow Session rename in EnvelopeCache Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 13 ++++---- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 30 +++++++++---------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 62cb9d70dc..3c991134e4 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -122,17 +122,17 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { final @Nullable Session endingSession = readSessionFromEnvelope(envelope); final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); - final boolean preservePendingSession = + final boolean preserveCurrentSession = endingSession != null && currentSession != null - && currentSession.isPendingUnhandled() + && currentSession.hasNonTerminatingUnhandledError() && endingSession.getSessionId() != null && currentSession.getSessionId() != null && !Objects.equals(endingSession.getSessionId(), currentSession.getSessionId()) && endingSession.getStarted() != null && currentSession.getStarted() != null && currentSession.getStarted().after(endingSession.getStarted()); - if (!preservePendingSession && !currentSessionFile.delete()) { + if (!preserveCurrentSession && !currentSessionFile.delete()) { options.getLogger().log(WARNING, "Current envelope doesn't exist."); } } @@ -149,7 +149,7 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not if (startingSession != null) { final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); if (currentSession != null && hasSameSessionId(currentSession, startingSession)) { - if (!isNewerPendingOrErrorSnapshot(currentSession, startingSession)) { + if (!isNewerUnhandledOrErrorSnapshot(currentSession, startingSession)) { writeSessionToDisk(currentSessionFile, startingSession); } } else { @@ -355,9 +355,10 @@ private void writeCrashMarkerFile() { } } - private boolean isNewerPendingOrErrorSnapshot( + private boolean isNewerUnhandledOrErrorSnapshot( final @NotNull Session currentSession, final @NotNull Session startingSession) { - return (currentSession.isPendingUnhandled() && !startingSession.isPendingUnhandled()) + return (currentSession.hasNonTerminatingUnhandledError() + && !startingSession.hasNonTerminatingUnhandledError()) || currentSession.errorCount() > startingSession.errorCount(); } diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index d2ab0c1a7b..00102fdf4b 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -162,13 +162,13 @@ class EnvelopeCacheTest { } @Test - fun `delayed same SID SessionStart preserves newer pending snapshot`() { + fun `delayed same SID SessionStart preserves newer unhandled snapshot`() { val cache = fixture.getSUT() val sid = SentryUUID.generateSentryId() val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) val newerSession = createSession(sessionId = sid) - newerSession.markPendingUnhandled() + newerSession.recordNonTerminatingUnhandledError() cache.persistCurrentSession(newerSession) val delayedStart = createSession(sessionId = sid) @@ -181,7 +181,7 @@ class EnvelopeCacheTest { Session::class.java, )!! assertThat(persistedSession.sessionId).isEqualTo(sid) - assertThat(persistedSession.isPendingUnhandled).isTrue() + assertThat(persistedSession.hasNonTerminatingUnhandledError()).isTrue() assertThat(persistedSession.errorCount()).isEqualTo(1) assertThat(previousSessionFile.exists()).isFalse() } @@ -206,7 +206,7 @@ class EnvelopeCacheTest { Session::class.java, )!! assertThat(persistedSession.sessionId).isEqualTo(sid) - assertThat(persistedSession.isPendingUnhandled).isFalse() + assertThat(persistedSession.hasNonTerminatingUnhandledError()).isFalse() assertThat(persistedSession.errorCount()).isEqualTo(1) assertThat(previousSessionFile.exists()).isFalse() } @@ -280,11 +280,11 @@ class EnvelopeCacheTest { } @Test - fun `mismatching SessionEnd preserves newer pending current session`() { + fun `mismatching SessionEnd preserves newer unhandled current session`() { val cache = fixture.getSUT() val endingSession = createSession(started = Date(1_000)) val currentSession = createSession(started = Date(2_000)) - currentSession.markPendingUnhandled() + currentSession.recordNonTerminatingUnhandledError() cache.persistCurrentSession(currentSession) val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) @@ -300,10 +300,10 @@ class EnvelopeCacheTest { } @Test - fun `mismatching newer SessionEnd deletes stale pending current session`() { + fun `mismatching newer SessionEnd deletes stale unhandled current session`() { val cache = fixture.getSUT() val currentSession = createSession(started = Date(1_000)) - currentSession.markPendingUnhandled() + currentSession.recordNonTerminatingUnhandledError() cache.persistCurrentSession(currentSession) val endingSession = createSession(started = Date(2_000)) @@ -315,7 +315,7 @@ class EnvelopeCacheTest { } @Test - fun `mismatching SessionEnd deletes non-pending current session`() { + fun `mismatching SessionEnd deletes current session without unhandled error`() { val cache = fixture.getSUT() val currentSession = createSession() cache.persistCurrentSession(currentSession) @@ -555,11 +555,11 @@ class EnvelopeCacheTest { } @Test - fun `AbnormalExit hint keeps persisted pending session as abnormal`() { + fun `AbnormalExit hint keeps persisted unhandled session as abnormal`() { val cache = fixture.getSUT() val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) - val session = createSession().apply { setPendingUnhandled(true) } + val session = createSession().apply { setNonTerminatingUnhandledError(true) } fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) @@ -581,7 +581,7 @@ class EnvelopeCacheTest { ) assertEquals(State.Abnormal, updatedSession!!.status) assertEquals("abnormal_mechanism", updatedSession.abnormalMechanism) - assertTrue(updatedSession.isPendingUnhandled) + assertTrue(updatedSession.hasNonTerminatingUnhandledError()) } @Test @@ -639,11 +639,11 @@ class EnvelopeCacheTest { } @Test - fun `NativeCrashExit hint keeps persisted pending session as crashed`() { + fun `NativeCrashExit hint keeps persisted unhandled session as crashed`() { val cache = fixture.getSUT() val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) - val session = createSession().apply { setPendingUnhandled(true) } + val session = createSession().apply { setNonTerminatingUnhandledError(true) } fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) val nativeCrashTimestamp = session.started!!.time + TimeUnit.HOURS.toMillis(1) @@ -658,7 +658,7 @@ class EnvelopeCacheTest { ) assertEquals(State.Crashed, updatedSession!!.status) assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time) - assertFalse(updatedSession.isPendingUnhandled) + assertFalse(updatedSession.hasNonTerminatingUnhandledError()) } @Test From 153074cd71a0fc0d191030aa951c44995c50a4c0 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 12:01:37 +0200 Subject: [PATCH 03/12] ref: follow the setter removal in EnvelopeCacheTest Co-authored-by: Cursor --- sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 00102fdf4b..01ad146313 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -559,7 +559,7 @@ class EnvelopeCacheTest { val cache = fixture.getSUT() val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) - val session = createSession().apply { setNonTerminatingUnhandledError(true) } + val session = createSession().apply { recordNonTerminatingUnhandledError() } fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) @@ -643,7 +643,7 @@ class EnvelopeCacheTest { val cache = fixture.getSUT() val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) - val session = createSession().apply { setNonTerminatingUnhandledError(true) } + val session = createSession().apply { recordNonTerminatingUnhandledError() } fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) val nativeCrashTimestamp = session.started!!.time + TimeUnit.HOURS.toMillis(1) From c120b31f8dc4ef834494bc3024e22dfb32ff052a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 11:55:56 +0200 Subject: [PATCH 04/12] docs: explain why stale session envelopes must not clobber newer state Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/cache/EnvelopeCache.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 3c991134e4..9d2ed03561 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -119,6 +119,10 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not final File previousSessionFile = getPreviousSessionFile(directoryPath); if (HintUtils.hasType(hint, SessionEnd.class)) { + // A SessionEnd normally clears session.json. Its envelope may have been queued while a + // newer session replaced it on disk though, and deleting would then drop that session's + // unhandled error before it can be finalized. Only keep the file when the stored session + // is provably a different, later one carrying the flag. try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { final @Nullable Session endingSession = readSessionFromEnvelope(envelope); final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); @@ -149,6 +153,8 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not if (startingSession != null) { final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); if (currentSession != null && hasSameSessionId(currentSession, startingSession)) { + // A start for the id already on disk is a late duplicate, and that stored snapshot + // may have advanced since it was written, so overwrite only if it has not. if (!isNewerUnhandledOrErrorSnapshot(currentSession, startingSession)) { writeSessionToDisk(currentSessionFile, startingSession); } From d5fec2426d265a11ef92205f8d0f3dbda9b3a74a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 12:00:00 +0200 Subject: [PATCH 05/12] ref(session): make the two stale-envelope checks read the same way Both session paths in EnvelopeCache answer the same question - is this envelope stale relative to what is already on disk - but the end path inlined eight clauses and phrased it as "preserve", while the start path hid it behind a helper and negated it. Name both isStaleSessionEnd and isStaleSessionStart so the shared idea is visible, and move the "why" onto those helpers. Also narrows the JavaUtilDate suppression to the comparison itself and fixes a comment that still claimed the item reader only served starts. Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 62 ++++++++++++------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 9d2ed03561..0ede8f8795 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -106,7 +106,6 @@ public boolean storeEnvelope(final @NotNull SentryEnvelope envelope, final @NotN return storeInternal(envelope, hint); } - @SuppressWarnings("JavaUtilDate") private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) { Objects.requireNonNull(envelope, "Envelope is required."); @@ -119,24 +118,10 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not final File previousSessionFile = getPreviousSessionFile(directoryPath); if (HintUtils.hasType(hint, SessionEnd.class)) { - // A SessionEnd normally clears session.json. Its envelope may have been queued while a - // newer session replaced it on disk though, and deleting would then drop that session's - // unhandled error before it can be finalized. Only keep the file when the stored session - // is provably a different, later one carrying the flag. try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { final @Nullable Session endingSession = readSessionFromEnvelope(envelope); final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); - final boolean preserveCurrentSession = - endingSession != null - && currentSession != null - && currentSession.hasNonTerminatingUnhandledError() - && endingSession.getSessionId() != null - && currentSession.getSessionId() != null - && !Objects.equals(endingSession.getSessionId(), currentSession.getSessionId()) - && endingSession.getStarted() != null - && currentSession.getStarted() != null - && currentSession.getStarted().after(endingSession.getStarted()); - if (!preserveCurrentSession && !currentSessionFile.delete()) { + if (!isStaleSessionEnd(endingSession, currentSession) && !currentSessionFile.delete()) { options.getLogger().log(WARNING, "Current envelope doesn't exist."); } } @@ -153,9 +138,7 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not if (startingSession != null) { final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); if (currentSession != null && hasSameSessionId(currentSession, startingSession)) { - // A start for the id already on disk is a late duplicate, and that stored snapshot - // may have advanced since it was written, so overwrite only if it has not. - if (!isNewerUnhandledOrErrorSnapshot(currentSession, startingSession)) { + if (!isStaleSessionStart(startingSession, currentSession)) { writeSessionToDisk(currentSessionFile, startingSession); } } else { @@ -312,7 +295,7 @@ private void writeCrashMarkerFile() { private @Nullable Session readSessionFromEnvelope(final @NotNull SentryEnvelope envelope) { final Iterable items = envelope.getItems(); - // we know that an envelope with a SessionStart hint has a single item inside + // we know that a session envelope has a single item inside if (items.iterator().hasNext()) { final SentryEnvelopeItem item = items.iterator().next(); @@ -331,7 +314,7 @@ private void writeCrashMarkerFile() { } else { return session; } - } catch (Throwable e) { + } catch (Exception e) { options.getLogger().log(ERROR, "Item failed to process.", e); } } else { @@ -361,8 +344,27 @@ private void writeCrashMarkerFile() { } } - private boolean isNewerUnhandledOrErrorSnapshot( - final @NotNull Session currentSession, final @NotNull Session startingSession) { + /** + * Whether a {@link SessionEnd} envelope was queued while a newer session replaced it on disk. + * Deleting the file would then drop the newer session's unhandled error before it can be + * finalized, so it is kept instead. + */ + private boolean isStaleSessionEnd( + final @Nullable Session endingSession, final @Nullable Session currentSession) { + return endingSession != null + && currentSession != null + && currentSession.hasNonTerminatingUnhandledError() + && hasDifferentSessionId(endingSession, currentSession) + && startedLaterThan(currentSession, endingSession); + } + + /** + * Whether a {@link SessionStart} envelope is a late duplicate of the session already on disk, + * whose snapshot has since advanced. Writing it would roll back the recorded unhandled error or + * error count. Only meaningful for two snapshots of the same session. + */ + private boolean isStaleSessionStart( + final @NotNull Session startingSession, final @NotNull Session currentSession) { return (currentSession.hasNonTerminatingUnhandledError() && !startingSession.hasNonTerminatingUnhandledError()) || currentSession.errorCount() > startingSession.errorCount(); @@ -375,6 +377,20 @@ private boolean hasSameSessionId( && Objects.equals(firstSession.getSessionId(), secondSession.getSessionId()); } + private boolean hasDifferentSessionId( + final @NotNull Session firstSession, final @NotNull Session secondSession) { + return firstSession.getSessionId() != null + && secondSession.getSessionId() != null + && !Objects.equals(firstSession.getSessionId(), secondSession.getSessionId()); + } + + @SuppressWarnings("JavaUtilDate") + private boolean startedLaterThan(final @NotNull Session session, final @NotNull Session other) { + return session.getStarted() != null + && other.getStarted() != null + && session.getStarted().after(other.getStarted()); + } + private boolean writeEnvelopeToDisk( final @NotNull File file, final @NotNull SentryEnvelope envelope) { if (file.exists()) { From f4dfc808db13565eab9e74ce8076dd94cdc8816d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 12 Aug 2026 08:56:03 +0200 Subject: [PATCH 06/12] ref(cache): replace the stale-start comparison with an identity check session.json has only two writers, the SessionStart path and persistCurrentSession. So if a start envelope finds its own session id already on disk, persistCurrentSession put it there for the live session, and that copy is necessarily at least as advanced. There is nothing to measure: comparing the unhandled flag and error count answered a question that only ever has one answer. The start path collapses to "if this envelope is about a different session than the one on disk, behave as before; otherwise leave it alone", which also avoids rotating a running session into previous_session.json. Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 0ede8f8795..c9f663670a 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -135,18 +135,12 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not if (HintUtils.hasType(hint, SessionStart.class)) { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { final @Nullable Session startingSession = readSessionFromEnvelope(envelope); - if (startingSession != null) { - final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); - if (currentSession != null && hasSameSessionId(currentSession, startingSession)) { - if (!isStaleSessionStart(startingSession, currentSession)) { - writeSessionToDisk(currentSessionFile, startingSession); - } - } else { - movePreviousSession(currentSessionFile, previousSessionFile); + final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); + if (!isLateDuplicateStart(startingSession, currentSession)) { + movePreviousSession(currentSessionFile, previousSessionFile); + if (startingSession != null) { writeSessionToDisk(currentSessionFile, startingSession); } - } else { - movePreviousSession(currentSessionFile, previousSessionFile); } } @@ -359,15 +353,16 @@ && hasDifferentSessionId(endingSession, currentSession) } /** - * Whether a {@link SessionStart} envelope is a late duplicate of the session already on disk, - * whose snapshot has since advanced. Writing it would roll back the recorded unhandled error or - * error count. Only meaningful for two snapshots of the same session. + * Whether a {@link SessionStart} envelope refers to the session already on disk. Only {@link + * #persistCurrentSession(Session)} can have put it there, writing the live session, so the stored + * copy is at least as advanced as this envelope. Rotating and overwriting it would file a running + * session as the previous one and roll back any unhandled error it has recorded since. */ - private boolean isStaleSessionStart( - final @NotNull Session startingSession, final @NotNull Session currentSession) { - return (currentSession.hasNonTerminatingUnhandledError() - && !startingSession.hasNonTerminatingUnhandledError()) - || currentSession.errorCount() > startingSession.errorCount(); + private boolean isLateDuplicateStart( + final @Nullable Session startingSession, final @Nullable Session currentSession) { + return startingSession != null + && currentSession != null + && hasSameSessionId(currentSession, startingSession); } private boolean hasSameSessionId( From 688a43f39f080f1cb73be76dc848ae4981cbba32 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 12 Aug 2026 10:04:04 +0200 Subject: [PATCH 07/12] ref(cache): restore catch (Throwable) in the envelope session reader Narrowing this pre-existing catch was incidental to the feature and the only thing in this PR that alters existing behaviour: an Error while parsing the session item used to be swallowed so the store continued and the envelope still reached disk, whereas propagating it abandons the store partway. It was also inconsistent, converting one of six catch (Throwable) blocks in this file simply because the edit landed next to it. The new readSessionFromDisk keeps catch (Exception), so new code still refuses to swallow fatal errors. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/cache/EnvelopeCache.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index c9f663670a..7eddb34949 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -308,7 +308,7 @@ private void writeCrashMarkerFile() { } else { return session; } - } catch (Exception e) { + } catch (Throwable e) { options.getLogger().log(ERROR, "Item failed to process.", e); } } else { From 0a2dda4e15ef6d4f833a99edae9564566196995a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 12 Aug 2026 10:10:45 +0200 Subject: [PATCH 08/12] ref(cache): delete the current session file unconditionally again The SessionEnd guard only paid off in a narrow window: a SessionEnd still queued while a newer session had already started and recorded an unhandled error. Dropping it degrades to the behaviour on main, because the queued SessionStart rewrites the file moments later, just without the flag. That cost a session.json read and deserialize on every SessionEnd for every SDK. The SessionStart guard stays. Its window is far wider, since app start is when the transport is busiest flushing the previous run's cache, and its failure mode is worse than a lost flag: movePreviousSession files the running session as the previous one. Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 38 +------ .../java/io/sentry/cache/EnvelopeCacheTest.kt | 102 ------------------ 2 files changed, 3 insertions(+), 137 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 7eddb34949..a229a86ce4 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -118,12 +118,8 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not final File previousSessionFile = getPreviousSessionFile(directoryPath); if (HintUtils.hasType(hint, SessionEnd.class)) { - try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { - final @Nullable Session endingSession = readSessionFromEnvelope(envelope); - final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); - if (!isStaleSessionEnd(endingSession, currentSession) && !currentSessionFile.delete()) { - options.getLogger().log(WARNING, "Current envelope doesn't exist."); - } + if (!currentSessionFile.delete()) { + options.getLogger().log(WARNING, "Current envelope doesn't exist."); } } @@ -289,7 +285,7 @@ private void writeCrashMarkerFile() { private @Nullable Session readSessionFromEnvelope(final @NotNull SentryEnvelope envelope) { final Iterable items = envelope.getItems(); - // we know that a session envelope has a single item inside + // we know that an envelope with a SessionStart hint has a single item inside if (items.iterator().hasNext()) { final SentryEnvelopeItem item = items.iterator().next(); @@ -338,20 +334,6 @@ private void writeCrashMarkerFile() { } } - /** - * Whether a {@link SessionEnd} envelope was queued while a newer session replaced it on disk. - * Deleting the file would then drop the newer session's unhandled error before it can be - * finalized, so it is kept instead. - */ - private boolean isStaleSessionEnd( - final @Nullable Session endingSession, final @Nullable Session currentSession) { - return endingSession != null - && currentSession != null - && currentSession.hasNonTerminatingUnhandledError() - && hasDifferentSessionId(endingSession, currentSession) - && startedLaterThan(currentSession, endingSession); - } - /** * Whether a {@link SessionStart} envelope refers to the session already on disk. Only {@link * #persistCurrentSession(Session)} can have put it there, writing the live session, so the stored @@ -372,20 +354,6 @@ private boolean hasSameSessionId( && Objects.equals(firstSession.getSessionId(), secondSession.getSessionId()); } - private boolean hasDifferentSessionId( - final @NotNull Session firstSession, final @NotNull Session secondSession) { - return firstSession.getSessionId() != null - && secondSession.getSessionId() != null - && !Objects.equals(firstSession.getSessionId(), secondSession.getSessionId()); - } - - @SuppressWarnings("JavaUtilDate") - private boolean startedLaterThan(final @NotNull Session session, final @NotNull Session other) { - return session.getStarted() != null - && other.getStarted() != null - && session.getStarted().after(other.getStarted()); - } - private boolean writeEnvelopeToDisk( final @NotNull File file, final @NotNull SentryEnvelope envelope) { if (file.exists()) { diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 01ad146313..69a51e2309 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -266,108 +266,6 @@ class EnvelopeCacheTest { assertThat(persistedPrevious.sessionId).isEqualTo(currentSession.sessionId) } - @Test - fun `matching SessionEnd deletes current session`() { - val cache = fixture.getSUT() - val session = createSession() - cache.persistCurrentSession(session) - - val envelope = SentryEnvelope.from(fixture.options.serializer, session, null) - cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) - - assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) - .isFalse() - } - - @Test - fun `mismatching SessionEnd preserves newer unhandled current session`() { - val cache = fixture.getSUT() - val endingSession = createSession(started = Date(1_000)) - val currentSession = createSession(started = Date(2_000)) - currentSession.recordNonTerminatingUnhandledError() - cache.persistCurrentSession(currentSession) - - val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) - cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) - - val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) - val persistedSession = - fixture.options.serializer.deserialize( - currentSessionFile.bufferedReader(), - Session::class.java, - )!! - assertThat(persistedSession.sessionId).isEqualTo(currentSession.sessionId) - } - - @Test - fun `mismatching newer SessionEnd deletes stale unhandled current session`() { - val cache = fixture.getSUT() - val currentSession = createSession(started = Date(1_000)) - currentSession.recordNonTerminatingUnhandledError() - cache.persistCurrentSession(currentSession) - val endingSession = createSession(started = Date(2_000)) - - val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) - cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) - - assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) - .isFalse() - } - - @Test - fun `mismatching SessionEnd deletes current session without unhandled error`() { - val cache = fixture.getSUT() - val currentSession = createSession() - cache.persistCurrentSession(currentSession) - val endingSession = createSession() - - val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) - cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) - - assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) - .isFalse() - } - - @Test - fun `null SIDs on SessionEnd delete current session`() { - val cache = fixture.getSUT() - val currentSession = createSession(sessionId = null) - cache.persistCurrentSession(currentSession) - val endingSession = createSession(sessionId = null) - - val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) - cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) - - assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) - .isFalse() - } - - @Test - fun `malformed SessionEnd deletes current session`() { - val cache = fixture.getSUT() - val currentSession = createSession() - cache.persistCurrentSession(currentSession) - - val envelope = SentryEnvelope(null, null, emptyList()) - cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) - - assertThat(EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!).exists()) - .isFalse() - } - - @Test - fun `unreadable current session on SessionEnd is deleted`() { - val cache = fixture.getSUT() - val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) - currentSessionFile.writeText("not-a-session") - - val endingSession = createSession() - val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) - cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) - - assertThat(currentSessionFile.exists()).isFalse() - } - @Test fun `updates current file on session update and read it back`() { val cache = fixture.getSUT() From dbfcd3ffc5d59e196ac6b86f5b5f7911e229754f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 13:59:56 +0200 Subject: [PATCH 09/12] ref(cache): fold the session-id comparison into one predicate Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 17 +++--- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 53 ------------------- 2 files changed, 7 insertions(+), 63 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index a229a86ce4..e64f3fb0e9 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -339,19 +339,16 @@ private void writeCrashMarkerFile() { * #persistCurrentSession(Session)} can have put it there, writing the live session, so the stored * copy is at least as advanced as this envelope. Rotating and overwriting it would file a running * session as the previous one and roll back any unhandled error it has recorded since. + * + *

A null session id never matches, so sessions we cannot tell apart are rotated as before. */ private boolean isLateDuplicateStart( final @Nullable Session startingSession, final @Nullable Session currentSession) { - return startingSession != null - && currentSession != null - && hasSameSessionId(currentSession, startingSession); - } - - private boolean hasSameSessionId( - final @NotNull Session firstSession, final @NotNull Session secondSession) { - return firstSession.getSessionId() != null - && secondSession.getSessionId() != null - && Objects.equals(firstSession.getSessionId(), secondSession.getSessionId()); + if (startingSession == null || currentSession == null) { + return false; + } + final @Nullable String startingSessionId = startingSession.getSessionId(); + return startingSessionId != null && startingSessionId.equals(currentSession.getSessionId()); } private boolean writeEnvelopeToDisk( diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 69a51e2309..8450f1651a 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -452,36 +452,6 @@ class EnvelopeCacheTest { assertEquals(sessionExitedWithAbnormal, updatedSession!!.timestamp!!.time) } - @Test - fun `AbnormalExit hint keeps persisted unhandled session as abnormal`() { - val cache = fixture.getSUT() - - val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) - val session = createSession().apply { recordNonTerminatingUnhandledError() } - fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) - - val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) - val abnormalHint = - object : AbnormalExit { - override fun mechanism(): String = "abnormal_mechanism" - - override fun ignoreCurrentThread(): Boolean = false - - override fun timestamp(): Long = session.started!!.time + TimeUnit.HOURS.toMillis(1) - } - val hints = HintUtils.createWithTypeCheckHint(abnormalHint) - cache.storeEnvelope(envelope, hints) - - val updatedSession = - fixture.options.serializer.deserialize( - previousSessionFile.bufferedReader(), - Session::class.java, - ) - assertEquals(State.Abnormal, updatedSession!!.status) - assertEquals("abnormal_mechanism", updatedSession.abnormalMechanism) - assertTrue(updatedSession.hasNonTerminatingUnhandledError()) - } - @Test fun `when AbnormalExit happened before previous session start, does not mark as abnormal`() { val cache = fixture.getSUT() @@ -536,29 +506,6 @@ class EnvelopeCacheTest { assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time) } - @Test - fun `NativeCrashExit hint keeps persisted unhandled session as crashed`() { - val cache = fixture.getSUT() - - val previousSessionFile = EnvelopeCache.getPreviousSessionFile(fixture.options.cacheDirPath!!) - val session = createSession().apply { recordNonTerminatingUnhandledError() } - fixture.options.serializer.serialize(session, previousSessionFile.bufferedWriter()) - - val nativeCrashTimestamp = session.started!!.time + TimeUnit.HOURS.toMillis(1) - val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) - val hints = HintUtils.createWithTypeCheckHint(NativeCrashExit { nativeCrashTimestamp }) - cache.storeEnvelope(envelope, hints) - - val updatedSession = - fixture.options.serializer.deserialize( - previousSessionFile.bufferedReader(), - Session::class.java, - ) - assertEquals(State.Crashed, updatedSession!!.status) - assertEquals(nativeCrashTimestamp, updatedSession.timestamp!!.time) - assertFalse(updatedSession.hasNonTerminatingUnhandledError()) - } - @Test fun `when NativeCrashExit happened before previous session start, does not mark as crashed`() { val cache = fixture.getSUT() From 2e8a06f7e93a1cd5f5249715eb17b40285317023 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 14:57:09 +0200 Subject: [PATCH 10/12] ref(cache): track the out-of-band session id instead of re-reading it from disk Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index e64f3fb0e9..3bc05fa2b1 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -76,6 +76,12 @@ public class EnvelopeCache extends CacheStrategy implements IEnvelopeCache { protected final @NotNull AutoClosableReentrantLock cacheLock = new AutoClosableReentrantLock(); protected final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); + /** + * Session id last written to the current session file by {@link #persistCurrentSession(Session)}, + * which bypasses the transport queue that every other write to that file goes through. + */ + private volatile @Nullable String lastPersistedSessionId; + public static @NotNull IEnvelopeCache create(final @NotNull SentryOptions options) { final String cacheDirPath = options.getCacheDirPath(); final int maxCacheItems = options.getMaxCacheItems(); @@ -131,8 +137,7 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not if (HintUtils.hasType(hint, SessionStart.class)) { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { final @Nullable Session startingSession = readSessionFromEnvelope(envelope); - final @Nullable Session currentSession = readSessionFromDisk(currentSessionFile); - if (!isLateDuplicateStart(startingSession, currentSession)) { + if (!isLateDuplicateStart(startingSession)) { movePreviousSession(currentSessionFile, previousSessionFile); if (startingSession != null) { writeSessionToDisk(currentSessionFile, startingSession); @@ -321,34 +326,21 @@ private void writeCrashMarkerFile() { return null; } - private @Nullable Session readSessionFromDisk(final @NotNull File sessionFile) { - if (!sessionFile.exists()) { - return null; - } - try (final Reader reader = - new BufferedReader(new InputStreamReader(new FileInputStream(sessionFile), UTF_8))) { - return serializer.getValue().deserialize(reader, Session.class); - } catch (Exception e) { - options.getLogger().log(ERROR, "Failed to read session from disk.", e); - return null; - } - } - /** - * Whether a {@link SessionStart} envelope refers to the session already on disk. Only {@link - * #persistCurrentSession(Session)} can have put it there, writing the live session, so the stored - * copy is at least as advanced as this envelope. Rotating and overwriting it would file a running - * session as the previous one and roll back any unhandled error it has recorded since. + * Whether a {@link SessionStart} envelope refers to the session {@link + * #persistCurrentSession(Session)} already wrote to the current session file. That copy is the + * live session, so it is at least as advanced as this envelope. Rotating and overwriting it would + * file a running session as the previous one and roll back any unhandled error it has recorded + * since. * *

A null session id never matches, so sessions we cannot tell apart are rotated as before. */ - private boolean isLateDuplicateStart( - final @Nullable Session startingSession, final @Nullable Session currentSession) { - if (startingSession == null || currentSession == null) { + private boolean isLateDuplicateStart(final @Nullable Session startingSession) { + if (startingSession == null) { return false; } final @Nullable String startingSessionId = startingSession.getSessionId(); - return startingSessionId != null && startingSessionId.equals(currentSession.getSessionId()); + return startingSessionId != null && startingSessionId.equals(lastPersistedSessionId); } private boolean writeEnvelopeToDisk( @@ -392,6 +384,7 @@ private void writeSessionToDisk(final @NotNull File file, final @NotNull Session public void persistCurrentSession(final @NotNull Session session) { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { writeSessionToDisk(getCurrentSessionFile(directory.getOrCreate().getAbsolutePath()), session); + lastPersistedSessionId = session.getSessionId(); } } From a7f837fd775cfda3c0e3f22c73e13b3d62f470c9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 15:06:02 +0200 Subject: [PATCH 11/12] ref(cache): rename isLateDuplicateStart to isAlreadyPersisted Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/cache/EnvelopeCache.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 3bc05fa2b1..1043550a9f 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -137,7 +137,7 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not if (HintUtils.hasType(hint, SessionStart.class)) { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { final @Nullable Session startingSession = readSessionFromEnvelope(envelope); - if (!isLateDuplicateStart(startingSession)) { + if (!isAlreadyPersisted(startingSession)) { movePreviousSession(currentSessionFile, previousSessionFile); if (startingSession != null) { writeSessionToDisk(currentSessionFile, startingSession); @@ -335,7 +335,7 @@ private void writeCrashMarkerFile() { * *

A null session id never matches, so sessions we cannot tell apart are rotated as before. */ - private boolean isLateDuplicateStart(final @Nullable Session startingSession) { + private boolean isAlreadyPersisted(final @Nullable Session startingSession) { if (startingSession == null) { return false; } From ae99f6c9f7b03dbcf7c0df56d027c26979ab026f Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 16:30:41 +0200 Subject: [PATCH 12/12] fix(sessions): don't let the persisted session guard swallow a SessionStart The guard skipped the SessionStart write whenever the id matched the last persisted one, even when session.json no longer held that session: - a queued SessionEnd for the prior session deletes the file the live session was just persisted to, so the skipped write left no session on disk at all - a failed persist still recorded the id, so the queued write that would have repaired the truncated file was skipped too Clear the id on SessionEnd and only record it when the write succeeded. Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 21 +++++--- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 1043550a9f..3aee522063 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -80,7 +80,7 @@ public class EnvelopeCache extends CacheStrategy implements IEnvelopeCache { * Session id last written to the current session file by {@link #persistCurrentSession(Session)}, * which bypasses the transport queue that every other write to that file goes through. */ - private volatile @Nullable String lastPersistedSessionId; + private @Nullable String lastPersistedSessionId; public static @NotNull IEnvelopeCache create(final @NotNull SentryOptions options) { final String cacheDirPath = options.getCacheDirPath(); @@ -124,8 +124,11 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not final File previousSessionFile = getPreviousSessionFile(directoryPath); if (HintUtils.hasType(hint, SessionEnd.class)) { - if (!currentSessionFile.delete()) { - options.getLogger().log(WARNING, "Current envelope doesn't exist."); + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + lastPersistedSessionId = null; + if (!currentSessionFile.delete()) { + options.getLogger().log(WARNING, "Current envelope doesn't exist."); + } } } @@ -365,7 +368,7 @@ private boolean writeEnvelopeToDisk( return true; } - private void writeSessionToDisk(final @NotNull File file, final @NotNull Session session) { + private boolean writeSessionToDisk(final @NotNull File file, final @NotNull Session session) { try (final OutputStream outputStream = new FileOutputStream(file); final Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) { options @@ -377,14 +380,20 @@ private void writeSessionToDisk(final @NotNull File file, final @NotNull Session options .getLogger() .log(ERROR, e, "Error writing Session to offline storage: %s", session.getSessionId()); + return false; } + return true; } @ApiStatus.Internal public void persistCurrentSession(final @NotNull Session session) { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { - writeSessionToDisk(getCurrentSessionFile(directory.getOrCreate().getAbsolutePath()), session); - lastPersistedSessionId = session.getSessionId(); + final boolean written = + writeSessionToDisk( + getCurrentSessionFile(directory.getOrCreate().getAbsolutePath()), session); + if (written) { + lastPersistedSessionId = session.getSessionId(); + } } } diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 69a51e2309..1fce4763fb 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -24,6 +24,7 @@ import io.sentry.hints.SessionStartHint import io.sentry.protocol.SentryId import io.sentry.util.HintUtils import java.io.File +import java.io.Writer import java.nio.file.Files import java.nio.file.Path import java.util.Date @@ -35,8 +36,10 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.same +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class EnvelopeCacheTest { @@ -266,6 +269,55 @@ class EnvelopeCacheTest { assertThat(persistedPrevious.sessionId).isEqualTo(currentSession.sessionId) } + @Test + fun `SessionEnd deleting the persisted session lets the delayed SessionStart write it again`() { + val cache = fixture.getSUT() + val sid = SentryUUID.generateSentryId() + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + cache.persistCurrentSession(createSession(sessionId = sid)) + + // the previous session's end envelope is still queued and deletes the file the live session + // was just written to + val endedSession = createSession() + cache.storeEnvelope( + SentryEnvelope.from(fixture.options.serializer, endedSession, null), + HintUtils.createWithTypeCheckHint(SessionEndHint()), + ) + assertThat(currentSessionFile.exists()).isFalse() + + val delayedStart = createSession(sessionId = sid) + cache.storeEnvelope( + SentryEnvelope.from(fixture.options.serializer, delayedStart, null), + HintUtils.createWithTypeCheckHint(SessionStartHint()), + ) + + val persistedSession = + fixture.options.serializer.deserialize( + currentSessionFile.bufferedReader(), + Session::class.java, + )!! + assertThat(persistedSession.sessionId).isEqualTo(sid) + } + + @Test + fun `failed persist lets the delayed SessionStart write the session`() { + val sid = SentryUUID.generateSentryId() + val liveSession = createSession(sessionId = sid) + val delayedStart = createSession(sessionId = sid) + val serializer = mock() + whenever(serializer.serialize(same(liveSession), any())) + .thenThrow(RuntimeException("forced ex")) + whenever(serializer.deserialize(any(), eq(Session::class.java))).thenReturn(delayedStart) + val cache = fixture.getSUT { options -> options.setSerializer(serializer) } + + cache.persistCurrentSession(liveSession) + + val envelope = SentryEnvelope.from(SentryOptions.empty().serializer, delayedStart, null) + cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionStartHint())) + + verify(serializer).serialize(same(delayedStart), any()) + } + @Test fun `updates current file on session update and read it back`() { val cache = fixture.getSUT()