-
Notifications
You must be signed in to change notification settings - Fork 377
fix: resolve pre-existing test failures on main #2605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -50,7 +50,8 @@ internal class OneSignalImp( | |||||||||||||||||||
| private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, | ||||||||||||||||||||
| ) : IOneSignal, IServiceProvider { | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private val suspendCompletion = CompletableDeferred<Unit>() | ||||||||||||||||||||
| @Volatile | ||||||||||||||||||||
| private var suspendCompletion = CompletableDeferred<Unit>() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| @Volatile | ||||||||||||||||||||
| private var initState: InitState = InitState.NOT_STARTED | ||||||||||||||||||||
|
|
@@ -288,34 +289,30 @@ internal class OneSignalImp( | |||||||||||||||||||
| context: Context, | ||||||||||||||||||||
| appId: String, | ||||||||||||||||||||
| ): Boolean { | ||||||||||||||||||||
| Logging.log(LogLevel.DEBUG, "Calling deprecated initWithContextSuspend(context: $context, appId: $appId)") | ||||||||||||||||||||
| Logging.log(LogLevel.DEBUG, "initWithContext(context: $context, appId: $appId)") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // do not do this again if already initialized or init is in progress | ||||||||||||||||||||
| synchronized(initLock) { | ||||||||||||||||||||
| if (initState.isSDKAccessible()) { | ||||||||||||||||||||
| Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized or in progress") | ||||||||||||||||||||
| return true | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| initState = InitState.IN_PROGRESS | ||||||||||||||||||||
| suspendCompletion = CompletableDeferred() | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| initFailureException = IllegalStateException("OneSignal initWithContext failed.") | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
Comment on lines
300
to
+305
|
||||||||||||||||||||
| initState = InitState.IN_PROGRESS | |
| suspendCompletion = CompletableDeferred() | |
| } | |
| initFailureException = IllegalStateException("OneSignal initWithContext failed.") | |
| initFailureException = IllegalStateException("OneSignal initWithContext failed.") | |
| initState = InitState.IN_PROGRESS | |
| suspendCompletion = CompletableDeferred() | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 If ensureApplicationServiceStarted throws between setting initState=IN_PROGRESS and dispatching suspendifyOnIO, suspendCompletion is never completed and initState stays permanently IN_PROGRESS. Any subsequent accessor call hits the new IN_PROGRESS -> waitAndReturn() branch in getServiceWithFeatureGate, calls suspendCompletion.await(), and deadlocks forever. Fix: wrap the ensureApplicationServiceStarted + suspendifyOnIO block in a try/catch that sets initState = FAILED and calls notifyInitComplete() on any exception.
Extended reasoning...
What the bug is and how it manifests
In the new initWithContext(context, appId), initState is set to IN_PROGRESS and suspendCompletion is replaced with a fresh CompletableDeferred() inside synchronized(initLock). Immediately after the lock is released, ensureApplicationServiceStarted(context) is called on the caller thread -- before suspendifyOnIO is dispatched. If that call throws, the exception propagates out of initWithContext with no cleanup: initState stays IN_PROGRESS and the brand-new, never-completed suspendCompletion is abandoned.
The specific code path that triggers it
ensureApplicationServiceStarted calls (applicationService as ApplicationService).start(context), which casts context.applicationContext to Application (ApplicationService.kt ~line 81). In non-standard host environments (instrumentation tests that wrap the context, Robolectric with custom application factories, multi-process scenarios, etc.) this cast can throw ClassCastException. registerActivityLifecycleCallbacks called inside start() can also throw in restricted environments. Any unchecked runtime exception escaping ensureApplicationServiceStarted triggers the bug.
Why existing code does not prevent it
There is no try/catch around the gap between the synchronized(initLock) block (which writes initState = IN_PROGRESS and creates a new suspendCompletion) and the suspendifyOnIO { internalInit(...) } dispatch. After the throw: isSDKAccessible() returns true for IN_PROGRESS (InitState.kt lines 36-38), so every subsequent initWithContext call returns true immediately without retrying. Meanwhile getServiceWithFeatureGate now has an explicit IN_PROGRESS -> waitAndReturn(getter) branch (added by this PR) which calls waitForInit() -> runBlocking { suspendCompletion.await() } -- a deferred that will never be completed.
What the impact is
Every accessor property (user, notifications, session, location, inAppMessages) and every non-suspend call to login/logout blocks the calling thread forever, producing an ANR if called from the main thread. The application cannot recover: retrying initWithContext is a no-op (isSDKAccessible() returns true for IN_PROGRESS), and there is no API to reset state. The previous runBlocking-based FF-OFF path would have surfaced the exception to the caller so it could be caught and handled. This PR removes that last safety net by making init always-async.
Step-by-step proof
- App calls OneSignal.initWithContext(ctx, appId) where ctx.applicationContext is not an Application subclass.
- synchronized(initLock): initState = IN_PROGRESS, suspendCompletion = CompletableDeferred() (uncompleted).
- ensureApplicationServiceStarted(ctx) calls ApplicationService.start(ctx) which casts ctx.applicationContext as Application -- throws ClassCastException -- propagates out of initWithContext.
- suspendifyOnIO { internalInit(...) } is never reached; notifyInitComplete() is never called.
- initState is still IN_PROGRESS; isSDKAccessible() returns true.
- App calls OneSignal.notifications.permission -- getServiceWithFeatureGate -- IN_PROGRESS branch -- waitAndReturn(getter) -- runBlocking { suspendCompletion.await() } -- blocks forever (ANR on main thread).
- App retries OneSignal.initWithContext(ctx2, appId) -- synchronized(initLock) sees isSDKAccessible() == true -- returns true immediately -- initialization is never retried.
How to fix it
Wrap the call to ensureApplicationServiceStarted and the suspendifyOnIO dispatch in a try/catch block. In the catch handler: set initState = InitState.FAILED, assign initFailureException = e, and call notifyInitComplete() so any waiting accessor unblocks and rethrows the exception instead of hanging forever:
try {
ensureApplicationServiceStarted(context)
suspendifyOnIO { internalInit(context, appId) }
} catch (e: Exception) {
initFailureException = e
initState = InitState.FAILED
notifyInitComplete()
throw e
}
Copilot
AI
Apr 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In legacy mode (!isBackgroundThreadingEnabled), InitState.IN_PROGRESS now blocks via waitAndReturn(getter) instead of failing fast. Given waitForInit() uses runBlocking, calling an accessor on the main thread immediately after initWithContext() can now block the UI thread indefinitely (ANR risk), whereas previously legacy mode threw if not initialized. If this behavior change is required, consider adding a main-thread guard (throw with guidance to use the suspend API / call from background) or otherwise making the blocking behavior explicit and safe.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ package com.onesignal.mocks | |
|
|
||
| import com.onesignal.common.threading.OneSignalDispatchers | ||
| import com.onesignal.common.threading.suspendifyOnIO | ||
| import com.onesignal.common.threading.suspendifyOnMain | ||
| import io.kotest.core.listeners.AfterSpecListener | ||
| import io.kotest.core.listeners.BeforeSpecListener | ||
| import io.kotest.core.listeners.BeforeTestListener | ||
|
|
@@ -30,7 +31,8 @@ import java.util.concurrent.atomic.AtomicInteger | |
| * (e.g., delay(50)) to wait for async work to finish. | ||
| * | ||
| * This helper avoids that by: | ||
| * - Mocking `suspendifyOnIO`, `launchOnIO`, and `launchOnDefault` so their blocks run immediately | ||
| * - Mocking `suspendifyOnIO`, `suspendifyOnMain`, and `OneSignalDispatchers.launchOnIO` / | ||
| * `launchOnDefault` so their blocks run immediately | ||
| * - Completing a `CompletableDeferred` when the async block finishes | ||
| * - Providing `awaitIO()` so tests can explicitly wait for all async work without sleeps | ||
| * | ||
|
|
@@ -114,6 +116,11 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, | |
| trackAsyncWork(block) | ||
| } | ||
|
|
||
| every { suspendifyOnMain(any<suspend () -> Unit>()) } answers { | ||
| val block = firstArg<suspend () -> Unit>() | ||
| trackAsyncWork(block) | ||
| } | ||
|
Comment on lines
+119
to
+122
|
||
|
|
||
| every { OneSignalDispatchers.launchOnIO(any<suspend () -> Unit>()) } answers { | ||
| val block = firstArg<suspend () -> Unit>() | ||
| trackAsyncWork(block) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suspendCompletionis now a mutable shared reference. SinceinternalInitsetsinitState = FAILEDand only then callsnotifyInitComplete(), another thread can start a new init attempt in between (FAILED is not "accessible"), replacingsuspendCompletion. The first init attempt would then complete the new deferred, unblocking waiters for the second init prematurely. Consider keeping a per-init localCompletableDeferred(capture it in the launched block and complete that instance) and/or synchronizing state transitions + completion signaling underinitLockto avoid cross-init races.