Skip to content

feat: [SDK-5581] gate dispatcher bootstrap behind sdk_dispatcher_bootstrap - #2805

Merged
abdulraqeeb33 merged 25 commits into
ar/startup-flagsfrom
ar/sdk-5581
Oct 22, 2026
Merged

abdulraqeeb33 merged 25 commits into
ar/startup-flagsfrom
ar/sdk-5581

Conversation

@abdulraqeeb33

@abdulraqeeb33 abdulraqeeb33 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Gate dispatcher bootstrap behind the sdk_dispatcher_bootstrap startup flag, default off, and log the on/off choice.

Details

Motivation

PR 2766 moves pool creation off the caller thread. That change has not shipped. This keeps the old lazy pools in the binary and turns bootstrap on only when the startup flag is set, so the first release can ramp it.

Ticket: SDK-5581

Stacked on #2808 (startup flags file and snapshot), which depends on KMP #29. Catalog key: KMP #28.

Scope

  • One decision at pool creation. Callers of launchOn* and prewarm do not see the flag.
  • The choice reads FeatureManager.startupFlags { context }, the same per-process snapshot FeatureManager.isEnabled reports, so telemetry never shows the flag on while the pools are lazy.
  • On: bootstrap pools (construction on OneSignal-bootstrap). Off, no startup file yet, unreadable file, or no application: lazy pools. A failed choice also falls back to lazy pools instead of throwing from every launch.
  • prewarm(context) notes the entry point's context in AppContext (from feat: [SDK-5581] latch APP_STARTUP flags from a small startup file #2808) before warming, so receivers, services and activities that run before init latch from the file. If nothing noted a context, this process stays on lazy pools.
  • APP_STARTUP: a value fetched this process applies on the next start. First launch with no startup file stays lazy.
  • Logcat lines follow the customer's SDK log level (the choice at INFO, degraded choices and failures at WARN), so nothing prints at NONE. They use tag OneSignal and prefix sdk_dispatcher_bootstrap (on; pools=bootstrap, off, flag not in cache; pools=lazy, off, no cache yet; pools=lazy, off, cache unreadable; pools=lazy, off, no application; pools=lazy, off, choice failed; pools=lazy).
  • Degraded choices (unreadable file, no application, failed choice) also go to remote telemetry through Logging.warn. Pools are usually chosen before init installs telemetry, so the report waits for DispatcherChoiceReporter, a startable service, and choices made after that report right away.
  • Lane and pool failures also go through Logging.warn. A per-thread guard stops a log listener that launches onto the failing lane from re-entering the failure log.
  • Known divergence: if building the bootstrap pools fails after the snapshot read the flag as on (out of memory, in practice), the process runs lazy pools while FeatureManager.isEnabled still reports on. The off, choice failed report is what separates those launches in telemetry.
  • Bootstrap pools moved to BootstrapDispatcherPools.kt so OneSignalDispatchers.kt stays under 500 lines (378). No behavior change from the move.
  • The KMP pin comes from feat: [SDK-5581] latch APP_STARTUP flags from a small startup file #2808.

No public API change. The flag is not removed in this PR.

Testing

Unit testing

  • DispatcherLaneBehaviorTests: every lane behavior test runs with bootstrap pools and with lazy pools.
  • DispatcherBootstrapFlagTests: flag key selects bootstrap, no key and unreadable file stay on the caller, a failed choice falls back to lazy pools, the pools latch from the context passed to prewarm, no noted context stays lazy, and FeatureManager reports the pools actually latched.
  • Also: a choice failure after an on snapshot runs lazy pools while FeatureManager reports on, an unreadable file reaches telemetry once init installs it (and only once), a degraded choice after that reports right away, and a healthy choice is not reported. Verified red with the report reverted.
  • DispatcherFailureLogTests: lane failures reach log listeners in both modes, a listener that launches work does not re-enter lane creation, and logcat lines follow the SDK log level.
  • OneSignalDispatchersTests and ActivityLifecycleInitializerTests cover the mode-specific paths. Tests reset the remembered application context between specs.
  • ./gradlew :OneSignal:core:spotlessCheck :OneSignal:core:detekt :OneSignal:core:testReleaseUnitTest passes.

Manual testing

Emulator: Medium Phone, API 36, demo com.onesignal.example. Run on an earlier revision of this branch; the pool choice behavior is unchanged since.

  • Forced on: sdk_dispatcher_bootstrap: on; pools=bootstrap on the main thread. Process stayed up. Bootstrap worker threads appeared.
  • Forced off, then again with the hardcode removed (real cache, key absent): sdk_dispatcher_bootstrap: off, flag not in cache; pools=lazy. Init completed.
  • ANR probe, then removed: an 8s stall inside lane creation. Flag off blocked launchOnIO on the main thread and the SDK watchdog logged ANR detected! Main thread unresponsive for 6003ms (foreground). Flag on ran the same stall on OneSignal-bootstrap and launchOnIO returned in 0ms with no ANR line.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

AR Abdul Azeez and others added 2 commits October 20, 2026 12:44
…strap

Keep the lazy pools in the binary so the unreleased bootstrap path stays off until Turbine enables it. The choice is latched from the cached feature list before FeatureManager exists.

Co-authored-by: Cursor <cursoragent@cursor.com>
Read the cached flag before taking the lock, and resolve an application when startup did not record one, so a cold receiver can still select bootstrap. Log the on or off decision to logcat, and keep lazy prewarm from building executors after shutdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 requested a review from a team as a code owner October 20, 2026 19:17
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff Coverage Report (Changed Lines Only)

Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff).

Changed Files Coverage

  • BootstrapDispatcherPools.kt: 217/236 touched executable lines (91.9%) (414 touched lines in diff)
  • DispatcherChoiceReporter.kt: 2/2 touched executable lines (100.0%) (8 touched lines in diff)
  • LazyDispatcherPools.kt: 124/136 touched executable lines (91.2%) (238 touched lines in diff)
  • OneSignalDispatchers.kt: 121/127 touched executable lines (95.3%) (189 touched lines in diff)
  • ActivityLifecycleInitializer.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • SyncJobService.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • OneSignalImp.kt: 2/2 touched executable lines (100.0%) (2 touched lines in diff)
  • NotificationOpenedActivityHMS.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • NotificationOpenedActivityBase.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • OneSignalHmsEventBridge.kt: 0/2 touched executable lines (0.0%) (2 touched lines in diff)
    • 2 uncovered touched lines in this file
  • BootUpReceiver.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • BroadcastCompletion.kt: 2/2 touched executable lines (100.0%) (5 touched lines in diff)
  • FCMBroadcastReceiver.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • NotificationDismissReceiver.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • UpgradeReceiver.kt: 1/1 touched executable lines (100.0%) (1 touched lines in diff)
  • ADMMessageHandler.kt: 0/3 touched executable lines (0.0%) (3 touched lines in diff)
    • 3 uncovered touched lines in this file
  • ADMMessageHandlerJob.kt: 0/3 touched executable lines (0.0%) (3 touched lines in diff)
    • 3 uncovered touched lines in this file
  • ⚠️ IOMockHelper.kt: Not in coverage report (may not be compiled/tested)

Overall (aggregate gate)

476/521 touched executable lines covered (91.4% — requires ≥ 80%)

Per-file detail (informational; gate is aggregate above):

  • OneSignalHmsEventBridge.kt: 0.0% (2 uncovered touched lines)

  • ADMMessageHandler.kt: 0.0% (3 uncovered touched lines)

  • ADMMessageHandlerJob.kt: 0.0% (3 uncovered touched lines)

📥 View workflow run

@cursor cursor Bot 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.

Stale comment

Multi-model review (Opus 5.5, GPT 5.6 Sol, Grok 4.7)

Intent: latch sdk_dispatcher_bootstrap from the cached feature list before FeatureManager exists, default lazy, so PR 2766 can ramp.

Act on

Caller-thread prefs I/O on first latch (3/3). prewarm() / launchOn* → currentPools() → DispatcherBootstrapCache.read() does getSharedPreferences("OneSignal").getString(...) on the caller. That is the androidx.startup thread, initWithContext, and receivers before goAsync(). Moving the read outside poolsLock does not keep it off those threads. Default-off is a new main-thread disk read vs shipped 5.10.2, and flag-on still blocks the first launchOn* on XML load — the opposite of the bootstrap goal.

Consider

  • Lazy prewarm cannot retry after Thread.start() fails (3/3). compareAndSet publishes the thread before start(). The outer catch clears prewarmStarted but not prewarmThread, so later prewarm() returns without warming.
  • Unreadable config logs the payload (Grok, verified). Android JSONTokener includes the full MODEL_STORE_config string in JSONException; that document can hold fcmParams.apiKey.
  • Context already in hand is unused (Opus). initWithContext never calls noteApplicationContext, so a disabled startup provider falls through to hidden ActivityThread.currentApplication.
  • KMP pin is the pre-squash SHA (Opus). #28 is merged; main is 53e568c. Pin is still 4d26557.

Noted

Test isolation leak of a mock Application via the real noteApplicationContext; production latch / race-loser shutdown() is not exercised (resetForTest injects pools); FeatureManager/local overrides can disagree with the latched mode (telemetry not attached yet); fallback lazy lanes report Active.

Dismissed

logRelease bypassing OneSignal.Debug.logLevel — intentional so release (NONE) still shows the on/off line, and Logging can dispatch.

Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

Clear the prewarm thread if start fails, log only the exception type when the config store will not parse, and record the init context before warmup. Pin the catalog at the merged KMP commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Fixed in bdd9206, except the prefs read.

  1. The read stays on the first caller. That call has to choose pools before it returns. Waiting on a worker is the same stall, and treating an unread flag as lazy would leave a cold start on the old pools even when the key is cached.
  2. Lazy prewarm clears its thread if start fails, so the next prewarm can warm.
  3. A bad config logs the exception type only. The message was the whole store, including fcmParams.apiKey.
  4. initWithContext notes the context it already has before prewarm.
  5. KMP pin is 53e568c.

@fadi-george

Copy link
Copy Markdown
Contributor

Potential issues:

  • Blocking: OneSignalDispatchers.kt:483 (prewarm -> currentPools -> DispatcherBootstrapCache.read), first pool access now loads the whole OneSignal prefs file (including the operations queue) and parses the config JSON on the caller. Callers are main at startup: ActivityLifecycleInitializer, initWithContext(context, appId), SyncJobService, receivers. Main didn't read this file before, and it runs with the flag on or off. Could the pools publish gates immediately and latch the flag off main, or mirror the flag into a tiny standalone pref?
  • Nit: LazyDispatcherPools.kt:181, status() reports Active whenever the executor is null after init, which covers both the Dispatchers.IO fallback and a lane closed mid-build. Test/diagnostic only, but could report Fallback / Shutdown instead.
  • Nit: OneSignalDispatchersTests.kt:32, afterTest doesn't call resetForTest(), so later specs inherit bootstrap pools.
  • Nit: OneSignalDispatchersTests.kt:531, shutdown() interrupts the prewarm thread in release.await, so the test may pass because the thread dies rather than because of the closed checks. Worth confirming.

AR Abdul Azeez and others added 2 commits October 21, 2026 10:37
- OneSignalDispatchers reads sdk_dispatcher_bootstrap through
  FeatureManager.startupFlags instead of its own cache, so the first caller
  never loads the OneSignal prefs file on main and FeatureManager reports the
  pools the process actually latched.
- Drop DispatcherBootstrapCache.
- A closed lazy lane reports Shutdown.
- Run the shared lane behavior tests against both pool types, and cover an
  unreadable startup file.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 changed the base branch from main to ar/startup-flags October 21, 2026 15:42
AR Abdul Azeez and others added 2 commits October 21, 2026 11:59
…hoice

Use StartupFlagsSource and report a missing cache as "off, no cache yet".

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 marked this pull request as draft October 21, 2026 17:12
AR Abdul Azeez and others added 3 commits October 21, 2026 12:13
- Lane and pool failures go through Logging.warn again, so they reach
  remote telemetry. A per-thread guard stops a log listener that launches
  onto the failing lane from re-entering the failure log.
- A failed pool choice falls back to lazy pools instead of throwing from
  every launch.
- Tests reset the remembered application context between specs, so one
  spec's mock Application cannot pick the next spec's pools.
- Split the bootstrap pools out of OneSignalDispatchers.kt.

Co-authored-by: Cursor <cursoragent@cursor.com>
AR Abdul Azeez and others added 2 commits October 21, 2026 14:14
…emetry

An unreadable startup file, no application context, or a failed choice
now reaches Logging.warn once init has installed remote telemetry, via
DispatcherChoiceReporter. Pools are usually chosen before that, so the
report waits for it and later choices report right away. Also documents
and tests that a choice failure after an on snapshot leaves FeatureManager
reporting the flag on over lazy pools.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 marked this pull request as ready for review October 21, 2026 19:57
@abdulraqeeb33
abdulraqeeb33 requested a review from a team October 21, 2026 19:57
@fadi-george

Copy link
Copy Markdown
Contributor

Fix CI

@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

@fadi-george thanks. Status of each:

  1. Prefs read on main: went with your tiny standalone pref. feat: [SDK-5581] latch APP_STARTUP flags from a small startup file #2808 mirrors APP_STARTUP flags into OneSignalStartupFlags (device-protected storage) and reads it once per process. Pool choice no longer touches the OneSignal store. The first caller still opens that one small file.
  2. status(): a lane closed mid-build now reports Shutdown (closed is checked first). A lane on the Dispatchers.IO fallback still reports Active, since it is serving work. Happy to split that out as Fallback if useful.
  3. afterTest reset: OneSignalTestProjectConfig now resets the dispatcher and its inputs before and after every spec, so later specs no longer inherit bootstrap pools.
  4. Interrupt in the shutdown test: fixed in f6f8370. The hook ignores the interrupt, so the closed checks are what stop the build.

@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Fix CI

Its a flaxy test in OperationsRepo. We need to work on this seperately.

…priority

Main can block on both: waiting for init, which queues behind the
bootstrap builds, or on a lazy lane the prewarm thread is building. At
NORM_PRIORITY - 2 a busy device could starve them while main waited.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fadi-george
fadi-george added this pull request to stack #2810 October 21, 2026 20:20

@cursor cursor Bot 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.

Multi-model review (Opus 5.5, GPT 5.6 Sol, Grok 4.7)

Intent: latch sdk_dispatcher_bootstrap from the startup-flags snapshot (default lazy) so PR 2766 can ramp, and report degraded choices once init can see them.

Act on

Degraded-choice telemetry can be dropped (2/3 Opus+Sol). recordChoice and reportChoiceToTelemetry hand off across two volatiles. A chooser that reads telemetryReady == false can write pendingChoiceReport after the reporter has already drained it. That is the only remote event this commit adds.

Consider

  • Re-entrant kotlin.lazy from logFailure (2/3 Opus+Grok). Logging.warn can launchOn* on the same thread; the inner initializer can publish an executor that the outer catch then overwrites with null. That pool is never shutdownNow() and has allowCoreThreadTimeOut(false).
  • First-caller snapshot read is still on main (2/3, accepted). It is now the small OneSignalStartupFlags file, not the full OneSignal prefs store. Residual StrictMode/disk-read cost is documented.

Noted

  • Lazy shutdown() can miss an initializer that has passed its last closed check but not yet published (Sol).
  • A failed bootstrap Thread.start leaves prewarmStarted true; a later dispatch still retries the gate (Grok).
  • A NO_STORE first snapshot (receiver prewarm + failed ActivityThread reflection) latches every APP_STARTUP flag off for the process (Opus).

Dismissed

  • Log.i at LogLevel.NONE — intentional for the ramp.
  • Lazy prewarm() then immediate launchOn* constructing on the caller — documented flag-off behavior.
  • Prior findings (prewarm start retry, config payload in logs, initWithContext context) — fixed.
Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

@fadi-george

Copy link
Copy Markdown
Contributor

Some findings:

  • Blocking: OneSignalDispatchers.kt:483 (prewarm -> currentPools -> DispatcherBootstrapCache.read), first pool access now loads the whole OneSignal prefs file (including the operations queue) and parses the config JSON on the caller. Callers are main at startup: ActivityLifecycleInitializer, initWithContext(context, appId), SyncJobService, receivers. Main didn't read this file before, and it runs with the flag on or off. Could the pools publish gates immediately and latch the flag off main, or mirror the flag into a tiny standalone pref?
  • Nit: LazyDispatcherPools.kt:181, status() reports Active whenever the executor is null after init, which covers both the Dispatchers.IO fallback and a lane closed mid-build. Test/diagnostic only, but could report Fallback / Shutdown instead.
  • Nit: OneSignalDispatchersTests.kt:32, afterTest doesn't call resetForTest(), so later specs inherit bootstrap pools.
  • Nit: OneSignalDispatchersTests.kt:531, shutdown() interrupts the prewarm thread in release.await, so the test may pass because the thread dies rather than because of the closed checks. Worth confirming.

AR Abdul Azeez and others added 8 commits October 22, 2026 08:47
- The pool choice and failure lines no longer print when the customer's log level is below them.
- Use the shared currentApplication() fallback from the startup flags cache.
- Move tests to the supplier write.

Co-authored-by: Cursor <cursoragent@cursor.com>
ar/startup-flags replaced the ActivityThread.currentApplication reflection
with the AppContext holder, so the dispatcher side follows:

- OneSignalDispatchers drops its own appContext field, noteApplicationContext
  and the reflection fallback; the pool choice reads AppContext.
- prewarm takes the entry point's Context and notes it before warming, so
  receivers, services and activities that run before init still latch from
  the startup flags file.
- With no context noted, the pools stay lazy rather than guessing one.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@fadi-george

Copy link
Copy Markdown
Contributor

Minor:

  • Nit: OneSignalDispatchersTests.kt:28, afterTest still doesn't call resetForTest().

@abdulraqeeb33
abdulraqeeb33 merged commit 4c11b8c into main Oct 22, 2026
6 of 10 checks passed
@abdulraqeeb33
abdulraqeeb33 deleted the ar/sdk-5581 branch October 22, 2026 18:43
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.

3 participants