From 0e52a4657ff9ae1590be5c4a12e11984f4ad9b87 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:18:05 +0200 Subject: [PATCH 01/48] collection: Unhandled Sessions Co-authored-by: Cursor From 1ae2b467296c6f96c4a875a69631d53add242fea Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:22:37 +0200 Subject: [PATCH 02/48] feat(core): Add Unhandled session state and pending-unhandled marker Adds Session.State.Unhandled from the session protocol, plus a pending-unhandled marker that survives serialization. A session carrying the marker finalizes as Unhandled instead of Exited on end(), while Crashed and Abnormal keep taking precedence. Co-authored-by: Cursor --- sentry/api/sentry.api | 5 + sentry/src/main/java/io/sentry/Session.java | 97 +++++++++--- .../io/sentry/PreviousSessionFinalizerTest.kt | 41 +++++ sentry/src/test/java/io/sentry/SessionTest.kt | 144 ++++++++++++++++++ .../protocol/SessionSerializationTest.kt | 13 ++ 5 files changed, 283 insertions(+), 17 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/SessionTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 43de4164a4f..941463a66f8 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4286,9 +4286,12 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public fun getTimestamp ()Ljava/util/Date; public fun getUnknown ()Ljava/util/Map; public fun getUserAgent ()Ljava/lang/String; + public fun isPendingUnhandled ()Z public fun isTerminated ()Z + public fun markPendingUnhandled ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V + public fun setPendingUnhandled (Z)V public fun setUnknown (Ljava/util/Map;)V public fun update (Lio/sentry/Session$State;Ljava/lang/String;Z)Z public fun update (Lio/sentry/Session$State;Ljava/lang/String;ZLjava/lang/String;)Z @@ -4309,6 +4312,7 @@ public final class io/sentry/Session$JsonKeys { public static final field ERRORS Ljava/lang/String; public static final field INIT Ljava/lang/String; public static final field IP_ADDRESS Ljava/lang/String; + public static final field PENDING_UNHANDLED Ljava/lang/String; public static final field RELEASE Ljava/lang/String; public static final field SEQ Ljava/lang/String; public static final field SID Ljava/lang/String; @@ -4324,6 +4328,7 @@ public final class io/sentry/Session$State : java/lang/Enum { public static final field Crashed Lio/sentry/Session$State; public static final field Exited Lio/sentry/Session$State; public static final field Ok Lio/sentry/Session$State; + public static final field Unhandled Lio/sentry/Session$State; public static fun valueOf (Ljava/lang/String;)Lio/sentry/Session$State; public static fun values ()[Lio/sentry/Session$State; } diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 2fdfffb35d9..a1334ea45f5 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -21,7 +21,8 @@ public enum State { Ok, Exited, Crashed, - Abnormal + Abnormal, + Unhandled } /** started timestamp */ @@ -66,6 +67,14 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; + /** + * Whether the session experienced an unhandled (but non-terminal) exception. Kept locally and + * persisted with the session, but never sent as a status while the session is alive. On end() the + * session is finalized as {@link State#Unhandled} instead of {@link State#Exited} unless a crash + * escalated it to {@link State#Crashed}. + */ + private boolean pendingUnhandled; + /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -188,6 +197,41 @@ public int errorCount() { return abnormalMechanism; } + /** + * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized + * yet. + */ + @ApiStatus.Internal + public boolean isPendingUnhandled() { + return pendingUnhandled; + } + + /** + * Marks the session as having experienced an unhandled (non-terminal) exception without ending + * it. On {@link #end()} the session will be finalized as {@link State#Unhandled} unless a crash + * escalated it to {@link State#Crashed} first. + */ + @ApiStatus.Internal + public void setPendingUnhandled(final boolean pendingUnhandled) { + this.pendingUnhandled = pendingUnhandled; + } + + /** Marks an active session as having experienced an unhandled non-terminal exception. */ + @ApiStatus.Internal + public boolean markPendingUnhandled() { + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + if (status != State.Ok) { + return false; + } + pendingUnhandled = true; + errorCount.incrementAndGet(); + init = null; + timestamp = DateUtils.getCurrentDateTime(); + sequence = getSequenceTimestamp(timestamp); + return true; + } + } + @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getTimestamp() { return timestamp; @@ -209,7 +253,9 @@ public void end(final @Nullable Date timestamp) { // at this state it might be Crashed already, so we don't check for it. if (status == State.Ok) { - status = State.Exited; + // a session that experienced an unhandled (but non-terminal) exception is finalized as + // Unhandled rather than Exited. + status = pendingUnhandled ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -262,6 +308,10 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; + // a real crash takes precedence over a pending unhandled (non-terminal) exception. + if (status == State.Crashed) { + pendingUnhandled = false; + } sessionHasBeenUpdated = true; } @@ -318,21 +368,24 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { */ @SuppressWarnings("MissingOverride") public @NotNull Session clone() { - return new Session( - status, - started, - timestamp, - errorCount.get(), - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism); + final Session session = + new Session( + status, + started, + timestamp, + errorCount.get(), + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism); + session.setPendingUnhandled(pendingUnhandled); + return session; } // JsonSerializable @@ -354,6 +407,7 @@ public static final class JsonKeys { public static final String IP_ADDRESS = "ip_address"; public static final String USER_AGENT = "user_agent"; public static final String ABNORMAL_MECHANISM = "abnormal_mechanism"; + public static final String PENDING_UNHANDLED = "pending_unhandled"; } @Override @@ -384,6 +438,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (abnormalMechanism != null) { writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism); } + if (pendingUnhandled) { + writer.name(JsonKeys.PENDING_UNHANDLED).value(pendingUnhandled); + } writer.name(JsonKeys.ATTRS); writer.beginObject(); writer.name(JsonKeys.RELEASE).value(logger, release); @@ -440,6 +497,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; + boolean pendingUnhandled = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -483,6 +541,10 @@ public static final class Deserializer implements JsonDeserializer { case JsonKeys.ABNORMAL_MECHANISM: abnormalMechanism = reader.nextStringOrNull(); break; + case JsonKeys.PENDING_UNHANDLED: + final Boolean pendingUnhandledValue = reader.nextBooleanOrNull(); + pendingUnhandled = pendingUnhandledValue != null && pendingUnhandledValue; + break; case JsonKeys.ATTRS: reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -542,6 +604,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); + session.setPendingUnhandled(pendingUnhandled); session.setUnknown(unknown); reader.endObject(); return session; diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index 4b433ffb3e1..36b934b4906 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -200,6 +200,47 @@ class PreviousSessionFinalizerTest { ) } + @Test + fun `if previous session has pending unhandled and no crash marker, finalizes as unhandled`() { + val finalizer = + fixture.getSut( + tmpDir, + session = + Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + ) + finalizer.run() + + verify(fixture.scopes) + .captureEnvelope( + argThat { + val session = fixture.sessionFromEnvelope(this) + session.release == "io.sentry.sample@1.0" && + session.status == Session.State.Unhandled && + session.isPendingUnhandled + } + ) + } + + @Test + fun `if previous session has pending unhandled but a native crash marker exists, finalizes as crashed`() { + val finalizer = + fixture.getSut( + tmpDir, + session = + Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), + ) + finalizer.run() + + verify(fixture.scopes) + .captureEnvelope( + argThat { + val session = fixture.sessionFromEnvelope(this) + session.release == "io.sentry.sample@1.0" && session.status == Crashed + } + ) + } + @Test fun `if previous session file exists, deletes previous session file`() { val finalizer = fixture.getSut(tmpDir, sessionFileExists = true) diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt new file mode 100644 index 00000000000..77a16b8625a --- /dev/null +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -0,0 +1,144 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import java.io.StringReader +import java.io.StringWriter +import kotlin.test.Test +import org.mockito.kotlin.mock + +class SessionTest { + + private fun okSession(): Session = Session(null, null, "environment", "release") + + @Test + fun `markPendingUnhandled atomically updates an Ok session`() { + val session = okSession() + val initialTimestamp = session.timestamp + + val updated = session.markPendingUnhandled() + + assertThat(updated).isTrue() + assertThat(session.status).isEqualTo(Session.State.Ok) + assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.errorCount()).isEqualTo(1) + assertThat(session.init).isNull() + assertThat(session.timestamp).isNotNull() + assertThat(session.timestamp!!.time).isAtLeast(initialTimestamp!!.time) + assertThat(session.sequence).isEqualTo(session.timestamp!!.time) + } + + @Test + fun `markPendingUnhandled does not change terminal sessions`() { + for (state in Session.State.entries.filter { it != Session.State.Ok }) { + val session = okSession() + session.update(state, null, false) + val before = session.clone() + + val updated = session.markPendingUnhandled() + + assertThat(updated).isFalse() + assertThat(session.status).isEqualTo(before.status) + assertThat(session.isPendingUnhandled).isEqualTo(before.isPendingUnhandled) + assertThat(session.errorCount()).isEqualTo(before.errorCount()) + assertThat(session.init).isEqualTo(before.init) + assertThat(session.timestamp).isEqualTo(before.timestamp) + assertThat(session.sequence).isEqualTo(before.sequence) + } + } + + @Test + fun `end without pending unhandled finalizes as Exited`() { + val session = okSession() + + session.end() + + assertThat(session.status).isEqualTo(Session.State.Exited) + } + + @Test + fun `end with pending unhandled finalizes as Unhandled`() { + val session = okSession() + assertThat(session.isPendingUnhandled).isFalse() + + session.setPendingUnhandled(true) + session.end() + + assertThat(session.status).isEqualTo(Session.State.Unhandled) + assertThat(session.isPendingUnhandled).isTrue() + } + + @Test + fun `end with pending unhandled keeps Abnormal as Abnormal`() { + val session = okSession() + session.setPendingUnhandled(true) + session.update(Session.State.Abnormal, null, false, "anr") + + session.end() + + assertThat(session.status).isEqualTo(Session.State.Abnormal) + assertThat(session.isPendingUnhandled).isTrue() + } + + @Test + fun `end with pending unhandled keeps Crashed as Crashed`() { + val session = okSession() + session.setPendingUnhandled(true) + session.update(Session.State.Crashed, null, false) + + session.end() + + assertThat(session.status).isEqualTo(Session.State.Crashed) + assertThat(session.isPendingUnhandled).isFalse() + } + + @Test + fun `updating to Crashed clears pending unhandled and end stays Crashed`() { + val session = okSession() + session.setPendingUnhandled(true) + + session.update(Session.State.Crashed, null, true) + session.end() + + assertThat(session.status).isEqualTo(Session.State.Crashed) + assertThat(session.isPendingUnhandled).isFalse() + } + + @Test + fun `clone preserves pending unhandled`() { + val session = okSession() + session.setPendingUnhandled(true) + + val clone = session.clone() + + assertThat(clone.isPendingUnhandled).isTrue() + } + + @Test + fun `serialization round-trips pending unhandled and Unhandled status`() { + val logger = mock() + val session = okSession() + session.setPendingUnhandled(true) + session.end() + assertThat(session.status).isEqualTo(Session.State.Unhandled) + + val writer = StringWriter() + session.serialize(JsonObjectWriter(writer, 100), logger) + + val deserialized = + Session.Deserializer().deserialize(JsonObjectReader(StringReader(writer.toString())), logger) + + assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) + assertThat(deserialized.isPendingUnhandled).isTrue() + } + + @Test + fun `pending unhandled defaults to false and is not serialized when unset`() { + val logger = mock() + val session = okSession() + + val writer = StringWriter() + session.serialize(JsonObjectWriter(writer, 100), logger) + + assertThat(writer.toString()).doesNotContain("pending_unhandled") + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index 644f57a0232..1280680e7f8 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -53,6 +53,19 @@ class SessionSerializationTest { assertEquals(expectedJson, actualJson) } + @Test + fun `serialize and deserialize round-trips Unhandled status and pending unhandled flag`() { + val session = Session(null, null, "environment", "release") + session.setPendingUnhandled(true) + session.end() + assertEquals(Session.State.Unhandled, session.status) + + val deserialized = deserialize(serialize(session)) + + assertEquals(Session.State.Unhandled, deserialized.status) + assertEquals(true, deserialized.isPendingUnhandled) + } + // Helper private fun sanitizedFile(path: String): String = From e62793f8693905c33e9fed08e8858136fb3acd7e Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:37:48 +0200 Subject: [PATCH 03/48] ref: rename pendingUnhandled to nonTerminatingUnhandledError "Unhandled" alone is ambiguous: a native crash is also an unhandled error, it just terminates the process and so ends the session as crashed rather than unhandled. Name the flag after the property that actually distinguishes the two and match the vocabulary of captureEnvelopeNonTerminating. Also clarify that the setter only restores the flag when rebuilding a session and must not be used to mutate a live one. Co-authored-by: Cursor --- sentry/api/sentry.api | 8 +-- sentry/src/main/java/io/sentry/Session.java | 71 +++++++++++-------- .../io/sentry/PreviousSessionFinalizerTest.kt | 14 ++-- sentry/src/test/java/io/sentry/SessionTest.kt | 57 +++++++-------- .../protocol/SessionSerializationTest.kt | 6 +- 5 files changed, 87 insertions(+), 69 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 941463a66f8..8a810918c03 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4286,12 +4286,12 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public fun getTimestamp ()Ljava/util/Date; public fun getUnknown ()Ljava/util/Map; public fun getUserAgent ()Ljava/lang/String; - public fun isPendingUnhandled ()Z + public fun hasNonTerminatingUnhandledError ()Z public fun isTerminated ()Z - public fun markPendingUnhandled ()Z + public fun recordNonTerminatingUnhandledError ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V - public fun setPendingUnhandled (Z)V + public fun setNonTerminatingUnhandledError (Z)V public fun setUnknown (Ljava/util/Map;)V public fun update (Lio/sentry/Session$State;Ljava/lang/String;Z)Z public fun update (Lio/sentry/Session$State;Ljava/lang/String;ZLjava/lang/String;)Z @@ -4312,7 +4312,7 @@ public final class io/sentry/Session$JsonKeys { public static final field ERRORS Ljava/lang/String; public static final field INIT Ljava/lang/String; public static final field IP_ADDRESS Ljava/lang/String; - public static final field PENDING_UNHANDLED Ljava/lang/String; + public static final field NON_TERMINATING_UNHANDLED_ERROR Ljava/lang/String; public static final field RELEASE Ljava/lang/String; public static final field SEQ Ljava/lang/String; public static final field SID Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index a1334ea45f5..33e9de42894 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -68,12 +68,15 @@ public enum State { private @Nullable String abnormalMechanism; /** - * Whether the session experienced an unhandled (but non-terminal) exception. Kept locally and - * persisted with the session, but never sent as a status while the session is alive. On end() the - * session is finalized as {@link State#Unhandled} instead of {@link State#Exited} unless a crash - * escalated it to {@link State#Crashed}. + * Whether the session experienced an unhandled error that did not terminate the process, + * e.g. an unhandled Flutter exception. A native crash is also unhandled, but it kills the process + * and therefore ends the session as {@link State#Crashed} instead. + * + *

Kept locally and persisted with the session, but never sent as a status while the session is + * alive. On end() the session is finalized as {@link State#Unhandled} instead of {@link + * State#Exited}, unless a crash escalated it to {@link State#Crashed}. */ - private boolean pendingUnhandled; + private boolean nonTerminatingUnhandledError; /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -198,32 +201,41 @@ public int errorCount() { } /** - * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized - * yet. + * Whether the session experienced an unhandled error that did not terminate the process, and so + * finalizes as {@link State#Unhandled} rather than {@link State#Exited}. */ @ApiStatus.Internal - public boolean isPendingUnhandled() { - return pendingUnhandled; + public boolean hasNonTerminatingUnhandledError() { + return nonTerminatingUnhandledError; } /** - * Marks the session as having experienced an unhandled (non-terminal) exception without ending - * it. On {@link #end()} the session will be finalized as {@link State#Unhandled} unless a crash - * escalated it to {@link State#Crashed} first. + * Restores the flag when rebuilding a session, i.e. from {@link #clone()} or the deserializer. + * + *

Not for use on a live session: unlike {@link #recordNonTerminatingUnhandledError()} this + * neither counts the error nor advances the session's sequence, so a session mutated through this + * setter would be sent as an out-of-date update. */ @ApiStatus.Internal - public void setPendingUnhandled(final boolean pendingUnhandled) { - this.pendingUnhandled = pendingUnhandled; + public void setNonTerminatingUnhandledError(final boolean nonTerminatingUnhandledError) { + this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; } - /** Marks an active session as having experienced an unhandled non-terminal exception. */ + /** + * Records that an active session experienced an unhandled error which did not terminate the + * process, counting the error and advancing the session's sequence without ending it. On {@link + * #end()} the session is finalized as {@link State#Unhandled} unless a crash escalated it to + * {@link State#Crashed} first. + * + * @return whether the session was updated, i.e. false if it had already reached a terminal state + */ @ApiStatus.Internal - public boolean markPendingUnhandled() { + public boolean recordNonTerminatingUnhandledError() { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { if (status != State.Ok) { return false; } - pendingUnhandled = true; + nonTerminatingUnhandledError = true; errorCount.incrementAndGet(); init = null; timestamp = DateUtils.getCurrentDateTime(); @@ -255,7 +267,7 @@ public void end(final @Nullable Date timestamp) { if (status == State.Ok) { // a session that experienced an unhandled (but non-terminal) exception is finalized as // Unhandled rather than Exited. - status = pendingUnhandled ? State.Unhandled : State.Exited; + status = nonTerminatingUnhandledError ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -308,9 +320,9 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; - // a real crash takes precedence over a pending unhandled (non-terminal) exception. + // a crash terminates the process, so it takes precedence over a non-terminating one. if (status == State.Crashed) { - pendingUnhandled = false; + nonTerminatingUnhandledError = false; } sessionHasBeenUpdated = true; } @@ -384,7 +396,7 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { environment, release, abnormalMechanism); - session.setPendingUnhandled(pendingUnhandled); + session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); return session; } @@ -407,7 +419,7 @@ public static final class JsonKeys { public static final String IP_ADDRESS = "ip_address"; public static final String USER_AGENT = "user_agent"; public static final String ABNORMAL_MECHANISM = "abnormal_mechanism"; - public static final String PENDING_UNHANDLED = "pending_unhandled"; + public static final String NON_TERMINATING_UNHANDLED_ERROR = "non_terminating_unhandled_error"; } @Override @@ -438,8 +450,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (abnormalMechanism != null) { writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism); } - if (pendingUnhandled) { - writer.name(JsonKeys.PENDING_UNHANDLED).value(pendingUnhandled); + if (nonTerminatingUnhandledError) { + writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(nonTerminatingUnhandledError); } writer.name(JsonKeys.ATTRS); writer.beginObject(); @@ -497,7 +509,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; - boolean pendingUnhandled = false; + boolean nonTerminatingUnhandledError = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -541,9 +553,10 @@ public static final class Deserializer implements JsonDeserializer { case JsonKeys.ABNORMAL_MECHANISM: abnormalMechanism = reader.nextStringOrNull(); break; - case JsonKeys.PENDING_UNHANDLED: - final Boolean pendingUnhandledValue = reader.nextBooleanOrNull(); - pendingUnhandled = pendingUnhandledValue != null && pendingUnhandledValue; + case JsonKeys.NON_TERMINATING_UNHANDLED_ERROR: + final Boolean nonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); + nonTerminatingUnhandledError = + nonTerminatingUnhandledErrorValue != null && nonTerminatingUnhandledErrorValue; break; case JsonKeys.ATTRS: reader.beginObject(); @@ -604,7 +617,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); - session.setPendingUnhandled(pendingUnhandled); + session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); session.setUnknown(unknown); reader.endObject(); return session; diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index 36b934b4906..af2e67b19d9 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -201,12 +201,14 @@ class PreviousSessionFinalizerTest { } @Test - fun `if previous session has pending unhandled and no crash marker, finalizes as unhandled`() { + fun `if previous session has a non-terminating unhandled error and no crash marker, finalizes as unhandled`() { val finalizer = fixture.getSut( tmpDir, session = - Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + Session(null, null, null, "io.sentry.sample@1.0").apply { + setNonTerminatingUnhandledError(true) + }, ) finalizer.run() @@ -216,18 +218,20 @@ class PreviousSessionFinalizerTest { val session = fixture.sessionFromEnvelope(this) session.release == "io.sentry.sample@1.0" && session.status == Session.State.Unhandled && - session.isPendingUnhandled + session.hasNonTerminatingUnhandledError() } ) } @Test - fun `if previous session has pending unhandled but a native crash marker exists, finalizes as crashed`() { + fun `if previous session has a non-terminating unhandled error but a native crash marker exists, finalizes as crashed`() { val finalizer = fixture.getSut( tmpDir, session = - Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + Session(null, null, null, "io.sentry.sample@1.0").apply { + setNonTerminatingUnhandledError(true) + }, nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), ) finalizer.run() diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index 77a16b8625a..6326179a9be 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -11,15 +11,15 @@ class SessionTest { private fun okSession(): Session = Session(null, null, "environment", "release") @Test - fun `markPendingUnhandled atomically updates an Ok session`() { + fun `recordNonTerminatingUnhandledError atomically updates an Ok session`() { val session = okSession() val initialTimestamp = session.timestamp - val updated = session.markPendingUnhandled() + val updated = session.recordNonTerminatingUnhandledError() assertThat(updated).isTrue() assertThat(session.status).isEqualTo(Session.State.Ok) - assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.hasNonTerminatingUnhandledError()).isTrue() assertThat(session.errorCount()).isEqualTo(1) assertThat(session.init).isNull() assertThat(session.timestamp).isNotNull() @@ -28,17 +28,18 @@ class SessionTest { } @Test - fun `markPendingUnhandled does not change terminal sessions`() { + fun `recordNonTerminatingUnhandledError does not change terminal sessions`() { for (state in Session.State.entries.filter { it != Session.State.Ok }) { val session = okSession() session.update(state, null, false) val before = session.clone() - val updated = session.markPendingUnhandled() + val updated = session.recordNonTerminatingUnhandledError() assertThat(updated).isFalse() assertThat(session.status).isEqualTo(before.status) - assertThat(session.isPendingUnhandled).isEqualTo(before.isPendingUnhandled) + assertThat(session.hasNonTerminatingUnhandledError()) + .isEqualTo(before.hasNonTerminatingUnhandledError()) assertThat(session.errorCount()).isEqualTo(before.errorCount()) assertThat(session.init).isEqualTo(before.init) assertThat(session.timestamp).isEqualTo(before.timestamp) @@ -47,7 +48,7 @@ class SessionTest { } @Test - fun `end without pending unhandled finalizes as Exited`() { + fun `end without a non-terminating unhandled error finalizes as Exited`() { val session = okSession() session.end() @@ -56,68 +57,68 @@ class SessionTest { } @Test - fun `end with pending unhandled finalizes as Unhandled`() { + fun `end with a non-terminating unhandled error finalizes as Unhandled`() { val session = okSession() - assertThat(session.isPendingUnhandled).isFalse() + assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) - assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `end with pending unhandled keeps Abnormal as Abnormal`() { + fun `end with a non-terminating unhandled error keeps Abnormal as Abnormal`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.update(Session.State.Abnormal, null, false, "anr") session.end() assertThat(session.status).isEqualTo(Session.State.Abnormal) - assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `end with pending unhandled keeps Crashed as Crashed`() { + fun `end with a non-terminating unhandled error keeps Crashed as Crashed`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.update(Session.State.Crashed, null, false) session.end() assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.isPendingUnhandled).isFalse() + assertThat(session.hasNonTerminatingUnhandledError()).isFalse() } @Test - fun `updating to Crashed clears pending unhandled and end stays Crashed`() { + fun `updating to Crashed clears a non-terminating unhandled error and end stays Crashed`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.update(Session.State.Crashed, null, true) session.end() assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.isPendingUnhandled).isFalse() + assertThat(session.hasNonTerminatingUnhandledError()).isFalse() } @Test - fun `clone preserves pending unhandled`() { + fun `clone preserves a non-terminating unhandled error`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) val clone = session.clone() - assertThat(clone.isPendingUnhandled).isTrue() + assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `serialization round-trips pending unhandled and Unhandled status`() { + fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { val logger = mock() val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) @@ -128,17 +129,17 @@ class SessionTest { Session.Deserializer().deserialize(JsonObjectReader(StringReader(writer.toString())), logger) assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) - assertThat(deserialized.isPendingUnhandled).isTrue() + assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `pending unhandled defaults to false and is not serialized when unset`() { + fun `a non-terminating unhandled error defaults to false and is not serialized when unset`() { val logger = mock() val session = okSession() val writer = StringWriter() session.serialize(JsonObjectWriter(writer, 100), logger) - assertThat(writer.toString()).doesNotContain("pending_unhandled") + assertThat(writer.toString()).doesNotContain("non_terminating_unhandled_error") } } diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index 1280680e7f8..ed85351f83e 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -54,16 +54,16 @@ class SessionSerializationTest { } @Test - fun `serialize and deserialize round-trips Unhandled status and pending unhandled flag`() { + fun `serialize and deserialize round-trips Unhandled status and non-terminating flag`() { val session = Session(null, null, "environment", "release") - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.end() assertEquals(Session.State.Unhandled, session.status) val deserialized = deserialize(serialize(session)) assertEquals(Session.State.Unhandled, deserialized.status) - assertEquals(true, deserialized.isPendingUnhandled) + assertEquals(true, deserialized.hasNonTerminatingUnhandledError()) } // Helper From af3fd9191f53c1d5691bcfcaec3b76b8277f34ff Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 12:00:56 +0200 Subject: [PATCH 04/48] ref: drop public setter for the non-terminating unhandled error flag clone() and Session.Deserializer are both inside Session, so they can restore the field directly. Dropping the setter keeps it off the public API surface and makes it impossible to flip the flag on a live session without counting the error and advancing the sequence. Co-authored-by: Cursor --- sentry/api/sentry.api | 1 - sentry/src/main/java/io/sentry/Session.java | 16 ++-------------- .../io/sentry/PreviousSessionFinalizerTest.kt | 4 ++-- sentry/src/test/java/io/sentry/SessionTest.kt | 12 ++++++------ .../sentry/protocol/SessionSerializationTest.kt | 2 +- 5 files changed, 11 insertions(+), 24 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8a810918c03..b01ffbd0ed9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4291,7 +4291,6 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public fun recordNonTerminatingUnhandledError ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V - public fun setNonTerminatingUnhandledError (Z)V public fun setUnknown (Ljava/util/Map;)V public fun update (Lio/sentry/Session$State;Ljava/lang/String;Z)Z public fun update (Lio/sentry/Session$State;Ljava/lang/String;ZLjava/lang/String;)Z diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 33e9de42894..1fde656ab05 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -209,18 +209,6 @@ public boolean hasNonTerminatingUnhandledError() { return nonTerminatingUnhandledError; } - /** - * Restores the flag when rebuilding a session, i.e. from {@link #clone()} or the deserializer. - * - *

Not for use on a live session: unlike {@link #recordNonTerminatingUnhandledError()} this - * neither counts the error nor advances the session's sequence, so a session mutated through this - * setter would be sent as an out-of-date update. - */ - @ApiStatus.Internal - public void setNonTerminatingUnhandledError(final boolean nonTerminatingUnhandledError) { - this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; - } - /** * Records that an active session experienced an unhandled error which did not terminate the * process, counting the error and advancing the session's sequence without ending it. On {@link @@ -396,7 +384,7 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { environment, release, abnormalMechanism); - session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); + session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; return session; } @@ -617,7 +605,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); - session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); + session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; session.setUnknown(unknown); reader.endObject(); return session; diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index af2e67b19d9..3a730e971bf 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -207,7 +207,7 @@ class PreviousSessionFinalizerTest { tmpDir, session = Session(null, null, null, "io.sentry.sample@1.0").apply { - setNonTerminatingUnhandledError(true) + recordNonTerminatingUnhandledError() }, ) finalizer.run() @@ -230,7 +230,7 @@ class PreviousSessionFinalizerTest { tmpDir, session = Session(null, null, null, "io.sentry.sample@1.0").apply { - setNonTerminatingUnhandledError(true) + recordNonTerminatingUnhandledError() }, nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), ) diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index 6326179a9be..d036805e06b 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -61,7 +61,7 @@ class SessionTest { val session = okSession() assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) @@ -71,7 +71,7 @@ class SessionTest { @Test fun `end with a non-terminating unhandled error keeps Abnormal as Abnormal`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.update(Session.State.Abnormal, null, false, "anr") session.end() @@ -83,7 +83,7 @@ class SessionTest { @Test fun `end with a non-terminating unhandled error keeps Crashed as Crashed`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.update(Session.State.Crashed, null, false) session.end() @@ -95,7 +95,7 @@ class SessionTest { @Test fun `updating to Crashed clears a non-terminating unhandled error and end stays Crashed`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.update(Session.State.Crashed, null, true) session.end() @@ -107,7 +107,7 @@ class SessionTest { @Test fun `clone preserves a non-terminating unhandled error`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() val clone = session.clone() @@ -118,7 +118,7 @@ class SessionTest { fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { val logger = mock() val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index ed85351f83e..c88e335e1ac 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -56,7 +56,7 @@ class SessionSerializationTest { @Test fun `serialize and deserialize round-trips Unhandled status and non-terminating flag`() { val session = Session(null, null, "environment", "release") - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.end() assertEquals(Session.State.Unhandled, session.status) From 59717b2d8f3a1a53cc6a306609919f55eede9e73 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 12:10:54 +0200 Subject: [PATCH 05/48] ref: initialize the non-terminating flag through a private constructor Every other field is set at construction; the flag was the odd one out, assigned afterwards. A private canonical constructor keeps construction complete without putting the flag on the public API, which a 15-arg public overload would do. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 79 +++++++++++++++------ 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 1fde656ab05..adefa43bd8f 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -99,6 +99,46 @@ public Session( final @Nullable String environment, final @NotNull String release, final @Nullable String abnormalMechanism) { + this( + status, + started, + timestamp, + errorCount, + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism, + false); + } + + /** + * Canonical constructor. Kept private so {@code nonTerminatingUnhandledError} stays off the + * public API: it is internal bookkeeping that only {@link #clone()} and {@link Deserializer} need + * to restore, and a public overload carrying it would let callers fabricate a session claiming an + * unhandled error that was never counted. + */ + private Session( + final @NotNull State status, + final @NotNull Date started, + final @Nullable Date timestamp, + final int errorCount, + final @Nullable String distinctId, + final @Nullable String sessionId, + final @Nullable Boolean init, + final @Nullable Long sequence, + final @Nullable Double duration, + final @Nullable String ipAddress, + final @Nullable String userAgent, + final @Nullable String environment, + final @NotNull String release, + final @Nullable String abnormalMechanism, + final boolean nonTerminatingUnhandledError) { this.status = status; this.started = started; this.timestamp = timestamp; @@ -113,6 +153,7 @@ public Session( this.environment = environment; this.release = release; this.abnormalMechanism = abnormalMechanism; + this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; } public Session( @@ -368,24 +409,22 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { */ @SuppressWarnings("MissingOverride") public @NotNull Session clone() { - final Session session = - new Session( - status, - started, - timestamp, - errorCount.get(), - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism); - session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; - return session; + return new Session( + status, + started, + timestamp, + errorCount.get(), + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism, + nonTerminatingUnhandledError); } // JsonSerializable @@ -604,8 +643,8 @@ public static final class Deserializer implements JsonDeserializer { userAgent, environment, release, - abnormalMechanism); - session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; + abnormalMechanism, + nonTerminatingUnhandledError); session.setUnknown(unknown); reader.endObject(); return session; From 55fc5695291b616be9c327d4f80ae2db53ab5686 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 09:44:26 +0200 Subject: [PATCH 06/48] ref: prefix the non-terminating flag field with has As a bare noun phrase the field read like it held the error rather than a boolean, most visibly where it is passed as a constructor argument. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 33 +++++++++++---------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index adefa43bd8f..ca62627a36a 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -76,7 +76,7 @@ public enum State { * alive. On end() the session is finalized as {@link State#Unhandled} instead of {@link * State#Exited}, unless a crash escalated it to {@link State#Crashed}. */ - private boolean nonTerminatingUnhandledError; + private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -118,7 +118,7 @@ public Session( } /** - * Canonical constructor. Kept private so {@code nonTerminatingUnhandledError} stays off the + * Canonical constructor. Kept private so {@code hasNonTerminatingUnhandledError} stays off the * public API: it is internal bookkeeping that only {@link #clone()} and {@link Deserializer} need * to restore, and a public overload carrying it would let callers fabricate a session claiming an * unhandled error that was never counted. @@ -138,7 +138,7 @@ private Session( final @Nullable String environment, final @NotNull String release, final @Nullable String abnormalMechanism, - final boolean nonTerminatingUnhandledError) { + final boolean hasNonTerminatingUnhandledError) { this.status = status; this.started = started; this.timestamp = timestamp; @@ -153,7 +153,7 @@ private Session( this.environment = environment; this.release = release; this.abnormalMechanism = abnormalMechanism; - this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; + this.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; } public Session( @@ -247,7 +247,7 @@ public int errorCount() { */ @ApiStatus.Internal public boolean hasNonTerminatingUnhandledError() { - return nonTerminatingUnhandledError; + return hasNonTerminatingUnhandledError; } /** @@ -264,7 +264,7 @@ public boolean recordNonTerminatingUnhandledError() { if (status != State.Ok) { return false; } - nonTerminatingUnhandledError = true; + hasNonTerminatingUnhandledError = true; errorCount.incrementAndGet(); init = null; timestamp = DateUtils.getCurrentDateTime(); @@ -296,7 +296,7 @@ public void end(final @Nullable Date timestamp) { if (status == State.Ok) { // a session that experienced an unhandled (but non-terminal) exception is finalized as // Unhandled rather than Exited. - status = nonTerminatingUnhandledError ? State.Unhandled : State.Exited; + status = hasNonTerminatingUnhandledError ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -351,7 +351,7 @@ public boolean update( this.status = status; // a crash terminates the process, so it takes precedence over a non-terminating one. if (status == State.Crashed) { - nonTerminatingUnhandledError = false; + hasNonTerminatingUnhandledError = false; } sessionHasBeenUpdated = true; } @@ -424,7 +424,7 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { environment, release, abnormalMechanism, - nonTerminatingUnhandledError); + hasNonTerminatingUnhandledError); } // JsonSerializable @@ -477,8 +477,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (abnormalMechanism != null) { writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism); } - if (nonTerminatingUnhandledError) { - writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(nonTerminatingUnhandledError); + if (hasNonTerminatingUnhandledError) { + writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(hasNonTerminatingUnhandledError); } writer.name(JsonKeys.ATTRS); writer.beginObject(); @@ -536,7 +536,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; - boolean nonTerminatingUnhandledError = false; + boolean hasNonTerminatingUnhandledError = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -581,9 +581,10 @@ public static final class Deserializer implements JsonDeserializer { abnormalMechanism = reader.nextStringOrNull(); break; case JsonKeys.NON_TERMINATING_UNHANDLED_ERROR: - final Boolean nonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); - nonTerminatingUnhandledError = - nonTerminatingUnhandledErrorValue != null && nonTerminatingUnhandledErrorValue; + final Boolean hasNonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); + hasNonTerminatingUnhandledError = + hasNonTerminatingUnhandledErrorValue != null + && hasNonTerminatingUnhandledErrorValue; break; case JsonKeys.ATTRS: reader.beginObject(); @@ -644,7 +645,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism, - nonTerminatingUnhandledError); + hasNonTerminatingUnhandledError); session.setUnknown(unknown); reader.endObject(); return session; From 407c39d07c2e3d7d728be6c3d43eecfeac0fe2d6 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 09:48:40 +0200 Subject: [PATCH 07/48] ref: drop comments that restate the code in Session Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index ca62627a36a..8375184e4ae 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -67,15 +67,6 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; - /** - * Whether the session experienced an unhandled error that did not terminate the process, - * e.g. an unhandled Flutter exception. A native crash is also unhandled, but it kills the process - * and therefore ends the session as {@link State#Crashed} instead. - * - *

Kept locally and persisted with the session, but never sent as a status while the session is - * alive. On end() the session is finalized as {@link State#Unhandled} instead of {@link - * State#Exited}, unless a crash escalated it to {@link State#Crashed}. - */ private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ @@ -242,8 +233,12 @@ public int errorCount() { } /** - * Whether the session experienced an unhandled error that did not terminate the process, and so - * finalizes as {@link State#Unhandled} rather than {@link State#Exited}. + * Whether the session experienced an unhandled error that did not terminate the process, + * e.g. an unhandled Flutter exception, and so finalizes as {@link State#Unhandled} rather than + * {@link State#Exited}. A native crash is also unhandled, but it kills the process and ends the + * session as {@link State#Crashed} instead. + * + *

Never sent as a status while the session is alive; it is only persisted with the session. */ @ApiStatus.Internal public boolean hasNonTerminatingUnhandledError() { @@ -294,8 +289,6 @@ public void end(final @Nullable Date timestamp) { // at this state it might be Crashed already, so we don't check for it. if (status == State.Ok) { - // a session that experienced an unhandled (but non-terminal) exception is finalized as - // Unhandled rather than Exited. status = hasNonTerminatingUnhandledError ? State.Unhandled : State.Exited; } From 8551441e069857b5a6ab494a6ffc6cce36967cad Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 11:37:50 +0200 Subject: [PATCH 08/48] test: move Session serialization cases out of SessionTest The round-trip case duplicated one already added to SessionSerializationTest. Keep JSON concerns in the serialization test and leave SessionTest to state transitions. Co-authored-by: Cursor --- sentry/src/test/java/io/sentry/SessionTest.kt | 32 ------------------- .../protocol/SessionSerializationTest.kt | 8 +++++ 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index d036805e06b..812138c1215 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -1,10 +1,7 @@ package io.sentry import com.google.common.truth.Truth.assertThat -import java.io.StringReader -import java.io.StringWriter import kotlin.test.Test -import org.mockito.kotlin.mock class SessionTest { @@ -113,33 +110,4 @@ class SessionTest { assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() } - - @Test - fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { - val logger = mock() - val session = okSession() - session.recordNonTerminatingUnhandledError() - session.end() - assertThat(session.status).isEqualTo(Session.State.Unhandled) - - val writer = StringWriter() - session.serialize(JsonObjectWriter(writer, 100), logger) - - val deserialized = - Session.Deserializer().deserialize(JsonObjectReader(StringReader(writer.toString())), logger) - - assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) - assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() - } - - @Test - fun `a non-terminating unhandled error defaults to false and is not serialized when unset`() { - val logger = mock() - val session = okSession() - - val writer = StringWriter() - session.serialize(JsonObjectWriter(writer, 100), logger) - - assertThat(writer.toString()).doesNotContain("non_terminating_unhandled_error") - } } diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index c88e335e1ac..663bcae0f06 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -10,6 +10,7 @@ import io.sentry.Session import java.io.StringReader import java.io.StringWriter import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.Test import org.mockito.kotlin.mock @@ -66,6 +67,13 @@ class SessionSerializationTest { assertEquals(true, deserialized.hasNonTerminatingUnhandledError()) } + @Test + fun `non-terminating flag is omitted when unset`() { + val session = Session(null, null, "environment", "release") + + assertFalse(serialize(session).contains("non_terminating_unhandled_error")) + } + // Helper private fun sanitizedFile(path: String): String = From d198a045206a55d2d4611d572aee6a6238ad3777 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 11:42:13 +0200 Subject: [PATCH 09/48] test: remove SessionTest Co-authored-by: Cursor --- sentry/src/test/java/io/sentry/SessionTest.kt | 113 ------------------ 1 file changed, 113 deletions(-) delete mode 100644 sentry/src/test/java/io/sentry/SessionTest.kt diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt deleted file mode 100644 index 812138c1215..00000000000 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package io.sentry - -import com.google.common.truth.Truth.assertThat -import kotlin.test.Test - -class SessionTest { - - private fun okSession(): Session = Session(null, null, "environment", "release") - - @Test - fun `recordNonTerminatingUnhandledError atomically updates an Ok session`() { - val session = okSession() - val initialTimestamp = session.timestamp - - val updated = session.recordNonTerminatingUnhandledError() - - assertThat(updated).isTrue() - assertThat(session.status).isEqualTo(Session.State.Ok) - assertThat(session.hasNonTerminatingUnhandledError()).isTrue() - assertThat(session.errorCount()).isEqualTo(1) - assertThat(session.init).isNull() - assertThat(session.timestamp).isNotNull() - assertThat(session.timestamp!!.time).isAtLeast(initialTimestamp!!.time) - assertThat(session.sequence).isEqualTo(session.timestamp!!.time) - } - - @Test - fun `recordNonTerminatingUnhandledError does not change terminal sessions`() { - for (state in Session.State.entries.filter { it != Session.State.Ok }) { - val session = okSession() - session.update(state, null, false) - val before = session.clone() - - val updated = session.recordNonTerminatingUnhandledError() - - assertThat(updated).isFalse() - assertThat(session.status).isEqualTo(before.status) - assertThat(session.hasNonTerminatingUnhandledError()) - .isEqualTo(before.hasNonTerminatingUnhandledError()) - assertThat(session.errorCount()).isEqualTo(before.errorCount()) - assertThat(session.init).isEqualTo(before.init) - assertThat(session.timestamp).isEqualTo(before.timestamp) - assertThat(session.sequence).isEqualTo(before.sequence) - } - } - - @Test - fun `end without a non-terminating unhandled error finalizes as Exited`() { - val session = okSession() - - session.end() - - assertThat(session.status).isEqualTo(Session.State.Exited) - } - - @Test - fun `end with a non-terminating unhandled error finalizes as Unhandled`() { - val session = okSession() - assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - - session.recordNonTerminatingUnhandledError() - session.end() - - assertThat(session.status).isEqualTo(Session.State.Unhandled) - assertThat(session.hasNonTerminatingUnhandledError()).isTrue() - } - - @Test - fun `end with a non-terminating unhandled error keeps Abnormal as Abnormal`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - session.update(Session.State.Abnormal, null, false, "anr") - - session.end() - - assertThat(session.status).isEqualTo(Session.State.Abnormal) - assertThat(session.hasNonTerminatingUnhandledError()).isTrue() - } - - @Test - fun `end with a non-terminating unhandled error keeps Crashed as Crashed`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - session.update(Session.State.Crashed, null, false) - - session.end() - - assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - } - - @Test - fun `updating to Crashed clears a non-terminating unhandled error and end stays Crashed`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - - session.update(Session.State.Crashed, null, true) - session.end() - - assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - } - - @Test - fun `clone preserves a non-terminating unhandled error`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - - val clone = session.clone() - - assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() - } -} From b24a47eac3beae26f3e27b301ab5222966c77f5d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:53:06 +0200 Subject: [PATCH 10/48] docs(session): describe hasNonTerminatingUnhandledError on the field It was the only field in Session without the one-line comment the surrounding declarations all carry. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 8375184e4ae..60987817218 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -67,6 +67,7 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; + /** whether an unhandled error occurred that did not terminate the process */ private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ From aaa4154e111b317f7af94c79a80d948911da644c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:54:34 +0200 Subject: [PATCH 11/48] docs(session): capitalise the hasNonTerminatingUnhandledError comment Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 60987817218..a4579c1c649 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -67,7 +67,7 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; - /** whether an unhandled error occurred that did not terminate the process */ + /** Whether an unhandled error occurred that did not terminate the process */ private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ From f236516f93fcb8105b9d92da115e3371fa6b5a83 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:58:23 +0200 Subject: [PATCH 12/48] ref(session): drop the private canonical constructor hasNonTerminatingUnhandledError is not final - recordNonTerminating UnhandledError and update() both write it - so setting it through a constructor established no invariant that a plain assignment does not. Both call sites are inside Session, so clone() and the deserializer can assign the field directly, which is what the deserializer already does for unknown. Removes the 15-parameter overload and the javadoc that existed to justify it. The public constructor is unchanged, so sentry.api is too. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 79 ++++++--------------- 1 file changed, 20 insertions(+), 59 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index a4579c1c649..1434e0ab6a5 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -91,46 +91,6 @@ public Session( final @Nullable String environment, final @NotNull String release, final @Nullable String abnormalMechanism) { - this( - status, - started, - timestamp, - errorCount, - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism, - false); - } - - /** - * Canonical constructor. Kept private so {@code hasNonTerminatingUnhandledError} stays off the - * public API: it is internal bookkeeping that only {@link #clone()} and {@link Deserializer} need - * to restore, and a public overload carrying it would let callers fabricate a session claiming an - * unhandled error that was never counted. - */ - private Session( - final @NotNull State status, - final @NotNull Date started, - final @Nullable Date timestamp, - final int errorCount, - final @Nullable String distinctId, - final @Nullable String sessionId, - final @Nullable Boolean init, - final @Nullable Long sequence, - final @Nullable Double duration, - final @Nullable String ipAddress, - final @Nullable String userAgent, - final @Nullable String environment, - final @NotNull String release, - final @Nullable String abnormalMechanism, - final boolean hasNonTerminatingUnhandledError) { this.status = status; this.started = started; this.timestamp = timestamp; @@ -145,7 +105,6 @@ private Session( this.environment = environment; this.release = release; this.abnormalMechanism = abnormalMechanism; - this.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; } public Session( @@ -403,22 +362,24 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { */ @SuppressWarnings("MissingOverride") public @NotNull Session clone() { - return new Session( - status, - started, - timestamp, - errorCount.get(), - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism, - hasNonTerminatingUnhandledError); + final @NotNull Session session = + new Session( + status, + started, + timestamp, + errorCount.get(), + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism); + session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; + return session; } // JsonSerializable @@ -638,8 +599,8 @@ public static final class Deserializer implements JsonDeserializer { userAgent, environment, release, - abnormalMechanism, - hasNonTerminatingUnhandledError); + abnormalMechanism); + session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; session.setUnknown(unknown); reader.endObject(); return session; From 2c1d4629f77cd5f1c43a25fc5f3d55b9952bfc1b Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 14:06:33 +0200 Subject: [PATCH 13/48] test(session): use Truth in the new session serialization tests Also swaps assertFalse(serialize(...).contains(...)) for Truth's doesNotContain, which reports the offending json on failure instead of just "expected false". The two new PreviousSessionFinalizerTest cases are left on Mockito argThat, which needs a Boolean predicate rather than an assertion. Co-authored-by: Cursor --- .../io/sentry/protocol/SessionSerializationTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index 663bcae0f06..ad19b90bb4e 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -1,5 +1,6 @@ package io.sentry.protocol +import com.google.common.truth.Truth.assertThat import io.sentry.DateUtils import io.sentry.FileFromResources import io.sentry.ILogger @@ -10,7 +11,6 @@ import io.sentry.Session import java.io.StringReader import java.io.StringWriter import kotlin.test.assertEquals -import kotlin.test.assertFalse import org.junit.Test import org.mockito.kotlin.mock @@ -59,19 +59,19 @@ class SessionSerializationTest { val session = Session(null, null, "environment", "release") session.recordNonTerminatingUnhandledError() session.end() - assertEquals(Session.State.Unhandled, session.status) + assertThat(session.status).isEqualTo(Session.State.Unhandled) val deserialized = deserialize(serialize(session)) - assertEquals(Session.State.Unhandled, deserialized.status) - assertEquals(true, deserialized.hasNonTerminatingUnhandledError()) + assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) + assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() } @Test fun `non-terminating flag is omitted when unset`() { val session = Session(null, null, "environment", "release") - assertFalse(serialize(session).contains("non_terminating_unhandled_error")) + assertThat(serialize(session)).doesNotContain("non_terminating_unhandled_error") } // Helper From d9def2ef666cd7f69c0c14644863a3f01008be94 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:25:04 +0200 Subject: [PATCH 14/48] 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 b01ffbd0ed9..cbe60812417 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 618de655478..62cb9d70dc4 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 dda06ee7e63..d2ab0c1a7b4 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 15/48] 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 62cb9d70dc4..3c991134e4c 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 d2ab0c1a7b4..00102fdf4bd 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 16/48] 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 00102fdf4bd..01ad1463133 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 17/48] 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 3c991134e4c..9d2ed035614 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 18/48] 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 9d2ed035614..0ede8f87954 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 19/48] 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 0ede8f87954..c9f663670a2 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 20/48] 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 c9f663670a2..7eddb349494 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 21/48] 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 7eddb349494..a229a86ce48 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 01ad1463133..69a51e23096 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 22/48] 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 a229a86ce48..e64f3fb0e93 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 69a51e23096..8450f1651ad 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 b28cadff71bf2cee9cb8db4549fd3a0f7341b0ab Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 14:01:09 +0200 Subject: [PATCH 23/48] test(session): cover the unhandled flag through the previous-session recovery paths Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index dda06ee7e63..c80117aa99b 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -346,6 +346,36 @@ 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() @@ -400,6 +430,29 @@ 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 d3af8adc89e5e613706684e325a82cfe8657c26d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 14:14:27 +0200 Subject: [PATCH 24/48] test(session): cover the unhandled session shape with a JSON fixture Co-authored-by: Cursor --- .../protocol/SessionSerializationTest.kt | 52 ++++++++++++++----- .../resources/json/session_unhandled.json | 18 +++++++ 2 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 sentry/src/test/resources/json/session_unhandled.json diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index ad19b90bb4e..ebe108b2fe6 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -35,6 +35,34 @@ class SessionSerializationTest { "b2d0224b-4b1f-49db-94c9-fd4a439b3ef5", "anr_foreground", ) + + /** + * An unhandled session cannot be built by mutating [getSut]: the flag is only reachable through + * [Session.recordNonTerminatingUnhandledError], which no-ops unless the session is still `Ok`, + * and a crash would clear it again. Ending on a fixed timestamp keeps `seq` and `duration` + * deterministic. + */ + fun getUnhandledSut() = + Session( + Session.State.Ok, + DateUtils.getDateTime("1945-06-16T06:36:49.000Z"), + DateUtils.getDateTime("1970-04-21T09:32:21.000Z"), + 9001, + "631693c2-3d61-4a93-8fd1-89817426ba5a", + "3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17", + true, + 4, + 5.5, + "5a174e69-a297-4ba4-b6e1-2244a8299ec8", + "790da4ae-50ca-48a2-98f6-9b7f4e05a8c3", + "d732be55-b57e-48ec-afe6-b0040c7f93de", + "b2d0224b-4b1f-49db-94c9-fd4a439b3ef5", + null, + ) + .apply { + recordNonTerminatingUnhandledError() + end(DateUtils.getDateTime("1970-04-21T09:32:21.000Z")) + } } private val fixture = Fixture() @@ -55,23 +83,19 @@ class SessionSerializationTest { } @Test - fun `serialize and deserialize round-trips Unhandled status and non-terminating flag`() { - val session = Session(null, null, "environment", "release") - session.recordNonTerminatingUnhandledError() - session.end() - assertThat(session.status).isEqualTo(Session.State.Unhandled) - - val deserialized = deserialize(serialize(session)) - - assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) - assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() + fun serializeUnhandled() { + val expected = sanitizedFile("json/session_unhandled.json") + val actual = serialize(fixture.getUnhandledSut()) + assertThat(actual).isEqualTo(expected) } @Test - fun `non-terminating flag is omitted when unset`() { - val session = Session(null, null, "environment", "release") - - assertThat(serialize(session)).doesNotContain("non_terminating_unhandled_error") + fun deserializeUnhandled() { + val expectedJson = sanitizedFile("json/session_unhandled.json") + val actual = deserialize(expectedJson) + assertThat(actual.status).isEqualTo(Session.State.Unhandled) + assertThat(actual.hasNonTerminatingUnhandledError()).isTrue() + assertThat(serialize(actual)).isEqualTo(expectedJson) } // Helper diff --git a/sentry/src/test/resources/json/session_unhandled.json b/sentry/src/test/resources/json/session_unhandled.json new file mode 100644 index 00000000000..cd822fee25b --- /dev/null +++ b/sentry/src/test/resources/json/session_unhandled.json @@ -0,0 +1,18 @@ +{ + "sid": "3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17", + "did": "631693c2-3d61-4a93-8fd1-89817426ba5a", + "started": "1945-06-16T06:36:49.000Z", + "status": "unhandled", + "seq": 9538341000, + "errors": 9002, + "duration": 7.84090532E8, + "timestamp": "1970-04-21T09:32:21.000Z", + "non_terminating_unhandled_error": true, + "attrs": + { + "release": "b2d0224b-4b1f-49db-94c9-fd4a439b3ef5", + "environment": "d732be55-b57e-48ec-afe6-b0040c7f93de", + "ip_address": "5a174e69-a297-4ba4-b6e1-2244a8299ec8", + "user_agent": "790da4ae-50ca-48a2-98f6-9b7f4e05a8c3" + } +} From 2e8a06f7e93a1cd5f5249715eb17b40285317023 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 14:57:09 +0200 Subject: [PATCH 25/48] 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 e64f3fb0e93..3bc05fa2b13 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 26/48] 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 3bc05fa2b13..1043550a9fb 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 27/48] 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 1043550a9fb..3aee5220637 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 69a51e23096..1fce4763fb4 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 d52eb4a11b3ed0631b53782f51406630c37bfd58 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 24 Aug 2026 10:00:47 +0200 Subject: [PATCH 28/48] perf(android): Use generated manifest metadata (#5976) * perf(android): Use build-time manifest metadata Allow the Android Gradle plugin to provide authoritative manifest metadata so SDK initialization can skip PackageManager and Bundle unparceling. Read the injected map directly to avoid conversion overhead. Refs JAVA-531 Co-Authored-By: Codex * changelog * ref(android): Store metadata in manifest reader Use ManifestMetadataReader directly as the Gradle plugin injection target and remove the dedicated holder class. Co-Authored-By: OpenAI Codex * docs: Generalize performance changelog entry Remove the device-specific benchmark percentage from the release note. Co-Authored-By: Codex * docs: Update replacement PR changelog link Point the performance entry at the replacement pull request. Co-Authored-By: Codex --------- Co-authored-by: Codex From 8a95759d564a18b3553fca0560ec9cc65431b020 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:22:37 +0200 Subject: [PATCH 29/48] feat(core): Add Unhandled session state and pending-unhandled marker Adds Session.State.Unhandled from the session protocol, plus a pending-unhandled marker that survives serialization. A session carrying the marker finalizes as Unhandled instead of Exited on end(), while Crashed and Abnormal keep taking precedence. Co-authored-by: Cursor --- sentry/api/sentry.api | 5 + sentry/src/main/java/io/sentry/Session.java | 97 +++++++++--- .../io/sentry/PreviousSessionFinalizerTest.kt | 41 +++++ sentry/src/test/java/io/sentry/SessionTest.kt | 144 ++++++++++++++++++ .../protocol/SessionSerializationTest.kt | 13 ++ 5 files changed, 283 insertions(+), 17 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/SessionTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 334b617fb7d..f9b36461007 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4304,9 +4304,12 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public fun getTimestamp ()Ljava/util/Date; public fun getUnknown ()Ljava/util/Map; public fun getUserAgent ()Ljava/lang/String; + public fun isPendingUnhandled ()Z public fun isTerminated ()Z + public fun markPendingUnhandled ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V + public fun setPendingUnhandled (Z)V public fun setUnknown (Ljava/util/Map;)V public fun update (Lio/sentry/Session$State;Ljava/lang/String;Z)Z public fun update (Lio/sentry/Session$State;Ljava/lang/String;ZLjava/lang/String;)Z @@ -4327,6 +4330,7 @@ public final class io/sentry/Session$JsonKeys { public static final field ERRORS Ljava/lang/String; public static final field INIT Ljava/lang/String; public static final field IP_ADDRESS Ljava/lang/String; + public static final field PENDING_UNHANDLED Ljava/lang/String; public static final field RELEASE Ljava/lang/String; public static final field SEQ Ljava/lang/String; public static final field SID Ljava/lang/String; @@ -4342,6 +4346,7 @@ public final class io/sentry/Session$State : java/lang/Enum { public static final field Crashed Lio/sentry/Session$State; public static final field Exited Lio/sentry/Session$State; public static final field Ok Lio/sentry/Session$State; + public static final field Unhandled Lio/sentry/Session$State; public static fun valueOf (Ljava/lang/String;)Lio/sentry/Session$State; public static fun values ()[Lio/sentry/Session$State; } diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 2fdfffb35d9..a1334ea45f5 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -21,7 +21,8 @@ public enum State { Ok, Exited, Crashed, - Abnormal + Abnormal, + Unhandled } /** started timestamp */ @@ -66,6 +67,14 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; + /** + * Whether the session experienced an unhandled (but non-terminal) exception. Kept locally and + * persisted with the session, but never sent as a status while the session is alive. On end() the + * session is finalized as {@link State#Unhandled} instead of {@link State#Exited} unless a crash + * escalated it to {@link State#Crashed}. + */ + private boolean pendingUnhandled; + /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -188,6 +197,41 @@ public int errorCount() { return abnormalMechanism; } + /** + * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized + * yet. + */ + @ApiStatus.Internal + public boolean isPendingUnhandled() { + return pendingUnhandled; + } + + /** + * Marks the session as having experienced an unhandled (non-terminal) exception without ending + * it. On {@link #end()} the session will be finalized as {@link State#Unhandled} unless a crash + * escalated it to {@link State#Crashed} first. + */ + @ApiStatus.Internal + public void setPendingUnhandled(final boolean pendingUnhandled) { + this.pendingUnhandled = pendingUnhandled; + } + + /** Marks an active session as having experienced an unhandled non-terminal exception. */ + @ApiStatus.Internal + public boolean markPendingUnhandled() { + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + if (status != State.Ok) { + return false; + } + pendingUnhandled = true; + errorCount.incrementAndGet(); + init = null; + timestamp = DateUtils.getCurrentDateTime(); + sequence = getSequenceTimestamp(timestamp); + return true; + } + } + @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getTimestamp() { return timestamp; @@ -209,7 +253,9 @@ public void end(final @Nullable Date timestamp) { // at this state it might be Crashed already, so we don't check for it. if (status == State.Ok) { - status = State.Exited; + // a session that experienced an unhandled (but non-terminal) exception is finalized as + // Unhandled rather than Exited. + status = pendingUnhandled ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -262,6 +308,10 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; + // a real crash takes precedence over a pending unhandled (non-terminal) exception. + if (status == State.Crashed) { + pendingUnhandled = false; + } sessionHasBeenUpdated = true; } @@ -318,21 +368,24 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { */ @SuppressWarnings("MissingOverride") public @NotNull Session clone() { - return new Session( - status, - started, - timestamp, - errorCount.get(), - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism); + final Session session = + new Session( + status, + started, + timestamp, + errorCount.get(), + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism); + session.setPendingUnhandled(pendingUnhandled); + return session; } // JsonSerializable @@ -354,6 +407,7 @@ public static final class JsonKeys { public static final String IP_ADDRESS = "ip_address"; public static final String USER_AGENT = "user_agent"; public static final String ABNORMAL_MECHANISM = "abnormal_mechanism"; + public static final String PENDING_UNHANDLED = "pending_unhandled"; } @Override @@ -384,6 +438,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (abnormalMechanism != null) { writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism); } + if (pendingUnhandled) { + writer.name(JsonKeys.PENDING_UNHANDLED).value(pendingUnhandled); + } writer.name(JsonKeys.ATTRS); writer.beginObject(); writer.name(JsonKeys.RELEASE).value(logger, release); @@ -440,6 +497,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; + boolean pendingUnhandled = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -483,6 +541,10 @@ public static final class Deserializer implements JsonDeserializer { case JsonKeys.ABNORMAL_MECHANISM: abnormalMechanism = reader.nextStringOrNull(); break; + case JsonKeys.PENDING_UNHANDLED: + final Boolean pendingUnhandledValue = reader.nextBooleanOrNull(); + pendingUnhandled = pendingUnhandledValue != null && pendingUnhandledValue; + break; case JsonKeys.ATTRS: reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -542,6 +604,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); + session.setPendingUnhandled(pendingUnhandled); session.setUnknown(unknown); reader.endObject(); return session; diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index 4b433ffb3e1..36b934b4906 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -200,6 +200,47 @@ class PreviousSessionFinalizerTest { ) } + @Test + fun `if previous session has pending unhandled and no crash marker, finalizes as unhandled`() { + val finalizer = + fixture.getSut( + tmpDir, + session = + Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + ) + finalizer.run() + + verify(fixture.scopes) + .captureEnvelope( + argThat { + val session = fixture.sessionFromEnvelope(this) + session.release == "io.sentry.sample@1.0" && + session.status == Session.State.Unhandled && + session.isPendingUnhandled + } + ) + } + + @Test + fun `if previous session has pending unhandled but a native crash marker exists, finalizes as crashed`() { + val finalizer = + fixture.getSut( + tmpDir, + session = + Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), + ) + finalizer.run() + + verify(fixture.scopes) + .captureEnvelope( + argThat { + val session = fixture.sessionFromEnvelope(this) + session.release == "io.sentry.sample@1.0" && session.status == Crashed + } + ) + } + @Test fun `if previous session file exists, deletes previous session file`() { val finalizer = fixture.getSut(tmpDir, sessionFileExists = true) diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt new file mode 100644 index 00000000000..77a16b8625a --- /dev/null +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -0,0 +1,144 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import java.io.StringReader +import java.io.StringWriter +import kotlin.test.Test +import org.mockito.kotlin.mock + +class SessionTest { + + private fun okSession(): Session = Session(null, null, "environment", "release") + + @Test + fun `markPendingUnhandled atomically updates an Ok session`() { + val session = okSession() + val initialTimestamp = session.timestamp + + val updated = session.markPendingUnhandled() + + assertThat(updated).isTrue() + assertThat(session.status).isEqualTo(Session.State.Ok) + assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.errorCount()).isEqualTo(1) + assertThat(session.init).isNull() + assertThat(session.timestamp).isNotNull() + assertThat(session.timestamp!!.time).isAtLeast(initialTimestamp!!.time) + assertThat(session.sequence).isEqualTo(session.timestamp!!.time) + } + + @Test + fun `markPendingUnhandled does not change terminal sessions`() { + for (state in Session.State.entries.filter { it != Session.State.Ok }) { + val session = okSession() + session.update(state, null, false) + val before = session.clone() + + val updated = session.markPendingUnhandled() + + assertThat(updated).isFalse() + assertThat(session.status).isEqualTo(before.status) + assertThat(session.isPendingUnhandled).isEqualTo(before.isPendingUnhandled) + assertThat(session.errorCount()).isEqualTo(before.errorCount()) + assertThat(session.init).isEqualTo(before.init) + assertThat(session.timestamp).isEqualTo(before.timestamp) + assertThat(session.sequence).isEqualTo(before.sequence) + } + } + + @Test + fun `end without pending unhandled finalizes as Exited`() { + val session = okSession() + + session.end() + + assertThat(session.status).isEqualTo(Session.State.Exited) + } + + @Test + fun `end with pending unhandled finalizes as Unhandled`() { + val session = okSession() + assertThat(session.isPendingUnhandled).isFalse() + + session.setPendingUnhandled(true) + session.end() + + assertThat(session.status).isEqualTo(Session.State.Unhandled) + assertThat(session.isPendingUnhandled).isTrue() + } + + @Test + fun `end with pending unhandled keeps Abnormal as Abnormal`() { + val session = okSession() + session.setPendingUnhandled(true) + session.update(Session.State.Abnormal, null, false, "anr") + + session.end() + + assertThat(session.status).isEqualTo(Session.State.Abnormal) + assertThat(session.isPendingUnhandled).isTrue() + } + + @Test + fun `end with pending unhandled keeps Crashed as Crashed`() { + val session = okSession() + session.setPendingUnhandled(true) + session.update(Session.State.Crashed, null, false) + + session.end() + + assertThat(session.status).isEqualTo(Session.State.Crashed) + assertThat(session.isPendingUnhandled).isFalse() + } + + @Test + fun `updating to Crashed clears pending unhandled and end stays Crashed`() { + val session = okSession() + session.setPendingUnhandled(true) + + session.update(Session.State.Crashed, null, true) + session.end() + + assertThat(session.status).isEqualTo(Session.State.Crashed) + assertThat(session.isPendingUnhandled).isFalse() + } + + @Test + fun `clone preserves pending unhandled`() { + val session = okSession() + session.setPendingUnhandled(true) + + val clone = session.clone() + + assertThat(clone.isPendingUnhandled).isTrue() + } + + @Test + fun `serialization round-trips pending unhandled and Unhandled status`() { + val logger = mock() + val session = okSession() + session.setPendingUnhandled(true) + session.end() + assertThat(session.status).isEqualTo(Session.State.Unhandled) + + val writer = StringWriter() + session.serialize(JsonObjectWriter(writer, 100), logger) + + val deserialized = + Session.Deserializer().deserialize(JsonObjectReader(StringReader(writer.toString())), logger) + + assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) + assertThat(deserialized.isPendingUnhandled).isTrue() + } + + @Test + fun `pending unhandled defaults to false and is not serialized when unset`() { + val logger = mock() + val session = okSession() + + val writer = StringWriter() + session.serialize(JsonObjectWriter(writer, 100), logger) + + assertThat(writer.toString()).doesNotContain("pending_unhandled") + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index 644f57a0232..1280680e7f8 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -53,6 +53,19 @@ class SessionSerializationTest { assertEquals(expectedJson, actualJson) } + @Test + fun `serialize and deserialize round-trips Unhandled status and pending unhandled flag`() { + val session = Session(null, null, "environment", "release") + session.setPendingUnhandled(true) + session.end() + assertEquals(Session.State.Unhandled, session.status) + + val deserialized = deserialize(serialize(session)) + + assertEquals(Session.State.Unhandled, deserialized.status) + assertEquals(true, deserialized.isPendingUnhandled) + } + // Helper private fun sanitizedFile(path: String): String = From 0a6e2c4a16a73969185d45943f951a021f823839 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 11:37:48 +0200 Subject: [PATCH 30/48] ref: rename pendingUnhandled to nonTerminatingUnhandledError "Unhandled" alone is ambiguous: a native crash is also an unhandled error, it just terminates the process and so ends the session as crashed rather than unhandled. Name the flag after the property that actually distinguishes the two and match the vocabulary of captureEnvelopeNonTerminating. Also clarify that the setter only restores the flag when rebuilding a session and must not be used to mutate a live one. Co-authored-by: Cursor --- sentry/api/sentry.api | 8 +-- sentry/src/main/java/io/sentry/Session.java | 71 +++++++++++-------- .../io/sentry/PreviousSessionFinalizerTest.kt | 14 ++-- sentry/src/test/java/io/sentry/SessionTest.kt | 57 +++++++-------- .../protocol/SessionSerializationTest.kt | 6 +- 5 files changed, 87 insertions(+), 69 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index f9b36461007..053b6675cbe 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4304,12 +4304,12 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public fun getTimestamp ()Ljava/util/Date; public fun getUnknown ()Ljava/util/Map; public fun getUserAgent ()Ljava/lang/String; - public fun isPendingUnhandled ()Z + public fun hasNonTerminatingUnhandledError ()Z public fun isTerminated ()Z - public fun markPendingUnhandled ()Z + public fun recordNonTerminatingUnhandledError ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V - public fun setPendingUnhandled (Z)V + public fun setNonTerminatingUnhandledError (Z)V public fun setUnknown (Ljava/util/Map;)V public fun update (Lio/sentry/Session$State;Ljava/lang/String;Z)Z public fun update (Lio/sentry/Session$State;Ljava/lang/String;ZLjava/lang/String;)Z @@ -4330,7 +4330,7 @@ public final class io/sentry/Session$JsonKeys { public static final field ERRORS Ljava/lang/String; public static final field INIT Ljava/lang/String; public static final field IP_ADDRESS Ljava/lang/String; - public static final field PENDING_UNHANDLED Ljava/lang/String; + public static final field NON_TERMINATING_UNHANDLED_ERROR Ljava/lang/String; public static final field RELEASE Ljava/lang/String; public static final field SEQ Ljava/lang/String; public static final field SID Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index a1334ea45f5..33e9de42894 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -68,12 +68,15 @@ public enum State { private @Nullable String abnormalMechanism; /** - * Whether the session experienced an unhandled (but non-terminal) exception. Kept locally and - * persisted with the session, but never sent as a status while the session is alive. On end() the - * session is finalized as {@link State#Unhandled} instead of {@link State#Exited} unless a crash - * escalated it to {@link State#Crashed}. + * Whether the session experienced an unhandled error that did not terminate the process, + * e.g. an unhandled Flutter exception. A native crash is also unhandled, but it kills the process + * and therefore ends the session as {@link State#Crashed} instead. + * + *

Kept locally and persisted with the session, but never sent as a status while the session is + * alive. On end() the session is finalized as {@link State#Unhandled} instead of {@link + * State#Exited}, unless a crash escalated it to {@link State#Crashed}. */ - private boolean pendingUnhandled; + private boolean nonTerminatingUnhandledError; /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -198,32 +201,41 @@ public int errorCount() { } /** - * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized - * yet. + * Whether the session experienced an unhandled error that did not terminate the process, and so + * finalizes as {@link State#Unhandled} rather than {@link State#Exited}. */ @ApiStatus.Internal - public boolean isPendingUnhandled() { - return pendingUnhandled; + public boolean hasNonTerminatingUnhandledError() { + return nonTerminatingUnhandledError; } /** - * Marks the session as having experienced an unhandled (non-terminal) exception without ending - * it. On {@link #end()} the session will be finalized as {@link State#Unhandled} unless a crash - * escalated it to {@link State#Crashed} first. + * Restores the flag when rebuilding a session, i.e. from {@link #clone()} or the deserializer. + * + *

Not for use on a live session: unlike {@link #recordNonTerminatingUnhandledError()} this + * neither counts the error nor advances the session's sequence, so a session mutated through this + * setter would be sent as an out-of-date update. */ @ApiStatus.Internal - public void setPendingUnhandled(final boolean pendingUnhandled) { - this.pendingUnhandled = pendingUnhandled; + public void setNonTerminatingUnhandledError(final boolean nonTerminatingUnhandledError) { + this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; } - /** Marks an active session as having experienced an unhandled non-terminal exception. */ + /** + * Records that an active session experienced an unhandled error which did not terminate the + * process, counting the error and advancing the session's sequence without ending it. On {@link + * #end()} the session is finalized as {@link State#Unhandled} unless a crash escalated it to + * {@link State#Crashed} first. + * + * @return whether the session was updated, i.e. false if it had already reached a terminal state + */ @ApiStatus.Internal - public boolean markPendingUnhandled() { + public boolean recordNonTerminatingUnhandledError() { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { if (status != State.Ok) { return false; } - pendingUnhandled = true; + nonTerminatingUnhandledError = true; errorCount.incrementAndGet(); init = null; timestamp = DateUtils.getCurrentDateTime(); @@ -255,7 +267,7 @@ public void end(final @Nullable Date timestamp) { if (status == State.Ok) { // a session that experienced an unhandled (but non-terminal) exception is finalized as // Unhandled rather than Exited. - status = pendingUnhandled ? State.Unhandled : State.Exited; + status = nonTerminatingUnhandledError ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -308,9 +320,9 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; - // a real crash takes precedence over a pending unhandled (non-terminal) exception. + // a crash terminates the process, so it takes precedence over a non-terminating one. if (status == State.Crashed) { - pendingUnhandled = false; + nonTerminatingUnhandledError = false; } sessionHasBeenUpdated = true; } @@ -384,7 +396,7 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { environment, release, abnormalMechanism); - session.setPendingUnhandled(pendingUnhandled); + session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); return session; } @@ -407,7 +419,7 @@ public static final class JsonKeys { public static final String IP_ADDRESS = "ip_address"; public static final String USER_AGENT = "user_agent"; public static final String ABNORMAL_MECHANISM = "abnormal_mechanism"; - public static final String PENDING_UNHANDLED = "pending_unhandled"; + public static final String NON_TERMINATING_UNHANDLED_ERROR = "non_terminating_unhandled_error"; } @Override @@ -438,8 +450,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (abnormalMechanism != null) { writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism); } - if (pendingUnhandled) { - writer.name(JsonKeys.PENDING_UNHANDLED).value(pendingUnhandled); + if (nonTerminatingUnhandledError) { + writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(nonTerminatingUnhandledError); } writer.name(JsonKeys.ATTRS); writer.beginObject(); @@ -497,7 +509,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; - boolean pendingUnhandled = false; + boolean nonTerminatingUnhandledError = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -541,9 +553,10 @@ public static final class Deserializer implements JsonDeserializer { case JsonKeys.ABNORMAL_MECHANISM: abnormalMechanism = reader.nextStringOrNull(); break; - case JsonKeys.PENDING_UNHANDLED: - final Boolean pendingUnhandledValue = reader.nextBooleanOrNull(); - pendingUnhandled = pendingUnhandledValue != null && pendingUnhandledValue; + case JsonKeys.NON_TERMINATING_UNHANDLED_ERROR: + final Boolean nonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); + nonTerminatingUnhandledError = + nonTerminatingUnhandledErrorValue != null && nonTerminatingUnhandledErrorValue; break; case JsonKeys.ATTRS: reader.beginObject(); @@ -604,7 +617,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); - session.setPendingUnhandled(pendingUnhandled); + session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); session.setUnknown(unknown); reader.endObject(); return session; diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index 36b934b4906..af2e67b19d9 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -201,12 +201,14 @@ class PreviousSessionFinalizerTest { } @Test - fun `if previous session has pending unhandled and no crash marker, finalizes as unhandled`() { + fun `if previous session has a non-terminating unhandled error and no crash marker, finalizes as unhandled`() { val finalizer = fixture.getSut( tmpDir, session = - Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + Session(null, null, null, "io.sentry.sample@1.0").apply { + setNonTerminatingUnhandledError(true) + }, ) finalizer.run() @@ -216,18 +218,20 @@ class PreviousSessionFinalizerTest { val session = fixture.sessionFromEnvelope(this) session.release == "io.sentry.sample@1.0" && session.status == Session.State.Unhandled && - session.isPendingUnhandled + session.hasNonTerminatingUnhandledError() } ) } @Test - fun `if previous session has pending unhandled but a native crash marker exists, finalizes as crashed`() { + fun `if previous session has a non-terminating unhandled error but a native crash marker exists, finalizes as crashed`() { val finalizer = fixture.getSut( tmpDir, session = - Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, + Session(null, null, null, "io.sentry.sample@1.0").apply { + setNonTerminatingUnhandledError(true) + }, nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), ) finalizer.run() diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index 77a16b8625a..6326179a9be 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -11,15 +11,15 @@ class SessionTest { private fun okSession(): Session = Session(null, null, "environment", "release") @Test - fun `markPendingUnhandled atomically updates an Ok session`() { + fun `recordNonTerminatingUnhandledError atomically updates an Ok session`() { val session = okSession() val initialTimestamp = session.timestamp - val updated = session.markPendingUnhandled() + val updated = session.recordNonTerminatingUnhandledError() assertThat(updated).isTrue() assertThat(session.status).isEqualTo(Session.State.Ok) - assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.hasNonTerminatingUnhandledError()).isTrue() assertThat(session.errorCount()).isEqualTo(1) assertThat(session.init).isNull() assertThat(session.timestamp).isNotNull() @@ -28,17 +28,18 @@ class SessionTest { } @Test - fun `markPendingUnhandled does not change terminal sessions`() { + fun `recordNonTerminatingUnhandledError does not change terminal sessions`() { for (state in Session.State.entries.filter { it != Session.State.Ok }) { val session = okSession() session.update(state, null, false) val before = session.clone() - val updated = session.markPendingUnhandled() + val updated = session.recordNonTerminatingUnhandledError() assertThat(updated).isFalse() assertThat(session.status).isEqualTo(before.status) - assertThat(session.isPendingUnhandled).isEqualTo(before.isPendingUnhandled) + assertThat(session.hasNonTerminatingUnhandledError()) + .isEqualTo(before.hasNonTerminatingUnhandledError()) assertThat(session.errorCount()).isEqualTo(before.errorCount()) assertThat(session.init).isEqualTo(before.init) assertThat(session.timestamp).isEqualTo(before.timestamp) @@ -47,7 +48,7 @@ class SessionTest { } @Test - fun `end without pending unhandled finalizes as Exited`() { + fun `end without a non-terminating unhandled error finalizes as Exited`() { val session = okSession() session.end() @@ -56,68 +57,68 @@ class SessionTest { } @Test - fun `end with pending unhandled finalizes as Unhandled`() { + fun `end with a non-terminating unhandled error finalizes as Unhandled`() { val session = okSession() - assertThat(session.isPendingUnhandled).isFalse() + assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) - assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `end with pending unhandled keeps Abnormal as Abnormal`() { + fun `end with a non-terminating unhandled error keeps Abnormal as Abnormal`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.update(Session.State.Abnormal, null, false, "anr") session.end() assertThat(session.status).isEqualTo(Session.State.Abnormal) - assertThat(session.isPendingUnhandled).isTrue() + assertThat(session.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `end with pending unhandled keeps Crashed as Crashed`() { + fun `end with a non-terminating unhandled error keeps Crashed as Crashed`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.update(Session.State.Crashed, null, false) session.end() assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.isPendingUnhandled).isFalse() + assertThat(session.hasNonTerminatingUnhandledError()).isFalse() } @Test - fun `updating to Crashed clears pending unhandled and end stays Crashed`() { + fun `updating to Crashed clears a non-terminating unhandled error and end stays Crashed`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.update(Session.State.Crashed, null, true) session.end() assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.isPendingUnhandled).isFalse() + assertThat(session.hasNonTerminatingUnhandledError()).isFalse() } @Test - fun `clone preserves pending unhandled`() { + fun `clone preserves a non-terminating unhandled error`() { val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) val clone = session.clone() - assertThat(clone.isPendingUnhandled).isTrue() + assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `serialization round-trips pending unhandled and Unhandled status`() { + fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { val logger = mock() val session = okSession() - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) @@ -128,17 +129,17 @@ class SessionTest { Session.Deserializer().deserialize(JsonObjectReader(StringReader(writer.toString())), logger) assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) - assertThat(deserialized.isPendingUnhandled).isTrue() + assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() } @Test - fun `pending unhandled defaults to false and is not serialized when unset`() { + fun `a non-terminating unhandled error defaults to false and is not serialized when unset`() { val logger = mock() val session = okSession() val writer = StringWriter() session.serialize(JsonObjectWriter(writer, 100), logger) - assertThat(writer.toString()).doesNotContain("pending_unhandled") + assertThat(writer.toString()).doesNotContain("non_terminating_unhandled_error") } } diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index 1280680e7f8..ed85351f83e 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -54,16 +54,16 @@ class SessionSerializationTest { } @Test - fun `serialize and deserialize round-trips Unhandled status and pending unhandled flag`() { + fun `serialize and deserialize round-trips Unhandled status and non-terminating flag`() { val session = Session(null, null, "environment", "release") - session.setPendingUnhandled(true) + session.setNonTerminatingUnhandledError(true) session.end() assertEquals(Session.State.Unhandled, session.status) val deserialized = deserialize(serialize(session)) assertEquals(Session.State.Unhandled, deserialized.status) - assertEquals(true, deserialized.isPendingUnhandled) + assertEquals(true, deserialized.hasNonTerminatingUnhandledError()) } // Helper From 914f836452e6dc957fa203235b79a4f02a8c6f09 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 12:00:56 +0200 Subject: [PATCH 31/48] ref: drop public setter for the non-terminating unhandled error flag clone() and Session.Deserializer are both inside Session, so they can restore the field directly. Dropping the setter keeps it off the public API surface and makes it impossible to flip the flag on a live session without counting the error and advancing the sequence. Co-authored-by: Cursor --- sentry/api/sentry.api | 1 - sentry/src/main/java/io/sentry/Session.java | 16 ++-------------- .../io/sentry/PreviousSessionFinalizerTest.kt | 4 ++-- sentry/src/test/java/io/sentry/SessionTest.kt | 12 ++++++------ .../sentry/protocol/SessionSerializationTest.kt | 2 +- 5 files changed, 11 insertions(+), 24 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 053b6675cbe..267b702ecae 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4309,7 +4309,6 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public fun recordNonTerminatingUnhandledError ()Z public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setInitAsTrue ()V - public fun setNonTerminatingUnhandledError (Z)V public fun setUnknown (Ljava/util/Map;)V public fun update (Lio/sentry/Session$State;Ljava/lang/String;Z)Z public fun update (Lio/sentry/Session$State;Ljava/lang/String;ZLjava/lang/String;)Z diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 33e9de42894..1fde656ab05 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -209,18 +209,6 @@ public boolean hasNonTerminatingUnhandledError() { return nonTerminatingUnhandledError; } - /** - * Restores the flag when rebuilding a session, i.e. from {@link #clone()} or the deserializer. - * - *

Not for use on a live session: unlike {@link #recordNonTerminatingUnhandledError()} this - * neither counts the error nor advances the session's sequence, so a session mutated through this - * setter would be sent as an out-of-date update. - */ - @ApiStatus.Internal - public void setNonTerminatingUnhandledError(final boolean nonTerminatingUnhandledError) { - this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; - } - /** * Records that an active session experienced an unhandled error which did not terminate the * process, counting the error and advancing the session's sequence without ending it. On {@link @@ -396,7 +384,7 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { environment, release, abnormalMechanism); - session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); + session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; return session; } @@ -617,7 +605,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism); - session.setNonTerminatingUnhandledError(nonTerminatingUnhandledError); + session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; session.setUnknown(unknown); reader.endObject(); return session; diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index af2e67b19d9..3a730e971bf 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -207,7 +207,7 @@ class PreviousSessionFinalizerTest { tmpDir, session = Session(null, null, null, "io.sentry.sample@1.0").apply { - setNonTerminatingUnhandledError(true) + recordNonTerminatingUnhandledError() }, ) finalizer.run() @@ -230,7 +230,7 @@ class PreviousSessionFinalizerTest { tmpDir, session = Session(null, null, null, "io.sentry.sample@1.0").apply { - setNonTerminatingUnhandledError(true) + recordNonTerminatingUnhandledError() }, nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), ) diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index 6326179a9be..d036805e06b 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -61,7 +61,7 @@ class SessionTest { val session = okSession() assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) @@ -71,7 +71,7 @@ class SessionTest { @Test fun `end with a non-terminating unhandled error keeps Abnormal as Abnormal`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.update(Session.State.Abnormal, null, false, "anr") session.end() @@ -83,7 +83,7 @@ class SessionTest { @Test fun `end with a non-terminating unhandled error keeps Crashed as Crashed`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.update(Session.State.Crashed, null, false) session.end() @@ -95,7 +95,7 @@ class SessionTest { @Test fun `updating to Crashed clears a non-terminating unhandled error and end stays Crashed`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.update(Session.State.Crashed, null, true) session.end() @@ -107,7 +107,7 @@ class SessionTest { @Test fun `clone preserves a non-terminating unhandled error`() { val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() val clone = session.clone() @@ -118,7 +118,7 @@ class SessionTest { fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { val logger = mock() val session = okSession() - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.end() assertThat(session.status).isEqualTo(Session.State.Unhandled) diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index ed85351f83e..c88e335e1ac 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -56,7 +56,7 @@ class SessionSerializationTest { @Test fun `serialize and deserialize round-trips Unhandled status and non-terminating flag`() { val session = Session(null, null, "environment", "release") - session.setNonTerminatingUnhandledError(true) + session.recordNonTerminatingUnhandledError() session.end() assertEquals(Session.State.Unhandled, session.status) From 3445b940ed1ca03b79f35bb1ffdd745c331f2172 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 10 Aug 2026 12:10:54 +0200 Subject: [PATCH 32/48] ref: initialize the non-terminating flag through a private constructor Every other field is set at construction; the flag was the odd one out, assigned afterwards. A private canonical constructor keeps construction complete without putting the flag on the public API, which a 15-arg public overload would do. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 79 +++++++++++++++------ 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 1fde656ab05..adefa43bd8f 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -99,6 +99,46 @@ public Session( final @Nullable String environment, final @NotNull String release, final @Nullable String abnormalMechanism) { + this( + status, + started, + timestamp, + errorCount, + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism, + false); + } + + /** + * Canonical constructor. Kept private so {@code nonTerminatingUnhandledError} stays off the + * public API: it is internal bookkeeping that only {@link #clone()} and {@link Deserializer} need + * to restore, and a public overload carrying it would let callers fabricate a session claiming an + * unhandled error that was never counted. + */ + private Session( + final @NotNull State status, + final @NotNull Date started, + final @Nullable Date timestamp, + final int errorCount, + final @Nullable String distinctId, + final @Nullable String sessionId, + final @Nullable Boolean init, + final @Nullable Long sequence, + final @Nullable Double duration, + final @Nullable String ipAddress, + final @Nullable String userAgent, + final @Nullable String environment, + final @NotNull String release, + final @Nullable String abnormalMechanism, + final boolean nonTerminatingUnhandledError) { this.status = status; this.started = started; this.timestamp = timestamp; @@ -113,6 +153,7 @@ public Session( this.environment = environment; this.release = release; this.abnormalMechanism = abnormalMechanism; + this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; } public Session( @@ -368,24 +409,22 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { */ @SuppressWarnings("MissingOverride") public @NotNull Session clone() { - final Session session = - new Session( - status, - started, - timestamp, - errorCount.get(), - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism); - session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; - return session; + return new Session( + status, + started, + timestamp, + errorCount.get(), + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism, + nonTerminatingUnhandledError); } // JsonSerializable @@ -604,8 +643,8 @@ public static final class Deserializer implements JsonDeserializer { userAgent, environment, release, - abnormalMechanism); - session.nonTerminatingUnhandledError = nonTerminatingUnhandledError; + abnormalMechanism, + nonTerminatingUnhandledError); session.setUnknown(unknown); reader.endObject(); return session; From 6ca65c56e2ec7493145218223a9f5bc3a0ae11cc Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 09:44:26 +0200 Subject: [PATCH 33/48] ref: prefix the non-terminating flag field with has As a bare noun phrase the field read like it held the error rather than a boolean, most visibly where it is passed as a constructor argument. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 33 +++++++++++---------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index adefa43bd8f..ca62627a36a 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -76,7 +76,7 @@ public enum State { * alive. On end() the session is finalized as {@link State#Unhandled} instead of {@link * State#Exited}, unless a crash escalated it to {@link State#Crashed}. */ - private boolean nonTerminatingUnhandledError; + private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ private final @NotNull AutoClosableReentrantLock sessionLock = new AutoClosableReentrantLock(); @@ -118,7 +118,7 @@ public Session( } /** - * Canonical constructor. Kept private so {@code nonTerminatingUnhandledError} stays off the + * Canonical constructor. Kept private so {@code hasNonTerminatingUnhandledError} stays off the * public API: it is internal bookkeeping that only {@link #clone()} and {@link Deserializer} need * to restore, and a public overload carrying it would let callers fabricate a session claiming an * unhandled error that was never counted. @@ -138,7 +138,7 @@ private Session( final @Nullable String environment, final @NotNull String release, final @Nullable String abnormalMechanism, - final boolean nonTerminatingUnhandledError) { + final boolean hasNonTerminatingUnhandledError) { this.status = status; this.started = started; this.timestamp = timestamp; @@ -153,7 +153,7 @@ private Session( this.environment = environment; this.release = release; this.abnormalMechanism = abnormalMechanism; - this.nonTerminatingUnhandledError = nonTerminatingUnhandledError; + this.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; } public Session( @@ -247,7 +247,7 @@ public int errorCount() { */ @ApiStatus.Internal public boolean hasNonTerminatingUnhandledError() { - return nonTerminatingUnhandledError; + return hasNonTerminatingUnhandledError; } /** @@ -264,7 +264,7 @@ public boolean recordNonTerminatingUnhandledError() { if (status != State.Ok) { return false; } - nonTerminatingUnhandledError = true; + hasNonTerminatingUnhandledError = true; errorCount.incrementAndGet(); init = null; timestamp = DateUtils.getCurrentDateTime(); @@ -296,7 +296,7 @@ public void end(final @Nullable Date timestamp) { if (status == State.Ok) { // a session that experienced an unhandled (but non-terminal) exception is finalized as // Unhandled rather than Exited. - status = nonTerminatingUnhandledError ? State.Unhandled : State.Exited; + status = hasNonTerminatingUnhandledError ? State.Unhandled : State.Exited; } if (timestamp != null) { @@ -351,7 +351,7 @@ public boolean update( this.status = status; // a crash terminates the process, so it takes precedence over a non-terminating one. if (status == State.Crashed) { - nonTerminatingUnhandledError = false; + hasNonTerminatingUnhandledError = false; } sessionHasBeenUpdated = true; } @@ -424,7 +424,7 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { environment, release, abnormalMechanism, - nonTerminatingUnhandledError); + hasNonTerminatingUnhandledError); } // JsonSerializable @@ -477,8 +477,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (abnormalMechanism != null) { writer.name(JsonKeys.ABNORMAL_MECHANISM).value(logger, abnormalMechanism); } - if (nonTerminatingUnhandledError) { - writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(nonTerminatingUnhandledError); + if (hasNonTerminatingUnhandledError) { + writer.name(JsonKeys.NON_TERMINATING_UNHANDLED_ERROR).value(hasNonTerminatingUnhandledError); } writer.name(JsonKeys.ATTRS); writer.beginObject(); @@ -536,7 +536,7 @@ public static final class Deserializer implements JsonDeserializer { String environment = null; String release = null; // @NotNull String abnormalMechanism = null; - boolean nonTerminatingUnhandledError = false; + boolean hasNonTerminatingUnhandledError = false; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -581,9 +581,10 @@ public static final class Deserializer implements JsonDeserializer { abnormalMechanism = reader.nextStringOrNull(); break; case JsonKeys.NON_TERMINATING_UNHANDLED_ERROR: - final Boolean nonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); - nonTerminatingUnhandledError = - nonTerminatingUnhandledErrorValue != null && nonTerminatingUnhandledErrorValue; + final Boolean hasNonTerminatingUnhandledErrorValue = reader.nextBooleanOrNull(); + hasNonTerminatingUnhandledError = + hasNonTerminatingUnhandledErrorValue != null + && hasNonTerminatingUnhandledErrorValue; break; case JsonKeys.ATTRS: reader.beginObject(); @@ -644,7 +645,7 @@ public static final class Deserializer implements JsonDeserializer { environment, release, abnormalMechanism, - nonTerminatingUnhandledError); + hasNonTerminatingUnhandledError); session.setUnknown(unknown); reader.endObject(); return session; From dfdd999ae0bf7e119aa9ecb41c93a314058df413 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 09:48:40 +0200 Subject: [PATCH 34/48] ref: drop comments that restate the code in Session Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index ca62627a36a..8375184e4ae 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -67,15 +67,6 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; - /** - * Whether the session experienced an unhandled error that did not terminate the process, - * e.g. an unhandled Flutter exception. A native crash is also unhandled, but it kills the process - * and therefore ends the session as {@link State#Crashed} instead. - * - *

Kept locally and persisted with the session, but never sent as a status while the session is - * alive. On end() the session is finalized as {@link State#Unhandled} instead of {@link - * State#Exited}, unless a crash escalated it to {@link State#Crashed}. - */ private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ @@ -242,8 +233,12 @@ public int errorCount() { } /** - * Whether the session experienced an unhandled error that did not terminate the process, and so - * finalizes as {@link State#Unhandled} rather than {@link State#Exited}. + * Whether the session experienced an unhandled error that did not terminate the process, + * e.g. an unhandled Flutter exception, and so finalizes as {@link State#Unhandled} rather than + * {@link State#Exited}. A native crash is also unhandled, but it kills the process and ends the + * session as {@link State#Crashed} instead. + * + *

Never sent as a status while the session is alive; it is only persisted with the session. */ @ApiStatus.Internal public boolean hasNonTerminatingUnhandledError() { @@ -294,8 +289,6 @@ public void end(final @Nullable Date timestamp) { // at this state it might be Crashed already, so we don't check for it. if (status == State.Ok) { - // a session that experienced an unhandled (but non-terminal) exception is finalized as - // Unhandled rather than Exited. status = hasNonTerminatingUnhandledError ? State.Unhandled : State.Exited; } From 487daa8c71477893fe4f1097f54b216a4c9d4cb0 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 11:37:50 +0200 Subject: [PATCH 35/48] test: move Session serialization cases out of SessionTest The round-trip case duplicated one already added to SessionSerializationTest. Keep JSON concerns in the serialization test and leave SessionTest to state transitions. Co-authored-by: Cursor --- sentry/src/test/java/io/sentry/SessionTest.kt | 32 ------------------- .../protocol/SessionSerializationTest.kt | 8 +++++ 2 files changed, 8 insertions(+), 32 deletions(-) diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index d036805e06b..812138c1215 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -1,10 +1,7 @@ package io.sentry import com.google.common.truth.Truth.assertThat -import java.io.StringReader -import java.io.StringWriter import kotlin.test.Test -import org.mockito.kotlin.mock class SessionTest { @@ -113,33 +110,4 @@ class SessionTest { assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() } - - @Test - fun `serialization round-trips a non-terminating unhandled error and Unhandled status`() { - val logger = mock() - val session = okSession() - session.recordNonTerminatingUnhandledError() - session.end() - assertThat(session.status).isEqualTo(Session.State.Unhandled) - - val writer = StringWriter() - session.serialize(JsonObjectWriter(writer, 100), logger) - - val deserialized = - Session.Deserializer().deserialize(JsonObjectReader(StringReader(writer.toString())), logger) - - assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) - assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() - } - - @Test - fun `a non-terminating unhandled error defaults to false and is not serialized when unset`() { - val logger = mock() - val session = okSession() - - val writer = StringWriter() - session.serialize(JsonObjectWriter(writer, 100), logger) - - assertThat(writer.toString()).doesNotContain("non_terminating_unhandled_error") - } } diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index c88e335e1ac..663bcae0f06 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -10,6 +10,7 @@ import io.sentry.Session import java.io.StringReader import java.io.StringWriter import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.Test import org.mockito.kotlin.mock @@ -66,6 +67,13 @@ class SessionSerializationTest { assertEquals(true, deserialized.hasNonTerminatingUnhandledError()) } + @Test + fun `non-terminating flag is omitted when unset`() { + val session = Session(null, null, "environment", "release") + + assertFalse(serialize(session).contains("non_terminating_unhandled_error")) + } + // Helper private fun sanitizedFile(path: String): String = From f7575a116909ee7400a43a5a52a719792e11bd59 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 11:42:13 +0200 Subject: [PATCH 36/48] test: remove SessionTest Co-authored-by: Cursor --- sentry/src/test/java/io/sentry/SessionTest.kt | 113 ------------------ 1 file changed, 113 deletions(-) delete mode 100644 sentry/src/test/java/io/sentry/SessionTest.kt diff --git a/sentry/src/test/java/io/sentry/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt deleted file mode 100644 index 812138c1215..00000000000 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package io.sentry - -import com.google.common.truth.Truth.assertThat -import kotlin.test.Test - -class SessionTest { - - private fun okSession(): Session = Session(null, null, "environment", "release") - - @Test - fun `recordNonTerminatingUnhandledError atomically updates an Ok session`() { - val session = okSession() - val initialTimestamp = session.timestamp - - val updated = session.recordNonTerminatingUnhandledError() - - assertThat(updated).isTrue() - assertThat(session.status).isEqualTo(Session.State.Ok) - assertThat(session.hasNonTerminatingUnhandledError()).isTrue() - assertThat(session.errorCount()).isEqualTo(1) - assertThat(session.init).isNull() - assertThat(session.timestamp).isNotNull() - assertThat(session.timestamp!!.time).isAtLeast(initialTimestamp!!.time) - assertThat(session.sequence).isEqualTo(session.timestamp!!.time) - } - - @Test - fun `recordNonTerminatingUnhandledError does not change terminal sessions`() { - for (state in Session.State.entries.filter { it != Session.State.Ok }) { - val session = okSession() - session.update(state, null, false) - val before = session.clone() - - val updated = session.recordNonTerminatingUnhandledError() - - assertThat(updated).isFalse() - assertThat(session.status).isEqualTo(before.status) - assertThat(session.hasNonTerminatingUnhandledError()) - .isEqualTo(before.hasNonTerminatingUnhandledError()) - assertThat(session.errorCount()).isEqualTo(before.errorCount()) - assertThat(session.init).isEqualTo(before.init) - assertThat(session.timestamp).isEqualTo(before.timestamp) - assertThat(session.sequence).isEqualTo(before.sequence) - } - } - - @Test - fun `end without a non-terminating unhandled error finalizes as Exited`() { - val session = okSession() - - session.end() - - assertThat(session.status).isEqualTo(Session.State.Exited) - } - - @Test - fun `end with a non-terminating unhandled error finalizes as Unhandled`() { - val session = okSession() - assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - - session.recordNonTerminatingUnhandledError() - session.end() - - assertThat(session.status).isEqualTo(Session.State.Unhandled) - assertThat(session.hasNonTerminatingUnhandledError()).isTrue() - } - - @Test - fun `end with a non-terminating unhandled error keeps Abnormal as Abnormal`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - session.update(Session.State.Abnormal, null, false, "anr") - - session.end() - - assertThat(session.status).isEqualTo(Session.State.Abnormal) - assertThat(session.hasNonTerminatingUnhandledError()).isTrue() - } - - @Test - fun `end with a non-terminating unhandled error keeps Crashed as Crashed`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - session.update(Session.State.Crashed, null, false) - - session.end() - - assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - } - - @Test - fun `updating to Crashed clears a non-terminating unhandled error and end stays Crashed`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - - session.update(Session.State.Crashed, null, true) - session.end() - - assertThat(session.status).isEqualTo(Session.State.Crashed) - assertThat(session.hasNonTerminatingUnhandledError()).isFalse() - } - - @Test - fun `clone preserves a non-terminating unhandled error`() { - val session = okSession() - session.recordNonTerminatingUnhandledError() - - val clone = session.clone() - - assertThat(clone.hasNonTerminatingUnhandledError()).isTrue() - } -} From ae2f5da5fe651f12dea3ab2ccff1eae0ef9b5014 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:53:06 +0200 Subject: [PATCH 37/48] docs(session): describe hasNonTerminatingUnhandledError on the field It was the only field in Session without the one-line comment the surrounding declarations all carry. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 8375184e4ae..60987817218 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -67,6 +67,7 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; + /** whether an unhandled error occurred that did not terminate the process */ private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ From d0cbd8187a701339ce0a23bb49245d297a84f221 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:54:34 +0200 Subject: [PATCH 38/48] docs(session): capitalise the hasNonTerminatingUnhandledError comment Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 60987817218..a4579c1c649 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -67,7 +67,7 @@ public enum State { /** the Abnormal mechanism, e.g. what was the reason for session to become abnormal (ANR) */ private @Nullable String abnormalMechanism; - /** whether an unhandled error occurred that did not terminate the process */ + /** Whether an unhandled error occurred that did not terminate the process */ private boolean hasNonTerminatingUnhandledError; /** The session lock, ops should be atomic */ From 4256a7b1ff7921eee0d83e0814b72ad6697e6466 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 13:58:23 +0200 Subject: [PATCH 39/48] ref(session): drop the private canonical constructor hasNonTerminatingUnhandledError is not final - recordNonTerminating UnhandledError and update() both write it - so setting it through a constructor established no invariant that a plain assignment does not. Both call sites are inside Session, so clone() and the deserializer can assign the field directly, which is what the deserializer already does for unknown. Removes the 15-parameter overload and the javadoc that existed to justify it. The public constructor is unchanged, so sentry.api is too. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 79 ++++++--------------- 1 file changed, 20 insertions(+), 59 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index a4579c1c649..1434e0ab6a5 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -91,46 +91,6 @@ public Session( final @Nullable String environment, final @NotNull String release, final @Nullable String abnormalMechanism) { - this( - status, - started, - timestamp, - errorCount, - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism, - false); - } - - /** - * Canonical constructor. Kept private so {@code hasNonTerminatingUnhandledError} stays off the - * public API: it is internal bookkeeping that only {@link #clone()} and {@link Deserializer} need - * to restore, and a public overload carrying it would let callers fabricate a session claiming an - * unhandled error that was never counted. - */ - private Session( - final @NotNull State status, - final @NotNull Date started, - final @Nullable Date timestamp, - final int errorCount, - final @Nullable String distinctId, - final @Nullable String sessionId, - final @Nullable Boolean init, - final @Nullable Long sequence, - final @Nullable Double duration, - final @Nullable String ipAddress, - final @Nullable String userAgent, - final @Nullable String environment, - final @NotNull String release, - final @Nullable String abnormalMechanism, - final boolean hasNonTerminatingUnhandledError) { this.status = status; this.started = started; this.timestamp = timestamp; @@ -145,7 +105,6 @@ private Session( this.environment = environment; this.release = release; this.abnormalMechanism = abnormalMechanism; - this.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; } public Session( @@ -403,22 +362,24 @@ private long getSequenceTimestamp(final @NotNull Date timestamp) { */ @SuppressWarnings("MissingOverride") public @NotNull Session clone() { - return new Session( - status, - started, - timestamp, - errorCount.get(), - distinctId, - sessionId, - init, - sequence, - duration, - ipAddress, - userAgent, - environment, - release, - abnormalMechanism, - hasNonTerminatingUnhandledError); + final @NotNull Session session = + new Session( + status, + started, + timestamp, + errorCount.get(), + distinctId, + sessionId, + init, + sequence, + duration, + ipAddress, + userAgent, + environment, + release, + abnormalMechanism); + session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; + return session; } // JsonSerializable @@ -638,8 +599,8 @@ public static final class Deserializer implements JsonDeserializer { userAgent, environment, release, - abnormalMechanism, - hasNonTerminatingUnhandledError); + abnormalMechanism); + session.hasNonTerminatingUnhandledError = hasNonTerminatingUnhandledError; session.setUnknown(unknown); reader.endObject(); return session; From 9b00da8782094cb6f59f025aa89e3ad95dc7966c Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 11 Aug 2026 14:06:33 +0200 Subject: [PATCH 40/48] test(session): use Truth in the new session serialization tests Also swaps assertFalse(serialize(...).contains(...)) for Truth's doesNotContain, which reports the offending json on failure instead of just "expected false". The two new PreviousSessionFinalizerTest cases are left on Mockito argThat, which needs a Boolean predicate rather than an assertion. Co-authored-by: Cursor --- .../io/sentry/protocol/SessionSerializationTest.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index 663bcae0f06..ad19b90bb4e 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -1,5 +1,6 @@ package io.sentry.protocol +import com.google.common.truth.Truth.assertThat import io.sentry.DateUtils import io.sentry.FileFromResources import io.sentry.ILogger @@ -10,7 +11,6 @@ import io.sentry.Session import java.io.StringReader import java.io.StringWriter import kotlin.test.assertEquals -import kotlin.test.assertFalse import org.junit.Test import org.mockito.kotlin.mock @@ -59,19 +59,19 @@ class SessionSerializationTest { val session = Session(null, null, "environment", "release") session.recordNonTerminatingUnhandledError() session.end() - assertEquals(Session.State.Unhandled, session.status) + assertThat(session.status).isEqualTo(Session.State.Unhandled) val deserialized = deserialize(serialize(session)) - assertEquals(Session.State.Unhandled, deserialized.status) - assertEquals(true, deserialized.hasNonTerminatingUnhandledError()) + assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) + assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() } @Test fun `non-terminating flag is omitted when unset`() { val session = Session(null, null, "environment", "release") - assertFalse(serialize(session).contains("non_terminating_unhandled_error")) + assertThat(serialize(session)).doesNotContain("non_terminating_unhandled_error") } // Helper From 76f3fa4eeae90feebb90a041424f2d7639f8406d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 14:01:09 +0200 Subject: [PATCH 41/48] test(session): cover the unhandled flag through the previous-session recovery paths Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index dda06ee7e63..c80117aa99b 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -346,6 +346,36 @@ 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() @@ -400,6 +430,29 @@ 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 8fe5142c61e7333991fd2be9b29428b56970fe3d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 13 Aug 2026 14:14:27 +0200 Subject: [PATCH 42/48] test(session): cover the unhandled session shape with a JSON fixture Co-authored-by: Cursor --- .../protocol/SessionSerializationTest.kt | 52 ++++++++++++++----- .../resources/json/session_unhandled.json | 18 +++++++ 2 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 sentry/src/test/resources/json/session_unhandled.json diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index ad19b90bb4e..ebe108b2fe6 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -35,6 +35,34 @@ class SessionSerializationTest { "b2d0224b-4b1f-49db-94c9-fd4a439b3ef5", "anr_foreground", ) + + /** + * An unhandled session cannot be built by mutating [getSut]: the flag is only reachable through + * [Session.recordNonTerminatingUnhandledError], which no-ops unless the session is still `Ok`, + * and a crash would clear it again. Ending on a fixed timestamp keeps `seq` and `duration` + * deterministic. + */ + fun getUnhandledSut() = + Session( + Session.State.Ok, + DateUtils.getDateTime("1945-06-16T06:36:49.000Z"), + DateUtils.getDateTime("1970-04-21T09:32:21.000Z"), + 9001, + "631693c2-3d61-4a93-8fd1-89817426ba5a", + "3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17", + true, + 4, + 5.5, + "5a174e69-a297-4ba4-b6e1-2244a8299ec8", + "790da4ae-50ca-48a2-98f6-9b7f4e05a8c3", + "d732be55-b57e-48ec-afe6-b0040c7f93de", + "b2d0224b-4b1f-49db-94c9-fd4a439b3ef5", + null, + ) + .apply { + recordNonTerminatingUnhandledError() + end(DateUtils.getDateTime("1970-04-21T09:32:21.000Z")) + } } private val fixture = Fixture() @@ -55,23 +83,19 @@ class SessionSerializationTest { } @Test - fun `serialize and deserialize round-trips Unhandled status and non-terminating flag`() { - val session = Session(null, null, "environment", "release") - session.recordNonTerminatingUnhandledError() - session.end() - assertThat(session.status).isEqualTo(Session.State.Unhandled) - - val deserialized = deserialize(serialize(session)) - - assertThat(deserialized.status).isEqualTo(Session.State.Unhandled) - assertThat(deserialized.hasNonTerminatingUnhandledError()).isTrue() + fun serializeUnhandled() { + val expected = sanitizedFile("json/session_unhandled.json") + val actual = serialize(fixture.getUnhandledSut()) + assertThat(actual).isEqualTo(expected) } @Test - fun `non-terminating flag is omitted when unset`() { - val session = Session(null, null, "environment", "release") - - assertThat(serialize(session)).doesNotContain("non_terminating_unhandled_error") + fun deserializeUnhandled() { + val expectedJson = sanitizedFile("json/session_unhandled.json") + val actual = deserialize(expectedJson) + assertThat(actual.status).isEqualTo(Session.State.Unhandled) + assertThat(actual.hasNonTerminatingUnhandledError()).isTrue() + assertThat(serialize(actual)).isEqualTo(expectedJson) } // Helper diff --git a/sentry/src/test/resources/json/session_unhandled.json b/sentry/src/test/resources/json/session_unhandled.json new file mode 100644 index 00000000000..cd822fee25b --- /dev/null +++ b/sentry/src/test/resources/json/session_unhandled.json @@ -0,0 +1,18 @@ +{ + "sid": "3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17", + "did": "631693c2-3d61-4a93-8fd1-89817426ba5a", + "started": "1945-06-16T06:36:49.000Z", + "status": "unhandled", + "seq": 9538341000, + "errors": 9002, + "duration": 7.84090532E8, + "timestamp": "1970-04-21T09:32:21.000Z", + "non_terminating_unhandled_error": true, + "attrs": + { + "release": "b2d0224b-4b1f-49db-94c9-fd4a439b3ef5", + "environment": "d732be55-b57e-48ec-afe6-b0040c7f93de", + "ip_address": "5a174e69-a297-4ba4-b6e1-2244a8299ec8", + "user_agent": "790da4ae-50ca-48a2-98f6-9b7f4e05a8c3" + } +} From 4c5d7a23b9522ebd8cf9392e04e9352f21208c19 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 10:57:03 +0200 Subject: [PATCH 43/48] docs(session): Clarify Unhandled state and hybrid-only recording Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 1434e0ab6a5..2eda423e69d 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -22,6 +22,10 @@ public enum State { Exited, Crashed, Abnormal, + /** + * Unhandled error the process survived. Not used while the session is alive; {@link + * Session#end()} sets this. Native crashes still end as {@link #Crashed}. + */ Unhandled } @@ -211,6 +215,9 @@ public boolean hasNonTerminatingUnhandledError() { * #end()} the session is finalized as {@link State#Unhandled} unless a crash escalated it to * {@link State#Crashed} first. * + *

Hybrid SDKs whose unhandled errors do not kill the process. Native Java/Android capture + * should not call this. + * * @return whether the session was updated, i.e. false if it had already reached a terminal state */ @ApiStatus.Internal From 843770207073d5ffb7da1cfe92c9a38a5285b984 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 11:05:49 +0200 Subject: [PATCH 44/48] docs(session): Reword Unhandled status javadoc Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 2eda423e69d..ee9c04ce88c 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -23,8 +23,9 @@ public enum State { Crashed, Abnormal, /** - * Unhandled error the process survived. Not used while the session is alive; {@link - * Session#end()} sets this. Native crashes still end as {@link #Crashed}. + * Final status when an unhandled error did not kill the process, such as a Flutter exception. + * The session stays {@link #Ok} until {@link Session#end()}. Native crashes still end as {@link + * #Crashed}. */ Unhandled } From 398f0eba2cc01423cee1a0919a367400856b9f9e Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 12:59:05 +0200 Subject: [PATCH 45/48] ref(session): Clear the unhandled marker for any terminal status update() only cleared hasNonTerminatingUnhandledError for Crashed, so an ANR arriving after a non-terminating unhandled error left the marker set on an Abnormal session and serialized it into previous_session.json. The marker only decides how an Ok session is finalized, so any explicit terminal status clears it now. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 9 +++++---- .../src/test/java/io/sentry/cache/EnvelopeCacheTest.kt | 2 +- .../java/io/sentry/protocol/SessionSerializationTest.kt | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index ee9c04ce88c..26300f73005 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -213,8 +213,8 @@ public boolean hasNonTerminatingUnhandledError() { /** * Records that an active session experienced an unhandled error which did not terminate the * process, counting the error and advancing the session's sequence without ending it. On {@link - * #end()} the session is finalized as {@link State#Unhandled} unless a crash escalated it to - * {@link State#Crashed} first. + * #end()} the session is finalized as {@link State#Unhandled} unless a terminal status such as + * {@link State#Crashed} or {@link State#Abnormal} took over first. * *

Hybrid SDKs whose unhandled errors do not kill the process. Native Java/Android capture * should not call this. @@ -310,8 +310,9 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; - // a crash terminates the process, so it takes precedence over a non-terminating one. - if (status == State.Crashed) { + // the flag only decides how an Ok session is finalized, so an explicit terminal status + // such as a crash or an ANR takes precedence over a non-terminating error. + if (status != State.Ok) { hasNonTerminatingUnhandledError = false; } sessionHasBeenUpdated = true; diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index c80117aa99b..8631071a653 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -373,7 +373,7 @@ class EnvelopeCacheTest { ) assertEquals(State.Abnormal, updatedSession!!.status) assertEquals("abnormal_mechanism", updatedSession.abnormalMechanism) - assertTrue(updatedSession.hasNonTerminatingUnhandledError()) + assertFalse(updatedSession.hasNonTerminatingUnhandledError()) } @Test diff --git a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt index ebe108b2fe6..982b94768e3 100644 --- a/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SessionSerializationTest.kt @@ -39,8 +39,8 @@ class SessionSerializationTest { /** * An unhandled session cannot be built by mutating [getSut]: the flag is only reachable through * [Session.recordNonTerminatingUnhandledError], which no-ops unless the session is still `Ok`, - * and a crash would clear it again. Ending on a fixed timestamp keeps `seq` and `duration` - * deterministic. + * and a terminal status would clear it again. Ending on a fixed timestamp keeps `seq` and + * `duration` deterministic. */ fun getUnhandledSut() = Session( From 334725567107532bd505ce2aa00a53c06ef40675 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 13:02:23 +0200 Subject: [PATCH 46/48] ref(session): Name the terminal-status check `status != State.Ok` stated the mechanism while the comment carried the meaning. An isTerminal helper says it directly and reads the same in recordNonTerminatingUnhandledError, whose javadoc already spoke of terminal states. Co-authored-by: Cursor --- sentry/src/main/java/io/sentry/Session.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 26300f73005..f83e3658fad 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -224,7 +224,7 @@ public boolean hasNonTerminatingUnhandledError() { @ApiStatus.Internal public boolean recordNonTerminatingUnhandledError() { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { - if (status != State.Ok) { + if (isTerminal(status)) { return false; } hasNonTerminatingUnhandledError = true; @@ -310,9 +310,9 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; - // the flag only decides how an Ok session is finalized, so an explicit terminal status - // such as a crash or an ANR takes precedence over a non-terminating error. - if (status != State.Ok) { + // the marker only decides how an Ok session is finalized, so a terminal status such as a + // crash or an ANR takes precedence over a non-terminating error. + if (isTerminal(status)) { hasNonTerminatingUnhandledError = false; } sessionHasBeenUpdated = true; @@ -347,6 +347,11 @@ public boolean update( } } + /** A session can only leave {@link State#Ok}, every other status is final. */ + private static boolean isTerminal(final @NotNull State state) { + return state != State.Ok; + } + /** * Returns a logical clock. * From 7f47fa4ab172946b0b8aeae4e7f09e5ed5338adc Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 13:03:41 +0200 Subject: [PATCH 47/48] Revert "ref(session): Name the terminal-status check" This reverts commit 334725567107532bd505ce2aa00a53c06ef40675. --- sentry/src/main/java/io/sentry/Session.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index f83e3658fad..26300f73005 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -224,7 +224,7 @@ public boolean hasNonTerminatingUnhandledError() { @ApiStatus.Internal public boolean recordNonTerminatingUnhandledError() { try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { - if (isTerminal(status)) { + if (status != State.Ok) { return false; } hasNonTerminatingUnhandledError = true; @@ -310,9 +310,9 @@ public boolean update( boolean sessionHasBeenUpdated = false; if (status != null) { this.status = status; - // the marker only decides how an Ok session is finalized, so a terminal status such as a - // crash or an ANR takes precedence over a non-terminating error. - if (isTerminal(status)) { + // the flag only decides how an Ok session is finalized, so an explicit terminal status + // such as a crash or an ANR takes precedence over a non-terminating error. + if (status != State.Ok) { hasNonTerminatingUnhandledError = false; } sessionHasBeenUpdated = true; @@ -347,11 +347,6 @@ public boolean update( } } - /** A session can only leave {@link State#Ok}, every other status is final. */ - private static boolean isTerminal(final @NotNull State state) { - return state != State.Ok; - } - /** * Returns a logical clock. * From 7a5cd319f7cd22faaf1a2612ba92713819936f31 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 24 Aug 2026 13:26:59 +0200 Subject: [PATCH 48/48] 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 3aee5220637..d3f473cfbc9 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 964d554b66f..0cc2c968195 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()