Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ object OneSignalDispatchers {
* thread (Activity-lifecycle handler, `JobService.onStartJob`, etc.), the construction cost
* — which includes a `kotlinx.coroutines.BuildersKt.launch` that hits
* `ThreadPoolExecutor.execute` and `LinkedBlockingQueue.offer` synchronously — is paid on
* the calling thread, blocking the main thread for many seconds on cold start. See SDK-4507.
* the calling thread, blocking the main thread for many seconds on cold start.
*
* Calling [prewarm] from a non-time-sensitive spot in [com.onesignal.OneSignal.initWithContext]
* shifts that cost to a dedicated `OneSignal-prewarm` daemon thread, so the first
Expand All @@ -199,31 +199,60 @@ object OneSignalDispatchers {
* on the caller. Failures are logged and swallowed because the executors retain their
* existing fallback paths (e.g. `Dispatchers.IO.limitedParallelism(1)` for [SerialIO]) and a
* failed prewarm will simply mean the first production caller pays the original cost.
*
* **Best-effort, not a hard guarantee.** Because [prewarm] is fire-and-forget, a caller that
* dispatches immediately afterward can still win the lazy-init race and pay construction on
* its own thread. The fix relies on placing [prewarm] at cold-start entry points where there
* is meaningful lead time (e.g. a `goAsync()` handoff or `initWithContext` work) before the
* first `suspendify*` / `launchOn*` dispatch. The known main-thread cold-start entry points:
* | Entry point | Class |
* |---|---|
* | `initWithContext` / `initWithContextSuspend` | `OneSignalImp` |
* | `onStartJob` | `core.services.SyncJobService` |
* | `onReceive` | `FCMBroadcastReceiver`, `NotificationDismissReceiver`, `BootUpReceiver`, `UpgradeReceiver` |
* | `onMessage` / registration callbacks | `ADMMessageHandler`, `ADMMessageHandlerJob` |
* | `onNewToken` / `onMessageReceived` | `OneSignalHmsEventBridge` |
* | `processIntent` / `processOpen` | `NotificationOpenedActivityBase`, `NotificationOpenedActivityHMS` |
*
* When adding a new cold-start entry point (receiver, job, activity trampoline, push bridge),
* call [prewarm] at the top of it before the first dispatch.
Comment thread
claude[bot] marked this conversation as resolved.
*/
fun prewarm() {
if (prewarmStarted) return
synchronized(prewarmLock) {
if (prewarmStarted) return
prewarmStarted = true
}
val prewarmThread = Thread(
{
try {
// Each launch* call below triggers the corresponding lazy chain
// (executor -> dispatcher -> scope) and submits an empty coroutine,
// which forces the worker thread(s) to start as well.
launchOnIO { /* warm IOScope + ioExecutor */ }
launchOnDefault { /* warm DefaultScope + defaultExecutor */ }
launchOnSerialIO { /* warm SerialIOScope + serialIOExecutor */ }
} catch (e: Exception) {
Logging.warn("OneSignalDispatchers.prewarm failed: ${e.message}")
}
},
"$BASE_THREAD_NAME-prewarm",
)
prewarmThread.isDaemon = true
prewarmThread.priority = Thread.NORM_PRIORITY - 2
prewarmThread.start()
try {
val prewarmThread = Thread(
{
try {
// Each launch* call below triggers the corresponding lazy chain
// (executor -> dispatcher -> scope) and submits an empty coroutine,
// which forces the worker thread(s) to start as well.
launchOnIO { /* warm IOScope + ioExecutor */ }
launchOnDefault { /* warm DefaultScope + defaultExecutor */ }
launchOnSerialIO { /* warm SerialIOScope + serialIOExecutor */ }
} catch (e: Throwable) {
Logging.warn("OneSignalDispatchers.prewarm failed: ${e.message}")
}
Comment thread
claude[bot] marked this conversation as resolved.
},
"$BASE_THREAD_NAME-prewarm",
)
prewarmThread.isDaemon = true
prewarmThread.priority = Thread.NORM_PRIORITY - 2
prewarmThread.start()
} catch (t: Throwable) {
// Constructing, configuring, or starting the daemon can itself fail before the body
// ever runs (e.g. OutOfMemoryError "unable to create new native thread", a
// SecurityManager denial, or InternalError). Swallow it so a prewarm failure never
// propagates onto the cold-start entry point's caller thread, and reset the guard so a
// later entry point can retry. The dispatchers keep their lazy fallbacks, so the only
// cost of a failed prewarm is that the first real dispatch pays the original
// construction cost.
synchronized(prewarmLock) { prewarmStarted = false }
Logging.warn("OneSignalDispatchers.prewarm failed to start daemon: ${t.message}")
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ package com.onesignal.core.services

import android.app.job.JobParameters
import com.onesignal.OneSignal
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.core.internal.background.IBackgroundManager
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.mocks.IOMockHelper
import com.onesignal.mocks.IOMockHelper.awaitIO
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk
import io.mockk.unmockkAll
import io.mockk.unmockkObject
import io.mockk.verify
import io.mockk.verifyOrder

private class Mocks {
val syncJobService = spyk(SyncJobService(), recordPrivateCalls = true)
Expand All @@ -34,10 +38,31 @@ class SyncJobServiceTests : FunSpec({
mocks = Mocks() // fresh instance for each test
mockkObject(OneSignal)
every { OneSignal.getService<IBackgroundManager>() } returns mocks.mockBackgroundManager
// IOMockHelper owns the OneSignalDispatchers object mock (incl. the prewarm() stub) for the
// whole spec; every onStartJob below calls prewarm(). Clear only its recorded calls so the
// ordering test's count starts at zero, while keeping IOMockHelper's stubbed answers.
clearMocks(OneSignalDispatchers, answers = false)
}

afterAny {
unmockkAll()
// Tear down only the mock this spec owns. OneSignalDispatchers and the ThreadUtils statics
// are owned by IOMockHelper and torn down in its afterSpec — unmockkAll() here would strip
// them after the first test and break the remaining ones.
unmockkObject(OneSignal)
}

test("onStartJob calls prewarm before suspendifyOnIO") {
coEvery { OneSignal.initWithContext(any()) } returns false

mocks.syncJobService.onStartJob(mocks.jobParameters)

verify(exactly = 1) { OneSignalDispatchers.prewarm() }
// prewarm() must run before the first suspendifyOnIO dispatch, otherwise the cold-init cost
// would already be paid on the caller (main) thread by the time the helper is entered.
verifyOrder {
OneSignalDispatchers.prewarm()
suspendifyOnIO(any<suspend () -> Unit>())
}
}

test("onStartJob returns true when initWithContext fails") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ package com.onesignal
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnDefault
import com.onesignal.core.internal.application.OneSignalInternalActivity
import com.onesignal.notifications.internal.open.INotificationOpenedProcessorHMS
Expand Down Expand Up @@ -79,6 +80,9 @@ class NotificationOpenedActivityHMS :
}

private fun processOpen(intent: Intent?) {
// HMS notification-open trampoline runs on the main thread and can cold-start the process;
// warm dispatchers before the first suspendifyOnDefault dispatch.
OneSignalDispatchers.prewarm()
suspendifyOnDefault {
if (!OneSignal.initWithContext(applicationContext)) {
return@suspendifyOnDefault
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import android.os.Bundle
import com.onesignal.OneSignal
import com.onesignal.common.AndroidUtils
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnDefault
import com.onesignal.core.internal.application.OneSignalInternalActivity
import com.onesignal.notifications.internal.open.INotificationOpenedProcessor
Expand All @@ -52,7 +53,10 @@
processIntent()
}

internal open fun processIntent() {
// Notification-open trampoline runs on the main thread and can cold-start the process;
// warm dispatchers before the first suspendifyOnDefault dispatch.

Check warning on line 58 in OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt

View check run for this annotation

Claude / Claude Code Review

NotificationOpenedActivityBaseTest spawns real prewarm daemon

Pre-existing `NotificationOpenedActivityBaseTest` (lines 41-56) instantiates `TestNotificationOpenedActivity` via `Robolectric.buildActivity(...).setup()`, which now invokes the new `OneSignalDispatchers.prewarm()` call inside `super.processIntent()` (NotificationOpenedActivityBase.kt:57). The spec does not `listener(IOMockHelper)` nor `mockkObject(OneSignalDispatchers)`, so the real `prewarm()` runs — spawning the `OneSignal-prewarm` daemon and lazy-initializing all three real `ThreadPoolExecut
Comment thread
claude[bot] marked this conversation as resolved.
OneSignalDispatchers.prewarm()
suspendifyOnDefault {
if (!OneSignal.initWithContext(applicationContext)) {
return@suspendifyOnDefault
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.os.Bundle
import com.huawei.hms.push.RemoteMessage
import com.onesignal.OneSignal
import com.onesignal.common.JSONUtils
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnDefault
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.core.internal.time.ITime
Expand Down Expand Up @@ -39,6 +40,8 @@ object OneSignalHmsEventBridge {
) {
if (firstToken.compareAndSet(true, false)) {
Logging.info("OneSignalHmsEventBridge onNewToken - HMS token: $token Bundle: $bundle")
// HMS can cold-start the process before initWithContext; warm dispatchers first.
OneSignalDispatchers.prewarm()
suspendifyOnIO {
val registerer = OneSignal.getService<IPushRegistratorCallback>()
registerer.fireCallback(token)
Expand All @@ -64,6 +67,8 @@ object OneSignalHmsEventBridge {
context: Context,
message: RemoteMessage,
) {
// HMS can cold-start the process before initWithContext; warm dispatchers first.
OneSignalDispatchers.prewarm()
suspendifyOnDefault {
if (!OneSignal.initWithContext(context)) {
return@suspendifyOnDefault
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.onesignal.OneSignal
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.notifications.internal.restoration.INotificationRestoreWorkManager
Expand All @@ -39,6 +40,10 @@ class BootUpReceiver : BroadcastReceiver() {
context: Context,
intent: Intent,
) {
// Boot can cold-start the process before initWithContext. Warm dispatchers before
// goAsync() so the daemon has lead time before the first suspendifyOnIO dispatch.
OneSignalDispatchers.prewarm()

val pendingResult = goAsync()
// in background, init onesignal and begin enqueueing restore work
suspendifyOnIO {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.onesignal.OneSignal
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.notifications.internal.bundle.INotificationBundleProcessor
Expand All @@ -25,6 +26,11 @@ class FCMBroadcastReceiver : BroadcastReceiver() {
return
}

// FCM can cold-start the process before initWithContext. Warm dispatchers before goAsync()
// so the prewarm daemon gets a head start during the handoff, making the dispatchers more
// likely to be warm by the time the suspendifyOnIO below submits its work.
OneSignalDispatchers.prewarm()

val pendingResult = goAsync()
// process in background
suspendifyOnIO {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.onesignal.OneSignal
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.notifications.internal.open.INotificationOpenedProcessor
Expand All @@ -39,6 +40,10 @@ class NotificationDismissReceiver : BroadcastReceiver() {
context: Context,
intent: Intent,
) {
// A dismiss can cold-start the process before initWithContext. Warm dispatchers before
// goAsync() so the daemon has lead time before the first suspendifyOnIO dispatch.
OneSignalDispatchers.prewarm()

val pendingResult = goAsync()

suspendifyOnIO {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import com.onesignal.OneSignal
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.notifications.internal.restoration.INotificationRestoreWorkManager
Expand All @@ -48,6 +49,10 @@ class UpgradeReceiver : BroadcastReceiver() {
return
}

// App upgrade can cold-start the process before initWithContext. Warm dispatchers before
// goAsync() so the daemon has lead time before the first suspendifyOnIO dispatch.
OneSignalDispatchers.prewarm()

val pendingResult = goAsync()

// init OneSignal and enqueue restore work in background
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.onesignal.notifications.services
import android.content.Intent
import com.amazon.device.messaging.ADMMessageHandlerBase
import com.onesignal.OneSignal
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.notifications.internal.bundle.INotificationBundleProcessor
Expand All @@ -15,6 +16,8 @@ class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") {
val context = applicationContext
val bundle = intent.extras ?: return

// ADM can cold-start the process before initWithContext; warm dispatchers first.
OneSignalDispatchers.prewarm()
suspendifyOnIO {
if (!OneSignal.initWithContext(context)) {
Logging.warn("onMessage skipped due to failed OneSignal init")
Expand All @@ -29,6 +32,7 @@ class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") {
override fun onRegistered(newRegistrationId: String) {
Logging.info("ADM registration ID: $newRegistrationId")

OneSignalDispatchers.prewarm()
suspendifyOnIO {
val registerer = OneSignal.getService<IPushRegistratorCallback>()
registerer.fireCallback(newRegistrationId)
Expand All @@ -44,6 +48,7 @@ class ADMMessageHandler : ADMMessageHandlerBase("ADMMessageHandler") {
)
}

OneSignalDispatchers.prewarm()
suspendifyOnIO {
val registerer = OneSignal.getService<IPushRegistratorCallback>()
registerer.fireCallback(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import com.amazon.device.messaging.ADMMessageHandlerJobBase
import com.onesignal.OneSignal
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.suspendifyOnIO
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.notifications.internal.bundle.INotificationBundleProcessor
Expand All @@ -22,6 +23,8 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() {

val safeContext = context.applicationContext

// ADM can cold-start the process before initWithContext; warm dispatchers first.
OneSignalDispatchers.prewarm()
suspendifyOnIO {
if (!OneSignal.initWithContext(safeContext)) {
Logging.warn("onMessage skipped due to failed OneSignal init")
Expand All @@ -39,6 +42,7 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() {
) {
Logging.info("ADM registration ID: $newRegistrationId")

OneSignalDispatchers.prewarm()
suspendifyOnIO {
val registerer = OneSignal.getService<IPushRegistratorCallback>()
registerer.fireCallback(newRegistrationId)
Expand All @@ -63,6 +67,7 @@ class ADMMessageHandlerJob : ADMMessageHandlerJobBase() {
)
}

OneSignalDispatchers.prewarm()
suspendifyOnIO {
val registerer = OneSignal.getService<IPushRegistratorCallback>()
registerer.fireCallback(null)
Expand Down
Loading
Loading