Skip to content

[SDK-499] Synchronize EmbeddedSessionManager to fix embedded session races - #1083

Merged
franco-zalamena-iterable merged 8 commits into
masterfrom
feature/SDK-499-embedded-session-thread-safety
Aug 25, 2026
Merged

[SDK-499] Synchronize EmbeddedSessionManager to fix embedded session races#1083
franco-zalamena-iterable merged 8 commits into
masterfrom
feature/SDK-499-embedded-session-thread-safety

Conversation

@franco-zalamena-iterable

@franco-zalamena-iterable franco-zalamena-iterable commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

📝 Summary

Makes EmbeddedSessionManager internally thread-safe, fixing a reported NullPointerException plus two further races in the same class.

🎟️ Jira Ticket: SDK-499

📖 Description

EmbeddedSessionManager had no synchronization at all, and its impression API has no SDK-internal callers — it is driven entirely by integrator code, so it can be entered from any thread. Three races: the reported NPE in updateDisplayCountAndDuration() (null-check then !! on start, which pauseImpression() nulls in between), concurrent modification of the impression map, and duplicate session tracking when endSession() raced with itself.

All access to session, impressions and the impression fields now goes through one private lock. A single class-wide lock instead of a concurrent map because the invariants span several fields — endSession() has to end all impressions, snapshot the list and reset both fields as one step. trackEmbeddedSession() is called after the lock is released, since it re-enters IterableApi and integrator code.

endSession() still leaves the session running when there are no impressions. That is pre-existing behavior, changing it changes what gets reported, so it is tracked separately in SDK-701. A test pins the current behavior so that change is explicit when SDK-701 lands.

No public API change and no behavior change on the single-threaded path.

This development was started by contributor @Shamyyoun

🧪 How to test?

./gradlew :iterableapi:testDebugUnitTest --tests "com.iterable.iterableapi.EmbeddedSessionManagerThreadSafetyTest"

concurrentSessionAndImpressionUpdatesDoNotThrow races 8 threads over the session and impression API. Reverting the two source files to master reproduces the reported crash:

java.lang.AssertionError: concurrent access failed: [java.lang.NullPointerException] expected:<0> but was:<1>

Full iterableapi suite passed locally.

🧾 Changelog

  • Fixed a NullPointerException in EmbeddedSessionManager.updateDisplayCountAndDuration() that could crash apps calling embedded session methods off the main thread. EmbeddedSessionManager is now internally synchronized, which also fixes concurrent modification of its impression map and duplicate session tracking when endSession() raced with itself. Thanks to @Shamyyoun for the report and initial fix.

📹 Loom recording if applicable

N/A

🐞 Github Issues solved

Addresses #1052, supersedes #1053. Deliberately not using a closing keyword yet so merging doesn't auto-close the issue before we reply to the reporter.

📚 Docs PR if applicable

N/A

Shamyyoun and others added 5 commits August 21, 2026 17:54
The NPE fix on start closed the reported crash but left the rest of the
class open to the same threading. Callers reach EmbeddedSessionManager
from arbitrary threads (issue #1052 reports Dispatchers.Default), and
impressions is a plain LinkedHashMap: startImpression writes to it while
endSession iterates it and then reassigns the field, so concurrent use
could also throw ConcurrentModificationException or lose entries. The
session field had the same check-then-act shape as start — two threads
could both pass isTracking() and each track a session.

Guard every access to impressions, session, and the impression fields
with one private lock. The per-impression synchronized block is now
redundant and removed; @volatile on start stays as visibility defence,
but the class lock is the invariant. trackEmbeddedSession runs after the
lock is released, since it calls back into IterableApi.

endSession keeps its existing behaviour of doing nothing — not even
resetting the session — when there are no impressions.

Adds a test racing 8 threads over startImpression, pauseImpression,
startSession and endSession, which reproduces the reported
NullPointerException without this change, plus two tests pinning the
endSession-with-no-impressions behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@franco-zalamena-iterable
franco-zalamena-iterable requested a review from a team as a code owner August 24, 2026 11:21

@jferrao-itrbl jferrao-itrbl left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved with very minor comments.

var displayCount: Int = 0,
var duration: Float = 0.0f,
var start: Date? = null
@Volatile var start: Date? = null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The start var is marked with @Volatile so every thread always sees the latest value. That extra marking is not needed, because the class already uses one lock for all of this data. It can make a later reader think the marking @Volatile is what stops the crash, when the lock is what actually does.

nit: Remove the extra @Volatile marking on start, or add a short comment that the lock is what keeps this safe.

Comment on lines +32 to +37
public void endSessionWithoutImpressionsLeavesSessionRunning() {
sessionManager.startSession();
sessionManager.endSession();

assertTrue(sessionManager.isTracking());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test checks that ending a session with no viewed messages still leaves the session running. Though that is a known bug, kept on purpose in this change, the test never says that in the file so someone could think this is the intended product behaviour.

nit: Add a one-line comment on the test: this pins a current bug; flip the assertion when ticket SDK-701 is done.

Comment on lines 108 to 127
@@ -108,13 +127,14 @@ public class EmbeddedSessionManager {
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe slightly out-of-scope but these two private methods walk and update the in-memory list of viewed messages without taking the lock themselves. Today that is fine, because the only caller already holds the lock. A later change could call them without the lock and bring races back.

nit: Add a short comment on both helpers: caller must already hold the lock.

Drop the redundant @volatile on EmbeddedImpressionData.start. Every read and
write of it happens under the session manager's lock, so the annotation only
suggested the field was safe on its own.

Rename the three private helpers that touch impressions without locking to a
Locked suffix, so the requirement is visible at each call site instead of only
at the declaration.

Note in the test that leaving the session open when there are no impressions is
existing behavior being pinned, not intended behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…edded-session-thread-safety

# Conflicts:
#	CHANGELOG.md
@franco-zalamena-iterable
franco-zalamena-iterable merged commit 7a9e946 into master Aug 25, 2026
8 checks passed
@franco-zalamena-iterable
franco-zalamena-iterable deleted the feature/SDK-499-embedded-session-thread-safety branch August 25, 2026 16:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NullPointerException in EmbeddedSessionManager.updateDisplayCountAndDuration() — thread-safety race condition

4 participants