Visitar URL original
jsaddle-wkwebview: invalidate under wkWebViewBatchLock by hamishmack · Pull Request #173 · ghcjs/jsaddle · GitHub
Skip to content

jsaddle-wkwebview: invalidate under wkWebViewBatchLock - #173

Merged
hamishmack merged 4 commits into
masterfrom
hkm/wkwebview-invalidate-lock
Nov 9, 2026
Merged

hamishmack merged 4 commits into
masterfrom
hkm/wkwebview-invalidate-lock

Conversation

@hamishmack

@hamishmack hamishmack commented Nov 9, 2026 •

Copy link
Copy Markdown
Member

Problem

jsaddleWebViewInvalidate (added in #171) closes a context's gate after waiting only for an in-flight send — the aliveVar section around evaluateJavaScript. But runJavaScriptWithSerializer holds the shared wkWebViewBatchLock from a batch's send until its reply arrives (sendBatch >> takeResult).

So if a context is invalidated after one of its batches has been sent but before the reply comes back, that reply is lost: the inbound gate drops it and the handler is detached. The batch thread then waits in takeResult forever while still holding the lock, and every other webview in the process hangs on its next round trip. It only recovers if/when the GC notices the orphaned wait.

This shows up as soon as a host invalidates one window's context while others keep running. In leksah, closing one window at a time froze the remaining windows for 26s, 85s, or until restart, in 3 of 4 test runs. (The whole-app teardown path didn't hit it because every context goes at once.)

Fix

The invalidator now takes wkWebViewBatchLock before closing the gate and detaching. That is the same order a round trip uses (the lock, then aliveVar inside the send), so it can't invert against one. Any round trip already under way finishes first, and none can start until the gate is closed.

  • Bounded: if a round trip never completes (a wedged page), the invalidation would otherwise block forever. After ~5s it closes the gate without the lock, as before.
  • No lost lock: the lock is acquired by polling tryTakeMVar under mask, not timeout around takeMVar, whose exception can land just after the take succeeds and lose the lock for good.
  • Docs: jsaddleWebViewInvalidate's haddock now says it waits for in-flight round trips and must be called off the main thread, where replies are delivered.

Testing

Tested in leksah (macOS, several WKWebView windows; closing a window stops its reflex network, invalidates its context, then releases the webview). 15 windows were opened and closed in 5 rounds:

  • the surviving window's 5s heartbeat never gapped more than 5.9s (before: 26s / 85s / indefinite stalls),
  • all 15 WebContent renderer processes exited,
  • no aborted frames.

Expected and unchanged: a closed context's leftover threads that try to send still get the context invalidated — batch dropped IOException, which ends that context's batch pump (stderr shows it as an uncaught exception).

Also: fix master's CI after #172

#172 added a batch-sender test suite to jsaddle-warp.cabal whose base has no upper bound. cabal check rejects that (missing-bounds-important, "Hackage would reject this package"), which fails every Haskell-CI job on master. 165fc80 bounds it (base <5), and cabal check is clean again. Master is merged into this branch (de25f2f) so the fix applies on top of #172.

Also: CI on GHC 9.6–9.14 (089f14a)

  • Matrix: tested-with, and so the Haskell-CI matrix, is now GHC 9.14.1, 9.12.4, 9.10.3, 9.8.4 and 9.6.7, the latest release of each series. GHC 8.4–9.4 are no longer tested. Bounds are unchanged.
  • Workflow: brought up to what haskell-ci 0.19.20261010 generates: Ubuntu noble container, cabal-install 3.16.0.0, actions/checkout@v7 / actions/cache@v6 (the v4 actions run on the deprecated Node 20), and cache keys named after the image. libwebkit2gtk-4.0-dev and libtinfo5 are dropped from apt: neither exists on noble, and none of the packages CI builds (jsaddle, jsaddle-warp, jsaddle-clib) needs WebKit. The file was edited by hand to match that output; haskell-ci regenerate with that version should reproduce it.

jsaddleWebViewInvalidate closed a context's gate after waiting only for an
in-flight *send*.  runJavaScriptWithSerializer holds wkWebViewBatchLock from a
batch's send until its *reply*, so a batch that had been sent but not yet
answered lost its reply (the inbound gate drops it and the handler is
detached) and its batch thread waited forever while still holding the lock.
Every other webview in the process then hung on its next round trip.

Seen in a multi-window app that invalidates one window's context when that
window closes: the remaining windows froze for 26s, 85s, or until restart.

Take the lock before closing the gate (same order as a round trip: the
lock, then aliveVar), so any round trip under way finishes first and none
can start until the gate is closed.  The wait is bounded (~5s), after which
the gate closes without the lock as before, and the lock is acquired by
polling tryTakeMVar rather than timeout+takeMVar, which can lose the lock
if the timeout fires just after the take.
cabal check rejects an unbounded base (missing-bounds-important), which
fails every Haskell-CI job since #172.
tested-with (and so the Haskell-CI matrix) is now GHC 9.14.1, 9.12.4,
9.10.3, 9.8.4 and 9.6.7, the latest release of each series; 8.4-9.4 are
no longer tested.

The workflow is brought up to haskell-ci 0.19.20261010's output: Ubuntu
noble container (jammy's libtinfo5 and libwebkit2gtk-4.0-dev do not exist
there, and none of the packages CI builds needs WebKit), cabal-install
3.16.0.0, actions/checkout@v7 and actions/cache@v6 (the v4 actions run
on the deprecated Node 20), and cache keys named after the image.
@hamishmack
hamishmack merged commit fdf66f5 into master Nov 9, 2026
5 checks passed
hamishmack added a commit to leksah/leksah that referenced this pull request Nov 9, 2026
Closing an OS window only removed the NSWindow.  Its WKWebView (kept alive
on purpose by leksah_new_window), WebContent renderer, jsaddle context and
the whole reflex network — polls, heartbeat, shared-state subscriptions —
ran on until the app quit.  Twelve such networks behind one visible window
measured ~70% idle CPU, ~120MB per renderer, and a steadily growing count
of blocked threads.

- IDE.Web.WindowScope: a window's lifetime.  Subscriptions register their
  undo with the CURRENT scope, which IDE.Web.Attach sets around the
  window's build and each of its frames (sound because every build and
  frame runs under the one frame lock).  cellDyn unsubscribes through it,
  as do the Local/Remote/TerminalRefresh registries, the js-context
  registration, and the window-lived reader loops (forkInCurrentScope).
- The frame loop exits between frames when the scope closes (woken by an
  empty batch), never by killThread: all windows share one Spider
  timeline, and an async exception mid-propagation could corrupt it for
  the windows that remain.
- macWindowClosing then runs closeWindowNetwork off the main thread: close
  the scope and wait for the frame loop to exit, invalidate the webview's
  jsaddle context (safe mid round trip since ghcjs/jsaddle#173), and only
  then leksah_release_webview — hand the closed window an empty content
  view, -_close the page (WebKit SPI, guarded; releasing alone left the
  renderer running), detach its handlers and balance the extra retain.
  leksah_detach_webview is factored out of leksah_close_all_windows.

Verified: 15 windows opened and closed; the remaining window's heartbeat
never gapped, every renderer exited, no aborted frames, idle CPU ~5-9%.
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.

1 participant