Conversation
Member
Author
|
stubbed toe on a number of testing issues that I'll pursue separately, related to #21739 mostly but also a bit of steering missing in the repo on how agents should pursue their work so they have all the backpressure they need based on what we've learned over time and encoded in CI / our wiki |
david-allison
requested changes
Sep 21, 2026
david-allison
left a comment
Member
There was a problem hiding this comment.
Subject: [PATCH] test(webview): assert cleanup after view teardown
---
Index: AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt
--- a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt (revision 4ebd4ce2ad64f7421f4940f22c27a09b2dc3a290)
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt (revision 96f5febde2fa0019748521f8492a496d2b7deaf1)
@@ -26,6 +26,7 @@
import org.hamcrest.Matchers.nullValue
import org.junit.Test
import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
import java.io.File
/** A picked or shared `file://` must only resolve to a file inside our own cache. */
@@ -74,15 +75,21 @@
@Test
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
- fun `onRenderProcessGone after view is destroyed does not crash - issue 21952`() {
+ fun `onRenderProcessGone after view is destroyed cleans up the dead WebView - issue 21952`() {
launchFragmentInContainer<MultimediaImageFragment>(
bundleOf(EXTRA_MEDIA_OPTIONS to MultimediaImageFragment.ImageOptions.GALLERY),
).use { scenario ->
scenario.onFragment { fragment ->
val layout = fragment.requireView().findViewById<SafeWebViewLayout>(R.id.multimedia_web_view)
+ val webView = layout.getChildAt(0) as WebView
fragment.parentFragmentManager.commitNow { detach(fragment) }
assertThat(fragment.view, nullValue())
- layout.onRenderProcessGone(layout.getChildAt(0) as WebView)
+
+ layout.onRenderProcessGone(webView)
+
+ assertThat("the dead WebView is destroyed", shadowOf(webView).wasDestroyCalled(), equalTo(true))
+ assertThat("the dead WebView is removed from its parent", webView.parent, nullValue())
+ assertThat("no replacement WebView is created", layout.childCount, equalTo(0))
}
}
}
mikehardy
force-pushed
the
fix-21952
branch
from
September 21, 2026 16:21
51201eb to
55c376e
Compare
mikehardy
force-pushed
the
fix-21952
branch
from
September 21, 2026 18:54
55c376e to
cc4c5bd
Compare
david-allison
requested changes
Sep 21, 2026
Member
There was a problem hiding this comment.
Unreviewed for correctness; as a nitpick, I'd like the tests to use view bindings
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/workarounds/SafeWebViewLayoutTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/workarounds/SafeWebViewLayoutTest.kt
index 92107f6750..da222b4df0 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/workarounds/SafeWebViewLayoutTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/workarounds/SafeWebViewLayoutTest.kt
@@ -149,6 +149,16 @@ class SafeWebViewLayoutTest : RobolectricTest() {
}
}
+ @Test
+ fun `destroy after crash cleanup prevents recovery on reattach`() {
+ assertOwnerTeardownPreventsRecovery { destroy() }
+ }
+
+ @Test
+ fun `safeDestroy after crash cleanup prevents recovery on reattach`() {
+ assertOwnerTeardownPreventsRecovery { safeDestroy() }
+ }
+
@Test
fun `recovery is skipped when orphaned layout reattaches outside fragment view`() {
launchFragmentInContainer<SafeWebViewLayoutHostFragment>().use { scenario ->
@@ -218,4 +228,28 @@ class SafeWebViewLayoutTest : RobolectricTest() {
}
}
}
+
+ private fun assertOwnerTeardownPreventsRecovery(teardown: SafeWebViewLayout.() -> Unit) {
+ launchFragmentInContainer<MultimediaImageFragment>(
+ bundleOf(EXTRA_MEDIA_OPTIONS to MultimediaImageFragment.ImageOptions.GALLERY),
+ ).use { scenario ->
+ scenario.onFragment { fragment ->
+ val layout = fragment.requireView().findViewById<SafeWebViewLayout>(R.id.multimedia_web_view)
+ val webView = layout.getChildAt(0) as WebView
+ val parent = layout.parent as ViewGroup
+ parent.removeView(layout)
+ assertThat(layout.isAttachedToWindow, equalTo(false))
+
+ layout.onRenderProcessGone(webView)
+ assertThat(shadowOf(webView).wasDestroyCalled(), equalTo(true))
+ assertThat(layout.childCount, equalTo(0))
+
+ layout.teardown()
+ parent.addView(layout)
+
+ assertThat(layout.isAttachedToWindow, equalTo(true))
+ assertThat("owner teardown must prevent crash recovery", layout.childCount, equalTo(0))
+ }
+ }
+ }
}
Always remove and destroy the terminated WebView, and skip recreation when the fragment view is already gone to avoid crashes on requireView. Also track WebView state so post-destroy calls become logged no-ops: a late onRenderProcessGone followed by owner teardown (e.g. PageFragment.safeDestroy) must not double-destroy. createPrintDocumentAdapter returns null once destroyed; Statistics export skips gracefully. Removes inert @SdkSuppress from the regression test; a @config(minSdk) replacement would fan Robolectric tests across every SDK >= 26.
A late render-process-gone callback can report a WebView that is no longer the layout's current child (e.g. already replaced). Destroy the stale instance and leave the live child untouched, instead of removing and recreating the wrong view.
fragment.view != null is not sufficient: after a fragment's view is destroyed and recreated, a late onRenderProcessGone on the old layout would pass that check and recreate a WebView inside a discarded view hierarchy. A layout detached from the window cleans up the terminated WebView but does not recreate it.
When recreation was skipped after a render process crash, the layout was left permanently dead even if it returned to a live view hierarchy. Split the destroyed state into recoverable (crash cleanup) and terminal (owner destroy/safeDestroy), and recreate the inner WebView from onAttachedToWindow when all of these hold: - the WebView was destroyed by crash cleanup, not by its owner - the fragment implements OnWebViewRecreatedListener, so the replacement is configured rather than attached raw - the layout is a descendant of the fragment's current view, so an orphaned layout from a recreated fragment view stays terminal Owner destroy/safeDestroy after crash cleanup promote recoverable to terminal without a second native destroy, so a later reattach cannot resurrect the WebView.
mikehardy
force-pushed
the
fix-21952
branch
from
September 22, 2026 03:07
cc4c5bd to
b9f19aa
Compare
Member
Author
|
your comments were both good catches - re-pushed |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Assisted-by: Cursor / Composer
Purpose / Description
ACRA on 2.25beta1 (#21952): after a WebView renderer death,
SafeWebViewLayoutrecreated the WebView and notified the fragment even when that fragment's view was already gone.MultimediaImageFragmentthen hitrequireView()via viewBinding and crashed — ironic, since we added this path so the default client would stop killing the whole process.Fixing that skip-recreation path exposed a second, subtler problem: once we destroy the terminated WebView without replacing it, the layout still looks "alive" to every caller.
destroy(),safeDestroy(),loadUrl, and friends all kept talking to a destroyedWebView, which Android forbids. The worst concrete case is the original teardown path itself —PageFragment.onDestroyView→safeDestroy()after a lateonRenderProcessGone— which would double-destroy.This PR therefore does more than "don't recreate after teardown." It gives
SafeWebViewLayoutan explicit state machine so crash cleanup and intentional owner teardown are no longer the same signal.Fixes
The signalling problem (why
WebViewStateexists)What was conflated before
SafeWebViewLayouthad no notion of "the inner WebView is gone." After a renderer death it either:this.webViewpointing at a destroyed instance while every public method still delegated to it.Those two situations look identical from the outside: "no usable WebView right now." They are not the same:
onRenderProcessGoneskipped recreation after destroying the dead rendererdestroy()/safeDestroy()(e.g. CardViewer stopping audio, PageFragment destroying ononDestroyView)A single boolean
isDestroyedconflates those. Attach-time recovery then either:destroy()(new WebView nobody will tear down, possibly afteronDestroyViewalready ran).How the conflation caused specific errors
onWebViewRecreatedwhilefragment.view == null→requireView()/ viewBinding crash.safeDestroy()/destroy()callsWebView.destroy()again → platform misuse (and a real crash risk on some devices).loadUrl/evaluateJavascript/ etc. on the destroyed instance after a skip.CardViewerFragment.onDestroyViewcallsdestroy()to stop audio; a later detach/reattach would recreate a WebView into a fragment that already tore down.fragment.view != nullafter the fragment's view was destroyed and reinflated, while the callback still holds the old layout → recreate into a discarded hierarchy.onRenderProcessGonereports a WebView that is no longer the current child → remove/destroy the wrong instance.How the three states solve it
findFragmentfails,fragment.view == null,!isAttachedToWindow) →DESTROYED_RECOVERABLE, and all delegating APIs become logged no-ops.destroy()/safeDestroy()→DESTROYED_TERMINAL, same no-ops, no attach-time recovery.onAttachedToWindowrecovery runs only when all of: state isDESTROYED_RECOVERABLE, fragment implementsOnWebViewRecreatedListener, and this layout is a descendant offragment.view. That last check stops orphaned layouts from a recreated fragment view from recovering into the wrong place.ACTIVEagain.So the "is it destroyed?" signal is no longer one bit — it carries why, which is what decide-whether-to-recover needs.
Approach (by commit)
fix(webview): handle renderer death after fragment view is goneremoveView+destroythe terminated WebView first (Android requires cleanup even when not recreating).fragment.view == null.createPrintDocumentAdapter; Statistics export skips with a log instead of crashing).onRenderProcessGone→ assert destroy/remove/no replacement; also callsafeDestroy()so the PageFragment teardown path cannot double-destroy.fix(webview): ignore stale WebView instances in onRenderProcessGoneWebViewis not the current child, destroy that stale instance and leave the live child alone.fix(webview): skip WebView recreation while detached from windowfragment.view != nullis not enough after view destroy/reinflate; also skip when!isAttachedToWindow.feat(webview): recover a crash-destroyed WebView on layout reattachdestroy()stays terminal (no resurrection).How Has This Been Tested?
MultimediaImageFragmentTest— issue Fragment MultimediaImageFragment did not return a View from onCreateView() #21952 path (detach + lateonRenderProcessGone+safeDestroy).SafeWebViewLayoutTest— double-destroy both call orders, stale WebView identity, detached-window skip, reattach recovery, intentional-destroy non-recovery, orphaned-layout non-recovery.@Config(minSdk = O)here: it fans each test across every SDK ≥ 26 and forces android-all jar downloads thatrobolectricDownloader.gradledeliberately does not pre-cache.@SdkSuppressis inert under Robolectric anyway../gradlew :AnkiDroid:ktlintMainSourceSetCheck :AnkiDroid:ktlintTestSourceSetCheckLearning (optional)
Same lifecycle class as #21893 (callback after view destroyed), different trigger. Real renderer death still needs an instrumented
chrome://crashtest; this PR covers the post-teardown and state-machine paths on the JVM.Accepted tradeoffs left on purpose:
settings/scalestay unguarded (no safe no-op return); Statistics PDF skip on a destroyed WebView is Timber-only (no fitting existing snackbar string without adding translations).Checklist