Fix an unbounded blocked-thread leak in runJavaScript; ObjC over-retains + jsaddleWebViewInvalidate for wkwebview - #171
Conversation
runJavaScriptWithSerializer delivered every result -- batch replies and callback notifications alike -- through a single-slot `recvMVar`, and the transports fork a thread per incoming batch of results. Any delivery with no batch thread waiting for it (a context whose pump has gone away, or simply more results than batches sent) left its thread blocked in putMVar for good. On a busy page that is a runaway: ~5000 blocked threads per second, measured at half a million threads and 12GB of RSS in one session (72GB in a longer one). Deliver into a TChan instead, so writing a result never blocks and those threads finish and can be collected. takeResult still discards results for earlier batches exactly as before.
These files are compiled without ARC, so every alloc needs its release: - completeSync built an NSString per synchronous round trip and never released it. The sync bridge is the hot path for anything driving the page from Haskell, so this grows without bound: +140MB resident per UI reload cycle in a ghci session, and a steady climb in ordinary use. - loadHTMLStringWithBaseURL leaked the NSString it wrapped in an NSURL; use the autoreleased +stringWithCString, as the html argument beside it already did. - AppDelegate's boot webview and its WKWebViewConfiguration were never released after the window took ownership, so that first webview -- and its WebContent renderer process -- outlived the window forever.
A host that owns the WKWebView itself (rather than letting `run` own it) had no way to take a jsaddle context back down. Two things kept it alive: jsaddle's threads go on calling evaluateJavaScript with the stored webview pointer, so releasing the webview is a use-after-free; and the three stable pointers the ObjC handler holds pin their closures -- for a reflex application, the entire live network -- for the life of the process. Leksah reloads its whole UI inside a ghci session, where that means ~30MB of live heap kept per reload and a WebContent process that never exits. jsaddleWebViewInvalidate webView now: - gates every use of the raw pointer behind an `aliveVar` MVar, so invalidation synchronises with an in-flight send and no later send can start. An invalidated send THROWS rather than silently dropping the batch: runJavaScriptWithSerializer holds the shared batch lock across sendBatch and the takeResult waiting for its reply, so a dropped batch would wedge every other context in the process. - gates the inbound handlers too -- a page whose JS is still running keeps posting results, and each delivery forks a thread that would then block on the dead context's pump. - detaches the ObjC handler on the main queue (where all three callbacks are delivered from, so the detach cannot race one mid-flight) and frees the stable pointers from a callback that runs after the detach, which is what finally lets an invalidated context be collected. addJSaddleHandler returns the handler so it can be detached; the handler methods now check their stable pointers, covering deliveries WebKit had already queued behind the detach.
There was a problem hiding this comment.
🟡 Not ready to approve
The new invalidation path has a few concrete correctness/robustness issues (global lock held while blocking invalidators, potential forkOS startup thread leak, overly broad exception swallowing, and unconditional stderr output) that should be addressed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR addresses long-lived resource leaks across jsaddle transports and adds explicit teardown support for jsaddle-wkwebview contexts, primarily to prevent runaway blocked threads, Objective-C memory leaks (non-ARC), and lingering stable pointers/webview processes in GHCi reload-heavy workflows.
Changes:
- Replace
runJavaScript’s single-slot resultMVarwith aTChanqueue so result delivery never blocks and leak-blocked deliverer threads. - Fix several non-ARC Objective-C over-retains and add detachable handler plumbing so a host can tear down a
WKWebViewsafely. - Introduce
jsaddleWebViewInvalidateto invalidate contexts tied to a host-ownedWKWebView, gating outbound/inbound use and freeing stable pointers after handler detach.
File summaries
| File | Description |
|---|---|
| jsaddle/src/Language/Javascript/JSaddle/Run.hs | Switch result delivery from a single-slot MVar to a TChan to prevent blocked-thread buildup on deliveries. |
| jsaddle-wkwebview/src/Language/Javascript/JSaddle/WKWebView/Internal.hs | Add context invalidation API, gate pointer usage, detach ObjC handler, and free stable pointers to allow context/webview teardown. |
| jsaddle-wkwebview/src-ghc/Language/Javascript/JSaddle/WKWebView.hs | Re-export jsaddleWebViewInvalidate for public use. |
| jsaddle-wkwebview/cbits/WKWebView-cbits.m | Return handler from addJSaddleHandler, add removeJSaddleHandler, guard callbacks after detach, and fix ObjC leaks. |
| jsaddle-wkwebview/cbits-cocoa/AppDelegate.m | Balance retains for the boot WKWebView and configuration so they don’t outlive the window. |
Review details
Suppressed comments (2)
jsaddle-wkwebview/src/Language/Javascript/JSaddle/WKWebView/Internal.hs:216
- If the context is invalidated before
didFinishNavigationfires,readyis never filled (the start handler is detached/cleared), so theforkOSthread blocks forever ontakeMVar ready. Have the invalidator alsotryPutMVar ready ()to unblock startup so the thread can exit (it will then hit thewithWebViewgate and be caught).
modifyMVar_ contextInvalidators $ return .
((ptr, modifyMVar_ aliveVar $ \_ -> do
mbDetach <- atomicModifyIORef' detachRef (\d -> (Nothing, d))
sequence_ mbDetach
return False) :)
jsaddle-wkwebview/src/Language/Javascript/JSaddle/WKWebView/Internal.hs:292
- Catching
SomeExceptionhere will silently swallow unexpected failures (e.g. decode errors, async exceptions), making debugging much harder. Prefer catching only the expectedIOExceptionfromwithWebView(context invalidated) and let other exceptions still surface.
void . forkOS . handle (\(_ :: SomeException) -> return ()) $ do
takeMVar ready
withWebView $ useAsCString (toStrict jsaddleJs) (evaluateJavaScript webView)
void (forkIO start)
- Files reviewed: 5/5 changed files
- Comments generated: 4
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| jsaddleWebViewInvalidate :: WKWebView -> IO () | ||
| jsaddleWebViewInvalidate (WKWebView ptr) = | ||
| modifyMVar_ contextInvalidators $ \invs -> do | ||
| sequence_ [ inv | (p, inv) <- invs, p == ptr ] | ||
| return (filter ((/= ptr) . fst) invs) |
There was a problem hiding this comment.
Fixed in d57f896 — and it was worse than described: jsaddleMain' registers on that same contextInvalidators MVar during startup, so an invalidation waiting on an in-flight send could hold up a new window booting, not just other invalidations. It now claims the matching entries under the lock and runs them outside it.
One consequence documented in the haddock: a second concurrent call for the same webview now finds nothing and returns without waiting for the first, so call it once per webview.
| import Control.Concurrent.MVar | ||
| (MVar, modifyMVar_, newEmptyMVar, newMVar, putMVar, takeMVar) | ||
| (MVar, modifyMVar_, newEmptyMVar, newMVar, putMVar, readMVar, takeMVar, | ||
| withMVar) |
There was a problem hiding this comment.
Both imported in d57f896: modifyMVar for the claim-then-run rework, and tryPutMVar to release the startup thread.
| -- Traced because the detach is asynchronous: this line is the only | ||
| -- evidence of WHEN (or whether) a context actually became collectable. | ||
| hPutStrLn stderr $ "jsaddle-wkwebview: context detached from webview " | ||
| <> show ptr <> ", stable pointers freed" |
There was a problem hiding this comment.
Agreed and removed in d57f896 — tracing when a context became collectable is the host application's business, not the library's.
- onDetached no longer writes to stderr. It was the only evidence of when an asynchronous detach completed, but unconditional output does not belong in a library. - AppDelegate releases the boot webview and its configuration AFTER the callbacks instead of before. Releasing first was safe -- the window retained the webview on the line above -- but reading webView after a -release is not something anyone should have to double check. - jsaddleWebViewInvalidate claims the registry entries under contextInvalidators and runs them OUTSIDE it. An invalidator blocks until its context's in-flight send finishes, and holding the registry across that stalled not just other webviews' invalidation but new contexts' registration -- jsaddleMain' takes the same MVar during startup, so one invalidation could hold up a window booting. Documented the consequence: call it once per webview, since a second concurrent call for the same webview now returns without waiting for the first. - The startup thread can no longer park forever. If a context is invalidated before the page signals ready, nothing else ever fills that MVar, so the invalidator tryPutMVars it; the thread wakes, hits the closed gate and exits instead of holding an OS thread and the context's closures. - That thread's handler catches IOException rather than SomeException, so it swallows the invalidated-send error and nothing else.
There was a problem hiding this comment.
🟡 Human review recommended
The changes span concurrency semantics in core messaging plus manual Objective-C/FFI lifetime management, and warrant a final human review focused on edge-case teardown ordering and memory behavior under high callback volume.
Review details
Suppressed comments (1)
jsaddle/src/Language/Javascript/JSaddle/Run.hs:210
- Using an unbounded
TChanmeans every inboundCallback/BatchResultsmessage is retained until some futuretakeResultdrains it. Because the JS runtime can emit manyCallbackmessages that repeatlastResultsfor an already-consumed batch, bursts of callbacks can accumulate a large backlog (memory growth +takeResultdoing O(backlog) reads to skip old batches). Consider dropping results for batches older than the current expected batch before enqueuing (e.g., track expected batch number in aTVarand ignoren < expected) or switching to a keyed store (e.g.,TVar (IntMap BatchResults)) to dedupe by batch id.
processResults :: Bool -> Results -> IO ()
processResults syncCallbacks = \case
(ProtocolError err) -> error $ "Protocol error : " <> T.unpack err
(Callback n br (JSValueReceived fNumber) f this a) -> do
atomically $ writeTChan recvChan (n, br)
f' <- runReaderT (unJSM $ wrapJSVal f) ctx
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
|
Re the suppressed note on the unbounded The backlog self-drains. It is also strictly better than what it replaces: the single-slot The genuine residual case is a live context that keeps receiving callbacks and never sends another batch. (A dead context no longer applies: |
Three commits. The first is a leak fix in jsaddle core that affects every transport; the other two are jsaddle-wkwebview.
1. Queue batch results instead of a one-slot MVar (
aef9f3d) — jsaddlerunJavaScript'srecvMVaris a single-slotMVar, so a delivery could block forever. The transports fork a thread per incoming batch of results, and any delivery with no batch thread waiting for it — a context whose pump has gone, or simply more results than batches sent — left that thread parked inputMVarfor the life of the process.With a busy page that is a runaway: ~5000 blocked threads per second, half a million threads and 12 GB RSS in one measured session (72 GB in a longer one). A
TChanmakes the deliverer's side non-blocking, so those threads finish and get collected.takeResultstill skips results for earlier batches exactly as before, so the semantics are unchanged — including the "unexpected batch" error for a later one.This is the one change here that is not macOS-specific.
2. Balance three ObjC over-retains (
19dbbf5) — jsaddle-wkwebviewThese files are compiled without ARC, so every
allocneeds itsrelease:completeSyncbuilt anNSStringper synchronous round trip and never released it. That is the hot path for anything driving the page from Haskell: +140 MB resident per UI reload cycle in a ghci session, and a steady climb in ordinary use.loadHTMLStringWithBaseURLleaked theNSStringit wrapped in anNSURL; it now uses autoreleased+stringWithCString, as thehtmlargument beside it already did.AppDelegate's boot webview and itsWKWebViewConfigurationwere never released after the window took ownership, so that first webview — and its WebContent renderer process — outlived the window forever.On that last one:
webViewis used again (callWithWebView) after[webView release], which is intentional and safe —[_window setContentView:webView]on the line above retains it, so the release just balances theallocand leaves the window as sole owner. Happy to reorder the release below the callbacks if you'd rather it not read that way.3.
jsaddleWebViewInvalidate(fad5f96) — jsaddle-wkwebviewA host that owns the
WKWebViewitself (rather than lettingrunown it) had no way to take a jsaddle context back down. Two things kept it alive: jsaddle's threads go on callingevaluateJavaScriptwith the stored webview pointer, so releasing the webview is a use-after-free; and the three stable pointers the ObjC handler holds pin their closures — for a reflex application, the entire live network — for the life of the process. leksah reloads its whole UI inside a ghci session, where that means ~30 MB of live heap per reload and a WebContent process that never exits.jsaddleWebViewInvalidate webView:aliveVarMVar, so invalidation synchronises with an in-flight send: it blocks until the send'sevaluateJavaScript(which copies the pointer into a retaining main-queue block) has returned, and no later send can start. After it returns the host may release the webview.runJavaScriptWithSerializerholds the shared batch lock acrosssendBatchand thetakeResultwaiting for that batch's reply, so a dropped batch would leave its pump waiting forever while holding the lock every other window needs — one invalidated context would deadlock the whole process. Throwing unwinds out ofwithMVarand kills only the dead context's own pump.addJSaddleHandlernow returns the handler so it can be detached, and the handler methods check their stable pointers, covering deliveries WebKit had already queued behind the detach (a detached sync prompt getscompletionHandler(@"")so the page is not left blocked).The frees can't call
hs_free_stable_ptrfrom the ObjC directly: under-objc-in-library(PR #170) that file is a plain dylib with no RTS to link against, so it goes back through the registered callback table.Contract: call
jsaddleWebViewInvalidatebefore releasing a webview you own. Nothing else changes for existing users —run/run'own their webview for the process lifetime and never invalidate.One thing to decide before merging
onDetachedwrites a line to stderr when a context's stable pointers are freed. The detach is asynchronous, so that line is the only evidence of when (or whether) a context actually became collectable — invaluable while chasing this down, but it is unconditional output from a library. Happy to drop it, or gate it behind an env var, if you'd prefer that not ship.Testing
jsaddleandjsaddle-wkwebviewbuild clean (no warnings) on macOS/aarch64, GHC 9.14.master(checked withgit merge-tree).:reloadof a ghci session — which is where the ~30 MB/reload heap retention and the never-exiting WebContent process were observed, and where they stopped.