Summary
SessionTrackingSignalProvider holds a single SimpleDateFormat instance that is read from two different threads with no synchronization between them. SimpleDateFormat is documented as not thread-safe, so its shared internal Calendar can be corrupted, and because the throwing code path has no exception handling anywhere above it, the resulting exception terminates the app process.
We hit this in a Flutter app via telemetrydecksdk 4.0.0 (which pins com.telemetrydeck:kotlin-sdk:7.0.0). The analysis below is from reading the source; I do not have a symbolicated trace to attach, because the exception surfaces on a native background thread where the Flutter error handlers cannot see it.
Affected versions
7.0.0, and main as of today — both SimpleDateFormat fields referenced below are unchanged on main, so 7.1.0 is affected as well.
The race
SessionTrackingSignalProvider.kt:40
private val dateFormat: DateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
Two paths reach that one instance:
| Path |
Thread |
Holds the monitor? |
enrich() (:70) → enrichInternal() (:78) → createMetadata() (:93) → dateFormat.parse(it) (:97) |
whichever thread processes the signal |
no |
onStart/onStop (:129, :242) → handleOnForeground/handleOnBackground → dateFormat.format(now) (:160, :190, :222) |
main thread, via ProcessLifecycleOwner |
yes (@Synchronized) |
onStart/onStop are @Synchronized, but that lock protects nothing here, because the enrich path never acquires it. createMetadata is not synchronized and is reached for every signal — TelemetryDeck.kt:396:
enrichedPayload = sessionManager?.enrich(signalType, clientUser, enrichedPayload) ?: enrichedPayload
So any signal sent while the app moves to the foreground or background can interleave a parse() with a format() on the same formatter.
Why it terminates the process rather than dropping a signal
TelemetryDeck.signal() (TelemetryDeck.kt:251) calls createSignal(...) with no try/catch, and neither createSignal nor enrich guards the provider call. In the Flutter plugin the call is dispatched from a plain CoroutineScope(Dispatchers.IO) with no SupervisorJob and no CoroutineExceptionHandler (TelemetrydecksdkPlugin.kt:34), so anything thrown inside enrichment reaches the thread's default uncaught handler and kills the process.
Since enrichment is best-effort metadata, a failure there arguably should never be able to take the host app down, independently of the thread-safety fix.
Not avoidable from the outside
SessionTrackingSignalProvider is installed unconditionally as the session manager (TelemetryDeck.kt:945):
manager.sessionManager = customSessionProvider ?: SessionTrackingSignalProvider()
Builder.sessionProvider(...) can replace it, but the Flutter plugin never exposes that, and defaultParameters only swaps the providers list — not the session manager. So a Flutter consumer has no way to opt out of the affected code.
Same pattern in DateSerializer
DateSerializer.kt:18 shares one SimpleDateFormat on an object singleton, used by both serialize and deserialize. Same class of bug, wider blast radius if cache reads and outgoing serialization can overlap.
Suggested fix
Make both formatters per-thread rather than shared, e.g.
private val dateFormat = ThreadLocal.withInitial {
SimpleDateFormat("yyyy-MM-dd", Locale.US)
}
java.time.format.DateTimeFormatter would be the cleaner target (it is immutable and thread-safe), though it needs API 26 or desugaring given minSdk 23.
Independently, wrapping the enrichment loop in createSignal so a provider failure degrades to "no extra parameters" would stop any future provider bug from crashing host apps.
Happy to open a PR for the ThreadLocal change if that direction works for you.
Summary
SessionTrackingSignalProviderholds a singleSimpleDateFormatinstance that is read from two different threads with no synchronization between them.SimpleDateFormatis documented as not thread-safe, so its shared internalCalendarcan be corrupted, and because the throwing code path has no exception handling anywhere above it, the resulting exception terminates the app process.We hit this in a Flutter app via
telemetrydecksdk4.0.0 (which pinscom.telemetrydeck:kotlin-sdk:7.0.0). The analysis below is from reading the source; I do not have a symbolicated trace to attach, because the exception surfaces on a native background thread where the Flutter error handlers cannot see it.Affected versions
7.0.0, and
mainas of today — bothSimpleDateFormatfields referenced below are unchanged onmain, so 7.1.0 is affected as well.The race
SessionTrackingSignalProvider.kt:40Two paths reach that one instance:
enrich()(:70) →enrichInternal()(:78) →createMetadata()(:93) →dateFormat.parse(it)(:97)onStart/onStop(:129, :242) →handleOnForeground/handleOnBackground→dateFormat.format(now)(:160, :190, :222)ProcessLifecycleOwner@Synchronized)onStart/onStopare@Synchronized, but that lock protects nothing here, because theenrichpath never acquires it.createMetadatais not synchronized and is reached for every signal —TelemetryDeck.kt:396:So any signal sent while the app moves to the foreground or background can interleave a
parse()with aformat()on the same formatter.Why it terminates the process rather than dropping a signal
TelemetryDeck.signal()(TelemetryDeck.kt:251) callscreateSignal(...)with notry/catch, and neithercreateSignalnorenrichguards the provider call. In the Flutter plugin the call is dispatched from a plainCoroutineScope(Dispatchers.IO)with noSupervisorJoband noCoroutineExceptionHandler(TelemetrydecksdkPlugin.kt:34), so anything thrown inside enrichment reaches the thread's default uncaught handler and kills the process.Since enrichment is best-effort metadata, a failure there arguably should never be able to take the host app down, independently of the thread-safety fix.
Not avoidable from the outside
SessionTrackingSignalProvideris installed unconditionally as the session manager (TelemetryDeck.kt:945):Builder.sessionProvider(...)can replace it, but the Flutter plugin never exposes that, anddefaultParametersonly swaps theproviderslist — not the session manager. So a Flutter consumer has no way to opt out of the affected code.Same pattern in DateSerializer
DateSerializer.kt:18shares oneSimpleDateFormaton anobjectsingleton, used by bothserializeanddeserialize. Same class of bug, wider blast radius if cache reads and outgoing serialization can overlap.Suggested fix
Make both formatters per-thread rather than shared, e.g.
java.time.format.DateTimeFormatterwould be the cleaner target (it is immutable and thread-safe), though it needs API 26 or desugaring givenminSdk23.Independently, wrapping the enrichment loop in
createSignalso a provider failure degrades to "no extra parameters" would stop any future provider bug from crashing host apps.Happy to open a PR for the
ThreadLocalchange if that direction works for you.