diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt index 4180059..a56b615 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteCatalog.kt @@ -86,6 +86,7 @@ internal fun suiteCatalog(): List = SuiteCase("L07", "Lifecycle", "can recover after Rejected navigation"), SuiteCase("L08", "Lifecycle", "isolated destroy() tears down cleanly"), SuiteCase("L09", "Lifecycle", "headers load then HTML recovery keeps API live"), + SuiteCase("L10", "Lifecycle", "mount/unmount churn while resizing survives"), // Rendering — measurements, not thresholds: the backend embeds a real // native WebView and never throttles it, so these report what the host // actually achieves (see README "Rendering model & frame rate"). diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt index 69ccbcd..2ee18a2 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/SuiteRunner.kt @@ -717,6 +717,28 @@ internal suspend fun runFullSuite( assertThat(r.contains("2"), "API dead after headers path: $r") } + case("L10") { + // Regression guard for the desktop/macOS use-after-free: the scene + // host queues `setFrame` closures capturing the embedded NSView and + // drains them a frame later, so a WebView that leaves composition + // right after its rect changed used to free that view first and crash + // in `objc_retain`. The churn pane resizes every frame while mounted, + // and the jittered delays land the disposal on different frame phases. + val before = ctx.getChurnMounts() + repeat(CHURN_CYCLES) { i -> + ctx.setChurnMounted(true) + delay(120L + (i % 5) * 17L) + ctx.setChurnMounted(false) + delay(30L + (i % 3) * 11L) + } + val mounted = ctx.getChurnMounts() - before + assertThat(mounted >= CHURN_CYCLES, "churn pane mounted $mounted of $CHURN_CYCLES times") + // The surviving main WebView must still be fully operational. + loadHtmlAwaitMarker(ctx.navigator, "after-churn") + val r = evalJs(ctx.navigator, "2+3") + assertThat(r.contains("5"), "main WebView dead after churn: $r") + } + // ── Rendering ──────────────────────────────────────────────────── // The WebView is a real native view (no offscreen rendering, no frame // pacing in this library), so these publish what the host reaches — a @@ -751,3 +773,9 @@ internal suspend fun runFullSuite( * (CI runners render in software). */ private const val MIN_ANIMATING_FPS = 10 + +/** + * Mount/unmount cycles L10 drives. High enough to hit the one-frame-wide + * disposal race repeatedly, low enough to keep the case around two seconds. + */ +private const val CHURN_CYCLES = 12 diff --git a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt index 056925a..f69b748 100644 --- a/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt +++ b/e2e-shared/src/commonMain/kotlin/dev/nucleusframework/webview/e2e/visualsuite/VisualSuiteApp.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -120,6 +121,13 @@ fun VisualSuiteApp( var secondaryHits by remember { mutableStateOf(0) } var onCreatedFired by remember { mutableStateOf(false) } + // L10 drives these: a second, real WebView that enters and leaves + // composition while its rect changes every frame. + var churnMounted by remember { mutableStateOf(false) } + // Plain counter, not snapshot state: the runner reads it from its own + // coroutine and must see the mount that just happened, not a snapshot of it. + val churnMounts = remember { intArrayOf(0) } + DisposableEffect(jsBridge) { val ping = object : IJsMessageHandler { @@ -189,6 +197,8 @@ fun VisualSuiteApp( setModifyMap = { modifyMap = it }, getOnCreatedFired = { onCreatedFired }, parentHandle = parentHandle, + setChurnMounted = { churnMounted = it }, + getChurnMounts = { churnMounts[0] }, ) val started = currentTimeMillis() var path = "" @@ -270,9 +280,12 @@ fun VisualSuiteApp( state = state, navigator = navigator, webViewJsBridge = jsBridge, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.weight(1f).fillMaxWidth(), onCreated = { onCreatedFired = true }, ) + if (churnMounted) { + ChurnWebViewPane(onMounted = { churnMounts[0]++ }) + } } // Checklist pane @@ -376,4 +389,56 @@ internal data class SuiteContext( val getOnCreatedFired: () -> Boolean, /** Tao HWND for isolated Windows WebView2 instances (0 elsewhere). */ val parentHandle: Long = 0L, + /** Mounts / unmounts the L10 churn pane. */ + val setChurnMounted: (Boolean) -> Unit = {}, + /** How often the L10 churn pane has entered composition so far. */ + val getChurnMounts: () -> Int = { 0 }, ) + +/** + * A second, real WebView whose height changes on **every** frame while it is + * mounted, so `onGloballyPositioned` keeps the host's native-view layout queue + * busy. L10 mounts and unmounts it repeatedly: the disposal then lands in the + * same frame as a pending layout update, which is the window the desktop + * use-after-free lived in. + */ +@Composable +private fun ChurnWebViewPane(onMounted: () -> Unit) { + val state = + rememberWebViewStateWithHTMLData( + data = pageWithMarker("churn"), + baseUrl = "https://suite.local/churn", + ).also { + it.webSettings.desktopWebSettings.transparent = false + it.webSettings.backgroundColor = Color.White + } + val navigator = rememberWebViewNavigator() + var extraHeight by remember { mutableStateOf(0) } + // Counted here, not in the LaunchedEffect below: setup runs while the + // change is applied, so a short mount window still registers. + DisposableEffect(Unit) { + onMounted() + onDispose { } + } + LaunchedEffect(Unit) { + var frame = 0 + while (true) { + withFrameNanos { } + frame++ + extraHeight = frame % CHURN_PANE_AMPLITUDE_DP + } + } + WebView( + state = state, + navigator = navigator, + modifier = Modifier + .fillMaxWidth() + .height((CHURN_PANE_BASE_DP + extraHeight).dp), + ) +} + +/** Resting height of the L10 churn pane. */ +private const val CHURN_PANE_BASE_DP = 48 + +/** Per-frame height sweep of the L10 churn pane, in dp. */ +private const val CHURN_PANE_AMPLITUDE_DP = 24 diff --git a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt index 3fd4c15..b3c13d9 100644 --- a/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt +++ b/webview-compose/src/jvmMain/kotlin/dev/nucleusframework/webview/web/WebViewDesktop.kt @@ -320,6 +320,23 @@ actual fun ActualWebView( } } + // Registered *before* the NativeView below so Compose forgets it *after* + // NativeView's own effects: effects are forgotten in reverse registration + // order, so the scene host detaches the embedded native view (and stops + // touching its handle) before the backend releases it. Registering this + // last would free the handle first and leave `detach` — and any interop + // action the host queued for the frame being presented — pointing at + // freed memory. + DisposableEffect(nativeWebView) { + onDispose { + state.webView = null + webViewJsBridge?.webView = null + (state.cookieManager as? DesktopCookieManager)?.attach(null) + currentOnDispose(nativeWebView) + nativeWebView.destroy() + } + } + val linuxWebView = nativeWebView as? LinuxWebKitNativeWebView val macosWebView = nativeWebView as? MacOsWebKitNativeWebView val windowsWebView = nativeWebView as? WindowsWebView2NativeWebView @@ -367,16 +384,6 @@ actual fun ActualWebView( } } } - - DisposableEffect(nativeWebView) { - onDispose { - state.webView = null - webViewJsBridge?.webView = null - (state.cookieManager as? DesktopCookieManager)?.attach(null) - currentOnDispose(nativeWebView) - nativeWebView.destroy() - } - } } /** diff --git a/webview-compose/src/jvmMain/native/macos/view_lifecycle.m b/webview-compose/src/jvmMain/native/macos/view_lifecycle.m index 5503e77..1f4d2fa 100644 --- a/webview-compose/src/jvmMain/native/macos/view_lifecycle.m +++ b/webview-compose/src/jvmMain/native/macos/view_lifecycle.m @@ -1,12 +1,72 @@ #include "compose_webview_internal.h" +/** + * Seconds a torn-down WKWebView is kept alive before its last strong + * reference is dropped. See [compose_webview_retire_view]. + */ +static const NSTimeInterval kComposeWebViewRetireDelay = 1.0; + +/** Torn-down WKWebViews still inside their retire window (main thread only). */ +static NSMutableArray *compose_webview_retired = nil; + +/** + * Keeps [view] alive for [kComposeWebViewRetireDelay] after teardown. + * + * The NSView handed to Nucleus' `NativeView` is this WKWebView itself, and + * the macOS scene host does not apply layout to embedded subviews right + * away: `TaoComposeSceneHost.nativeViewHost().setFrame` enqueues a closure + * capturing the raw NSView pointer into the frame's interop transaction, + * which is drained later, on the main queue, inside the Metal present. + * `detach` on the other hand is immediate and cannot purge those pending + * closures. + * + * Releasing the WKWebView synchronously from composition teardown therefore + * races the drain: a `setFrame` queued for the frame being presented reaches + * `objc_retain` on freed memory and the process dies with SIGSEGV in + * `objc_retain` (SEGV_ACCERR). The same ordering also lets Compose forget + * this effect *before* `NativeView`'s own detach effect — effects are + * forgotten in reverse registration order — so `nativeRemoveSubview` would + * see a dangling pointer too. + * + * Deferring only the final release keeps the pointer valid across both: + * the view is already stopped, unparented and delegate-free, so a late + * `setFrame` / `nativeRemoveSubview` lands on an inert but live object. + */ +static void compose_webview_retire_view(WKWebView *view) { + if (view == nil) return; + /* The block captures `view` strongly, so the reference survives the hop + even when teardown ran off the main thread. */ + void (^retire)(void) = ^{ + if (compose_webview_retired == nil) { + compose_webview_retired = [NSMutableArray array]; + } + [compose_webview_retired addObject:view]; + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kComposeWebViewRetireDelay * NSEC_PER_SEC)), + dispatch_get_main_queue(), + ^{ + [compose_webview_retired removeObjectIdenticalTo:view]; + }); + }; + if ([NSThread isMainThread]) { + retire(); + } else { + dispatch_async(dispatch_get_main_queue(), retire); + } +} + @implementation ComposeWebViewState - (void)teardown { if (self.webView != nil) { self.webView.navigationDelegate = nil; [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"ipc"]; + [self.webView stopLoading]; [self.webView removeFromSuperview]; + // Hand the last strong reference to the retire list instead of + // dropping it here — the scene host may still hold a queued + // `setFrame` for this NSView. + compose_webview_retire_view(self.webView); self.webView = nil; } self.configuration = nil;