Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ internal fun suiteCatalog(): List<SuiteCase> =
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").
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -189,6 +197,8 @@ fun VisualSuiteApp(
setModifyMap = { modifyMap = it },
getOnCreatedFired = { onCreatedFired },
parentHandle = parentHandle,
setChurnMounted = { churnMounted = it },
getChurnMounts = { churnMounts[0] },
)
val started = currentTimeMillis()
var path = ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}

/**
Expand Down
60 changes: 60 additions & 0 deletions webview-compose/src/jvmMain/native/macos/view_lifecycle.m
Original file line number Diff line number Diff line change
@@ -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<WKWebView *> *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;
Expand Down