From 5df202965b39f883f4d3213ce6c4b84767b83ea2 Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Wed, 8 Jul 2026 13:42:11 +1200 Subject: [PATCH 1/7] Add runJavaScriptWithSerializer: opt-in per-runner batch serialisation runJavaScript gains an optional serialiser (Maybe (MVar ())) that brackets each batch round-trip (sendBatch..takeResult). runJavaScript is now `runJavaScriptWithSerializer Nothing`, so warp/terminal/CLib/null keep running contexts fully in parallel: each browser client is its own context on its own transport, and serialising across them would let one slow client stall the rest. jsaddle-wkwebview passes `Just` a module-global lock shared across all windows. They all dispatch onto the one Cocoa main queue, and a synchronous window.prompt round-trip blocks that queue, so two windows driving JS concurrently can wedge each other. The lock is safe against jsaddle's sync-callback protocol: a prompt handler returns the pre-set lastAsyncBatch without needing the batch thread, so blocking the batch thread never stalls an in-flight synchronous round-trip. --- .../Javascript/JSaddle/WKWebView/Internal.hs | 18 +++++++++-- .../src/Language/Javascript/JSaddle/Run.hs | 32 ++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/jsaddle-wkwebview/src/Language/Javascript/JSaddle/WKWebView/Internal.hs b/jsaddle-wkwebview/src/Language/Javascript/JSaddle/WKWebView/Internal.hs index f6d378d7..06f3bb4e 100644 --- a/jsaddle-wkwebview/src/Language/Javascript/JSaddle/WKWebView/Internal.hs +++ b/jsaddle-wkwebview/src/Language/Javascript/JSaddle/WKWebView/Internal.hs @@ -9,7 +9,9 @@ module Language.Javascript.JSaddle.WKWebView.Internal import Control.Monad (void, join) import Control.Concurrent (forkIO, forkOS) -import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) +import Control.Concurrent.MVar (MVar, newEmptyMVar, newMVar, putMVar, takeMVar) + +import System.IO.Unsafe (unsafePerformIO) import Data.Monoid ((<>)) import Data.ByteString (useAsCString, packCString) @@ -25,7 +27,7 @@ import Foreign.Ptr (Ptr, nullPtr) import Foreign.StablePtr (StablePtr, newStablePtr, deRefStablePtr) import Language.Javascript.JSaddle (Results, Batch, JSM) -import Language.Javascript.JSaddle.Run (runJavaScript) +import Language.Javascript.JSaddle.Run (runJavaScriptWithSerializer) import Language.Javascript.JSaddle.Run.Files (initState, runBatch, ghcjsHelpers) import System.Directory (getCurrentDirectory) @@ -33,6 +35,16 @@ import System.Directory (getCurrentDirectory) newtype WKWebView = WKWebView (Ptr WKWebView) newtype JSaddleHandler = JSaddleHandler (Ptr JSaddleHandler) +-- | Serialises the jsaddle batch round-trip across every WKWebView in the +-- process. All windows dispatch their JS onto the one Cocoa main queue, and a +-- synchronous @window.prompt@ round-trip blocks that queue; without this lock +-- two windows driving JS concurrently can wedge each other. Shared (not +-- per-webview) precisely because the contended resource — the main queue — is +-- shared. See 'runJavaScriptWithSerializer'. +{-# NOINLINE wkWebViewBatchLock #-} +wkWebViewBatchLock :: MVar () +wkWebViewBatchLock = unsafePerformIO (newMVar ()) + foreign export ccall jsaddleStart :: StablePtr (IO ()) -> IO () foreign export ccall jsaddleResult :: StablePtr (Results -> IO ()) -> CString -> IO () foreign export ccall jsaddleSyncResult :: StablePtr (Results -> IO Batch) -> JSaddleHandler -> CString -> IO () @@ -83,7 +95,7 @@ jsaddleMain' :: JSM () -> WKWebView -> IO () -> IO () jsaddleMain' f webView loadHtml = do ready <- newEmptyMVar - (processResult, syncResult, start) <- runJavaScript (\batch -> + (processResult, syncResult, start) <- runJavaScriptWithSerializer (Just wkWebViewBatchLock) (\batch -> useAsCString (toStrict $ "runJSaddleBatch(" <> encode batch <> ");") $ evaluateJavaScript webView) f diff --git a/jsaddle/src/Language/Javascript/JSaddle/Run.hs b/jsaddle/src/Language/Javascript/JSaddle/Run.hs index dde9c7ed..bfad9764 100644 --- a/jsaddle/src/Language/Javascript/JSaddle/Run.hs +++ b/jsaddle/src/Language/Javascript/JSaddle/Run.hs @@ -26,6 +26,7 @@ module Language.Javascript.JSaddle.Run ( #ifndef ghcjs_HOST_OS -- * Functions used to implement JSaddle using JSON messaging , runJavaScript + , runJavaScriptWithSerializer , AsyncCommand(..) , Command(..) , Result(..) @@ -55,7 +56,7 @@ import Control.Concurrent.STM.TChan import Control.Concurrent.STM.TVar (writeTVar, readTVar, readTVarIO, modifyTVar', newTVarIO) import Control.Concurrent.MVar - (tryTakeMVar, MVar, putMVar, takeMVar, newMVar, newEmptyMVar, readMVar, modifyMVar) + (tryTakeMVar, MVar, putMVar, takeMVar, newMVar, newEmptyMVar, readMVar, modifyMVar, withMVar) import System.IO.Unsafe (unsafeInterleaveIO) import System.Random @@ -134,7 +135,29 @@ sendAsyncCommand cmd = do liftIO $ s cmd runJavaScript :: (Batch -> IO ()) -> JSM () -> IO (Results -> IO (), Results -> IO Batch, IO ()) -runJavaScript sendBatch entryPoint = do +runJavaScript = runJavaScriptWithSerializer Nothing + +-- | Like 'runJavaScript', but with an optional serialiser that brackets each +-- batch round-trip (@sendBatch@..@takeResult@). +-- +-- Pass @Nothing@ (as plain 'runJavaScript' does) for the default: contexts run +-- fully independently, which is what transports with per-context transports want +-- — e.g. jsaddle-warp, where each browser client is its own context on its own +-- WebSocket and serialising across them would let one slow client stall the +-- rest. +-- +-- Pass @Just lock@ — one 'MVar' shared across all the contexts that share a +-- single transport thread — when concurrent contexts on that thread can wedge +-- each other. This is the case for the native GUI runners (several WKWebView / +-- WebKitGTK / WebView2 windows dispatching onto the one Cocoa\/GTK\/UI main +-- thread, where a synchronous @window.prompt@ round-trip blocks that thread): +-- holding the lock means at most one context occupies the transport at a time. +-- It is safe against jsaddle's sync-callback protocol — a prompt's handler +-- returns the pre-set 'lastAsyncBatch' without needing the batch thread, so +-- blocking the batch thread here never stalls an in-flight synchronous +-- round-trip. +runJavaScriptWithSerializer :: Maybe (MVar ()) -> (Batch -> IO ()) -> JSM () -> IO (Results -> IO (), Results -> IO Batch, IO ()) +runJavaScriptWithSerializer mSerializer sendBatch entryPoint = do contextId' <- randomIO startTime' <- getCurrentTime recvMVar <- newEmptyMVar @@ -146,6 +169,8 @@ runJavaScript sendBatch entryPoint = do animationFrameHandlers' <- newMVar [] loggingEnabled <- newIORef False liveRefs' <- newMVar S.empty + let withSerializer :: IO a -> IO a + withSerializer act = maybe act (\lock -> withMVar lock (const act)) mSerializer let ctx = JSContextRef { contextId = contextId' , startTime = startTime' @@ -210,8 +235,7 @@ runJavaScript sendBatch entryPoint = do logInfo (\x -> "Sync " <> x <> show (length cmds, last cmds)) _ <- tryTakeMVar lastAsyncBatch putMVar lastAsyncBatch batch - sendBatch batch - takeResult recvMVar nBatch >>= \case + withSerializer (sendBatch batch >> takeResult recvMVar nBatch) >>= \case (n, _) | n /= nBatch -> error $ "Unexpected jsaddle results (expected batch " <> show nBatch <> ", got batch " <> show n <> ")" (_, Success callbacksToFree results) | length results /= length resultMVars -> error "Unexpected number of jsaddle results" From c8f04af20b7e53f885198f44308766ef9b1e23a6 Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Thu, 9 Jul 2026 00:55:51 +1200 Subject: [PATCH 2/7] jsaddle: bump aeson upper bound to <2.4 (allow aeson 2.3.x) --- jsaddle/jsaddle.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsaddle/jsaddle.cabal b/jsaddle/jsaddle.cabal index 541ef4a5..920e6071 100644 --- a/jsaddle/jsaddle.cabal +++ b/jsaddle/jsaddle.cabal @@ -111,7 +111,7 @@ library Language.Javascript.JSaddle.Value Language.Javascript.JSaddle.Types build-depends: - aeson >=0.11.3.0 && <2.3, + aeson >=0.11.3.0 && <2.4, base >=4.9 && <5, base-compat >=0.9.0 && <0.16, base64-bytestring >=1.0.0.1 && <1.3, From ead142a5e736f1139a32a0335df48a5661fe1323 Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Thu, 9 Jul 2026 01:00:16 +1200 Subject: [PATCH 3/7] jsaddle-{webkitgtk,wkwebview,webview2}: bump aeson upper bound to <2.4 --- jsaddle-webkitgtk/jsaddle-webkitgtk.cabal | 2 +- jsaddle-webview2/jsaddle-webview2.cabal | 2 +- jsaddle-wkwebview/jsaddle-wkwebview.cabal | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/jsaddle-webkitgtk/jsaddle-webkitgtk.cabal b/jsaddle-webkitgtk/jsaddle-webkitgtk.cabal index 277630c5..25ab6393 100644 --- a/jsaddle-webkitgtk/jsaddle-webkitgtk.cabal +++ b/jsaddle-webkitgtk/jsaddle-webkitgtk.cabal @@ -26,7 +26,7 @@ library base <5 if !impl(ghcjs -any) && !arch(javascript) build-depends: - aeson >=0.8.0.2 && <2.3, + aeson >=0.8.0.2 && <2.4, bytestring >=0.10.6.0 && <0.13, directory >=1.0.0.2 && <1.4, gi-glib >=2.0.14 && <2.1, diff --git a/jsaddle-webview2/jsaddle-webview2.cabal b/jsaddle-webview2/jsaddle-webview2.cabal index bf0d68d5..91d244d4 100644 --- a/jsaddle-webview2/jsaddle-webview2.cabal +++ b/jsaddle-webview2/jsaddle-webview2.cabal @@ -31,7 +31,7 @@ library ghc-options: -ferror-spans -Wall build-depends: base <5, - aeson >=0.8.0.2 && <2.3, + aeson >=0.8.0.2 && <2.4, bytestring >=0.10.6.0 && <0.13, data-default, jsaddle >=0.9.9.0 && <0.10, diff --git a/jsaddle-wkwebview/jsaddle-wkwebview.cabal b/jsaddle-wkwebview/jsaddle-wkwebview.cabal index 2b7d96a9..fa806cb1 100644 --- a/jsaddle-wkwebview/jsaddle-wkwebview.cabal +++ b/jsaddle-wkwebview/jsaddle-wkwebview.cabal @@ -40,7 +40,7 @@ library else frameworks: Foundation, WebKit build-depends: - aeson >=0.8.0.2 && <2.3, + aeson >=0.8.0.2 && <2.4, bytestring >=0.10.6.0 && <0.13, directory, jsaddle >= 0.9.9.0 && <0.10, From c4d992b725f26f1dd41639a822b30642167607c2 Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Fri, 17 Jul 2026 22:00:26 +1200 Subject: [PATCH 4/7] jsaddle-wkwebview: rename cbits/WKWebView.m to avoid archive-member collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cabal 3.12+ compiles foreign sources into the same object tree as Haskell modules, `ar` stores archive members by basename, and GHC 9.14's in-process TH loader resolves members by name — so the cbits object and the Language.Javascript.JSaddle.WKWebView module object both becoming `WKWebView.o` makes any TH splice that loads this package fail with a spurious "duplicate definition for symbol _openApp" (seen building leksah's gi-gtk TH splices). --- .../cbits/{WKWebView.m => WKWebView-cbits.m} | 0 jsaddle-wkwebview/jsaddle-wkwebview.cabal | 8 +++++++- 2 files changed, 7 insertions(+), 1 deletion(-) rename jsaddle-wkwebview/cbits/{WKWebView.m => WKWebView-cbits.m} (100%) diff --git a/jsaddle-wkwebview/cbits/WKWebView.m b/jsaddle-wkwebview/cbits/WKWebView-cbits.m similarity index 100% rename from jsaddle-wkwebview/cbits/WKWebView.m rename to jsaddle-wkwebview/cbits/WKWebView-cbits.m diff --git a/jsaddle-wkwebview/jsaddle-wkwebview.cabal b/jsaddle-wkwebview/jsaddle-wkwebview.cabal index fa806cb1..dc4dbbc7 100644 --- a/jsaddle-wkwebview/jsaddle-wkwebview.cabal +++ b/jsaddle-wkwebview/jsaddle-wkwebview.cabal @@ -50,8 +50,14 @@ library exposed-modules: Language.Javascript.JSaddle.WKWebView.Internal hs-source-dirs: src-ghc + -- Named -cbits so its object's archive-member basename can't + -- collide with the Language.Javascript.JSaddle.WKWebView module + -- object (Cabal 3.12+ places Haskell and foreign objects in one + -- object tree; ar stores members by basename, and GHC 9.14's + -- in-process TH loader mis-resolves duplicate member names — + -- "duplicate definition for symbol _openApp"). cxx-sources: - cbits/WKWebView.m + cbits/WKWebView-cbits.m cc-options: -Wno-everything if os(ios) frameworks: UIKit, UserNotifications From cdf4bee203e4004208d85e60344444dc494d003a Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Fri, 17 Jul 2026 22:16:37 +1200 Subject: [PATCH 5/7] jsaddle-wkwebview: single Cocoa ObjC translation unit for GHC's TH loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHC's runtime linker (used for Template Haskell when GHC itself is statically linked) treats the Objective-C protocol/class metadata every ObjC object emits (e.g. __OBJC_LABEL_PROTOCOL_$_NSObject) as strong duplicate definitions — the system linker coalesces them — so loading a library with more than one ObjC archive member fails. Combine the Cocoa sources (WKWebView-cbits.m + AppDelegate.m) into one translation unit via #include when include-app-delegate is set, and move the base cxx-source into each branch so it isn't compiled twice. --- .../cbits-cocoa/WKWebView-AppDelegate.m | 10 +++++++ jsaddle-wkwebview/jsaddle-wkwebview.cabal | 29 +++++++++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 jsaddle-wkwebview/cbits-cocoa/WKWebView-AppDelegate.m diff --git a/jsaddle-wkwebview/cbits-cocoa/WKWebView-AppDelegate.m b/jsaddle-wkwebview/cbits-cocoa/WKWebView-AppDelegate.m new file mode 100644 index 00000000..0442f058 --- /dev/null +++ b/jsaddle-wkwebview/cbits-cocoa/WKWebView-AppDelegate.m @@ -0,0 +1,10 @@ +// Single translation unit combining the package's Cocoa Objective-C +// sources. GHC's runtime linker — used for Template Haskell when the +// compiler is statically linked — refuses the duplicate Objective-C +// protocol/class metadata every ObjC object file emits (e.g. +// __OBJC_LABEL_PROTOCOL_$_NSObject, coalesced by the system linker but +// treated as strong duplicate definitions by GHC), so all of the +// package's ObjC code must land in ONE archive member for TH splices +// in dependents to be able to load this library. +#include "../cbits/WKWebView-cbits.m" +#include "AppDelegate.m" diff --git a/jsaddle-wkwebview/jsaddle-wkwebview.cabal b/jsaddle-wkwebview/jsaddle-wkwebview.cabal index dc4dbbc7..fbff95cd 100644 --- a/jsaddle-wkwebview/jsaddle-wkwebview.cabal +++ b/jsaddle-wkwebview/jsaddle-wkwebview.cabal @@ -50,25 +50,36 @@ library exposed-modules: Language.Javascript.JSaddle.WKWebView.Internal hs-source-dirs: src-ghc - -- Named -cbits so its object's archive-member basename can't - -- collide with the Language.Javascript.JSaddle.WKWebView module - -- object (Cabal 3.12+ places Haskell and foreign objects in one - -- object tree; ar stores members by basename, and GHC 9.14's - -- in-process TH loader mis-resolves duplicate member names — - -- "duplicate definition for symbol _openApp"). - cxx-sources: - cbits/WKWebView-cbits.m + -- The cbits file is named -cbits so its object's archive-member + -- basename can't collide with the + -- Language.Javascript.JSaddle.WKWebView module object (Cabal + -- 3.12+ places Haskell and foreign objects in one object tree; + -- ar stores members by basename, and GHC 9.14's in-process TH + -- loader mis-resolves duplicate member names). cc-options: -Wno-everything if os(ios) frameworks: UIKit, UserNotifications if flag(include-app-delegate) cxx-sources: + cbits/WKWebView-cbits.m cbits-uikit/AppDelegate.m cbits-uikit/ViewController.m cpp-options: -DUSE_UIKIT + else + cxx-sources: + cbits/WKWebView-cbits.m else frameworks: Cocoa if flag(include-app-delegate) + -- A SINGLE Objective-C translation unit (it #includes + -- WKWebView-cbits.m and AppDelegate.m): GHC's runtime + -- linker — used for TH under a statically linked GHC — + -- rejects the duplicate ObjC protocol metadata every ObjC + -- object emits, so the package's ObjC code must form one + -- archive member for dependents' TH splices to load it. cxx-sources: - cbits-cocoa/AppDelegate.m + cbits-cocoa/WKWebView-AppDelegate.m cpp-options: -DUSE_COCOA + else + cxx-sources: + cbits/WKWebView-cbits.m From e0d6d036980b58395fea952580f8a820e86f5313 Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Fri, 17 Jul 2026 22:23:49 +1200 Subject: [PATCH 6/7] jsaddle-wkwebview: silence nullability-completeness in the composite TU AppDelegate.m's _Nonnull annotations put the combined translation unit into clang's nullability-audit mode, which then demands annotations on every pointer in WKWebView-cbits.m; both files compile cleanly standalone. --- jsaddle-wkwebview/cbits-cocoa/WKWebView-AppDelegate.m | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/jsaddle-wkwebview/cbits-cocoa/WKWebView-AppDelegate.m b/jsaddle-wkwebview/cbits-cocoa/WKWebView-AppDelegate.m index 0442f058..3ec56dc9 100644 --- a/jsaddle-wkwebview/cbits-cocoa/WKWebView-AppDelegate.m +++ b/jsaddle-wkwebview/cbits-cocoa/WKWebView-AppDelegate.m @@ -6,5 +6,12 @@ // treated as strong duplicate definitions by GHC), so all of the // package's ObjC code must land in ONE archive member for TH splices // in dependents to be able to load this library. +// Combining the sources puts the whole translation unit into clang's +// nullability-audit mode (AppDelegate.m uses _Nonnull), which then +// demands annotations on every pointer in WKWebView-cbits.m — the +// files compile cleanly standalone, so silence the completeness +// diagnostics for the composite. +#pragma clang diagnostic ignored "-Wnullability-completeness" +#pragma clang diagnostic ignored "-Wnullability-completeness-on-arrays" #include "../cbits/WKWebView-cbits.m" #include "AppDelegate.m" From c58314477d68a060d46dc1cd5507c42a98771f89 Mon Sep 17 00:00:00 2001 From: Hamish Mackenzie Date: Fri, 17 Jul 2026 22:33:00 +1200 Subject: [PATCH 7/7] jsaddle-wkwebview: ship the #included ObjC sources in the sdist AppDelegate.m left every cxx-sources list when the composite TU was introduced, so cabal stopped including it in the sdist and the composite's #include failed with 'file not found'. --- jsaddle-wkwebview/jsaddle-wkwebview.cabal | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/jsaddle-wkwebview/jsaddle-wkwebview.cabal b/jsaddle-wkwebview/jsaddle-wkwebview.cabal index fbff95cd..e63df005 100644 --- a/jsaddle-wkwebview/jsaddle-wkwebview.cabal +++ b/jsaddle-wkwebview/jsaddle-wkwebview.cabal @@ -15,6 +15,13 @@ category: Web, Javascript author: Hamish Mackenzie tested-with: GHC==9.12.2, GHC==9.10.1, GHC==9.8.4, GHC==9.6.7, GHC==9.4.8, GHC==9.2.8, GHC==9.0.2, GHC==8.10.7, GHC==8.8.4, GHC==8.6.5, GHC==8.4.4 +-- #included by cbits-cocoa/WKWebView-AppDelegate.m (the single Cocoa +-- translation unit) rather than compiled directly, so they must be +-- shipped explicitly. +extra-source-files: + cbits/WKWebView-cbits.m + cbits-cocoa/AppDelegate.m + flag include-app-delegate description: Include default AppDelegate C sources. default: True