Skip to content

fix: resolve pre-existing test failures on main#2605

Open
abdulraqeeb33 wants to merge 3 commits intomainfrom
fix/pre-existing-test-failures
Open

fix: resolve pre-existing test failures on main#2605
abdulraqeeb33 wants to merge 3 commits intomainfrom
fix/pre-existing-test-failures

Conversation

@abdulraqeeb33
Copy link
Copy Markdown
Contributor

Summary

  • SDKInitTests: initWithContext(context, appId) now always dispatches init asynchronously (non-blocking), and getServiceWithFeatureGate properly handles FAILED (throws initFailureException) and IN_PROGRESS (blocks until ready) states. Fixes both "initWithContext with appId does not block" and "accessor instances after initWithContext without appID shows the failure reason".
  • InAppMessagesManagerTests: Added suspendifyOnMain mock to IOMockHelper so fireOnMain callbacks execute synchronously in tests, eliminating the race condition in "addLifecycleListener subscribes listener".
  • FeatureManagerTests: Added afterEach cleanup for ThreadingMode global state to prevent CI flakiness from parallel test execution.

Test plan

  • All SDKInitTests pass (15/15), including the two previously failing tests
  • All InAppMessagesManagerTests pass (63/63), including "addLifecycleListener subscribes listener"
  • All FeatureManagerTests pass (4/4)
  • Full testDebugUnitTest across all modules passes with no regressions

Made with Cursor

Copilot AI review requested due to automatic review settings April 7, 2026 19:00
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses pre-existing unit test failures/flakiness by making SDK initialization and dispatcher behavior more deterministic, and by cleaning up global threading state between tests.

Changes:

  • Updated OneSignalImp.initWithContext(context, appId) to always dispatch initialization asynchronously and adjusted accessor gating logic to handle IN_PROGRESS and FAILED init states.
  • Extended IOMockHelper to synchronously execute suspendifyOnMain work in tests to remove race conditions.
  • Added afterEach cleanup to reset ThreadingMode.useBackgroundThreading in FeatureManagerTests.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt Mock suspendifyOnMain to make main-thread callbacks deterministic in unit tests.
OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureManagerTests.kt Reset global ThreadingMode after each test to reduce cross-test interference.
OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt Make non-suspend init always async; improve behavior for IN_PROGRESS/FAILED states in accessors and operations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -50,7 +50,8 @@ internal class OneSignalImp(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : IOneSignal, IServiceProvider {

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suspendCompletion is now a mutable shared reference. Since internalInit sets initState = FAILED and only then calls notifyInitComplete(), another thread can start a new init attempt in between (FAILED is not "accessible"), replacing suspendCompletion. The first init attempt would then complete the new deferred, unblocking waiters for the second init prematurely. Consider keeping a per-init local CompletableDeferred (capture it in the launched block and complete that instance) and/or synchronizing state transitions + completion signaling under initLock to avoid cross-init races.

Suggested change
private val initLock = Any()

Copilot uses AI. Check for mistakes.
Comment on lines 300 to +305
initState = InitState.IN_PROGRESS
suspendCompletion = CompletableDeferred()
}

initFailureException = IllegalStateException("OneSignal initWithContext failed.")

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initState is set to IN_PROGRESS inside the initLock section, but initFailureException is assigned immediately after the lock is released. There’s a race where another thread can observe IN_PROGRESS, wait for init, and (on failure) throw a generic exception because initFailureException is still null. To make failure reporting deterministic, set initFailureException inside the same synchronized(initLock) block where initState/suspendCompletion are updated (or otherwise ensure it’s assigned-before publishing IN_PROGRESS).

Suggested change
initState = InitState.IN_PROGRESS
suspendCompletion = CompletableDeferred()
}
initFailureException = IllegalStateException("OneSignal initWithContext failed.")
initFailureException = IllegalStateException("OneSignal initWithContext failed.")
initState = InitState.IN_PROGRESS
suspendCompletion = CompletableDeferred()
}

Copilot uses AI. Check for mistakes.
Comment on lines +529 to +533
return when (initState) {
InitState.SUCCESS -> getter()
InitState.IN_PROGRESS -> waitAndReturn(getter)
InitState.FAILED -> throw initFailureException
?: IllegalStateException("Initialization failed. Cannot proceed.")
Copy link

Copilot AI Apr 7, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +118 to +121
every { suspendifyOnMain(any<suspend () -> Unit>()) } answers {
val block = firstArg<suspend () -> Unit>()
trackAsyncWork(block)
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mock makes suspendifyOnMain execute via trackAsyncWork, but trackAsyncWork currently catches Exception and neither rethrows nor records it (the comment says it logs, but it doesn’t). That means exceptions thrown inside a fireOnMain callback can be silently swallowed and the test may still pass. Consider capturing the exception and failing the test (e.g., complete the waiter exceptionally / rethrow on awaitIO()), or at least logging so failures aren’t hidden.

Copilot uses AI. Check for mistakes.
Comment on lines 300 to 314
initState = InitState.IN_PROGRESS
suspendCompletion = CompletableDeferred()
}

initFailureException = IllegalStateException("OneSignal initWithContext failed.")

// FeatureManager depends on ConfigModelStore/PreferencesService which requires appContext.
// Ensure app context is available before evaluating feature gates.
ensureApplicationServiceStarted(context)

if (isBackgroundThreadingEnabled) {
// init in background and return immediately to ensure non-blocking
suspendifyOnIO {
internalInit(context, appId)
}
return true
}

// Legacy FF-OFF behavior intentionally blocks caller thread until initialization completes.
return runBlocking(runtimeIoDispatcher) {
// Always dispatch init asynchronously so this method never blocks the caller.
// Callers that need to wait (accessors, login, logout) will block via suspendCompletion.
suspendifyOnIO {
internalInit(context, appId)
}
Copy link
Copy Markdown

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

  1. App calls OneSignal.initWithContext(ctx, appId) where ctx.applicationContext is not an Application subclass.
  2. synchronized(initLock): initState = IN_PROGRESS, suspendCompletion = CompletableDeferred() (uncompleted).
  3. ensureApplicationServiceStarted(ctx) calls ApplicationService.start(ctx) which casts ctx.applicationContext as Application -- throws ClassCastException -- propagates out of initWithContext.
  4. suspendifyOnIO { internalInit(...) } is never reached; notifyInitComplete() is never called.
  5. initState is still IN_PROGRESS; isSDKAccessible() returns true.
  6. App calls OneSignal.notifications.permission -- getServiceWithFeatureGate -- IN_PROGRESS branch -- waitAndReturn(getter) -- runBlocking { suspendCompletion.await() } -- blocks forever (ANR on main thread).
  7. 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
}

- SDKInitTests: make initWithContext(context, appId) non-blocking by
  always dispatching internalInit asynchronously. Update
  getServiceWithFeatureGate to properly handle FAILED (throw
  initFailureException) and IN_PROGRESS (block until ready) states
  instead of only checking isInitialized. Add requireInitForOperation
  helper for login/logout to also wait when init is in progress.

- InAppMessagesManagerTests: add suspendifyOnMain mock to IOMockHelper
  so fireOnMain callbacks execute synchronously in tests, eliminating
  the race condition where verify ran before the spawned thread.

- NotificationGenerationProcessorTests: register IOMockHelper and
  remove delay() calls from "prevent default behavior twice" tests.
  The delays exceeded the 10ms EXTERNAL_CALLBACKS_TIMEOUT causing the
  timeout to fire before the async block could set wantsToDisplay.

- FeatureManagerTests: add afterEach cleanup for ThreadingMode global
  state to prevent CI flakiness from parallel test execution.

Made-with: Cursor
@abdulraqeeb33 abdulraqeeb33 force-pushed the fix/pre-existing-test-failures branch from dd21578 to c4eb74b Compare April 7, 2026 20:19
@abdulraqeeb33 abdulraqeeb33 changed the base branch from main to feat/identity_verification_5.8 April 9, 2026 13:10
@abdulraqeeb33 abdulraqeeb33 changed the base branch from feat/identity_verification_5.8 to main April 9, 2026 13:11
AR Abdul Azeez added 2 commits April 9, 2026 09:13
Remove notification.display() after the second preventDefault(true). The
display waiter uses a CONFLATED channel; display() wakes with true and
overwrites preventDefault(true)'s wake(false), so the processor could
still call displayNotification — flaky on fast CI (see PR #2605).

Clarify IOMockHelper KDoc: top-level launchOnIO is not stubbed; only
OneSignalDispatchers.launchOnIO is.

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants