Skip to content

wasm: support cross-worker syscall/js values and callbacks - #2738

Open
cpunion wants to merge 9 commits into
xgo-dev:mainfrom
cpunion:codex/wasm-js-cross-worker-20261006
Open

cpunion wants to merge 9 commits into
xgo-dev:mainfrom
cpunion:codex/wasm-js-cross-worker-20261006

Conversation

@cpunion

@cpunion cpunion commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Allow goroutines on different Emscripten workers to share public syscall/js.Value and js.Func. Host operations and callbacks execute in Go main's worker-zero JavaScript realm; remote callers park for synchronous results or exceptions and reuse an executor that retires with the caller. Worker-zero calls and reserved primitives avoid forwarding allocations; raw C/Emscripten handles remain local to their worker.

Support nested/yielding callbacks, Goexit defers and GC-safe executor cleanup. Contended runqueue/allocator paths acknowledge GC in place without replaying foreign frames. A bounded host handoff prevents a continuous collector from starving allocators. Contiguous heaps avoid arena searches; object/metadata scans batch bounded, aligned zero/free/tail spans while retaining individual handling for mixed states and partial boundaries.

Root filesystem proxy payload/request/response allocations until their JS callers finish: numeric JS addresses alone do not keep Go allocations alive. A deterministic regression collects between allocations and checks data plus cleanup in both memory widths; the original proxy loses payload bytes under this test. Chrome Memory32/64 filesystem GC stress now passes. Add host metadata tests for segment boundaries, alignment, retained/freed tails and zeroed storage, alongside the Wasm wide-graph/overflow regression.

Validation includes Node/Chrome Memory32/64 with 2/4 workers, eight live callbacks/children at a GC barrier, private roots, exceptions, finalizer contention and executor retirement. WAMR and pinned Wasmer threaded GC pass. With #2743, full Memory32 test/go passes locally in about 20–21 seconds under the original 60-second limit; hosted CI and full compatibility qualification remain pending for this head. Rebased on main be669acd8. The dedicated worker CI retains every acceptance case and its existing bounds.

Fixes #2737.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: WASM bounded-worker syscall/js cross-worker sharing

This is a well-structured change. The forwarding/executor pattern is sound: the single-channel-per-G design serializes caller and executor (no intra-call concurrency), nested calls on worker 0 correctly bypass forwarding (isRemoteJSWorker() is false there, avoiding deadlock and recursive round-trips), the spawnWorker save/restore around the synchronous go fn() is correct, and the channel is lazily created per caller-G and closed in finishWasmG. Cross-worker handoff of the call/done struct is correctly synchronized by the unbuffered channel. The Goexit and panic paths are handled, and test/wasm/js-workers/main.go exercises shared values, nested callbacks, panics, Goexit, and executor-leak detection.

Findings below are mostly minor/latent plus one build-tag correctness issue. No high-severity memory-safety or security issues were found.

Additional notes not placed inline:

  • Stale "realm affinity" comment (runtime/internal/runtime/proc_wasm_workers.go:46-48, outside this diff's hunks): the comment "Override both inherited realm affinity and callback-event pinning" is stale — this PR removed the jsRealm affinity model, so only callback-event pinning is overridden now.
  • Per-op round-trip amplification: each remote syscall/js call is a full synchronous round-trip to worker 0 (channel send → cross-worker switch → <-done). Method chains off worker 0 (e.g. iterating a JS array via Index/Length) become O(N) cross-worker handoffs where the single-worker build had O(N) direct FFI calls. There is no batching path. Worth documenting as a constraint for JS-heavy loops on non-zero workers.
  • Executor-G lifetime: one executor G is created per calling G and lives (holding a native + Asyncify stack on worker 0) until the caller G exits; jsCalls is never closed early. Many short-lived caller Gs each touching JS once will accumulate executor Gs/stacks concentrated on worker 0. Consider documenting this, or a shared/bounded executor pool.

Comment thread runtime/internal/lib/syscall/js/worker_call_single.go Outdated
Comment thread runtime/internal/runtime/js_worker_call_wasm_workers.go
Comment thread runtime/internal/lib/syscall/js/js.go Outdated

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (addendum): build-tag correctness

One inline finding from the main review was dropped on submission due to a title-length limit; posting it here.

Comment thread runtime/internal/lib/syscall/js/worker_call_single.go Outdated
@codecov

codecov Bot commented Oct 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

LLGo WebAssembly build benchmarks

411e3caab987 | workflow run | long-term charts

WebAssembly output sizes
Example, profile and compiler Wasm module vs base Generated JS glue vs base
cprintf/j32-emscripten/LLGo 149240 B 0 B / +0.0% 88157 B 0 B / +0.0%
cprintf/j32-goos-js/LLGo 148511 B 0 B / +0.0% 72985 B 0 B / +0.0%
cprintf/j64-emscripten-memory64/LLGo 136707 B 0 B / +0.0% 91328 B 0 B / +0.0%
cprintf/w32-goos-wasip1/LLGo 154296 B 0 B / +0.0% 0 B 0 B / 0.0%
cprintf/w32-wasi/LLGo 154979 B 0 B / +0.0% 0 B 0 B / 0.0%
fmtprintf/j32-emscripten/LLGo 3053055 B +3929 B / +0.1% (worse) 130373 B 0 B / +0.0%
fmtprintf/j32-goos-js/Go 2526852 B 0 B / +0.0% 0 B 0 B / 0.0%
fmtprintf/j32-goos-js/LLGo 3145567 B +415 B / +0.01319% (worse) 100773 B 0 B / +0.0%
fmtprintf/j64-emscripten-memory64/LLGo 2815787 B +3945 B / +0.1% (worse) 135853 B 0 B / +0.0%
fmtprintf/w32-goos-wasip1/Go 2500019 B 0 B / +0.0% 0 B 0 B / 0.0%
fmtprintf/w32-goos-wasip1/LLGo 2350088 B +287 B / +0.01221% (worse) 0 B 0 B / 0.0%
fmtprintf/w32-wasi/LLGo 2346720 B +287 B / +0.01223% (worse) 0 B 0 B / 0.0%
j32-emscripten/LLGo 148582 B 0 B / +0.0% 88157 B 0 B / +0.0%
j32-goos-js/Go 1895533 B 0 B / +0.0% 0 B 0 B / 0.0%
j32-goos-js/LLGo 148002 B 0 B / +0.0% 72985 B 0 B / +0.0%
j64-emscripten-memory64/LLGo 136047 B 0 B / +0.0% 91328 B 0 B / +0.0%
reflectcall/j32-emscripten/LLGo 1473293 B +34 B / +0.002308% (worse) 104917 B 0 B / +0.0%
reflectcall/j32-goos-js/Go 2191221 B 0 B / +0.0% 0 B 0 B / 0.0%
reflectcall/j32-goos-js/LLGo 1523214 B +28 B / +0.001838% (worse) 89743 B 0 B / +0.0%
reflectcall/j64-emscripten-memory64/LLGo 1366052 B +27 B / +0.001977% (worse) 109492 B 0 B / +0.0%
reflectcall/w32-goos-wasip1/Go 2205707 B 0 B / +0.0% 0 B 0 B / 0.0%
reflectcall/w32-goos-wasip1/LLGo 1278336 B +96 B / +0.00751% (worse) 0 B 0 B / 0.0%
reflectcall/w32-wasi/LLGo 1275915 B +96 B / +0.007525% (worse) 0 B 0 B / 0.0%
w32-goos-wasip1/Go 1909947 B 0 B / +0.0% 0 B 0 B / 0.0%
w32-goos-wasip1/LLGo 153943 B 0 B / +0.0% 0 B 0 B / 0.0%
w32-wasi/LLGo 154626 B 0 B / +0.0% 0 B 0 B / 0.0%
LLGo WebAssembly build measurements
Example and profile Build vs base
j32-emscripten 6.952 s +44.59 ms / +0.6% (worse)
j32-goos-js 6.778 s -206.3 ms / -3.0% (better)
j64-emscripten-memory64 6.092 s -75.87 ms / -1.2% (better)
reflectcall/w32-wasi 23.159 s -515.1 ms / -2.2% (better)
w32-goos-wasip1 4.365 s +34.43 ms / +0.8% (worse)
w32-wasi 4.146 s -39.97 ms / -1.0% (better)

Compared with 78973102bf54 measured in the same runner job.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

LLGo baseline benchmarks

ab4626f46608 | workflow run | long-term charts

Program measurements

Platform Workload File size vs base Text size vs base Build vs base Run vs base
Linux cprintf 8568 B 0 B / +0.0% 387 B 0 B / +0.0% 1.352 s +56.59 ms / +4.4% (worse) 1.501 ms +106.9 us / +7.7% (worse)
Linux cprintf-lto 8408 B 0 B / +0.0% 368 B 0 B / +0.0% 1.260 s +75.94 ms / +6.4% (worse) 1.391 ms +14.95 us / +1.1% (worse)
Linux fmtprintf 4886280 B 0 B / +0.0% 504761 B 0 B / +0.0% 9.643 s -331.8 ms / -3.3% (better) 3.732 ms -312.9 us / -7.7% (better)
Linux fmtprintf-lto 3616568 B 0 B / +0.0% 443075 B 0 B / +0.0% 19.595 s -485.4 ms / -2.4% (better) 3.470 ms +256.6 us / +8.0% (worse)
Linux println 675040 B 0 B / +0.0% 16855 B 0 B / +0.0% 1.265 s -18.83 ms / -1.5% (better) 1.730 ms -12.77 us / -0.7% (better)
Linux println-lto 190040 B 0 B / +0.0% 14273 B 0 B / +0.0% 1.681 s +15.44 ms / +0.9% (worse) 1.838 ms +105.9 us / +6.1% (worse)
macOS cprintf 50736 B 0 B / +0.0% 4409 B 0 B / +0.0% 1.334 s -530 ms / -28.4% (better) 3.298 ms -6.628 ms / -66.8% (better)
macOS cprintf-lto 50496 B 0 B / +0.0% 161 B 0 B / +0.0% 949.504 ms -552.9 ms / -36.8% (better) 2.349 ms -4.832 ms / -67.3% (better)
macOS fmtprintf 1774368 B 0 B / +0.0% 880529 B 0 B / +0.0% 7.013 s -1.93 s / -21.6% (better) 10.259 ms -4.863 ms / -32.2% (better)
macOS fmtprintf-lto 1361232 B 0 B / +0.0% 764957 B 0 B / +0.0% 15.950 s -523.2 ms / -3.2% (better) 7.400 ms -1.025 ms / -12.2% (better)
macOS println 99344 B 0 B / +0.0% 24216 B 0 B / +0.0% 895.720 ms -729.6 ms / -44.9% (better) 3.854 ms -2.022 ms / -34.4% (better)
macOS println-lto 83664 B 0 B / +0.0% 21457 B 0 B / +0.0% 1.491 s -431.8 ms / -22.5% (better) 5.765 ms -1.839 ms / -24.2% (better)
Windows MinGW cprintf 651776 B 0 B / +0.0% 4550 B 0 B / +0.0% 1.713 s -22.76 ms / -1.3% (better) 3.622 ms +156.7 us / +4.5% (worse)
Windows MinGW cprintf-lto 43520 B 0 B / +0.0% 4486 B 0 B / +0.0% 1.730 s -31.65 ms / -1.8% (better) 3.363 ms -138.9 us / -4.0% (better)
Windows MinGW fmtprintf 5440000 B 0 B / +0.0% 604454 B 0 B / +0.0% 7.369 s -453.9 ms / -5.8% (better) 8.056 ms -1.27 ms / -13.6% (better)
Windows MinGW fmtprintf-lto 4144128 B 0 B / +0.0% 551574 B 0 B / +0.0% 14.820 s -935.9 ms / -5.9% (better) 7.904 ms -1.01 ms / -11.3% (better)
Windows MinGW println 707072 B 0 B / +0.0% 25190 B 0 B / +0.0% 1.672 s -102.6 ms / -5.8% (better) 6.471 ms -417 us / -6.1% (better)
Windows MinGW println-lto 208896 B 0 B / +0.0% 22054 B 0 B / +0.0% 2.005 s -74 ms / -3.6% (better) 6.478 ms -609.6 us / -8.6% (better)
Windows MinGW 386 cprintf 601600 B 0 B / +0.0% 5326 B 0 B / +0.0% 1.711 s -6.392 ms / -0.4% (better) 5.092 ms -626.5 us / -11.0% (better)
Windows MinGW 386 cprintf-lto 103424 B 0 B / +0.0% 5094 B 0 B / +0.0% 1.764 s +9.924 ms / +0.6% (worse) 5.080 ms -127.1 us / -2.4% (better)
Windows MinGW 386 fmtprintf 4745728 B 0 B / +0.0% 472478 B 0 B / +0.0% 7.543 s -15.38 ms / -0.2% (better) 10.418 ms -392.2 us / -3.6% (better)
Windows MinGW 386 fmtprintf-lto 4148736 B 0 B / +0.0% 451114 B 0 B / +0.0% 14.696 s -112.5 ms / -0.8% (better) 10.402 ms -753.8 us / -6.8% (better)
Windows MinGW 386 println 653312 B 0 B / +0.0% 21490 B 0 B / +0.0% 1.725 s -9.882 ms / -0.6% (better) 9.640 ms +893.5 us / +10.2% (worse)
Windows MinGW 386 println-lto 258560 B 0 B / +0.0% 19306 B 0 B / +0.0% 2.007 s -40.77 ms / -2.0% (better) 9.575 ms +557.1 us / +6.2% (worse)
Windows MinGW ARM64 cprintf 660992 B 0 B / +0.0% 4408 B 0 B / +0.0% 1.928 s +4.857 ms / +0.3% (worse) 6.355 ms -575.3 us / -8.3% (better)
Windows MinGW ARM64 cprintf-lto 43520 B 0 B / +0.0% 4340 B 0 B / +0.0% 1.969 s -2.132 ms / -0.1% (better) 6.590 ms -258.7 us / -3.8% (better)
Windows MinGW ARM64 fmtprintf 5348864 B 0 B / +0.0% 512636 B 0 B / +0.0% 7.124 s +72.51 ms / +1.0% (worse) 13.235 ms +764.3 us / +6.1% (worse)
Windows MinGW ARM64 fmtprintf-lto 4312064 B 0 B / +0.0% 478828 B 0 B / +0.0% 14.012 s +198.6 ms / +1.4% (worse) 13.278 ms +4.9 us / +0.03692% (worse)
Windows MinGW ARM64 println 714240 B 0 B / +0.0% 23884 B 0 B / +0.0% 1.915 s -17.42 ms / -0.9% (better) 11.144 ms -184.7 us / -1.6% (better)
Windows MinGW ARM64 println-lto 215552 B 0 B / +0.0% 21232 B 0 B / +0.0% 2.181 s +33.4 ms / +1.6% (worse) 11.036 ms +158.3 us / +1.5% (worse)
Windows MSVC cprintf 893952 B 0 B / +0.0% 65798 B 0 B / +0.0% 1.840 s +41.29 ms / +2.3% (worse) 3.432 ms +45.8 us / +1.4% (worse)
Windows MSVC cprintf-lto 289792 B 0 B / +0.0% 65734 B 0 B / +0.0% 1.639 s +23.63 ms / +1.5% (worse) 3.300 ms -26.6 us / -0.8% (better)
Windows MSVC fmtprintf 5741056 B 0 B / +0.0% 700022 B 0 B / +0.0% 7.223 s +37.47 ms / +0.5% (worse) 8.987 ms +352.5 us / +4.1% (worse)
Windows MSVC fmtprintf-lto 4467200 B 0 B / +0.0% 651110 B 0 B / +0.0% 14.191 s -262.6 ms / -1.8% (better) 9.756 ms -224.4 us / -2.2% (better)
Windows MSVC println 1017856 B 0 B / +0.0% 120854 B 0 B / +0.0% 1.599 s -10.28 ms / -0.6% (better) 7.865 ms +1.063 ms / +15.6% (worse)
Windows MSVC println-lto 528384 B 0 B / +0.0% 118390 B 0 B / +0.0% 1.904 s -4.854 ms / -0.3% (better) 7.766 ms +51 us / +0.7% (worse)
Windows MSVC 386 cprintf 513536 B 0 B / +0.0% 3931 B 0 B / +0.0% 1.565 s +62.97 ms / +4.2% (worse) 5.692 ms +35.1 us / +0.6% (worse)
Windows MSVC 386 cprintf-lto 44032 B 0 B / +0.0% 3853 B 0 B / +0.0% 1.806 s +267.7 ms / +17.4% (worse) 5.662 ms -100 us / -1.7% (better)
Windows MSVC 386 fmtprintf 4480000 B 0 B / +0.0% 455868 B 0 B / +0.0% 7.365 s +96.86 ms / +1.3% (worse) 12.665 ms +1.421 ms / +12.6% (worse)
Windows MSVC 386 fmtprintf-lto 3899904 B 0 B / +0.0% 426651 B 0 B / +0.0% 13.645 s +220.9 ms / +1.6% (worse) 12.245 ms +41.3 us / +0.3% (worse)
Windows MSVC 386 println 567808 B 0 B / +0.0% 20340 B 0 B / +0.0% 1.586 s +33.1 ms / +2.1% (worse) 10.682 ms +1.358 ms / +14.6% (worse)
Windows MSVC 386 println-lto 199168 B 0 B / +0.0% 18501 B 0 B / +0.0% 1.814 s +8.813 ms / +0.5% (worse) 9.345 ms +348.4 us / +3.9% (worse)
Windows MSVC ARM64 cprintf 662528 B 0 B / +0.0% 4192 B 0 B / +0.0% 1.553 s +24.11 ms / +1.6% (worse) 6.384 ms +271.6 us / +4.4% (worse)
Windows MSVC ARM64 cprintf-lto 47616 B 0 B / +0.0% 4084 B 0 B / +0.0% 1.561 s +17.32 ms / +1.1% (worse) 6.384 ms -188.5 us / -2.9% (better)
Windows MSVC ARM64 fmtprintf 5345280 B 0 B / +0.0% 512580 B 0 B / +0.0% 6.432 s -15.05 ms / -0.2% (better) 12.787 ms -360.9 us / -2.7% (better)
Windows MSVC ARM64 fmtprintf-lto 4318208 B 0 B / +0.0% 479488 B 0 B / +0.0% 12.541 s -94.37 ms / -0.7% (better) 13.226 ms +624.4 us / +5.0% (worse)
Windows MSVC ARM64 println 715776 B 0 B / +0.0% 23908 B 0 B / +0.0% 1.553 s +5.04 ms / +0.3% (worse) 11.112 ms +10.3 us / +0.1% (worse)
Windows MSVC ARM64 println-lto 220672 B 0 B / +0.0% 21380 B 0 B / +0.0% 1.758 s -10.41 ms / -0.6% (better) 11.243 ms -222.6 us / -1.9% (better)
Core language and compiler benchmarks
Platform Benchmark ns/op vs base
Linux BenchmarkLookupPCRandom 14.710 ns/op +0.02 ns/op / +0.1% (worse)
Linux BenchmarkMergeCompilerFlags 209.900 ns/op -41 ns/op / -16.3% (better)
Linux BenchmarkMergeLinkerFlags 138.100 ns/op -18.6 ns/op / -11.9% (better)
Linux BenchmarkChannelBuffered 55.100 ns/op +0.29 ns/op / +0.5% (worse)
Linux BenchmarkChannelHandoff 12677 ns/op -979 ns/op / -7.2% (better)
Linux BenchmarkDefer 49.800 ns/op -3.3 ns/op / -6.2% (better)
Linux BenchmarkDirectCall 1.564 ns/op +0.049 ns/op / +3.2% (worse)
Linux BenchmarkGlobalRead 1.167 ns/op -0.389 ns/op / -25.0% (better)
Linux BenchmarkGlobalWrite 7.763 ns/op -0.019 ns/op / -0.2% (better)
Linux BenchmarkGoroutine 26104 ns/op -681 ns/op / -2.5% (better)
Linux BenchmarkInterfaceCall 6.161 ns/op -0.083 ns/op / -1.3% (better)
Linux BenchmarkRuntimeGetG 3.005 ns/op +0.172 ns/op / +6.1% (worse)
macOS BenchmarkLookupPCRandom 13.580 ns/op -8.53 ns/op / -38.6% (better)
macOS BenchmarkMergeCompilerFlags 139.500 ns/op -57.8 ns/op / -29.3% (better)
macOS BenchmarkMergeLinkerFlags 97.250 ns/op -38.95 ns/op / -28.6% (better)
macOS BenchmarkChannelBuffered 30.920 ns/op -2.41 ns/op / -7.2% (better)
macOS BenchmarkChannelHandoff 7474 ns/op -5756 ns/op / -43.5% (better)
macOS BenchmarkDefer 43.570 ns/op -2.63 ns/op / -5.7% (better)
macOS BenchmarkDirectCall 1.106 ns/op -0.214 ns/op / -16.2% (better)
macOS BenchmarkGlobalRead 1.172 ns/op -0.16 ns/op / -12.0% (better)
macOS BenchmarkGlobalWrite 1.309 ns/op -0.526 ns/op / -28.7% (better)
macOS BenchmarkGoroutine 84026 ns/op +41143 ns/op / +95.9% (worse)
macOS BenchmarkInterfaceCall 4.724 ns/op -0.691 ns/op / -12.8% (better)
macOS BenchmarkRuntimeGetG 2.612 ns/op -0.194 ns/op / -6.9% (better)
Windows MinGW BenchmarkLookupPCRandom 13.110 ns/op -0.01 ns/op / -0.1% (better)
Windows MinGW BenchmarkMergeCompilerFlags 611.300 ns/op +5.2 ns/op / +0.9% (worse)
Windows MinGW BenchmarkMergeLinkerFlags 541.900 ns/op +10.3 ns/op / +1.9% (worse)
Windows MinGW BenchmarkChannelBuffered 31.610 ns/op +1.08 ns/op / +3.5% (worse)
Windows MinGW BenchmarkChannelHandoff 961.600 ns/op +65.7 ns/op / +7.3% (worse)
Windows MinGW BenchmarkDefer 55.160 ns/op -0.67 ns/op / -1.2% (better)
Windows MinGW BenchmarkDirectCall 1.550 ns/op +0.002 ns/op / +0.1% (worse)
Windows MinGW BenchmarkGlobalRead 1.548 ns/op -0.003 ns/op / -0.2% (better)
Windows MinGW BenchmarkGlobalWrite 2.468 ns/op -0.006 ns/op / -0.2% (better)
Windows MinGW BenchmarkGoroutine 91129 ns/op +2527 ns/op / +2.9% (worse)
Windows MinGW BenchmarkInterfaceCall 8.391 ns/op +0.019 ns/op / +0.2% (worse)
Windows MinGW BenchmarkRuntimeGetG 2.482 ns/op +0.314 ns/op / +14.5% (worse)
Windows MinGW 386 BenchmarkLookupPCRandom 26.530 ns/op +0.05 ns/op / +0.2% (worse)
Windows MinGW 386 BenchmarkMergeCompilerFlags 700.400 ns/op -50.4 ns/op / -6.7% (better)
Windows MinGW 386 BenchmarkMergeLinkerFlags 679.600 ns/op -14.3 ns/op / -2.1% (better)
Windows MinGW 386 BenchmarkChannelBuffered 39.800 ns/op -1.5 ns/op / -3.6% (better)
Windows MinGW 386 BenchmarkChannelHandoff 851.400 ns/op +26.6 ns/op / +3.2% (worse)
Windows MinGW 386 BenchmarkDefer 42.980 ns/op -0.49 ns/op / -1.1% (better)
Windows MinGW 386 BenchmarkDirectCall 1.549 ns/op -0.003 ns/op / -0.2% (better)
Windows MinGW 386 BenchmarkGlobalRead 1.551 ns/op 0 ns/op / +0.0%
Windows MinGW 386 BenchmarkGlobalWrite 7.778 ns/op -0.012 ns/op / -0.2% (better)
Windows MinGW 386 BenchmarkGoroutine 105263 ns/op +361 ns/op / +0.3% (worse)
Windows MinGW 386 BenchmarkInterfaceCall 8.386 ns/op -0.008 ns/op / -0.1% (better)
Windows MinGW 386 BenchmarkRuntimeGetG 1.926 ns/op -0.003 ns/op / -0.2% (better)
Windows MinGW ARM64 BenchmarkLookupPCRandom 12.170 ns/op +0.01 ns/op / +0.1% (worse)
Windows MinGW ARM64 BenchmarkMergeCompilerFlags 558.100 ns/op -11.6 ns/op / -2.0% (better)
Windows MinGW ARM64 BenchmarkMergeLinkerFlags 527.100 ns/op -8.4 ns/op / -1.6% (better)
Windows MinGW ARM64 BenchmarkChannelBuffered 37.410 ns/op -0.09 ns/op / -0.2% (better)
Windows MinGW ARM64 BenchmarkChannelHandoff 2848 ns/op +738 ns/op / +35.0% (worse)
Windows MinGW ARM64 BenchmarkDefer 58.920 ns/op +2.6 ns/op / +4.6% (worse)
Windows MinGW ARM64 BenchmarkDirectCall 0.590 ns/op -0.0733 ns/op / -11.1% (better)
Windows MinGW ARM64 BenchmarkGlobalRead 0.663 ns/op +0.0001 ns/op / +0.01507% (worse)
Windows MinGW ARM64 BenchmarkGlobalWrite 0.737 ns/op -0.0014 ns/op / -0.2% (better)
Windows MinGW ARM64 BenchmarkGoroutine 64143 ns/op +2719 ns/op / +4.4% (worse)
Windows MinGW ARM64 BenchmarkInterfaceCall 4.164 ns/op -0.07 ns/op / -1.7% (better)
Windows MinGW ARM64 BenchmarkRuntimeGetG 1.769 ns/op 0 ns/op / +0.0%
Windows MSVC BenchmarkLookupPCRandom 13.060 ns/op -0.18 ns/op / -1.4% (better)
Windows MSVC BenchmarkMergeCompilerFlags 661.100 ns/op +36.2 ns/op / +5.8% (worse)
Windows MSVC BenchmarkMergeLinkerFlags 590.600 ns/op +34.7 ns/op / +6.2% (worse)
Windows MSVC BenchmarkChannelBuffered 28.620 ns/op -3.59 ns/op / -11.1% (better)
Windows MSVC BenchmarkChannelHandoff 1092 ns/op +30 ns/op / +2.8% (worse)
Windows MSVC BenchmarkDefer 55.600 ns/op -0.52 ns/op / -0.9% (better)
Windows MSVC BenchmarkDirectCall 1.548 ns/op 0 ns/op / +0.0%
Windows MSVC BenchmarkGlobalRead 1.549 ns/op -0.003 ns/op / -0.2% (better)
Windows MSVC BenchmarkGlobalWrite 2.459 ns/op -0.04 ns/op / -1.6% (better)
Windows MSVC BenchmarkGoroutine 88343 ns/op -2010 ns/op / -2.2% (better)
Windows MSVC BenchmarkInterfaceCall 8.374 ns/op -0.328 ns/op / -3.8% (better)
Windows MSVC BenchmarkRuntimeGetG 2.482 ns/op -0.006 ns/op / -0.2% (better)
Windows MSVC 386 BenchmarkLookupPCRandom 26.570 ns/op -0.02 ns/op / -0.1% (better)
Windows MSVC 386 BenchmarkMergeCompilerFlags 775.800 ns/op +9.3 ns/op / +1.2% (worse)
Windows MSVC 386 BenchmarkMergeLinkerFlags 718.300 ns/op +35.7 ns/op / +5.2% (worse)
Windows MSVC 386 BenchmarkChannelBuffered 38.580 ns/op -0.29 ns/op / -0.7% (better)
Windows MSVC 386 BenchmarkChannelHandoff 842.700 ns/op -33.3 ns/op / -3.8% (better)
Windows MSVC 386 BenchmarkDefer 47.530 ns/op -1.46 ns/op / -3.0% (better)
Windows MSVC 386 BenchmarkDirectCall 1.862 ns/op +0.314 ns/op / +20.3% (worse)
Windows MSVC 386 BenchmarkGlobalRead 1.555 ns/op +0.005 ns/op / +0.3% (worse)
Windows MSVC 386 BenchmarkGlobalWrite 7.782 ns/op +0.003 ns/op / +0.03857% (worse)
Windows MSVC 386 BenchmarkGoroutine 115302 ns/op +493 ns/op / +0.4% (worse)
Windows MSVC 386 BenchmarkInterfaceCall 8.406 ns/op +0.019 ns/op / +0.2% (worse)
Windows MSVC 386 BenchmarkRuntimeGetG 1.927 ns/op -0.244 ns/op / -11.2% (better)
Windows MSVC ARM64 BenchmarkLookupPCRandom 12.040 ns/op 0 ns/op / +0.0%
Windows MSVC ARM64 BenchmarkMergeCompilerFlags 558.400 ns/op -7.3 ns/op / -1.3% (better)
Windows MSVC ARM64 BenchmarkMergeLinkerFlags 529.100 ns/op +2.7 ns/op / +0.5% (worse)
Windows MSVC ARM64 BenchmarkChannelBuffered 37.540 ns/op -1.35 ns/op / -3.5% (better)
Windows MSVC ARM64 BenchmarkChannelHandoff 1878 ns/op -152 ns/op / -7.5% (better)
Windows MSVC ARM64 BenchmarkDefer 60.480 ns/op +1.09 ns/op / +1.8% (worse)
Windows MSVC ARM64 BenchmarkDirectCall 0.589 ns/op -0.0736 ns/op / -11.1% (better)
Windows MSVC ARM64 BenchmarkGlobalRead 0.663 ns/op -0.0001 ns/op / -0.01507% (better)
Windows MSVC ARM64 BenchmarkGlobalWrite 3.832 ns/op +0.036 ns/op / +0.9% (worse)
Windows MSVC ARM64 BenchmarkGoroutine 53738 ns/op -297 ns/op / -0.5% (better)
Windows MSVC ARM64 BenchmarkInterfaceCall 4.137 ns/op -0.097 ns/op / -2.3% (better)
Windows MSVC ARM64 BenchmarkRuntimeGetG 1.805 ns/op +0.034 ns/op / +1.9% (worse)
Timer runtime benchmarks
Platform Operation and runtime ns/op vs base
Linux AfterFuncZeroDelivery/Go 912.400 ns/op -78.4 ns/op / -7.9% (better)
Linux AfterFuncZeroDelivery/LLGo 38903 ns/op -1407 ns/op / -3.5% (better)
Linux CreateStop/Go 302.200 ns/op -15.4 ns/op / -4.8% (better)
Linux CreateStop/LLGo 1915 ns/op -83 ns/op / -4.2% (better)
Linux RearmStopped/Go 115.800 ns/op +0.9 ns/op / +0.8% (worse)
Linux RearmStopped/LLGo 1435 ns/op +254 ns/op / +21.5% (worse)
Linux ResetActive/Go 68.560 ns/op +1.09 ns/op / +1.6% (worse)
Linux ResetActive/LLGo 802.200 ns/op -15.1 ns/op / -1.8% (better)
Linux ResetHeap1024/Go 67.180 ns/op -0.08 ns/op / -0.1% (better)
Linux ResetHeap1024/LLGo 178.100 ns/op -3.6 ns/op / -2.0% (better)
macOS AfterFuncZeroDelivery/Go 496 ns/op -160 ns/op / -24.4% (better)
macOS AfterFuncZeroDelivery/LLGo 107665 ns/op -23072 ns/op / -17.6% (better)
macOS CreateStop/Go 155.200 ns/op -105.1 ns/op / -40.4% (better)
macOS CreateStop/LLGo 433.100 ns/op -299.4 ns/op / -40.9% (better)
macOS RearmStopped/Go 68.870 ns/op -31.53 ns/op / -31.4% (better)
macOS RearmStopped/LLGo 421.600 ns/op -253.9 ns/op / -37.6% (better)
macOS ResetActive/Go 41.390 ns/op -28.11 ns/op / -40.4% (better)
macOS ResetActive/LLGo 187.900 ns/op -70.4 ns/op / -27.3% (better)
macOS ResetHeap1024/Go 65.500 ns/op +7.79 ns/op / +13.5% (worse)
macOS ResetHeap1024/LLGo 93.950 ns/op -39.05 ns/op / -29.4% (better)
Windows MinGW AfterFuncZeroDelivery/Go 555.200 ns/op +15.6 ns/op / +2.9% (worse)
Windows MinGW AfterFuncZeroDelivery/LLGo 182484 ns/op -1271 ns/op / -0.7% (better)
Windows MinGW CreateStop/Go 115 ns/op -2 ns/op / -1.7% (better)
Windows MinGW CreateStop/LLGo 424.200 ns/op +9.9 ns/op / +2.4% (worse)
Windows MinGW RearmStopped/Go 31.320 ns/op -0.17 ns/op / -0.5% (better)
Windows MinGW RearmStopped/LLGo 279.100 ns/op -6.9 ns/op / -2.4% (better)
Windows MinGW ResetActive/Go 20.050 ns/op -0.03 ns/op / -0.1% (better)
Windows MinGW ResetActive/LLGo 166.200 ns/op -7.4 ns/op / -4.3% (better)
Windows MinGW ResetHeap1024/Go 20.450 ns/op -0.08 ns/op / -0.4% (better)
Windows MinGW ResetHeap1024/LLGo 125.600 ns/op +0.3 ns/op / +0.2% (worse)
Windows MinGW 386 AfterFuncZeroDelivery/Go 964.200 ns/op +16.9 ns/op / +1.8% (worse)
Windows MinGW 386 AfterFuncZeroDelivery/LLGo 200324 ns/op +3817 ns/op / +1.9% (worse)
Windows MinGW 386 CreateStop/Go 191.400 ns/op +1.1 ns/op / +0.6% (worse)
Windows MinGW 386 CreateStop/LLGo 505.100 ns/op +0.2 ns/op / +0.03961% (worse)
Windows MinGW 386 RearmStopped/Go 63.610 ns/op +0.41 ns/op / +0.6% (worse)
Windows MinGW 386 RearmStopped/LLGo 361.800 ns/op +2.7 ns/op / +0.8% (worse)
Windows MinGW 386 ResetActive/Go 39.050 ns/op -0.09 ns/op / -0.2% (better)
Windows MinGW 386 ResetActive/LLGo 924 ns/op -38.5 ns/op / -4.0% (better)
Windows MinGW 386 ResetHeap1024/Go 39.470 ns/op -0.13 ns/op / -0.3% (better)
Windows MinGW 386 ResetHeap1024/LLGo 188.700 ns/op +0.3 ns/op / +0.2% (worse)
Windows MinGW ARM64 AfterFuncZeroDelivery/Go 661.800 ns/op -3.6 ns/op / -0.5% (better)
Windows MinGW ARM64 AfterFuncZeroDelivery/LLGo 149439 ns/op +3815 ns/op / +2.6% (worse)
Windows MinGW ARM64 CreateStop/Go 197.200 ns/op -3.3 ns/op / -1.6% (better)
Windows MinGW ARM64 CreateStop/LLGo 359.400 ns/op -5.6 ns/op / -1.5% (better)
Windows MinGW ARM64 RearmStopped/Go 70.660 ns/op +0.06 ns/op / +0.1% (worse)
Windows MinGW ARM64 RearmStopped/LLGo 251.700 ns/op +0.9 ns/op / +0.4% (worse)
Windows MinGW ARM64 ResetActive/Go 31.060 ns/op +0.01 ns/op / +0.03221% (worse)
Windows MinGW ARM64 ResetActive/LLGo 123.400 ns/op -6.1 ns/op / -4.7% (better)
Windows MinGW ARM64 ResetHeap1024/Go 31.180 ns/op +0.1 ns/op / +0.3% (worse)
Windows MinGW ARM64 ResetHeap1024/LLGo 125.400 ns/op 0 ns/op / +0.0%
Windows MSVC AfterFuncZeroDelivery/Go 549.500 ns/op -6.1 ns/op / -1.1% (better)
Windows MSVC AfterFuncZeroDelivery/LLGo 175753 ns/op +810 ns/op / +0.5% (worse)
Windows MSVC CreateStop/Go 116.500 ns/op -1.8 ns/op / -1.5% (better)
Windows MSVC CreateStop/LLGo 423.700 ns/op -74.2 ns/op / -14.9% (better)
Windows MSVC RearmStopped/Go 31.420 ns/op -0.18 ns/op / -0.6% (better)
Windows MSVC RearmStopped/LLGo 258.200 ns/op -2.4 ns/op / -0.9% (better)
Windows MSVC ResetActive/Go 20.120 ns/op -0.23 ns/op / -1.1% (better)
Windows MSVC ResetActive/LLGo 146.600 ns/op +6.6 ns/op / +4.7% (worse)
Windows MSVC ResetHeap1024/Go 20.470 ns/op +0.03 ns/op / +0.1% (worse)
Windows MSVC ResetHeap1024/LLGo 124.300 ns/op -1.2 ns/op / -1.0% (better)
Windows MSVC 386 AfterFuncZeroDelivery/Go 955.100 ns/op -7 ns/op / -0.7% (better)
Windows MSVC 386 AfterFuncZeroDelivery/LLGo 205285 ns/op +3102 ns/op / +1.5% (worse)
Windows MSVC 386 CreateStop/Go 197.300 ns/op +0.3 ns/op / +0.2% (worse)
Windows MSVC 386 CreateStop/LLGo 467.500 ns/op +0.1 ns/op / +0.02139% (worse)
Windows MSVC 386 RearmStopped/Go 63.600 ns/op +0.05 ns/op / +0.1% (worse)
Windows MSVC 386 RearmStopped/LLGo 318.800 ns/op -15.6 ns/op / -4.7% (better)
Windows MSVC 386 ResetActive/Go 39.020 ns/op -0.01 ns/op / -0.02562% (better)
Windows MSVC 386 ResetActive/LLGo 961 ns/op +696.1 ns/op / +262.8% (worse)
Windows MSVC 386 ResetHeap1024/Go 39.570 ns/op -0.01 ns/op / -0.02527% (better)
Windows MSVC 386 ResetHeap1024/LLGo 170.600 ns/op -2.6 ns/op / -1.5% (better)
Windows MSVC ARM64 AfterFuncZeroDelivery/Go 669.300 ns/op +4.9 ns/op / +0.7% (worse)
Windows MSVC ARM64 AfterFuncZeroDelivery/LLGo 162819 ns/op -2013 ns/op / -1.2% (better)
Windows MSVC ARM64 CreateStop/Go 195 ns/op -3.3 ns/op / -1.7% (better)
Windows MSVC ARM64 CreateStop/LLGo 433 ns/op -0.5 ns/op / -0.1% (better)
Windows MSVC ARM64 RearmStopped/Go 70.530 ns/op -0.1 ns/op / -0.1% (better)
Windows MSVC ARM64 RearmStopped/LLGo 275.500 ns/op +1.7 ns/op / +0.6% (worse)
Windows MSVC ARM64 ResetActive/Go 31.090 ns/op +0.27 ns/op / +0.9% (worse)
Windows MSVC ARM64 ResetActive/LLGo 142.200 ns/op -2.2 ns/op / -1.5% (better)
Windows MSVC ARM64 ResetHeap1024/Go 31.170 ns/op +0.1 ns/op / +0.3% (worse)
Windows MSVC ARM64 ResetHeap1024/LLGo 136.100 ns/op -0.1 ns/op / -0.1% (better)

Compared with be669acd80cc measured in the same runner job.

@cpunion
cpunion force-pushed the codex/wasm-js-cross-worker-20261006 branch 2 times, most recently from 8cbc757 to b76a7b8 Compare October 6, 2026 00:15
@cpunion
cpunion force-pushed the codex/wasm-js-cross-worker-20261006 branch from b76a7b8 to 8f26b66 Compare October 6, 2026 01:26
@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@fennoai please review the updated single commit. It adds cooperative finalizer locking, callback scheduling/registry fixes and regression coverage. Complete local worker acceptance and the Node/Chrome 2/4-worker Memory32/64 JS matrix pass; no CI jobs were retried.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Re-review complete

Reviewed the updated commit (8f26b66) across code-quality, performance, security/concurrency, and documentation passes. Posted a PR review: #pullrequestreview-5422927013.

All three findings from the prior review are resolved: build-tag coverage gap, local-path closure allocation (now //go:noinline remoteX helpers), and the stale jsCalls channel after Goexit.

New mechanisms verified sound: the callback scheduling state machine (jsCallbackID/callbackResume/popWasmCallbackChild), runqueue.Remove/Front invariants + tests, the cooperative finalizer locking contract, and the emscripten_set_immediate latency change. No memory-safety, use-after-free, deadlock, or data-race defects found.

Remaining notes (all minor):

  • [P2] popWasmCallbackChild + runqueue.Remove do two O(n) scans per pop on the callback-active path → can degrade to O(n²); the scan already holds previous and could splice in place.
  • [P3] callbackResume is a per-worker flag read only against the top event — bounded away from corruption, but worth scoping per wasmJSEvent or documenting the invariant.
  • Stale "realm affinity" comment at proc_wasm_workers.go:47.
  • A possible dropped-finalizer ordering window at mfinal_wasm.go:97-114 that appears mitigated by KeepAlive — flagged for author confirmation.

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of updated commit (8f26b66)

Reviewed the full PR diff at the new head. This revision is high quality and addresses every finding from my prior review:

  • Build-tag gap fixed — worker_call_single.go, func_mutex_other.go, and mfinal_lock_wasm_other.go now use the (!llgo || !llgo.wasm.workers) form, so the matrix is fully covered with no gap or overlap.
  • Local-path closure allocation fixed — forwarding closures are isolated in //go:noinline remoteX helpers (worker_calls.go), so SSA no longer hoists the heap alloc before the isRemoteJSWorker() check; the worker-0 fast path is allocation-free and the helpers are dead-code-eliminated in single-worker builds.
  • Stale jsCalls after Goexit fixed — CallMainJSWorker now detaches (jsCalls = nil) and closes the channel before Goexit(), and finishWasmG guards on non-nil, so there is no double-close.

I verified the new mechanisms the commit introduces:

  • Callback scheduling (popWasmWorkerRunq/popWasmCallbackChild + jsCallbackID + callbackResume): children are drained before reopening a host callback stack; id 0 is reserved as the "no callback" sentinel so non-callback children never spuriously match; runqueue.Remove preserves head/tail/size/queued invariants on every branch and is covered by the new runqueue_test.go.
  • Finalizer locking (mfinal_wasm.go + mfinal_lock_wasm_{workers,other}.go): the lazy-init-returns-locked contract is honored at all four call sites with a matching unlock on every path; the collector callback runs callWasmFinalizer outside the lock. No deadlock or missed unlock.
  • emscripten_set_immediate (workers.c): correctly removes the setTimeout(0) ~4ms nested-timeout clamp from every cross-worker handoff; available under the pthread/worker targets used here.

No memory-safety, use-after-free, deadlock, or data-race defects were found. The notes below are minor (performance + stale comments + one robustness hardening).

Notes not placed inline (outside diff hunks or whole-mechanism):

  • Stale "realm affinity" comment — runtime/internal/runtime/proc_wasm_workers.go:47: // Override both inherited realm affinity and callback-event pinning still describes the removed jsRealm model. In the new code, independentSpawn gates only the callback-event-pinning branch; worker affinity is now carried by spawnWorker (read first at line 316) and is unaffected by independentSpawn. Suggest rewording to reference only callback-event pinning (and jsCallbackID inheritance), dropping "both … realm affinity."
  • Possible dropped-finalizer window (please confirm) — mfinal_wasm.go:97-114: tinygogc.AddFinalizer registers the collector record (line 97) before the entry is published into wasmFinalizers.m[key] (line 113); the callback early-returns if m[key] != entry. This looks mitigated by KeepAlive(obj) at line 115 (the object stays reachable past the map store, so the collector cannot queue the callback in that window), but it is worth a quick confirmation that objPtr cannot be collected independently of what KeepAlive(obj) retains (e.g. interior pointers). If safe, a one-line comment noting the KeepAlive ordering guarantee would make the intent explicit.

Comment thread runtime/internal/runtime/proc_wasm_workers.go
Comment thread runtime/internal/runtime/proc_wasm_workers.go Outdated
@cpunion
cpunion force-pushed the codex/wasm-js-cross-worker-20261006 branch from 8f26b66 to 3234270 Compare October 6, 2026 02:11
@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@fennoai please verify the two updated findings at 323427074: callback child removal no longer rescans the queue, and resume intent is scoped to each nested event. Full local worker acceptance and optimized O3/Os/Oz Node/Chrome checks pass. The CI timeout fix retains all 128 filesystem tasks, GC calls and the successful-exit check, with a 120-second browser budget for that stress case; no CI jobs were retried.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Both findings verified fixed at 3234270

[P2] Callback child removal no longer rescans the queue — ✅ confirmed.

  • runqueue.go adds RemoveAfter(previous), which splices out previous.RunqueueNext() in O(1); Remove now delegates to it.
  • popWasmCallbackChild tracks previous through its single Front()→RunqueueNext() scan and calls RemoveAfter(previous) on a match, so the redundant second O(n) traversal is gone. The N-sibling drain is no longer O(n²). (popWasmWorkerRunq's Remove(e.gp) for resuming the callback's own G is a single-node lookup, not the sibling loop, so the quadratic path is eliminated.)
  • previous lags candidate by exactly one node and is advanced even when candidate == e.gp is skipped, so RemoveAfter(previous) removes the correct child, including when the match immediately follows e.gp.
  • New TestQueueRemoveAfterCursor covers tail-cursor (no-op), mid-cursor removal + reuse + FIFO order, and front removal via a nil cursor, with head/tail/length assertions.

[P3] Resume intent scoped to each nested event — ✅ confirmed.

  • The per-worker callbackResume bool is removed entirely (no references remain anywhere in runtime/).
  • Resume intent now lives on wasmJSEvent.resume, initialized true per event; popWasmWorkerRunq reads/writes e.resume on the top-of-stack event only. Each nested event therefore carries its own resume intent, and HandleWasmEvent restores the outer jsCallbackID on unwind. The stale-flag-across-events hazard is gone.

No regressions observed in the touched scheduling path: the jsCallbackID sentinel-0 convention, the PollWasmEvent _Gwaiting gate, and the finalizer-lock contract are all unchanged from the previously-reviewed revision. I did not re-run the acceptance/CI matrix; I'm relying on your note that local worker acceptance, the O3/Os/Oz Node/Chrome checks, and the CI-timeout stress case (128 filesystem tasks, GC calls, success-exit check, 120s browser budget) pass.

View job run

@cpunion
cpunion force-pushed the codex/wasm-js-cross-worker-20261006 branch from 3234270 to 4bd1194 Compare October 6, 2026 03:02
@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@fennoai please review 4bd1194da. The implementation audit keeps the shared API and existing build-tag adapters: optimized single-worker forwarding checks disappear; an explicit constant produced an identical Wasm code section and gave no O0 size reduction.

The focused changes avoid remote requests for reserved primitive Type checks and ValueOf(Value/Func/nil/bool), isolate host-only captures in a noinline helper, and use the nonpreempting funcMutex for callback poll registration too. The remote primitive benchmark (5000 Type/ValueOf pairs) changed from a median 149 ms/about 5 MB to 0.25 ms/0 bytes; this does not describe general JS host calls. The executor regression fails against the old forwarding implementation and passes the final implementation.

Consolidated the 128-task GC stress in dedicated Node/Chrome worker acceptance, with an explicit stress argument; optimized filesystem smoke remains covered. Removed the generic browser timeout configuration and optimization timeout changes from this PR. The dedicated Memory64 browser stress has a bounded budget; other worker checks retain theirs. The filesystem success marker now appears only after successful cleanup, and an intentional panic is rejected by both Node and Chrome without that marker. No CI jobs were retried.

Complete local worker acceptance, O3/Os/Oz Node/Chrome boundaries, single-worker JS and runqueue tests pass. Final filesystem checks pass all eight host/width/worker combinations. Same-checkout-path size comparison against main: cprintf/println unchanged, fmtprintf +3955 bytes (0.13%; code +312 bytes). Full source-specific qualification: https://github.com/cpunion/llgo-compat-ci/actions/runs/37407152952 .

Two clarifications from the previous review: RemoveAfter is O(1) unlinking, but child matching still scans the queue; repeated scans behind unrelated nodes can still be quadratic, so there is no general O(1) scheduling claim. The finalizer publication order is unchanged from main: obj remains an SSA use through KeepAlive, which makes the compiler publish its interface pointer in the GC root frame across AddFinalizer and registry publication; roots remain linked through the function return. cl/gcroot.go and the root-frame implementation provide that lifetime, rather than an assumed LLVM KeepAlive intrinsic. Requests still allocate state and executors retain fibers until their callers exit; these costs are now documented.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review complete — 4bd1194

Posted a PR review: #pullrequestreview-5423460886.

I verified the focused changes adversarially and found no correctness issues:

  • Value.Type() and remoteValueOf fast paths are realm-invariant (reserved emval handles 2/4/6/8 are global constants; dynamic handles start ≥10, so no collision) — correct on any worker without a remote round-trip.
  • The func.go lock/poll reorder and funcMutex switch introduce no race, no lock-ordering inversion, and cannot wrongly disable the poll.
  • New regression tests directly cover the portable-primitive, nested-callback-yield, executor-lifetime, and finalizer-contention paths.

Your two clarifications are both correct and I've updated accordingly: scheduling is not O(1) (child matching still scans — RemoveAfter only makes the unlink O(1)), and the finalizer publication order is sound via the GC-root-frame KeepAlive lifetime (cl/gcroot.go), resolving my earlier "please confirm" note.

Only one minor readability note (inline): add a one-line comment explaining why Type() checks reserved refs before the remote-dispatch branch, since it's the reverse of the other methods.

I relied on your reported acceptance/CI results (local worker acceptance, O3/Os/Oz Node/Chrome, single-worker JS + runqueue tests, 8-combination filesystem checks, and the linked compat-CI run); I don't have CI-log access and didn't re-run the matrix.

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 4bd1194

Reviewed the incremental changes against the previously-reviewed head. This revision is focused and correct; I verified the new fast paths adversarially and found no correctness issues.

New local fast paths verified realm-invariant (the correctness-sensitive part):

  • Value.Type() now resolves the reserved refs (undefined/null/true/false, handles 2/4/6/8) before the remote-dispatch check. These are compile-time constants (not TLS) and match Emscripten's globally-reserved emval handles — dynamic handles start at ≥10, so there is no collision, and answering locally needs no realm state. Correct on any worker.
  • remoteValueOf handles Value/Func/nil/bool locally (returns a copy or a reserved handle) and forwards the rest via the //go:noinline forwardValueOf, keeping request allocations in the host-only helper. Each local case is realm-invariant.
  • func.go: moving emval_install_invoke/ensureCallbackPoll outside funcsMu, switching callbackPoll to the nonpreempting funcMutex, and gating stopCallbackPollLocked behind !keepWasmCallbackPoll introduce no race, no lock-ordering inversion (funcsMu → callbackPoll is the only nesting), and cannot wrongly disable the poll (const-true in the workers build).

The stale "realm affinity" comment flagged earlier is now accurate, and no MarkCurrentJSRealm/jsRealm references remain. The new regression tests (testPortablePrimitives asserting portable conversions create no executor, testCallbackYield covering nested-event resume scope, testExecutorLifetime, testFinalizerContention) directly exercise these paths.

On your two clarifications — both acknowledged and correct:

  • Scheduling is not O(1). Agreed, and my earlier framing overstated it. RemoveAfter makes the unlink O(1), but popWasmCallbackChild still linearly scans for the matching child, so repeated scans behind unrelated nodes remain potentially quadratic. There is no general O(1) scheduling claim — thanks for the correction.
  • Finalizer publication order. Understood: obj stays an SSA use through KeepAlive, so the compiler keeps its interface pointer live in the GC root frame across AddFinalizer and registry publication (via cl/gcroot.go + the root-frame implementation, not an LLVM KeepAlive intrinsic), and roots stay linked through the function return. That resolves my prior "please confirm" note; the ordering is unchanged from main and sound.

The documented costs (per-request state allocation, executors retaining fibers until their callers exit, non-O(1) child matching) are the right things to document. One minor readability note inline; otherwise no further findings.

Comment thread runtime/internal/lib/syscall/js/js.go
@cpunion
cpunion force-pushed the codex/wasm-js-cross-worker-20261006 branch from 4bd1194 to 2d5697d Compare October 6, 2026 03:28
@cpunion cpunion changed the title wasm: share syscall/js values across scheduler workers wasm: support cross-worker syscall/js values and callbacks Oct 6, 2026
@cpunion
cpunion force-pushed the codex/wasm-js-cross-worker-20261006 branch from 2d5697d to 411e3ca Compare October 6, 2026 04:20
@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

The CI callback timeout is fixed at 411e3caab. Under the same 0.18-CPU Linux quota, the old outer batch wait fails while callback operations still progress; reporting child JS completion, synchronous return and external event completion passes the entire batch, even though its total duration exceeds 30 seconds. All eight callbacks, 128 nested/yielding calls, GC and synchronous identity assertions remain; each wait still has its original 30-second no-progress bound and the outer process bound is unchanged.

Full local worker acceptance passes: Node/Chrome, Memory32/64, 2/4 workers, private roots, finalizers, sync.Pool and filesystem GC stress. It also exposed a nondeterministic portable-primitive assertion: process-global NumGoroutine can grow when a background G starts. The assertion now checks this caller's actual executor through a small internal testing accessor, which is unused and removed from ordinary modules. No CI jobs were retried.

@fennoai please review these focused test changes and the executor accessor.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review complete — 411e3ca

Posted a PR review: #pullrequestreview-5423808252. The real diff vs the prior head is just three files (test-only + one test-only accessor), all correct — no findings.

  • js.go adds the exact Type() ordering comment I suggested last round (resolving my only open note).
  • HasMainJSWorkerExecutorForTesting() is a read-only, nil-guarded, build-tag-gated accessor referenced only by the test via //go:linkname, following the package's established *ForTesting convention — confirmed no ordinary-module caller.
  • testPortablePrimitives swaps the flaky NumGoroutine() delta for a precise per-caller executor check (and now also asserts a fresh caller starts with no executor).
  • testCallbacks is the CI-timeout fix: a progress channel drained 8*3 times bounds each child/sync-return/external-event axis independently instead of charging all concurrent work to one 30s timer; the counts verify (3 sends × 8 = 24).

Behavioral invariants from prior revisions are intact. I relied on your reported local acceptance results and don't have CI-log access.

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 411e3ca

Scoped the diff to the three files that actually differ from the previously-reviewed head (4bd1194). All changes are test-only plus one test-only accessor, and all are correct. No findings.

  • js.go Type() — adds the one-line comment I suggested last round (// Reserved refs are valid in every realm; resolve them before remote dispatch.), resolving my only outstanding note. No behavior change.
  • HasMainJSWorkerExecutorForTesting() (js_worker_call_wasm_workers.go) — a read-only, nil-guarded accessor (gp != nil && gp.context.platform.jsCalls != nil) under the llgo && js && wasm && llgo.wasm.workers build tag. Referenced only by test/wasm/js-workers/main.go via //go:linkname; no ordinary-module caller. It follows the package's established *ForTesting convention (GMPForTesting, CurrentGForTesting, ParkForTesting, ReadyForTesting, SchedulerStateForTesting).
  • testPortablePrimitives — replaces the flaky runtime.NumGoroutine() before/after delta (which a background timer/finalizer G could perturb) with hasMainJSWorkerExecutor() checks that inspect this caller's executor directly. Strictly more precise and deterministic, and it also asserts a fresh caller starts without an executor.
  • testCallbacks — the CI-timeout fix: swaps the single aggregate result channel (8 waits) for a progress channel drained 8*3 times, bounding each child JS completion, synchronous return, and external event on its own axis instead of charging all concurrent GC/callback work to one 30s timer. The counts line up: each outer iteration emits 3 progress sends (nested child + post-invoke + post-external-callback) × 8 = 24 = 8*3. The original 30s no-progress bound per wait and the outer process bound are unchanged, as described.

The behavioral invariants from prior revisions (all eight callbacks, 128 nested/yielding calls, GC, synchronous identity assertions) remain in place. I relied on your reported local worker acceptance results (Node/Chrome, Memory32/64, 2/4 workers, finalizers, sync.Pool, filesystem GC stress); I don't have CI-log access and did not re-run the matrix.

@cpunion
cpunion force-pushed the codex/wasm-js-cross-worker-20261006 branch from 411e3ca to 87995c1 Compare October 6, 2026 05:05
@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

The nested callback GC stress is corrected at 87995c126. CI at 411e3caab reached the synchronous/external callback phase but still accumulated eight serialized collections inside each callback's result deadline. The test now waits until all eight callback children are parked, performs one collection while every callback's JS roots are live, and releases the children together. This covers all eight callback roots simultaneously; concurrent GC stress remains in the separate shared-values and finalizer tests. All 30-second waits, eight synchronous/external callbacks and 128 yielding/nested invocations remain.

Full local Node/Chrome worker acceptance passes (Memory32/64, 2/4 workers, private roots, finalizers, sync.Pool and filesystem GC). The callback/shared-value probe also passes under an emulated Linux 0.18-CPU quota. No CI jobs were retried; the code push triggered fresh CI.

Standard-library qualification additionally exposed worker-local libffi MakeFunc table entries. A separate follow-up contribution will address that compatibility failure.

@fennoai please review the callback GC barrier change.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review complete — 87995c1

Posted a PR review: #pullrequestreview-5424011873. The only change vs the prior head is the callback GC barrier in testCallbacks (one file, +12/-1) — correct and well-commented, no findings.

The barrier replaces eight serialized in-callback runtime.GC() calls with a coordinated single collection: child Gs signal gcReady and park on gcRelease; the main G waits for all 8, runs one GC while every callback's JS roots are live, then close(gcRelease) to release them together. I verified no deadlock / no lost signal (gcReady buffered to exactly 8; close is the right one-shot broadcast), that the single collection covers all eight root sets, and that the progress accounting (8*3) still balances. Coverage isn't reduced — concurrent GC stress remains in the shared-values and finalizer tests.

The libffi MakeFunc follow-up is out of scope. I relied on your reported local acceptance results and have no CI-log access.

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 87995c1

The only change vs the prior reviewed head (411e3ca) is the callback GC barrier in testCallbacks (test/wasm/js-workers/main.go, +12/-1). It is correct and well-commented — no findings.

The barrier works as intended:

  • Each callback's child G now signals gcReady <- 1 and parks on <-gcRelease instead of calling runtime.GC() itself.
  • The main G drains all 8 gcReady signals (so every callback child is parked with its JS roots live), runs one runtime.GC(), then close(gcRelease) to release all children together.

Correctness points I checked:

  • No deadlock / no lost signal — gcReady is buffered to exactly 8 (one per callback child), so no child blocks on the send before parking on gcRelease; the main G is free to reach the drain loop while the callback Gs park in await(result) and the children run on pool workers. close(gcRelease) is the correct one-shot broadcast release for all 8 blocked receivers.
  • Single collection covers all roots — GC now runs once while all eight callbacks' roots are simultaneously live, replacing eight serialized collections that each charged their cumulative wall time to an independent 30-second result deadline. This matches the stated CI-timeout fix.
  • progress accounting unchanged — the child still emits its progress <- 1 after the barrier, so the final 8*3 drain still balances; gcReady/gcRelease are separate channels and don't interfere. All 30-second waits, the eight synchronous/external callbacks, and the 128 yielding/nested invocations remain.

Concurrent (unsynchronized) GC stress is still exercised by the separate shared-values and finalizer tests, as you note, so moving this one test to a coordinated single collection doesn't reduce coverage.

Noted for the record: the follow-up on worker-local libffi MakeFunc table entries is out of scope here. I relied on your reported local acceptance results (Node/Chrome, Memory32/64, 2/4 workers, the 0.18-CPU-quota probe); I have no CI-log access and did not re-run the matrix.

@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@fennoai please review cc47f7a, especially the three runqueue waits now using the existing in-place GC callback. A controlled contended-lock/budget-exhaustion probe reproduces invalid goroutine status transition with the old callback and passes with the fix. The permanent fixture exercises actual Gosched under contention both with and without GC, and full Memory32/64 four-worker JS tests pass. onJSWorker now always invokes its callback rather than discarding a false execution result. The callback-child scan is confined to active synchronous Go callbacks; ordinary remote property operations remain FIFO. Full worker acceptance and fresh CI are running.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review complete — cc47f7a

Posted a PR review: #pullrequestreview-5424216827. Six files differ from the prior head; all changes are correct — no findings.

  • Three runqueue waits → wasmGCAllocatorYield (the core fix): I traced both callbacks. CooperativeSafepoint can swap fibers and call goschedBackend() mid-wait, which re-enters a "runnable but not queued" G's status transition → the invalid-transition fault. wasmGCAllocatorYield only acknowledges STW in place and never reschedules, so it's the correct yield for these brief runq critical sections while still honoring GC.
  • onJSWorker always invokes fn: a genuine correctness fix — the old code discarded CallMainJSWorker's false result and silently skipped fn (returning a zero value) when the caller was on worker 0; the new version runs fn() locally in that case.
  • FIFO preserved; comment relocation is cosmetic and now correctly placed.
  • New fixture is build-tag-confined, test-only, and faithfully reproduces the contended-lock/budget-exhaustion scenario (run with both a no-op and runtime.GC) — a proper permanent regression guard.

I relied on your reported acceptance results (no CI-log access); the libffi MakeFunc follow-up is out of scope.

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of cc47f7a

Scoped to the six files that differ from the prior reviewed head (87995c1): the three runqueue-wait yield-callback changes, the onJSWorker always-invoke fix, a comment relocation, and a new test-only fixture. All correct — no findings.

Three runqueue waits → wasmGCAllocatorYield (the core fix) — correct.
enqueueWasmG, popWasmWorkerRunq, and wasmWorkerRunqLen now acquire worker.lock with wasmGCAllocatorYield instead of CooperativeSafepoint. I verified the two callbacks:

  • CooperativeSafepoint can, in its slow path, swap to the system fiber for a GC request and call goschedBackend() when the runq is non-empty — i.e. it can reschedule / transition the current G mid-wait. Running that while the caller is manipulating the runq (a G that is "runnable but not queued") is exactly what re-enters the status transition and produces the invalid-transition fault.
  • wasmGCAllocatorYield only acknowledges an STW request in place via wasmWorkerStopForGC and explicitly does not schedule another G (it may sit below a foreign C frame). The same G resumes after the world is released.

So the swap removes the illegal reschedule from these brief runq critical sections while still honoring STW. Mutex.Lock(yield) calls yield while spinning and tolerates nil, so the contract holds. The enqueueWasmG comment accurately describes the "runnable but not queued" hazard.

onJSWorker now always invokes fn — real correctness fix.
Previously onJSWorker returned CallMainJSWorker(fn)'s bool and every remoteX helper discarded it. CallMainJSWorker returns false (without running fn) when the caller is already on worker 0 / no worker. If the G is on worker 0 at the CallMainJSWorker re-check (e.g. a race against the earlier isRemoteJSWorker() test), the old path skipped fn entirely and the helper returned a zero-valued result. The new onJSWorker runs fn() locally in that case, so the result is always produced. The single-worker variant (fn()) matches.

FIFO preserved. popWasmCallbackChild remains gated behind an active jsEvent, so ordinary remote property operations are unaffected; only active synchronous Go callbacks trigger the child scan. Unchanged by this diff.

Comment relocation (executor-reuse note moved from runMainJSCall to CallMainJSWorker) correctly attaches it to the function that lazily creates the per-G executor. Cosmetic.

New fixture ExerciseWasmRunqueueTransitionForTesting — build-tag confined (llgo && js && wasm && llgo.wasm.workers), referenced only by the test via //go:linkname. It faithfully reproduces the failing scenario: holds the worker lock from another worker, forces budget exhaustion (pollbudget.New(1)) so an exhausted budget must not re-enter Gosched, then drives goschedBackend() under contention with GC racing — run both with a no-op and with runtime.GC. A permanent fixture that reproduced the fault on the old callback and passes on the new one is the right regression guard.

I relied on your reported acceptance results (Memory32/64 four-worker JS, worker acceptance); I have no CI-log access and did not re-run the matrix. The libffi MakeFunc follow-up remains out of scope.

@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@fennoai Please review the focused GC follow-up in 875508f and 584723c: contiguous-heap/zero-word scan fast paths and bounded handoff to contending allocators after collection. In the combined reflection-worker source, the unchanged full test/go package passes Memory32/64 under its 60-second limit; the non-yielding collector exceeds the 90-second diagnostic process bound. GC waits remain in place and do not swap Go fibers below foreign frames.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review complete — 584723c

Posted a PR review: #pullrequestreview-5425344754. Six files differ from the prior head — all correct, no findings.

  • Zero-word scan fast path (gc_tinygo.go): safe hot-loop short-circuit; a null word is never a heap pointer and the lookup would return nil anyway.
  • Contiguous-heap fast path (segments.go): gated on the compile-time const segmentedHeap, and the heapSegments[0] range check (start ≤ addr < metadata) is semantically identical to the single-segment binary-search result. No risk.
  • Bounded post-collection handoff (unlockForGC): only runs after a completed GC() and only when contended; a ≤1 ms futex-timeout yield that is never woken and cannot stall. Hot-path alloc/free is untouched, and it's a timed wait — not a fiber swap or Gosched — so it keeps the "no fiber swap below foreign frames" invariant.
  • Mutex.Contended(): read-only atomic accessor.
  • CI matrix: adds a workers suite with consistent step gating (Chrome for runtime+workers; Wasmtime/Wasmer/single-worker off for workers; browser-workers only on workers); the 21→22 job-count bump matches.

I relied on your reported acceptance results (no CI-log access).

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 584723c (GC follow-up)

Scoped to the six files that differ from the prior reviewed head (cc47f7a): two GC scan fast paths, the bounded post-collection allocator handoff, a Contended() accessor, and a CI matrix split. All correct — no findings.

Zero-word scan fast path (tinygogc/gc_tinygo.go startMark) — skipping word == 0 before segmentForAddress is a safe hot-loop short-circuit: a null word is never a heap pointer, and the lookup would return nil for it anyway, so behavior is unchanged.

Contiguous-heap fast path (tinygogc/segments.go segmentForAddress) — correct and risk-free. It is gated on the compile-time const segmentedHeap (false only for the default contiguous heap, true for wasi-threads), so the branch is dead-code-eliminated where it doesn't apply. The fast path checks heapSegments[0] with start <= addr < metadata, which is exactly what the single-segment binary-search path returns (it only yields the segment when addr < metadata, else nil). Semantically identical, just without the arena index + binary search per scanned word.

Bounded post-collection handoff (tinygogc/mutex_wasm_workers.go unlockForGC) — sound. I confirmed unlockForGC is called only from GC() right after the collection cycle completes and the world resumes; the ordinary Free/alloc path uses plain unlock, so hot-path allocation is unaffected. The bounded wait is further gated on Contended(), so uncontended collections pay nothing. gcHandoffWord stays 0 with an expected value of 0, so wasmworkers.Wait(&gcHandoffWord, 0, 1_000_000) is a bounded ≤1 ms futex-timeout yield that is never woken and cannot stall — it just gives a woken remote mutator a scheduling window before the collector (possibly the only runnable G on its worker) re-wins the allocator CAS. This is a timed wait, not a fiber swap or Gosched, so it upholds the "GC waits don't swap Go fibers below foreign frames" invariant.

Mutex.Contended() (wasmsync/mutex.go) — read-only atomic accessor over waiters; correct.

CI matrix (llgo.yml, test_ci_workflows.py) — adds a dedicated workers suite with consistent step gating: Chrome now set up for matrix.suite != 'test-command' (i.e. both runtime and workers); Wasmtime / Wasmer / single-worker scheduler steps are gated off for workers; the bounded browser-workers step now runs only on workers. The expected job count bump (21 → 22) matches the added matrix leg.

I relied on your reported acceptance results (reflection-worker source, Memory32/64 under the 60s limit; the non-yielding collector exceeding the 90s diagnostic bound); I have no CI-log access and did not re-run the matrix.

@cpunion
cpunion force-pushed the codex/wasm-js-cross-worker-20261006 branch from 58f7baa to d2611e8 Compare October 6, 2026 07:58
@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@fennoai The final scan follow-up is d2611e8: skip only aligned, bounded, completely zero 16-byte spans on Memory32; nonzero words still use the original four-byte Go/C pointer scan. Full test/go passes on latest main for Memory32/64, and the WAMR threaded-GC acceptance passes. All allocator handoff/scan changes are now rebased on main f167e3c; the experimental ticket lock, epoch-loop change and reflection cache are not included.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review complete — d2611e8

Posted a PR review: #pullrequestreview-5425550618. The PR was rebased on a newer main (f167e3c), so I reviewed against the new declared base (three-dot) to exclude the upstream SIMD/CPU-feature churn. The only new logic this revision is the zero-16-byte-span scan fast path — correct, no findings.

I verified it: platform-gated to Memory32 via the gcScanZeroSpans const (dead-code-eliminated on memory64); both uint64 loads are 8-aligned (addr&7==0) and bounded within the object (end-addr >= 16); it skips only all-zero 16-byte spans so no pointer can be missed, with any nonzero sub-word falling through to the original 4-byte scan; the +16 - gcScanWordSize plus loop +4 advances exactly one 16-byte span; and since blocks are 16 bytes (bytesPerBlock = 16 on wasm32), the addr != end loop still terminates exactly. The adjacent word == 0 skip was already reviewed at 584723c.

I also confirmed the experimental ticket-lock / epoch-loop / reflection-cache you excluded are not in the diff. I relied on your reported acceptance results (no CI-log access).

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of d2611e8 (final scan follow-up, rebased on f167e3c)

I diffed the PR against its new declared base (f167e3c...d2611e8) so the rebase's upstream SIMD/CPU-feature churn is excluded — that machinery is part of the new base, not this PR. The PR's own change set is the familiar wasm-workers + GC content I've reviewed across prior revisions, and the only new logic this revision is the zero-16-byte-span scan fast path in tinygogc. It is correct — no findings.

Zero-span scan fast path (gc_tinygo.go startMark, with scanword_wasm32.go/scanword_default.go):

if gcScanZeroSpans && addr&7 == 0 && end-addr >= 16 &&
    *(*uint64)(addr) == 0 && *(*uint64)(addr+8) == 0 { addr += 16 - gcScanWordSize; continue }
  • Platform-gated: gcScanZeroSpans is a const — true only in scanword_wasm32.go (!memory64, gcScanWordSize = 4), false in scanword_default.go (memory64 / baremetal). So the 16-byte skip compiles in only on Memory32 and is dead-code-eliminated elsewhere, matching "on Memory32."
  • Alignment: addr&7 == 0 makes both *(*uint64) loads 8-aligned. Blocks are bytesPerBlock = wordsPerBlock(4) × 4 = 16 bytes on wasm32, so start/end are 16-aligned and addr stays 16-aligned across fast-path hops (+16) and 4-aligned on the fallthrough (+4).
  • Bounded: end-addr >= 16 guarantees both loads and the +16 advance stay within [start,end) — no read past the object and, since end is 16-aligned, the addr != end loop still terminates exactly.
  • No missed pointer: the span is skipped only when all 16 bytes are zero (= four zero 4-byte words), which hold no pointers. Any nonzero sub-word fails the condition and falls through to the original 4-byte loadGCScanWord scan, preserving Go-low-word / C-either-word discovery. The conservative scan cannot drop a live root.
  • Advance math: addr += 16 - gcScanWordSize (+12) plus the loop's +gcScanWordSize (+4) = exactly one 16-byte span, no word skipped or overshot.

The adjacent word == 0 single-word skip was already reviewed at 584723c and is unchanged.

I confirmed the experimental ticket lock / epoch-loop / reflection cache you mentioned are not present in the diff. I relied on your reported acceptance results (test/go on latest main for Memory32/64; WAMR threaded-GC acceptance); I have no CI-log access and did not re-run the matrix.

@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@fennoai Please review the GC metadata follow-up in 1bf3662. The full Memory32 worker qualification hit its 60-second package deadline during repeated collections. Contiguous block lookup now uses the single arena directly. Object-end lookup, overflow rescanning and sweeping batch only complete metadata bytes; mixed/partial bytes retain individual state handling, and freed tail groups are cleared over the same heap range as before. The new wide-graph regression exercises mark-stack overflow, mixed allocation lengths, live contents and zeroed new allocations on Memory32/64. Full test/go passes locally in about 24/7 seconds under the original 60-second limit; threaded GC passes on both WAMR and pinned patched Wasmer. #2743 is rebased onto this change; current contribution CI is running.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review complete — 1bf3662

Posted a PR review: #pullrequestreview-5425956521. Clean three-file delta (same base as prior head) — the GC whole-metadata-byte batching plus a wide-graph regression. All correct, no findings.

I verified each batch against the 2-bit state encoding (Free=00/Head=01/Tail=10/Mark=11, 4 blocks/byte, all-tails=0xAA):

  • segmentForBlock contiguous fast path — const-gated, first ≤ block ≤ last, identical panic path.
  • gcFindNextIn all-tails skip — forward mirror of the existing gcFindHeadIn byte-skip; an all-tails byte has no object boundary.
  • finishMark no-mark skip — the stateByte & (stateByte>>1) & 0x55 == 0 test is exactly "no Mark(11) nibble", and finishMark only acts on Mark blocks.
  • sweep all-free / all-tails batches — I confirmed they reproduce the per-block Free counting and tail-freeing exactly, including that four gcMarkFreeIn calls both clear the metadata byte and zero the same 4 × bytesPerBlock heap range the batch Memsets, with freeCurrentObject threaded correctly.

All batches fire only on complete aligned bytes with ≥4 blocks left, so partial bytes keep per-state handling and the advance is exactly one byte. The new gc_bitmap_test.go exercises all four paths (mark-stack overflow, mixed lengths, live contents, zeroed allocations). I relied on your reported acceptance results (no CI-log access).

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 1bf3662 (GC metadata follow-up)

Both heads share base f167e3c, so this is a clean three-file delta: whole-metadata-byte batching in tinygogc plus a wide-graph regression. I verified each batch is behaviorally identical to the per-block path it accelerates (states: Free=00, Head=01, Tail=10, Mark=11; blocksPerStateByte = 4; blockStateByteAllTails = 0xAA). No findings.

  • segmentForBlock contiguous fast path — gated on const !segmentedHeap; checks heapSegments[0] with first <= block <= last (the inclusive upper bound matches the binary-search path's in-range boundary) and panics identically otherwise. Mirrors the segmentForAddress fast path from the prior revision.
  • gcFindNextIn all-tails skip — when byte-aligned with ≥4 blocks left and the byte is 0xAA (four tails), skip 4; otherwise fall through to the per-block blockAddr++. This is the forward mirror of the long-standing gcFindHeadIn byte-skip, and an all-tails byte provably contains no object boundary.
  • finishMark no-mark-byte skip — the test stateByte & (stateByte>>1) & (blockStateByteAllTails>>1) == 0 is exactly "no 11 (Mark) nibble": & (>>1) sets a state's low bit iff both its bits are 1 (Mark), and 0xAA>>1 = 0x55 masks the low bit of each 2-bit state. Since finishMark only acts on Mark blocks, skipping a byte with no marked head is a no-op — correct.
  • sweep all-free / all-tails batches — stateByte == 0 adds 4 × bytesPerBlock free bytes (identical to four blockStateFree cases, and leaves freeCurrentObject untouched). stateByte == 0xAA with freeCurrentObject zeroes the metadata byte and Memsets 4 × bytesPerBlock of heap, freed += 4 — I confirmed this exactly reproduces four gcMarkFreeIn calls, which each clear the nibble and zero the block's wordsPerBlock words, so the heap range cleared is the same as before. With freeCurrentObject == false the tails are live and skipped untouched, matching the per-block path.

All batches are gated on %blocksPerStateByte == 0 && remaining >= blocksPerStateByte, so partial/trailing bytes always fall through to the exact per-state handling, and block += blocksPerStateByte - 1 + the loop block++ advances exactly one byte with no overshoot. "Mixed/partial bytes retain individual handling" and "freed tails cleared over the same range" both hold.

The new gc_bitmap_test.go (wide graph → mark-stack overflow, varied object lengths, live contents, zeroed new allocations) directly exercises all four touched paths on Memory32/64.

I relied on your reported results (test/go ~24/7s under the 60s limit; threaded GC on WAMR and pinned patched Wasmer); I have no CI-log access and did not re-run the matrix.

@cpunion
cpunion force-pushed the codex/wasm-js-cross-worker-20261006 branch from 1bf3662 to ab4626f Compare October 6, 2026 09:37
@cpunion

cpunion commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@fennoai Please review the two follow-ups in ab4626f (rebased on main be669ac). Browser Memory64 filesystem GC stress exposed raw malloc buffers held only by JS numbers; payload/request/response now use explicit rooted allocations and release every root in finally. The regression forces collection between allocations, reproduces zeroed payloads with the old proxy, and checks Memory32/64 data and root cleanup. Actual Chrome stress passes in both widths.

The GC scan now batches four aligned metadata bytes (16 blocks), bounded by each arena's usable blocks, with byte fallback at mixed/partial boundaries. Existing live graph/overflow tests pass in Memory32/64; host tests exercise metadata alignment, end boundaries, retained/freed tails, cleared storage and counters. WAMR and pinned Wasmer threaded GC pass. The host extraction test now includes the real batch constants and segmented-heap selector, fixing its native CI build failure. No CI retries or deadline increases.

@fennoai

fennoai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review complete — ab4626f

Posted a PR review: #pullrequestreview-5426571302. Reviewed against the new base (three-dot) to exclude the rebase's upstream churn. Both follow-ups correct — no findings.

  • Browser FS rooted-allocation fix — a real GC-safety fix: the proxy's payload/request/response buffers now use AllocRoot/FreeRoot (I confirmed tinygogc.AllocRoot links them into the scanned, uncollectable rootAllocations list), so a collection between allocations can no longer reclaim buffers held only by JS Numbers (the Memory64 bug). All three are released in finally; export names and __deps match; float64 keeps the boundary numeric in both widths. The .mjs regression models collection and asserts no leaked roots.
  • 16-block (4-byte word) metadata batching — sound 4× extension of the byte batching I verified last revision: blockStateWordAllTails = 0xAAAAAAAA, the no-mark word test & 0x55555555, and the sweep all-free/all-tails word cases are exact 16-block analogues, each gated on 4-byte-aligned metadata (metadata&3==0) and ≥16 remaining blocks, with byte-batch and per-block fallbacks for partial/mixed boundaries. gcFindNextIn's restructure preserves break-on-first-non-tail.

The host-extraction test now includes gcFindNextIn, the batch consts, and the segmentedHeap selector (fixing the native build), plus new boundary/contiguous-lookup tests. I relied on your reported acceptance results (no CI-log access).

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of ab4626f (two follow-ups, rebased on be669ac)

Reviewed against the new base (three-dot) so the rebase's upstream churn (setup-gdb, ssa/di.go, test/goroot/*) is excluded. Both follow-ups are correct — no findings.

1. Browser FS rooted-allocation fix — a genuine GC-safety fix.
The proxy's payload/request/response buffers were allocated with raw _malloc, whose only reference was a JS Number — invisible to the Go collector, so a collection between allocations could reclaim and zero them (the Memory64 stress failure). They now use llgo_browser_fs_malloc/_free, which route through AllocRoot/FreeRoot. I confirmed tinygogc.AllocRoot links the allocation into the rootAllocations list (scanned, uncollectable until freed — equivalent to GC_malloc_uncollectable), so the buffers survive any intervening collection, and the finally block releases all three on every exit path via FreeRoot (which unlinks and frees). The //export llgo_browser_fs_malloc/_free names match the JS _llgo_browser_fs_* calls and the updated __deps; float64 keeps the boundary numeric in both widths (a Memory64 address fits a double's 53-bit mantissa for realistic heaps). Build tag llgo && js && wasm is right. The .mjs test now zeroes non-rooted allocations on each _malloc (modeling collection) and asserts roots.size == 0, directly reproducing the old zeroed-payload bug and verifying root cleanup in both widths.

2. 16-block (4-byte word) metadata batching — sound extension of the byte batching reviewed previously.
blockStateWordAllTails = 0xAA × 0x01010101 = 0xAAAAAAAA; the finishMark no-mark test states & (states>>1) & (blockStateWordAllTails>>1 = 0x55555555) == 0 is the exact 16-block analogue of the byte test (11/Mark detection); the sweep word cases (states == 0 → 16×free; states == 0xAAAAAAAA + freeCurrentObject → zero the metadata word + Memset 16×bytesPerBlock + freed += 16) reproduce 16 per-block operations, consistent with the gcMarkFreeIn metadata-clear + block-zero semantics I verified last revision. Every word path is gated on metadata&3 == 0 (safe aligned 32-bit read) and segment.last-block >= blocksPerStateWord, falling back to the single-byte batch and then per-block for 4-byte-unaligned metadata, partial/trailing bytes, or mixed states — so "bounded by each arena's usable blocks, byte fallback at mixed/partial boundaries" holds. gcFindNextIn's loop restructure preserves break-on-first-non-tail. The advance (+= blocksPerStateWord - 1 then loop block++) is exactly 16 blocks.

The host extraction test (wasm_gc_segments_test.go) now pulls in gcFindNextIn, the blockStateByteAllTails/blocksPerStateWord consts, and a segmentedHeap selector, which is what fixes the native CI build; the new TestSweepMetadataBatches (8 offsets × keep/free) and TestContiguousHeapLookup exercise the batch boundaries, retained/freed tails, cleared storage, counters, and the contiguous lookup at partial-byte offsets.

I relied on your reported results (Chrome Memory32/64 FS stress; live-graph/overflow + host metadata tests; WAMR and pinned Wasmer threaded GC); I have no CI-log access and did not re-run the matrix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wasm: browser filesystem callbacks can restore another Fiber stack under GC stress

1 participant