Skip to content

Crash: unsynchronized SimpleDateFormat in SessionTrackingSignalProvider can terminate the app process #108

Description

@MikesDevCorner

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/handleOnBackgrounddateFormat.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions