From 8dd05ff18c564f7eed528a590b7fed2534c9d701 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 16 Jul 2026 13:53:11 +0200 Subject: [PATCH 1/9] feat: add InternalSentrySdk.captureEnvelopeNonTerminating for pending-unhandled sessions Hybrid unhandled exceptions that don't terminate the process (e.g. Flutter) no longer end the session as crashed. The session is kept alive and marked pending-unhandled, finalized as unhandled on session end or escalated to crashed on a native crash. Existing captureEnvelope(byte[], boolean) behavior (React Native) is unchanged. Co-authored-by: Cursor --- CHANGELOG.md | 6 + .../api/sentry-android-core.api | 1 + .../android/core/InternalSentrySdk.java | 110 ++++++++++++++++-- .../android/core/InternalSentrySdkTest.kt | 109 +++++++++++++++++ sentry/api/sentry.api | 4 + sentry/src/main/java/io/sentry/Session.java | 80 ++++++++++--- .../io/sentry/PreviousSessionFinalizerTest.kt | 41 +++++++ sentry/src/test/java/io/sentry/SessionTest.kt | 84 +++++++++++++ .../protocol/SessionSerializationTest.kt | 13 +++ 9 files changed, 420 insertions(+), 28 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/SessionTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c57c75fe6a7..b9e2d2175f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add `InternalSentrySdk.captureEnvelopeNonTerminating` for hybrid SDKs (e.g. Flutter) so unhandled exceptions that don't terminate the process no longer end the session as `crashed`. The session is kept alive, marked pending-unhandled, and finalized as `unhandled` on session end (or `crashed` if a native crash follows). The existing `captureEnvelope(byte[], boolean)` behavior is unchanged. ([#XXXX](https://github.com/getsentry/sentry-java/pull/XXXX)) + ## 8.49.0 ### Features diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index adebedf2700..d9d3b859219 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -315,6 +315,7 @@ public abstract interface class io/sentry/android/core/IDebugImagesLoader { public final class io/sentry/android/core/InternalSentrySdk { public fun ()V public static fun captureEnvelope ([BZ)Lio/sentry/protocol/SentryId; + public static fun captureEnvelopeNonTerminating ([B)Lio/sentry/protocol/SentryId; public static fun getAppStartMeasurement ()Ljava/util/Map; public static fun getCurrentScope ()Lio/sentry/IScope; public static fun serializeScope (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/IScope;)Ljava/util/Map; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 2779f803a69..77058f2a9e0 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -32,9 +32,15 @@ import io.sentry.protocol.User; import io.sentry.util.MapObjectWriter; import io.sentry.util.TracingUtils; +import java.io.BufferedWriter; import java.io.ByteArrayInputStream; import java.io.File; +import java.io.FileOutputStream; import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -49,6 +55,9 @@ @ApiStatus.Internal public final class InternalSentrySdk { + @SuppressWarnings("CharsetObjectCanBeUsed") + private static final Charset UTF_8_CHARSET = Charset.forName("UTF-8"); + /** * @return a copy of the current scopes's topmost scope, or null in case the scopes is disabled */ @@ -153,13 +162,55 @@ public static Map serializeScope( * - will not perform any sampling: it's up to the caller to take care of this
* - will enrich the envelope with a Session update if applicable
* + *

Unhandled events ({@code handled=false}) end the session as {@code crashed}. Prefer {@link + * #captureEnvelopeNonTerminating(byte[])} for hybrid runtimes where the process is expected to + * continue (e.g. Flutter). + * * @param envelopeData the serialized envelope data + * @param maybeStartNewSession if true, starts a new session after a crashed session is cleared * @return The Id (SentryId object) of the event, or null in case the envelope could not be * captured */ @Nullable public static SentryId captureEnvelope( final @NotNull byte[] envelopeData, final boolean maybeStartNewSession) { + return captureEnvelope(envelopeData, maybeStartNewSession, true); + } + + /** + * Captures the provided envelope for a non-terminating hybrid exception (e.g. Flutter). + * + *

Compared to {@link #captureEnvelope(byte[], boolean)} this method does not + * treat {@code handled=false} as a crash that ends the session. Instead it: + * + *

    + *
  • marks the current session as pending-unhandled and increments the error count + *
  • keeps session status {@code Ok} and the same session id on the scope + *
  • does not attach a session update item to this envelope + *
  • does not start a new session + *
  • persists the current session so pending-unhandled survives process death + *
+ * + *

The session is finalized later by normal lifecycle ({@code endSession} / background / + * previous-session recovery) as {@code unhandled}, unless a native crash escalates it to {@code + * crashed}. + * + *

Same as {@link #captureEnvelope(byte[], boolean)}, this method will not enrich events, run + * {@code beforeSend}, or sample — the caller is responsible for that. + * + * @param envelopeData the serialized envelope data + * @return the id of the captured envelope, or null if capture failed + */ + @Nullable + public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envelopeData) { + return captureEnvelope(envelopeData, false, false); + } + + @Nullable + private static SentryId captureEnvelope( + final @NotNull byte[] envelopeData, + final boolean maybeStartNewSession, + final boolean isTerminating) { final @NotNull IScopes scopes = ScopesAdapter.getInstance(); final @NotNull SentryOptions options = scopes.getOptions(); @@ -176,13 +227,19 @@ public static SentryId captureEnvelope( // determine session state based on events inside envelope @Nullable Session.State status = null; boolean crashedOrErrored = false; + boolean markPendingUnhandled = false; for (SentryEnvelopeItem item : envelope.getItems()) { envelopeItems.add(item); final SentryEvent event = item.getEvent(serializer); if (event != null) { if (event.isCrashed()) { - status = Session.State.Crashed; + if (isTerminating) { + status = Session.State.Crashed; + } else { + // non-terminating: don't crash the session, only remember the unhandled exception. + markPendingUnhandled = true; + } } if (event.isCrashed() || event.isErrored()) { crashedOrErrored = true; @@ -191,16 +248,24 @@ public static SentryId captureEnvelope( } // update session and add it to envelope if necessary - final @Nullable Session session = updateSession(scopes, options, status, crashedOrErrored); + final @Nullable Session session = + updateSession(scopes, options, status, crashedOrErrored, markPendingUnhandled); if (session != null) { - final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); - envelopeItems.add(sessionItem); - deleteCurrentSessionFile( - options, - // should be sync if going to crash or already not a main thread - !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); - if (maybeStartNewSession) { - scopes.startSession(); + if (isTerminating) { + final SentryEnvelopeItem sessionItem = + SentryEnvelopeItem.fromSession(serializer, session); + envelopeItems.add(sessionItem); + deleteCurrentSessionFile( + options, + // should be sync if going to crash or already not a main thread + !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); + if (maybeStartNewSession) { + scopes.startSession(); + } + } else { + // non-terminating: keep the (still Ok) session on the scope, don't attach a session + // update, and persist it so the pending-unhandled flag survives process death. + persistCurrentSession(options, session); } } @@ -296,17 +361,40 @@ private static void deleteCurrentSessionFile(final @NotNull SentryOptions option } } + private static void persistCurrentSession( + final @NotNull SentryOptions options, final @NotNull Session session) { + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + options.getLogger().log(INFO, "Cache dir is not set, not persisting the current session."); + return; + } + + final File sessionFile = EnvelopeCache.getCurrentSessionFile(cacheDirPath); + try (final OutputStream outputStream = new FileOutputStream(sessionFile); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8_CHARSET))) { + options.getSerializer().serialize(session, writer); + } catch (Throwable e) { + options.getLogger().log(WARNING, "Failed to persist the current session.", e); + } + } + @Nullable private static Session updateSession( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @Nullable Session.State status, - final boolean crashedOrErrored) { + final boolean crashedOrErrored, + final boolean markPendingUnhandled) { final @NotNull AtomicReference sessionRef = new AtomicReference<>(); scopes.configureScope( scope -> { final @Nullable Session session = scope.getSession(); if (session != null) { + if (markPendingUnhandled) { + // remember the unhandled (but non-terminal) exception; finalized later as Unhandled. + session.setPendingUnhandled(true); + } final boolean updated = session.update(status, null, crashedOrErrored, null); // if we have an uncaughtExceptionHint we can end the session. if (updated) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index 5917d44d11d..bb0bd0f2407 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -22,6 +22,7 @@ import io.sentry.Session import io.sentry.SpanId import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.cache.EnvelopeCache import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.App import io.sentry.protocol.Contexts @@ -107,6 +108,21 @@ class InternalSentrySdkTest { InternalSentrySdk.captureEnvelope(data, maybeStartNewSession) } + fun captureEnvelopeNonTerminatingWithEvent(event: SentryEvent = SentryEvent()) { + val options = Sentry.getCurrentScopes().options + val eventId = SentryId() + val header = SentryEnvelopeHeader(eventId) + val eventItem = SentryEnvelopeItem.fromEvent(options.serializer, event) + + val envelope = SentryEnvelope(header, listOf(eventItem)) + + val outputStream = ByteArrayOutputStream() + options.serializer.serialize(envelope, outputStream) + val data = outputStream.toByteArray() + + InternalSentrySdk.captureEnvelopeNonTerminating(data) + } + fun createSentryEventWithUnhandledException(): SentryEvent { return SentryEvent(RuntimeException()).apply { val mechanism = Mechanism() @@ -452,6 +468,99 @@ class InternalSentrySdkTest { assertNotEquals(capturedSession.sessionId, scopeRef.get().session!!.sessionId) } + @Test + fun `captureEnvelopeNonTerminating keeps the session Ok and marks it pending unhandled`() { + val fixture = Fixture() + fixture.init(context) + + val originalSid = AtomicReference() + Sentry.configureScope { scope -> originalSid.set(scope.session!!.sessionId) } + + // when capture envelope is called with an unhandled event through the non-terminating API + fixture.captureEnvelopeNonTerminatingWithEvent( + fixture.createSentryEventWithUnhandledException() + ) + + // then only the original event envelope is captured, without a session item + assertEquals(1, fixture.capturedEnvelopes.size) + val capturedEnvelopeItems = fixture.capturedEnvelopes.first().items.toList() + assertEquals(1, capturedEnvelopeItems.size) + assertEquals(SentryItemType.Event, capturedEnvelopeItems[0].header.type) + + // and the session stays alive on the scope, same id, marked pending unhandled + val scopeSession = AtomicReference() + Sentry.configureScope { scope -> scopeSession.set(scope.session) } + assertEquals(Session.State.Ok, scopeSession.get().status) + assertTrue(scopeSession.get().isPendingUnhandled) + assertEquals(originalSid.get(), scopeSession.get().sessionId) + + // and it is persisted so pending survives process death + val sessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + val persistedSession = + fixture.options.serializer.deserialize(sessionFile.reader(), Session::class.java)!! + assertEquals(Session.State.Ok, persistedSession.status) + assertTrue(persistedSession.isPendingUnhandled) + assertEquals(originalSid.get(), persistedSession.sessionId) + } + + @Test + fun `captureEnvelopeNonTerminating then endSession finalizes the session as unhandled`() { + val fixture = Fixture() + fixture.init(context) + + fixture.captureEnvelopeNonTerminatingWithEvent( + fixture.createSentryEventWithUnhandledException() + ) + fixture.capturedEnvelopes.clear() + + // when the session is ended by normal lifecycle + Sentry.endSession() + + // then the ended session is captured as unhandled + val sessionItems = + fixture.capturedEnvelopes + .flatMap { it.items.toList() } + .filter { + it.header.type == SentryItemType.Session + } + assertEquals(1, sessionItems.size) + val endedSession = + fixture.options.serializer.deserialize( + InputStreamReader(ByteArrayInputStream(sessionItems[0].data)), + Session::class.java, + )!! + assertEquals(Session.State.Unhandled, endedSession.status) + } + + @Test + fun `captureEnvelopeNonTerminating then a crash finalizes the session as crashed`() { + val fixture = Fixture() + fixture.init(context) + + fixture.captureEnvelopeNonTerminatingWithEvent( + fixture.createSentryEventWithUnhandledException() + ) + fixture.capturedEnvelopes.clear() + + // when a subsequent hard crash is captured through the terminating API + fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException()) + + // then the crash wins over the pending unhandled state + val sessionItems = + fixture.capturedEnvelopes + .flatMap { it.items.toList() } + .filter { + it.header.type == SentryItemType.Session + } + assertEquals(1, sessionItems.size) + val crashedSession = + fixture.options.serializer.deserialize( + InputStreamReader(ByteArrayInputStream(sessionItems[0].data)), + Session::class.java, + )!! + assertEquals(Session.State.Crashed, crashedSession.status) + } + @Test fun `getAppStartMeasurement returns correct serialized data from the app start instance`() { Fixture().mockFinishedAppStart() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0e5aad4826b..da08dbce407 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4273,9 +4273,11 @@ 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 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 @@ -4296,6 +4298,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; @@ -4311,6 +4314,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..ac775f04623 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,24 @@ public int errorCount() { return abnormalMechanism; } + /** + * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized + * yet. + */ + 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; + } + @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getTimestamp() { return timestamp; @@ -209,7 +236,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 +291,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 +351,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 +390,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 +421,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 +480,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 +524,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 +587,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..1c73cf58d75 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 = Date(2023, 10, 1), + ) + 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..b1232f74d8f --- /dev/null +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -0,0 +1,84 @@ +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 `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 `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 d72305049aed5ccda8469706ef62318a31bf757d Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Thu, 16 Jul 2026 16:07:38 +0200 Subject: [PATCH 2/9] fix(android): Preserve terminating envelope capture behavior Keep the existing hybrid capture path isolated from non-terminating Flutter session handling while retaining pending-unhandled session persistence. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 134 +++++++++++------- 1 file changed, 83 insertions(+), 51 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 77058f2a9e0..3c54e6bd6b6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -40,7 +40,7 @@ import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; -import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -55,9 +55,6 @@ @ApiStatus.Internal public final class InternalSentrySdk { - @SuppressWarnings("CharsetObjectCanBeUsed") - private static final Charset UTF_8_CHARSET = Charset.forName("UTF-8"); - /** * @return a copy of the current scopes's topmost scope, or null in case the scopes is disabled */ @@ -174,7 +171,58 @@ public static Map serializeScope( @Nullable public static SentryId captureEnvelope( final @NotNull byte[] envelopeData, final boolean maybeStartNewSession) { - return captureEnvelope(envelopeData, maybeStartNewSession, true); + final @NotNull IScopes scopes = ScopesAdapter.getInstance(); + final @NotNull SentryOptions options = scopes.getOptions(); + + try (final InputStream envelopeInputStream = new ByteArrayInputStream(envelopeData)) { + final @NotNull ISerializer serializer = options.getSerializer(); + final @Nullable SentryEnvelope envelope = + options.getEnvelopeReader().read(envelopeInputStream); + if (envelope == null) { + return null; + } + + final @NotNull List envelopeItems = new ArrayList<>(); + + // determine session state based on events inside envelope + @Nullable Session.State status = null; + boolean crashedOrErrored = false; + for (SentryEnvelopeItem item : envelope.getItems()) { + envelopeItems.add(item); + + final SentryEvent event = item.getEvent(serializer); + if (event != null) { + if (event.isCrashed()) { + status = Session.State.Crashed; + } + if (event.isCrashed() || event.isErrored()) { + crashedOrErrored = true; + } + } + } + + // update session and add it to envelope if necessary + final @Nullable Session session = + updateSession(scopes, options, status, crashedOrErrored, false); + if (session != null) { + final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); + envelopeItems.add(sessionItem); + deleteCurrentSessionFile( + options, + // should be sync if going to crash or already not a main thread + !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); + if (maybeStartNewSession) { + scopes.startSession(); + } + } + + final SentryEnvelope repackagedEnvelope = + new SentryEnvelope(envelope.getHeader(), envelopeItems); + return scopes.captureEnvelope(repackagedEnvelope); + } catch (Throwable t) { + options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); + } + return null; } /** @@ -203,14 +251,6 @@ public static SentryId captureEnvelope( */ @Nullable public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envelopeData) { - return captureEnvelope(envelopeData, false, false); - } - - @Nullable - private static SentryId captureEnvelope( - final @NotNull byte[] envelopeData, - final boolean maybeStartNewSession, - final boolean isTerminating) { final @NotNull IScopes scopes = ScopesAdapter.getInstance(); final @NotNull SentryOptions options = scopes.getOptions(); @@ -222,56 +262,48 @@ private static SentryId captureEnvelope( return null; } - final @NotNull List envelopeItems = new ArrayList<>(); - - // determine session state based on events inside envelope - @Nullable Session.State status = null; - boolean crashedOrErrored = false; boolean markPendingUnhandled = false; + boolean addErrorsCount = false; for (SentryEnvelopeItem item : envelope.getItems()) { - envelopeItems.add(item); - final SentryEvent event = item.getEvent(serializer); if (event != null) { + // isCrashed() means mechanism.handled=false (unhandled exception), not that the + // process terminated. For e.g Flutter that distinction is why we only mark pending here. if (event.isCrashed()) { - if (isTerminating) { - status = Session.State.Crashed; - } else { - // non-terminating: don't crash the session, only remember the unhandled exception. - markPendingUnhandled = true; - } - } - if (event.isCrashed() || event.isErrored()) { - crashedOrErrored = true; + markPendingUnhandled = true; + addErrorsCount = true; + } else if (event.isErrored()) { + addErrorsCount = true; } } } - // update session and add it to envelope if necessary - final @Nullable Session session = - updateSession(scopes, options, status, crashedOrErrored, markPendingUnhandled); - if (session != null) { - if (isTerminating) { - final SentryEnvelopeItem sessionItem = - SentryEnvelopeItem.fromSession(serializer, session); - envelopeItems.add(sessionItem); - deleteCurrentSessionFile( - options, - // should be sync if going to crash or already not a main thread - !maybeStartNewSession || !scopes.getOptions().getThreadChecker().isMainThread()); - if (maybeStartNewSession) { - scopes.startSession(); - } - } else { - // non-terminating: keep the (still Ok) session on the scope, don't attach a session - // update, and persist it so the pending-unhandled flag survives process death. + if (markPendingUnhandled || addErrorsCount) { + final @NotNull AtomicReference sessionRef = new AtomicReference<>(); + final boolean pending = markPendingUnhandled; + final boolean addErrors = addErrorsCount; + scopes.configureScope( + scope -> { + final @Nullable Session session = scope.getSession(); + if (session != null) { + if (pending) { + session.setPendingUnhandled(true); + } + if (session.update(null, null, addErrors, null) || pending) { + sessionRef.set(session); + } + } else { + options.getLogger().log(INFO, "Session is null on captureEnvelopeNonTerminating"); + } + }); + final @Nullable Session session = sessionRef.get(); + if (session != null) { persistCurrentSession(options, session); } } - final SentryEnvelope repackagedEnvelope = - new SentryEnvelope(envelope.getHeader(), envelopeItems); - return scopes.captureEnvelope(repackagedEnvelope); + // Capture the original envelope as-is (no session item attached). + return scopes.captureEnvelope(envelope); } catch (Throwable t) { options.getLogger().log(SentryLevel.ERROR, "Failed to capture envelope", t); } @@ -372,7 +404,7 @@ private static void persistCurrentSession( final File sessionFile = EnvelopeCache.getCurrentSessionFile(cacheDirPath); try (final OutputStream outputStream = new FileOutputStream(sessionFile); final Writer writer = - new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8_CHARSET))) { + new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { options.getSerializer().serialize(session, writer); } catch (Throwable e) { options.getLogger().log(WARNING, "Failed to persist the current session.", e); From e8f1ef9670bf9768ac4e5446634b9a87ce32ce05 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 21 Jul 2026 13:33:25 +0200 Subject: [PATCH 3/9] fix(android): Harden pending unhandled session persistence Serialize pending session mutations with scope and cache lifecycle operations so delayed session updates cannot overwrite or delete newer session state. Co-authored-by: Cursor --- .../android/core/InternalSentrySdk.java | 67 ++---- .../android/core/InternalSentrySdkTest.kt | 36 ++- sentry/api/sentry.api | 6 + sentry/src/main/java/io/sentry/Scope.java | 3 +- sentry/src/main/java/io/sentry/Session.java | 17 ++ .../java/io/sentry/cache/EnvelopeCache.java | 72 +++++- sentry/src/test/java/io/sentry/SessionTest.kt | 60 +++++ .../java/io/sentry/cache/EnvelopeCacheTest.kt | 212 +++++++++++++++++- 8 files changed, 400 insertions(+), 73 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index 3c54e6bd6b6..f89e312efac 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -32,15 +32,9 @@ import io.sentry.protocol.User; import io.sentry.util.MapObjectWriter; import io.sentry.util.TracingUtils; -import java.io.BufferedWriter; import java.io.ByteArrayInputStream; import java.io.File; -import java.io.FileOutputStream; import java.io.InputStream; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.Writer; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -202,8 +196,7 @@ public static SentryId captureEnvelope( } // update session and add it to envelope if necessary - final @Nullable Session session = - updateSession(scopes, options, status, crashedOrErrored, false); + final @Nullable Session session = updateSession(scopes, options, status, crashedOrErrored); if (session != null) { final SentryEnvelopeItem sessionItem = SentryEnvelopeItem.fromSession(serializer, session); envelopeItems.add(sessionItem); @@ -279,27 +272,28 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel } if (markPendingUnhandled || addErrorsCount) { - final @NotNull AtomicReference sessionRef = new AtomicReference<>(); final boolean pending = markPendingUnhandled; final boolean addErrors = addErrorsCount; scopes.configureScope( scope -> { - final @Nullable Session session = scope.getSession(); - if (session != null) { - if (pending) { - session.setPendingUnhandled(true); - } - if (session.update(null, null, addErrors, null) || pending) { - sessionRef.set(session); - } - } else { - options.getLogger().log(INFO, "Session is null on captureEnvelopeNonTerminating"); - } + scope.withSession( + session -> { + if (session != null) { + final boolean updated = + pending + ? session.markPendingUnhandled() + : session.update(null, null, addErrors, null); + if (updated && options.getEnvelopeDiskCache() instanceof EnvelopeCache) { + ((EnvelopeCache) options.getEnvelopeDiskCache()) + .persistCurrentSession(session); + } + } else { + options + .getLogger() + .log(INFO, "Session is null on captureEnvelopeNonTerminating"); + } + }); }); - final @Nullable Session session = sessionRef.get(); - if (session != null) { - persistCurrentSession(options, session); - } } // Capture the original envelope as-is (no session item attached). @@ -393,40 +387,17 @@ private static void deleteCurrentSessionFile(final @NotNull SentryOptions option } } - private static void persistCurrentSession( - final @NotNull SentryOptions options, final @NotNull Session session) { - final String cacheDirPath = options.getCacheDirPath(); - if (cacheDirPath == null) { - options.getLogger().log(INFO, "Cache dir is not set, not persisting the current session."); - return; - } - - final File sessionFile = EnvelopeCache.getCurrentSessionFile(cacheDirPath); - try (final OutputStream outputStream = new FileOutputStream(sessionFile); - final Writer writer = - new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { - options.getSerializer().serialize(session, writer); - } catch (Throwable e) { - options.getLogger().log(WARNING, "Failed to persist the current session.", e); - } - } - @Nullable private static Session updateSession( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @Nullable Session.State status, - final boolean crashedOrErrored, - final boolean markPendingUnhandled) { + final boolean crashedOrErrored) { final @NotNull AtomicReference sessionRef = new AtomicReference<>(); scopes.configureScope( scope -> { final @Nullable Session session = scope.getSession(); if (session != null) { - if (markPendingUnhandled) { - // remember the unhandled (but non-terminal) exception; finalized later as Unhandled. - session.setPendingUnhandled(true); - } final boolean updated = session.update(status, null, crashedOrErrored, null); // if we have an uncaughtExceptionHint we can end the session. if (updated) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt index bb0bd0f2407..e5bb2013c2f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/InternalSentrySdkTest.kt @@ -39,6 +39,7 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -533,32 +534,43 @@ class InternalSentrySdkTest { } @Test - fun `captureEnvelopeNonTerminating then a crash finalizes the session as crashed`() { + fun `captureEnvelopeNonTerminating then a crash finalizes old session and starts a new one`() { val fixture = Fixture() fixture.init(context) fixture.captureEnvelopeNonTerminatingWithEvent( fixture.createSentryEventWithUnhandledException() ) + val pendingSession = AtomicReference() + Sentry.configureScope { scope -> pendingSession.set(scope.session) } + val oldSid = pendingSession.get().sessionId + assertTrue(pendingSession.get().isPendingUnhandled) fixture.capturedEnvelopes.clear() - // when a subsequent hard crash is captured through the terminating API - fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException()) + // when a subsequent hard crash is captured through the existing terminating API + fixture.captureEnvelopeWithEvent(fixture.createSentryEventWithUnhandledException(), true) - // then the crash wins over the pending unhandled state - val sessionItems = - fixture.capturedEnvelopes - .flatMap { it.items.toList() } - .filter { - it.header.type == SentryItemType.Session - } - assertEquals(1, sessionItems.size) + // then the crash envelope contains the finalized old session + assertEquals(2, fixture.capturedEnvelopes.size) + val crashEnvelopeItems = fixture.capturedEnvelopes.last().items.toList() + assertEquals(2, crashEnvelopeItems.size) + assertEquals(SentryItemType.Event, crashEnvelopeItems[0].header.type) + assertEquals(SentryItemType.Session, crashEnvelopeItems[1].header.type) val crashedSession = fixture.options.serializer.deserialize( - InputStreamReader(ByteArrayInputStream(sessionItems[0].data)), + InputStreamReader(ByteArrayInputStream(crashEnvelopeItems[1].data)), Session::class.java, )!! assertEquals(Session.State.Crashed, crashedSession.status) + assertFalse(crashedSession.isPendingUnhandled) + assertEquals(oldSid, crashedSession.sessionId) + + // and a new Ok session with a different id is active + val activeSession = AtomicReference() + Sentry.configureScope { scope -> activeSession.set(scope.session) } + assertEquals(Session.State.Ok, activeSession.get().status) + assertFalse(activeSession.get().isPendingUnhandled) + assertNotEquals(oldSid, activeSession.get().sessionId) } @Test diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index da08dbce407..e32429d5966 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2492,6 +2492,10 @@ public abstract interface class io/sentry/Scope$IWithPropagationContext { public abstract fun accept (Lio/sentry/PropagationContext;)V } +public abstract interface class io/sentry/Scope$IWithSession { + public abstract fun accept (Lio/sentry/Session;)V +} + public abstract interface class io/sentry/Scope$IWithTransaction { public abstract fun accept (Lio/sentry/ITransaction;)V } @@ -4275,6 +4279,7 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso 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 @@ -4843,6 +4848,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/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index f5c57f5ac5d..8e06c6bf594 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1008,7 +1008,8 @@ public Session withSession(final @NotNull IWithSession sessionCallback) { } /** The IWithSession callback */ - interface IWithSession { + @ApiStatus.Internal + public interface IWithSession { /** * The accept method of the callback diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index ac775f04623..a1334ea45f5 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -201,6 +201,7 @@ public int errorCount() { * Whether the session has a pending unhandled (non-terminal) exception that hasn't been finalized * yet. */ + @ApiStatus.Internal public boolean isPendingUnhandled() { return pendingUnhandled; } @@ -215,6 +216,22 @@ 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; diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 475993bbc1d..c3ccb29b709 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -115,8 +115,15 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not final File previousSessionFile = getPreviousSessionFile(directory.getAbsolutePath()); 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); + if (endingSession != null + && currentSession != null + && hasSameSessionId(endingSession, currentSession) + && !currentSessionFile.delete()) { + options.getLogger().log(WARNING, "Current envelope doesn't exist."); + } } } @@ -126,8 +133,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); @@ -271,8 +292,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 @@ -292,7 +312,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); @@ -306,10 +326,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 (Throwable 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( @@ -349,6 +394,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.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/SessionTest.kt b/sentry/src/test/java/io/sentry/SessionTest.kt index b1232f74d8f..77a16b8625a 100644 --- a/sentry/src/test/java/io/sentry/SessionTest.kt +++ b/sentry/src/test/java/io/sentry/SessionTest.kt @@ -10,6 +10,42 @@ 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() @@ -31,6 +67,30 @@ class SessionTest { 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() diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 973070e0afe..c4aaaf5cef0 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 @@ -131,6 +132,157 @@ 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 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())) + + 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 `null SIDs on SessionEnd preserve 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())) + + val currentSessionFile = EnvelopeCache.getCurrentSessionFile(fixture.options.cacheDirPath!!) + assertThat(currentSessionFile.exists()).isTrue() + } + @Test fun `updates current file on session update and read it back`() { val cache = fixture.getSUT() @@ -317,6 +469,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() @@ -371,6 +553,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() @@ -409,14 +614,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 3603211b13e08963d62625c036ed84f34b19a148 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 21 Jul 2026 13:34:30 +0200 Subject: [PATCH 4/9] changelog Co-authored-by: Cursor --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e2d2175f0..188585c83a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add `InternalSentrySdk.captureEnvelopeNonTerminating` for hybrid SDKs (e.g. Flutter) so unhandled exceptions that don't terminate the process no longer end the session as `crashed`. The session is kept alive, marked pending-unhandled, and finalized as `unhandled` on session end (or `crashed` if a native crash follows). The existing `captureEnvelope(byte[], boolean)` behavior is unchanged. ([#XXXX](https://github.com/getsentry/sentry-java/pull/XXXX)) +- Add `InternalSentrySdk.captureEnvelopeNonTerminating` for hybrid SDKs (e.g. Flutter) so unhandled exceptions that don't terminate the process no longer end the session as `crashed`. The session is kept alive, marked pending-unhandled, and finalized as `unhandled` on session end (or `crashed` if a native crash follows). The existing `captureEnvelope(byte[], boolean)` behavior is unchanged. ([#5795](https://github.com/getsentry/sentry-java/pull/5795)) ## 8.49.0 From c6f705ca63a0d8c42ab4eb8978a965861d2351db Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 21 Jul 2026 13:40:31 +0200 Subject: [PATCH 5/9] Update CHANGELOG with unreleased fixes Added unreleased section with recent fixes for ANR and crash events. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d843ca5eb65..421ba724948 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## Unreleased + ### Fixes - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) From c38c9f93d5f81dbbe021b8cf192dcf660b0bcabf Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Tue, 21 Jul 2026 13:53:25 +0200 Subject: [PATCH 6/9] ref(android): Clarify non-terminating unhandled detection Check for an unhandled exception directly instead of using the crash-named predicate in a path where the process intentionally continues. Co-authored-by: Cursor --- .../main/java/io/sentry/android/core/InternalSentrySdk.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java index f89e312efac..61499032e25 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/InternalSentrySdk.java @@ -260,9 +260,7 @@ public static SentryId captureEnvelopeNonTerminating(final @NotNull byte[] envel for (SentryEnvelopeItem item : envelope.getItems()) { final SentryEvent event = item.getEvent(serializer); if (event != null) { - // isCrashed() means mechanism.handled=false (unhandled exception), not that the - // process terminated. For e.g Flutter that distinction is why we only mark pending here. - if (event.isCrashed()) { + if (event.getUnhandledException() != null) { markPendingUnhandled = true; addErrorsCount = true; } else if (event.isErrored()) { From 184e89a0169d3695ae1b7c520c298508ba57eba7 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 22 Jul 2026 11:57:16 +0200 Subject: [PATCH 7/9] fix(sessions): Scope delayed end protection to pending sessions Only preserve a mismatched current session when it carries pending unhandled state, retaining existing deletion behavior for other sessions. Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 12 +++-- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 47 +++++++++++++++++-- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index c3ccb29b709..cd82b3bcb19 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -118,10 +118,14 @@ 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); - if (endingSession != null - && currentSession != null - && hasSameSessionId(endingSession, currentSession) - && !currentSessionFile.delete()) { + final boolean preservePendingSession = + endingSession != null + && currentSession != null + && currentSession.isPendingUnhandled() + && endingSession.getSessionId() != null + && currentSession.getSessionId() != null + && !Objects.equals(endingSession.getSessionId(), currentSession.getSessionId()); + if (!preservePendingSession && !currentSessionFile.delete()) { options.getLogger().log(WARNING, "Current envelope doesn't exist."); } } diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index c4aaaf5cef0..d94db2dca4b 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -251,9 +251,10 @@ class EnvelopeCacheTest { } @Test - fun `mismatching SessionEnd preserves newer current session`() { + fun `mismatching SessionEnd preserves newer pending current session`() { val cache = fixture.getSUT() val currentSession = createSession() + currentSession.markPendingUnhandled() cache.persistCurrentSession(currentSession) val endingSession = createSession() @@ -270,7 +271,21 @@ class EnvelopeCacheTest { } @Test - fun `null SIDs on SessionEnd preserve current session`() { + 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) @@ -279,8 +294,34 @@ class EnvelopeCacheTest { 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!!) - assertThat(currentSessionFile.exists()).isTrue() + 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 From 9954dec6a15ee73f3f56eb2f134649ea1d3bebd9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 22 Jul 2026 12:42:06 +0200 Subject: [PATCH 8/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt index 1c73cf58d75..36b934b4906 100644 --- a/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt +++ b/sentry/src/test/java/io/sentry/PreviousSessionFinalizerTest.kt @@ -228,7 +228,7 @@ class PreviousSessionFinalizerTest { tmpDir, session = Session(null, null, null, "io.sentry.sample@1.0").apply { setPendingUnhandled(true) }, - nativeCrashTimestamp = Date(2023, 10, 1), + nativeCrashTimestamp = DateUtils.getDateTime("2023-10-01T00:00:00.000Z"), ) finalizer.run() From 2af889121c08b4136b9bdf3f99bad0bf7723701a Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Wed, 22 Jul 2026 12:49:37 +0200 Subject: [PATCH 9/9] fix(sessions): Verify pending session ordering before preservation Only retain a mismatched pending session when its start time proves it is newer than the delayed session end. Co-authored-by: Cursor --- .../java/io/sentry/cache/EnvelopeCache.java | 6 +++++- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 19 +++++++++++++++++-- 2 files changed, 22 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 cd82b3bcb19..a98badec84b 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."); @@ -124,7 +125,10 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not && currentSession.isPendingUnhandled() && endingSession.getSessionId() != null && currentSession.getSessionId() != null - && !Objects.equals(endingSession.getSessionId(), currentSession.getSessionId()); + && !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."); } diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index d94db2dca4b..d2f753e1200 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -253,10 +253,10 @@ class EnvelopeCacheTest { @Test fun `mismatching SessionEnd preserves newer pending current session`() { val cache = fixture.getSUT() - val currentSession = createSession() + val endingSession = createSession(started = Date(1_000)) + val currentSession = createSession(started = Date(2_000)) currentSession.markPendingUnhandled() cache.persistCurrentSession(currentSession) - val endingSession = createSession() val envelope = SentryEnvelope.from(fixture.options.serializer, endingSession, null) cache.storeEnvelope(envelope, HintUtils.createWithTypeCheckHint(SessionEndHint())) @@ -270,6 +270,21 @@ class EnvelopeCacheTest { 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()