Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/scripts/test_ci_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/llgo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down
35 changes: 23 additions & 12 deletions dev/test_wasm_workers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" \
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions dev/wasmstdlib/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions dev/wasmstdlib/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions doc/wasm-browser-filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions doc/wasm-proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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。

## 反射与外部调用

Expand Down
25 changes: 19 additions & 6 deletions internal/build/testdata/wasm-browser-fs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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" {
Expand All @@ -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) {
Expand Down
58 changes: 58 additions & 0 deletions internal/build/testdata/wasm-test/gc_bitmap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
}
16 changes: 8 additions & 8 deletions internal/build/testdata/wasm-test/js_workers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions internal/build/testdata/wasm-workers/browser-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ if (!browser || !url) {
throw new Error("usage: node browser-runner.mjs <chrome> <url>");
}

// 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",
Expand Down
4 changes: 2 additions & 2 deletions internal/build/testdata/wasm-workers/browser.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand All @@ -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";
Expand Down
Loading
Loading