From 7f92778a96f8f3b685ce3892b8c0184e0d0bdd00 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 21:43:35 -0400 Subject: [PATCH 1/2] Add js/wasm stub running callbacks inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing mainthread package locks an OS thread and pumps a queue — under GOOS=js GOARCH=wasm there is only one thread and no OS-thread concept, and blocking the event loop deadlocks requestAnimationFrame. This change tags the existing implementation `!js` and adds a parallel `js && wasm` build where `Call`/`CallErr` run the callback inline on the JS event loop, `Run` invokes the supplied function directly, and `CallNonBlock` launches a goroutine as usual. Callers using this API through the pixel backend can build for WebAssembly without changes. Co-Authored-By: Claude Opus 4.7 --- mainthread.go | 2 ++ mainthread_wasm.go | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 mainthread_wasm.go diff --git a/mainthread.go b/mainthread.go index e445ce0..fb7a124 100644 --- a/mainthread.go +++ b/mainthread.go @@ -1,3 +1,5 @@ +//go:build !js + package mainthread import ( diff --git a/mainthread_wasm.go b/mainthread_wasm.go new file mode 100644 index 0000000..5d98409 --- /dev/null +++ b/mainthread_wasm.go @@ -0,0 +1,24 @@ +//go:build js && wasm + +package mainthread + +// Under WASM the entire program runs on the JS event-loop thread. Queuing onto +// a separate main thread would deadlock, so every helper runs the callback +// inline on the calling goroutine. + +var CallQueueCap = 16 + +// Run invokes the supplied function synchronously and returns when it exits. +func Run(run func()) { run() } + +// Call runs f inline on the current goroutine. +func Call(f func()) { f() } + +// CallNonBlock runs f inline on the current goroutine. +func CallNonBlock(f func()) { f() } + +// CallErr runs f inline and returns its error. +func CallErr(f func() error) error { return f() } + +// CallVal runs f inline and returns its value. +func CallVal[T any](f func() T) T { return f() } From 08fc034e56e90d3a93cab44bcee5d6683b5d30b9 Mon Sep 17 00:00:00 2001 From: Fisher Evans Date: Mon, 20 Apr 2026 22:03:39 -0400 Subject: [PATCH 2/2] Make CallNonBlock non-blocking and document WASM semantics CallNonBlock was running the callback inline, which defeats the "return immediately" semantic the name and desktop behavior promise. Switch to launching a goroutine so the caller yields without waiting. Also document CallQueueCap as compat-only under WASM and add a README section explaining the single-threaded adaptation. Co-Authored-By: Claude Opus 4.7 --- README.md | 16 ++++++++++++++++ mainthread_wasm.go | 20 +++++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c914328..f37ff73 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,19 @@ mainthread.Call(func() { ``` However, be careful with `mainthread.CallNonBlock` when dealing with local variables. + +## WebAssembly (`GOOS=js GOARCH=wasm`) + +A parallel implementation ships under the `js && wasm` build tag. Go programs +compiled for the browser run as a single JavaScript event-loop thread, so +there is no separate main thread to queue onto — and blocking the event loop +deadlocks `requestAnimationFrame`. The WASM build therefore: + +- runs `Run`, `Call`, `CallErr`, and `CallVal` inline on the calling goroutine, +- spawns a goroutine for `CallNonBlock` so the caller still returns without + waiting, and +- exports `CallQueueCap` only for source compatibility; it is unused. + +Downstream libraries that need to differ for WASM (for example, to avoid using +finalizers that post to the queue) can gate on the same `js && wasm` build +tag. diff --git a/mainthread_wasm.go b/mainthread_wasm.go index 5d98409..06fb98b 100644 --- a/mainthread_wasm.go +++ b/mainthread_wasm.go @@ -3,19 +3,29 @@ package mainthread // Under WASM the entire program runs on the JS event-loop thread. Queuing onto -// a separate main thread would deadlock, so every helper runs the callback -// inline on the calling goroutine. +// a separate main thread would deadlock, so the blocking helpers (Run, Call, +// CallErr, CallVal) run the callback inline. CallNonBlock uses a goroutine to +// preserve its "return immediately" semantic. +// CallQueueCap exists for source compatibility with the desktop build; the +// WASM shim does not queue anything, so the value is unused. var CallQueueCap = 16 // Run invokes the supplied function synchronously and returns when it exits. +// On WASM there is no separate main thread to pump, so Run is effectively a +// direct call. func Run(run func()) { run() } -// Call runs f inline on the current goroutine. +// Call runs f inline on the current goroutine. Equivalent to the desktop +// behavior of queueing onto the main thread and blocking until done, because +// under WASM the calling goroutine is already on the main (and only) thread. func Call(f func()) { f() } -// CallNonBlock runs f inline on the current goroutine. -func CallNonBlock(f func()) { f() } +// CallNonBlock runs f on a new goroutine so the caller is not blocked. This +// matches the desktop semantic ("queue and return") as closely as possible +// under a single-threaded runtime; f will execute cooperatively once the +// caller yields to the scheduler. +func CallNonBlock(f func()) { go f() } // CallErr runs f inline and returns its error. func CallErr(f func() error) error { return f() }