fix: [SDK-4983] destroy IAM WebView on dismiss to stop Activity leaks - #2706
Conversation
OSWebView was retained via InAppDisplayer.lastInstance after dismiss, so the host Activity could not be GC'd. Destroy the WebView on all dismiss paths and clear lastInstance. Co-authored-by: Cursor <cursoragent@cursor.com>
📊 Diff Coverage ReportDiff Coverage Report (Changed Lines Only)Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff). Changed Files Coverage
Overall (aggregate gate)148/177 touched executable lines covered (83.6% — requires ≥ 80%) |
|
Possible lifecycle regressions:
Dismissal also isn’t thread-safe or fully idempotent, so concurrent or normal paths may run cleanup twice. Could we serialize setup/dismissal behind a terminal dismissed state and add tests for early dismissal, lifecycle completion, and concurrent dismissal? |
Make dismiss terminal and single-flight so early cleanup still fires messageWasDismissed, cannot race WebView setup, and clears lastInstance once under concurrent dismiss. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@fadi-george Good catches — addressed in 8bf28ab:
|
|
Thanks, the lifecycle, setup race, and single-flight cleanup issues appear addressed. Two test gaps remain: The concurrent dismissal test inherits runBlocking’s context, so the dismissals execute sequentially rather than exercising a real race. |
Exercise concurrent dismiss and in-flight setup races under real dispatchers, cover post-dismiss render/show paths, and extract helpers so Detekt and the 80% diff-coverage gate pass. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed in cf82995:
|
|
one blocker remains in InAppDisplayer.showMessageContent: lastInstance can now be cleared by the background onDismissed callback between the null check and lastInstance!!, causing an NPE when a preview arrives during dismissal could we capture a local snapshot and synchronize or confine all lastInstance access? @volatile alone would not make the check-and-use atomic. |
…e derefs Background dismissal clears lastInstance, so the preview path could NPE between its null check and lastInstance!!. Hold it in an AtomicReference and claim it with getAndSet so the read and use cannot be split, and snapshot messageView in the activity lifecycle callbacks for the same reason. Co-authored-by: Cursor <cursoragent@cursor.com>
…failure paths Brings diff coverage on the real PR range to 83.3%; the previous 81.8% reading was taken before the Detekt refactor was committed, so it scored new code against the old line set. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Good catch — that was mine. Making Fixed in 6ab4c56. if (message.isPreview) {
lastInstance.getAndSet(null)?.dismissAndAwaitNextMessage()
}
I also swept On the tests: I tried to write a regression test for the NPE and it passed against the pre-fix code, so I removed it rather than leave something that can't fail. The window is two adjacent field reads and isn't reachable from outside the class, so the guarantee here is structural ( CI: Detekt clean; diff coverage 83.3% ( |
…ge view showMessageView snapshotted messageView/webView and never re-validated them, so a concurrent finishDismiss could re-arm a destroyed WebView and attach a PopupWindow that nothing was left holding to remove - the same leak this branch fixes. Claim both views under lifecycleLock, re-check before attaching, and tear the view down in finishDismiss instead of only dereferencing it. Also guard messageWasDisplayed against firing after dismiss, claim the WebView before destroying it in setupWebView so it cannot be destroyed twice, and mark the cross-thread fields volatile. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Ran a self-review pass over the branch before asking for another look. It found a real bug of my own making, fixed in 901a630, plus one behavior change I'd like your call on. The bug I introducedWhen I extracted local snapshots in messageView?.setWebView(webView!!)
messageView?.showView(activity)so once
The result is a popup holding a destroyed WebView attached to the live Activity with Fix: claim both views under Also in the same commit:
Lock ordering came back clean: no suspend calls under Your call: never-displayed IAMs are now consumedWorth flagging because it's a deliberate behavior change from Always firing So an IAM dismissed before The manager already has the right mechanism ( Same gap exists if TestsAdded a regression test for the orphaned view, and verified it by breaking the fix — it fails without it. My first attempt at that test passed against the broken code ( 167 IAM tests pass, Detekt clean, diff coverage 83.6% ( |
Summary
OSWebViewafter In-App Message dismiss (#2312, SDK-4983).WebViewon all dismiss paths (back/system PopupWindow, close action, drag, timer, programmatic), and clearInAppDisplayer.lastInstancevia anonDismissedcallback.Test plan
./gradlew :OneSignal:in-app-messages:testOriginalUnitTest --tests com.onesignal.inAppMessages.internal.display.WebViewManagerDismissCleanupTestsOSWebViewretaining Activity A