From 1f6932cad4c88099440749d540ced34c592cc4a0 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 20 Oct 2026 12:44:25 -0500 Subject: [PATCH 01/13] feat: [SDK-5581] gate dispatcher bootstrap behind sdk_dispatcher_bootstrap 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 --- OneSignal-KMP-SDK | 2 +- .../common/threading/DispatcherPools.kt | 28 +++ .../common/threading/LazyDispatcherPools.kt | 179 ++++++++++++++++++ .../common/threading/OneSignalDispatchers.kt | 159 +++++++++++----- .../impl/ActivityLifecycleInitializer.kt | 3 + .../features/DispatcherBootstrapCache.kt | 69 +++++++ .../threading/OneSignalDispatchersTests.kt | 30 +++ .../impl/ActivityLifecycleInitializerTests.kt | 1 + .../features/DispatcherBootstrapCacheTests.kt | 112 +++++++++++ 9 files changed, 535 insertions(+), 48 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DispatcherPools.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index ee61d46732..4d26557be5 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit ee61d4673265876e5cab6328b7a0745f79b7a5aa +Subproject commit 4d26557be59b67d8f77ed996b0be5d7308eb95f0 diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DispatcherPools.kt new file mode 100644 index 0000000000..c8d665bf3c --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DispatcherPools.kt @@ -0,0 +1,28 @@ +package com.onesignal.common.threading + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.ThreadPoolExecutor + +internal interface DispatcherLane { + val dispatcher: CoroutineDispatcher + + fun executor(): ThreadPoolExecutor? + + fun status(): String +} + +internal interface DispatcherPools { + val io: DispatcherLane + val defaultLane: DispatcherLane + val serialIO: DispatcherLane + val ingress: DispatcherLane + val ioScope: CoroutineScope + val defaultScope: CoroutineScope + val serialIOScope: CoroutineScope + val ingressScope: CoroutineScope + + fun prewarm() + + fun shutdown() +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt new file mode 100644 index 0000000000..e0be688248 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt @@ -0,0 +1,179 @@ +package com.onesignal.common.threading + +import com.onesignal.debug.internal.logging.Logging +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadFactory +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * Pre-bootstrap pools. The first dispatch constructs the executor on the caller thread. + * [prewarm] tries to do that on a daemon thread, and a faster dispatch can still win. + */ +@Suppress("TooGenericExceptionCaught") +internal class LazyDispatcherPools : DispatcherPools { + private val ioLane = + LazyExecutorLane("IO", IO_CORE, IO_MAX, QUEUE_CAPACITY, "$BASE-IO", Thread.NORM_PRIORITY - 1) { + Dispatchers.IO + } + private val defaultExecutorLane = + LazyExecutorLane("DEFAULT", DEFAULT_CORE, DEFAULT_MAX, QUEUE_CAPACITY, "$BASE-Default", Thread.NORM_PRIORITY) { + Dispatchers.Default + } + private val serialLane = + LazyExecutorLane("SERIAL_IO", 1, 1, Int.MAX_VALUE, "$BASE-SerialIO", Thread.NORM_PRIORITY - 1) { + @Suppress("OPT_IN_USAGE") + Dispatchers.IO.limitedParallelism(1) + } + private val ingressLane = + LazyExecutorLane("INGRESS", 1, 1, Int.MAX_VALUE, "$BASE-Ingress", Thread.NORM_PRIORITY - 1) { + @Suppress("OPT_IN_USAGE") + Dispatchers.IO.limitedParallelism(1) + } + + override val io: DispatcherLane = ioLane + override val defaultLane: DispatcherLane = defaultExecutorLane + override val serialIO: DispatcherLane = serialLane + override val ingress: DispatcherLane = ingressLane + + private val ioScopeLazy = lazy { CoroutineScope(SupervisorJob() + ioLane.dispatcher) } + private val defaultScopeLazy = lazy { CoroutineScope(SupervisorJob() + defaultExecutorLane.dispatcher) } + private val serialScopeLazy = lazy { CoroutineScope(SupervisorJob() + serialLane.dispatcher) } + private val ingressScopeLazy = lazy { CoroutineScope(SupervisorJob() + ingressLane.dispatcher) } + + override val ioScope: CoroutineScope get() = ioScopeLazy.value + override val defaultScope: CoroutineScope get() = defaultScopeLazy.value + override val serialIOScope: CoroutineScope get() = serialScopeLazy.value + override val ingressScope: CoroutineScope get() = ingressScopeLazy.value + + override fun prewarm() { + val thread = + Thread( + { + warm { ioScope } + warm { defaultScope } + warm { serialIOScope } + warm { ingressScope } + }, + "$BASE-prewarm", + ) + thread.isDaemon = true + thread.priority = Thread.NORM_PRIORITY - 2 + thread.start() + } + + override fun shutdown() { + listOf(ioScopeLazy, defaultScopeLazy, serialScopeLazy, ingressScopeLazy).forEach { scope -> + if (scope.isInitialized()) runCatching { scope.value.cancel() } + } + listOf(ioLane, defaultExecutorLane, serialLane, ingressLane).forEach { it.shutdown() } + } + + private fun warm(resolve: () -> CoroutineScope) { + runCatching { resolve().launch { } } + } + + private class LazyExecutorLane( + private val lane: String, + private val core: Int, + private val max: Int, + private val queueCapacity: Int, + private val threadName: String, + private val priority: Int, + private val fallback: () -> CoroutineDispatcher, + ) : DispatcherLane { + private val executorLazy: Lazy = + lazy { + var created: ThreadPoolExecutor? = null + try { + OneSignalDispatchers.beforeLaneCreateForTest?.invoke(lane) + created = newExecutor() + OneSignalDispatchers.afterExecutorCreateForTest?.invoke(lane, created) + created + } catch (t: Throwable) { + created?.shutdownNow() + Logging.warn("OneSignalDispatchers: Using fallback for $lane lane: ${t.message}", t) + null + } + } + + override val dispatcher: CoroutineDispatcher by lazy { + val executor = executorLazy.value + if (executor == null) { + fallback() + } else { + try { + executor.asCoroutineDispatcher() + } catch (t: Throwable) { + Logging.warn("OneSignalDispatchers: Using fallback for $lane lane: ${t.message}", t) + fallback() + } + } + } + + override fun executor(): ThreadPoolExecutor? = + if (executorLazy.isInitialized()) executorLazy.value else null + + override fun status(): String = + try { + val executor = if (executorLazy.isInitialized()) executorLazy.value else null + when { + executor == null && executorLazy.isInitialized() -> "Active" + executor == null -> "Cold" + executor.isShutdown -> "Shutdown" + else -> "Active" + } + } catch (_: Throwable) { + "Cold" + } + + fun shutdown() { + if (!executorLazy.isInitialized()) return + runCatching { executorLazy.value?.shutdownNow() } + } + + private fun newExecutor(): ThreadPoolExecutor = + ThreadPoolExecutor( + core, + max, + KEEP_ALIVE_SECONDS, + TimeUnit.SECONDS, + LinkedBlockingQueue(queueCapacity), + NamedThreadFactory(threadName, priority), + ).apply { + allowCoreThreadTimeOut(false) + } + } + + private class NamedThreadFactory( + private val namePrefix: String, + private val priority: Int, + ) : ThreadFactory { + private val threadNumber = AtomicInteger(1) + + override fun newThread(r: Runnable): Thread { + val thread = Thread(r, "$namePrefix-${threadNumber.getAndIncrement()}") + thread.isDaemon = true + thread.priority = priority + return thread + } + } + + private companion object { + private const val BASE = OneSignalDispatchers.BASE_THREAD_NAME + private const val IO_CORE = 2 + private const val IO_MAX = 3 + private const val DEFAULT_CORE = 2 + private const val DEFAULT_MAX = 3 + private const val QUEUE_CAPACITY = 200 + private const val KEEP_ALIVE_SECONDS = 30L + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 738fdc22ae..b197ef0337 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -1,5 +1,6 @@ package com.onesignal.common.threading +import android.content.Context import com.onesignal.debug.internal.logging.Logging import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -118,7 +119,9 @@ object OneSignalDispatchers { private class GateDispatcher( private val lane: Lane, private val requestBootstrap: (GateDispatcher) -> Unit, - ) : CoroutineDispatcher() { + ) : CoroutineDispatcher(), DispatcherLane { + override val dispatcher: CoroutineDispatcher get() = this + private val lock = Any() private val pending = ArrayDeque>() private var state = LaneState.COLD @@ -234,7 +237,7 @@ object OneSignalDispatchers { failPending("OneSignal dispatcher bootstrap thread failed to start") } - fun status(): String = + override fun status(): String = synchronized(lock) { when (state) { LaneState.READY -> "Active" @@ -243,7 +246,7 @@ object OneSignalDispatchers { } } - fun executor(): ThreadPoolExecutor? = synchronized(lock) { target?.executor } + override fun executor(): ThreadPoolExecutor? = synchronized(lock) { target?.executor } private fun failPending(reason: String) { val queued = @@ -333,26 +336,30 @@ object OneSignalDispatchers { } } - private class Pools { + private class Pools : DispatcherPools { private val coordinator = BootstrapCoordinator() - val IO = GateDispatcher(Lane.IO, coordinator::request) - val Default = GateDispatcher(Lane.DEFAULT, coordinator::request) - val SerialIO = GateDispatcher(Lane.SERIAL_IO, coordinator::request) - val Ingress = GateDispatcher(Lane.INGRESS, coordinator::request) - val IOScope = CoroutineScope(SupervisorJob() + IO) - val DefaultScope = CoroutineScope(SupervisorJob() + Default) - val SerialIOScope = CoroutineScope(SupervisorJob() + SerialIO) - val IngressScope = CoroutineScope(SupervisorJob() + Ingress) + private val ioGate = GateDispatcher(Lane.IO, coordinator::request) + private val defaultGate = GateDispatcher(Lane.DEFAULT, coordinator::request) + private val serialGate = GateDispatcher(Lane.SERIAL_IO, coordinator::request) + private val ingressGate = GateDispatcher(Lane.INGRESS, coordinator::request) + override val io: DispatcherLane = ioGate + override val defaultLane: DispatcherLane = defaultGate + override val serialIO: DispatcherLane = serialGate + override val ingress: DispatcherLane = ingressGate + override val ioScope = CoroutineScope(SupervisorJob() + ioGate) + override val defaultScope = CoroutineScope(SupervisorJob() + defaultGate) + override val serialIOScope = CoroutineScope(SupervisorJob() + serialGate) + override val ingressScope = CoroutineScope(SupervisorJob() + ingressGate) // Ingress bootstraps first because receivers have a ~10s broadcast budget. - private val gates = listOf(Ingress, IO, Default, SerialIO) - private val scopes = listOf(IngressScope, IOScope, DefaultScope, SerialIOScope) + private val gates = listOf(ingressGate, ioGate, defaultGate, serialGate) + private val scopes = listOf(ingressScope, ioScope, defaultScope, serialIOScope) - fun prewarm() { + override fun prewarm() { gates.forEach { it.requestWarmup() } } - fun shutdown() { + override fun shutdown() { scopes.forEach { it.cancel() } gates.forEach { it.close() } } @@ -433,31 +440,87 @@ object OneSignalDispatchers { } @Volatile - private var pools = Pools() + private var poolsRef: DispatcherPools? = null + private val poolsLock = Any() + + @Volatile + private var appContext: Context? = null + + /** Test override. Null reads the cached flag, and no cache means off. */ + @Volatile + internal var bootstrapEnabledForTest: Boolean? = null + + /** Installed by process startup. Null means off, which is also the result before the first fetch. */ + @Volatile + internal var readBootstrapEnabled: ((Context) -> Boolean)? = null + + // Pools latch on first use, so context has to be recorded before the first prewarm or launch. + @Suppress("TooGenericExceptionCaught") + internal fun noteApplicationContext(context: Context) { + if (appContext != null) return + appContext = + try { + context.applicationContext + } catch (t: Throwable) { + Logging.warn("OneSignalDispatchers: application context unavailable: ${t.message}", t) + null + } + } + + internal fun resetBootstrapInputsForTest() { + appContext = null + readBootstrapEnabled = null + bootstrapEnabledForTest = null + } + + private fun currentPools(): DispatcherPools { + poolsRef?.let { return it } + return synchronized(poolsLock) { + poolsRef ?: newPools().also { poolsRef = it } + } + } + + private fun newPools(): DispatcherPools = + if (dispatcherBootstrapEnabled()) Pools() else LazyDispatcherPools() + + private fun dispatcherBootstrapEnabled(): Boolean = bootstrapEnabledForTest ?: readCachedBootstrap() + + @Suppress("TooGenericExceptionCaught") + private fun readCachedBootstrap(): Boolean { + val context = appContext + val read = readBootstrapEnabled + if (context == null || read == null) return false + return try { + read(context) + } catch (t: Throwable) { + Logging.warn("OneSignalDispatchers: bootstrap flag unreadable, using lazy pools: ${t.message}", t) + false + } + } // Dispatchers and scopes delegate to the current generation so [resetForTest] can swap them. - val IO: CoroutineDispatcher get() = pools.IO + val IO: CoroutineDispatcher get() = currentPools().io.dispatcher - val Default: CoroutineDispatcher get() = pools.Default + val Default: CoroutineDispatcher get() = currentPools().defaultLane.dispatcher - val SerialIO: CoroutineDispatcher get() = pools.SerialIO + val SerialIO: CoroutineDispatcher get() = currentPools().serialIO.dispatcher fun launchOnIO(block: suspend () -> Unit): Job { - return pools.IOScope.launch { block() } + return currentPools().ioScope.launch { block() } } fun launchOnDefault(block: suspend () -> Unit): Job { - return pools.DefaultScope.launch { block() } + return currentPools().defaultScope.launch { block() } } /** Launches [block] on the single-thread serial IO dispatcher (FIFO across all callers). */ fun launchOnSerialIO(block: suspend () -> Unit): Job { - return pools.SerialIOScope.launch { block() } + return currentPools().serialIOScope.launch { block() } } /** Launches short durable-ingress work on a lane isolated from general SDK I/O. */ fun launchOnIngress(block: suspend () -> Unit): Job { - return pools.IngressScope.launch { block() } + return currentPools().ingressScope.launch { block() } } @Volatile @@ -465,9 +528,8 @@ object OneSignalDispatchers { private val prewarmLock = Any() /** - * Requests asynchronous initialization and worker prestart for every lane. Dispatch correctness - * does not depend on this call: a cold first dispatch queues behind the same bootstrap gate and - * returns without constructing or waiting for a pool on its caller thread. + * Warms every lane away from the caller. + * sdk_dispatcher_bootstrap queues that work. Otherwise a faster dispatch can still construct on the caller. */ @Suppress("TooGenericExceptionCaught") fun prewarm() { @@ -477,7 +539,7 @@ object OneSignalDispatchers { prewarmStarted = true } try { - pools.prewarm() + currentPools().prewarm() } catch (t: Throwable) { synchronized(prewarmLock) { prewarmStarted = false } Logging.warn("OneSignalDispatchers.prewarm failed: ${t.message}", t) @@ -515,10 +577,13 @@ object OneSignalDispatchers { */ @Suppress("TooGenericExceptionCaught") internal fun resetForTest() { - val old = pools - pools = Pools() + val old: DispatcherPools? + synchronized(poolsLock) { + old = poolsRef + poolsRef = newPools() + } try { - old.shutdown() + old?.shutdown() } catch (e: Exception) { Logging.error("OneSignalDispatchers.resetForTest failed: ${e.message}", e) } @@ -530,11 +595,11 @@ object OneSignalDispatchers { @Suppress("ComplexMethod") internal fun getPerformanceMetrics(): String { - val current = pools - val io = current.IO.executor() - val default = current.Default.executor() - val serial = current.SerialIO.executor() - val ingress = current.Ingress.executor() + val current = currentPools() + val io = current.io.executor() + val default = current.defaultLane.executor() + val serial = current.serialIO.executor() + val ingress = current.ingress.executor() return """ OneSignalDispatchers Performance Metrics: - IO Pool: ${io?.let { "${it.activeCount}/${it.corePoolSize}" } ?: "n/a"} active/core threads @@ -549,25 +614,25 @@ object OneSignalDispatchers { } internal fun getStatus(): String { - val current = pools + val current = currentPools() return """ OneSignalDispatchers Status: - - IO Executor: ${current.IO.status()} - - Default Executor: ${current.Default.status()} - - SerialIO Executor: ${current.SerialIO.status()} - - Ingress Executor: ${current.Ingress.status()} - - IO Scope: ${scopeStatus("IOScope") { current.IOScope.isActive }} - - Default Scope: ${scopeStatus("DefaultScope") { current.DefaultScope.isActive }} - - SerialIO Scope: ${scopeStatus("SerialIOScope") { current.SerialIOScope.isActive }} - - Ingress Scope: ${scopeStatus("IngressScope") { current.IngressScope.isActive }} + - IO Executor: ${current.io.status()} + - Default Executor: ${current.defaultLane.status()} + - SerialIO Executor: ${current.serialIO.status()} + - Ingress Executor: ${current.ingress.status()} + - IO Scope: ${scopeStatus("IOScope") { current.ioScope.isActive }} + - Default Scope: ${scopeStatus("DefaultScope") { current.defaultScope.isActive }} + - SerialIO Scope: ${scopeStatus("SerialIOScope") { current.serialIOScope.isActive }} + - Ingress Scope: ${scopeStatus("IngressScope") { current.ingressScope.isActive }} """.trimIndent() } internal fun awaitReadyForTest(timeoutMs: Long = TEST_READY_TIMEOUT_MS): Boolean { val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { - val current = pools - if (listOf(current.IO, current.Default, current.SerialIO, current.Ingress).all { it.status() == "Active" }) { + val current = currentPools() + if (listOf(current.io, current.defaultLane, current.serialIO, current.ingress).all { it.status() == "Active" }) { return true } Thread.sleep(TEST_READY_POLL_MS) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt index 9fde58fc5b..683bf3fc00 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.startup.Initializer import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.features.DispatcherBootstrapCache /** * Registers activity lifecycle observation at process start via androidx.startup, before the host @@ -21,6 +22,8 @@ import com.onesignal.core.internal.application.IApplicationService */ class ActivityLifecycleInitializer : Initializer { override fun create(context: Context): IApplicationService { + OneSignalDispatchers.readBootstrapEnabled = DispatcherBootstrapCache::isEnabled + OneSignalDispatchers.noteApplicationContext(context) OneSignalDispatchers.prewarm() val applicationService = ApplicationService.getInstance() (context.applicationContext as? Application)?.let { application -> diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt new file mode 100644 index 0000000000..173f78e1bf --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt @@ -0,0 +1,69 @@ +package com.onesignal.core.internal.features + +import android.content.Context +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.features.FeatureFlag +import org.json.JSONArray +import org.json.JSONObject + +/** + * Cached sdkRemoteFeatureFlags, read directly so a cold receiver can latch + * before FeatureManager exists. Unreadable cache means off. + */ +@Suppress("TooGenericExceptionCaught", "SwallowedException") +internal object DispatcherBootstrapCache { + fun isEnabled(context: Context): Boolean = + try { + val raw = + context.applicationContext + .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + .getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE, null) + containsKey(raw, FeatureFlag.SDK_DISPATCHER_BOOTSTRAP.key) + } catch (t: Throwable) { + Logging.warn( + "DispatcherBootstrapCache: unreadable, treating bootstrap as off: ${t.message}", + t, + ) + false + } + + internal fun containsKey( + raw: String?, + key: String, + ): Boolean = flagNames(raw).any { it.equals(key, ignoreCase = true) } + + private fun flagNames(raw: String?): List = + parseModels(raw)?.let { models -> + buildList { + for (modelIndex in 0 until models.length()) { + collectFlags(models.optJSONObject(modelIndex), this) + } + } + } ?: emptyList() + + private fun parseModels(raw: String?): JSONArray? = + if (raw.isNullOrBlank()) { + null + } else { + try { + JSONArray(raw) + } catch (_: Throwable) { + null + } + } + + private fun collectFlags( + model: JSONObject?, + into: MutableList, + ) { + val flags = model?.optJSONArray(FLAGS_PROPERTY) ?: return + for (flagIndex in 0 until flags.length()) { + into.add(flags.optString(flagIndex)) + } + } + + private const val FLAGS_PROPERTY = "sdkRemoteFeatureFlags" +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt index 1e8556b8b9..79481f19da 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt @@ -24,6 +24,15 @@ class OneSignalDispatchersTests : FunSpec({ Logging.logLevel = LogLevel.NONE } + beforeTest { + OneSignalDispatchers.bootstrapEnabledForTest = true + OneSignalDispatchers.resetForTest() + } + + afterTest { + OneSignalDispatchers.bootstrapEnabledForTest = null + } + test("OneSignalDispatchers should be properly initialized") { // Access dispatchers to trigger initialization OneSignalDispatchers.IO shouldNotBe null @@ -479,6 +488,27 @@ class OneSignalDispatchersTests : FunSpec({ status shouldContain "SerialIO Scope: Active" } + test("flag off constructs the IO executor on the calling thread") { + OneSignalDispatchers.bootstrapEnabledForTest = false + OneSignalDispatchers.resetForTest() + val callerThreadId = AtomicLong() + val createThreadId = AtomicLong() + val finished = CountDownLatch(1) + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") createThreadId.set(Thread.currentThread().id) + } + + Thread { + callerThreadId.set(Thread.currentThread().id) + OneSignalDispatchers.launchOnIO { } + finished.countDown() + }.start() + + finished.await(2, TimeUnit.SECONDS) shouldBe true + createThreadId.get() shouldBe callerThreadId.get() + createThreadId.get() shouldNotBe 0L + } + test("prewarm is idempotent: a second call is a no-op and does not spawn a second prewarm thread") { OneSignalDispatchers.resetForTest() OneSignalDispatchers.prewarm() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializerTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializerTests.kt index d6774628c6..9e0295269c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializerTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializerTests.kt @@ -45,6 +45,7 @@ class ActivityLifecycleInitializerTests : FunSpec({ application.registerActivityLifecycleCallbacks(any()) } verifyOrder { + OneSignalDispatchers.noteApplicationContext(application) OneSignalDispatchers.prewarm() application.registerActivityLifecycleCallbacks(any()) } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt new file mode 100644 index 0000000000..ce3c32df99 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt @@ -0,0 +1,112 @@ +package com.onesignal.core.internal.features + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.common.threading.OneSignalDispatchers +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.features.FeatureFlag +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +@RobolectricTest +class DispatcherBootstrapCacheTests : FunSpec({ + val key = FeatureFlag.SDK_DISPATCHER_BOOTSTRAP.key + + afterTest { + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.resetForTest() + } + + test("missing, empty, and unreadable caches are off") { + DispatcherBootstrapCache.containsKey(null, key) shouldBe false + DispatcherBootstrapCache.containsKey("", key) shouldBe false + DispatcherBootstrapCache.containsKey("[]", key) shouldBe false + DispatcherBootstrapCache.containsKey("not json", key) shouldBe false + DispatcherBootstrapCache.containsKey("[{}]", key) shouldBe false + DispatcherBootstrapCache.containsKey( + """[{"sdkRemoteFeatureFlags":["sdk_custom_logging"]}]""", + key, + ) shouldBe false + } + + test("the cached key enables bootstrap regardless of case") { + val raw = """[{"sdkRemoteFeatureFlags":["SDK_Dispatcher_Bootstrap"]}]""" + DispatcherBootstrapCache.containsKey(raw, key) shouldBe true + } + + test("a persisted key selects bootstrap on the next pool generation") { + val context = ApplicationProvider.getApplicationContext() + writeFlags(context, JSONArray().put(key)) + latchFrom(context) + + val callerThreadId = AtomicLong() + val createThreadId = AtomicLong() + val createStarted = CountDownLatch(1) + val finished = CountDownLatch(1) + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") { + createThreadId.set(Thread.currentThread().id) + createStarted.countDown() + } + } + Thread { + callerThreadId.set(Thread.currentThread().id) + OneSignalDispatchers.launchOnIO { } + finished.countDown() + }.start() + + createStarted.await(2, TimeUnit.SECONDS) shouldBe true + finished.await(2, TimeUnit.SECONDS) shouldBe true + createThreadId.get() shouldNotBe callerThreadId.get() + } + + test("a cache without the key keeps construction on the caller") { + val context = ApplicationProvider.getApplicationContext() + writeFlags(context, JSONArray()) + latchFrom(context) + + val callerThreadId = AtomicLong() + val createThreadId = AtomicLong() + val finished = CountDownLatch(1) + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") createThreadId.set(Thread.currentThread().id) + } + Thread { + callerThreadId.set(Thread.currentThread().id) + OneSignalDispatchers.launchOnIO { } + finished.countDown() + }.start() + + finished.await(2, TimeUnit.SECONDS) shouldBe true + createThreadId.get() shouldBe callerThreadId.get() + } +}) + +private fun latchFrom(context: Context) { + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.readBootstrapEnabled = DispatcherBootstrapCache::isEnabled + OneSignalDispatchers.noteApplicationContext(context) + OneSignalDispatchers.resetForTest() +} + +private fun writeFlags( + context: Context, + flags: JSONArray, +) { + val model = JSONObject().put("sdkRemoteFeatureFlags", flags) + val store = JSONArray().put(model).toString() + context + .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + .edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE, store) + .commit() +} From bf8d7d3928a907123edc621f199d9dfe2454ae77 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 20 Oct 2026 14:16:21 -0500 Subject: [PATCH 02/13] fix: [SDK-5581] latch dispatcher bootstrap off the pools lock 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 --- .../common/threading/LazyDispatcherPools.kt | 87 +++++++++---- .../common/threading/OneSignalDispatchers.kt | 120 ++++++++++++++---- .../impl/ActivityLifecycleInitializer.kt | 2 - .../features/DispatcherBootstrapCache.kt | 46 ++++--- .../threading/OneSignalDispatchersTests.kt | 54 ++++++++ .../features/DispatcherBootstrapCacheTests.kt | 50 +++++++- 6 files changed, 283 insertions(+), 76 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt index e0be688248..1032b1b536 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt @@ -1,6 +1,5 @@ package com.onesignal.common.threading -import com.onesignal.debug.internal.logging.Logging import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -12,7 +11,9 @@ import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.ThreadFactory import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference /** * Pre-bootstrap pools. The first dispatch constructs the executor on the caller thread. @@ -20,6 +21,7 @@ import java.util.concurrent.atomic.AtomicInteger */ @Suppress("TooGenericExceptionCaught") internal class LazyDispatcherPools : DispatcherPools { + private val closed = AtomicBoolean(false) private val ioLane = LazyExecutorLane("IO", IO_CORE, IO_MAX, QUEUE_CAPACITY, "$BASE-IO", Thread.NORM_PRIORITY - 1) { Dispatchers.IO @@ -54,34 +56,67 @@ internal class LazyDispatcherPools : DispatcherPools { override val serialIOScope: CoroutineScope get() = serialScopeLazy.value override val ingressScope: CoroutineScope get() = ingressScopeLazy.value + private val prewarmThread = AtomicReference(null) + override fun prewarm() { + if (closed.get()) return val thread = Thread( { - warm { ioScope } - warm { defaultScope } - warm { serialIOScope } - warm { ingressScope } + // Ingress first: receivers have a ~10s broadcast budget. + warm("INGRESS") { ingressScope } + warm("IO") { ioScope } + warm("DEFAULT") { defaultScope } + warm("SERIAL_IO") { serialIOScope } }, "$BASE-prewarm", ) thread.isDaemon = true thread.priority = Thread.NORM_PRIORITY - 2 + if (!prewarmThread.compareAndSet(null, thread) || closed.get()) return thread.start() } override fun shutdown() { + closed.set(true) + prewarmThread.getAndSet(null)?.let { thread -> + thread.interrupt() + runCatching { thread.join(PREWARM_JOIN_MS) } + } listOf(ioScopeLazy, defaultScopeLazy, serialScopeLazy, ingressScopeLazy).forEach { scope -> if (scope.isInitialized()) runCatching { scope.value.cancel() } } listOf(ioLane, defaultExecutorLane, serialLane, ingressLane).forEach { it.shutdown() } } - private fun warm(resolve: () -> CoroutineScope) { - runCatching { resolve().launch { } } + private fun warm( + lane: String, + resolve: () -> CoroutineScope, + ) { + OneSignalDispatchers.beforeLazyWarmForTest?.invoke(lane) + if (closed.get()) return + val scope = resolveScope(resolve) ?: return + if (closed.get()) { + runCatching { scope.cancel() } + } else { + runCatching { scope.launch { } }.onFailure(::logWarmFailure) + } + } + + private fun resolveScope(resolve: () -> CoroutineScope): CoroutineScope? = + try { + resolve() + } catch (t: Throwable) { + logWarmFailure(t) + null + } + + private fun logWarmFailure(t: Throwable) { + if (closed.get()) return + OneSignalDispatchers.logRelease("lazy prewarm failed", t) } - private class LazyExecutorLane( + private inner class LazyExecutorLane( private val lane: String, private val core: Int, private val max: Int, @@ -90,20 +125,27 @@ internal class LazyDispatcherPools : DispatcherPools { private val priority: Int, private val fallback: () -> CoroutineDispatcher, ) : DispatcherLane { - private val executorLazy: Lazy = - lazy { - var created: ThreadPoolExecutor? = null - try { - OneSignalDispatchers.beforeLaneCreateForTest?.invoke(lane) - created = newExecutor() - OneSignalDispatchers.afterExecutorCreateForTest?.invoke(lane, created) - created - } catch (t: Throwable) { - created?.shutdownNow() - Logging.warn("OneSignalDispatchers: Using fallback for $lane lane: ${t.message}", t) - null - } + private val executorLazy: Lazy = lazy { createExecutor() } + + private fun createExecutor(): ThreadPoolExecutor? { + if (closed.get()) return null + return try { + buildExecutor() + } catch (t: Throwable) { + OneSignalDispatchers.logRelease("lazy $lane fell back", t) + OneSignalDispatchers.beforeFallbackCreateForTest?.invoke(lane) + null } + } + + private fun buildExecutor(): ThreadPoolExecutor? { + OneSignalDispatchers.beforeLaneCreateForTest?.invoke(lane) + if (closed.get()) return null + val created = newExecutor() + OneSignalDispatchers.afterExecutorCreateForTest?.invoke(lane, created) + if (closed.get()) created.shutdownNow() + return created.takeUnless { it.isShutdown } + } override val dispatcher: CoroutineDispatcher by lazy { val executor = executorLazy.value @@ -113,7 +155,7 @@ internal class LazyDispatcherPools : DispatcherPools { try { executor.asCoroutineDispatcher() } catch (t: Throwable) { - Logging.warn("OneSignalDispatchers: Using fallback for $lane lane: ${t.message}", t) + OneSignalDispatchers.logRelease("lazy $lane fell back", t) fallback() } } @@ -175,5 +217,6 @@ internal class LazyDispatcherPools : DispatcherPools { private const val DEFAULT_MAX = 3 private const val QUEUE_CAPACITY = 200 private const val KEEP_ALIVE_SECONDS = 30L + private const val PREWARM_JOIN_MS = 250L } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index b197ef0337..a2bcfdaebe 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -1,6 +1,9 @@ package com.onesignal.common.threading import android.content.Context +import android.util.Log +import com.onesignal.core.internal.features.BootstrapCacheRead +import com.onesignal.core.internal.features.DispatcherBootstrapCache import com.onesignal.debug.internal.logging.Logging import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -48,6 +51,7 @@ object OneSignalDispatchers { private const val TEST_READY_TIMEOUT_MS = 2_000L private const val TEST_READY_POLL_MS = 5L internal const val BASE_THREAD_NAME = "OneSignal" // Base thread name prefix + private const val RELEASE_LOG_TAG = "OneSignal" private const val IO_THREAD_NAME_PREFIX = "$BASE_THREAD_NAME-IO" // Thread name prefix for I/O operations private const val DEFAULT_THREAD_NAME_PREFIX = @@ -65,6 +69,10 @@ object OneSignalDispatchers { @Volatile internal var beforeFallbackCreateForTest: ((String) -> Unit)? = null + /** Lazy prewarm, before the lane lazy runs. Blocking here must not deadlock shutdown. */ + @Volatile + internal var beforeLazyWarmForTest: ((String) -> Unit)? = null + private class OptimizedThreadFactory( private val namePrefix: String, private val priority: Int = Thread.NORM_PRIORITY, @@ -180,11 +188,11 @@ object OneSignalDispatchers { try { createTarget(lane) } catch (t: Throwable) { - Logging.warn("OneSignalDispatchers: Using fallback for $lane lane: ${t.message}", t) + logRelease("$lane fell back", t) try { createFallbackTarget(lane) } catch (fallbackError: Throwable) { - Logging.warn("OneSignalDispatchers: Fallback failed for $lane lane: ${fallbackError.message}", fallbackError) + logRelease("$lane fallback failed", fallbackError) failPending("OneSignal $lane dispatcher fallback failed") null } @@ -309,7 +317,7 @@ object OneSignalDispatchers { } } catch (t: Throwable) { running.set(false) - Logging.warn("OneSignalDispatchers: Failed to start bootstrap thread: ${t.message}", t) + logRelease("bootstrap thread failed to start", t) while (true) { val gate = queue.poll() ?: break gate.bootstrapFailed() @@ -325,7 +333,7 @@ object OneSignalDispatchers { try { gate.initialize() } catch (t: Throwable) { - Logging.warn("OneSignalDispatchers: Bootstrap failed for a lane: ${t.message}", t) + logRelease("bootstrap lane failed", t) gate.bootstrapFailed() } } @@ -450,10 +458,6 @@ object OneSignalDispatchers { @Volatile internal var bootstrapEnabledForTest: Boolean? = null - /** Installed by process startup. Null means off, which is also the result before the first fetch. */ - @Volatile - internal var readBootstrapEnabled: ((Context) -> Boolean)? = null - // Pools latch on first use, so context has to be recorded before the first prewarm or launch. @Suppress("TooGenericExceptionCaught") internal fun noteApplicationContext(context: Context) { @@ -469,32 +473,90 @@ object OneSignalDispatchers { internal fun resetBootstrapInputsForTest() { appContext = null - readBootstrapEnabled = null bootstrapEnabledForTest = null } private fun currentPools(): DispatcherPools { poolsRef?.let { return it } - return synchronized(poolsLock) { - poolsRef ?: newPools().also { poolsRef = it } + // Flag read does disk I/O. Keep it outside the lock so other launches are not stuck behind it. + val choice = choosePools() + val published = + synchronized(poolsLock) { + val existing = poolsRef + if (existing != null) { + choice.pools.shutdown() + existing + } else { + poolsRef = choice.pools + choice.pools + } + } + if (published === choice.pools && bootstrapEnabledForTest == null) { + val mode = if (choice.pools is LazyDispatcherPools) "lazy" else "bootstrap" + logRelease("${choice.detail}; pools=$mode", warn = choice.warn) } + return published } - private fun newPools(): DispatcherPools = - if (dispatcherBootstrapEnabled()) Pools() else LazyDispatcherPools() + private fun newPools(): DispatcherPools = choosePools().pools - private fun dispatcherBootstrapEnabled(): Boolean = bootstrapEnabledForTest ?: readCachedBootstrap() + private fun choosePools(): PoolChoice { + val override = bootstrapEnabledForTest + return if (override != null) { + PoolChoice(poolsFor(override), "test", false) + } else { + chooseFromCache() + } + } - @Suppress("TooGenericExceptionCaught") - private fun readCachedBootstrap(): Boolean { - val context = appContext - val read = readBootstrapEnabled - if (context == null || read == null) return false - return try { - read(context) - } catch (t: Throwable) { - Logging.warn("OneSignalDispatchers: bootstrap flag unreadable, using lazy pools: ${t.message}", t) - false + private fun chooseFromCache(): PoolChoice { + val context = appContext ?: rememberApplicationContext() + val read = context?.let { DispatcherBootstrapCache.read(it) } + val detail = + when (read) { + BootstrapCacheRead.ENABLED -> "on" + BootstrapCacheRead.DISABLED -> "off, flag not in cache" + BootstrapCacheRead.UNREADABLE -> "off, cache unreadable" + null -> "off, no application" + } + return PoolChoice(poolsFor(read == BootstrapCacheRead.ENABLED), detail, warn = read == null) + } + + private fun poolsFor(enabled: Boolean): DispatcherPools = if (enabled) Pools() else LazyDispatcherPools() + + private class PoolChoice( + val pools: DispatcherPools, + val detail: String, + val warn: Boolean, + ) + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun rememberApplicationContext(): Context? { + if (appContext != null) return appContext + // Receivers call prewarm() with no Context when the startup provider is disabled. + val resolved = + try { + val method = Class.forName("android.app.ActivityThread").getMethod("currentApplication") + method.invoke(null) as? Context + } catch (_: Throwable) { + null + } + if (resolved != null) appContext = resolved.applicationContext + return appContext + } + + /** Logcat, not [Logging]: the SDK log level is often NONE in release, and Logging can dispatch. */ + @Suppress("SwallowedException") + internal fun logRelease( + message: String, + error: Throwable? = null, + warn: Boolean = error != null, + ) { + val line = "sdk_dispatcher_bootstrap: $message" + try { + if (warn) Log.w(RELEASE_LOG_TAG, line, error) else Log.i(RELEASE_LOG_TAG, line) + } catch (_: RuntimeException) { + // JVM unit tests throw from android.util.Log. A log must not fail the latch. } } @@ -528,8 +590,8 @@ object OneSignalDispatchers { private val prewarmLock = Any() /** - * Warms every lane away from the caller. - * sdk_dispatcher_bootstrap queues that work. Otherwise a faster dispatch can still construct on the caller. + * Warms every lane off the caller when it can. + * Bootstrap queues construction. Lazy construction runs on the caller, or blocks behind prewarm. */ @Suppress("TooGenericExceptionCaught") fun prewarm() { @@ -542,7 +604,7 @@ object OneSignalDispatchers { currentPools().prewarm() } catch (t: Throwable) { synchronized(prewarmLock) { prewarmStarted = false } - Logging.warn("OneSignalDispatchers.prewarm failed: ${t.message}", t) + logRelease("prewarm failed", t) } } @@ -577,10 +639,11 @@ object OneSignalDispatchers { */ @Suppress("TooGenericExceptionCaught") internal fun resetForTest() { + val created = newPools() val old: DispatcherPools? synchronized(poolsLock) { old = poolsRef - poolsRef = newPools() + poolsRef = created } try { old?.shutdown() @@ -591,6 +654,7 @@ object OneSignalDispatchers { beforeLaneCreateForTest = null afterExecutorCreateForTest = null beforeFallbackCreateForTest = null + beforeLazyWarmForTest = null } @Suppress("ComplexMethod") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt index 683bf3fc00..55869839cb 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/application/impl/ActivityLifecycleInitializer.kt @@ -5,7 +5,6 @@ import android.content.Context import androidx.startup.Initializer import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.application.IApplicationService -import com.onesignal.core.internal.features.DispatcherBootstrapCache /** * Registers activity lifecycle observation at process start via androidx.startup, before the host @@ -22,7 +21,6 @@ import com.onesignal.core.internal.features.DispatcherBootstrapCache */ class ActivityLifecycleInitializer : Initializer { override fun create(context: Context): IApplicationService { - OneSignalDispatchers.readBootstrapEnabled = DispatcherBootstrapCache::isEnabled OneSignalDispatchers.noteApplicationContext(context) OneSignalDispatchers.prewarm() val applicationService = ApplicationService.getInstance() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt index 173f78e1bf..4c4e8c9125 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt @@ -1,10 +1,10 @@ package com.onesignal.core.internal.features import android.content.Context +import android.util.Log import com.onesignal.core.internal.config.CONFIG_NAME_SPACE import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores -import com.onesignal.debug.internal.logging.Logging import com.onesignal.features.FeatureFlag import org.json.JSONArray import org.json.JSONObject @@ -13,36 +13,46 @@ import org.json.JSONObject * Cached sdkRemoteFeatureFlags, read directly so a cold receiver can latch * before FeatureManager exists. Unreadable cache means off. */ +internal enum class BootstrapCacheRead { ENABLED, DISABLED, UNREADABLE } + @Suppress("TooGenericExceptionCaught", "SwallowedException") internal object DispatcherBootstrapCache { - fun isEnabled(context: Context): Boolean = + fun read(context: Context): BootstrapCacheRead = try { val raw = context.applicationContext .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) .getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE, null) - containsKey(raw, FeatureFlag.SDK_DISPATCHER_BOOTSTRAP.key) + classify(raw) } catch (t: Throwable) { - Logging.warn( - "DispatcherBootstrapCache: unreadable, treating bootstrap as off: ${t.message}", - t, - ) - false + // android.util.Log, not Logging: Logging can dispatch, and this runs before pools exist. + Log.w(TAG, "sdk_dispatcher_bootstrap: cache unreadable, lazy pools", t) + BootstrapCacheRead.UNREADABLE } + private fun classify(raw: String?): BootstrapCacheRead { + val models = if (raw.isNullOrBlank()) null else parseModels(raw) + val names = models?.let { flagNames(it) }.orEmpty() + return when { + raw.isNullOrBlank() -> BootstrapCacheRead.DISABLED + models == null -> BootstrapCacheRead.UNREADABLE + names.any { it.equals(FeatureFlag.SDK_DISPATCHER_BOOTSTRAP.key, ignoreCase = true) } -> + BootstrapCacheRead.ENABLED + else -> BootstrapCacheRead.DISABLED + } + } + internal fun containsKey( raw: String?, key: String, - ): Boolean = flagNames(raw).any { it.equals(key, ignoreCase = true) } + ): Boolean = parseModels(raw)?.let { flagNames(it) }.orEmpty().any { it.equals(key, ignoreCase = true) } - private fun flagNames(raw: String?): List = - parseModels(raw)?.let { models -> - buildList { - for (modelIndex in 0 until models.length()) { - collectFlags(models.optJSONObject(modelIndex), this) - } + private fun flagNames(models: JSONArray): List = + buildList { + for (modelIndex in 0 until models.length()) { + collectFlags(models.optJSONObject(modelIndex), this) } - } ?: emptyList() + } private fun parseModels(raw: String?): JSONArray? = if (raw.isNullOrBlank()) { @@ -50,7 +60,8 @@ internal object DispatcherBootstrapCache { } else { try { JSONArray(raw) - } catch (_: Throwable) { + } catch (t: Throwable) { + Log.w(TAG, "sdk_dispatcher_bootstrap: config store unreadable, lazy pools", t) null } } @@ -66,4 +77,5 @@ internal object DispatcherBootstrapCache { } private const val FLAGS_PROPERTY = "sdkRemoteFeatureFlags" + private const val TAG = "OneSignal" } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt index 79481f19da..8a92481a7b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt @@ -509,6 +509,50 @@ class OneSignalDispatchersTests : FunSpec({ createThreadId.get() shouldNotBe 0L } + test("flag off prewarm warms ingress before the other lanes") { + OneSignalDispatchers.bootstrapEnabledForTest = false + OneSignalDispatchers.resetForTest() + val order = Collections.synchronizedList(mutableListOf()) + val created = CountDownLatch(4) + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + order.add(lane) + created.countDown() + } + OneSignalDispatchers.prewarm() + created.await(2, TimeUnit.SECONDS) shouldBe true + order.first() shouldBe "INGRESS" + } + + test("flag off prewarm does not build executors after reset") { + OneSignalDispatchers.bootstrapEnabledForTest = false + OneSignalDispatchers.resetForTest() + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + OneSignalDispatchers.beforeLazyWarmForTest = { + entered.countDown() + release.await(2, TimeUnit.SECONDS) + } + OneSignalDispatchers.prewarm() + entered.await(2, TimeUnit.SECONDS) shouldBe true + val workersBefore = dispatcherWorkerIds() + OneSignalDispatchers.resetForTest() + release.countDown() + Thread.sleep(400) + (dispatcherWorkerIds() - workersBefore) shouldBe emptySet() + } + + test("flag off falls back when executor construction throws") { + OneSignalDispatchers.bootstrapEnabledForTest = false + OneSignalDispatchers.resetForTest() + val fallbacks = AtomicInteger() + OneSignalDispatchers.beforeLaneCreateForTest = { throw IllegalStateException("boom") } + OneSignalDispatchers.beforeFallbackCreateForTest = { fallbacks.incrementAndGet() } + val ran = CountDownLatch(1) + OneSignalDispatchers.launchOnIO { ran.countDown() } + ran.await(2, TimeUnit.SECONDS) shouldBe true + fallbacks.get() shouldBe 1 + } + test("prewarm is idempotent: a second call is a no-op and does not spawn a second prewarm thread") { OneSignalDispatchers.resetForTest() OneSignalDispatchers.prewarm() @@ -518,3 +562,13 @@ class OneSignalDispatchersTests : FunSpec({ OneSignalDispatchers.getStatus() shouldBe statusBefore } }) + +private fun dispatcherWorkerIds(): Set = + Thread.getAllStackTraces().keys + .filter { thread -> + thread.name.startsWith("OneSignal-IO") || + thread.name.startsWith("OneSignal-Default") || + thread.name.startsWith("OneSignal-SerialIO") || + thread.name.startsWith("OneSignal-Ingress") + }.map { it.id } + .toSet() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt index ce3c32df99..8f8821471b 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt @@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.core.internal.config.CONFIG_NAME_SPACE +import com.onesignal.core.internal.config.ConfigModel import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.features.FeatureFlag @@ -12,7 +13,6 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import org.json.JSONArray -import org.json.JSONObject import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong @@ -22,6 +22,7 @@ class DispatcherBootstrapCacheTests : FunSpec({ val key = FeatureFlag.SDK_DISPATCHER_BOOTSTRAP.key afterTest { + clearFlags() OneSignalDispatchers.resetBootstrapInputsForTest() OneSignalDispatchers.resetForTest() } @@ -45,7 +46,7 @@ class DispatcherBootstrapCacheTests : FunSpec({ test("a persisted key selects bootstrap on the next pool generation") { val context = ApplicationProvider.getApplicationContext() - writeFlags(context, JSONArray().put(key)) + writeFlags(context, listOf(key)) latchFrom(context) val callerThreadId = AtomicLong() @@ -71,7 +72,7 @@ class DispatcherBootstrapCacheTests : FunSpec({ test("a cache without the key keeps construction on the caller") { val context = ApplicationProvider.getApplicationContext() - writeFlags(context, JSONArray()) + writeFlags(context, emptyList()) latchFrom(context) val callerThreadId = AtomicLong() @@ -89,21 +90,56 @@ class DispatcherBootstrapCacheTests : FunSpec({ finished.await(2, TimeUnit.SECONDS) shouldBe true createThreadId.get() shouldBe callerThreadId.get() } + + test("prewarm latches from the process application when startup did not note a context") { + val context = ApplicationProvider.getApplicationContext() + writeFlags(context, listOf(key)) + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.resetForTest() + + val callerThreadId = AtomicLong() + val createThreadId = AtomicLong() + val createStarted = CountDownLatch(1) + val finished = CountDownLatch(1) + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") { + createThreadId.set(Thread.currentThread().id) + createStarted.countDown() + } + } + Thread { + callerThreadId.set(Thread.currentThread().id) + OneSignalDispatchers.launchOnIO { } + finished.countDown() + }.start() + + createStarted.await(2, TimeUnit.SECONDS) shouldBe true + finished.await(2, TimeUnit.SECONDS) shouldBe true + createThreadId.get() shouldNotBe callerThreadId.get() + } }) private fun latchFrom(context: Context) { OneSignalDispatchers.resetBootstrapInputsForTest() - OneSignalDispatchers.readBootstrapEnabled = DispatcherBootstrapCache::isEnabled OneSignalDispatchers.noteApplicationContext(context) OneSignalDispatchers.resetForTest() } +private fun clearFlags() { + val context = ApplicationProvider.getApplicationContext() + context + .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + .edit() + .remove(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE) + .commit() +} + private fun writeFlags( context: Context, - flags: JSONArray, + flags: List, ) { - val model = JSONObject().put("sdkRemoteFeatureFlags", flags) - val store = JSONArray().put(model).toString() + val model = ConfigModel().apply { sdkRemoteFeatureFlags = flags } + val store = JSONArray().put(model.toJSON()).toString() context .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) .edit() From bdd92062807c16bf60ca469e91318ad6cea7ea2d Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 20 Oct 2026 15:05:21 -0500 Subject: [PATCH 03/13] fix: [SDK-5581] keep a failed lazy prewarm retryable 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 --- OneSignal-KMP-SDK | 2 +- .../common/threading/LazyDispatcherPools.kt | 18 ++++++++++++++++-- .../common/threading/OneSignalDispatchers.kt | 5 +++++ .../features/DispatcherBootstrapCache.kt | 3 ++- .../com/onesignal/internal/OneSignalImp.kt | 2 ++ .../threading/OneSignalDispatchersTests.kt | 13 +++++++++++++ .../features/DispatcherBootstrapCacheTests.kt | 12 ++++++++++++ 7 files changed, 51 insertions(+), 4 deletions(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index 4d26557be5..53e568c375 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit 4d26557be59b67d8f77ed996b0be5d7308eb95f0 +Subproject commit 53e568c3758157d924b1f9ad419b18e3c99603ce diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt index 1032b1b536..910f729f07 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt @@ -73,8 +73,22 @@ internal class LazyDispatcherPools : DispatcherPools { ) thread.isDaemon = true thread.priority = Thread.NORM_PRIORITY - 2 - if (!prewarmThread.compareAndSet(null, thread) || closed.get()) return - thread.start() + if (prewarmThread.compareAndSet(null, thread)) startPrewarm(thread) + } + + private fun startPrewarm(thread: Thread) { + if (closed.get()) { + prewarmThread.compareAndSet(thread, null) + return + } + try { + OneSignalDispatchers.beforeLazyPrewarmStartForTest?.invoke() + thread.start() + } catch (t: Throwable) { + // start() failed after the ref was published. Clear it or every retry hits a failed CAS. + prewarmThread.compareAndSet(thread, null) + throw t + } } override fun shutdown() { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index a2bcfdaebe..8a93347d1f 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -73,6 +73,10 @@ object OneSignalDispatchers { @Volatile internal var beforeLazyWarmForTest: ((String) -> Unit)? = null + /** After the lazy prewarm thread is published, before [Thread.start]. */ + @Volatile + internal var beforeLazyPrewarmStartForTest: (() -> Unit)? = null + private class OptimizedThreadFactory( private val namePrefix: String, private val priority: Int = Thread.NORM_PRIORITY, @@ -655,6 +659,7 @@ object OneSignalDispatchers { afterExecutorCreateForTest = null beforeFallbackCreateForTest = null beforeLazyWarmForTest = null + beforeLazyPrewarmStartForTest = null } @Suppress("ComplexMethod") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt index 4c4e8c9125..98de0a38a5 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt @@ -61,7 +61,8 @@ internal object DispatcherBootstrapCache { try { JSONArray(raw) } catch (t: Throwable) { - Log.w(TAG, "sdk_dispatcher_bootstrap: config store unreadable, lazy pools", t) + // JSONException's message is the whole document. That store can hold fcmParams.apiKey. + Log.w(TAG, "sdk_dispatcher_bootstrap: config store unreadable, lazy pools (${t.javaClass.simpleName})") null } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 7325c27971..5f886da662 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -353,6 +353,7 @@ internal class OneSignalImp : IOneSignal, // of suspendifyOnIO / launchOnSerialIO doesn't pay the ThreadPoolExecutor + dispatcher + // scope construction cost on the main thread (observed as 5-20s main-thread blocks at the // first foreground/background lifecycle event). prewarm() is idempotent and fire-and-forget. + OneSignalDispatchers.noteApplicationContext(context) OneSignalDispatchers.prewarm() synchronized(initLock) { @@ -762,6 +763,7 @@ internal class OneSignalImp : IOneSignal, // (e.g. SyncJobService.onStartJob -> suspendifyOnIO -> initWithContext(context)) pays the // cold-init cost on the dispatcher used to enter [withContext] below, so warm // OneSignalDispatchers on a background thread before we touch [ioDispatcher]. + OneSignalDispatchers.noteApplicationContext(context) OneSignalDispatchers.prewarm() // Observing a terminal SUCCESS must never require an IO worker (see waitForInit). diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt index 8a92481a7b..5cc8816bf6 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt @@ -553,6 +553,19 @@ class OneSignalDispatchersTests : FunSpec({ fallbacks.get() shouldBe 1 } + test("flag off prewarm retries when the prewarm thread fails to start") { + OneSignalDispatchers.bootstrapEnabledForTest = false + OneSignalDispatchers.resetForTest() + OneSignalDispatchers.beforeLazyPrewarmStartForTest = { throw IllegalStateException("start failed") } + OneSignalDispatchers.prewarm() + + OneSignalDispatchers.beforeLazyPrewarmStartForTest = null + val created = CountDownLatch(4) + OneSignalDispatchers.beforeLaneCreateForTest = { created.countDown() } + OneSignalDispatchers.prewarm() + created.await(2, TimeUnit.SECONDS) shouldBe true + } + test("prewarm is idempotent: a second call is a no-op and does not spawn a second prewarm thread") { OneSignalDispatchers.resetForTest() OneSignalDispatchers.prewarm() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt index 8f8821471b..263ee46a9c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt @@ -12,7 +12,9 @@ import com.onesignal.features.FeatureFlag import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldNotContain import org.json.JSONArray +import org.robolectric.shadows.ShadowLog import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong @@ -39,6 +41,16 @@ class DispatcherBootstrapCacheTests : FunSpec({ ) shouldBe false } + test("an unreadable config does not log the store") { + val secret = "fcm-api-key-sentinel" + val raw = """[{"fcmParams":{"apiKey":"$secret"}]""" + ShadowLog.clear() + DispatcherBootstrapCache.containsKey(raw, key) shouldBe false + ShadowLog.getLogs().joinToString("\n") { item -> + item.msg + (item.throwable?.toString() ?: "") + } shouldNotContain secret + } + test("the cached key enables bootstrap regardless of case") { val raw = """[{"sdkRemoteFeatureFlags":["SDK_Dispatcher_Bootstrap"]}]""" DispatcherBootstrapCache.containsKey(raw, key) shouldBe true From f6f83705cae868ecbc43abc83ac1c6ebb1dbfc4b Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 10:39:04 -0500 Subject: [PATCH 04/13] fix: [SDK-5581] pick dispatcher pools from the startup flags snapshot - 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 --- .../common/threading/LazyDispatcherPools.kt | 1 + .../common/threading/OneSignalDispatchers.kt | 22 +- .../features/DispatcherBootstrapCache.kt | 82 ----- .../core/internal/features/README.md | 6 +- .../threading/DispatcherBootstrapFlagTests.kt | 116 ++++++ .../threading/DispatcherLaneBehaviorTests.kt | 337 ++++++++++++++++++ .../threading/OneSignalDispatchersTests.kt | 314 +--------------- .../features/DispatcherBootstrapCacheTests.kt | 160 --------- 8 files changed, 485 insertions(+), 553 deletions(-) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherLaneBehaviorTests.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt index 910f729f07..145697a76c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt @@ -182,6 +182,7 @@ internal class LazyDispatcherPools : DispatcherPools { try { val executor = if (executorLazy.isInitialized()) executorLazy.value else null when { + closed.get() -> "Shutdown" executor == null && executorLazy.isInitialized() -> "Active" executor == null -> "Cold" executor.isShutdown -> "Shutdown" diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 8a93347d1f..f810504de7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -2,9 +2,10 @@ package com.onesignal.common.threading import android.content.Context import android.util.Log -import com.onesignal.core.internal.features.BootstrapCacheRead -import com.onesignal.core.internal.features.DispatcherBootstrapCache +import com.onesignal.core.internal.features.FeatureManager +import com.onesignal.core.internal.features.StartupFlags import com.onesignal.debug.internal.logging.Logging +import com.onesignal.features.FeatureFlag import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -478,6 +479,7 @@ object OneSignalDispatchers { internal fun resetBootstrapInputsForTest() { appContext = null bootstrapEnabledForTest = null + FeatureManager.resetStartupFlagsForTest() } private fun currentPools(): DispatcherPools { @@ -514,16 +516,15 @@ object OneSignalDispatchers { } private fun chooseFromCache(): PoolChoice { - val context = appContext ?: rememberApplicationContext() - val read = context?.let { DispatcherBootstrapCache.read(it) } + val flags = FeatureManager.startupFlags { appContext ?: rememberApplicationContext() } + val enabled = flags.isEnabled(FeatureFlag.SDK_DISPATCHER_BOOTSTRAP) val detail = - when (read) { - BootstrapCacheRead.ENABLED -> "on" - BootstrapCacheRead.DISABLED -> "off, flag not in cache" - BootstrapCacheRead.UNREADABLE -> "off, cache unreadable" - null -> "off, no application" + when (flags.source) { + StartupFlags.Source.FILE -> if (enabled) "on" else "off, flag not in cache" + StartupFlags.Source.UNREADABLE -> "off, cache unreadable" + StartupFlags.Source.NO_APPLICATION -> "off, no application" } - return PoolChoice(poolsFor(read == BootstrapCacheRead.ENABLED), detail, warn = read == null) + return PoolChoice(poolsFor(enabled), detail, warn = flags.source == StartupFlags.Source.NO_APPLICATION) } private fun poolsFor(enabled: Boolean): DispatcherPools = if (enabled) Pools() else LazyDispatcherPools() @@ -643,6 +644,7 @@ object OneSignalDispatchers { */ @Suppress("TooGenericExceptionCaught") internal fun resetForTest() { + FeatureManager.resetStartupFlagsForTest() val created = newPools() val old: DispatcherPools? synchronized(poolsLock) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt deleted file mode 100644 index 98de0a38a5..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/DispatcherBootstrapCache.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.onesignal.core.internal.features - -import android.content.Context -import android.util.Log -import com.onesignal.core.internal.config.CONFIG_NAME_SPACE -import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys -import com.onesignal.core.internal.preferences.PreferenceStores -import com.onesignal.features.FeatureFlag -import org.json.JSONArray -import org.json.JSONObject - -/** - * Cached sdkRemoteFeatureFlags, read directly so a cold receiver can latch - * before FeatureManager exists. Unreadable cache means off. - */ -internal enum class BootstrapCacheRead { ENABLED, DISABLED, UNREADABLE } - -@Suppress("TooGenericExceptionCaught", "SwallowedException") -internal object DispatcherBootstrapCache { - fun read(context: Context): BootstrapCacheRead = - try { - val raw = - context.applicationContext - .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) - .getString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE, null) - classify(raw) - } catch (t: Throwable) { - // android.util.Log, not Logging: Logging can dispatch, and this runs before pools exist. - Log.w(TAG, "sdk_dispatcher_bootstrap: cache unreadable, lazy pools", t) - BootstrapCacheRead.UNREADABLE - } - - private fun classify(raw: String?): BootstrapCacheRead { - val models = if (raw.isNullOrBlank()) null else parseModels(raw) - val names = models?.let { flagNames(it) }.orEmpty() - return when { - raw.isNullOrBlank() -> BootstrapCacheRead.DISABLED - models == null -> BootstrapCacheRead.UNREADABLE - names.any { it.equals(FeatureFlag.SDK_DISPATCHER_BOOTSTRAP.key, ignoreCase = true) } -> - BootstrapCacheRead.ENABLED - else -> BootstrapCacheRead.DISABLED - } - } - - internal fun containsKey( - raw: String?, - key: String, - ): Boolean = parseModels(raw)?.let { flagNames(it) }.orEmpty().any { it.equals(key, ignoreCase = true) } - - private fun flagNames(models: JSONArray): List = - buildList { - for (modelIndex in 0 until models.length()) { - collectFlags(models.optJSONObject(modelIndex), this) - } - } - - private fun parseModels(raw: String?): JSONArray? = - if (raw.isNullOrBlank()) { - null - } else { - try { - JSONArray(raw) - } catch (t: Throwable) { - // JSONException's message is the whole document. That store can hold fcmParams.apiKey. - Log.w(TAG, "sdk_dispatcher_bootstrap: config store unreadable, lazy pools (${t.javaClass.simpleName})") - null - } - } - - private fun collectFlags( - model: JSONObject?, - into: MutableList, - ) { - val flags = model?.optJSONArray(FLAGS_PROPERTY) ?: return - for (flagIndex in 0 until flags.length()) { - into.add(flags.optString(flagIndex)) - } - } - - private const val FLAGS_PROPERTY = "sdkRemoteFeatureFlags" - private const val TAG = "OneSignal" -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md index 8045c7fc35..b06249b021 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/README.md @@ -4,6 +4,7 @@ `OneSignalStartupFlags`, so code that runs before SDK init can read them without loading the whole OneSignal prefs file (operations queue included) on the main thread. Typical pre-init readers make a choice that lives for the whole process, such as which thread pools or logging pipeline to build. +`OneSignalDispatchers` is one: it picks its pools from `sdk_dispatcher_bootstrap`. ## Adding a startup flag @@ -43,8 +44,9 @@ Nothing else. Both calls return the same value for the life of the process. ## Tests - `FeatureManager.resetStartupFlagsForTest()` forgets the snapshot, as a new process would. - `OneSignalTestProjectConfig` calls it around every spec. + `OneSignalTestProjectConfig` calls it around every spec, and `OneSignalDispatchers.resetForTest()` + calls it before building a new pool generation. - `StartupFlagsCache.setSnapshotForTest(...)` installs a fixed snapshot; `readFile` reads the file without touching the snapshot. - `FeatureManager.localFeatureOverrides` does not reach the snapshot. Pre-init readers need their own - test hooks. + test hooks (for example `OneSignalDispatchers.bootstrapEnabledForTest`). diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt new file mode 100644 index 0000000000..6307c48e4a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt @@ -0,0 +1,116 @@ +package com.onesignal.common.threading + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.features.FeatureManager +import com.onesignal.core.internal.features.StartupFlagsCache +import com.onesignal.features.FeatureFlag +import com.onesignal.mocks.MockHelper +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** How [OneSignalDispatchers] picks pools from the startup flags snapshot. */ +@RobolectricTest +class DispatcherBootstrapFlagTests : FunSpec({ + val bootstrap = FeatureFlag.SDK_DISPATCHER_BOOTSTRAP + val key = bootstrap.key + + afterTest { + StartupFlagsCache.write(appContext(), emptyList()) + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.resetForTest() + } + + test("each APP_STARTUP flag is mirrored on its own") { + val context = appContext() + val logging = FeatureFlag.SDK_CUSTOM_LOGGING + StartupFlagsCache.write(context, listOf(logging.key)) + StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe true + StartupFlagsCache.readFile(context).isEnabled(bootstrap) shouldBe false + + StartupFlagsCache.write(context, listOf(logging.key, key)) + StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe true + StartupFlagsCache.readFile(context).isEnabled(bootstrap) shouldBe true + } + + test("a mirrored key selects bootstrap on the next pool generation") { + val context = appContext() + StartupFlagsCache.write(context, listOf(key)) + latchFrom(context) + + builtOnCaller() shouldBe false + } + + test("no mirrored key keeps construction on the caller") { + val context = appContext() + StartupFlagsCache.write(context, emptyList()) + latchFrom(context) + + builtOnCaller() shouldBe true + } + + test("an unreadable file keeps construction on the caller") { + val broken = mockk() + every { broken.applicationContext } returns broken + every { broken.getSharedPreferences(any(), any()) } throws SecurityException("prefs unavailable") + latchFrom(broken) + + builtOnCaller() shouldBe true + } + + test("prewarm latches from the process application when startup did not note a context") { + StartupFlagsCache.write(appContext(), listOf(key)) + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.resetForTest() + + builtOnCaller() shouldBe false + } + + test("FeatureManager reports the pools the process actually latched when store and file disagree") { + val context = appContext() + StartupFlagsCache.write(context, emptyList()) + latchFrom(context) + builtOnCaller() shouldBe true + + val store = MockHelper.configModelStore { it.sdkRemoteFeatureFlags = listOf(key) } + val applicationService = MockHelper.applicationService() + every { applicationService.appContext } returns context + val manager = FeatureManager(store, applicationService) + + manager.isEnabled(bootstrap) shouldBe false + manager.enabledFeatureKeys() shouldBe emptyList() + } +}) + +private fun appContext(): Context = ApplicationProvider.getApplicationContext() + +private fun latchFrom(context: Context) { + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.noteApplicationContext(context) + OneSignalDispatchers.resetForTest() +} + +/** True when the IO lane is built on the thread that first launched work. */ +private fun builtOnCaller(): Boolean { + val callerThreadId = AtomicLong() + val createThreadId = AtomicLong() + val created = CountDownLatch(1) + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") { + createThreadId.set(Thread.currentThread().id) + created.countDown() + } + } + Thread { + callerThreadId.set(Thread.currentThread().id) + OneSignalDispatchers.launchOnIO { } + }.start() + created.await(2, TimeUnit.SECONDS) shouldBe true + return createThreadId.get() == callerThreadId.get() +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherLaneBehaviorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherLaneBehaviorTests.kt new file mode 100644 index 0000000000..e9c7a1348e --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherLaneBehaviorTests.kt @@ -0,0 +1,337 @@ +package com.onesignal.common.threading + +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +/** Lane behavior both pool types must share. Each test runs once per value of the flag. */ +class DispatcherLaneBehaviorTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + listOf(true to "bootstrap", false to "lazy").forEach { (bootstrap, mode) -> + context("$mode pools") { + beforeEach { + OneSignalDispatchers.bootstrapEnabledForTest = bootstrap + OneSignalDispatchers.resetForTest() + } + + afterEach { + OneSignalDispatchers.bootstrapEnabledForTest = null + } + + test("builds lanes on the thread this mode promises") { + val callerThreadId = AtomicLong() + val createThreadId = AtomicLong() + val created = CountDownLatch(1) + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") { + createThreadId.set(Thread.currentThread().id) + created.countDown() + } + } + Thread { + callerThreadId.set(Thread.currentThread().id) + OneSignalDispatchers.launchOnIO { } + }.start() + + created.await(2, TimeUnit.SECONDS) shouldBe true + (createThreadId.get() == callerThreadId.get()) shouldBe !bootstrap + } + + test("OneSignalDispatchers should be properly initialized") { + // Access dispatchers to trigger initialization + OneSignalDispatchers.IO shouldNotBe null + OneSignalDispatchers.Default shouldNotBe null + } + + test("ingress work runs while every IO worker is blocked") { + OneSignalDispatchers.resetForTest() + val releaseIo = CountDownLatch(1) + val ioWorkersBlocked = CountDownLatch(2) + val ingressRan = CountDownLatch(1) + var ingressThreadName: String? = null + + repeat(2) { + OneSignalDispatchers.launchOnIO { + ioWorkersBlocked.countDown() + releaseIo.await() + } + } + ioWorkersBlocked.await(1, TimeUnit.SECONDS) shouldBe true + + OneSignalDispatchers.launchOnIngress { + ingressThreadName = Thread.currentThread().name + ingressRan.countDown() + } + + try { + ingressRan.await(1, TimeUnit.SECONDS) shouldBe true + ingressThreadName shouldContain "OneSignal-Ingress" + } finally { + releaseIo.countDown() + OneSignalDispatchers.resetForTest() + } + } + + test("IO dispatcher should execute work on background thread") { + val mainThreadId = Thread.currentThread().id + var backgroundThreadId: Long? = null + + runBlocking { + withContext(OneSignalDispatchers.IO) { + backgroundThreadId = Thread.currentThread().id + } + } + + backgroundThreadId shouldNotBe null + backgroundThreadId shouldNotBe mainThreadId + } + + test("Default dispatcher should execute work on background thread") { + val mainThreadId = Thread.currentThread().id + var backgroundThreadId: Long? = null + + runBlocking { + withContext(OneSignalDispatchers.Default) { + backgroundThreadId = Thread.currentThread().id + } + } + + backgroundThreadId shouldNotBe null + backgroundThreadId shouldNotBe mainThreadId + } + + test("IOScope should launch coroutines asynchronously") { + var completed = false + + OneSignalDispatchers.launchOnIO { + Thread.sleep(100) + completed = true + } + + Thread.sleep(50) + completed shouldBe false + } + + test("DefaultScope should launch coroutines asynchronously") { + var completed = false + + OneSignalDispatchers.launchOnDefault { + Thread.sleep(100) + completed = true + } + + Thread.sleep(50) + completed shouldBe false + } + + test("getStatus should return meaningful status information") { + OneSignalDispatchers.prewarm() + OneSignalDispatchers.awaitReadyForTest() shouldBe true + val status = OneSignalDispatchers.getStatus() + + status shouldContain "OneSignalDispatchers Status:" + status shouldContain "IO Executor: Active" + status shouldContain "Default Executor: Active" + status shouldContain "SerialIO Executor: Active" + status shouldContain "IO Scope: Active" + status shouldContain "Default Scope: Active" + status shouldContain "SerialIO Scope: Active" + status shouldContain "Ingress Executor: Active" + status shouldContain "Ingress Scope: Active" + } + + test("getPerformanceMetrics should include SerialIO queue and total completed task counters") { + // Trigger lazy init of the SerialIO executor before asking for metrics so its queue + // line resolves to a concrete value instead of the "n/a" fallback path. + OneSignalDispatchers.SerialIO shouldNotBe null + + val metrics = OneSignalDispatchers.getPerformanceMetrics() + + metrics shouldContain "OneSignalDispatchers Performance Metrics:" + metrics shouldContain "SerialIO Queue:" + metrics shouldContain "Total completed tasks:" + } + + test("SerialIO dispatcher executes work on a background thread") { + val callerThreadId = Thread.currentThread().id + var serialThreadId: Long? = null + + runBlocking { + withContext(OneSignalDispatchers.SerialIO) { + serialThreadId = Thread.currentThread().id + } + } + + serialThreadId shouldNotBe null + serialThreadId shouldNotBe callerThreadId + } + + test("launchOnSerialIO runs tasks on a single thread in submission order") { + // SerialIO's contract: submission order on the caller thread == execution order on + // the worker thread. We submit N tasks with a small sleep so they queue up, then + // assert the recorded order matches submission order and that all observations + // came from one thread. + val taskCount = 5 + val observedOrder = mutableListOf() + val observedThreads = mutableSetOf() + val latch = CountDownLatch(taskCount) + + repeat(taskCount) { i -> + OneSignalDispatchers.launchOnSerialIO { + Thread.sleep(5) + synchronized(observedOrder) { + observedOrder.add(i) + observedThreads.add(Thread.currentThread().id) + } + latch.countDown() + } + } + + latch.await() + observedOrder shouldBe (0 until taskCount).toList() + observedThreads.size shouldBe 1 + } + + test("exceptions in a SerialIO task do not stop subsequent tasks from running") { + // Mirrors the parallel "exceptions in one task should not affect others" case for + // launchOnIO. A thrown exception in one serial task must not poison the dispatcher + // for the rest of the queue. + val latch = CountDownLatch(3) + val successCount = AtomicInteger(0) + val errorCount = AtomicInteger(0) + + repeat(3) { i -> + OneSignalDispatchers.launchOnSerialIO { + try { + if (i == 1) { + throw RuntimeException("Test error") + } + successCount.incrementAndGet() + } catch (e: Exception) { + errorCount.incrementAndGet() + } finally { + latch.countDown() + } + } + } + + latch.await() + successCount.get() shouldBe 2 + errorCount.get() shouldBe 1 + } + + test("dispatchers should handle concurrent operations") { + val results = mutableListOf() + val expectedResults = (1..5).toList() + + runBlocking { + (1..5).forEach { i -> + OneSignalDispatchers.launchOnIO { + Thread.sleep(10) + synchronized(results) { + results.add(i) + } + } + } + + Thread.sleep(100) + } + + results.sorted() shouldBe expectedResults + } + + test("multiple concurrent launches should not cause issues") { + val latch = CountDownLatch(5) // Reduced from 20 to 5 + val completed = AtomicInteger(0) + + repeat(5) { i -> // Reduced from 20 to 5 + OneSignalDispatchers.launchOnIO { + delay(10) // Use coroutine delay instead of Thread.sleep + completed.incrementAndGet() + latch.countDown() + } + } + + latch.await() + completed.get() shouldBe 5 // Updated expectation + } + + test("mixed IO and computation tasks should work together") { + val latch = CountDownLatch(10) + val ioCount = AtomicInteger(0) + val compCount = AtomicInteger(0) + + repeat(5) { i -> + OneSignalDispatchers.launchOnIO { + Thread.sleep(20) + ioCount.incrementAndGet() + latch.countDown() + } + + OneSignalDispatchers.launchOnDefault { + Thread.sleep(20) + compCount.incrementAndGet() + latch.countDown() + } + } + + latch.await() + ioCount.get() shouldBe 5 + compCount.get() shouldBe 5 + } + + test("exceptions in one task should not affect others") { + val latch = CountDownLatch(5) + val successCount = AtomicInteger(0) + val errorCount = AtomicInteger(0) + + repeat(5) { i -> + OneSignalDispatchers.launchOnIO { + try { + if (i == 2) { + throw RuntimeException("Test error") + } + Thread.sleep(10) + successCount.incrementAndGet() + } catch (e: Exception) { + errorCount.incrementAndGet() + } finally { + latch.countDown() + } + } + } + + latch.await() + successCount.get() shouldBe 4 + errorCount.get() shouldBe 1 + } + + test("prewarm returns immediately and warms IO / Default / SerialIO dispatchers on a background thread") { + OneSignalDispatchers.resetForTest() + OneSignalDispatchers.prewarm() + + OneSignalDispatchers.awaitReadyForTest() shouldBe true + val status = OneSignalDispatchers.getStatus() + status shouldContain "IO Executor: Active" + status shouldContain "Default Executor: Active" + status shouldContain "SerialIO Executor: Active" + status shouldContain "IO Scope: Active" + status shouldContain "Default Scope: Active" + status shouldContain "SerialIO Scope: Active" + } + } + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt index 5cc8816bf6..598d5b393f 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt @@ -5,12 +5,8 @@ import com.onesignal.debug.internal.logging.Logging import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe -import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import java.util.Collections import java.util.concurrent.CountDownLatch import java.util.concurrent.ThreadPoolExecutor @@ -33,41 +29,6 @@ class OneSignalDispatchersTests : FunSpec({ OneSignalDispatchers.bootstrapEnabledForTest = null } - test("OneSignalDispatchers should be properly initialized") { - // Access dispatchers to trigger initialization - OneSignalDispatchers.IO shouldNotBe null - OneSignalDispatchers.Default shouldNotBe null - } - - test("ingress work runs while every IO worker is blocked") { - OneSignalDispatchers.resetForTest() - val releaseIo = CountDownLatch(1) - val ioWorkersBlocked = CountDownLatch(2) - val ingressRan = CountDownLatch(1) - var ingressThreadName: String? = null - - repeat(2) { - OneSignalDispatchers.launchOnIO { - ioWorkersBlocked.countDown() - releaseIo.await() - } - } - ioWorkersBlocked.await(1, TimeUnit.SECONDS) shouldBe true - - OneSignalDispatchers.launchOnIngress { - ingressThreadName = Thread.currentThread().name - ingressRan.countDown() - } - - try { - ingressRan.await(1, TimeUnit.SECONDS) shouldBe true - ingressThreadName shouldContain "OneSignal-Ingress" - } finally { - releaseIo.countDown() - OneSignalDispatchers.resetForTest() - } - } - test("prewarm bootstraps the ingress lane before the other lanes") { OneSignalDispatchers.resetForTest() val created = Collections.synchronizedList(mutableListOf()) @@ -212,126 +173,6 @@ class OneSignalDispatchersTests : FunSpec({ OneSignalDispatchers.resetForTest() } - test("IO dispatcher should execute work on background thread") { - val mainThreadId = Thread.currentThread().id - var backgroundThreadId: Long? = null - - runBlocking { - withContext(OneSignalDispatchers.IO) { - backgroundThreadId = Thread.currentThread().id - } - } - - backgroundThreadId shouldNotBe null - backgroundThreadId shouldNotBe mainThreadId - } - - test("Default dispatcher should execute work on background thread") { - val mainThreadId = Thread.currentThread().id - var backgroundThreadId: Long? = null - - runBlocking { - withContext(OneSignalDispatchers.Default) { - backgroundThreadId = Thread.currentThread().id - } - } - - backgroundThreadId shouldNotBe null - backgroundThreadId shouldNotBe mainThreadId - } - - test("IOScope should launch coroutines asynchronously") { - var completed = false - - OneSignalDispatchers.launchOnIO { - Thread.sleep(100) - completed = true - } - - Thread.sleep(50) - completed shouldBe false - } - - test("DefaultScope should launch coroutines asynchronously") { - var completed = false - - OneSignalDispatchers.launchOnDefault { - Thread.sleep(100) - completed = true - } - - Thread.sleep(50) - completed shouldBe false - } - - test("getStatus should return meaningful status information") { - OneSignalDispatchers.prewarm() - OneSignalDispatchers.awaitReadyForTest() shouldBe true - val status = OneSignalDispatchers.getStatus() - - status shouldContain "OneSignalDispatchers Status:" - status shouldContain "IO Executor: Active" - status shouldContain "Default Executor: Active" - status shouldContain "SerialIO Executor: Active" - status shouldContain "IO Scope: Active" - status shouldContain "Default Scope: Active" - status shouldContain "SerialIO Scope: Active" - status shouldContain "Ingress Executor: Active" - status shouldContain "Ingress Scope: Active" - } - - test("getPerformanceMetrics should include SerialIO queue and total completed task counters") { - // Trigger lazy init of the SerialIO executor before asking for metrics so its queue - // line resolves to a concrete value instead of the "n/a" fallback path. - OneSignalDispatchers.SerialIO shouldNotBe null - - val metrics = OneSignalDispatchers.getPerformanceMetrics() - - metrics shouldContain "OneSignalDispatchers Performance Metrics:" - metrics shouldContain "SerialIO Queue:" - metrics shouldContain "Total completed tasks:" - } - - test("SerialIO dispatcher executes work on a background thread") { - val callerThreadId = Thread.currentThread().id - var serialThreadId: Long? = null - - runBlocking { - withContext(OneSignalDispatchers.SerialIO) { - serialThreadId = Thread.currentThread().id - } - } - - serialThreadId shouldNotBe null - serialThreadId shouldNotBe callerThreadId - } - - test("launchOnSerialIO runs tasks on a single thread in submission order") { - // SerialIO's contract: submission order on the caller thread == execution order on - // the worker thread. We submit N tasks with a small sleep so they queue up, then - // assert the recorded order matches submission order and that all observations - // came from one thread. - val taskCount = 5 - val observedOrder = mutableListOf() - val observedThreads = mutableSetOf() - val latch = CountDownLatch(taskCount) - - repeat(taskCount) { i -> - OneSignalDispatchers.launchOnSerialIO { - Thread.sleep(5) - synchronized(observedOrder) { - observedOrder.add(i) - observedThreads.add(Thread.currentThread().id) - } - latch.countDown() - } - } - - latch.await() - observedOrder shouldBe (0 until taskCount).toList() - observedThreads.size shouldBe 1 - } - test("executorStatus returns 'Active' / 'Shutdown' on the happy path and the Not initialized message when the underlying check throws") { // Happy paths (Shutdown / Active) are exercised indirectly via getStatus(); this // case pins down the defensive catch branch, which fires when the underlying @@ -360,134 +201,6 @@ class OneSignalDispatchersTests : FunSpec({ } shouldBe "IOScope Not initialized Unknown error" } - test("exceptions in a SerialIO task do not stop subsequent tasks from running") { - // Mirrors the parallel "exceptions in one task should not affect others" case for - // launchOnIO. A thrown exception in one serial task must not poison the dispatcher - // for the rest of the queue. - val latch = CountDownLatch(3) - val successCount = AtomicInteger(0) - val errorCount = AtomicInteger(0) - - repeat(3) { i -> - OneSignalDispatchers.launchOnSerialIO { - try { - if (i == 1) { - throw RuntimeException("Test error") - } - successCount.incrementAndGet() - } catch (e: Exception) { - errorCount.incrementAndGet() - } finally { - latch.countDown() - } - } - } - - latch.await() - successCount.get() shouldBe 2 - errorCount.get() shouldBe 1 - } - - test("dispatchers should handle concurrent operations") { - val results = mutableListOf() - val expectedResults = (1..5).toList() - - runBlocking { - (1..5).forEach { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(10) - synchronized(results) { - results.add(i) - } - } - } - - Thread.sleep(100) - } - - results.sorted() shouldBe expectedResults - } - - test("multiple concurrent launches should not cause issues") { - val latch = CountDownLatch(5) // Reduced from 20 to 5 - val completed = AtomicInteger(0) - - repeat(5) { i -> // Reduced from 20 to 5 - OneSignalDispatchers.launchOnIO { - delay(10) // Use coroutine delay instead of Thread.sleep - completed.incrementAndGet() - latch.countDown() - } - } - - latch.await() - completed.get() shouldBe 5 // Updated expectation - } - - test("mixed IO and computation tasks should work together") { - val latch = CountDownLatch(10) - val ioCount = AtomicInteger(0) - val compCount = AtomicInteger(0) - - repeat(5) { i -> - OneSignalDispatchers.launchOnIO { - Thread.sleep(20) - ioCount.incrementAndGet() - latch.countDown() - } - - OneSignalDispatchers.launchOnDefault { - Thread.sleep(20) - compCount.incrementAndGet() - latch.countDown() - } - } - - latch.await() - ioCount.get() shouldBe 5 - compCount.get() shouldBe 5 - } - - test("exceptions in one task should not affect others") { - val latch = CountDownLatch(5) - val successCount = AtomicInteger(0) - val errorCount = AtomicInteger(0) - - repeat(5) { i -> - OneSignalDispatchers.launchOnIO { - try { - if (i == 2) { - throw RuntimeException("Test error") - } - Thread.sleep(10) - successCount.incrementAndGet() - } catch (e: Exception) { - errorCount.incrementAndGet() - } finally { - latch.countDown() - } - } - } - - latch.await() - successCount.get() shouldBe 4 - errorCount.get() shouldBe 1 - } - - test("prewarm returns immediately and warms IO / Default / SerialIO dispatchers on a background thread") { - OneSignalDispatchers.resetForTest() - OneSignalDispatchers.prewarm() - - OneSignalDispatchers.awaitReadyForTest() shouldBe true - val status = OneSignalDispatchers.getStatus() - status shouldContain "IO Executor: Active" - status shouldContain "Default Executor: Active" - status shouldContain "SerialIO Executor: Active" - status shouldContain "IO Scope: Active" - status shouldContain "Default Scope: Active" - status shouldContain "SerialIO Scope: Active" - } - test("flag off constructs the IO executor on the calling thread") { OneSignalDispatchers.bootstrapEnabledForTest = false OneSignalDispatchers.resetForTest() @@ -528,17 +241,19 @@ class OneSignalDispatchersTests : FunSpec({ OneSignalDispatchers.resetForTest() val entered = CountDownLatch(1) val release = CountDownLatch(1) + // Shutdown interrupts prewarm. Ignore it so the closed checks, not a dead thread, stop the build. OneSignalDispatchers.beforeLazyWarmForTest = { entered.countDown() - release.await(2, TimeUnit.SECONDS) + awaitIgnoringInterrupts(release) } OneSignalDispatchers.prewarm() entered.await(2, TimeUnit.SECONDS) shouldBe true - val workersBefore = dispatcherWorkerIds() OneSignalDispatchers.resetForTest() + val builtAfterReset = AtomicInteger() + OneSignalDispatchers.afterExecutorCreateForTest = { _, _ -> builtAfterReset.incrementAndGet() } release.countDown() Thread.sleep(400) - (dispatcherWorkerIds() - workersBefore) shouldBe emptySet() + builtAfterReset.get() shouldBe 0 } test("flag off falls back when executor construction throws") { @@ -576,12 +291,13 @@ class OneSignalDispatchersTests : FunSpec({ } }) -private fun dispatcherWorkerIds(): Set = - Thread.getAllStackTraces().keys - .filter { thread -> - thread.name.startsWith("OneSignal-IO") || - thread.name.startsWith("OneSignal-Default") || - thread.name.startsWith("OneSignal-SerialIO") || - thread.name.startsWith("OneSignal-Ingress") - }.map { it.id } - .toSet() +private fun awaitIgnoringInterrupts(latch: CountDownLatch) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) + while (latch.count > 0 && System.nanoTime() < deadline) { + try { + latch.await(deadline - System.nanoTime(), TimeUnit.NANOSECONDS) + } catch (_: InterruptedException) { + // Keep waiting: the test needs the thread alive after shutdown. + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt deleted file mode 100644 index 263ee46a9c..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/DispatcherBootstrapCacheTests.kt +++ /dev/null @@ -1,160 +0,0 @@ -package com.onesignal.core.internal.features - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest -import com.onesignal.common.threading.OneSignalDispatchers -import com.onesignal.core.internal.config.CONFIG_NAME_SPACE -import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys -import com.onesignal.core.internal.preferences.PreferenceStores -import com.onesignal.features.FeatureFlag -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe -import io.kotest.matchers.string.shouldNotContain -import org.json.JSONArray -import org.robolectric.shadows.ShadowLog -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicLong - -@RobolectricTest -class DispatcherBootstrapCacheTests : FunSpec({ - val key = FeatureFlag.SDK_DISPATCHER_BOOTSTRAP.key - - afterTest { - clearFlags() - OneSignalDispatchers.resetBootstrapInputsForTest() - OneSignalDispatchers.resetForTest() - } - - test("missing, empty, and unreadable caches are off") { - DispatcherBootstrapCache.containsKey(null, key) shouldBe false - DispatcherBootstrapCache.containsKey("", key) shouldBe false - DispatcherBootstrapCache.containsKey("[]", key) shouldBe false - DispatcherBootstrapCache.containsKey("not json", key) shouldBe false - DispatcherBootstrapCache.containsKey("[{}]", key) shouldBe false - DispatcherBootstrapCache.containsKey( - """[{"sdkRemoteFeatureFlags":["sdk_custom_logging"]}]""", - key, - ) shouldBe false - } - - test("an unreadable config does not log the store") { - val secret = "fcm-api-key-sentinel" - val raw = """[{"fcmParams":{"apiKey":"$secret"}]""" - ShadowLog.clear() - DispatcherBootstrapCache.containsKey(raw, key) shouldBe false - ShadowLog.getLogs().joinToString("\n") { item -> - item.msg + (item.throwable?.toString() ?: "") - } shouldNotContain secret - } - - test("the cached key enables bootstrap regardless of case") { - val raw = """[{"sdkRemoteFeatureFlags":["SDK_Dispatcher_Bootstrap"]}]""" - DispatcherBootstrapCache.containsKey(raw, key) shouldBe true - } - - test("a persisted key selects bootstrap on the next pool generation") { - val context = ApplicationProvider.getApplicationContext() - writeFlags(context, listOf(key)) - latchFrom(context) - - val callerThreadId = AtomicLong() - val createThreadId = AtomicLong() - val createStarted = CountDownLatch(1) - val finished = CountDownLatch(1) - OneSignalDispatchers.beforeLaneCreateForTest = { lane -> - if (lane == "IO") { - createThreadId.set(Thread.currentThread().id) - createStarted.countDown() - } - } - Thread { - callerThreadId.set(Thread.currentThread().id) - OneSignalDispatchers.launchOnIO { } - finished.countDown() - }.start() - - createStarted.await(2, TimeUnit.SECONDS) shouldBe true - finished.await(2, TimeUnit.SECONDS) shouldBe true - createThreadId.get() shouldNotBe callerThreadId.get() - } - - test("a cache without the key keeps construction on the caller") { - val context = ApplicationProvider.getApplicationContext() - writeFlags(context, emptyList()) - latchFrom(context) - - val callerThreadId = AtomicLong() - val createThreadId = AtomicLong() - val finished = CountDownLatch(1) - OneSignalDispatchers.beforeLaneCreateForTest = { lane -> - if (lane == "IO") createThreadId.set(Thread.currentThread().id) - } - Thread { - callerThreadId.set(Thread.currentThread().id) - OneSignalDispatchers.launchOnIO { } - finished.countDown() - }.start() - - finished.await(2, TimeUnit.SECONDS) shouldBe true - createThreadId.get() shouldBe callerThreadId.get() - } - - test("prewarm latches from the process application when startup did not note a context") { - val context = ApplicationProvider.getApplicationContext() - writeFlags(context, listOf(key)) - OneSignalDispatchers.resetBootstrapInputsForTest() - OneSignalDispatchers.resetForTest() - - val callerThreadId = AtomicLong() - val createThreadId = AtomicLong() - val createStarted = CountDownLatch(1) - val finished = CountDownLatch(1) - OneSignalDispatchers.beforeLaneCreateForTest = { lane -> - if (lane == "IO") { - createThreadId.set(Thread.currentThread().id) - createStarted.countDown() - } - } - Thread { - callerThreadId.set(Thread.currentThread().id) - OneSignalDispatchers.launchOnIO { } - finished.countDown() - }.start() - - createStarted.await(2, TimeUnit.SECONDS) shouldBe true - finished.await(2, TimeUnit.SECONDS) shouldBe true - createThreadId.get() shouldNotBe callerThreadId.get() - } -}) - -private fun latchFrom(context: Context) { - OneSignalDispatchers.resetBootstrapInputsForTest() - OneSignalDispatchers.noteApplicationContext(context) - OneSignalDispatchers.resetForTest() -} - -private fun clearFlags() { - val context = ApplicationProvider.getApplicationContext() - context - .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) - .edit() - .remove(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE) - .commit() -} - -private fun writeFlags( - context: Context, - flags: List, -) { - val model = ConfigModel().apply { sdkRemoteFeatureFlags = flags } - val store = JSONArray().put(model.toJSON()).toString() - context - .getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) - .edit() - .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + CONFIG_NAME_SPACE, store) - .commit() -} From 539f555b17b68e27df867f59fd06898afe23bc96 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 12:01:36 -0500 Subject: [PATCH 05/13] refactor: [SDK-5581] map KMP startup flag sources in the dispatcher choice Use StartupFlagsSource and report a missing cache as "off, no cache yet". Co-authored-by: Cursor --- .../common/threading/OneSignalDispatchers.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index f810504de7..4a355369a7 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -3,9 +3,9 @@ package com.onesignal.common.threading import android.content.Context import android.util.Log import com.onesignal.core.internal.features.FeatureManager -import com.onesignal.core.internal.features.StartupFlags import com.onesignal.debug.internal.logging.Logging import com.onesignal.features.FeatureFlag +import com.onesignal.features.StartupFlagsSource import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -520,11 +520,12 @@ object OneSignalDispatchers { val enabled = flags.isEnabled(FeatureFlag.SDK_DISPATCHER_BOOTSTRAP) val detail = when (flags.source) { - StartupFlags.Source.FILE -> if (enabled) "on" else "off, flag not in cache" - StartupFlags.Source.UNREADABLE -> "off, cache unreadable" - StartupFlags.Source.NO_APPLICATION -> "off, no application" + StartupFlagsSource.STORE -> if (enabled) "on" else "off, flag not in cache" + StartupFlagsSource.MISSING -> "off, no cache yet" + StartupFlagsSource.UNREADABLE -> "off, cache unreadable" + StartupFlagsSource.NO_STORE -> "off, no application" } - return PoolChoice(poolsFor(enabled), detail, warn = flags.source == StartupFlags.Source.NO_APPLICATION) + return PoolChoice(poolsFor(enabled), detail, warn = flags.source == StartupFlagsSource.NO_STORE) } private fun poolsFor(enabled: Boolean): DispatcherPools = if (enabled) Pools() else LazyDispatcherPools() From 03cbcebd21a3131c5d9d4b75282ed57901d8a6fd Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 13:13:21 -0500 Subject: [PATCH 06/13] fix: [SDK-5581] address review findings on dispatcher pools - 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 --- .../threading/BootstrapDispatcherPools.kt | 413 ++++++++++++++++ .../common/threading/LazyDispatcherPools.kt | 6 +- .../common/threading/OneSignalDispatchers.kt | 445 ++---------------- .../onesignal/OneSignalTestProjectConfig.kt | 14 +- .../threading/DispatcherBootstrapFlagTests.kt | 9 + .../threading/DispatcherFailureLogTests.kt | 68 +++ 6 files changed, 542 insertions(+), 413 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherFailureLogTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt new file mode 100644 index 0000000000..09509d7f68 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt @@ -0,0 +1,413 @@ +package com.onesignal.common.threading + +import com.onesignal.debug.internal.logging.Logging +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import java.util.concurrent.CancellationException +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ThreadFactory +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext + +private const val BASE = OneSignalDispatchers.BASE_THREAD_NAME + +// Optimized pool sizes based on CPU cores and workload analysis +private const val IO_CORE_POOL_SIZE = 2 // Increased for better concurrency +private const val IO_MAX_POOL_SIZE = 3 // Increased for better concurrency +private const val DEFAULT_CORE_POOL_SIZE = 2 // Optimal for CPU operations +private const val DEFAULT_MAX_POOL_SIZE = 3 // Slightly larger for CPU operations +private const val KEEP_ALIVE_TIME_SECONDS = + 30L // Keep threads alive longer to reduce recreation +private const val QUEUE_CAPACITY = + 200 // Increased to handle more queued operations during init, while still preventing memory bloat +private const val IO_THREAD_NAME_PREFIX = + "$BASE-IO" // Thread name prefix for I/O operations +private const val DEFAULT_THREAD_NAME_PREFIX = + "$BASE-Default" // Thread name prefix for CPU operations +private const val SERIAL_IO_THREAD_NAME = + "$BASE-SerialIO" // Single, named thread for order-sensitive work +private const val INGRESS_THREAD_NAME = "$BASE-Ingress" + +private class OptimizedThreadFactory( + private val namePrefix: String, + private val priority: Int = Thread.NORM_PRIORITY, +) : ThreadFactory { + private val threadNumber = AtomicInteger(1) + + override fun newThread(r: Runnable): Thread { + val thread = Thread(r, "$namePrefix-${threadNumber.getAndIncrement()}") + thread.isDaemon = true + thread.priority = priority + return thread + } +} + +private enum class Lane { + IO, + DEFAULT, + SERIAL_IO, + INGRESS, +} + +private enum class LaneState { + COLD, + STARTING, + DRAINING, + READY, + CLOSED, +} + +private class LaneTarget( + val dispatcher: CoroutineDispatcher, + val executor: ThreadPoolExecutor?, +) { + fun close() { + executor?.shutdownNow() + } +} + +private data class LaneConfig( + val corePoolSize: Int, + val maxPoolSize: Int, + val threadName: String, + val priority: Int = Thread.NORM_PRIORITY, + val queueCapacity: Int = QUEUE_CAPACITY, +) + +/** + * A stable dispatcher that queues work until its backing executor has been built and + * prestarted on the bootstrap thread. CoroutineScope.launch can therefore return its real Job + * without forcing executor construction or waiting on a lazy lock on the caller thread. + */ +@Suppress("TooManyFunctions") +private class GateDispatcher( + private val lane: Lane, + private val requestBootstrap: (GateDispatcher) -> Unit, +) : CoroutineDispatcher(), DispatcherLane { + override val dispatcher: CoroutineDispatcher get() = this + + private val lock = Any() + private val pending = ArrayDeque>() + private var state = LaneState.COLD + private var target: LaneTarget? = null + + override fun dispatch( + context: CoroutineContext, + block: Runnable, + ) { + var readyDispatcher: CoroutineDispatcher? = null + var shouldBootstrap = false + var rejected = false + + synchronized(lock) { + when (state) { + LaneState.COLD -> { + pending.addLast(context to block) + state = LaneState.STARTING + shouldBootstrap = true + } + LaneState.STARTING, LaneState.DRAINING -> pending.addLast(context to block) + LaneState.READY -> readyDispatcher = target!!.dispatcher + LaneState.CLOSED -> rejected = true + } + } + + when { + shouldBootstrap -> requestBootstrap(this) + readyDispatcher != null -> dispatchSafely(readyDispatcher!!, context, block) + rejected -> cancelAndComplete(context, block, "OneSignal dispatcher generation is closed") + } + } + + fun requestWarmup() { + val shouldBootstrap = + synchronized(lock) { + if (state == LaneState.COLD) { + state = LaneState.STARTING + true + } else { + false + } + } + if (shouldBootstrap) requestBootstrap(this) + } + + @Suppress("TooGenericExceptionCaught") + fun initialize() { + createTargetOrFallback()?.let(::drainPending) + } + + // Catches Throwable because thread creation in prestartAllCoreThreads fails with OutOfMemoryError, + // which is exactly the case the Dispatchers fallback exists for. + @Suppress("TooGenericExceptionCaught") + private fun createTargetOrFallback(): LaneTarget? = + try { + createTarget(lane) + } catch (t: Throwable) { + OneSignalDispatchers.logFailure("$lane fell back", t) + try { + createFallbackTarget(lane) + } catch (fallbackError: Throwable) { + OneSignalDispatchers.logFailure("$lane fallback failed", fallbackError) + failPending("OneSignal $lane dispatcher fallback failed") + null + } + } + + private fun drainPending(newTarget: LaneTarget) { + while (true) { + val next = + synchronized(lock) { + if (state == LaneState.CLOSED) { + null + } else { + state = LaneState.DRAINING + pending.removeFirstOrNull().also { + if (it == null) { + target = newTarget + state = LaneState.READY + } + } + } + } + + if (next == null) { + val closed = synchronized(lock) { state == LaneState.CLOSED } + if (closed) newTarget.close() + return + } + dispatchSafely(newTarget.dispatcher, next.first, next.second) + } + } + + fun close() { + val queued: List> + val oldTarget: LaneTarget? + synchronized(lock) { + if (state == LaneState.CLOSED) return + state = LaneState.CLOSED + queued = pending.toList() + pending.clear() + oldTarget = target + target = null + } + queued.forEach { + cancelAndComplete(it.first, it.second, "OneSignal dispatcher generation was reset") + } + oldTarget?.close() + } + + fun bootstrapFailed() { + failPending("OneSignal dispatcher bootstrap thread failed to start") + } + + override fun status(): String = + synchronized(lock) { + when (state) { + LaneState.READY -> "Active" + LaneState.CLOSED -> "Shutdown" + else -> state.name.lowercase().replaceFirstChar { it.uppercase() } + } + } + + override fun executor(): ThreadPoolExecutor? = synchronized(lock) { target?.executor } + + private fun failPending(reason: String) { + val queued = + synchronized(lock) { + val copy = pending.toList() + pending.clear() + // CLOSED is terminal: reopening would let a discarded generation build an executor + // that no later reset tears down. + if (state != LaneState.CLOSED) state = LaneState.COLD + copy + } + queued.forEach { cancelAndComplete(it.first, it.second, reason) } + } + + private fun dispatchSafely( + dispatcher: CoroutineDispatcher, + context: CoroutineContext, + block: Runnable, + ) { + try { + dispatcher.dispatch(context, block) + } catch (e: RuntimeException) { + Logging.error("OneSignalDispatchers: $lane dispatch rejected: ${e.message}", e) + cancelAndComplete(context, block, "OneSignal $lane dispatch rejected") + } + } + + private fun cancelAndComplete( + context: CoroutineContext, + block: Runnable, + reason: String, + ) { + context[Job]?.cancel(CancellationException(reason)) + // Mirrors kotlinx's rejected-execution handling: the caller may be the main thread, + // so the cancelled continuation's catch/finally must not run inline here. + Dispatchers.IO.dispatch(context, block) + } +} + +private class BootstrapCoordinator { + private val queue = ConcurrentLinkedQueue() + private val running = AtomicBoolean(false) + + fun request(gate: GateDispatcher) { + queue.add(gate) + startIfNeeded() + } + + @Suppress("TooGenericExceptionCaught") + private fun startIfNeeded() { + if (!running.compareAndSet(false, true)) return + try { + Thread( + ::runLoop, + "$BASE-bootstrap", + ).apply { + isDaemon = true + priority = Thread.NORM_PRIORITY - 2 + start() + } + } catch (t: Throwable) { + running.set(false) + OneSignalDispatchers.logFailure("bootstrap thread failed to start", t) + while (true) { + val gate = queue.poll() ?: break + gate.bootstrapFailed() + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun runLoop() { + try { + while (true) { + val gate = queue.poll() ?: return + try { + gate.initialize() + } catch (t: Throwable) { + OneSignalDispatchers.logFailure("bootstrap lane failed", t) + gate.bootstrapFailed() + } + } + } finally { + running.set(false) + if (queue.isNotEmpty()) startIfNeeded() + } + } +} + +internal class BootstrapDispatcherPools : DispatcherPools { + private val coordinator = BootstrapCoordinator() + private val ioGate = GateDispatcher(Lane.IO, coordinator::request) + private val defaultGate = GateDispatcher(Lane.DEFAULT, coordinator::request) + private val serialGate = GateDispatcher(Lane.SERIAL_IO, coordinator::request) + private val ingressGate = GateDispatcher(Lane.INGRESS, coordinator::request) + override val io: DispatcherLane = ioGate + override val defaultLane: DispatcherLane = defaultGate + override val serialIO: DispatcherLane = serialGate + override val ingress: DispatcherLane = ingressGate + override val ioScope = CoroutineScope(SupervisorJob() + ioGate) + override val defaultScope = CoroutineScope(SupervisorJob() + defaultGate) + override val serialIOScope = CoroutineScope(SupervisorJob() + serialGate) + override val ingressScope = CoroutineScope(SupervisorJob() + ingressGate) + + // Ingress bootstraps first because receivers have a ~10s broadcast budget. + private val gates = listOf(ingressGate, ioGate, defaultGate, serialGate) + private val scopes = listOf(ingressScope, ioScope, defaultScope, serialIOScope) + + override fun prewarm() { + gates.forEach { it.requestWarmup() } + } + + override fun shutdown() { + scopes.forEach { it.cancel() } + gates.forEach { it.close() } + } +} + +@Suppress("TooGenericExceptionCaught") +private fun createTarget(lane: Lane): LaneTarget { + OneSignalDispatchers.beforeLaneCreateForTest?.invoke(lane.name) + val config = lane.config() + val executor = + ThreadPoolExecutor( + config.corePoolSize, + config.maxPoolSize, + KEEP_ALIVE_TIME_SECONDS, + TimeUnit.SECONDS, + LinkedBlockingQueue(config.queueCapacity), + OptimizedThreadFactory(config.threadName, config.priority), + ) + try { + executor.allowCoreThreadTimeOut(false) + OneSignalDispatchers.afterExecutorCreateForTest?.invoke(lane.name, executor) + executor.prestartAllCoreThreads() + } catch (t: Throwable) { + executor.shutdownNow() + throw t + } + return LaneTarget(executor.asCoroutineDispatcher(), executor) +} + +private fun Lane.config(): LaneConfig = + when (this) { + Lane.IO -> + LaneConfig( + IO_CORE_POOL_SIZE, + IO_MAX_POOL_SIZE, + IO_THREAD_NAME_PREFIX, + Thread.NORM_PRIORITY - 1, + ) + Lane.DEFAULT -> + LaneConfig( + DEFAULT_CORE_POOL_SIZE, + DEFAULT_MAX_POOL_SIZE, + DEFAULT_THREAD_NAME_PREFIX, + ) + Lane.SERIAL_IO -> + LaneConfig( + 1, + 1, + SERIAL_IO_THREAD_NAME, + Thread.NORM_PRIORITY - 1, + // Unbounded like Executors.newSingleThreadExecutor; rejecting would drop ordered lifecycle work. + Int.MAX_VALUE, + ) + // Kept separate from IO so receiver handoffs never queue behind SDK HTTP. + Lane.INGRESS -> + LaneConfig( + 1, + 1, + INGRESS_THREAD_NAME, + Thread.NORM_PRIORITY - 1, + Int.MAX_VALUE, + ) + } + +private fun createFallbackTarget(lane: Lane): LaneTarget { + OneSignalDispatchers.beforeFallbackCreateForTest?.invoke(lane.name) + val dispatcher = + when (lane) { + Lane.IO -> Dispatchers.IO + Lane.DEFAULT -> Dispatchers.Default + Lane.SERIAL_IO, Lane.INGRESS -> { + @Suppress("OPT_IN_USAGE") + Dispatchers.IO.limitedParallelism(1) + } + } + dispatcher.dispatch(kotlin.coroutines.EmptyCoroutineContext, Runnable {}) + return LaneTarget(dispatcher, null) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt index 145697a76c..f2aceb8bc6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt @@ -127,7 +127,7 @@ internal class LazyDispatcherPools : DispatcherPools { private fun logWarmFailure(t: Throwable) { if (closed.get()) return - OneSignalDispatchers.logRelease("lazy prewarm failed", t) + OneSignalDispatchers.logFailure("lazy prewarm failed", t) } private inner class LazyExecutorLane( @@ -146,7 +146,7 @@ internal class LazyDispatcherPools : DispatcherPools { return try { buildExecutor() } catch (t: Throwable) { - OneSignalDispatchers.logRelease("lazy $lane fell back", t) + OneSignalDispatchers.logFailure("lazy $lane fell back", t) OneSignalDispatchers.beforeFallbackCreateForTest?.invoke(lane) null } @@ -169,7 +169,7 @@ internal class LazyDispatcherPools : DispatcherPools { try { executor.asCoroutineDispatcher() } catch (t: Throwable) { - OneSignalDispatchers.logRelease("lazy $lane fell back", t) + OneSignalDispatchers.logFailure("lazy $lane fell back", t) fallback() } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 4a355369a7..ab84ed9f62 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -3,27 +3,15 @@ package com.onesignal.common.threading import android.content.Context import android.util.Log import com.onesignal.core.internal.features.FeatureManager +import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.features.FeatureFlag import com.onesignal.features.StartupFlagsSource import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.cancel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.util.concurrent.CancellationException -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.ThreadFactory import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import kotlin.coroutines.CoroutineContext /** * Optimized threading manager for the OneSignal SDK. @@ -40,26 +28,10 @@ import kotlin.coroutines.CoroutineContext */ @Suppress("TooManyFunctions", "StringLiteralDuplication") object OneSignalDispatchers { - // Optimized pool sizes based on CPU cores and workload analysis - private const val IO_CORE_POOL_SIZE = 2 // Increased for better concurrency - private const val IO_MAX_POOL_SIZE = 3 // Increased for better concurrency - private const val DEFAULT_CORE_POOL_SIZE = 2 // Optimal for CPU operations - private const val DEFAULT_MAX_POOL_SIZE = 3 // Slightly larger for CPU operations - private const val KEEP_ALIVE_TIME_SECONDS = - 30L // Keep threads alive longer to reduce recreation - private const val QUEUE_CAPACITY = - 200 // Increased to handle more queued operations during init, while still preventing memory bloat private const val TEST_READY_TIMEOUT_MS = 2_000L private const val TEST_READY_POLL_MS = 5L internal const val BASE_THREAD_NAME = "OneSignal" // Base thread name prefix private const val RELEASE_LOG_TAG = "OneSignal" - private const val IO_THREAD_NAME_PREFIX = - "$BASE_THREAD_NAME-IO" // Thread name prefix for I/O operations - private const val DEFAULT_THREAD_NAME_PREFIX = - "$BASE_THREAD_NAME-Default" // Thread name prefix for CPU operations - private const val SERIAL_IO_THREAD_NAME = - "$BASE_THREAD_NAME-SerialIO" // Single, named thread for order-sensitive work - private const val INGRESS_THREAD_NAME = "$BASE_THREAD_NAME-Ingress" @Volatile internal var beforeLaneCreateForTest: ((String) -> Unit)? = null @@ -78,379 +50,10 @@ object OneSignalDispatchers { @Volatile internal var beforeLazyPrewarmStartForTest: (() -> Unit)? = null - private class OptimizedThreadFactory( - private val namePrefix: String, - private val priority: Int = Thread.NORM_PRIORITY, - ) : ThreadFactory { - private val threadNumber = AtomicInteger(1) - - override fun newThread(r: Runnable): Thread { - val thread = Thread(r, "$namePrefix-${threadNumber.getAndIncrement()}") - thread.isDaemon = true - thread.priority = priority - return thread - } - } - - private enum class Lane { - IO, - DEFAULT, - SERIAL_IO, - INGRESS, - } - - private enum class LaneState { - COLD, - STARTING, - DRAINING, - READY, - CLOSED, - } - - private class LaneTarget( - val dispatcher: CoroutineDispatcher, - val executor: ThreadPoolExecutor?, - ) { - fun close() { - executor?.shutdownNow() - } - } - - private data class LaneConfig( - val corePoolSize: Int, - val maxPoolSize: Int, - val threadName: String, - val priority: Int = Thread.NORM_PRIORITY, - val queueCapacity: Int = QUEUE_CAPACITY, - ) - - /** - * A stable dispatcher that queues work until its backing executor has been built and - * prestarted on the bootstrap thread. CoroutineScope.launch can therefore return its real Job - * without forcing executor construction or waiting on a lazy lock on the caller thread. - */ - private class GateDispatcher( - private val lane: Lane, - private val requestBootstrap: (GateDispatcher) -> Unit, - ) : CoroutineDispatcher(), DispatcherLane { - override val dispatcher: CoroutineDispatcher get() = this - - private val lock = Any() - private val pending = ArrayDeque>() - private var state = LaneState.COLD - private var target: LaneTarget? = null - - override fun dispatch( - context: CoroutineContext, - block: Runnable, - ) { - var readyDispatcher: CoroutineDispatcher? = null - var shouldBootstrap = false - var rejected = false - - synchronized(lock) { - when (state) { - LaneState.COLD -> { - pending.addLast(context to block) - state = LaneState.STARTING - shouldBootstrap = true - } - LaneState.STARTING, LaneState.DRAINING -> pending.addLast(context to block) - LaneState.READY -> readyDispatcher = target!!.dispatcher - LaneState.CLOSED -> rejected = true - } - } - - when { - shouldBootstrap -> requestBootstrap(this) - readyDispatcher != null -> dispatchSafely(readyDispatcher!!, context, block) - rejected -> cancelAndComplete(context, block, "OneSignal dispatcher generation is closed") - } - } - - fun requestWarmup() { - val shouldBootstrap = - synchronized(lock) { - if (state == LaneState.COLD) { - state = LaneState.STARTING - true - } else { - false - } - } - if (shouldBootstrap) requestBootstrap(this) - } - - @Suppress("TooGenericExceptionCaught") - fun initialize() { - createTargetOrFallback()?.let(::drainPending) - } - - // Catches Throwable because thread creation in prestartAllCoreThreads fails with OutOfMemoryError, - // which is exactly the case the Dispatchers fallback exists for. - @Suppress("TooGenericExceptionCaught") - private fun createTargetOrFallback(): LaneTarget? = - try { - createTarget(lane) - } catch (t: Throwable) { - logRelease("$lane fell back", t) - try { - createFallbackTarget(lane) - } catch (fallbackError: Throwable) { - logRelease("$lane fallback failed", fallbackError) - failPending("OneSignal $lane dispatcher fallback failed") - null - } - } - - private fun drainPending(newTarget: LaneTarget) { - while (true) { - val next = - synchronized(lock) { - if (state == LaneState.CLOSED) { - null - } else { - state = LaneState.DRAINING - pending.removeFirstOrNull().also { - if (it == null) { - target = newTarget - state = LaneState.READY - } - } - } - } - - if (next == null) { - val closed = synchronized(lock) { state == LaneState.CLOSED } - if (closed) newTarget.close() - return - } - dispatchSafely(newTarget.dispatcher, next.first, next.second) - } - } - - fun close() { - val queued: List> - val oldTarget: LaneTarget? - synchronized(lock) { - if (state == LaneState.CLOSED) return - state = LaneState.CLOSED - queued = pending.toList() - pending.clear() - oldTarget = target - target = null - } - queued.forEach { - cancelAndComplete(it.first, it.second, "OneSignal dispatcher generation was reset") - } - oldTarget?.close() - } - - fun bootstrapFailed() { - failPending("OneSignal dispatcher bootstrap thread failed to start") - } - - override fun status(): String = - synchronized(lock) { - when (state) { - LaneState.READY -> "Active" - LaneState.CLOSED -> "Shutdown" - else -> state.name.lowercase().replaceFirstChar { it.uppercase() } - } - } - - override fun executor(): ThreadPoolExecutor? = synchronized(lock) { target?.executor } - - private fun failPending(reason: String) { - val queued = - synchronized(lock) { - val copy = pending.toList() - pending.clear() - // CLOSED is terminal: reopening would let a discarded generation build an executor - // that no later reset tears down. - if (state != LaneState.CLOSED) state = LaneState.COLD - copy - } - queued.forEach { cancelAndComplete(it.first, it.second, reason) } - } - - private fun dispatchSafely( - dispatcher: CoroutineDispatcher, - context: CoroutineContext, - block: Runnable, - ) { - try { - dispatcher.dispatch(context, block) - } catch (e: RuntimeException) { - Logging.error("OneSignalDispatchers: $lane dispatch rejected: ${e.message}", e) - cancelAndComplete(context, block, "OneSignal $lane dispatch rejected") - } - } - - private fun cancelAndComplete( - context: CoroutineContext, - block: Runnable, - reason: String, - ) { - context[Job]?.cancel(CancellationException(reason)) - // Mirrors kotlinx's rejected-execution handling: the caller may be the main thread, - // so the cancelled continuation's catch/finally must not run inline here. - Dispatchers.IO.dispatch(context, block) - } - } - - private class BootstrapCoordinator { - private val queue = ConcurrentLinkedQueue() - private val running = AtomicBoolean(false) - - fun request(gate: GateDispatcher) { - queue.add(gate) - startIfNeeded() - } - - @Suppress("TooGenericExceptionCaught") - private fun startIfNeeded() { - if (!running.compareAndSet(false, true)) return - try { - Thread( - ::runLoop, - "$BASE_THREAD_NAME-bootstrap", - ).apply { - isDaemon = true - priority = Thread.NORM_PRIORITY - 2 - start() - } - } catch (t: Throwable) { - running.set(false) - logRelease("bootstrap thread failed to start", t) - while (true) { - val gate = queue.poll() ?: break - gate.bootstrapFailed() - } - } - } - - @Suppress("TooGenericExceptionCaught") - private fun runLoop() { - try { - while (true) { - val gate = queue.poll() ?: return - try { - gate.initialize() - } catch (t: Throwable) { - logRelease("bootstrap lane failed", t) - gate.bootstrapFailed() - } - } - } finally { - running.set(false) - if (queue.isNotEmpty()) startIfNeeded() - } - } - } - - private class Pools : DispatcherPools { - private val coordinator = BootstrapCoordinator() - private val ioGate = GateDispatcher(Lane.IO, coordinator::request) - private val defaultGate = GateDispatcher(Lane.DEFAULT, coordinator::request) - private val serialGate = GateDispatcher(Lane.SERIAL_IO, coordinator::request) - private val ingressGate = GateDispatcher(Lane.INGRESS, coordinator::request) - override val io: DispatcherLane = ioGate - override val defaultLane: DispatcherLane = defaultGate - override val serialIO: DispatcherLane = serialGate - override val ingress: DispatcherLane = ingressGate - override val ioScope = CoroutineScope(SupervisorJob() + ioGate) - override val defaultScope = CoroutineScope(SupervisorJob() + defaultGate) - override val serialIOScope = CoroutineScope(SupervisorJob() + serialGate) - override val ingressScope = CoroutineScope(SupervisorJob() + ingressGate) - - // Ingress bootstraps first because receivers have a ~10s broadcast budget. - private val gates = listOf(ingressGate, ioGate, defaultGate, serialGate) - private val scopes = listOf(ingressScope, ioScope, defaultScope, serialIOScope) - - override fun prewarm() { - gates.forEach { it.requestWarmup() } - } - - override fun shutdown() { - scopes.forEach { it.cancel() } - gates.forEach { it.close() } - } - } - - @Suppress("TooGenericExceptionCaught") - private fun createTarget(lane: Lane): LaneTarget { - beforeLaneCreateForTest?.invoke(lane.name) - val config = lane.config() - val executor = - ThreadPoolExecutor( - config.corePoolSize, - config.maxPoolSize, - KEEP_ALIVE_TIME_SECONDS, - TimeUnit.SECONDS, - LinkedBlockingQueue(config.queueCapacity), - OptimizedThreadFactory(config.threadName, config.priority), - ) - try { - executor.allowCoreThreadTimeOut(false) - afterExecutorCreateForTest?.invoke(lane.name, executor) - executor.prestartAllCoreThreads() - } catch (t: Throwable) { - executor.shutdownNow() - throw t - } - return LaneTarget(executor.asCoroutineDispatcher(), executor) - } - - private fun Lane.config(): LaneConfig = - when (this) { - Lane.IO -> - LaneConfig( - IO_CORE_POOL_SIZE, - IO_MAX_POOL_SIZE, - IO_THREAD_NAME_PREFIX, - Thread.NORM_PRIORITY - 1, - ) - Lane.DEFAULT -> - LaneConfig( - DEFAULT_CORE_POOL_SIZE, - DEFAULT_MAX_POOL_SIZE, - DEFAULT_THREAD_NAME_PREFIX, - ) - Lane.SERIAL_IO -> - LaneConfig( - 1, - 1, - SERIAL_IO_THREAD_NAME, - Thread.NORM_PRIORITY - 1, - // Unbounded like Executors.newSingleThreadExecutor; rejecting would drop ordered lifecycle work. - Int.MAX_VALUE, - ) - // Kept separate from IO so receiver handoffs never queue behind SDK HTTP. - Lane.INGRESS -> - LaneConfig( - 1, - 1, - INGRESS_THREAD_NAME, - Thread.NORM_PRIORITY - 1, - Int.MAX_VALUE, - ) - } + @Volatile + internal var beforePoolChoiceForTest: (() -> Unit)? = null - private fun createFallbackTarget(lane: Lane): LaneTarget { - beforeFallbackCreateForTest?.invoke(lane.name) - val dispatcher = - when (lane) { - Lane.IO -> Dispatchers.IO - Lane.DEFAULT -> Dispatchers.Default - Lane.SERIAL_IO, Lane.INGRESS -> { - @Suppress("OPT_IN_USAGE") - Dispatchers.IO.limitedParallelism(1) - } - } - dispatcher.dispatch(kotlin.coroutines.EmptyCoroutineContext, Runnable {}) - return LaneTarget(dispatcher, null) - } + private val inFailureLog = ThreadLocal() @Volatile private var poolsRef: DispatcherPools? = null @@ -485,7 +88,7 @@ object OneSignalDispatchers { private fun currentPools(): DispatcherPools { poolsRef?.let { return it } // Flag read does disk I/O. Keep it outside the lock so other launches are not stuck behind it. - val choice = choosePools() + val choice = choosePoolsOrLazy() val published = synchronized(poolsLock) { val existing = poolsRef @@ -504,9 +107,21 @@ object OneSignalDispatchers { return published } - private fun newPools(): DispatcherPools = choosePools().pools + private fun newPools(): DispatcherPools = choosePoolsOrLazy().pools + + // Every launch in the process goes through this, so a failed choice must not throw. + // Lazy pools need no flags, and they are what the flag being off already means. + @Suppress("TooGenericExceptionCaught") + private fun choosePoolsOrLazy(): PoolChoice = + try { + choosePools() + } catch (t: Throwable) { + logFailure("pool choice failed, using lazy pools", t) + PoolChoice(LazyDispatcherPools(), "off, choice failed", warn = true) + } private fun choosePools(): PoolChoice { + beforePoolChoiceForTest?.invoke() val override = bootstrapEnabledForTest return if (override != null) { PoolChoice(poolsFor(override), "test", false) @@ -528,7 +143,7 @@ object OneSignalDispatchers { return PoolChoice(poolsFor(enabled), detail, warn = flags.source == StartupFlagsSource.NO_STORE) } - private fun poolsFor(enabled: Boolean): DispatcherPools = if (enabled) Pools() else LazyDispatcherPools() + private fun poolsFor(enabled: Boolean): DispatcherPools = if (enabled) BootstrapDispatcherPools() else LazyDispatcherPools() private class PoolChoice( val pools: DispatcherPools, @@ -566,6 +181,25 @@ object OneSignalDispatchers { } } + /** Lane and pool failures. Also sent through [Logging] so they reach remote telemetry. */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + internal fun logFailure( + message: String, + error: Throwable, + ) { + // Log listeners and visual logging can launch onto the failing lane, which fails and logs again. + if (inFailureLog.get() == true) return logRelease(message, error) + inFailureLog.set(true) + try { + if (LogLevel.WARN > Logging.logLevel) logRelease(message, error) + Logging.warn("OneSignalDispatchers: $message", error) + } catch (_: RuntimeException) { + logRelease(message, error) + } finally { + inFailureLog.remove() + } + } + // Dispatchers and scopes delegate to the current generation so [resetForTest] can swap them. val IO: CoroutineDispatcher get() = currentPools().io.dispatcher @@ -610,7 +244,7 @@ object OneSignalDispatchers { currentPools().prewarm() } catch (t: Throwable) { synchronized(prewarmLock) { prewarmStarted = false } - logRelease("prewarm failed", t) + logFailure("prewarm failed", t) } } @@ -663,6 +297,7 @@ object OneSignalDispatchers { beforeFallbackCreateForTest = null beforeLazyWarmForTest = null beforeLazyPrewarmStartForTest = null + beforePoolChoiceForTest = null } @Suppress("ComplexMethod") diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalTestProjectConfig.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalTestProjectConfig.kt index b4bc385edd..5adcad7739 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalTestProjectConfig.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/OneSignalTestProjectConfig.kt @@ -1,7 +1,6 @@ package com.onesignal import com.onesignal.common.threading.OneSignalDispatchers -import com.onesignal.core.internal.features.FeatureManager import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.extensions.Extension import io.kotest.core.listeners.AfterSpecListener @@ -31,12 +30,17 @@ class OneSignalTestProjectConfig : AbstractProjectConfig() { private object OneSignalDispatchersResetListener : BeforeSpecListener, AfterSpecListener { override suspend fun beforeSpec(spec: Spec) { - OneSignalDispatchers.resetForTest() - FeatureManager.resetStartupFlagsForTest() + resetDispatchers() } override suspend fun afterSpec(spec: Spec) { - OneSignalDispatchers.resetForTest() - FeatureManager.resetStartupFlagsForTest() + resetDispatchers() } } + +// Inputs first: the fresh generation latches its pools from them, and a spec's mock +// Application must not decide the pools of the next spec. +private fun resetDispatchers() { + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.resetForTest() +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt index 6307c48e4a..fe23e81e54 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt @@ -64,6 +64,15 @@ class DispatcherBootstrapFlagTests : FunSpec({ builtOnCaller() shouldBe true } + test("a pool choice failure falls back to lazy pools instead of throwing") { + val context = appContext() + StartupFlagsCache.write(context, listOf(key)) + OneSignalDispatchers.beforePoolChoiceForTest = { throw IllegalStateException("choice failed") } + latchFrom(context) + + builtOnCaller() shouldBe true + } + test("prewarm latches from the process application when startup did not note a context") { StartupFlagsCache.write(appContext(), listOf(key)) OneSignalDispatchers.resetBootstrapInputsForTest() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherFailureLogTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherFailureLogTests.kt new file mode 100644 index 0000000000..e0e8044d81 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherFailureLogTests.kt @@ -0,0 +1,68 @@ +package com.onesignal.common.threading + +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.debug.ILogListener +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.ints.shouldBeLessThanOrEqual +import io.kotest.matchers.shouldBe +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** Lane failures go through [Logging], whose listeners can launch straight back onto the failing lane. */ +@RobolectricTest +class DispatcherFailureLogTests : FunSpec({ + // Top-level tests only: the Robolectric extension deadlocks on nested containers. + fun usePools(bootstrap: Boolean) { + Logging.logLevel = LogLevel.NONE + OneSignalDispatchers.bootstrapEnabledForTest = bootstrap + OneSignalDispatchers.resetForTest() + } + + afterEach { + OneSignalDispatchers.bootstrapEnabledForTest = null + OneSignalDispatchers.resetForTest() + } + + listOf(true to "bootstrap", false to "lazy").forEach { (bootstrap, mode) -> + test("$mode pools: a lane failure reaches log listeners") { + usePools(bootstrap) + val logged = CountDownLatch(1) + val listener = ILogListener { if ("IO fell back" in it.entry) logged.countDown() } + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") throw IllegalStateException("no threads") + } + Logging.addListener(listener) + try { + OneSignalDispatchers.launchOnIO { } + logged.await(2, TimeUnit.SECONDS) shouldBe true + } finally { + Logging.removeListener(listener) + } + } + + test("$mode pools: a listener that launches work does not re-enter lane creation") { + usePools(bootstrap) + val attempts = AtomicInteger() + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") { + attempts.incrementAndGet() + throw IllegalStateException("no threads") + } + } + val listener = ILogListener { OneSignalDispatchers.launchOnIO { } } + Logging.addListener(listener) + try { + val ran = CountDownLatch(1) + OneSignalDispatchers.launchOnIO { ran.countDown() } + ran.await(2, TimeUnit.SECONDS) shouldBe true + } finally { + Logging.removeListener(listener) + } + + attempts.get() shouldBeLessThanOrEqual 2 + } + } +}) From cb95776b2f9b8b51999b59172be6bc4fd08f5657 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 14:19:59 -0500 Subject: [PATCH 07/13] fix: [SDK-5581] report degraded dispatcher pool choices to remote telemetry 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 --- .../threading/DispatcherChoiceReporter.kt | 8 ++ .../common/threading/OneSignalDispatchers.kt | 57 ++++++++++--- .../java/com/onesignal/core/CoreModule.kt | 2 + .../threading/DispatcherBootstrapFlagTests.kt | 79 ++++++++++++++++++- 4 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DispatcherChoiceReporter.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DispatcherChoiceReporter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DispatcherChoiceReporter.kt new file mode 100644 index 0000000000..3a4d472b63 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/DispatcherChoiceReporter.kt @@ -0,0 +1,8 @@ +package com.onesignal.common.threading + +import com.onesignal.core.internal.startup.IStartableService + +/** Sends a degraded pool choice to remote telemetry, which init installs before services start. */ +internal class DispatcherChoiceReporter : IStartableService { + override fun start() = OneSignalDispatchers.reportChoiceToTelemetry() +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index ab84ed9f62..641baa8b19 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -62,6 +62,12 @@ object OneSignalDispatchers { @Volatile private var appContext: Context? = null + @Volatile + private var telemetryReady = false + + @Volatile + private var pendingChoiceReport: PoolChoice? = null + /** Test override. Null reads the cached flag, and no cache means off. */ @Volatile internal var bootstrapEnabledForTest: Boolean? = null @@ -82,6 +88,8 @@ object OneSignalDispatchers { internal fun resetBootstrapInputsForTest() { appContext = null bootstrapEnabledForTest = null + telemetryReady = false + pendingChoiceReport = null FeatureManager.resetStartupFlagsForTest() } @@ -100,26 +108,46 @@ object OneSignalDispatchers { choice.pools } } - if (published === choice.pools && bootstrapEnabledForTest == null) { - val mode = if (choice.pools is LazyDispatcherPools) "lazy" else "bootstrap" - logRelease("${choice.detail}; pools=$mode", warn = choice.warn) - } + if (published === choice.pools) recordChoice(choice) return published } - private fun newPools(): DispatcherPools = choosePoolsOrLazy().pools - // Every launch in the process goes through this, so a failed choice must not throw. // Lazy pools need no flags, and they are what the flag being off already means. + // A failure after an "on" snapshot leaves FeatureManager reporting the flag on over lazy + // pools. The "choice failed" report is what tells those launches apart in telemetry. @Suppress("TooGenericExceptionCaught") private fun choosePoolsOrLazy(): PoolChoice = try { choosePools() } catch (t: Throwable) { - logFailure("pool choice failed, using lazy pools", t) - PoolChoice(LazyDispatcherPools(), "off, choice failed", warn = true) + PoolChoice(LazyDispatcherPools(), "off, choice failed", warn = true, error = t) } + /** Call only after [choice] is published: reporting can log, and log listeners can launch. */ + private fun recordChoice(choice: PoolChoice) { + if (bootstrapEnabledForTest != null) return + logRelease(choice.summary, choice.error, warn = choice.warn) + if (!choice.warn) return + if (telemetryReady) reportChoice(choice) else pendingChoiceReport = choice + } + + /** Pools are usually chosen before init installs remote telemetry, so degraded choices wait for it. */ + internal fun reportChoiceToTelemetry() { + telemetryReady = true + pendingChoiceReport?.let(::reportChoice) + pendingChoiceReport = null + } + + @Suppress("SwallowedException") + private fun reportChoice(choice: PoolChoice) { + try { + Logging.warn("OneSignalDispatchers: sdk_dispatcher_bootstrap ${choice.summary}", choice.error) + } catch (_: RuntimeException) { + // A telemetry fault must not fail init or the launch that published the pools. + } + } + private fun choosePools(): PoolChoice { beforePoolChoiceForTest?.invoke() val override = bootstrapEnabledForTest @@ -140,7 +168,8 @@ object OneSignalDispatchers { StartupFlagsSource.UNREADABLE -> "off, cache unreadable" StartupFlagsSource.NO_STORE -> "off, no application" } - return PoolChoice(poolsFor(enabled), detail, warn = flags.source == StartupFlagsSource.NO_STORE) + val degraded = flags.source == StartupFlagsSource.NO_STORE || flags.source == StartupFlagsSource.UNREADABLE + return PoolChoice(poolsFor(enabled), detail, warn = degraded) } private fun poolsFor(enabled: Boolean): DispatcherPools = if (enabled) BootstrapDispatcherPools() else LazyDispatcherPools() @@ -149,7 +178,10 @@ object OneSignalDispatchers { val pools: DispatcherPools, val detail: String, val warn: Boolean, - ) + val error: Throwable? = null, + ) { + val summary: String get() = "$detail; pools=${if (pools is LazyDispatcherPools) "lazy" else "bootstrap"}" + } @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun rememberApplicationContext(): Context? { @@ -280,12 +312,13 @@ object OneSignalDispatchers { @Suppress("TooGenericExceptionCaught") internal fun resetForTest() { FeatureManager.resetStartupFlagsForTest() - val created = newPools() + val choice = choosePoolsOrLazy() val old: DispatcherPools? synchronized(poolsLock) { old = poolsRef - poolsRef = created + poolsRef = choice.pools } + recordChoice(choice) try { old?.shutdown() } catch (e: Exception) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index e20d9b44ee..fc59121a26 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -2,6 +2,7 @@ package com.onesignal.core import com.onesignal.common.modules.IModule import com.onesignal.common.services.ServiceBuilder +import com.onesignal.common.threading.DispatcherChoiceReporter import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.application.impl.ApplicationService import com.onesignal.core.internal.backend.IFeatureFlagsBackendService @@ -86,6 +87,7 @@ internal class CoreModule : IModule { builder.register().provides() builder.register().provides() builder.register().provides() + builder.register().provides() builder.register().provides() builder.register() diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt index fe23e81e54..a59cfc192e 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt @@ -5,12 +5,17 @@ import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.core.internal.features.FeatureManager import com.onesignal.core.internal.features.StartupFlagsCache +import com.onesignal.debug.ILogListener +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging import com.onesignal.features.FeatureFlag import com.onesignal.mocks.MockHelper import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import io.mockk.every import io.mockk.mockk +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong @@ -22,6 +27,8 @@ class DispatcherBootstrapFlagTests : FunSpec({ val key = bootstrap.key afterTest { + reportListener?.let(Logging::removeListener) + reportListener = null StartupFlagsCache.write(appContext(), emptyList()) OneSignalDispatchers.resetBootstrapInputsForTest() OneSignalDispatchers.resetForTest() @@ -56,10 +63,7 @@ class DispatcherBootstrapFlagTests : FunSpec({ } test("an unreadable file keeps construction on the caller") { - val broken = mockk() - every { broken.applicationContext } returns broken - every { broken.getSharedPreferences(any(), any()) } throws SecurityException("prefs unavailable") - latchFrom(broken) + latchFrom(unreadableContext()) builtOnCaller() shouldBe true } @@ -73,6 +77,53 @@ class DispatcherBootstrapFlagTests : FunSpec({ builtOnCaller() shouldBe true } + test("a choice failure after an on snapshot runs lazy pools while FeatureManager reports on") { + val context = appContext() + StartupFlagsCache.write(context, listOf(key)) + OneSignalDispatchers.beforePoolChoiceForTest = { + FeatureManager.startupFlags { context } + throw IllegalStateException("choice failed") + } + val reports = captureChoiceReports() + latchFrom(context) + + builtOnCaller() shouldBe true + FeatureManager(MockHelper.configModelStore(), applicationServiceFor(context)).isEnabled(bootstrap) shouldBe true + OneSignalDispatchers.reportChoiceToTelemetry() + reports.single() shouldContain "off, choice failed; pools=lazy" + } + + test("an unreadable file reaches telemetry once init has installed it") { + val reports = captureChoiceReports() + latchFrom(unreadableContext()) + reports shouldBe emptyList() + + OneSignalDispatchers.reportChoiceToTelemetry() + reports.single() shouldContain "off, cache unreadable; pools=lazy" + OneSignalDispatchers.reportChoiceToTelemetry() + reports.size shouldBe 1 + } + + test("a degraded choice after telemetry is up is reported right away") { + val reports = captureChoiceReports() + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.reportChoiceToTelemetry() + OneSignalDispatchers.noteApplicationContext(unreadableContext()) + OneSignalDispatchers.resetForTest() + + reports.single() shouldContain "off, cache unreadable" + } + + test("a healthy choice is not reported") { + val context = appContext() + StartupFlagsCache.write(context, listOf(key)) + val reports = captureChoiceReports() + latchFrom(context) + OneSignalDispatchers.reportChoiceToTelemetry() + + reports shouldBe emptyList() + } + test("prewarm latches from the process application when startup did not note a context") { StartupFlagsCache.write(appContext(), listOf(key)) OneSignalDispatchers.resetBootstrapInputsForTest() @@ -99,6 +150,26 @@ class DispatcherBootstrapFlagTests : FunSpec({ private fun appContext(): Context = ApplicationProvider.getApplicationContext() +private var reportListener: ILogListener? = null + +/** Warnings [OneSignalDispatchers] sends through [Logging] about its pool choice. */ +private fun captureChoiceReports(): List { + val reports = CopyOnWriteArrayList() + val listener = ILogListener { if (it.level == LogLevel.WARN && "sdk_dispatcher_bootstrap" in it.entry) reports.add(it.entry) } + reportListener = listener + Logging.addListener(listener) + return reports +} + +private fun unreadableContext(): Context { + val broken = mockk() + every { broken.applicationContext } returns broken + every { broken.getSharedPreferences(any(), any()) } throws SecurityException("prefs unavailable") + return broken +} + +private fun applicationServiceFor(context: Context) = MockHelper.applicationService().also { every { it.appContext } returns context } + private fun latchFrom(context: Context) { OneSignalDispatchers.resetBootstrapInputsForTest() OneSignalDispatchers.noteApplicationContext(context) From 1cb8f849bcfe34e9a7a80ec036b76d461e3085c0 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 21 Oct 2026 15:16:33 -0500 Subject: [PATCH 08/13] fix: [SDK-5581] run the bootstrap and lazy prewarm threads at normal 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 --- .../threading/BootstrapDispatcherPools.kt | 3 +- .../common/threading/LazyDispatcherPools.kt | 3 +- .../threading/DispatcherBootstrapFlagTests.kt | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt index 09509d7f68..e6a4f7aa7a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt @@ -277,7 +277,8 @@ private class BootstrapCoordinator { "$BASE-bootstrap", ).apply { isDaemon = true - priority = Thread.NORM_PRIORITY - 2 + // Not lowered: main can block waiting for init, which queues behind these builds. + priority = Thread.NORM_PRIORITY start() } } catch (t: Throwable) { diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt index f2aceb8bc6..6b952218ae 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt @@ -72,7 +72,8 @@ internal class LazyDispatcherPools : DispatcherPools { "$BASE-prewarm", ) thread.isDaemon = true - thread.priority = Thread.NORM_PRIORITY - 2 + // Not lowered: a launch on main blocks behind this thread's lazy builds, so it must not be starved. + thread.priority = Thread.NORM_PRIORITY if (prewarmThread.compareAndSet(null, thread)) startPrewarm(thread) } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt index a59cfc192e..05c93d9fcb 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt @@ -18,6 +18,7 @@ import io.mockk.mockk import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong /** How [OneSignalDispatchers] picks pools from the startup flags snapshot. */ @@ -124,6 +125,39 @@ class DispatcherBootstrapFlagTests : FunSpec({ reports shouldBe emptyList() } + test("the bootstrap thread runs at normal priority") { + OneSignalDispatchers.bootstrapEnabledForTest = true + OneSignalDispatchers.resetForTest() + val priority = AtomicInteger(-1) + val seen = CountDownLatch(1) + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") { + priority.set(Thread.currentThread().priority) + seen.countDown() + } + } + + OneSignalDispatchers.launchOnIO { } + + seen.await(2, TimeUnit.SECONDS) shouldBe true + priority.get() shouldBe Thread.NORM_PRIORITY + } + + test("the lazy prewarm thread runs at normal priority") { + OneSignalDispatchers.bootstrapEnabledForTest = false + OneSignalDispatchers.resetForTest() + val priority = AtomicInteger(-1) + val seen = CountDownLatch(1) + OneSignalDispatchers.beforeLazyWarmForTest = { + if (priority.compareAndSet(-1, Thread.currentThread().priority)) seen.countDown() + } + + OneSignalDispatchers.prewarm() + + seen.await(2, TimeUnit.SECONDS) shouldBe true + priority.get() shouldBe Thread.NORM_PRIORITY + } + test("prewarm latches from the process application when startup did not note a context") { StartupFlagsCache.write(appContext(), listOf(key)) OneSignalDispatchers.resetBootstrapInputsForTest() From a7d0af82d1380a93dc69068167d0f809cc8e1d04 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 08:50:20 -0500 Subject: [PATCH 09/13] fix: [SDK-5581] dispatcher logcat lines follow the SDK log level - 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 --- .../common/threading/OneSignalDispatchers.kt | 15 ++++---------- .../threading/DispatcherBootstrapFlagTests.kt | 20 +++++++++---------- .../threading/DispatcherFailureLogTests.kt | 17 ++++++++++++++++ 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 641baa8b19..cebf9b6d78 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -3,6 +3,7 @@ package com.onesignal.common.threading import android.content.Context import android.util.Log import com.onesignal.core.internal.features.FeatureManager +import com.onesignal.core.internal.features.currentApplication import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.features.FeatureFlag @@ -183,22 +184,14 @@ object OneSignalDispatchers { val summary: String get() = "$detail; pools=${if (pools is LazyDispatcherPools) "lazy" else "bootstrap"}" } - @Suppress("TooGenericExceptionCaught", "SwallowedException") private fun rememberApplicationContext(): Context? { if (appContext != null) return appContext // Receivers call prewarm() with no Context when the startup provider is disabled. - val resolved = - try { - val method = Class.forName("android.app.ActivityThread").getMethod("currentApplication") - method.invoke(null) as? Context - } catch (_: Throwable) { - null - } - if (resolved != null) appContext = resolved.applicationContext + currentApplication()?.let { appContext = it.applicationContext } return appContext } - /** Logcat, not [Logging]: the SDK log level is often NONE in release, and Logging can dispatch. */ + /** Logcat at the customer's SDK log level, not through [Logging]: Logging can dispatch. */ @Suppress("SwallowedException") internal fun logRelease( message: String, @@ -207,6 +200,7 @@ object OneSignalDispatchers { ) { val line = "sdk_dispatcher_bootstrap: $message" try { + if (!Logging.atLogLevel(if (warn) LogLevel.WARN else LogLevel.INFO)) return if (warn) Log.w(RELEASE_LOG_TAG, line, error) else Log.i(RELEASE_LOG_TAG, line) } catch (_: RuntimeException) { // JVM unit tests throw from android.util.Log. A log must not fail the latch. @@ -223,7 +217,6 @@ object OneSignalDispatchers { if (inFailureLog.get() == true) return logRelease(message, error) inFailureLog.set(true) try { - if (LogLevel.WARN > Logging.logLevel) logRelease(message, error) Logging.warn("OneSignalDispatchers: $message", error) } catch (_: RuntimeException) { logRelease(message, error) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt index 05c93d9fcb..fef1ebee06 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt @@ -30,7 +30,7 @@ class DispatcherBootstrapFlagTests : FunSpec({ afterTest { reportListener?.let(Logging::removeListener) reportListener = null - StartupFlagsCache.write(appContext(), emptyList()) + StartupFlagsCache.write(appContext()) { emptyList() } OneSignalDispatchers.resetBootstrapInputsForTest() OneSignalDispatchers.resetForTest() } @@ -38,18 +38,18 @@ class DispatcherBootstrapFlagTests : FunSpec({ test("each APP_STARTUP flag is mirrored on its own") { val context = appContext() val logging = FeatureFlag.SDK_CUSTOM_LOGGING - StartupFlagsCache.write(context, listOf(logging.key)) + StartupFlagsCache.write(context) { listOf(logging.key) } StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe true StartupFlagsCache.readFile(context).isEnabled(bootstrap) shouldBe false - StartupFlagsCache.write(context, listOf(logging.key, key)) + StartupFlagsCache.write(context) { listOf(logging.key, key) } StartupFlagsCache.readFile(context).isEnabled(logging) shouldBe true StartupFlagsCache.readFile(context).isEnabled(bootstrap) shouldBe true } test("a mirrored key selects bootstrap on the next pool generation") { val context = appContext() - StartupFlagsCache.write(context, listOf(key)) + StartupFlagsCache.write(context) { listOf(key) } latchFrom(context) builtOnCaller() shouldBe false @@ -57,7 +57,7 @@ class DispatcherBootstrapFlagTests : FunSpec({ test("no mirrored key keeps construction on the caller") { val context = appContext() - StartupFlagsCache.write(context, emptyList()) + StartupFlagsCache.write(context) { emptyList() } latchFrom(context) builtOnCaller() shouldBe true @@ -71,7 +71,7 @@ class DispatcherBootstrapFlagTests : FunSpec({ test("a pool choice failure falls back to lazy pools instead of throwing") { val context = appContext() - StartupFlagsCache.write(context, listOf(key)) + StartupFlagsCache.write(context) { listOf(key) } OneSignalDispatchers.beforePoolChoiceForTest = { throw IllegalStateException("choice failed") } latchFrom(context) @@ -80,7 +80,7 @@ class DispatcherBootstrapFlagTests : FunSpec({ test("a choice failure after an on snapshot runs lazy pools while FeatureManager reports on") { val context = appContext() - StartupFlagsCache.write(context, listOf(key)) + StartupFlagsCache.write(context) { listOf(key) } OneSignalDispatchers.beforePoolChoiceForTest = { FeatureManager.startupFlags { context } throw IllegalStateException("choice failed") @@ -117,7 +117,7 @@ class DispatcherBootstrapFlagTests : FunSpec({ test("a healthy choice is not reported") { val context = appContext() - StartupFlagsCache.write(context, listOf(key)) + StartupFlagsCache.write(context) { listOf(key) } val reports = captureChoiceReports() latchFrom(context) OneSignalDispatchers.reportChoiceToTelemetry() @@ -159,7 +159,7 @@ class DispatcherBootstrapFlagTests : FunSpec({ } test("prewarm latches from the process application when startup did not note a context") { - StartupFlagsCache.write(appContext(), listOf(key)) + StartupFlagsCache.write(appContext()) { listOf(key) } OneSignalDispatchers.resetBootstrapInputsForTest() OneSignalDispatchers.resetForTest() @@ -168,7 +168,7 @@ class DispatcherBootstrapFlagTests : FunSpec({ test("FeatureManager reports the pools the process actually latched when store and file disagree") { val context = appContext() - StartupFlagsCache.write(context, emptyList()) + StartupFlagsCache.write(context) { emptyList() } latchFrom(context) builtOnCaller() shouldBe true diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherFailureLogTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherFailureLogTests.kt index e0e8044d81..ed34141650 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherFailureLogTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherFailureLogTests.kt @@ -7,6 +7,7 @@ import com.onesignal.debug.internal.logging.Logging import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeLessThanOrEqual import io.kotest.matchers.shouldBe +import org.robolectric.shadows.ShadowLog import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -26,6 +27,22 @@ class DispatcherFailureLogTests : FunSpec({ OneSignalDispatchers.resetForTest() } + test("logcat lines follow the customer's SDK log level") { + ShadowLog.clear() + try { + Logging.logLevel = LogLevel.NONE + OneSignalDispatchers.logRelease("probe at none", IllegalStateException("x")) + Logging.logLevel = LogLevel.INFO + OneSignalDispatchers.logRelease("probe at info") + } finally { + Logging.logLevel = LogLevel.WARN + } + + val lines = ShadowLog.getLogsForTag("OneSignal").map { it.msg } + lines.any { "probe at none" in it } shouldBe false + lines.any { "probe at info" in it } shouldBe true + } + listOf(true to "bootstrap", false to "lazy").forEach { (bootstrap, mode) -> test("$mode pools: a lane failure reaches log listeners") { usePools(bootstrap) From fe13b18e1e45bc03ac30e9139a6ae706df032a4b Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 10:45:25 -0500 Subject: [PATCH 10/13] fix: [SDK-5581] hand off the degraded pool choice report atomically Co-authored-by: Cursor --- .../common/threading/OneSignalDispatchers.kt | 32 ++++++++++++++----- .../threading/DispatcherBootstrapFlagTests.kt | 29 +++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 8ad8a35349..bf954fc993 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -60,10 +60,13 @@ object OneSignalDispatchers { private var poolsRef: DispatcherPools? = null private val poolsLock = Any() + /** Inside the choice report handoff, between reading readiness and parking the report. */ @Volatile - private var telemetryReady = false + internal var duringChoiceHandoffForTest: (() -> Unit)? = null - @Volatile + // Guards only the two fields below. Never report while holding it: reporting can launch. + private val choiceReportLock = Any() + private var telemetryReady = false private var pendingChoiceReport: PoolChoice? = null /** Test override. Null reads the cached flag, and no cache means off. */ @@ -73,8 +76,10 @@ object OneSignalDispatchers { internal fun resetBootstrapInputsForTest() { AppContext.resetForTest() bootstrapEnabledForTest = null - telemetryReady = false - pendingChoiceReport = null + synchronized(choiceReportLock) { + telemetryReady = false + pendingChoiceReport = null + } FeatureManager.resetStartupFlagsForTest() } @@ -114,14 +119,24 @@ object OneSignalDispatchers { if (bootstrapEnabledForTest != null) return logRelease(choice.summary, choice.error, warn = choice.warn) if (!choice.warn) return - if (telemetryReady) reportChoice(choice) else pendingChoiceReport = choice + val reportNow = + synchronized(choiceReportLock) { + val ready = telemetryReady + duringChoiceHandoffForTest?.invoke() + if (!ready) pendingChoiceReport = choice + ready + } + if (reportNow) reportChoice(choice) } /** Pools are usually chosen before init installs remote telemetry, so degraded choices wait for it. */ internal fun reportChoiceToTelemetry() { - telemetryReady = true - pendingChoiceReport?.let(::reportChoice) - pendingChoiceReport = null + val pending = + synchronized(choiceReportLock) { + telemetryReady = true + pendingChoiceReport.also { pendingChoiceReport = null } + } + pending?.let(::reportChoice) } @Suppress("SwallowedException") @@ -302,6 +317,7 @@ object OneSignalDispatchers { beforeLazyWarmForTest = null beforeLazyPrewarmStartForTest = null beforePoolChoiceForTest = null + duringChoiceHandoffForTest = null } @Suppress("ComplexMethod") diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt index f990cbed94..54e0cf1cd8 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherBootstrapFlagTests.kt @@ -116,6 +116,27 @@ class DispatcherBootstrapFlagTests : FunSpec({ reports.single() shouldContain "off, cache unreadable" } + test("a degraded choice racing telemetry readiness is still reported") { + val reports = captureChoiceReports() + OneSignalDispatchers.resetBootstrapInputsForTest() + val inHandoff = CountDownLatch(1) + val release = CountDownLatch(1) + OneSignalDispatchers.duringChoiceHandoffForTest = { + inHandoff.countDown() + release.await(2, TimeUnit.SECONDS) + } + val chooser = Thread { OneSignalDispatchers.resetForTest() }.apply { start() } + inHandoff.await(2, TimeUnit.SECONDS) shouldBe true + + val reporter = Thread { OneSignalDispatchers.reportChoiceToTelemetry() }.apply { start() } + awaitBlockedOrDone(reporter) + release.countDown() + chooser.join(2_000) + reporter.join(2_000) + + reports.single() shouldContain "off, no application; pools=lazy" + } + test("a healthy choice is not reported") { val context = appContext() StartupFlagsCache.write(context) { listOf(key) } @@ -215,6 +236,14 @@ private fun unreadableContext(): Context { private fun applicationServiceFor(context: Context) = MockHelper.applicationService().also { every { it.appContext } returns context } +private fun awaitBlockedOrDone(thread: Thread) { + val deadline = System.currentTimeMillis() + 2_000 + while (thread.state != Thread.State.BLOCKED && thread.state != Thread.State.TERMINATED) { + if (System.currentTimeMillis() > deadline) return + Thread.sleep(5) + } +} + private fun latchFrom(context: Context) { OneSignalDispatchers.resetBootstrapInputsForTest() AppContext.note(context) From 7314e14c938d3e3e5b3b46fdcc05cc147419a80a Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 10:45:25 -0500 Subject: [PATCH 11/13] fix: [SDK-5581] dispatcher specs restore clean pools after each test Co-authored-by: Cursor --- .../onesignal/common/threading/DispatcherLaneBehaviorTests.kt | 3 ++- .../onesignal/common/threading/OneSignalDispatchersTests.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherLaneBehaviorTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherLaneBehaviorTests.kt index 8f6cf4cbb6..bcc1c91bb3 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherLaneBehaviorTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/DispatcherLaneBehaviorTests.kt @@ -28,7 +28,8 @@ class DispatcherLaneBehaviorTests : FunSpec({ } afterEach { - OneSignalDispatchers.bootstrapEnabledForTest = null + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.resetForTest() } test("builds lanes on the thread this mode promises") { diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt index e048202460..e8f938c02c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt @@ -26,7 +26,8 @@ class OneSignalDispatchersTests : FunSpec({ } afterTest { - OneSignalDispatchers.bootstrapEnabledForTest = null + OneSignalDispatchers.resetBootstrapInputsForTest() + OneSignalDispatchers.resetForTest() } test("prewarm bootstraps the ingress lane before the other lanes") { From c015fb41ac0e2004ac01e83adcb1bf4008b12685 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 10:45:26 -0500 Subject: [PATCH 12/13] fix: [SDK-5581] cover reset landing mid-build in lazy prewarm Co-authored-by: Cursor --- .../threading/OneSignalDispatchersTests.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt index e8f938c02c..da5509c631 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt @@ -13,6 +13,7 @@ import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference class OneSignalDispatchersTests : FunSpec({ @@ -257,6 +258,48 @@ class OneSignalDispatchersTests : FunSpec({ builtAfterReset.get() shouldBe 0 } + test("flag off prewarm does not build an executor when reset lands mid-build") { + OneSignalDispatchers.bootstrapEnabledForTest = false + OneSignalDispatchers.resetForTest() + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "INGRESS") { + entered.countDown() + awaitIgnoringInterrupts(release) + } + } + OneSignalDispatchers.prewarm(null) + entered.await(2, TimeUnit.SECONDS) shouldBe true + OneSignalDispatchers.resetForTest() + val builtAfterReset = AtomicInteger() + OneSignalDispatchers.afterExecutorCreateForTest = { _, _ -> builtAfterReset.incrementAndGet() } + release.countDown() + Thread.sleep(400) + builtAfterReset.get() shouldBe 0 + } + + test("flag off prewarm shuts down an executor built after reset") { + OneSignalDispatchers.bootstrapEnabledForTest = false + OneSignalDispatchers.resetForTest() + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val built = AtomicReference() + OneSignalDispatchers.afterExecutorCreateForTest = { lane, executor -> + if (lane == "INGRESS") { + built.set(executor) + entered.countDown() + awaitIgnoringInterrupts(release) + } + } + OneSignalDispatchers.prewarm(null) + entered.await(2, TimeUnit.SECONDS) shouldBe true + OneSignalDispatchers.resetForTest() + release.countDown() + Thread.sleep(400) + built.get().isShutdown shouldBe true + } + test("flag off falls back when executor construction throws") { OneSignalDispatchers.bootstrapEnabledForTest = false OneSignalDispatchers.resetForTest() From 7b51785054316a846303653b560add79ad9c03b3 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 22 Oct 2026 10:45:26 -0500 Subject: [PATCH 13/13] fix: [SDK-5581] report Fallback for lanes running on Dispatchers Co-authored-by: Cursor --- .../threading/BootstrapDispatcherPools.kt | 2 +- .../common/threading/LazyDispatcherPools.kt | 2 +- .../common/threading/OneSignalDispatchers.kt | 3 +- .../threading/OneSignalDispatchersTests.kt | 33 +++++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt index e6a4f7aa7a..be66d1c6e6 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/BootstrapDispatcherPools.kt @@ -213,7 +213,7 @@ private class GateDispatcher( override fun status(): String = synchronized(lock) { when (state) { - LaneState.READY -> "Active" + LaneState.READY -> if (target?.executor == null) "Fallback" else "Active" LaneState.CLOSED -> "Shutdown" else -> state.name.lowercase().replaceFirstChar { it.uppercase() } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt index 6b952218ae..5fdd301f96 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/LazyDispatcherPools.kt @@ -184,7 +184,7 @@ internal class LazyDispatcherPools : DispatcherPools { val executor = if (executorLazy.isInitialized()) executorLazy.value else null when { closed.get() -> "Shutdown" - executor == null && executorLazy.isInitialized() -> "Active" + executor == null && executorLazy.isInitialized() -> "Fallback" executor == null -> "Cold" executor.isShutdown -> "Shutdown" else -> "Active" diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index bf954fc993..2904d75739 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -359,7 +359,7 @@ object OneSignalDispatchers { val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { val current = currentPools() - if (listOf(current.io, current.defaultLane, current.serialIO, current.ingress).all { it.status() == "Active" }) { + if (listOf(current.io, current.defaultLane, current.serialIO, current.ingress).all { it.status() in READY_STATUSES }) { return true } Thread.sleep(TEST_READY_POLL_MS) @@ -389,6 +389,7 @@ object OneSignalDispatchers { "$name $NOT_INITIALIZED ${e.message ?: UNKNOWN_ERROR}" } + private val READY_STATUSES = setOf("Active", "Fallback") private const val NOT_INITIALIZED = "Not initialized" private const val UNKNOWN_ERROR = "Unknown error" } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt index da5509c631..fbd2b07a28 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/OneSignalDispatchersTests.kt @@ -300,6 +300,30 @@ class OneSignalDispatchersTests : FunSpec({ built.get().isShutdown shouldBe true } + test("flag off reports Fallback for a lane that fell back") { + OneSignalDispatchers.bootstrapEnabledForTest = false + OneSignalDispatchers.resetForTest() + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") throw IllegalStateException("boom") + } + val ran = CountDownLatch(1) + OneSignalDispatchers.launchOnIO { ran.countDown() } + ran.await(2, TimeUnit.SECONDS) shouldBe true + + awaitStatus("IO Executor: Fallback") shouldBe true + } + + test("bootstrap reports Fallback for a lane that fell back") { + OneSignalDispatchers.beforeLaneCreateForTest = { lane -> + if (lane == "IO") throw IllegalStateException("boom") + } + val ran = CountDownLatch(1) + OneSignalDispatchers.launchOnIO { ran.countDown() } + ran.await(2, TimeUnit.SECONDS) shouldBe true + + awaitStatus("IO Executor: Fallback") shouldBe true + } + test("flag off falls back when executor construction throws") { OneSignalDispatchers.bootstrapEnabledForTest = false OneSignalDispatchers.resetForTest() @@ -335,6 +359,15 @@ class OneSignalDispatchersTests : FunSpec({ } }) +private fun awaitStatus(line: String): Boolean { + val deadline = System.currentTimeMillis() + 2_000 + while (System.currentTimeMillis() < deadline) { + if (line in OneSignalDispatchers.getStatus()) return true + Thread.sleep(5) + } + return false +} + private fun awaitIgnoringInterrupts(latch: CountDownLatch) { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) while (latch.count > 0 && System.nanoTime() < deadline) {