From 68e8e04455fc938c216c04bd2c2c0c92d585e7af 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 | 215 +++++++++++++++++- 3 files changed, 284 insertions(+), 12 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 713fb13cf9..093c788981 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4901,6 +4901,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 8631071a65..483754033b 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() @@ -491,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 45f334c01d5abe7bc0d076ddcc8144539ecde3dc 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 | 18 +++++++++--------- 2 files changed, 16 insertions(+), 15 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 483754033b..58e7c1220d 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) From 8480c3b71bb3f73badad2afd99017dc0c3a5ce37 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 11:55:56 +0200 Subject: [PATCH 03/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 c13c9a5062eeba401a1f23518f1490082c5191e2 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 12:00:00 +0200 Subject: [PATCH 04/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 a655958545d8555140b59db3a1635edf9bd4bb40 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 12 Aug 2026 08:56:03 +0200 Subject: [PATCH 05/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 01ecf6a6f81164496092188b5a74f947e9d4e87a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 12 Aug 2026 10:04:04 +0200 Subject: [PATCH 06/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 00a36b6df111d4952eb83fa8b6283d9fdb5ee11d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 12 Aug 2026 10:10:45 +0200 Subject: [PATCH 07/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 58e7c1220d..9c942c1039 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 61f1ea93471a673bdb08b3f75a3cdff1a49a3f47 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 13:59:56 +0200 Subject: [PATCH 08/12] ref(cache): fold the session-id comparison into one predicate Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 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( From 9d3d31187381ec2aada0425ffcb5e5bd06cc6143 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 14:57:09 +0200 Subject: [PATCH 09/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 a3220ffe5d4263a7b23badcebefd3e8104190e5b Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 15:06:02 +0200 Subject: [PATCH 10/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 d285ae6451ac0e092bfb7cb1b165c0abe2385452 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 16:30:41 +0200 Subject: [PATCH 11/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 9c942c1039..964d554b66 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() From 89d8e9ddb58c14d963349d5fe2ecf1c95c77da03 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 13:26:59 +0200 Subject: [PATCH 12/12] fix(cache): Clear the persisted session id when the write fails writeSessionToDisk truncates the current session file before serializing, so a failed persist leaves it corrupt. lastPersistedSessionId kept pointing at it, and isAlreadyPersisted then skipped the rotation that would have replaced the file, on the premise that it still held the live session. Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 5 ++-- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 3aee522063..d3f473cfbc 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -391,9 +391,8 @@ public void persistCurrentSession(final @NotNull Session session) { final boolean written = writeSessionToDisk( getCurrentSessionFile(directory.getOrCreate().getAbsolutePath()), session); - if (written) { - lastPersistedSessionId = session.getSessionId(); - } + // a failed write truncates the file, so there is no good copy left to protect + lastPersistedSessionId = written ? session.getSessionId() : null; } } diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 964d554b66..0cc2c96819 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -214,6 +214,35 @@ class EnvelopeCacheTest { assertThat(previousSessionFile.exists()).isFalse() } + @Test + fun `failed persist stops the delayed same SID SessionStart from being skipped`() { + val cache = fixture.getSUT() + val sid = SentryUUID.generateSentryId() + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val newerSession = createSession(sessionId = sid) + newerSession.recordNonTerminatingUnhandledError() + cache.persistCurrentSession(newerSession) + + // a directory where the session file belongs makes the write fail + assertTrue(currentSessionFile.delete()) + assertTrue(currentSessionFile.mkdir()) + cache.persistCurrentSession(newerSession) + assertTrue(currentSessionFile.delete()) + + 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.hasNonTerminatingUnhandledError()).isFalse() + assertThat(persistedSession.errorCount()).isEqualTo(0) + } + @Test fun `null SIDs on SessionStart rotate instead of preserving as same session`() { val cache = fixture.getSUT()