From efb2064a5e7a308a38301721935fbd0047f1a81a Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 6 Oct 2026 02:28:58 +0800 Subject: [PATCH 01/12] wasm: share syscall/js values across scheduler workers --- dev/test_wasm_workers.sh | 35 +- dev/wasmstdlib/main.go | 3 + dev/wasmstdlib/main_test.go | 18 + doc/wasm-browser-filesystem.md | 6 + doc/wasm-proposal.md | 4 +- .../build/testdata/wasm-browser-fs/main.go | 25 +- .../testdata/wasm-test/js_workers_test.go | 16 +- .../testdata/wasm-workers/browser-runner.mjs | 7 +- .../build/testdata/wasm-workers/browser.html | 4 +- runtime/_patch/syscall/fs_js_wasm_workers.go | 6 +- runtime/_test/locality/locality.go | 43 +- .../lib/runtime/mfinal_lock_wasm_other.go | 19 + .../lib/runtime/mfinal_lock_wasm_workers.go | 15 + runtime/internal/lib/runtime/mfinal_wasm.go | 18 +- .../lib/syscall/js/callback_poll_workers.go | 4 +- runtime/internal/lib/syscall/js/emval.go | 5 +- .../lib/syscall/js/emval_release_single.go | 2 +- runtime/internal/lib/syscall/js/func.go | 33 +- .../lib/syscall/js/func_mutex_other.go | 7 + .../lib/syscall/js/func_mutex_workers.go | 19 + runtime/internal/lib/syscall/js/js.go | 94 ++++- .../internal/lib/syscall/js/worker_call.go | 15 + .../lib/syscall/js/worker_call_single.go | 7 + .../internal/lib/syscall/js/worker_calls.go | 161 ++++++++ runtime/internal/runqueue/runqueue.go | 40 ++ runtime/internal/runqueue/runqueue_test.go | 47 +++ runtime/internal/runtime/host_events_js.go | 2 - .../runtime/host_events_js_workers.go | 28 +- .../runtime/js_worker_call_wasm_workers.go | 71 ++++ runtime/internal/runtime/proc_wasm_workers.go | 70 +++- runtime/internal/wasmworkers/_wrap/workers.c | 6 +- runtime/wasmworkers/go_workers.go | 7 +- test/llgoext/runtime_g_test.go | 11 +- test/wasm/js-workers/main.go | 385 ++++++++++++++++++ 34 files changed, 1110 insertions(+), 123 deletions(-) create mode 100644 runtime/internal/lib/runtime/mfinal_lock_wasm_other.go create mode 100644 runtime/internal/lib/runtime/mfinal_lock_wasm_workers.go create mode 100644 runtime/internal/lib/syscall/js/func_mutex_other.go create mode 100644 runtime/internal/lib/syscall/js/func_mutex_workers.go create mode 100644 runtime/internal/lib/syscall/js/worker_call.go create mode 100644 runtime/internal/lib/syscall/js/worker_call_single.go create mode 100644 runtime/internal/lib/syscall/js/worker_calls.go create mode 100644 runtime/internal/runtime/js_worker_call_wasm_workers.go create mode 100644 test/wasm/js-workers/main.go diff --git a/dev/test_wasm_workers.sh b/dev/test_wasm_workers.sh index 81ee25bdf2..51a2faa275 100755 --- a/dev/test_wasm_workers.sh +++ b/dev/test_wasm_workers.sh @@ -10,6 +10,7 @@ wasm_tools_cmd="${WASM_TOOLS:-wasm-tools}" wasm_opt_cmd="${WASMOPT:-wasm-opt}" worker_fixture="${repo_root}/internal/build/testdata/wasm-workers" hardening_fixture="${repo_root}/internal/build/testdata/wasm-hardening" +js_worker_fixture="${repo_root}/test/wasm/js-workers" test_fixture="${repo_root}/internal/build/testdata/wasm-test" work_dir="$(mktemp -d "${TMPDIR:-/tmp}/llgo-wasm-workers.XXXXXX")" browser_server= @@ -39,16 +40,17 @@ require_tool() { } run_emscripten() { - local target="$1" - local runner="$2" - local fixture="$3" - local expected="$4" - local name="$5" - shift 5 + local workers="$1" + local target="$2" + local runner="$3" + local fixture="$4" + local expected="$5" + local name="$6" + shift 6 local module="${work_dir}/${name}.mjs" local output="${work_dir}/${name}.out" - LLGO_WASM_WORKERS=2 "${llgo_cmd}" build -target "${target}" -o "${module}" "${fixture}" + LLGO_WASM_WORKERS="${workers}" "${llgo_cmd}" build -target "${target}" -o "${module}" "${fixture}" "${wasm_tools_cmd}" validate --features all "${work_dir}/${name}.wasm" run_with_timeout env "$@" "${node_cmd}" "${repo_root}/targets/${runner}" "${module}" "${name}" 2>&1 | tee "${output}" grep -Fxq "${expected}" "${output}" @@ -102,7 +104,9 @@ run_filesystem_acceptance() { LLGO_WASM_WORKERS="${workers}" "${llgo_cmd}" build -target "${target}" \ -o "${module}" "${repo_root}/internal/build/testdata/wasm-browser-fs" "${wasm_tools_cmd}" validate --features all "${module%.mjs}.wasm" - run_with_timeout "${node_cmd}" "${repo_root}/targets/${runner}" "${module}" "fs-${mode}" \ + local stress_args=() + if [[ "${workers}" = 2 ]]; then stress_args=(--gc-stress); fi + run_with_timeout "${node_cmd}" "${repo_root}/targets/${runner}" "${module}" "fs-${mode}" "${stress_args[@]}" \ 2>&1 | tee "${module}.out" grep -Fxq "wasm filesystem ok" "${module}.out" done @@ -148,6 +152,7 @@ run_browser_acceptance() { local module for module in workers-emscripten.mjs workers-memory64.mjs hardening-workers-emscripten.mjs hardening-workers-memory64.mjs \ + js-workers-2-emscripten.mjs js-workers-2-memory64.mjs js-workers-4-emscripten.mjs js-workers-4-memory64.mjs \ fs-single-emscripten.mjs fs-workers-emscripten.mjs fs-single-emscripten-memory64.mjs fs-workers-emscripten-memory64.mjs; do echo "browser worker acceptance: ${module}" run_with_timeout "${node_cmd}" "${worker_fixture}/browser-runner.mjs" \ @@ -173,14 +178,20 @@ run_single_hardening emscripten emscripten-runner.mjs hardening-single-emscripte run_single_hardening emscripten-memory64 emscripten-memory64-runner.mjs hardening-single-memory64 # EC32 and EC64 run the same scheduler, GC, C-boundary, and lifecycle probes. -run_emscripten emscripten emscripten-runner.mjs \ +run_emscripten 2 emscripten emscripten-runner.mjs \ "${worker_fixture}" "wasm workers ok" workers-emscripten -run_emscripten emscripten-memory64 emscripten-memory64-runner.mjs \ +run_emscripten 2 emscripten-memory64 emscripten-memory64-runner.mjs \ "${worker_fixture}" "wasm workers ok" workers-memory64 -run_emscripten emscripten emscripten-runner.mjs \ +for workers in 2 4; do + run_emscripten "${workers}" emscripten emscripten-runner.mjs \ + "${js_worker_fixture}" "wasm js workers ok" "js-workers-${workers}-emscripten" + run_emscripten "${workers}" emscripten-memory64 emscripten-memory64-runner.mjs \ + "${js_worker_fixture}" "wasm js workers ok" "js-workers-${workers}-memory64" +done +run_emscripten 2 emscripten emscripten-runner.mjs \ "${hardening_fixture}" "wasm hardening ok" hardening-workers-emscripten \ LLGO_WASM_EXPECT_ARG=hardening-workers-emscripten LLGO_WASM_BLOCKED_G=1000 -run_emscripten emscripten-memory64 emscripten-memory64-runner.mjs \ +run_emscripten 2 emscripten-memory64 emscripten-memory64-runner.mjs \ "${hardening_fixture}" "wasm hardening ok" hardening-workers-memory64 \ LLGO_WASM_EXPECT_ARG=hardening-workers-memory64 LLGO_WASM_BLOCKED_G=1000 diff --git a/dev/wasmstdlib/main.go b/dev/wasmstdlib/main.go index 107c5b7010..b4ffadd231 100644 --- a/dev/wasmstdlib/main.go +++ b/dev/wasmstdlib/main.go @@ -62,6 +62,9 @@ func sourceContext(p profile) (tags, cgo string) { return "", "0" } tags, cgo = "llgo,osusergo,llgo.wasm.gc.linear", "0" + if workers, _ := strconv.Atoi(os.Getenv("LLGO_WASM_WORKERS")); (p.Target == "emscripten" || p.Target == "emscripten-memory64") && workers > 1 { + tags += ",llgo.wasm.workers" + } switch p.Target { case "emscripten": return tags + ",llgo.wasm.emscripten", "1" diff --git a/dev/wasmstdlib/main_test.go b/dev/wasmstdlib/main_test.go index 68194fe584..b9279328a9 100644 --- a/dev/wasmstdlib/main_test.go +++ b/dev/wasmstdlib/main_test.go @@ -348,6 +348,7 @@ func TestProfilesAndCommands(t *testing.T) { func TestProfileSourceContexts(t *testing.T) { t.Setenv("LLGO_WASI_THREADS", "") + t.Setenv("LLGO_WASM_WORKERS", "") tests := map[string]struct{ tags, cgo string }{ "J32-GoJS": {"llgo,osusergo,llgo.wasm.gc.linear", "0"}, "J32-Emscripten": {"llgo,osusergo,llgo.wasm.gc.linear,llgo.wasm.emscripten", "1"}, @@ -368,6 +369,23 @@ func TestProfileSourceContexts(t *testing.T) { } } +func TestWorkerProfileSourceContexts(t *testing.T) { + for _, workers := range []string{"1", "2", "4"} { + t.Setenv("LLGO_WASM_WORKERS", workers) + for _, name := range []string{"J32-GoJS", "J32-Emscripten", "J64-Emscripten", "W32-WASI", "GoJS-reference", "GoWASI-reference"} { + p, err := selectProfile(name) + if err != nil { + t.Fatal(err) + } + tags, _ := sourceContext(p) + want := workers != "1" && (p.Target == "emscripten" || p.Target == "emscripten-memory64") + if strings.Contains(tags, "llgo.wasm.workers") != want { + t.Errorf("%s workers=%s source tags = %q", name, workers, tags) + } + } + } +} + func TestWASIThreadSourceContext(t *testing.T) { t.Setenv("LLGO_WASI_THREADS", "1") p, err := selectProfile("W32-WASI") diff --git a/doc/wasm-browser-filesystem.md b/doc/wasm-browser-filesystem.md index 45dfe40a93..f5a7b0774c 100644 --- a/doc/wasm-browser-filesystem.md +++ b/doc/wasm-browser-filesystem.md @@ -21,6 +21,12 @@ only metadata. The bridge validates methods and buffer ranges and installs its worker wrappers once. Read completion copies only the returned bytes into the caller's requested offset, preserving bytes outside that range. +In the bounded Go scheduler, `syscall/js` uses Go main's worker-zero realm. +Filesystem handles cached in other Go workers' TLS refer to that same realm; +their calls park the calling G and execute on worker zero before entering the +filesystem bridge. The bridge's browser runtime-thread proxy remains necessary +for `Module.FS` and Go/C interoperability. + Node continues using `node:fs`. Its worker `process.cwd/chdir` methods proxy to the runtime thread so cwd changes are visible across workers. Node's C filesystem remains Emscripten's virtual filesystem, as before. diff --git a/doc/wasm-proposal.md b/doc/wasm-proposal.md index 6eb9ef5340..050c4c3952 100644 --- a/doc/wasm-proposal.md +++ b/doc/wasm-proposal.md @@ -88,7 +88,7 @@ These four entries are acceptance paths for three profiles. Memory ABI, host/pro - W32 uses the Go WASI Preview 1 host contract and can use wasi-libc without becoming a separate C profile. - Host adapters own imports, startup, filesystems, callbacks, timers, process exit, and artifact sidecars. Runtime scheduling, GC, panic/defer/recover, reflection semantics, and caller metadata remain provider-independent where possible. -The opt-in bounded Emscripten worker mode (`LLGO_WASM_WORKERS=2` through `16`; `1` keeps the single-worker scheduler) keeps each goroutine on one physical worker. Emscripten `syscall/js` handles are JavaScript-realm-local, so a goroutine that uses them keeps ordinary descendants on that worker. Callers may use `github.com/xgo-dev/llgo/runtime/wasmworkers.GoIndependent` to dispatch work to the pool only when they ensure its closure carries no JS value or thread-local C state; the runtime does not inspect the closure. Passing a `js.Value` between unrelated workers is not yet supported; resolving that boundary is required before treating multi-worker mode as the default Go-compatible JavaScript provider. +The opt-in bounded Emscripten worker mode (`LLGO_WASM_WORKERS=2` through `16`; `1` keeps the single-worker scheduler) keeps each goroutine on one physical worker. `syscall/js` values and functions may be shared between workers: their operations execute in Go main's JavaScript realm on worker zero. A remote caller parks its G while a reusable executor G services its calls; separate callers have separate executors so nested callbacks can wait for other Go work. Object identity, callback results, exceptions, and GC roots remain intact without cloning JavaScript objects. `github.com/xgo-dev/llgo/runtime/wasmworkers.GoIndependent` may capture public JS values, but raw Emscripten handles and C thread-local state must stay on their physical worker. Worker zero is an Emscripten pthread, not the browser UI thread; this does not add DOM access to worker mode. Multi-worker mode remains opt-in pending full compatibility qualification. ValueOf conversions of Value/Func/nil/bool and Type checks for reserved undefined/null/bool handles stay local. Other remote JS operations require a synchronous worker-zero round trip; JS-heavy loops should batch work in one JS call where possible. Each remote caller retains its executor fiber until the caller exits. ## Reflection and foreign calls @@ -207,7 +207,7 @@ Hosted profile 是 Memory ABI 与 Host ABI 的组合。源码兼容、C 互操 - W32 使用 Go WASI Preview 1 host contract,并可使用 wasi-libc,不再因此拆出单独 C profile。 - Host adapter 负责 imports、启动、文件系统、回调、定时器、进程退出和产物 sidecar。调度、GC、panic/defer/recover、反射语义和 caller metadata 在可行范围内保持 provider 无关。 -当前有界 Emscripten Worker 模式需设置 `LLGO_WASM_WORKERS=2` 至 `16` 显式启用;设置为 `1` 时仍使用单 Worker 调度器。每个 goroutine 固定在一个物理 Worker。Emscripten 的 `syscall/js` 句柄属于创建它的 JavaScript realm,因此使用这些句柄的 goroutine 会让普通子 goroutine 留在同一 Worker。调用者必须确保闭包不携带 JS 值或 C 线程局部状态,才可用 `github.com/xgo-dev/llgo/runtime/wasmworkers.GoIndependent` 将独立任务分配到池中;运行时不会检查闭包。尚不支持在无亲缘关系的 Worker 间传递 `js.Value`;在把多 Worker 模式作为默认的 Go 兼容 JavaScript provider 前,必须解决这一边界。 +当前有界 Emscripten Worker 模式需设置 `LLGO_WASM_WORKERS=2` 至 `16` 显式启用;设置为 `1` 时仍使用单 Worker 调度器。每个 goroutine 固定在一个物理 Worker,`syscall/js` 值和函数可跨 Worker 共享,操作统一在 Go main 所在的 Worker 0 的 JavaScript realm 中执行。远端调用只挂起调用方 G,由可复用的执行 G 处理;各调用方有独立的执行 G,使嵌套回调可以等待其他 Go 任务。对象身份、回调返回值、异常和 GC 根无需复制 JS 对象即可保留。`github.com/xgo-dev/llgo/runtime/wasmworkers.GoIndependent` 可以携带公开 JS 值,但原始 Emscripten 句柄和 C 线程局部状态仍须留在所属物理 Worker。Worker 0 是 Emscripten pthread,并非浏览器 UI 线程,此机制不增加 Worker 模式的 DOM 访问能力。完成全量兼容性验收前,多 Worker 模式继续显式启用。 ValueOf 对 Value/Func/nil/bool 的转换,以及对保留的 undefined/null/bool 句柄的 Type 检查在本地处理。其余远端 JS 操作需同步往返 Worker 0,频繁访问 JS 的循环应尽量在一次 JS 调用中批量处理。远端调用方退出时才释放其执行 G 的 fiber。 ## 反射与外部调用 diff --git a/internal/build/testdata/wasm-browser-fs/main.go b/internal/build/testdata/wasm-browser-fs/main.go index 56b10a4867..8df10e5fc3 100644 --- a/internal/build/testdata/wasm-browser-fs/main.go +++ b/internal/build/testdata/wasm-browser-fs/main.go @@ -26,9 +26,13 @@ func check(err error) { } func main() { - // The single-worker browser runtime stays alive for host callbacks. Emit - // the completion marker only after all filesystem cleanup has finished. - defer func() { println("wasm filesystem ok") }() + testFilesystem() + // Single-worker browsers stay alive for callbacks. Only a successful + // return after cleanup emits the marker used by their acceptance runner. + println("wasm filesystem ok") +} + +func testFilesystem() { dir, err := os.MkdirTemp("", "llgo-browser-fs-") check(err) defer os.RemoveAll(dir) @@ -40,9 +44,18 @@ func main() { check(err) done := make(chan int64) workers := make(map[int64]bool) - for range 16 { + multiWorker := len(os.Args) > 1 && strings.Contains(os.Args[1], "workers") + gcStress := multiWorker && len(os.Args) > 2 && os.Args[2] == "--gc-stress" + operations := 16 + if gcStress { + operations = 128 + } + for range operations { wasmworkers.GoIndependent(func() { _, _, mid, _, _, _, _ := gmpForTesting() + if gcStress { + runtime.GC() + } contents, err := os.ReadFile(name) check(err) if string(contents) != "Go file" { @@ -62,10 +75,10 @@ func main() { done <- mid }) } - for range 16 { + for range operations { workers[<-done] = true } - if len(os.Args) > 1 && strings.Contains(os.Args[1], "workers") && len(workers) < 2 { + if multiWorker && len(workers) < 2 { panic("filesystem test did not exercise distinct workers") } if _, err = os.Stat(filepath.Join(dir, "missing")); !os.IsNotExist(err) { diff --git a/internal/build/testdata/wasm-test/js_workers_test.go b/internal/build/testdata/wasm-test/js_workers_test.go index 89668d56a8..777a32bd5d 100644 --- a/internal/build/testdata/wasm-test/js_workers_test.go +++ b/internal/build/testdata/wasm-test/js_workers_test.go @@ -30,17 +30,17 @@ type workerFinalizerBarrier struct { padding [128]uintptr } -func TestWorkerJSRealmInheritance(t *testing.T) { +func TestWorkerSharedJSValues(t *testing.T) { object := js.ValueOf(map[string]any{"value": 42}) owner := schedulerProcID() result := make(chan workerCallbackResult, 1) go func() { - // The intermediate G has not used syscall/js itself. Its child still - // needs the original realm when the handle is passed through a closure. + // Neither descendant needs to stay on the originating Go worker to + // use a JavaScript value passed through the closure. go func() { worker := schedulerProcID() - if worker != owner || object.Get("value").Int() != 42 { - result <- workerCallbackResult{origin: owner, callback: worker, err: fmt.Errorf("JavaScript value moved from worker %d to %d", owner, worker)} + if value := object.Get("value").Int(); value != 42 { + result <- workerCallbackResult{origin: owner, callback: worker, err: fmt.Errorf("JavaScript value = %d on worker %d, want 42", value, worker)} return } result <- workerCallbackResult{origin: owner, callback: worker} @@ -89,7 +89,7 @@ func TestWorkerEmvalFinalizersStayInRealm(t *testing.T) { owners[result.origin] = true } if len(owners) < 2 { - t.Fatalf("JavaScript values were created on %d worker, want at least 2", len(owners)) + t.Fatalf("JavaScript values were used on %d worker, want at least 2", len(owners)) } finalizersDone := make(chan struct{}, 3) @@ -343,8 +343,8 @@ func TestWorkerHostCallbackRealms(t *testing.T) { if result.err != nil { t.Fatal(result.err) } - if result.callback != result.origin { - t.Fatalf("callback worker = %d, want origin worker %d", result.callback, result.origin) + if result.callback != 0 { + t.Fatalf("callback worker = %d, want shared JS worker 0 (caller %d)", result.callback, result.origin) } workers[result.origin] = true case <-timeout: diff --git a/internal/build/testdata/wasm-workers/browser-runner.mjs b/internal/build/testdata/wasm-workers/browser-runner.mjs index 6a2f3733b1..53a84b9fa5 100644 --- a/internal/build/testdata/wasm-workers/browser-runner.mjs +++ b/internal/build/testdata/wasm-workers/browser-runner.mjs @@ -8,9 +8,10 @@ if (!browser || !url) { throw new Error("usage: node browser-runner.mjs "); } -// Shared CI runners can spend tens of seconds starting Chrome and the memory64 -// worker even after the Node acceptance run has passed. -const deadline = Date.now() + 90_000; +// The dedicated filesystem stress runs 128 collections across concurrent Gs; +// allow its Memory64 root scans more time than ordinary worker smoke tests. +const fsStress = new URL(url).searchParams.get("module")?.startsWith("fs-workers-"); +const deadline = Date.now() + (fsStress ? 240_000 : 90_000); const profile = await mkdtemp(join(tmpdir(), "llgo-wasm-chrome-")); const { child: chrome, stop: stopChrome } = spawnBrowser(browser, [ "--headless=new", diff --git a/internal/build/testdata/wasm-workers/browser.html b/internal/build/testdata/wasm-workers/browser.html index 6a5f0d375a..1c10cd46b7 100644 --- a/internal/build/testdata/wasm-workers/browser.html +++ b/internal/build/testdata/wasm-workers/browser.html @@ -23,7 +23,7 @@ if (name.startsWith("fs-single-") && line === "wasm filesystem ok") resolve(0); }; module.default({ - arguments: [name], + arguments: name.startsWith("fs-workers-") ? [name, "--gc-stress"] : [name], print(line) { record(line); }, @@ -42,7 +42,7 @@ if (status !== 0) { throw new Error(`module exited with status ${status}: ${output.join("\n")}`); } - if (!output.some(line => /wasm (workers|hardening|filesystem) ok/.test(line))) { + if (!output.some(line => /wasm (workers|hardening|filesystem|js workers) ok/.test(line))) { throw new Error(`module produced no success marker: ${output.join("\n")}`); } body.dataset.result = "pass"; diff --git a/runtime/_patch/syscall/fs_js_wasm_workers.go b/runtime/_patch/syscall/fs_js_wasm_workers.go index b80fc9cdb7..9f0f17899b 100644 --- a/runtime/_patch/syscall/fs_js_wasm_workers.go +++ b/runtime/_patch/syscall/fs_js_wasm_workers.go @@ -10,9 +10,9 @@ import ( //go:linkname llgoHostJSGlobal syscall/js.GlobalForHost func llgoHostJSGlobal() js.Value -// Node objects are Emscripten emval handles owned by one JavaScript worker. -// The upstream syscall implementation reads these names directly, so proxy -// their methods through handles initialized on first use in each worker. +// The upstream syscall implementation reads these names directly. Initialize +// each worker's cached handles on first use; syscall/js routes their operations +// through Go's main JavaScript realm. // Integer flags and the Go file table remain process-wide. type workerJSHostValue uint8 diff --git a/runtime/_test/locality/locality.go b/runtime/_test/locality/locality.go index f5856590f7..950cd0a0bc 100644 --- a/runtime/_test/locality/locality.go +++ b/runtime/_test/locality/locality.go @@ -30,14 +30,10 @@ import ( "github.com/xgo-dev/llgo/runtime/_test/locality/testdata/localityscope" ) -//go:linkname schedulerMultiplexesGoroutinesForTesting github.com/xgo-dev/llgo/runtime/internal/runtime.SchedulerMultiplexesGoroutinesForTesting -func schedulerMultiplexesGoroutinesForTesting() bool - -var initializerSequence int +var initializerSequence int32 func nextLocalValue(base int) int { - initializerSequence++ - return base + initializerSequence + return base + int(atomic.AddInt32(&initializerSequence, 1)) } //llgointernal:tls @@ -77,11 +73,13 @@ func TestTLSAndGLSIsolation(t *testing.T) { tlsCounter = 11 glsCounter = 22 parentSet := snapshotLocality() + parentTLSAddress := &tlsCounter type childResult struct { - first localitySnapshot - again localitySnapshot - set localitySnapshot + first localitySnapshot + again localitySnapshot + set localitySnapshot + tlsAddress *int } done := make(chan childResult) go func() { @@ -89,12 +87,14 @@ func TestTLSAndGLSIsolation(t *testing.T) { again := snapshotLocality() tlsCounter = 31 glsCounter = 32 - done <- childResult{first: first, again: again, set: snapshotLocality()} + done <- childResult{first: first, again: again, set: snapshotLocality(), tlsAddress: &tlsCounter} }() child := <-done - multiplexed := schedulerMultiplexesGoroutinesForTesting() - if multiplexed { + // A bounded scheduler can place the child on the same or another worker. + // TLS is shared only when both Gs use the same physical TLS address. + sameWorker := child.tlsAddress == parentTLSAddress + if sameWorker { if child.first.tlsCounter != parentSet.tlsCounter || child.first.glsCounter != 0 { t.Fatalf("multiplexed child locality = %+v, parent = %+v", child.first, parentSet) } @@ -104,7 +104,7 @@ func TestTLSAndGLSIsolation(t *testing.T) { if child.first != child.again { t.Fatalf("child initializer ran more than once: first=%+v again=%+v", child.first, child.again) } - if multiplexed { + if sameWorker { if child.first.initializedTLS != parentInitial.initializedTLS || child.first.initializedGLS == parentInitial.initializedGLS { t.Fatalf("multiplexed child initialization: parent=%+v child=%+v", parentInitial, child.first) } @@ -115,7 +115,7 @@ func TestTLSAndGLSIsolation(t *testing.T) { t.Fatalf("child local writes were lost: %+v", child.set) } wantParent := parentSet - if multiplexed { + if sameWorker { wantParent.tlsCounter = child.set.tlsCounter } if got := snapshotLocality(); got != wantParent { @@ -200,6 +200,10 @@ func TestRecursiveInitializerObservesPartialValue(t *testing.T) { } } +// Count initialization on this physical worker, rather than aggregating other +// workers that may have initialized the same TLS group earlier. +// +//llgointernal:tls var lateInitializerAttempts int func nextLateValue() int { @@ -449,6 +453,7 @@ func TestCrossPackageMixedInitializerGroup(t *testing.T) { address *int addressStable bool calls int + tlsAddress *int } done := make(chan result) go func() { @@ -460,18 +465,18 @@ func TestCrossPackageMixedInitializerGroup(t *testing.T) { address: address, addressStable: address == localityscope.MixedScalarAddress(), calls: localityscope.MixedCalls(), + tlsAddress: &tlsCounter, } }() got := <-done - // TLS belongs to the worker, unlike the GLS groups tested above. Logical - // goroutines multiplexed on one worker must reuse its initialized block. - multiplexed := schedulerMultiplexesGoroutinesForTesting() + // Check package-block TLS against an independent native TLS address. + sameWorker := got.tlsAddress == &tlsCounter wantCalls := before + 1 - if multiplexed { + if sameWorker { wantCalls = before } if got.scalar == 0 || got.pointer == nil || !got.addressStable || got.calls != wantCalls || - (got.address == parentAddress) != multiplexed { + (got.address == parentAddress) != sameWorker { t.Fatalf("cross-package mixed initializer = %+v, baseline %d", got, before) } } diff --git a/runtime/internal/lib/runtime/mfinal_lock_wasm_other.go b/runtime/internal/lib/runtime/mfinal_lock_wasm_other.go new file mode 100644 index 0000000000..3f327c6446 --- /dev/null +++ b/runtime/internal/lib/runtime/mfinal_lock_wasm_other.go @@ -0,0 +1,19 @@ +//go:build wasm && llgo.wasm.gc.linear && (!llgo || !js || !llgo.wasm.workers) + +package runtime + +import psync "github.com/xgo-dev/llgo/runtime/internal/sync" + +type wasmFinalizerMutex = psync.Mutex + +var wasmFinalizerOnce psync.Once + +func initWasmFinalizers() { + wasmFinalizers.mu.Init(nil) + wasmFinalizers.m = make(map[uintptr]*wasmFinalizerEntry) +} + +func lockWasmFinalizers() { + wasmFinalizerOnce.Do(initWasmFinalizers) + wasmFinalizers.mu.Lock() +} diff --git a/runtime/internal/lib/runtime/mfinal_lock_wasm_workers.go b/runtime/internal/lib/runtime/mfinal_lock_wasm_workers.go new file mode 100644 index 0000000000..995795e7de --- /dev/null +++ b/runtime/internal/lib/runtime/mfinal_lock_wasm_workers.go @@ -0,0 +1,15 @@ +//go:build llgo && js && wasm && llgo.wasm.workers && llgo.wasm.gc.linear + +package runtime + +type wasmFinalizerMutex = wasmSemaMutex + +func lockWasmFinalizers() { + // Map updates and collector cancellation can allocate or wait for the GC + // lock. A contending worker must remain able to acknowledge an STW request + // instead of blocking in pthread_mutex_lock while the owner is stopped. + wasmFinalizers.mu.Lock() + if wasmFinalizers.m == nil { + wasmFinalizers.m = make(map[uintptr]*wasmFinalizerEntry) + } +} diff --git a/runtime/internal/lib/runtime/mfinal_wasm.go b/runtime/internal/lib/runtime/mfinal_wasm.go index 74f01daddc..90100a755b 100644 --- a/runtime/internal/lib/runtime/mfinal_wasm.go +++ b/runtime/internal/lib/runtime/mfinal_wasm.go @@ -8,7 +8,6 @@ import ( "github.com/xgo-dev/llgo/runtime/abi" llruntime "github.com/xgo-dev/llgo/runtime/internal/runtime" "github.com/xgo-dev/llgo/runtime/internal/runtime/tinygogc" - psync "github.com/xgo-dev/llgo/runtime/internal/sync" ) type wasmFinalizerInterfaceArg struct { @@ -41,14 +40,8 @@ const ( ) var wasmFinalizers struct { - once psync.Once - mu psync.Mutex - m map[uintptr]*wasmFinalizerEntry -} - -func initWasmFinalizers() { - wasmFinalizers.mu.Init(nil) - wasmFinalizers.m = make(map[uintptr]*wasmFinalizerEntry) + mu wasmFinalizerMutex + m map[uintptr]*wasmFinalizerEntry } // SetFinalizer implements the Go finalizer contract for the linear-memory @@ -68,9 +61,8 @@ func SetFinalizer(obj any, finalizer any) { throw("runtime.SetFinalizer: first argument is nil") } - wasmFinalizers.once.Do(initWasmFinalizers) key := ^uintptr(objPtr) - wasmFinalizers.mu.Lock() + lockWasmFinalizers() if old := wasmFinalizers.m[key]; old != nil { delete(wasmFinalizers.m, key) old.cancelCollector() @@ -103,7 +95,7 @@ func SetFinalizer(obj any, finalizer any) { resultSize: resultSize, } cancel, registered := tinygogc.AddFinalizer(objPtr, func(ptr unsafe.Pointer) { - wasmFinalizers.mu.Lock() + lockWasmFinalizers() if wasmFinalizers.m[key] != entry { wasmFinalizers.mu.Unlock() return @@ -117,7 +109,7 @@ func SetFinalizer(obj any, finalizer any) { } entry.cancelCollector = cancel - wasmFinalizers.mu.Lock() + lockWasmFinalizers() wasmFinalizers.m[key] = entry wasmFinalizers.mu.Unlock() KeepAlive(obj) diff --git a/runtime/internal/lib/syscall/js/callback_poll_workers.go b/runtime/internal/lib/syscall/js/callback_poll_workers.go index 2c74c27574..f7df2ff111 100644 --- a/runtime/internal/lib/syscall/js/callback_poll_workers.go +++ b/runtime/internal/lib/syscall/js/callback_poll_workers.go @@ -2,6 +2,6 @@ package js -// The hook polls all JavaScript worker realms. A callback on one worker must -// not disable polling while another worker still has registered functions. +// Keep the shared poll hook installed while worker schedulers can enter it. +// Registered functions and emval handles are owned by Go's main JS worker. const keepWasmCallbackPoll = true diff --git a/runtime/internal/lib/syscall/js/emval.go b/runtime/internal/lib/syscall/js/emval.go index 2cb89cf9ae..8131709f67 100644 --- a/runtime/internal/lib/syscall/js/emval.go +++ b/runtime/internal/lib/syscall/js/emval.go @@ -9,11 +9,11 @@ import ( c "github.com/xgo-dev/llgo/runtime/internal/clite" _ "github.com/xgo-dev/llgo/runtime/internal/embind" - llruntime "github.com/xgo-dev/llgo/runtime/internal/runtime" ) // Emscripten emval handles belong to the JavaScript realm of the physical -// worker that created them. +// worker that created them. Public syscall/js operations use Go's main worker, +// including when their Value is held by a G running on another worker. // //llgointernal:tls var ( @@ -37,7 +37,6 @@ var ( ) func ensureEmvalGlobals() { - llruntime.MarkCurrentJSRealm() initEmvalGlobals() } diff --git a/runtime/internal/lib/syscall/js/emval_release_single.go b/runtime/internal/lib/syscall/js/emval_release_single.go index d201f460a8..741bc5da87 100644 --- a/runtime/internal/lib/syscall/js/emval_release_single.go +++ b/runtime/internal/lib/syscall/js/emval_release_single.go @@ -1,4 +1,4 @@ -//go:build js && wasm && !llgo.wasm.workers +//go:build js && wasm && (!llgo || !llgo.wasm.workers) package js diff --git a/runtime/internal/lib/syscall/js/func.go b/runtime/internal/lib/syscall/js/func.go index 055761a75c..bdc0fd4cd6 100644 --- a/runtime/internal/lib/syscall/js/func.go +++ b/runtime/internal/lib/syscall/js/func.go @@ -8,24 +8,22 @@ package js import ( - "sync" - "github.com/xgo-dev/llgo/runtime/internal/clite" llruntime "github.com/xgo-dev/llgo/runtime/internal/runtime" ) -// Emscripten callback IDs and emval queues belong to one JavaScript worker. +// Emscripten callback IDs and emval queues belong to Go's main JS worker. // //llgointernal:tls var ( - funcsMu sync.Mutex + funcsMu funcMutex funcs map[uint32]func(Value, []Value) any nextFuncID uint32 activeCallbacks uint32 ) var callbackPoll struct { - sync.Mutex + funcMutex registered bool } @@ -51,20 +49,25 @@ type Func struct { // external events resume the scheduler and run on a new G. A callback may // block on Go goroutines or timers, but must not wait for another asynchronous // JS event: its caller still owns the JS event loop, as in the Go runtime. +// In bounded-worker builds, fn executes on Go's main JS worker even if the +// Func is created or invoked by a G on another worker. // // Func.Release must be called to free up resources when the function will not be invoked any more. func FuncOf(fn func(this Value, args []Value) any) Func { + if isRemoteJSWorker() { + return remoteFuncOf(fn) + } + ensureEmvalGlobals() + // Install the bridge and poll before entering the registry critical + // section: host setup and poll registration can suspend this G. + emval_install_invoke() + ensureCallbackPoll() funcsMu.Lock() if funcs == nil { funcs = make(map[uint32]func(Value, []Value) any) nextFuncID = 1 } - // Each JavaScript worker has its own Module. Install _llgo_invoke on - // this worker before wrapping; a process-wide C++ guard would leave - // later workers with a missing invoke. - emval_install_invoke() - ensureCallbackPoll() id := nextFuncID nextFuncID++ funcs[id] = fn @@ -103,9 +106,16 @@ func itoa(buf []byte, val uint64) []byte { // The function must not be invoked after calling Release. // It is allowed to call Release while the function is still running. func (c Func) Release() { + if isRemoteJSWorker() { + remoteRelease(c) + return + } + funcsMu.Lock() delete(funcs, c.id) - stopCallbackPollLocked() + if !keepWasmCallbackPoll { + stopCallbackPollLocked() + } funcsMu.Unlock() } @@ -151,7 +161,6 @@ func dispatchCallback(handle uintptr, owner int) { if current := llruntime.SchedulerProcID(); current != owner { panic("syscall/js: callback dispatched outside its JavaScript realm") } - llruntime.MarkCurrentJSRealm() defer cEmvalDecref(handle) cb := Value{ref: ref(handle)} id := uint32(cb.Get("id").Int()) diff --git a/runtime/internal/lib/syscall/js/func_mutex_other.go b/runtime/internal/lib/syscall/js/func_mutex_other.go new file mode 100644 index 0000000000..52f6e8a7c9 --- /dev/null +++ b/runtime/internal/lib/syscall/js/func_mutex_other.go @@ -0,0 +1,7 @@ +//go:build js && wasm && (!llgo || !llgo.wasm.workers) + +package js + +import "sync" + +type funcMutex = sync.Mutex diff --git a/runtime/internal/lib/syscall/js/func_mutex_workers.go b/runtime/internal/lib/syscall/js/func_mutex_workers.go new file mode 100644 index 0000000000..6055368e49 --- /dev/null +++ b/runtime/internal/lib/syscall/js/func_mutex_workers.go @@ -0,0 +1,19 @@ +//go:build llgo && js && wasm && llgo.wasm.workers + +package js + +import ( + llruntime "github.com/xgo-dev/llgo/runtime/internal/runtime" + "github.com/xgo-dev/llgo/runtime/internal/wasmsync" +) + +// Registry and poll-registration sections contain no G suspension. Keep +// Unlock free of the entry safepoint in sync.Mutex.Unlock: preempting a holder +// strands synchronous callbacks behind unrelated JS callers on this worker. +type funcMutex struct{ mutex wasmsync.Mutex } + +//go:nosplit +func (m *funcMutex) Lock() { m.mutex.Lock(llruntime.CooperativeSafepoint) } + +//go:nosplit +func (m *funcMutex) Unlock() { m.mutex.Unlock() } diff --git a/runtime/internal/lib/syscall/js/js.go b/runtime/internal/lib/syscall/js/js.go index 576ebc1ddd..2bc78d00ad 100644 --- a/runtime/internal/lib/syscall/js/js.go +++ b/runtime/internal/lib/syscall/js/js.go @@ -79,6 +79,10 @@ var ( // Equal reports whether v and w are equal according to JavaScript's === operator. func (v Value) Equal(w Value) bool { + if isRemoteJSWorker() { + return remoteEqual(v, w) + } + ensureEmvalGlobals() return emval_equals(v, w) && v.ref != valueNaN.ref } @@ -105,21 +109,31 @@ func (v Value) IsNull() bool { // IsNaN reports whether v is the JavaScript value "NaN". func (v Value) IsNaN() bool { + if isRemoteJSWorker() { + return remoteIsNaN(v) + } + ensureEmvalGlobals() return v.ref == valueNaN.ref } // Global returns the JavaScript global object, usually "window" or "global". func Global() Value { + if isRemoteJSWorker() { + return remoteGlobal() + } + ensureEmvalGlobals() return valueGlobal } -// GlobalForHost is used by LLGo's worker-local filesystem adapter during -// package initialization and lazy initialization on other workers. Its handles -// stay in TLS and are never handed to a child goroutine, so this setup must -// not pin the current goroutine to one realm. +// GlobalForHost initializes LLGo's filesystem adapter. Like Global, its handles +// belong to Go's main JS worker. func GlobalForHost() Value { + if isRemoteJSWorker() { + return remoteGlobalForHost() + } + initEmvalGlobals() return valueGlobal } @@ -139,6 +153,10 @@ func GlobalForHost() Value { // // Panics if x is not one of the expected types. func ValueOf(x any) Value { + if isRemoteJSWorker() { + return remoteValueOf(x) + } + ensureEmvalGlobals() switch x := x.(type) { case Value: @@ -248,6 +266,7 @@ func (t Type) isObject() bool { // Type returns the JavaScript type of the value v. It is similar to JavaScript's typeof operator, // except that it returns TypeNull instead of TypeObject for null. func (v Value) Type() Type { + // Reserved refs are valid in every realm; resolve them before remote dispatch. if v.ref == 0 { return TypeUndefined } @@ -259,6 +278,10 @@ func (v Value) Type() Type { case valueTrue.ref, valueFalse.ref: return TypeBoolean } + if isRemoteJSWorker() { + return remoteType(v) + } + if emval_is_number(v) { return TypeNumber } else if emval_is_string(v) { @@ -279,6 +302,10 @@ func (v Value) Type() Type { // Get returns the JavaScript property p of value v. // It panics if v is not a JavaScript object. func (v Value) Get(p string) Value { + if isRemoteJSWorker() { + return remoteGet(v, p) + } + if vType := v.Type(); !vType.isObject() { panic(&ValueError{"Value.Get", vType}) } @@ -291,6 +318,11 @@ func (v Value) Get(p string) Value { // Set sets the JavaScript property p of value v to ValueOf(x). // It panics if v is not a JavaScript object. func (v Value) Set(p string, x any) { + if isRemoteJSWorker() { + remoteSet(v, p, x) + return + } + if vType := v.Type(); !vType.isObject() { panic(&ValueError{"Value.Set", vType}) } @@ -313,6 +345,11 @@ func (v Value) Set(p string, x any) { // Delete deletes the JavaScript property p of value v. // It panics if v is not a JavaScript object. func (v Value) Delete(p string) { + if isRemoteJSWorker() { + remoteDelete(v, p) + return + } + if vType := v.Type(); !vType.isObject() { panic(&ValueError{"Value.Delete", vType}) } @@ -333,6 +370,10 @@ func (v Value) Delete(p string) { // Index returns JavaScript index i of value v. // It panics if v is not a JavaScript object. func (v Value) Index(i int) Value { + if isRemoteJSWorker() { + return remoteIndex(v, i) + } + if vType := v.Type(); !vType.isObject() { panic(&ValueError{"Value.Index", vType}) } @@ -348,6 +389,11 @@ func (v Value) Index(i int) Value { // SetIndex sets the JavaScript index i of value v to ValueOf(x). // It panics if v is not a JavaScript object. func (v Value) SetIndex(i int, x any) { + if isRemoteJSWorker() { + remoteSetIndex(v, i, x) + return + } + if vType := v.Type(); !vType.isObject() { panic(&ValueError{"Value.SetIndex", vType}) } @@ -396,6 +442,10 @@ func (v Value) SetIndex(i int, x any) { // Length returns the JavaScript property "length" of v. // It panics if v is not a JavaScript object. func (v Value) Length() int { + if isRemoteJSWorker() { + return remoteLength(v) + } + if vType := v.Type(); !vType.isObject() { panic(&ValueError{"Value.Length", vType}) } @@ -412,6 +462,10 @@ func (v Value) Length() int { // It panics if v has no method m. // The arguments get mapped to JavaScript values according to the ValueOf function. func (v Value) Call(m string, args ...any) (res Value) { + if isRemoteJSWorker() { + return remoteCall(v, m, args...) + } + var err c.Int if len(args) == 0 { res = emval_method_call(v, c.AllocaCStr(m), c.SizeT(len(m)), nil, 0, &err) @@ -465,6 +519,10 @@ func (v Value) Call(m string, args ...any) (res Value) { // It panics if v is not a JavaScript function. // The arguments get mapped to JavaScript values according to the ValueOf function. func (v Value) Invoke(args ...any) (res Value) { + if isRemoteJSWorker() { + return remoteInvoke(v, args...) + } + var err c.Int if len(args) == 0 { res = emval_call(v, nil, 0, 0, &err) @@ -511,6 +569,10 @@ func (v Value) Invoke(args ...any) (res Value) { // It panics if v is not a JavaScript function. // The arguments get mapped to JavaScript values according to the ValueOf function. func (v Value) New(args ...any) (res Value) { + if isRemoteJSWorker() { + return remoteNew(v, args...) + } + var err c.Int if len(args) == 0 { res = emval_call(v, nil, 0, 1, &err) @@ -560,6 +622,10 @@ func (v Value) isNumber() bool { } func (v Value) float(method string) float64 { + if isRemoteJSWorker() { + return remoteFloat(v, method) + } + if !v.isNumber() { panic(&ValueError{method, v.Type()}) } @@ -599,6 +665,10 @@ func (v Value) Bool() bool { // false, 0, "", null, undefined, and NaN are "falsy", and everything else is // "truthy". See https://developer.mozilla.org/en-US/docs/Glossary/Truthy. func (v Value) Truthy() bool { + if isRemoteJSWorker() { + return remoteTruthy(v) + } + ensureEmvalGlobals() switch v.Type() { case TypeUndefined, TypeNull: @@ -621,6 +691,10 @@ func (v Value) Truthy() bool { // it does not panic if v's Type is not TypeString. Instead, it returns a string of the form "" // or "" where T is v's type and V is a string representation of v's value. func (v Value) String() string { + if isRemoteJSWorker() { + return remoteString(v) + } + switch v.Type() { case TypeString: return jsString(v) @@ -667,6 +741,10 @@ func jsString(v Value) string { // InstanceOf reports whether v is an instance of type t according to JavaScript's instanceof operator. func (v Value) InstanceOf(t Value) bool { + if isRemoteJSWorker() { + return remoteInstanceOf(v, t) + } + return emval_instanceof(v, t) // r := valueInstanceOf(v.ref, t.ref) // runtime.KeepAlive(v) @@ -693,6 +771,10 @@ func (e *ValueError) Error() string { // It panics if src is not a Uint8Array or Uint8ClampedArray. // It returns the number of bytes copied, which will be the minimum of the lengths of src and dst. func CopyBytesToGo(dst []byte, src Value) int { + if isRemoteJSWorker() { + return remoteCopyBytesToGo(dst, src) + } + n, ok := emval_copy_bytes(dst, src, true) if !ok { panic("syscall/js: CopyBytesToGo: expected src to be a Uint8Array or Uint8ClampedArray") @@ -736,6 +818,10 @@ func CopyBytesToGo(dst []byte, src Value) int { // It panics if dst is not a Uint8Array or Uint8ClampedArray. // It returns the number of bytes copied, which will be the minimum of the lengths of src and dst. func CopyBytesToJS(dst Value, src []byte) int { + if isRemoteJSWorker() { + return remoteCopyBytesToJS(dst, src) + } + n, ok := emval_copy_bytes(src, dst, false) if !ok { panic("syscall/js: CopyBytesToJS: expected dst to be a Uint8Array or Uint8ClampedArray") diff --git a/runtime/internal/lib/syscall/js/worker_call.go b/runtime/internal/lib/syscall/js/worker_call.go new file mode 100644 index 0000000000..6648c2b7b2 --- /dev/null +++ b/runtime/internal/lib/syscall/js/worker_call.go @@ -0,0 +1,15 @@ +//go:build llgo && js && wasm && llgo.wasm.workers + +package js + +import llruntime "github.com/xgo-dev/llgo/runtime/internal/runtime" + +// The forwarding helpers are separate, non-inlined calls so their captured +// variables are allocated only on remote workers. +func isRemoteJSWorker() bool { + return llruntime.SchedulerProcID() != 0 +} + +func onJSWorker(fn func()) bool { + return llruntime.CallMainJSWorker(fn) +} diff --git a/runtime/internal/lib/syscall/js/worker_call_single.go b/runtime/internal/lib/syscall/js/worker_call_single.go new file mode 100644 index 0000000000..3c73555009 --- /dev/null +++ b/runtime/internal/lib/syscall/js/worker_call_single.go @@ -0,0 +1,7 @@ +//go:build js && wasm && (!llgo || !llgo.wasm.workers) + +package js + +func isRemoteJSWorker() bool { return false } + +func onJSWorker(func()) bool { return false } diff --git a/runtime/internal/lib/syscall/js/worker_calls.go b/runtime/internal/lib/syscall/js/worker_calls.go new file mode 100644 index 0000000000..75db7f0d09 --- /dev/null +++ b/runtime/internal/lib/syscall/js/worker_calls.go @@ -0,0 +1,161 @@ +//go:build js && wasm + +package js + +// Keep forwarding closures and their captured result slots out of the local +// call frame. Otherwise SSA hoists their heap allocations before the worker +// check, even when the call never leaves worker zero. + +//go:noinline +func remoteEqual(v Value, w Value) (result bool) { + onJSWorker(func() { result = v.Equal(w) }) + return +} + +//go:noinline +func remoteIsNaN(v Value) (result bool) { + onJSWorker(func() { result = v.IsNaN() }) + return +} + +//go:noinline +func remoteGlobal() (result Value) { + onJSWorker(func() { result = Global() }) + return +} + +//go:noinline +func remoteGlobalForHost() (result Value) { + onJSWorker(func() { result = GlobalForHost() }) + return +} + +//go:noinline +func remoteValueOf(x any) Value { + // These conversions copy existing values or reserved handles; they do not + // access the owning realm. Keep request allocations in the host-only helper. + switch x := x.(type) { + case Value: + return x + case Func: + return x.Value + case nil: + return valueNull + case bool: + if x { + return valueTrue + } + return valueFalse + } + return forwardValueOf(x) +} + +//go:noinline +func forwardValueOf(x any) (result Value) { + onJSWorker(func() { result = ValueOf(x) }) + return +} + +//go:noinline +func remoteType(v Value) (result Type) { + onJSWorker(func() { result = v.Type() }) + return +} + +//go:noinline +func remoteGet(v Value, p string) (result Value) { + onJSWorker(func() { result = v.Get(p) }) + return +} + +//go:noinline +func remoteSet(v Value, p string, x any) { + onJSWorker(func() { v.Set(p, x) }) +} + +//go:noinline +func remoteDelete(v Value, p string) { + onJSWorker(func() { v.Delete(p) }) +} + +//go:noinline +func remoteIndex(v Value, i int) (result Value) { + onJSWorker(func() { result = v.Index(i) }) + return +} + +//go:noinline +func remoteSetIndex(v Value, i int, x any) { + onJSWorker(func() { v.SetIndex(i, x) }) +} + +//go:noinline +func remoteLength(v Value) (result int) { + onJSWorker(func() { result = v.Length() }) + return +} + +//go:noinline +func remoteCall(v Value, m string, args ...any) (res Value) { + onJSWorker(func() { res = v.Call(m, args...) }) + return +} + +//go:noinline +func remoteInvoke(v Value, args ...any) (res Value) { + onJSWorker(func() { res = v.Invoke(args...) }) + return +} + +//go:noinline +func remoteNew(v Value, args ...any) (res Value) { + onJSWorker(func() { res = v.New(args...) }) + return +} + +//go:noinline +func remoteFloat(v Value, method string) (result float64) { + onJSWorker(func() { result = v.float(method) }) + return +} + +//go:noinline +func remoteTruthy(v Value) (result bool) { + onJSWorker(func() { result = v.Truthy() }) + return +} + +//go:noinline +func remoteString(v Value) (result string) { + onJSWorker(func() { result = v.String() }) + return +} + +//go:noinline +func remoteInstanceOf(v Value, t Value) (result bool) { + onJSWorker(func() { result = v.InstanceOf(t) }) + return +} + +//go:noinline +func remoteCopyBytesToGo(dst []byte, src Value) (result int) { + onJSWorker(func() { result = CopyBytesToGo(dst, src) }) + return +} + +//go:noinline +func remoteCopyBytesToJS(dst Value, src []byte) (result int) { + onJSWorker(func() { result = CopyBytesToJS(dst, src) }) + return +} + +//go:noinline +func remoteFuncOf(fn func(this Value, args []Value) any) (result Func) { + onJSWorker(func() { result = FuncOf(fn) }) + return +} + +//go:noinline +func remoteRelease(c Func) { + onJSWorker(func() { c.Release() }) +} diff --git a/runtime/internal/runqueue/runqueue.go b/runtime/internal/runqueue/runqueue.go index 1e6f32c57c..18674f49bd 100644 --- a/runtime/internal/runqueue/runqueue.go +++ b/runtime/internal/runqueue/runqueue.go @@ -70,6 +70,46 @@ func (q *Queue[T]) Pop() T { return node } +// Remove takes a particular node without changing the order of other nodes. +func (q *Queue[T]) Remove(node T) T { + var zero T + if node == zero || !node.RunqueueQueued() { + return zero + } + var previous T + for current := q.head; current != zero; current = current.RunqueueNext() { + if current == node { + return q.RemoveAfter(previous) + } + previous = current + } + return zero +} + +// RemoveAfter removes the node following previous in constant time. The caller +// must obtain previous by traversing q; zero means remove the front node. +func (q *Queue[T]) RemoveAfter(previous T) T { + var zero T + if previous == zero { + return q.Pop() + } + node := previous.RunqueueNext() + if node == zero { + return zero + } + previous.SetRunqueueNext(node.RunqueueNext()) + if q.tail == node { + q.tail = previous + } + node.SetRunqueueNext(zero) + node.SetRunqueueQueued(false) + q.size-- + return node +} + func (q *Queue[T]) Len() uintptr { return q.size } + +// Front returns the oldest queued node without removing it. +func (q *Queue[T]) Front() T { return q.head } diff --git a/runtime/internal/runqueue/runqueue_test.go b/runtime/internal/runqueue/runqueue_test.go index 38a9fd5fb7..efcf2a89f3 100644 --- a/runtime/internal/runqueue/runqueue_test.go +++ b/runtime/internal/runqueue/runqueue_test.go @@ -58,3 +58,50 @@ func TestQueueRejectsInvalidNodes(t *testing.T) { t.Fatal("Push accepted nil") } } + +func TestQueueRemovePreservesFIFO(t *testing.T) { + for remove := 0; remove < 3; remove++ { + nodes := [3]*testNode{{value: 0}, {value: 1}, {value: 2}} + var q Queue[*testNode] + for _, node := range nodes { + q.Push(node) + } + if q.Remove(nodes[remove]) != nodes[remove] || q.Len() != 2 { + t.Fatalf("Remove(%d) did not detach the node", remove) + } + if q.Remove(nodes[remove]) != nil || !q.Push(nodes[remove]) { + t.Fatalf("removed node %d could not be reused", remove) + } + for i, node := range nodes { + if i != remove && q.Pop() != node { + t.Fatalf("Remove(%d) changed FIFO order", remove) + } + } + if q.Pop() != nodes[remove] || q.Pop() != nil || q.Len() != 0 { + t.Fatalf("Remove(%d) broke the tail or length", remove) + } + } + var q, other Queue[*testNode] + node := &testNode{} + other.Push(node) + if q.Remove(nil) != nil || q.Remove(node) != nil || other.Pop() != node { + t.Fatal("Remove accepted a node outside the queue") + } +} + +func TestQueueRemoveAfterCursor(t *testing.T) { + first, second, third := &testNode{}, &testNode{}, &testNode{} + var q Queue[*testNode] + q.Push(first) + q.Push(second) + q.Push(third) + if q.RemoveAfter(third) != nil || q.Len() != 3 { + t.Fatal("removal after the tail changed the queue") + } + if q.RemoveAfter(first) != second || !q.Push(second) || q.RemoveAfter(first) != third { + t.Fatal("cursor removal broke node reuse or FIFO order") + } + if q.RemoveAfter(nil) != first || q.RemoveAfter(nil) != second || q.RemoveAfter(nil) != nil || q.Len() != 0 { + t.Fatal("front removal broke the head, tail or length") + } +} diff --git a/runtime/internal/runtime/host_events_js.go b/runtime/internal/runtime/host_events_js.go index 80ac2dc840..a2b836e96a 100644 --- a/runtime/internal/runtime/host_events_js.go +++ b/runtime/internal/runtime/host_events_js.go @@ -6,8 +6,6 @@ package runtime -func MarkCurrentJSRealm() {} - // Adapted from Go's runtime/lock_js.go handleEvent and beforeIdle. An event // must return on its original G, after its handler and other runnable work // complete. Only the scheduler primitives differ from the Go implementation. diff --git a/runtime/internal/runtime/host_events_js_workers.go b/runtime/internal/runtime/host_events_js_workers.go index a878a53509..12e17c16c9 100644 --- a/runtime/internal/runtime/host_events_js_workers.go +++ b/runtime/internal/runtime/host_events_js_workers.go @@ -8,20 +8,14 @@ import ( "github.com/xgo-dev/llgo/runtime/internal/wasmworkers" ) -// MarkCurrentJSRealm keeps descendants of a goroutine using syscall/js in -// the same Emscripten realm. Emval handles are local to that realm. -func MarkCurrentJSRealm() { - if gp := getg(); gp != nil { - gp.context.platform.jsRealm = true - } -} - // A synchronous JavaScript callback resumes the same goroutine that entered // its JavaScript call. Keep the event stack per physical worker: the callback // can temporarily park while other goroutines satisfy a channel operation. type wasmJSEvent struct { gp *g + id uint32 returned bool + resume bool } type wasmExternalJSEvent struct { @@ -74,23 +68,33 @@ func HandleWasmEvent(handler func()) { suspendWasmWorkerGCSystem(worker) return } - e := &wasmJSEvent{gp: getg()} + worker.nextJSEventID++ + if worker.nextJSEventID == 0 { + worker.nextJSEventID++ + } + gp := getg() + // Give a newly entered handler one resumption before unrelated JS callers + // can open more native callback stacks. Later yields alternate with Go work; + // nested events keep their own resume intent. + e := &wasmJSEvent{gp: gp, id: worker.nextJSEventID, resume: true} + previousCallbackID := gp.context.platform.jsCallbackID + gp.context.platform.jsCallbackID = e.id worker.jsEvents = append(worker.jsEvents, e) handler() e.returned = true gopark() worker.jsEvents[len(worker.jsEvents)-1] = nil worker.jsEvents = worker.jsEvents[:len(worker.jsEvents)-1] + gp.context.platform.jsCallbackID = previousCallbackID } func PollWasmEvent() { worker := currentWasmWorker() - if worker == nil || getg() != nil || len(worker.jsEvents) == 0 || - wasmWorkerRunqLen(worker) != 0 { + if worker == nil || getg() != nil || len(worker.jsEvents) == 0 { return } e := worker.jsEvents[len(worker.jsEvents)-1] - if e.returned { + if e.returned && readgstatus(e.gp) == _Gwaiting { goready(e.gp) } } diff --git a/runtime/internal/runtime/js_worker_call_wasm_workers.go b/runtime/internal/runtime/js_worker_call_wasm_workers.go new file mode 100644 index 0000000000..eb970b50e2 --- /dev/null +++ b/runtime/internal/runtime/js_worker_call_wasm_workers.go @@ -0,0 +1,71 @@ +//go:build llgo && js && wasm && llgo.wasm.workers + +package runtime + +type wasmJSCall struct { + fn func() + done chan struct{} + failure any + returned bool +} + +func HasMainJSWorkerExecutorForTesting() bool { + gp := getg() + return gp != nil && gp.context.platform.jsCalls != nil +} + +// CallMainJSWorker runs fn on worker zero when the caller is on another +// worker. It parks only the calling G, leaving both worker schedulers free +// to process nested calls, callbacks, and GC. False means fn was not called. +func CallMainJSWorker(fn func()) bool { + worker := currentWasmWorker() + if worker == nil || worker.index == 0 { + return false + } + gp := getg() + if gp.context.platform.jsCalls == nil { + calls := make(chan *wasmJSCall) + gp.context.platform.jsCalls = calls + spawnMainJSCall(func() { + for call := range calls { + runMainJSCall(call) + } + }) + } + call := &wasmJSCall{fn: fn, done: make(chan struct{})} + gp.context.platform.jsCalls <- call + <-call.done + if call.failure != nil { + panic(call.failure) + } + if !call.returned { + // Goexit also terminates the executor. Detach it before unwinding the + // caller: a caller's defer may use JS and needs a fresh executor. + calls := gp.context.platform.jsCalls + gp.context.platform.jsCalls = nil + close(calls) + Goexit() + } + return true +} + +// Each calling G has its own executor so a JS callback can wait for another +// G that calls back into this realm. Reusing its fiber avoids allocating a +// native and Asyncify stack for every property access. +func runMainJSCall(call *wasmJSCall) { + defer func() { + call.failure = recover() + call.fn = nil + close(call.done) + }() + call.fn() + call.returned = true +} + +func spawnMainJSCall(fn func()) { + gp := getg() + previous := gp.context.platform.spawnWorker + gp.context.platform.spawnWorker = &wasmMultiSched.workers[0] + defer func() { gp.context.platform.spawnWorker = previous }() + go fn() +} diff --git a/runtime/internal/runtime/proc_wasm_workers.go b/runtime/internal/runtime/proc_wasm_workers.go index 3d39d36bdf..05101dab82 100644 --- a/runtime/internal/runtime/proc_wasm_workers.go +++ b/runtime/internal/runtime/proc_wasm_workers.go @@ -34,9 +34,9 @@ const maxWasmWorkers = 16 func SchedulerMultiplexesGoroutinesForTesting() bool { return true } -// SpawnIndependentWasmG starts work that captures no syscall/js values in a -// worker chosen by the scheduler. Ordinary descendants inherit their -// parent's JS realm because Emscripten emval handles are worker-local. +// SpawnIndependentWasmG starts work on a worker chosen by the scheduler. +// Public syscall/js operations are dispatched to worker zero; raw C TLS and +// Emscripten handles must stay on their physical worker. func SpawnIndependentWasmG(fn func()) { gp := getg() if gp == nil { @@ -44,7 +44,7 @@ func SpawnIndependentWasmG(fn func()) { return } // The go statement below still enters newprocBackend through NewProc. - // Override both inherited realm affinity and callback-event pinning for + // Override callback-event pinning for // exactly that spawn, without changing ordinary descendants of this G. previous := gp.context.platform.independentSpawn gp.context.platform.independentSpawn = true @@ -59,8 +59,10 @@ type runtimeContextPlatform struct { runqNext *g runqQueued bool owner *wasmWorker - jsRealm bool independentSpawn bool + spawnWorker *wasmWorker + jsCallbackID uint32 + jsCalls chan *wasmJSCall // Keep runqQueued inside unsafe.Sizeof(runtimeContext{}) on wasm32. LLVM // aligns the preceding uint64 G fields more strictly than go/types does. layoutEnd [8]byte @@ -79,6 +81,7 @@ type wasmWorker struct { localContext LocalContext pollingCallback bool jsEvents []*wasmJSEvent + nextJSEventID uint32 syncEventDepth int index int safepointBudget pollbudget.Budget @@ -308,9 +311,11 @@ func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, cal var worker *wasmWorker current := currentWasmWorker() independent := callergp != nil && callergp.context.platform.independentSpawn - if callergp == nil || !independent && current != nil && - (current.pollingCallback || len(current.jsEvents) != 0 || current.syncEventDepth != 0 || - callergp != nil && callergp.context.platform.jsRealm) { + if callergp != nil { + worker = callergp.context.platform.spawnWorker + } + if worker == nil && (callergp == nil || !independent && current != nil && + (current.pollingCallback || len(current.jsEvents) != 0 || current.syncEventDepth != 0)) { // Host callbacks carry thread-local JavaScript handles. Dispatch the G // from the same physical worker/JS realm that received the callback. worker = current @@ -319,8 +324,8 @@ func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, cal worker = nextWasmWorker() } gp.context.platform.owner = worker - if callergp != nil && !independent { - gp.context.platform.jsRealm = callergp.context.platform.jsRealm + if worker == current && callergp != nil { + gp.context.platform.jsCallbackID = callergp.context.platform.jsCallbackID } if !initWasmFiber(gp, wasmcontext.Entry(wasmGStart), unsafe.Pointer(gp), stackSize) { releaseG() @@ -374,6 +379,10 @@ func wasmGStart(arg unsafe.Pointer) { } func finishWasmG(gp *g) { + if calls := gp.context.platform.jsCalls; calls != nil { + gp.context.platform.jsCalls = nil + close(calls) + } releaseStartArg(gp) casgstatus(gp, _Grunning, _Gdead) atomic.Add(&wasmMultiSched.active, ^uint32(0)) @@ -449,11 +458,49 @@ func enqueueWasmG(worker *wasmWorker, gp *g) { func popWasmWorkerRunq(worker *wasmWorker) *g { worker.lock.Lock(CooperativeSafepoint) - gp := worker.runq.Pop() + var gp *g + var e *wasmJSEvent + if n := len(worker.jsEvents); n != 0 { + e = worker.jsEvents[n-1] + // Drain the callback's runnable children before returning to JS, without + // opening unrelated host callback stacks. For an active callback, + // alternate with Go work so Gosched still lets other Gs progress. + if e.returned { + gp = popWasmCallbackChild(worker, e) + } + if gp == nil && (e.returned || e.resume) { + gp = worker.runq.Remove(e.gp) + e.resume = false + } + if gp == nil && readgstatus(e.gp) == _Grunnable { + // A yielding callback may need a child G queued behind unrelated + // JS callers. Run that task before opening another host callback stack. + gp = popWasmCallbackChild(worker, e) + e.resume = true + } + } + if gp == nil { + gp = worker.runq.Pop() + if e != nil { + e.resume = true + } + } worker.lock.Unlock() return gp } +// The worker queue lock must be held by the caller. +func popWasmCallbackChild(worker *wasmWorker, event *wasmJSEvent) *g { + var previous *g + for candidate := worker.runq.Front(); candidate != nil; candidate = candidate.RunqueueNext() { + if candidate != event.gp && candidate.context.platform.jsCallbackID == event.id { + return worker.runq.RemoveAfter(previous) + } + previous = candidate + } + return nil +} + func wasmWorkerRunqLen(worker *wasmWorker) uintptr { worker.lock.Lock(CooperativeSafepoint) size := worker.runq.Len() @@ -479,6 +526,7 @@ func waitWasmWorkerRunq(worker *wasmWorker) *g { // the sequence, so the futex wait returns instead of losing the wake. sequence := atomic.Load(&worker.wake) wasmWorkerStopForGC(worker) + PollWasmEvent() if gp := popWasmWorkerRunq(worker); gp != nil { return gp } diff --git a/runtime/internal/wasmworkers/_wrap/workers.c b/runtime/internal/wasmworkers/_wrap/workers.c index fda01d2ef1..7e93123d16 100644 --- a/runtime/internal/wasmworkers/_wrap/workers.c +++ b/runtime/internal/wasmworkers/_wrap/workers.c @@ -42,11 +42,13 @@ static void llgo_wasm_worker_wait_finished( // Emscripten fibers use Asyncify underneath; switching fibers while this // host callback is still active can make its callback frame part of the // saved scheduler continuation. - emscripten_async_call(llgo_wasm_worker_resume, worker, 0); + // An immediate task also unwinds the host stack, without imposing the + // timer's minimum delay on every cross-worker channel handoff. + emscripten_set_immediate(llgo_wasm_worker_resume, worker); } void llgo_wasm_worker_resume_soon(void *worker) { - emscripten_async_call(llgo_wasm_worker_resume, worker, 0); + emscripten_set_immediate(llgo_wasm_worker_resume, worker); } int llgo_wasm_worker_count(void) { diff --git a/runtime/wasmworkers/go_workers.go b/runtime/wasmworkers/go_workers.go index 3133abdda4..cadf67067f 100644 --- a/runtime/wasmworkers/go_workers.go +++ b/runtime/wasmworkers/go_workers.go @@ -7,10 +7,9 @@ package wasmworkers import llruntime "github.com/xgo-dev/llgo/runtime/internal/runtime" // GoIndependent starts fn on a scheduler worker chosen from the configured -// pool. The closure must not carry syscall/js values, Emscripten handles, or -// thread-local C state from its caller. It may create its own values after it -// starts. An ordinary go statement inherits the caller's JavaScript realm -// after the caller has used syscall/js. +// pool. syscall/js values may be shared: their operations run in the shared +// Go main worker's JavaScript realm. The closure must not carry raw Emscripten +// handles or thread-local C state from its caller. func GoIndependent(fn func()) { llruntime.SpawnIndependentWasmG(fn) } diff --git a/test/llgoext/runtime_g_test.go b/test/llgoext/runtime_g_test.go index a637f1fc2e..09ed6a6fba 100644 --- a/test/llgoext/runtime_g_test.go +++ b/test/llgoext/runtime_g_test.go @@ -145,6 +145,7 @@ func TestRuntimeGMPLinks(t *testing.T) { seenG := map[uint64]bool{parent.goid: true} seenM := map[int64]bool{parent.mid: true} + mProcs := map[int64]int32{parent.mid: parent.pid} seenP := map[int32]bool{parent.pid: true} multiplexed := runtimeSchedulerMultiplexesForTesting() for i := 0; i < cap(results); i++ { @@ -157,8 +158,13 @@ func TestRuntimeGMPLinks(t *testing.T) { t.Fatalf("duplicate G id %d", state.goid) } if multiplexed { - if state.mid != parent.mid || state.pid != parent.pid { - t.Fatalf("multiplexed G %d used M/P %d/%d, want %d/%d", state.goid, state.mid, state.pid, parent.mid, parent.pid) + // A worker pool multiplexes Gs over several M/P pairs. Reused + // workers must retain their P, rather than all Gs using parent's M. + if pid, ok := mProcs[state.mid]; ok && state.pid != pid { + t.Fatalf("M %d changed P from %d to %d", state.mid, pid, state.pid) + } + if !seenM[state.mid] && seenP[state.pid] { + t.Fatalf("different Ms shared P %d", state.pid) } } else { if seenM[state.mid] { @@ -170,6 +176,7 @@ func TestRuntimeGMPLinks(t *testing.T) { } seenG[state.goid] = true seenM[state.mid] = true + mProcs[state.mid] = state.pid seenP[state.pid] = true } } diff --git a/test/wasm/js-workers/main.go b/test/wasm/js-workers/main.go new file mode 100644 index 0000000000..636afecff3 --- /dev/null +++ b/test/wasm/js-workers/main.go @@ -0,0 +1,385 @@ +//go:build js && wasm && llgo.wasm.workers + +package main + +import ( + "math" + "runtime" + "syscall/js" + "time" + _ "unsafe" + + "github.com/xgo-dev/llgo/runtime/wasmworkers" +) + +//go:linkname workerID github.com/xgo-dev/llgo/runtime/internal/runtime.SchedulerProcID +func workerID() int + +//go:linkname callMainJSWorker github.com/xgo-dev/llgo/runtime/internal/runtime.CallMainJSWorker +func callMainJSWorker(func()) bool + +//go:linkname hasMainJSWorkerExecutor github.com/xgo-dev/llgo/runtime/internal/runtime.HasMainJSWorkerExecutorForTesting +func hasMainJSWorkerExecutor() bool + +func main() { + testLocalAllocations() + testPortablePrimitives() + println("js workers: finalizers") + testFinalizerContention() + println("js workers: shared values") + testSharedValues() + println("js workers: callbacks") + testCallbacks() + println("js workers: panic and Goexit") + testPanicAndGoexit() + testExecutorLifetime() + println("wasm js workers ok") +} + +func testFinalizerContention() { + done := make(chan int, 8) + for range 8 { + wasmworkers.GoIndependent(func() { + for i := range 32 { + object := new([64]byte) + finalizer := func(*[64]byte) { panic("canceled finalizer ran") } + runtime.SetFinalizer(object, finalizer) + // Replacing and canceling an entry take the collector lock while + // holding the registry lock. Other workers must still reach STW. + runtime.SetFinalizer(object, finalizer) + if i%4 == 0 { + runtime.GC() + } + runtime.SetFinalizer(object, nil) + runtime.KeepAlive(object) + } + done <- 1 + }) + } + for range 8 { + await(done) + } + runtime.GC() +} + +func testLocalAllocations() { + js.Global() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + for range 1000 { + if js.Undefined().Type() != js.TypeUndefined || js.Global().IsUndefined() { + panic("local JavaScript primitives changed") + } + } + runtime.ReadMemStats(&after) + if after.TotalAlloc != before.TotalAlloc { + panic("local JavaScript calls allocated forwarding state") + } +} + +func testPortablePrimitives() { + // Portable conversions must not create a worker-zero executor. Inspect + // this G's executor directly: background timer/finalizer Gs can start here. + time.Sleep(time.Millisecond) + done := make(chan int) + owners := make(map[int]bool) + for range 8 { + waitForGoroutines(2) + wasmworkers.GoIndependent(func() { + inputs := [6]any{nil, true, false, js.Undefined(), js.Null(), js.Func{Value: js.Null()}} + types := [6]js.Type{js.TypeNull, js.TypeBoolean, js.TypeBoolean, js.TypeUndefined, js.TypeNull, js.TypeNull} + if hasMainJSWorkerExecutor() { + panic("new caller already has a JavaScript executor") + } + for i := range 1000 { + index := i % len(inputs) + if js.ValueOf(inputs[index]).Type() != types[index] || (js.Value{}).Type() != js.TypeUndefined { + panic("portable JavaScript primitive changed") + } + } + if hasMainJSWorkerExecutor() { + panic("portable JavaScript primitives created an executor") + } + done <- workerID() + }) + owners[<-done] = true + } + if len(owners) < 2 { + panic("portable primitives did not exercise remote workers") + } +} + +func testSharedValues() { + global := js.Global() + object := js.ValueOf(map[string]any{"value": 42}) + identity := js.Global().Get("Function").New("value", "return value") + arrayConstructor := js.Global().Get("Uint8Array") + progress := make(chan int, 8) + for range 8 { + wasmworkers.GoIndependent(func() { + origin := workerID() + if !js.Global().Equal(global) { + panic("Go workers use different JavaScript globals") + } + for i := range 32 { + alias := identity.Invoke(object) + if !alias.Equal(object) || alias.Get("value").Int() != 42 { + panic("cross-worker call lost object identity") + } + private := js.ValueOf(map[string]any{"index": i, "object": alias}) + if i%4 == 0 { + runtime.GC() + } + if !private.Get("object").Equal(object) || private.Get("index").Int() != i { + panic("worker-private JavaScript value lost its GC root") + } + private.Set("text", "worker") + if private.Get("text").String() != "worker" { + panic("cross-worker property assignment failed") + } + private.Delete("text") + if !private.Get("text").IsUndefined() { + panic("cross-worker property deletion failed") + } + bytes := arrayConstructor.New(3) + if !bytes.InstanceOf(arrayConstructor) || bytes.Length() != 3 { + panic("cross-worker constructor changed its realm") + } + if js.CopyBytesToJS(bytes, []byte{1, 2, 3}) != 3 { + panic("cross-worker byte upload failed") + } + bytes.SetIndex(1, 42) + var copied [3]byte + if js.CopyBytesToGo(copied[:], bytes) != 3 || copied != [3]byte{1, 42, 3} { + panic("cross-worker byte download failed") + } + if !js.ValueOf(math.NaN()).IsNaN() || js.ValueOf(math.NaN()).Truthy() || + js.ValueOf(0).Truthy() || !js.ValueOf(true).Bool() || !js.ValueOf(nil).IsNull() { + panic("cross-worker primitive semantics changed") + } + if workerID() != origin { + panic("JavaScript proxy migrated the caller's C stack") + } + progress <- origin + } + }) + } + owners := make(map[int]bool) + // Each round reports progress; its JS operations and private GC roots must + // complete within the deadline even when the full concurrent batch is slow. + for range 8 * 32 { + owners[await(progress)] = true + } + if len(owners) < 2 { + panic("shared values were not used by distinct Go workers") + } +} + +func testCallbacks() { + println("js workers: yielding callbacks") + testCallbackYield() + println("js workers: synchronous and external callbacks") + progress := make(chan int, 8*3) + gcReady, gcRelease := make(chan int, 8), make(chan struct{}) + object := js.ValueOf(map[string]any{"value": 42}) + callback := js.FuncOf(func(this js.Value, args []js.Value) any { + if workerID() != 0 || !this.Equal(object) || !args[0].Equal(object) { + panic("JavaScript callback lost its realm or arguments") + } + result := make(chan int, 1) + wasmworkers.GoIndependent(func() { + gcReady <- 1 + <-gcRelease + value := object.Get("value").Int() + progress <- 1 + result <- value + }) + return await(result) + args[1].Int() + }) + defer callback.Release() + invoke := js.Global().Get("Function").New("cb", "object", "return cb.call(object, object, 7)") + for range 8 { + wasmworkers.GoIndependent(func() { + if invoke.Invoke(callback, object).Int() != 49 { + panic("nested callback did not return synchronously") + } + progress <- 1 + event := make(chan int, 1) + cb := js.FuncOf(func(js.Value, []js.Value) any { + event <- workerID() + return nil + }) + js.Global().Call("setTimeout", cb, 0) + if await(event) != 0 { + panic("external callback ran outside the shared realm") + } + cb.Release() + progress <- 1 + }) + } + // Collect while all eight synchronous callbacks and their child Gs are + // parked, so every callback's JS roots are tested by the same collection. + // Eight serial collections would charge their cumulative wall time to + // each callback's otherwise independent 30-second result deadline. + for range 8 { + await(gcReady) + } + runtime.GC() + close(gcRelease) + // Bound each completed child JS call, synchronous return and external + // event, rather than charging all concurrent GC/callback work to one timer. + for range 8 * 3 { + await(progress) + } +} + +func testCallbackYield() { + wake := make(chan struct{}) + ready := make(chan struct{}) + // This G predates the callback and does not inherit its callback scope. + go func() { <-wake; close(ready) }() + unrelated := js.FuncOf(func(js.Value, []js.Value) any { + close(wake) + for { + select { + case <-ready: + return true + default: + runtime.Gosched() + } + } + }) + if !unrelated.Invoke().Bool() { + panic("yielding callback starved unrelated Go work") + } + unrelated.Release() + + inner := js.FuncOf(func(_ js.Value, args []js.Value) any { + // Register and release a callback while already inside a native JS + // callback stack, exercising both registry locks during nested work. + nested := js.FuncOf(func(_ js.Value, args []js.Value) any { return args[0] }) + defer nested.Release() + runtime.Gosched() + return nested.Invoke(args[0]).Int() + }) + defer inner.Release() + callback := js.FuncOf(func(_ js.Value, args []js.Value) any { + // An inner callback must not overwrite the outer handler's yield state. + value := inner.Invoke(args[0]).Int() + runtime.Gosched() + ready := make(chan struct{}) + go func() { close(ready) }() + for { + select { + case <-ready: + return value + default: + runtime.Gosched() + } + } + }) + defer callback.Release() + done := make(chan int, 128) + for i := range 128 { + wasmworkers.GoIndependent(func() { + if callback.Invoke(i).Int() != i { + panic("yielding callback lost its synchronous result") + } + done <- i + }) + } + for range 128 { + await(done) + } +} + +func testPanicAndGoexit() { + expected := js.ValueOf(map[string]any{"message": "cross-worker exception"}) + throw := js.Global().Get("Function").New("error", "throw error") + done := make(chan int, 8) + for range 8 { + wasmworkers.GoIndependent(func() { + defer func() { + if expected.Get("message").String() != "cross-worker exception" { + panic("Goexit defer could not use JavaScript") + } + done <- workerID() + }() + func() { + defer func() { + failure, ok := recover().(js.Error) + if !ok || !failure.Value.Equal(expected) { + panic("JavaScript exception lost identity crossing workers") + } + }() + throw.Invoke(expected) + panic("JavaScript throw returned") + }() + if expected.Get("message").String() != "cross-worker exception" { + panic("JS executor did not survive a recovered panic") + } + func() { + defer func() { + if _, ok := recover().(*runtime.PanicNilError); !ok { + panic("forwarded panic(nil) became Goexit") + } + }() + if !callMainJSWorker(func() { panic(nil) }) { + panic(nil) + } + }() + if !callMainJSWorker(runtime.Goexit) { + runtime.Goexit() + } + panic("proxy resumed a goroutine after Goexit") + }) + } + owners := make(map[int]bool) + for range 8 { + owners[await(done)] = true + } + if len(owners) < 2 { + panic("Goexit did not exercise a remote worker") + } +} + +func testExecutorLifetime() { + // Earlier callback/executor Gs may still be retiring after their last + // channel send. Start with only main and the runtime timer G remaining. + waitForGoroutines(2) + baseline := runtime.NumGoroutine() + done := make(chan int, 32) + for range 32 { + wasmworkers.GoIndependent(func() { + if js.Global().Get("Object").Type() != js.TypeFunction { + panic("short-lived caller could not use JavaScript") + } + done <- 1 + }) + } + for range 32 { + await(done) + } + waitForGoroutines(baseline) +} + +func waitForGoroutines(limit int) { + deadline := time.Now().Add(30 * time.Second) + for runtime.NumGoroutine() > limit { + if time.Now().After(deadline) { + panic("JavaScript executor leaked after its caller exited") + } + runtime.Gosched() + } +} + +func await(ch <-chan int) int { + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + select { + case value := <-ch: + return value + case <-timer.C: + panic("cross-worker JavaScript operation timed out") + } +} From b902ba142b3335d4f13463b22d0bcf9f9d0434f5 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 6 Oct 2026 13:33:24 +0800 Subject: [PATCH 02/12] wasm: stop runqueue lock waiters in place for GC --- .../internal/lib/syscall/js/worker_call.go | 6 ++- .../lib/syscall/js/worker_call_single.go | 2 +- .../runtime/js_worker_call_wasm_workers.go | 6 +-- runtime/internal/runtime/proc_wasm_workers.go | 8 ++-- .../runtime/runqueue_wasm_workers_testing.go | 40 +++++++++++++++++++ test/wasm/js-workers/main.go | 6 +++ 6 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 runtime/internal/runtime/runqueue_wasm_workers_testing.go diff --git a/runtime/internal/lib/syscall/js/worker_call.go b/runtime/internal/lib/syscall/js/worker_call.go index 6648c2b7b2..1c9fe22b4c 100644 --- a/runtime/internal/lib/syscall/js/worker_call.go +++ b/runtime/internal/lib/syscall/js/worker_call.go @@ -10,6 +10,8 @@ func isRemoteJSWorker() bool { return llruntime.SchedulerProcID() != 0 } -func onJSWorker(fn func()) bool { - return llruntime.CallMainJSWorker(fn) +func onJSWorker(fn func()) { + if !llruntime.CallMainJSWorker(fn) { + fn() + } } diff --git a/runtime/internal/lib/syscall/js/worker_call_single.go b/runtime/internal/lib/syscall/js/worker_call_single.go index 3c73555009..21931fe4e2 100644 --- a/runtime/internal/lib/syscall/js/worker_call_single.go +++ b/runtime/internal/lib/syscall/js/worker_call_single.go @@ -4,4 +4,4 @@ package js func isRemoteJSWorker() bool { return false } -func onJSWorker(func()) bool { return false } +func onJSWorker(fn func()) { fn() } diff --git a/runtime/internal/runtime/js_worker_call_wasm_workers.go b/runtime/internal/runtime/js_worker_call_wasm_workers.go index eb970b50e2..479f918b5b 100644 --- a/runtime/internal/runtime/js_worker_call_wasm_workers.go +++ b/runtime/internal/runtime/js_worker_call_wasm_workers.go @@ -17,6 +17,9 @@ func HasMainJSWorkerExecutorForTesting() bool { // CallMainJSWorker runs fn on worker zero when the caller is on another // worker. It parks only the calling G, leaving both worker schedulers free // to process nested calls, callbacks, and GC. False means fn was not called. +// Each calling G has its own executor so a JS callback can wait for another +// G that calls back into this realm. Reusing its fiber avoids allocating a +// native and Asyncify stack for every property access. func CallMainJSWorker(fn func()) bool { worker := currentWasmWorker() if worker == nil || worker.index == 0 { @@ -49,9 +52,6 @@ func CallMainJSWorker(fn func()) bool { return true } -// Each calling G has its own executor so a JS callback can wait for another -// G that calls back into this realm. Reusing its fiber avoids allocating a -// native and Asyncify stack for every property access. func runMainJSCall(call *wasmJSCall) { defer func() { call.failure = recover() diff --git a/runtime/internal/runtime/proc_wasm_workers.go b/runtime/internal/runtime/proc_wasm_workers.go index 05101dab82..6473f68fec 100644 --- a/runtime/internal/runtime/proc_wasm_workers.go +++ b/runtime/internal/runtime/proc_wasm_workers.go @@ -446,7 +446,9 @@ func enqueueWasmG(worker *wasmWorker, gp *g) { fatal("runtime: enqueue on nil WebAssembly worker") return } - worker.lock.Lock(CooperativeSafepoint) + // G may already be runnable but not queued. Stop for GC in place: a + // fiber switch here could lose it or reenter its status transition. + worker.lock.Lock(wasmGCAllocatorYield) ok := worker.runq.Push(gp) worker.lock.Unlock() if !ok { @@ -457,7 +459,7 @@ func enqueueWasmG(worker *wasmWorker, gp *g) { } func popWasmWorkerRunq(worker *wasmWorker) *g { - worker.lock.Lock(CooperativeSafepoint) + worker.lock.Lock(wasmGCAllocatorYield) var gp *g var e *wasmJSEvent if n := len(worker.jsEvents); n != 0 { @@ -502,7 +504,7 @@ func popWasmCallbackChild(worker *wasmWorker, event *wasmJSEvent) *g { } func wasmWorkerRunqLen(worker *wasmWorker) uintptr { - worker.lock.Lock(CooperativeSafepoint) + worker.lock.Lock(wasmGCAllocatorYield) size := worker.runq.Len() worker.lock.Unlock() return size diff --git a/runtime/internal/runtime/runqueue_wasm_workers_testing.go b/runtime/internal/runtime/runqueue_wasm_workers_testing.go new file mode 100644 index 0000000000..19ee0fdb1e --- /dev/null +++ b/runtime/internal/runtime/runqueue_wasm_workers_testing.go @@ -0,0 +1,40 @@ +//go:build llgo && js && wasm && llgo.wasm.workers + +package runtime + +import ( + "github.com/xgo-dev/llgo/runtime/internal/pollbudget" + "github.com/xgo-dev/llgo/runtime/internal/sync/atomic" + "github.com/xgo-dev/llgo/runtime/internal/wasmworkers" +) + +// ExerciseWasmRunqueueTransitionForTesting holds this worker's queue from a +// different worker while Gosched transitions the caller to runnable. An +// exhausted budget must not reenter Gosched, and a concurrent GC must stop +// the caller in place until it has actually been inserted into the queue. +func ExerciseWasmRunqueueTransitionForTesting(collect func()) { + gp := getg() + worker := currentWasmWorker() + var held uint32 + previous := gp.context.platform.spawnWorker + gp.context.platform.spawnWorker = worker + go func() {}() // leave runnable work to trigger the preemptive slow path + gp.context.platform.spawnWorker = &wasmMultiSched.workers[1] + go func() { + worker.lock.Lock(nil) + atomic.Store(&held, uint32(1)) + wasmworkers.Wake(&held) + // Give the caller time to reach its contended enqueue before GC. + wasmworkers.Wait(&held, 1, 50_000_000) + collect() + worker.lock.Unlock() + }() + gp.context.platform.spawnWorker = previous + for atomic.Load(&held) == 0 { + wasmworkers.Wait(&held, 0, 1_000_000) + } + budget := worker.safepointBudget + worker.safepointBudget = pollbudget.New(1) + goschedBackend() + worker.safepointBudget = budget +} diff --git a/test/wasm/js-workers/main.go b/test/wasm/js-workers/main.go index 636afecff3..b135f40b39 100644 --- a/test/wasm/js-workers/main.go +++ b/test/wasm/js-workers/main.go @@ -21,9 +21,15 @@ func callMainJSWorker(func()) bool //go:linkname hasMainJSWorkerExecutor github.com/xgo-dev/llgo/runtime/internal/runtime.HasMainJSWorkerExecutorForTesting func hasMainJSWorkerExecutor() bool +//go:linkname queueTransition github.com/xgo-dev/llgo/runtime/internal/runtime.ExerciseWasmRunqueueTransitionForTesting +func queueTransition(func()) + func main() { testLocalAllocations() testPortablePrimitives() + println("js workers: queue transitions") + queueTransition(func() {}) + queueTransition(runtime.GC) println("js workers: finalizers") testFinalizerContention() println("js workers: shared values") From b36b2af7a3d9c14b9c9c6c2d18a7ac4fb158efc5 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 6 Oct 2026 14:04:31 +0800 Subject: [PATCH 03/12] ci: run bounded Wasm workers in a dedicated suite --- .github/workflows/llgo.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index a557984c9c..c02f3c0146 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -872,7 +872,7 @@ jobs: strategy: fail-fast: false matrix: - suite: [runtime, test-command] + suite: [runtime, workers, test-command] runs-on: ${{ fromJSON(needs.prepare.outputs.linux_large_runner) }} steps: - uses: actions/checkout@v7 @@ -893,7 +893,7 @@ jobs: node-version: "24" - name: Set up Chrome for browser acceptance - if: matrix.suite == 'runtime' + if: matrix.suite != 'test-command' id: setup-chrome uses: browser-actions/setup-chrome@v2 with: @@ -903,16 +903,19 @@ jobs: uses: ./.github/actions/setup-binaryen - name: Set up Wasmtime + if: matrix.suite != 'workers' uses: bytecodealliance/actions/wasmtime/setup@v1 with: version: "39.0.1" - name: Restore Wasmer cache + if: matrix.suite != 'workers' uses: ./.github/actions/setup-llgo-cache with: wasmer-cache-mode: readwrite - name: Install Wasmer for W32 + if: matrix.suite != 'workers' run: bash dev/install_wasmer.sh - name: Set up wasm-tools @@ -997,6 +1000,7 @@ jobs: run: dev/wasm_ci_report_test.sh - name: Test single-worker wasm schedulers + if: matrix.suite != 'workers' shell: bash env: CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} @@ -1005,7 +1009,7 @@ jobs: run: dev/test_wasm_single_worker.sh "${{ matrix.suite }}" - name: Test bounded browser workers - if: matrix.suite == 'runtime' + if: matrix.suite == 'workers' shell: bash env: CHROME: ${{ steps.setup-chrome.outputs.chrome-path }} From 6e679f4aca9d310275fb93fef5f7e528e178f385 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 6 Oct 2026 15:42:17 +0800 Subject: [PATCH 04/12] gc: yield to contending Wasm allocators after collection --- runtime/internal/runtime/tinygogc/gc_tinygo.go | 3 +++ .../runtime/tinygogc/mutex_wasm_workers.go | 15 ++++++++++++++- runtime/internal/wasmsync/mutex.go | 3 +++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/runtime/internal/runtime/tinygogc/gc_tinygo.go b/runtime/internal/runtime/tinygogc/gc_tinygo.go index dd06f84e7b..3fd837878f 100644 --- a/runtime/internal/runtime/tinygogc/gc_tinygo.go +++ b/runtime/internal/runtime/tinygogc/gc_tinygo.go @@ -570,6 +570,9 @@ func startMark(root uintptr) { for addr := start; addr != end; addr += gcScanWordSize { // Load the word. word := loadGCScanWord(addr) + if word == 0 { + continue + } referencedSegment := segmentForAddress(word) if referencedSegment == nil { diff --git a/runtime/internal/runtime/tinygogc/mutex_wasm_workers.go b/runtime/internal/runtime/tinygogc/mutex_wasm_workers.go index 9c8889001f..e2ab7728ad 100644 --- a/runtime/internal/runtime/tinygogc/mutex_wasm_workers.go +++ b/runtime/internal/runtime/tinygogc/mutex_wasm_workers.go @@ -6,6 +6,7 @@ import ( _ "unsafe" "github.com/xgo-dev/llgo/runtime/internal/wasmsync" + "github.com/xgo-dev/llgo/runtime/internal/wasmworkers" ) type mutex = wasmsync.Mutex @@ -21,4 +22,16 @@ func unlock(m *mutex) { //go:linkname gcAllocatorYield github.com/xgo-dev/llgo/runtime/internal/runtime.wasmGCAllocatorYield func gcAllocatorYield() -func unlockForGC(m *mutex) { unlock(m) } +var gcHandoffWord uint32 + +func unlockForGC(m *mutex) { + contended := m.Contended() + unlock(m) + if contended { + // A collector can have no other runnable G on its worker. Gosched then + // immediately runs it again, so it repeatedly wins the allocator CAS + // before a woken remote mutator resumes. Give that native worker a + // bounded scheduling opportunity after resuming the world. + wasmworkers.Wait(&gcHandoffWord, 0, 1_000_000) + } +} diff --git a/runtime/internal/wasmsync/mutex.go b/runtime/internal/wasmsync/mutex.go index 14fa77c530..8ab5292ed7 100644 --- a/runtime/internal/wasmsync/mutex.go +++ b/runtime/internal/wasmsync/mutex.go @@ -46,3 +46,6 @@ func (m *Mutex) Unlock() { wasmworkers.WakeOne(&m.state) } } + +// Contended reports whether another worker is waiting for this mutex. +func (m *Mutex) Contended() bool { return atomic.Load(&m.waiters) != 0 } From 1dc5acd4c31ad3ae732fe5023797ee017084a21b Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 6 Oct 2026 15:54:58 +0800 Subject: [PATCH 05/12] gc: batch zero-filled Memory32 scan spans --- runtime/internal/runtime/tinygogc/gc_tinygo.go | 9 +++++++++ runtime/internal/runtime/tinygogc/scanword_default.go | 1 + runtime/internal/runtime/tinygogc/scanword_wasm32.go | 1 + 3 files changed, 11 insertions(+) diff --git a/runtime/internal/runtime/tinygogc/gc_tinygo.go b/runtime/internal/runtime/tinygogc/gc_tinygo.go index 3fd837878f..077adf2d4e 100644 --- a/runtime/internal/runtime/tinygogc/gc_tinygo.go +++ b/runtime/internal/runtime/tinygogc/gc_tinygo.go @@ -568,6 +568,15 @@ func startMark(root uintptr) { start, end := gcAddressOfIn(segment, block), gcAddressOfIn(segment, endBlock) for addr := start; addr != end; addr += gcScanWordSize { + // Fiber buffers contain large zero-filled spans. On Memory32, skip + // four null pointer words at once without skipping either half of + // a nonzero Go/C pointer. Bound and align both loads inside the object. + if gcScanZeroSpans && addr&7 == 0 && end-addr >= 16 && + *(*uint64)(unsafe.Pointer(addr)) == 0 && + *(*uint64)(unsafe.Pointer(addr + 8)) == 0 { + addr += 16 - gcScanWordSize + continue + } // Load the word. word := loadGCScanWord(addr) if word == 0 { diff --git a/runtime/internal/runtime/tinygogc/scanword_default.go b/runtime/internal/runtime/tinygogc/scanword_default.go index 89624dc353..6478cf77ec 100644 --- a/runtime/internal/runtime/tinygogc/scanword_default.go +++ b/runtime/internal/runtime/tinygogc/scanword_default.go @@ -5,6 +5,7 @@ package tinygogc import "unsafe" const gcScanWordSize = uintptr(unsafe.Sizeof(uintptr(0))) +const gcScanZeroSpans = false func loadGCScanWord(addr uintptr) uintptr { return *(*uintptr)(unsafe.Pointer(addr)) diff --git a/runtime/internal/runtime/tinygogc/scanword_wasm32.go b/runtime/internal/runtime/tinygogc/scanword_wasm32.go index cfc2c9c76a..c4e056680a 100644 --- a/runtime/internal/runtime/tinygogc/scanword_wasm32.go +++ b/runtime/internal/runtime/tinygogc/scanword_wasm32.go @@ -9,6 +9,7 @@ import "unsafe" // physical pointer word finds both layouts: a Go pointer occupies the low word // of its slot, while C and host-toolchain pointers may occupy either word. const gcScanWordSize = uintptr(4) +const gcScanZeroSpans = true func loadGCScanWord(addr uintptr) uintptr { return uintptr(*(*uint32)(unsafe.Pointer(addr))) From 9be42ef5ee0ee6305d9dad9230ed23b87a0b8ecc Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 6 Oct 2026 16:41:52 +0800 Subject: [PATCH 06/12] gc: batch heap metadata scans --- .../testdata/wasm-test/gc_bitmap_test.go | 58 +++++++++++++++++++ .../internal/runtime/tinygogc/gc_tinygo.go | 34 +++++++++++ runtime/internal/runtime/tinygogc/segments.go | 8 +++ 3 files changed, 100 insertions(+) create mode 100644 internal/build/testdata/wasm-test/gc_bitmap_test.go diff --git a/internal/build/testdata/wasm-test/gc_bitmap_test.go b/internal/build/testdata/wasm-test/gc_bitmap_test.go new file mode 100644 index 0000000000..dc0158bd04 --- /dev/null +++ b/internal/build/testdata/wasm-test/gc_bitmap_test.go @@ -0,0 +1,58 @@ +//go:build wasm && !nogc + +package wasmtest + +import ( + "runtime" + "testing" +) + +type gcBitmapNode struct { + value int + data []byte + next *gcBitmapNode +} + +//go:noinline +func dirtyGCBitmapAllocation(size int) { + data := make([]byte, size) + for i := range data { + data[i] = 0x7d + } + runtime.KeepAlive(data) +} + +func TestGCBitmapSpans(t *testing.T) { + // A wide graph forces the bounded mark stack to overflow. Varied object + // lengths mix heads, tails and free space across metadata-byte boundaries. + sizes := [...]int{1, 31, 32, 33, 95, 127, 128, 129, 255, 256, 257, 513} + nodes := make([]*gcBitmapNode, 192) + for i := range nodes { + size := sizes[i%len(sizes)] + dirtyGCBitmapAllocation(size) + leaf := &gcBitmapNode{value: i + 1, data: make([]byte, size)} + for j := range leaf.data { + leaf.data[j] = byte(i + j) + } + nodes[i] = &gcBitmapNode{next: leaf} + } + for round := 0; round < 3; round++ { + runtime.GC() + for i, node := range nodes { + if node.next == nil || node.next.value != i+1 { + t.Fatalf("round %d: graph edge %d was lost", round, i) + } + for j, value := range node.next.data { + if value != byte(i+j) { + t.Fatalf("round %d: live object %d byte %d changed", round, i, j) + } + } + dirtyGCBitmapAllocation(sizes[i%len(sizes)]) + for j, value := range make([]byte, sizes[i%len(sizes)]) { + if value != 0 { + t.Fatalf("round %d: new object %d byte %d was not zeroed", round, i, j) + } + } + } + } +} diff --git a/runtime/internal/runtime/tinygogc/gc_tinygo.go b/runtime/internal/runtime/tinygogc/gc_tinygo.go index 077adf2d4e..7b44c203ff 100644 --- a/runtime/internal/runtime/tinygogc/gc_tinygo.go +++ b/runtime/internal/runtime/tinygogc/gc_tinygo.go @@ -221,6 +221,12 @@ func gcFindNextIn(segment *heapSegment, blockAddr uintptr) uintptr { blockAddr++ } for blockAddr < segment.last && gcStateOfIn(segment, blockAddr) == blockStateTail { + // A full metadata byte containing only tails has no object boundary. + if (blockAddr-segment.first)%blocksPerStateByte == 0 && + segment.last-blockAddr >= blocksPerStateByte && gcStateByteOfIn(segment, blockAddr) == blockStateByteAllTails { + blockAddr += blocksPerStateByte + continue + } blockAddr++ } return blockAddr @@ -635,6 +641,15 @@ func finishMark() { for segmentIndex := 0; segmentIndex < heapSegmentCount; segmentIndex++ { segment := &heapSegments[segmentIndex] for block := segment.first; block < segment.last; block++ { + if (block-segment.first)%blocksPerStateByte == 0 && segment.last-block >= blocksPerStateByte { + stateByte := gcStateByteOfIn(segment, block) + // A mark has both state bits set. Skip complete bytes with + // no marked heads, including free space and object tails. + if stateByte&(stateByte>>1)&(blockStateByteAllTails>>1) == 0 { + block += blocksPerStateByte - 1 + continue + } + } if gcStateOfIn(segment, block) != blockStateMark { continue } @@ -671,6 +686,25 @@ func sweep() (freeBytes uintptr) { for segmentIndex := 0; segmentIndex < heapSegmentCount; segmentIndex++ { segment := &heapSegments[segmentIndex] for block := segment.first; block < segment.last; block++ { + // Free space and long object tails dominate grown heaps. Process + // complete metadata bytes without decoding each two-bit state. + if (block-segment.first)%blocksPerStateByte == 0 && segment.last-block >= blocksPerStateByte { + stateByte := gcStateByteOfIn(segment, block) + if stateByte == 0 { + freeBytes += blocksPerStateByte * bytesPerBlock + block += blocksPerStateByte - 1 + continue + } + if stateByte == blockStateByteAllTails { + if freeCurrentObject { + *(*byte)(unsafe.Pointer(segment.metadata + (block-segment.first)/blocksPerStateByte)) = 0 + c.Memset(unsafe.Pointer(gcAddressOfIn(segment, block)), 0, blocksPerStateByte*bytesPerBlock) + freed += blocksPerStateByte + } + block += blocksPerStateByte - 1 + continue + } + } switch gcStateOfIn(segment, block) { case blockStateHead: // Unmarked head. Free it, including all tail blocks following it. diff --git a/runtime/internal/runtime/tinygogc/segments.go b/runtime/internal/runtime/tinygogc/segments.go index 3c0a920d81..be5f77f3d0 100644 --- a/runtime/internal/runtime/tinygogc/segments.go +++ b/runtime/internal/runtime/tinygogc/segments.go @@ -47,6 +47,14 @@ func addHeapSegment(start, end uintptr) bool { } func segmentForBlock(block uintptr) *heapSegment { + if !segmentedHeap { + segment := &heapSegments[0] + if block >= segment.first && block <= segment.last { + return segment + } + gcPanic(c.Str("gc: invalid heap block")) + return nil + } low, high := 0, heapSegmentCount for low < high { mid := low + (high-low)/2 From f7394b317dba3962f4a0d18aa1e88f8bb84c3fd2 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 6 Oct 2026 17:34:28 +0800 Subject: [PATCH 07/12] wasm: root browser filesystem proxy buffers across collection --- internal/wasmworkers/browser_fs.js | 18 ++++++++++------- internal/wasmworkers/browser_fs_test.mjs | 21 ++++++++++++++++++++ runtime/internal/runtime/browser_fs_alloc.go | 19 ++++++++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 runtime/internal/runtime/browser_fs_alloc.go diff --git a/internal/wasmworkers/browser_fs.js b/internal/wasmworkers/browser_fs.js index 2f74364521..aba675fe66 100644 --- a/internal/wasmworkers/browser_fs.js +++ b/internal/wasmworkers/browser_fs.js @@ -1,7 +1,7 @@ // Browser syscall/js hosts share the Emscripten runtime thread's FS, including // C's file descriptors and cwd. Never create a separate MEMFS on each worker. addToLibrary({ - $llgoBrowserFS__deps: ['llgo_browser_fs_call', 'llgo_browser_fs_result', '$stringToNewUTF8', '$UTF8ToString', 'malloc', 'free', '$FS'], + $llgoBrowserFS__deps: ['llgo_browser_fs_call', 'llgo_browser_fs_result', '$stringToUTF8', '$lengthBytesUTF8', '$UTF8ToString', 'llgo_browser_fs_malloc', 'llgo_browser_fs_free', '$FS'], $llgoBrowserFS__postset: 'llgoBrowserFS.install();', $llgoBrowserFS: { installed: false, @@ -37,15 +37,19 @@ addToLibrary({ } // Allocate on the requesting worker: malloc may block. Only metadata // crosses JSON; byte payloads use shared linear memory in both widths. - payload = _malloc(Math.max(1, length)); + payload = _llgo_browser_fs_malloc(Math.max(1, length)); if (!payload) throw Object.assign(new Error('filesystem buffer allocation failed'), { code: 'ENOMEM' }); const pointer = Number(payload); if (name !== 'read') HEAPU8.set(buffer.subarray(offset, offset + length), pointer); args = [args[0], { pointer, length }, 0, length, args[4]]; } - request = stringToNewUTF8(JSON.stringify({ target, name, args })); + // Each allocation may collect: JS numbers do not publish Go roots. + const text = JSON.stringify({ target, name, args }); + const requestSize = lengthBytesUTF8(text) + 1; + request = _llgo_browser_fs_malloc(requestSize); + stringToUTF8(text, request, requestSize); const size = _llgo_browser_fs_call(request); - response = _malloc(size); + response = _llgo_browser_fs_malloc(size); if (!response) throw Object.assign(new Error('filesystem response allocation failed'), { code: 'ENOMEM' }); _llgo_browser_fs_result(request, response); const reply = JSON.parse(UTF8ToString(response)); @@ -65,10 +69,10 @@ addToLibrary({ } finally { if (request) { _llgo_browser_fs_result(request, 0); - _free(request); + _llgo_browser_fs_free(request); } - if (response) _free(response); - if (payload) _free(payload); + if (response) _llgo_browser_fs_free(response); + if (payload) _llgo_browser_fs_free(payload); } }, install() { diff --git a/internal/wasmworkers/browser_fs_test.mjs b/internal/wasmworkers/browser_fs_test.mjs index 72e1d2daf3..3b7c87a664 100644 --- a/internal/wasmworkers/browser_fs_test.mjs +++ b/internal/wasmworkers/browser_fs_test.mjs @@ -8,6 +8,8 @@ for (const memory64 of [false, true]) { const memory = new WebAssembly.Memory({initial: 16, maximum: 128, shared: true}); let heap = new Uint8Array(memory.buffer); const allocated = new Set(); + const roots = new Set(); + const sizes = new Map(); let next = 64, largestRequest = 0; function malloc(size) { const pointer = next; @@ -17,9 +19,15 @@ for (const memory64 of [false, true]) { heap = new Uint8Array(memory.buffer); } allocated.add(pointer); + sizes.set(pointer, size); return memory64 ? BigInt(pointer) : pointer; } function free(pointer) { assert.ok(allocated.delete(Number(pointer))); } + function collect() { + for (const pointer of allocated) { + if (!roots.has(pointer)) heap.fill(0, pointer, pointer + sizes.get(pointer)); + } + } function stringToUTF8(value, pointer, size) { const bytes = new TextEncoder().encode(value); assert.ok(bytes.length < size); @@ -40,10 +48,22 @@ for (const memory64 of [false, true]) { const ctx = vm.createContext({Uint8Array, ArrayBuffer, HEAPU8: heap, Module: {}, ENVIRONMENT_IS_NODE: false, ENVIRONMENT_IS_PTHREAD: pthread, _malloc(size) { + collect(); const pointer = malloc(size); ctx.HEAPU8 = heap; return pointer; }, + _llgo_browser_fs_malloc(size) { + collect(); + const pointer = Number(malloc(size)); + roots.add(pointer); + ctx.HEAPU8 = heap; + return pointer; + }, + _llgo_browser_fs_free(pointer) { + assert.ok(roots.delete(Number(pointer))); + free(pointer); + }, updateMemoryViews() { ctx.HEAPU8 = heap; }, _free: free, stringToUTF8, UTF8ToString, stringToNewUTF8(text) { @@ -142,6 +162,7 @@ for (const memory64 of [false, true]) { assert.ok(largeRead.subarray(-7).every(byte => byte === 99)); assert.ok(largestRequest < 200, 'large payloads must not travel through JSON'); assert.equal(allocated.size, 0); + assert.equal(roots.size, 0); assert.equal(Object.keys(main.llgoBrowserFSResponses).length, 0); } console.log('browser filesystem proxy contract passed'); diff --git a/runtime/internal/runtime/browser_fs_alloc.go b/runtime/internal/runtime/browser_fs_alloc.go new file mode 100644 index 0000000000..9a345e9a9a --- /dev/null +++ b/runtime/internal/runtime/browser_fs_alloc.go @@ -0,0 +1,19 @@ +//go:build llgo && js && wasm + +package runtime + +import "unsafe" + +// Filesystem proxy buffers are held by JavaScript numeric addresses, which +// are invisible to the Go collector. Keep them rooted until the proxy returns. +// Doubles keep this JS-only boundary numeric in both memory widths. + +//export llgo_browser_fs_malloc +func browserFSMalloc(size float64) float64 { + return float64(uintptr(AllocRoot(uintptr(size)))) +} + +//export llgo_browser_fs_free +func browserFSFree(address float64) { + FreeRoot(unsafe.Pointer(uintptr(address))) +} From 9064d9a2c76458ebc068b3ee4524709d5e3db337 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 6 Oct 2026 17:36:18 +0800 Subject: [PATCH 08/12] gc: scan bounded metadata words and exercise segment boundaries --- internal/build/wasm_gc_segments_test.go | 86 ++++++++++++++++++- .../internal/runtime/tinygogc/gc_tinygo.go | 45 +++++++++- 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/internal/build/wasm_gc_segments_test.go b/internal/build/wasm_gc_segments_test.go index 8aa813b05b..8913839e09 100644 --- a/internal/build/wasm_gc_segments_test.go +++ b/internal/build/wasm_gc_segments_test.go @@ -20,7 +20,7 @@ func TestWasmGCSegmentBoundariesAndSweep(t *testing.T) { source.WriteString(gcSegmentWorkloadSource) functions := map[string]bool{"gcAddressOfIn": true, "gcStateByteOfIn": true, "gcStateFromByteIn": true, "gcStateOfIn": true, "gcSetStateIn": true, - "gcMarkFreeIn": true, "gcUnmarkIn": true, "sweep": true} + "gcMarkFreeIn": true, "gcUnmarkIn": true, "gcFindNextIn": true, "sweep": true} for _, name := range []string{"segments.go", "segment_lookup.go", "gc_tinygo.go"} { file, err := parser.ParseFile(fset, filepath.Join("..", "..", "runtime", "internal", "runtime", "tinygogc", name), nil, 0) if err != nil { @@ -31,11 +31,23 @@ func TestWasmGCSegmentBoundariesAndSweep(t *testing.T) { continue } if name == "gc_tinygo.go" { - fn, ok := decl.(*ast.FuncDecl) - if !ok || !functions[fn.Name.Name] { + switch node := decl.(type) { + case *ast.FuncDecl: + if !functions[node.Name.Name] { + continue + } + delete(functions, node.Name.Name) + case *ast.GenDecl: + if node.Tok != token.CONST { + continue + } + first := node.Specs[0].(*ast.ValueSpec).Names[0].Name + if first != "blockStateByteAllTails" && first != "blocksPerStateWord" { + continue + } + default: continue } - delete(functions, fn.Name.Name) } if err := format.Node(&source, fset, decl); err != nil { t.Fatal(err) @@ -77,7 +89,14 @@ var c = struct { for i := uintptr(0); i < n; i++ { *(*byte)(unsafe.Add(p, i)) = byte(value) }; return p }} func gcPanic(s string) { panic(s) } +func resetHeap() { + heapSegmentCount = 0 + heapSegments = [maxHeapSegments]heapSegment{} + heapSegmentsByAddress = [maxHeapSegments]*heapSegment{} + endBlock, gcFrees, gcFreedBlocks = 0, 0, 0 +} func TestAllSegments(t *testing.T) { + resetHeap() const stride = 4096 data := make([]byte, maxHeapSegments*stride) base := uintptr(unsafe.Pointer(&data[0])) @@ -117,4 +136,63 @@ func TestAllSegments(t *testing.T) { if addHeapSegment(base, base+stride) { t.Fatal("segment limit ignored") } runtime.KeepAlive(data) } +func TestSweepMetadataBatches(t *testing.T) { + for offset := uintptr(0); offset < 8; offset++ { + for _, keep := range []bool{false, true} { + resetHeap() + data := make([]byte, 4096) + base := uintptr(unsafe.Pointer(&data[0])) + if !addHeapSegment(base, base+4096-offset) { t.Fatal("segment rejected") } + segment := &heapSegments[0] + gcSetStateIn(segment, 0, blockStateHead) + if keep { gcSetStateIn(segment, 0, blockStateMark) } + for block := uintptr(1); block < 40; block++ { gcSetStateIn(segment, block, blockStateTail) } + for block := uintptr(0); block < 40; block++ { + *(*uintptr)(unsafe.Pointer(gcAddressOfIn(segment, block))) = 123 + } + if next := gcFindNextIn(segment, 0); next != 40 { t.Fatalf("tail boundary: %d != 40", next) } + // The last partial metadata byte must remain bounded and retain a live head. + last := segment.last - 1 + gcSetStateIn(segment, last, blockStateMark) + *(*uintptr)(unsafe.Pointer(gcAddressOfIn(segment, last))) = 456 + if next := gcFindNextIn(segment, last); next != segment.last { t.Fatalf("final boundary: %d", next) } + free := sweep() + live := uintptr(1) + if keep { live += 40 } + if free != (segment.last-live)*bytesPerBlock { t.Fatalf("incorrect free count: %d", free) } + for block := segment.first; block < segment.last; block++ { + state, value := uint8(blockStateFree), uintptr(0) + if keep && block < 40 { + state, value = blockStateTail, 123 + if block == 0 { state = blockStateHead } + } + if block == last { state, value = blockStateHead, 456 } + if gcStateOfIn(segment, block) != state || *(*uintptr)(unsafe.Pointer(gcAddressOfIn(segment, block))) != value { + t.Fatalf("offset %d keep %v block %d corrupted", offset, keep, block) + } + } + if keep && (gcFrees != 0 || gcFreedBlocks != 0) || !keep && (gcFrees != 1 || gcFreedBlocks != 40) { + t.Fatalf("incorrect freed counts: %d objects, %d blocks", gcFrees, gcFreedBlocks) + } + runtime.KeepAlive(data) + } + } +} +func TestSingleArenaLookup(t *testing.T) { + resetHeap() + data := make([]byte, 4096) + base := uintptr(unsafe.Pointer(&data[0])) + if !addHeapSegment(base, base+4096) { t.Fatal("segment rejected") } + segment := &heapSegments[0] + for block := segment.first; block <= segment.last; block++ { + if segmentForBlock(block) != segment { t.Fatalf("block %d not found", block) } + } + for _, address := range []uintptr{segment.start, segment.metadata-1} { + if segmentForAddress(address) != segment { t.Fatalf("address %#x not found", address) } + } + for _, address := range []uintptr{segment.start-1, segment.metadata, segment.end} { + if segmentForAddress(address) != nil { t.Fatalf("invalid address %#x accepted", address) } + } + runtime.KeepAlive(data) +} ` diff --git a/runtime/internal/runtime/tinygogc/gc_tinygo.go b/runtime/internal/runtime/tinygogc/gc_tinygo.go index 7b44c203ff..82ef6e26b2 100644 --- a/runtime/internal/runtime/tinygogc/gc_tinygo.go +++ b/runtime/internal/runtime/tinygogc/gc_tinygo.go @@ -58,6 +58,13 @@ const blockStateByteAllTails = 0 | uint8(blockStateTail<<(stateBits*1)) | uint8(blockStateTail<<(stateBits*0)) +// A bounded, aligned word read handles 16 blocks at once. Byte decoding +// remains the fallback for mixed states and partial segment boundaries. +const ( + blocksPerStateWord = blocksPerStateByte * 4 + blockStateWordAllTails = uint32(blockStateByteAllTails) * 0x01010101 +) + var ( heapStart uintptr // start address of heap area heapEnd uintptr // end address of heap area @@ -220,13 +227,23 @@ func gcFindNextIn(segment *heapSegment, blockAddr uintptr) uintptr { if gcStateOfIn(segment, blockAddr) == blockStateHead || gcStateOfIn(segment, blockAddr) == blockStateMark { blockAddr++ } - for blockAddr < segment.last && gcStateOfIn(segment, blockAddr) == blockStateTail { + for blockAddr < segment.last { + if (blockAddr-segment.first)%blocksPerStateByte == 0 && segment.last-blockAddr >= blocksPerStateWord { + metadata := segment.metadata + (blockAddr-segment.first)/blocksPerStateByte + if metadata&3 == 0 && *(*uint32)(unsafe.Pointer(metadata)) == blockStateWordAllTails { + blockAddr += blocksPerStateWord + continue + } + } // A full metadata byte containing only tails has no object boundary. if (blockAddr-segment.first)%blocksPerStateByte == 0 && segment.last-blockAddr >= blocksPerStateByte && gcStateByteOfIn(segment, blockAddr) == blockStateByteAllTails { blockAddr += blocksPerStateByte continue } + if gcStateOfIn(segment, blockAddr) != blockStateTail { + break + } blockAddr++ } return blockAddr @@ -642,6 +659,14 @@ func finishMark() { segment := &heapSegments[segmentIndex] for block := segment.first; block < segment.last; block++ { if (block-segment.first)%blocksPerStateByte == 0 && segment.last-block >= blocksPerStateByte { + metadata := segment.metadata + (block-segment.first)/blocksPerStateByte + if metadata&3 == 0 && segment.last-block >= blocksPerStateWord { + states := *(*uint32)(unsafe.Pointer(metadata)) + if states&(states>>1)&(blockStateWordAllTails>>1) == 0 { + block += blocksPerStateWord - 1 + continue + } + } stateByte := gcStateByteOfIn(segment, block) // A mark has both state bits set. Skip complete bytes with // no marked heads, including free space and object tails. @@ -689,6 +714,24 @@ func sweep() (freeBytes uintptr) { // Free space and long object tails dominate grown heaps. Process // complete metadata bytes without decoding each two-bit state. if (block-segment.first)%blocksPerStateByte == 0 && segment.last-block >= blocksPerStateByte { + metadata := segment.metadata + (block-segment.first)/blocksPerStateByte + if metadata&3 == 0 && segment.last-block >= blocksPerStateWord { + states := *(*uint32)(unsafe.Pointer(metadata)) + if states == 0 { + freeBytes += blocksPerStateWord * bytesPerBlock + block += blocksPerStateWord - 1 + continue + } + if states == blockStateWordAllTails { + if freeCurrentObject { + *(*uint32)(unsafe.Pointer(metadata)) = 0 + c.Memset(unsafe.Pointer(gcAddressOfIn(segment, block)), 0, blocksPerStateWord*bytesPerBlock) + freed += blocksPerStateWord + } + block += blocksPerStateWord - 1 + continue + } + } stateByte := gcStateByteOfIn(segment, block) if stateByte == 0 { freeBytes += blocksPerStateByte * bytesPerBlock From aaa5e554bb6e783345a20af7c5e207f4b2089a8e Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 13 Oct 2026 19:37:23 +0800 Subject: [PATCH 09/12] ci: account for the dedicated Wasm worker suite --- .github/scripts/test_ci_workflows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/test_ci_workflows.py b/.github/scripts/test_ci_workflows.py index be7e06125d..6a7f31ad2e 100644 --- a/.github/scripts/test_ci_workflows.py +++ b/.github/scripts/test_ci_workflows.py @@ -17,7 +17,7 @@ WORKFLOWS = Path(__file__).resolve().parents[1] / "workflows" PREPARE = "./.github/workflows/ci-prepare.yml" CODE_WORKFLOWS = { - "llgo.yml": 22, "go.yml": 6, "targets.yml": 8, "build-cache.yml": 4, + "llgo.yml": 23, "go.yml": 6, "targets.yml": 8, "build-cache.yml": 4, "benchmark.yml": 9, "release-build.yml": 15, "doc.yml": 6, "fmt.yml": 1, } From 5f38fd38354e3e58b3d7c6197c7a4cbff7c5666e Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 13 Oct 2026 19:51:21 +0800 Subject: [PATCH 10/12] test: retain segmented and single-arena GC lookup coverage --- internal/build/wasm_gc_segments_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/build/wasm_gc_segments_test.go b/internal/build/wasm_gc_segments_test.go index 8913839e09..630728544b 100644 --- a/internal/build/wasm_gc_segments_test.go +++ b/internal/build/wasm_gc_segments_test.go @@ -80,6 +80,7 @@ const ( wordsPerBlock = 4 bytesPerBlock = wordsPerBlock * unsafe.Sizeof(uintptr(0)) ) +var segmentedHeap = true var endBlock uintptr var gcFrees, gcFreedBlocks uint64 var c = struct { @@ -90,6 +91,7 @@ var c = struct { }} func gcPanic(s string) { panic(s) } func resetHeap() { + segmentedHeap = true heapSegmentCount = 0 heapSegments = [maxHeapSegments]heapSegment{} heapSegmentsByAddress = [maxHeapSegments]*heapSegment{} @@ -180,6 +182,7 @@ func TestSweepMetadataBatches(t *testing.T) { } func TestSingleArenaLookup(t *testing.T) { resetHeap() + segmentedHeap = false data := make([]byte, 4096) base := uintptr(unsafe.Pointer(&data[0])) if !addHeapSegment(base, base+4096) { t.Fatal("segment rejected") } From 68f763954cb534cd55456da2d8b127ee460a4083 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 13 Oct 2026 21:37:09 +0800 Subject: [PATCH 11/12] gc: batch backward object-head metadata scans --- internal/build/wasm_gc_head_cache_test.go | 13 +++++++--- internal/build/wasm_gc_segments_test.go | 26 ++++++++++++++++++- .../internal/runtime/tinygogc/gc_tinygo.go | 9 +++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/internal/build/wasm_gc_head_cache_test.go b/internal/build/wasm_gc_head_cache_test.go index 4eb6f5945e..7c9457d682 100644 --- a/internal/build/wasm_gc_head_cache_test.go +++ b/internal/build/wasm_gc_head_cache_test.go @@ -36,10 +36,17 @@ func TestWasmGCHeadCacheWorkloads(t *testing.T) { for _, decl := range file.Decls { if name == "gc_tinygo.go" { fn, ok := decl.(*ast.FuncDecl) - if !ok || !functions[fn.Name.Name] { - continue + if ok { + if !functions[fn.Name.Name] { + continue + } + delete(functions, fn.Name.Name) + } else { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST || gen.Specs[0].(*ast.ValueSpec).Names[0].Name != "blocksPerStateWord" { + continue + } } - delete(functions, fn.Name.Name) } if err := format.Node(&source, fset, decl); err != nil { t.Fatal(err) diff --git a/internal/build/wasm_gc_segments_test.go b/internal/build/wasm_gc_segments_test.go index 630728544b..3d59c94c95 100644 --- a/internal/build/wasm_gc_segments_test.go +++ b/internal/build/wasm_gc_segments_test.go @@ -20,7 +20,7 @@ func TestWasmGCSegmentBoundariesAndSweep(t *testing.T) { source.WriteString(gcSegmentWorkloadSource) functions := map[string]bool{"gcAddressOfIn": true, "gcStateByteOfIn": true, "gcStateFromByteIn": true, "gcStateOfIn": true, "gcSetStateIn": true, - "gcMarkFreeIn": true, "gcUnmarkIn": true, "gcFindNextIn": true, "sweep": true} + "gcMarkFreeIn": true, "gcUnmarkIn": true, "gcFindHeadIn": true, "gcFindNextIn": true, "sweep": true} for _, name := range []string{"segments.go", "segment_lookup.go", "gc_tinygo.go"} { file, err := parser.ParseFile(fset, filepath.Join("..", "..", "runtime", "internal", "runtime", "tinygogc", name), nil, 0) if err != nil { @@ -180,6 +180,30 @@ func TestSweepMetadataBatches(t *testing.T) { } } } +func TestMarkHeadMetadataBatches(t *testing.T) { + for _, prefix := range []bool{false, true} { + for offset := uintptr(0); offset < 8; offset++ { + resetHeap() + data := make([]byte, 8192) + base := uintptr(unsafe.Pointer(&data[0])) + if prefix && !addHeapSegment(base, base+4096) { t.Fatal("prefix segment rejected") } + if !addHeapSegment(base+4096, base+8192-offset) { t.Fatal("segment rejected") } + segment := &heapSegments[heapSegmentCount-1] + for _, span := range [][2]uintptr{{segment.first+5, segment.first+85}, {segment.last-40, segment.last}} { + state := blockStateHead + if offset&1 != 0 { state = blockStateMark } + gcSetStateIn(segment, span[0], state) + for block := span[0]+1; block < span[1]; block++ { gcSetStateIn(segment, block, blockStateTail) } + for block := span[0]; block < span[1]; block++ { + if head := gcFindHeadIn(segment, block); head != span[0] { + t.Fatalf("prefix %v offset %d block %d: head %d != %d", prefix, offset, block, head, span[0]) + } + } + } + runtime.KeepAlive(data) + } + } +} func TestSingleArenaLookup(t *testing.T) { resetHeap() segmentedHeap = false diff --git a/runtime/internal/runtime/tinygogc/gc_tinygo.go b/runtime/internal/runtime/tinygogc/gc_tinygo.go index 82ef6e26b2..e357c74cde 100644 --- a/runtime/internal/runtime/tinygogc/gc_tinygo.go +++ b/runtime/internal/runtime/tinygogc/gc_tinygo.go @@ -176,6 +176,15 @@ func gcFindHead(blockAddr uintptr) uintptr { func gcFindHeadIn(segment *heapSegment, blockAddr uintptr) uintptr { for { + // Walk a complete, aligned metadata word backwards when all 16 + // represented blocks are tails. Keep the read inside this segment. + offset := blockAddr - segment.first + metadata := segment.metadata + offset/blocksPerStateByte + if offset >= blocksPerStateWord && metadata&3 == 3 && + *(*uint32)(unsafe.Pointer(metadata - 3)) == blockStateWordAllTails { + blockAddr -= offset%blocksPerStateByte + blocksPerStateWord - blocksPerStateByte + 1 + continue + } // Optimization: check whether the current block state byte (which // contains the state of multiple blocks) is composed entirely of tail // blocks. If so, we can skip back to the last block in the previous From d2febcf6556beaddb6d54abca95bcf9649f6c2fa Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 14 Oct 2026 12:33:56 +0800 Subject: [PATCH 12/12] syscall/js: clarify remote nested callback goroutine identity --- runtime/internal/lib/syscall/js/func.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/runtime/internal/lib/syscall/js/func.go b/runtime/internal/lib/syscall/js/func.go index bdc0fd4cd6..0ac3ba84e1 100644 --- a/runtime/internal/lib/syscall/js/func.go +++ b/runtime/internal/lib/syscall/js/func.go @@ -45,12 +45,14 @@ type Func struct { // FuncOf returns a function to be used by JavaScript. // // The Go function fn is called with JavaScript's "this" and arguments, and -// its result is returned synchronously. Nested calls run on the calling G; -// external events resume the scheduler and run on a new G. A callback may -// block on Go goroutines or timers, but must not wait for another asynchronous -// JS event: its caller still owns the JS event loop, as in the Go runtime. -// In bounded-worker builds, fn executes on Go's main JS worker even if the -// Func is created or invoked by a G on another worker. +// its result is returned synchronously. Nested calls run on the G that entered +// JavaScript; external events resume the scheduler and run on a new G. A +// callback may block on Go goroutines or timers, but must not wait for another +// asynchronous JS event: its caller still owns the JS event loop, as in the Go +// runtime. +// In bounded-worker builds, fn executes on Go's main JS worker. A nested call +// originating on another worker runs fn on the originating G's executor G, +// not on the originating G. // // Func.Release must be called to free up resources when the function will not be invoked any more. func FuncOf(fn func(this Value, args []Value) any) Func {