From 32670f93eafe8bfca9feb2f78dc20975f7c66b71 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 12:42:46 +0800 Subject: [PATCH 01/20] runtime/wasm: add single-worker Asyncify scheduler --- internal/build/build.go | 7 +- internal/build/outputs.go | 6 + internal/crosscompile/crosscompile.go | 2 +- .../internal/clite/emscripten/_wrap/fiber.c | 5 + runtime/internal/clite/emscripten/fiber.go | 42 +++ .../internal/clite/emscripten/fiber_wasm.go | 21 ++ runtime/internal/lib/runtime/debug.go | 3 + runtime/internal/runqueue/runqueue.go | 75 +++++ runtime/internal/runtime/g_pthread.go | 2 +- runtime/internal/runtime/g_wasm.go | 32 ++ runtime/internal/runtime/os_pthread.go | 11 +- runtime/internal/runtime/os_wasm.go | 23 ++ runtime/internal/runtime/proc.go | 118 ++------ runtime/internal/runtime/proc_atomic.go | 6 +- runtime/internal/runtime/proc_pthread.go | 120 ++++++++ runtime/internal/runtime/proc_wasm.go | 278 ++++++++++++++++++ runtime/internal/runtime/runtime2.go | 32 +- runtime/internal/runtime/z_default.go | 13 +- 18 files changed, 668 insertions(+), 128 deletions(-) create mode 100644 runtime/internal/clite/emscripten/_wrap/fiber.c create mode 100644 runtime/internal/clite/emscripten/fiber.go create mode 100644 runtime/internal/clite/emscripten/fiber_wasm.go create mode 100644 runtime/internal/runqueue/runqueue.go create mode 100644 runtime/internal/runtime/g_wasm.go create mode 100644 runtime/internal/runtime/os_wasm.go create mode 100644 runtime/internal/runtime/proc_pthread.go create mode 100644 runtime/internal/runtime/proc_wasm.go diff --git a/internal/build/build.go b/internal/build/build.go index 7b66fc73e9..dc8140f80c 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -684,11 +684,8 @@ func DefaultBuildTags(goarch, target string) string { func defaultBuildTags(goarch, target string) string { tags := "llgo,math_big_pure_go,purego" - // Raw GOOS/GOARCH wasm builds do not have a target configuration that - // selects a collector. BDWGC is not available in either wasm host, so use - // the supported collector-free runtime unless a named target supplies its - // own runtime configuration. - if goarch == "wasm" && target == "" { + // BDWGC is unavailable in both wasm hosts. + if goarch == "wasm" { tags += ",nogc" } return tags diff --git a/internal/build/outputs.go b/internal/build/outputs.go index 4ab63186e7..3553fbb049 100644 --- a/internal/build/outputs.go +++ b/internal/build/outputs.go @@ -279,6 +279,12 @@ func defaultAppExt(conf *Config) string { return ".so" } case BuildModeExe: + if conf.Goos == "js" && conf.OutFile != "" { + switch ext := filepath.Ext(conf.OutFile); ext { + case ".js", ".mjs": + return ext + } + } // For executable mode, handle target-specific logic if conf.Target != "" { if strings.HasPrefix(conf.Target, "wasi") || strings.HasPrefix(conf.Target, "wasm") { diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index e41c3e811f..36a1911ed2 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -440,7 +440,7 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le // "-Wl,--export=malloc", "-Wl,--export=free", } export.LDFLAGS = append(export.LDFLAGS, []string{ - "-sENVIRONMENT=web,worker", + "-sENVIRONMENT=web,worker,node", "-DPLATFORM_WEB", "-sEXPORT_KEEPALIVE=1", "-sEXPORT_ES6=1", diff --git a/runtime/internal/clite/emscripten/_wrap/fiber.c b/runtime/internal/clite/emscripten/_wrap/fiber.c new file mode 100644 index 0000000000..43a99f8fb5 --- /dev/null +++ b/runtime/internal/clite/emscripten/_wrap/fiber.c @@ -0,0 +1,5 @@ +#include + +_Static_assert( + sizeof(emscripten_fiber_t) == 8 * sizeof(void *), + "LLGo Fiber storage does not match emscripten_fiber_t"); diff --git a/runtime/internal/clite/emscripten/fiber.go b/runtime/internal/clite/emscripten/fiber.go new file mode 100644 index 0000000000..8490f8fd10 --- /dev/null +++ b/runtime/internal/clite/emscripten/fiber.go @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package emscripten exposes the small host ABI needed by the WebAssembly +// execution-context backend. +package emscripten + +import c "github.com/goplus/llgo/runtime/internal/clite" + +// Fiber is the opaque emscripten_fiber_t storage. The C layout consists of +// eight pointer-sized fields on wasm32. +type Fiber struct { + _ [8]uintptr +} + +//llgo:type C +type FiberEntry func(c.Pointer) + +// llgo:link (*Fiber).Init C.emscripten_fiber_init +func (fiber *Fiber) Init(entry FiberEntry, arg, stack c.Pointer, stackSize uintptr, asyncifyStack c.Pointer, asyncifyStackSize uintptr) { +} + +// llgo:link (*Fiber).InitCurrent C.emscripten_fiber_init_from_current_context +func (fiber *Fiber) InitCurrent(asyncifyStack c.Pointer, asyncifyStackSize uintptr) { +} + +// llgo:link (*Fiber).Swap C.emscripten_fiber_swap +func (fiber *Fiber) Swap(next *Fiber) { +} diff --git a/runtime/internal/clite/emscripten/fiber_wasm.go b/runtime/internal/clite/emscripten/fiber_wasm.go new file mode 100644 index 0000000000..2a7060a4a1 --- /dev/null +++ b/runtime/internal/clite/emscripten/fiber_wasm.go @@ -0,0 +1,21 @@ +//go:build js && wasm + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package emscripten + +const LLGoFiles = "_wrap/fiber.c" diff --git a/runtime/internal/lib/runtime/debug.go b/runtime/internal/lib/runtime/debug.go index b19cb2b9d2..f8d832d59b 100644 --- a/runtime/internal/lib/runtime/debug.go +++ b/runtime/internal/lib/runtime/debug.go @@ -1,5 +1,7 @@ package runtime +import llruntime "github.com/goplus/llgo/runtime/internal/runtime" + func NumCPU() int { return int(c_maxprocs()) } @@ -9,6 +11,7 @@ func Breakpoint() { } func Gosched() { + llruntime.Gosched() } func NumCgoCall() int64 { diff --git a/runtime/internal/runqueue/runqueue.go b/runtime/internal/runqueue/runqueue.go new file mode 100644 index 0000000000..1e6f32c57c --- /dev/null +++ b/runtime/internal/runqueue/runqueue.go @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package runqueue provides an allocation-free intrusive FIFO for scheduler +// backends with one queue owner. +package runqueue + +// Node is the intrusive link contract implemented by scheduler-owned values. +type Node[T comparable] interface { + RunqueueNext() T + SetRunqueueNext(T) + RunqueueQueued() bool + SetRunqueueQueued(bool) +} + +type Queue[T interface { + comparable + Node[T] +}] struct { + head T + tail T + size uintptr +} + +// Push appends node and reports whether it was non-zero and not queued. +func (q *Queue[T]) Push(node T) bool { + var zero T + if node == zero || node.RunqueueQueued() { + return false + } + node.SetRunqueueNext(zero) + node.SetRunqueueQueued(true) + if q.tail == zero { + q.head = node + } else { + q.tail.SetRunqueueNext(node) + } + q.tail = node + q.size++ + return true +} + +// Pop removes and returns the oldest node, or its zero value when empty. +func (q *Queue[T]) Pop() T { + var zero T + node := q.head + if node == zero { + return zero + } + q.head = node.RunqueueNext() + if q.head == zero { + q.tail = zero + } + node.SetRunqueueNext(zero) + node.SetRunqueueQueued(false) + q.size-- + return node +} + +func (q *Queue[T]) Len() uintptr { + return q.size +} diff --git a/runtime/internal/runtime/g_pthread.go b/runtime/internal/runtime/g_pthread.go index 83aebf0127..abf8b127b7 100644 --- a/runtime/internal/runtime/g_pthread.go +++ b/runtime/internal/runtime/g_pthread.go @@ -1,4 +1,4 @@ -//go:build llgo && !baremetal +//go:build llgo && !baremetal && (!js || !wasm) /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/g_wasm.go b/runtime/internal/runtime/g_wasm.go new file mode 100644 index 0000000000..9ec5a4f221 --- /dev/null +++ b/runtime/internal/runtime/g_wasm.go @@ -0,0 +1,32 @@ +//go:build llgo && js && wasm + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtime + +var currentG *g + +func getg() *g { + if currentG == nil { + currentG = initRuntimeContext(allocRuntimeContext(), nil, _Grunning) + } + return currentG +} + +func setg(gp *g) { + currentG = gp +} diff --git a/runtime/internal/runtime/os_pthread.go b/runtime/internal/runtime/os_pthread.go index 4a7447fda4..81500f272f 100644 --- a/runtime/internal/runtime/os_pthread.go +++ b/runtime/internal/runtime/os_pthread.go @@ -1,3 +1,5 @@ +//go:build !llgo || !js || !wasm + /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. * @@ -63,8 +65,11 @@ func initThreadAttr(attr *pthread.Attr, stackSize uintptr) c.Int { return 0 } -func exitCurrentM() { - mp := getg().m - mexit(mp) +func goexitBackend(gp *g) { + if gp.isMain { + fatal("no goroutines (main called runtime.Goexit) - deadlock!") + c.Exit(2) + } + mexit(gp.m) pthread.Exit(nil) } diff --git a/runtime/internal/runtime/os_wasm.go b/runtime/internal/runtime/os_wasm.go new file mode 100644 index 0000000000..956031b417 --- /dev/null +++ b/runtime/internal/runtime/os_wasm.go @@ -0,0 +1,23 @@ +//go:build llgo && js && wasm + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtime + +// mOS is empty for the single-worker WebAssembly backend. The host Worker is +// owned by Emscripten rather than created for an individual M. +type mOS struct{} diff --git a/runtime/internal/runtime/proc.go b/runtime/internal/runtime/proc.go index b9e35ef954..0eaf57396a 100644 --- a/runtime/internal/runtime/proc.go +++ b/runtime/internal/runtime/proc.go @@ -28,17 +28,14 @@ import ( //llgo:type C type goroutineFunc func(unsafe.Pointer) unsafe.Pointer -// runtimeContext keeps the G, M, and P for the current 1:1 backend in one -// allocation. Keeping their ownership together makes mexit deterministic while -// leaving the individual objects and links compatible with a later M:N backend. +// runtimeContext owns one G and its target-specific suspended execution state. +// M and P ownership belongs to the selected scheduler backend and can outlive, +// or be shared by, multiple runtime contexts. type runtimeContext struct { g g - m m - p p - // root is non-nil for contexts passed through a host-thread API. Such - // contexts must remain visible to the collector until mexit. - root unsafe.Pointer + root unsafe.Pointer + platform runtimeContextPlatform } var sched struct { @@ -53,18 +50,11 @@ var sched struct { // lowering, this ABI contains no pthread types: the selected runtime backend // decides how to provide an M and execute the G. func NewProc(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr) { - gp := newproc1(fn, arg, getg()) - if errno := newm(gp.m, stackSize); errno != 0 { - ctx := gp.context - FreeRoot(arg) - FreeRoot(ctx.root) - panic("runtime: failed to create new OS thread") - } + newprocBackend(fn, arg, stackSize, getg()) } -// newproc1 creates a runnable G and its initial M/P ownership. The pthread -// backend starts that G immediately; a future scheduler can enqueue the same G -// without changing the compiler ABI. +// newproc1 creates target-independent runnable G state. The selected backend +// attaches execution resources and either starts or queues it. func newproc1(fn goroutineFunc, arg unsafe.Pointer, callergp *g) *g { if fn == nil { panic("go of nil func value") @@ -78,7 +68,9 @@ func newproc1(fn goroutineFunc, arg unsafe.Pointer, callergp *g) *g { } func allocRuntimeContext() *runtimeContext { - size := unsafe.Sizeof(runtimeContext{}) + // LLVM rounds contexts containing 64-bit IDs to this boundary on wasm. + const contextAlignment = uintptr(unsafe.Sizeof(uint64(0))) + size := (unsafe.Sizeof(runtimeContext{}) + contextAlignment - 1) &^ (contextAlignment - 1) root := AllocRoot(size) if root == nil { panic("runtime: failed to allocate goroutine context") @@ -89,99 +81,27 @@ func allocRuntimeContext() *runtimeContext { return ctx } -// newm starts the platform execution resource for mp. -func newm(mp *m, stackSize uintptr) int { - return newosproc(mp, stackSize) -} - -// mstart is the first LLGo runtime function executed on a new M. -func mstart(arg unsafe.Pointer) unsafe.Pointer { - mp := (*m)(arg) - if mp == nil || mp.curg == nil || mp.p == nil { - fatal("runtime: invalid mstart context") - return nil - } - gp := mp.curg - pp := mp.p - - setg(gp) - casgstatus(gp, _Grunnable, _Grunning) - setpstatus(pp, _Prunning) - - fn, arg := gp.startfn, gp.startarg - gp.startfn = nil - gp.startarg = nil - ret := fn(arg) - mexit(mp) - return ret -} - -// mexit tears down the current 1:1 G/M/P context. It does not terminate the -// host thread so both a returning start routine and runtime.Goexit can share -// the same ownership cleanup. -func mexit(mp *m) { - if mp == nil || mp.curg == nil || mp.p == nil { - fatal("runtime: invalid mexit context") +func freeRuntimeContext(ctx *runtimeContext) { + if ctx == nil || ctx.root == nil { return } - gp := mp.curg - pp := mp.p - ctx := gp.context root := ctx.root - - casgstatus(gp, _Grunning, _Gdead) - setpstatus(pp, _Pdead) - - pp.m = nil - mp.p = nil - mp.curg = nil - gp.m = nil - - setg(nil) - if root != nil { - ctx.root = nil - FreeRoot(root) - } + ctx.root = nil + FreeRoot(root) } -func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { +func initG(ctx *runtimeContext, callergp *g, status uint32) *g { gp := &ctx.g - mp := &ctx.m - pp := &ctx.p - - gp.m = mp gp.atomicstatus = status gp.goid = nextGoid(gp) if callergp != nil { gp.parentGoid = callergp.goid } gp.context = ctx - - mp.curg = gp - mp.p = pp - mp.id = nextMid(mp) - - pp.id = nextPid(pp) - pstatus := uint32(_Pidle) - if status == _Grunning { - pstatus = _Prunning - } - setpstatus(pp, pstatus) - pp.m = mp return gp } -// GMPForTesting reports the current runtime ownership graph. It is kept -// internal to the compiler runtime and linked only by LLGo execution tests. -func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) { - gp := getg() - if gp == nil || gp.m == nil || gp.m.p == nil { - return - } - mp := gp.m - pp := mp.p - ctx := gp.context - return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp), - mp.curg == gp && pp.m == mp && ctx != nil && - &ctx.g == gp && &ctx.m == mp && &ctx.p == pp +// Gosched yields the processor, allowing another goroutine to run. +func Gosched() { + goschedBackend() } diff --git a/runtime/internal/runtime/proc_atomic.go b/runtime/internal/runtime/proc_atomic.go index eaec33b38f..af70f3e43f 100644 --- a/runtime/internal/runtime/proc_atomic.go +++ b/runtime/internal/runtime/proc_atomic.go @@ -21,15 +21,15 @@ package runtime import "github.com/goplus/llgo/runtime/internal/clite/sync/atomic" func nextGoid(gp *g) uint64 { - return atomic.Add(&sched.goidgen, uint64(1)) + return atomic.Add(&sched.goidgen, uint64(1)) + 1 } func nextMid(mp *m) int64 { - return atomic.Add(&sched.midgen, int64(1)) + return atomic.Add(&sched.midgen, int64(1)) + 1 } func nextPid(pp *p) int32 { - return atomic.Add(&sched.pidgen, int32(1)) - 1 + return atomic.Add(&sched.pidgen, int32(1)) } func readgstatus(gp *g) uint32 { diff --git a/runtime/internal/runtime/proc_pthread.go b/runtime/internal/runtime/proc_pthread.go new file mode 100644 index 0000000000..f3f96fccf7 --- /dev/null +++ b/runtime/internal/runtime/proc_pthread.go @@ -0,0 +1,120 @@ +//go:build !llgo || !js || !wasm + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtime + +import "unsafe" + +// The pthread backend keeps its one-to-one M/P pair in the G context without +// exposing those fields to other execution-context backends. +type runtimeContextPlatform struct { + m m + p p +} + +func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, callergp *g) { + gp := newproc1(fn, arg, callergp) + if errno := newm(gp.m, stackSize); errno != 0 { + FreeRoot(arg) + freeRuntimeContext(gp.context) + panic("runtime: failed to create new OS thread") + } +} + +func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { + gp := initG(ctx, callergp, status) + mp := &ctx.platform.m + pp := &ctx.platform.p + + gp.m = mp + mp.curg = gp + mp.p = pp + mp.id = nextMid(mp) + + pp.id = nextPid(pp) + pstatus := uint32(_Pidle) + if status == _Grunning { + pstatus = _Prunning + } + setpstatus(pp, pstatus) + pp.m = mp + return gp +} + +func newm(mp *m, stackSize uintptr) int { + return newosproc(mp, stackSize) +} + +func mstart(arg unsafe.Pointer) unsafe.Pointer { + mp := (*m)(arg) + if mp == nil || mp.curg == nil || mp.p == nil { + fatal("runtime: invalid mstart context") + return nil + } + gp := mp.curg + pp := mp.p + + setg(gp) + casgstatus(gp, _Grunnable, _Grunning) + setpstatus(pp, _Prunning) + + fn, arg := gp.startfn, gp.startarg + gp.startfn = nil + gp.startarg = nil + ret := fn(arg) + mexit(mp) + return ret +} + +func mexit(mp *m) { + if mp == nil || mp.curg == nil || mp.p == nil { + fatal("runtime: invalid mexit context") + return + } + gp := mp.curg + pp := mp.p + ctx := gp.context + + casgstatus(gp, _Grunning, _Gdead) + setpstatus(pp, _Pdead) + + pp.m = nil + mp.p = nil + mp.curg = nil + gp.m = nil + + setg(nil) + freeRuntimeContext(ctx) +} + +func goschedBackend() { +} + +// GMPForTesting reports the current runtime ownership graph. +func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) { + gp := getg() + if gp == nil || gp.m == nil || gp.m.p == nil { + return + } + mp := gp.m + pp := mp.p + ctx := gp.context + return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp), + mp.curg == gp && pp.m == mp && ctx != nil && + &ctx.g == gp && &ctx.platform.m == mp && &ctx.platform.p == pp +} diff --git a/runtime/internal/runtime/proc_wasm.go b/runtime/internal/runtime/proc_wasm.go new file mode 100644 index 0000000000..b7eb301645 --- /dev/null +++ b/runtime/internal/runtime/proc_wasm.go @@ -0,0 +1,278 @@ +//go:build llgo && js && wasm + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/clite/emscripten" + "github.com/goplus/llgo/runtime/internal/runqueue" +) + +const ( + defaultWasmGStackSize = 64 << 10 + defaultWasmAsyncifyStackSize = 64 << 10 +) + +type runtimeContextPlatform struct { + fiber emscripten.Fiber + stack unsafe.Pointer + asyncifyStack unsafe.Pointer +} + +var wasmSched struct { + m m + p p + runq runqueue.Queue[*g] + retired *runtimeContext + started bool +} + +func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { + gp := initG(ctx, callergp, status) + if status == _Grunning { + initWasmScheduler(gp) + } + return gp +} + +func initWasmScheduler(gp *g) { + if wasmSched.started { + fatal("runtime: WebAssembly scheduler initialized twice") + return + } + wasmSched.started = true + mp := &wasmSched.m + pp := &wasmSched.p + mp.curg = gp + mp.p = pp + mp.id = nextMid(mp) + pp.id = nextPid(pp) + setpstatus(pp, _Prunning) + pp.m = mp + gp.m = mp +} + +func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, callergp *g) { + gp := newproc1(fn, arg, callergp) + initWasmFiber(gp, stackSize) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func initWasmFiber(gp *g, stackSize uintptr) { + if stackSize == 0 { + stackSize = defaultWasmGStackSize + } + stackSize = alignWasmStackSize(stackSize) + asyncifySize := uintptr(defaultWasmAsyncifyStackSize) + if stackSize > asyncifySize { + asyncifySize = stackSize + } + + platform := &gp.context.platform + platform.stack = allocWasmStack(stackSize) + platform.asyncifyStack = allocWasmStack(asyncifySize) + platform.fiber.Init( + emscripten.FiberEntry(wasmGStart), + unsafe.Pointer(gp), + platform.stack, + stackSize, + platform.asyncifyStack, + asyncifySize, + ) +} + +func alignWasmStackSize(size uintptr) uintptr { + const alignment = uintptr(16) + return (size + alignment - 1) &^ (alignment - 1) +} + +func allocWasmStack(size uintptr) unsafe.Pointer { + stack := AllocRoot(size) + if stack == nil { + panic("runtime: failed to allocate WebAssembly goroutine stack") + } + return stack +} + +func ensureCurrentWasmFiber(gp *g) { + platform := &gp.context.platform + if platform.asyncifyStack != nil { + return + } + platform.asyncifyStack = allocWasmStack(defaultWasmAsyncifyStackSize) + platform.fiber.InitCurrent(platform.asyncifyStack, defaultWasmAsyncifyStackSize) +} + +func wasmGStart(arg unsafe.Pointer) { + gp := (*g)(arg) + if gp == nil || getg() != gp { + fatal("runtime: invalid WebAssembly goroutine entry") + return + } + reapRetiredWasmG() + fn, fnarg := gp.startfn, gp.startarg + gp.startfn = nil + gp.startarg = nil + fn(fnarg) + goexitBackend(gp) +} + +func goschedBackend() { + gp := getg() + casgstatus(gp, _Grunning, _Grunnable) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + return + } + next := popWasmRunq() + if next == gp { + casgstatus(gp, _Grunnable, _Grunning) + return + } + resumeWasmG(gp, next) +} + +func gopark() { + gp := getg() + casgstatus(gp, _Grunning, _Gwaiting) + next := popWasmRunq() + if next == nil { + fatal("all goroutines are asleep - deadlock!") + return + } + resumeWasmG(gp, next) +} + +func goready(gp *g) { + if gp == nil { + fatal("runtime: ready of nil goroutine") + return + } + casgstatus(gp, _Gwaiting, _Grunnable) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func resumeWasmG(old, next *g) { + if old == nil || next == nil || next.context == nil { + fatal("runtime: invalid WebAssembly context switch") + return + } + ensureCurrentWasmFiber(old) + if next.context.platform.asyncifyStack == nil { + fatal("runtime: uninitialized WebAssembly goroutine context") + return + } + + casgstatus(next, _Grunnable, _Grunning) + mp := &wasmSched.m + old.m = nil + next.m = mp + mp.curg = next + setg(next) + old.context.platform.fiber.Swap(&next.context.platform.fiber) + reapRetiredWasmG() +} + +func goexitBackend(gp *g) { + next := popWasmRunq() + if next == nil { + fatal("no goroutines (main called runtime.Goexit) - deadlock!") + return + } + + casgstatus(gp, _Grunning, _Gdead) + if wasmSched.retired != nil { + fatal("runtime: unreaped WebAssembly goroutine") + return + } + wasmSched.retired = gp.context + resumeDeadWasmG(gp, next) +} + +func resumeDeadWasmG(old, next *g) { + ensureCurrentWasmFiber(old) + casgstatus(next, _Grunnable, _Grunning) + mp := &wasmSched.m + old.m = nil + next.m = mp + mp.curg = next + setg(next) + old.context.platform.fiber.Swap(&next.context.platform.fiber) + fatal("runtime: resumed dead WebAssembly goroutine") +} + +func reapRetiredWasmG() { + ctx := wasmSched.retired + if ctx == nil { + return + } + wasmSched.retired = nil + platform := &ctx.platform + if platform.stack != nil { + FreeRoot(platform.stack) + platform.stack = nil + } + if platform.asyncifyStack != nil { + FreeRoot(platform.asyncifyStack) + platform.asyncifyStack = nil + } + freeRuntimeContext(ctx) +} + +func popWasmRunq() *g { + return wasmSched.runq.Pop() +} + +// CurrentGForTesting returns an opaque handle suitable for ReadyForTesting. +func CurrentGForTesting() unsafe.Pointer { + return unsafe.Pointer(getg()) +} + +// ParkForTesting parks the current G until another G marks it runnable. +func ParkForTesting() { + gopark() +} + +// ReadyForTesting makes a G previously parked by ParkForTesting runnable. +func ReadyForTesting(handle unsafe.Pointer) { + goready((*g)(handle)) +} + +// SchedulerStateForTesting reports single-worker queue and ownership state. +func SchedulerStateForTesting() (runq uintptr, mid int64, pid int32) { + return wasmSched.runq.Len(), wasmSched.m.id, wasmSched.p.id +} + +func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) { + gp := getg() + if gp == nil || gp.m == nil || gp.m.p == nil { + return + } + mp := gp.m + pp := mp.p + ctx := gp.context + return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp), + mp == &wasmSched.m && pp == &wasmSched.p && + mp.curg == gp && pp.m == mp && ctx != nil && &ctx.g == gp +} diff --git a/runtime/internal/runtime/runtime2.go b/runtime/internal/runtime/runtime2.go index 8e0fe9dc7a..06012c0ce9 100644 --- a/runtime/internal/runtime/runtime2.go +++ b/runtime/internal/runtime/runtime2.go @@ -23,6 +23,7 @@ import "unsafe" const ( _Grunnable = 1 _Grunning = 2 + _Gwaiting = 4 _Gdead = 6 ) @@ -54,11 +55,32 @@ type g struct { goexit bool isMain bool paniconfault bool + + runqQueued uint32 + runqNext *g +} + +func (gp *g) RunqueueNext() *g { + return gp.runqNext +} + +func (gp *g) SetRunqueueNext(next *g) { + gp.runqNext = next +} + +func (gp *g) RunqueueQueued() bool { + return gp.runqQueued != 0 +} + +func (gp *g) SetRunqueueQueued(queued bool) { + if queued { + gp.runqQueued = 1 + } else { + gp.runqQueued = 0 + } } -// m represents the host execution resource running Go code. The platform -// thread handle is deliberately confined to mOS so other backends do not leak -// pthread types into the scheduler core. +// m represents the host execution resource running Go code. type m struct { curg *g p *p @@ -66,9 +88,7 @@ type m struct { os mOS } -// p represents the scheduling resources attached to an M. The pthread backend -// currently binds one P to one M; a later M:N scheduler can retain this object -// while replacing that fixed binding with a P pool and run queues. +// p represents the scheduling resources attached to an M. type p struct { id int32 status uint32 diff --git a/runtime/internal/runtime/z_default.go b/runtime/internal/runtime/z_default.go index 6ab61c0d66..71823fa552 100644 --- a/runtime/internal/runtime/z_default.go +++ b/runtime/internal/runtime/z_default.go @@ -28,18 +28,11 @@ func Rethrow(link *Defer) { c.Siglongjmp(link.Addr, 1) } } else if gp.goexit { - // Goexit must run deferred functions before terminating the current - // goroutine. Reuse the longjmp-based defer unwinding: - // 1) If we have a defer frame, longjmp to it so it can execute defers. - // 2) Once we've unwound past the last frame (link==nil), terminate the - // current pthread. + // Goexit runs deferred functions before the selected scheduler removes + // the current goroutine. if link != nil { c.Siglongjmp(link.Addr, 1) } - if gp.isMain { - fatal("no goroutines (main called runtime.Goexit) - deadlock!") - c.Exit(2) - } - exitCurrentM() + goexitBackend(gp) } } From 8b69b75ca7e530ddb951e68a769c3fe1f66f95e0 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 12:42:53 +0800 Subject: [PATCH 02/20] test(runtime): exercise wasm scheduler in Node --- .github/workflows/llgo.yml | 2 + internal/build/build_test.go | 2 +- internal/build/outputs_test.go | 23 +++ .../build/testdata/wasm-scheduler/main.go | 133 ++++++++++++++++++ internal/crosscompile/crosscompile_test.go | 10 ++ .../internal/clite/emscripten/fiber_test.go | 12 ++ runtime/internal/runqueue/runqueue_test.go | 60 ++++++++ 7 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 internal/build/testdata/wasm-scheduler/main.go create mode 100644 runtime/internal/clite/emscripten/fiber_test.go create mode 100644 runtime/internal/runqueue/runqueue_test.go diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index e8e34e6854..c55888e328 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -432,4 +432,6 @@ jobs: run: | GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-js.wasm" ./internal/build/testdata/wasm-runtime GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-wasip1.wasm" ./internal/build/testdata/wasm-runtime + GOOS=js GOARCH=wasm llgo build -target wasm -o "$RUNNER_TEMP/wasm-scheduler.mjs" ./internal/build/testdata/wasm-scheduler + node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-scheduler.mjs'; await Module();" file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 7f3eb3893c..5a7c1908ed 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -107,7 +107,7 @@ func TestDefaultBuildTags(t *testing.T) { }{ {name: "native", goarch: "arm64", want: base}, {name: "raw wasm", goarch: "wasm", want: base + ",nogc"}, - {name: "configured wasm target", goarch: "wasm", target: "wasip1", want: base}, + {name: "configured wasm target", goarch: "wasm", target: "wasip1", want: base + ",nogc"}, } { t.Run(test.name, func(t *testing.T) { if got := defaultBuildTags(test.goarch, test.target); got != test.want { diff --git a/internal/build/outputs_test.go b/internal/build/outputs_test.go index db2667b290..61c17e1c58 100644 --- a/internal/build/outputs_test.go +++ b/internal/build/outputs_test.go @@ -185,6 +185,29 @@ func TestBuildOutFmtsWithTarget(t *testing.T) { } } +func TestDefaultAppExtJSExplicitGlueOutput(t *testing.T) { + tests := []struct { + out string + want string + }{ + {out: "app.mjs", want: ".mjs"}, + {out: "app.js", want: ".js"}, + {out: "app.wasm", want: ".wasm"}, + {want: ".wasm"}, + } + for _, tt := range tests { + conf := &Config{ + Goos: "js", + Goarch: "wasm", + BuildMode: BuildModeExe, + OutFile: tt.out, + } + if got := defaultAppExt(conf); got != tt.want { + t.Errorf("defaultAppExt(%q) = %q, want %q", tt.out, got, tt.want) + } + } +} + func TestBuildOutFmtsNativeTarget(t *testing.T) { tests := []struct { name string diff --git a/internal/build/testdata/wasm-scheduler/main.go b/internal/build/testdata/wasm-scheduler/main.go new file mode 100644 index 0000000000..8e3d06880c --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/main.go @@ -0,0 +1,133 @@ +package main + +import ( + "runtime" + "unsafe" +) + +//go:linkname currentGForTesting github.com/goplus/llgo/runtime/internal/runtime.CurrentGForTesting +func currentGForTesting() unsafe.Pointer + +//go:linkname parkForTesting github.com/goplus/llgo/runtime/internal/runtime.ParkForTesting +func parkForTesting() + +//go:linkname readyForTesting github.com/goplus/llgo/runtime/internal/runtime.ReadyForTesting +func readyForTesting(unsafe.Pointer) + +//go:linkname schedulerStateForTesting github.com/goplus/llgo/runtime/internal/runtime.SchedulerStateForTesting +func schedulerStateForTesting() (runq uintptr, mid int64, pid int32) + +//go:linkname gmpForTesting github.com/goplus/llgo/runtime/internal/runtime.GMPForTesting +func gmpForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) + +var ( + parked unsafe.Pointer + mainMID int64 + mainPID int32 + mainGID uint64 + seenG [4]uint64 + seenGCount int + eventLog [8]int + eventCount int + done int +) + +func event(value int) { + eventLog[eventCount] = value + eventCount++ +} + +func checkCurrentG() { + goid, parent, mid, pid, gstatus, pstatus, linked := gmpForTesting() + if goid == 0 || goid == mainGID || parent != mainGID { + panic("invalid goroutine identity") + } + if mid != mainMID || pid != mainPID { + panic("goroutine did not reuse the single worker M/P") + } + if gstatus != 2 || pstatus != 1 || !linked { + panic("invalid running G/M/P state") + } + for i := 0; i < seenGCount; i++ { + if seenG[i] == goid { + panic("duplicate goroutine identity") + } + } + seenG[seenGCount] = goid + seenGCount++ +} + +func main() { + var ( + gstatus uint32 + pstatus uint32 + linked bool + ) + mainGID, _, mainMID, mainPID, gstatus, pstatus, linked = gmpForTesting() + if mainGID == 0 || mainMID == 0 || mainPID < 0 || gstatus != 2 || pstatus != 1 || !linked { + panic("invalid main G/M/P state") + } + + go func() { + checkCurrentG() + event(1) + parked = currentGForTesting() + parkForTesting() + event(8) + done++ + }() + + go func() { + checkCurrentG() + event(2) + runtime.Gosched() + event(6) + readyForTesting(parked) + event(7) + done++ + }() + + go func() { + checkCurrentG() + defer func() { + if recover() != "expected panic" { + panic("unexpected recover value") + } + event(3) + done++ + }() + panic("expected panic") + }() + + go func() { + checkCurrentG() + defer func() { + event(4) + done++ + }() + runtime.Goexit() + panic("Goexit returned") + }() + + if runq, mid, pid := schedulerStateForTesting(); runq != 4 || mid != mainMID || pid != mainPID { + panic("invalid initial scheduler state") + } + event(0) + for done != 4 { + runtime.Gosched() + } + + want := [...]int{0, 1, 2, 3, 4, 6, 7, 8} + if eventCount != len(want) { + panic("unexpected event count") + } + for i, value := range want { + if eventLog[i] != value { + panic("unexpected scheduler order") + } + } + if seenGCount != len(seenG) { + panic("not all goroutines ran") + } + println("wasm scheduler ok") +} diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index ea89a9596a..56edf0e498 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -172,6 +172,16 @@ func TestUseCrossCompileSDK(t *testing.T) { } } +func TestUseJSSupportsNode(t *testing.T) { + export, err := use("js", "wasm", false, false, optlevel.Oz, lto.Off, false) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(export.LDFLAGS, "-sENVIRONMENT=web,worker,node") { + t.Fatalf("LDFLAGS do not enable Node: %v", export.LDFLAGS) + } +} + func TestUseTarget(t *testing.T) { // Test cases for target-based configuration testCases := []struct { diff --git a/runtime/internal/clite/emscripten/fiber_test.go b/runtime/internal/clite/emscripten/fiber_test.go new file mode 100644 index 0000000000..da2456dbc1 --- /dev/null +++ b/runtime/internal/clite/emscripten/fiber_test.go @@ -0,0 +1,12 @@ +package emscripten + +import ( + "testing" + "unsafe" +) + +func TestFiberStorageUsesEightWords(t *testing.T) { + if got, want := unsafe.Sizeof(Fiber{}), uintptr(8)*unsafe.Sizeof(uintptr(0)); got != want { + t.Fatalf("Fiber size = %d, want %d", got, want) + } +} diff --git a/runtime/internal/runqueue/runqueue_test.go b/runtime/internal/runqueue/runqueue_test.go new file mode 100644 index 0000000000..38a9fd5fb7 --- /dev/null +++ b/runtime/internal/runqueue/runqueue_test.go @@ -0,0 +1,60 @@ +package runqueue + +import "testing" + +type testNode struct { + value int + queued bool + next *testNode +} + +func (node *testNode) RunqueueNext() *testNode { + return node.next +} + +func (node *testNode) SetRunqueueNext(next *testNode) { + node.next = next +} + +func (node *testNode) RunqueueQueued() bool { + return node.queued +} + +func (node *testNode) SetRunqueueQueued(queued bool) { + node.queued = queued +} + +func TestQueueFIFOAndReuse(t *testing.T) { + first := &testNode{value: 1} + second := &testNode{value: 2} + var q Queue[*testNode] + + if !q.Push(first) || !q.Push(second) { + t.Fatal("Push rejected initialized nodes") + } + if q.Push(first) { + t.Fatal("Push accepted a queued node") + } + if got := q.Len(); got != 2 { + t.Fatalf("Len = %d, want 2", got) + } + if got := q.Pop(); got != first || got.value != 1 { + t.Fatalf("first Pop = %p, want %p", got, first) + } + if got := q.Pop(); got != second || got.value != 2 { + t.Fatalf("second Pop = %p, want %p", got, second) + } + if got := q.Pop(); got != nil { + t.Fatalf("empty Pop = %p, want nil", got) + } + if !q.Push(first) || q.Pop() != first { + t.Fatal("queue did not accept a reused node") + } +} + +func TestQueueRejectsInvalidNodes(t *testing.T) { + var q Queue[*testNode] + if q.Push(nil) { + t.Fatal("Push accepted nil") + } +} From fe0d36d0daff7ca61272a708db17af3de08190a3 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 13:10:27 +0800 Subject: [PATCH 03/20] build/wasm: distinguish Go Memory64 from wasm32 target --- .github/workflows/llgo.yml | 9 ++++- .github/workflows/targets.yml | 5 +++ internal/build/build.go | 20 ++++++---- internal/build/build_test.go | 25 +++++++++++++ internal/build/source_patch_test.go | 37 ++++++++++++------- internal/build/testdata/wasm-scheduler/abi.c | 5 +++ internal/build/testdata/wasm-scheduler/abi.go | 8 ++++ .../build/testdata/wasm-scheduler/main.go | 1 + .../build/testdata/wasm-scheduler/model_go.go | 14 +++++++ .../testdata/wasm-scheduler/model_target.go | 14 +++++++ internal/crosscompile/crosscompile.go | 30 ++++++++++++++- internal/crosscompile/crosscompile_test.go | 28 ++++++++++++++ runtime/internal/clite/c.go | 2 +- .../internal/clite/ctypes_selection_test.go | 34 +++++++++++++++++ runtime/internal/clite/ctypes_wasm.go | 5 +-- runtime/internal/clite/ctypes_wasm64.go | 25 +++++++++++++ runtime/internal/clite/emscripten/fiber.go | 2 +- ssa/ssa_test.go | 18 +++++++++ ssa/target.go | 5 +++ 19 files changed, 260 insertions(+), 27 deletions(-) create mode 100644 internal/build/testdata/wasm-scheduler/abi.c create mode 100644 internal/build/testdata/wasm-scheduler/abi.go create mode 100644 internal/build/testdata/wasm-scheduler/model_go.go create mode 100644 internal/build/testdata/wasm-scheduler/model_target.go create mode 100644 runtime/internal/clite/ctypes_selection_test.go create mode 100644 runtime/internal/clite/ctypes_wasm64.go diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index c55888e328..c23e654c23 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -414,6 +414,11 @@ jobs: with: version: "4.0.21" + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "25" + - name: Set up Go for building llgo uses: ./.github/actions/setup-go @@ -432,6 +437,8 @@ jobs: run: | GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-js.wasm" ./internal/build/testdata/wasm-runtime GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-wasip1.wasm" ./internal/build/testdata/wasm-runtime - GOOS=js GOARCH=wasm llgo build -target wasm -o "$RUNNER_TEMP/wasm-scheduler.mjs" ./internal/build/testdata/wasm-scheduler + GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-scheduler-go.mjs" ./internal/build/testdata/wasm-scheduler + node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-scheduler-go.mjs'; await Module();" + llgo build -target wasm -o "$RUNNER_TEMP/wasm-scheduler.mjs" ./internal/build/testdata/wasm-scheduler node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-scheduler.mjs'; await Module();" file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" diff --git a/.github/workflows/targets.yml b/.github/workflows/targets.yml index 6eed98a333..171eb78566 100644 --- a/.github/workflows/targets.yml +++ b/.github/workflows/targets.yml @@ -29,6 +29,11 @@ jobs: with: llvm-version: ${{matrix.llvm}} + - name: Set up Emscripten + uses: emscripten-core/setup-emsdk@v15 + with: + version: "4.0.21" + - name: Set up Go for build uses: ./.github/actions/setup-go diff --git a/internal/build/build.go b/internal/build/build.go index dc8140f80c..6352bdd505 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -291,9 +291,6 @@ func Do(args []string, conf *Config) ([]Package, error) { if conf.Goarch == "" { conf.Goarch = runtime.GOARCH } - if conf.AppExt == "" { - conf.AppExt = defaultAppExt(conf) - } if conf.BuildMode == "" { conf.BuildMode = BuildModeExe } @@ -332,6 +329,9 @@ func Do(args []string, conf *Config) ([]Package, error) { if conf.Target != "" && export.GOARCH != "" { conf.Goarch = export.GOARCH } + if conf.AppExt == "" { + conf.AppExt = defaultAppExt(conf) + } if err := validateLinkOptions(conf, &export); err != nil { return nil, err } @@ -410,10 +410,7 @@ func Do(args []string, conf *Config) ([]Package, error) { // final-PC sites for sidecar construction. prog.EnableFuncInfoSites(shouldEnablePCLNSites(conf, funcInfo, emitDebugInfo)) sizes := func(sizes types.Sizes, compiler, arch string) types.Sizes { - if arch == "wasm" { - sizes = &types.StdSizes{WordSize: 4, MaxAlign: 4} - } - return prog.TypeSizes(sizes) + return prog.TypeSizes(effectiveTypeSizes(sizes, conf.Goos, arch, conf.Target)) } dedup := packages.NewDeduper() dedup.SetPreload(func(pkg *types.Package, files []*ast.File) { @@ -691,6 +688,15 @@ func defaultBuildTags(goarch, target string) string { return tags } +func effectiveTypeSizes(sizes types.Sizes, goos, goarch, target string) types.Sizes { + // Named wasm targets use the native wasm32 data model. The raw js/wasm + // entry point keeps Go's 64-bit word model and is emitted as Memory64. + if goarch == "wasm" && (target != "" || goos != "js") { + return &types.StdSizes{WordSize: 4, MaxAlign: 4} + } + return sizes +} + func allowMissingFunctionBodies(initial []*packages.Package) { for _, pkg := range initial { hasMissingBody := false diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 5a7c1908ed..b7c540fad5 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -11,6 +11,7 @@ import ( gobuild "go/build" "go/parser" "go/token" + "go/types" "io" "os" "os/exec" @@ -117,6 +118,30 @@ func TestDefaultBuildTags(t *testing.T) { } } +func TestEffectiveWasmTypeSizes(t *testing.T) { + goSizes := types.SizesFor("gc", "wasm") + for _, test := range []struct { + name string + goos string + target string + want int64 + }{ + {name: "Go js wasm", goos: "js", want: 8}, + {name: "configured wasm", goos: "js", target: "wasm", want: 4}, + {name: "WASI compatibility", goos: "wasip1", want: 4}, + } { + t.Run(test.name, func(t *testing.T) { + got := effectiveTypeSizes(goSizes, test.goos, "wasm", test.target) + if size := got.Sizeof(types.Typ[types.Uintptr]); size != test.want { + t.Fatalf("uintptr size = %d, want %d", size, test.want) + } + }) + } + if got := effectiveTypeSizes(goSizes, "linux", "amd64", ""); got != goSizes { + t.Fatal("native type sizes changed") + } +} + func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { runtimeDir := filepath.Join(env.LLGoRuntimeDir(), "internal", "lib", "runtime") for _, goos := range []string{"js", "wasip1"} { diff --git a/internal/build/source_patch_test.go b/internal/build/source_patch_test.go index 4e913720a7..e8df1a755b 100644 --- a/internal/build/source_patch_test.go +++ b/internal/build/source_patch_test.go @@ -19,29 +19,40 @@ import ( ) func TestWasmRuntimeSourcePatchTypeChecks(t *testing.T) { - for _, goos := range []string{"js", "wasip1"} { - t.Run(goos, func(t *testing.T) { - cfgEnv := append(os.Environ(), "GOOS="+goos, "GOARCH=wasm") + for _, test := range []struct { + name string + goos string + target string + buildFlags []string + }{ + {name: "js Memory64", goos: "js"}, + {name: "js wasm32 target", goos: "js", target: "wasm", buildFlags: []string{"-tags=tinygo.wasm"}}, + {name: "WASI wasm32", goos: "wasip1"}, + } { + t.Run(test.name, func(t *testing.T) { + cfgEnv := append(os.Environ(), "GOOS="+test.goos, "GOARCH=wasm") goroot, goversion, err := env.GOROOTAndGOVERSIONWithEnv(cfgEnv) if err != nil { t.Fatal(err) } overlay, err := buildSourcePatchOverlayForGOROOT(nil, env.LLGoRuntimeDir(), goroot, sourcePatchBuildContext{ - goos: goos, - goarch: "wasm", - goversion: goversion, + goos: test.goos, + goarch: "wasm", + goversion: goversion, + buildFlags: test.buildFlags, }) if err != nil { t.Fatal(err) } - pkgs, err := packages.LoadEx(nil, func(types.Sizes, string, string) types.Sizes { - return &types.StdSizes{WordSize: 4, MaxAlign: 4} + pkgs, err := packages.LoadEx(nil, func(sizes types.Sizes, _ string, arch string) types.Sizes { + return effectiveTypeSizes(sizes, test.goos, arch, test.target) }, &packages.Config{ - Mode: loadSyntax | packages.NeedDeps | packages.NeedModule | packages.NeedExportFile, - Env: cfgEnv, - Fset: token.NewFileSet(), - Overlay: overlay, + Mode: loadSyntax | packages.NeedDeps | packages.NeedModule | packages.NeedExportFile, + Env: cfgEnv, + Fset: token.NewFileSet(), + Overlay: overlay, + BuildFlags: test.buildFlags, }, "runtime") if err != nil { t.Fatal(err) @@ -51,7 +62,7 @@ func TestWasmRuntimeSourcePatchTypeChecks(t *testing.T) { } if pkgs[0].IllTyped { logPackageErrors(t, pkgs[0], make(map[string]bool)) - t.Fatal("runtime did not type-check with wasm32 sizes") + t.Fatal("runtime did not type-check") } }) } diff --git a/internal/build/testdata/wasm-scheduler/abi.c b/internal/build/testdata/wasm-scheduler/abi.c new file mode 100644 index 0000000000..2f6f6ff3ba --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/abi.c @@ -0,0 +1,5 @@ +#include + +size_t llgo_test_sizeof_long(void) { + return sizeof(long); +} diff --git a/internal/build/testdata/wasm-scheduler/abi.go b/internal/build/testdata/wasm-scheduler/abi.go new file mode 100644 index 0000000000..3004ad69d4 --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/abi.go @@ -0,0 +1,8 @@ +package main + +import _ "unsafe" + +const LLGoFiles = "abi.c" + +//go:linkname cLongSize C.llgo_test_sizeof_long +func cLongSize() uintptr diff --git a/internal/build/testdata/wasm-scheduler/main.go b/internal/build/testdata/wasm-scheduler/main.go index 8e3d06880c..b2b1900d7a 100644 --- a/internal/build/testdata/wasm-scheduler/main.go +++ b/internal/build/testdata/wasm-scheduler/main.go @@ -58,6 +58,7 @@ func checkCurrentG() { } func main() { + checkWasmModel() var ( gstatus uint32 pstatus uint32 diff --git a/internal/build/testdata/wasm-scheduler/model_go.go b/internal/build/testdata/wasm-scheduler/model_go.go new file mode 100644 index 0000000000..73781131c4 --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/model_go.go @@ -0,0 +1,14 @@ +//go:build !tinygo.wasm + +package main + +import "unsafe" + +func checkWasmModel() { + if unsafe.Sizeof(uintptr(0)) != 8 { + panic("GOOS/GOARCH wasm must use 64-bit words") + } + if cLongSize() != 8 { + panic("GOOS/GOARCH wasm must use the LP64 C data model") + } +} diff --git a/internal/build/testdata/wasm-scheduler/model_target.go b/internal/build/testdata/wasm-scheduler/model_target.go new file mode 100644 index 0000000000..1fadc4efc1 --- /dev/null +++ b/internal/build/testdata/wasm-scheduler/model_target.go @@ -0,0 +1,14 @@ +//go:build tinygo.wasm + +package main + +import "unsafe" + +func checkWasmModel() { + if unsafe.Sizeof(uintptr(0)) != 4 { + panic("-target wasm must use 32-bit words") + } + if cLongSize() != 4 { + panic("-target wasm must use the wasm32 C data model") + } +} diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index 36a1911ed2..fae2ff8102 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -218,6 +218,10 @@ func compileWithConfig( } func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Level, ltoMode lto.Mode, goGlobalDCE bool) (export Export, err error) { + return useWithJSWasm32(goos, goarch, wasiThreads, forceEspClang, level, ltoMode, goGlobalDCE, false) +} + +func useWithJSWasm32(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Level, ltoMode lto.Mode, goGlobalDCE, jsWasm32 bool) (export Export, err error) { targetTriple := llvm.GetTargetTriple(goos, goarch) llgoRoot := env.LLGoROOT() @@ -397,6 +401,7 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le "-fwasm-exceptions", "-mllvm", "-wasm-enable-sjlj", }...) + export.LLVMTarget = "wasm32-unknown-wasip1" // Add thread support if enabled if wasiThreads { export.CCFLAGS = append( @@ -412,7 +417,13 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le } case "js": - targetTriple := "wasm32-unknown-emscripten" + // The Go wasm type model uses 64-bit words. Use Memory64 so LLVM + // pointers have the same width; named wasm targets retain wasm32. + targetTriple := "wasm64-unknown-emscripten" + if jsWasm32 { + targetTriple = "wasm32-unknown-emscripten" + } + export.LLVMTarget = targetTriple // Emscripten configuration using system installation // Specify emcc as the compiler export.CC = "emcc" @@ -452,6 +463,9 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool, level optlevel.Le "-sASYNCIFY=1", "-sSTACK_SIZE=5242880", // 50MB }...) + if !jsWasm32 { + export.LDFLAGS = append(export.LDFLAGS, "-sMEMORY64=1") + } default: err = errors.New("unsupported GOOS for WebAssembly: " + goos) @@ -716,5 +730,19 @@ func Use(goos, goarch, targetName string, wasiThreads, forceEspClang bool, level if targetName != "" && !strings.HasPrefix(targetName, "wasm") && !strings.HasPrefix(targetName, "wasi") { return UseTarget(targetName, level, ltoMode) } + if targetName == "wasm" { + config, err := targets.NewDefaultResolver().Resolve(targetName) + if err != nil { + return export, err + } + export, err = useWithJSWasm32(config.GOOS, config.GOARCH, wasiThreads, forceEspClang, level, ltoMode, goGlobalDCE, true) + if err != nil { + return export, err + } + export.BuildTags = config.BuildTags + export.GOOS = config.GOOS + export.GOARCH = config.GOARCH + return export, nil + } return use(goos, goarch, wasiThreads, forceEspClang, level, ltoMode, goGlobalDCE) } diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index 56edf0e498..41f933f940 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -177,9 +177,37 @@ func TestUseJSSupportsNode(t *testing.T) { if err != nil { t.Fatal(err) } + if export.LLVMTarget != "wasm64-unknown-emscripten" { + t.Fatalf("LLVMTarget = %q, want wasm64-unknown-emscripten", export.LLVMTarget) + } if !slices.Contains(export.LDFLAGS, "-sENVIRONMENT=web,worker,node") { t.Fatalf("LDFLAGS do not enable Node: %v", export.LDFLAGS) } + if !slices.Contains(export.LDFLAGS, "-sMEMORY64=1") { + t.Fatalf("LDFLAGS do not enable Memory64: %v", export.LDFLAGS) + } +} + +func TestUseWasmTargetSelectsGoPlatform(t *testing.T) { + export, err := Use(runtime.GOOS, runtime.GOARCH, "wasm", false, false, optlevel.Oz, lto.Off, false) + if err != nil { + t.Fatal(err) + } + if export.GOOS != "js" || export.GOARCH != "wasm" { + t.Fatalf("GOOS/GOARCH = %s/%s, want js/wasm", export.GOOS, export.GOARCH) + } + if export.CC != "emcc" { + t.Fatalf("CC = %q, want emcc", export.CC) + } + if export.LLVMTarget != "wasm32-unknown-emscripten" { + t.Fatalf("LLVMTarget = %q, want wasm32-unknown-emscripten", export.LLVMTarget) + } + if !slices.Contains(export.BuildTags, "tinygo.wasm") { + t.Fatalf("BuildTags do not identify the wasm32 target: %v", export.BuildTags) + } + if slices.Contains(export.LDFLAGS, "-sMEMORY64=1") { + t.Fatalf("wasm32 LDFLAGS enable Memory64: %v", export.LDFLAGS) + } } func TestUseTarget(t *testing.T) { diff --git a/runtime/internal/clite/c.go b/runtime/internal/clite/c.go index 78a9897339..aa6f7a6fef 100644 --- a/runtime/internal/clite/c.go +++ b/runtime/internal/clite/c.go @@ -51,7 +51,7 @@ type integer interface { } type SizeT = uintptr -type SsizeT = Long +type SsizeT = int type IntptrT = uintptr type UintptrT = uintptr diff --git a/runtime/internal/clite/ctypes_selection_test.go b/runtime/internal/clite/ctypes_selection_test.go new file mode 100644 index 0000000000..1db4dc4c20 --- /dev/null +++ b/runtime/internal/clite/ctypes_selection_test.go @@ -0,0 +1,34 @@ +package c + +import ( + "go/build" + "slices" + "testing" +) + +func TestWasmCTypeFileSelection(t *testing.T) { + for _, test := range []struct { + name string + goos string + tags []string + want string + }{ + {name: "js Memory64", goos: "js", want: "ctypes_wasm64.go"}, + {name: "js wasm32 target", goos: "js", tags: []string{"tinygo.wasm"}, want: "ctypes_wasm.go"}, + {name: "WASI wasm32", goos: "wasip1", want: "ctypes_wasm.go"}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := build.Default + ctx.GOOS = test.goos + ctx.GOARCH = "wasm" + ctx.BuildTags = test.tags + pkg, err := ctx.ImportDir(".", 0) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(pkg.GoFiles, test.want) { + t.Fatalf("GoFiles = %v, want %s", pkg.GoFiles, test.want) + } + }) + } +} diff --git a/runtime/internal/clite/ctypes_wasm.go b/runtime/internal/clite/ctypes_wasm.go index 9b68f43a68..a580ccb6e5 100644 --- a/runtime/internal/clite/ctypes_wasm.go +++ b/runtime/internal/clite/ctypes_wasm.go @@ -1,5 +1,4 @@ -//go:build wasip1 || js -// +build wasip1 js +//go:build wasip1 || (js && tinygo.wasm) /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. @@ -19,7 +18,7 @@ package c -// For WebAssembly targets, Long is 32-bit per the spec +// WASI and configured js/wasm targets use the wasm32 C data model. type ( Long = int32 Ulong = uint32 diff --git a/runtime/internal/clite/ctypes_wasm64.go b/runtime/internal/clite/ctypes_wasm64.go new file mode 100644 index 0000000000..1f6b409b79 --- /dev/null +++ b/runtime/internal/clite/ctypes_wasm64.go @@ -0,0 +1,25 @@ +//go:build js && !tinygo.wasm + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package c + +// Emscripten Memory64 uses the LP64 C data model. +type ( + Long = int64 + Ulong = uint64 +) diff --git a/runtime/internal/clite/emscripten/fiber.go b/runtime/internal/clite/emscripten/fiber.go index 8490f8fd10..00a4fe87f7 100644 --- a/runtime/internal/clite/emscripten/fiber.go +++ b/runtime/internal/clite/emscripten/fiber.go @@ -21,7 +21,7 @@ package emscripten import c "github.com/goplus/llgo/runtime/internal/clite" // Fiber is the opaque emscripten_fiber_t storage. The C layout consists of -// eight pointer-sized fields on wasm32. +// eight pointer-sized fields. type Fiber struct { _ [8]uintptr } diff --git a/ssa/ssa_test.go b/ssa/ssa_test.go index 15bca0945e..6bafc55a5c 100644 --- a/ssa/ssa_test.go +++ b/ssa/ssa_test.go @@ -2694,6 +2694,24 @@ func TestTargetMachineAndDataLayout(t *testing.T) { } } +func TestWasmTargetSpec(t *testing.T) { + for _, test := range []struct { + name string + target string + want string + }{ + {name: "Go environment", want: "wasm64-unknown-js"}, + {name: "configured target", target: "wasm", want: "wasm32-unknown-js"}, + } { + t.Run(test.name, func(t *testing.T) { + got := (&Target{GOOS: "js", GOARCH: "wasm", Target: test.target}).Spec().Triple + if got != test.want { + t.Fatalf("triple = %q, want %q", got, test.want) + } + }) + } +} + func TestAbiTables(t *testing.T) { prog := NewProgram(nil) prog.sizes = types.SizesFor("gc", runtime.GOARCH) diff --git a/ssa/target.go b/ssa/target.go index a352b477fd..fbe8cf38ac 100644 --- a/ssa/target.go +++ b/ssa/target.go @@ -146,6 +146,11 @@ func (p *Target) Spec() (spec TargetSpec) { } case "wasm": llvmarch = "wasm32" + // Keep raw js/wasm consistent with Go's 64-bit word model. Named + // targets use their existing wasm32 ABI. + if goos == "js" && p.Target == "" { + llvmarch = "wasm64" + } default: llvmarch = goarch } From 76f2b784cd6a186e92d13f6ae3256ecac65d4f0c Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 15:59:33 +0800 Subject: [PATCH 04/20] test(crosscompile): cover wasm target setup errors --- internal/crosscompile/crosscompile_test.go | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index 41f933f940..66be579201 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -5,6 +5,7 @@ package crosscompile import ( "os" + "path/filepath" "runtime" "slices" "strings" @@ -210,6 +211,50 @@ func TestUseWasmTargetSelectsGoPlatform(t *testing.T) { } } +func TestUseWasmTargetErrors(t *testing.T) { + newLLGoRoot := func(t *testing.T, wasmConfig string) { + t.Helper() + root := t.TempDir() + runtimeDir := filepath.Join(root, "runtime") + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runtimeDir, "go.mod"), []byte("module github.com/goplus/llgo/runtime\n"), 0o644); err != nil { + t.Fatal(err) + } + if wasmConfig != "" { + targetsDir := filepath.Join(root, "targets") + if err := os.MkdirAll(targetsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(targetsDir, "wasm.json"), []byte(wasmConfig), 0o644); err != nil { + t.Fatal(err) + } + } + t.Setenv("LLGO_ROOT", root) + } + + t.Run("resolve", func(t *testing.T) { + newLLGoRoot(t, "") + _, err := Use(runtime.GOOS, runtime.GOARCH, "wasm", false, false, optlevel.Oz, lto.Off, false) + if err == nil || !strings.Contains(err.Error(), "failed to resolve target wasm") { + t.Fatalf("Use error = %v, want target resolution error", err) + } + }) + + t.Run("toolchain setup", func(t *testing.T) { + newLLGoRoot(t, `{"goos":"js","goarch":"wasm"}`) + oldCacheRoot := cacheRoot + cacheRoot = func() string { return "\x00" } + defer func() { cacheRoot = oldCacheRoot }() + + _, err := Use(runtime.GOOS, runtime.GOARCH, "wasm", false, true, optlevel.Oz, lto.Off, false) + if err == nil { + t.Fatal("Use succeeded with an invalid toolchain cache path") + } + }) +} + func TestUseTarget(t *testing.T) { // Test cases for target-based configuration testCases := []struct { From 0e631e5383160acd8fb2f3d587b79d9f13cc18dd Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 23:57:30 +0800 Subject: [PATCH 05/20] runtime/wasm: make scheduler invariant failures fatal --- .github/workflows/llgo.yml | 15 +++++++++++++-- internal/build/testdata/wasm-scheduler/abi.c | 5 +++++ internal/build/testdata/wasm-scheduler/abi.go | 3 +++ internal/build/testdata/wasm-scheduler/main.go | 12 +++++++++++- runtime/internal/runtime/proc.go | 3 ++- runtime/internal/runtime/proc_atomic.go | 2 ++ runtime/internal/runtime/proc_wasm.go | 17 +++++++++++------ runtime/internal/runtime/runtime2.go | 8 ++++---- runtime/internal/runtime/stubs.go | 2 ++ 9 files changed, 53 insertions(+), 14 deletions(-) diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index c23e654c23..c00ac9bee8 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -435,10 +435,21 @@ jobs: - name: Build standard runtime for wasm shell: bash run: | + run_wasm_scheduler() { + local module="$1" + local output + node --input-type=module -e "import Module from '$module'; await Module();" + if output=$(node --input-type=module -e "import Module from '$module'; await Module({preRun: [module => { module.ENV.LLGO_WASM_SCHEDULER_DEADLOCK = '1'; }]});" 2>&1); then + echo "deadlock scheduler fixture unexpectedly succeeded" + return 1 + fi + grep -Fq "fatal error: all goroutines are asleep - deadlock!" <<<"$output" + } + GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-js.wasm" ./internal/build/testdata/wasm-runtime GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-wasip1.wasm" ./internal/build/testdata/wasm-runtime GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-scheduler-go.mjs" ./internal/build/testdata/wasm-scheduler - node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-scheduler-go.mjs'; await Module();" + run_wasm_scheduler "$RUNNER_TEMP/wasm-scheduler-go.mjs" llgo build -target wasm -o "$RUNNER_TEMP/wasm-scheduler.mjs" ./internal/build/testdata/wasm-scheduler - node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-scheduler.mjs'; await Module();" + run_wasm_scheduler "$RUNNER_TEMP/wasm-scheduler.mjs" file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" diff --git a/internal/build/testdata/wasm-scheduler/abi.c b/internal/build/testdata/wasm-scheduler/abi.c index 2f6f6ff3ba..b648edf3ec 100644 --- a/internal/build/testdata/wasm-scheduler/abi.c +++ b/internal/build/testdata/wasm-scheduler/abi.c @@ -1,5 +1,10 @@ #include +#include size_t llgo_test_sizeof_long(void) { return sizeof(long); } + +int llgo_test_scheduler_deadlock(void) { + return getenv("LLGO_WASM_SCHEDULER_DEADLOCK") != NULL; +} diff --git a/internal/build/testdata/wasm-scheduler/abi.go b/internal/build/testdata/wasm-scheduler/abi.go index 3004ad69d4..caa04596fc 100644 --- a/internal/build/testdata/wasm-scheduler/abi.go +++ b/internal/build/testdata/wasm-scheduler/abi.go @@ -6,3 +6,6 @@ const LLGoFiles = "abi.c" //go:linkname cLongSize C.llgo_test_sizeof_long func cLongSize() uintptr + +//go:linkname schedulerDeadlockMode C.llgo_test_scheduler_deadlock +func schedulerDeadlockMode() int32 diff --git a/internal/build/testdata/wasm-scheduler/main.go b/internal/build/testdata/wasm-scheduler/main.go index b2b1900d7a..12fd8c81ae 100644 --- a/internal/build/testdata/wasm-scheduler/main.go +++ b/internal/build/testdata/wasm-scheduler/main.go @@ -59,13 +59,17 @@ func checkCurrentG() { func main() { checkWasmModel() + if schedulerDeadlockMode() != 0 { + testParkedMainDeadlock() + return + } var ( gstatus uint32 pstatus uint32 linked bool ) mainGID, _, mainMID, mainPID, gstatus, pstatus, linked = gmpForTesting() - if mainGID == 0 || mainMID == 0 || mainPID < 0 || gstatus != 2 || pstatus != 1 || !linked { + if mainGID != 1 || mainMID != 1 || mainPID != 0 || gstatus != 2 || pstatus != 1 || !linked { panic("invalid main G/M/P state") } @@ -132,3 +136,9 @@ func main() { } println("wasm scheduler ok") } + +func testParkedMainDeadlock() { + go func() {}() + parkForTesting() + panic("park returned after scheduler deadlock") +} diff --git a/runtime/internal/runtime/proc.go b/runtime/internal/runtime/proc.go index 0eaf57396a..ae66de6057 100644 --- a/runtime/internal/runtime/proc.go +++ b/runtime/internal/runtime/proc.go @@ -101,7 +101,8 @@ func initG(ctx *runtimeContext, callergp *g, status uint32) *g { return gp } -// Gosched yields the processor, allowing another goroutine to run. +// Gosched asks the active backend to yield. The WebAssembly fiber backend +// switches to another runnable G; pthread Gs rely on the host thread scheduler. func Gosched() { goschedBackend() } diff --git a/runtime/internal/runtime/proc_atomic.go b/runtime/internal/runtime/proc_atomic.go index af70f3e43f..0920cfb7e7 100644 --- a/runtime/internal/runtime/proc_atomic.go +++ b/runtime/internal/runtime/proc_atomic.go @@ -20,6 +20,8 @@ package runtime import "github.com/goplus/llgo/runtime/internal/clite/sync/atomic" +// LLGo's atomic.Add returns the value before the addition. G and M reserve ID +// zero, while P IDs are zero-based like the Go runtime. func nextGoid(gp *g) uint64 { return atomic.Add(&sched.goidgen, uint64(1)) + 1 } diff --git a/runtime/internal/runtime/proc_wasm.go b/runtime/internal/runtime/proc_wasm.go index b7eb301645..18a91a5e38 100644 --- a/runtime/internal/runtime/proc_wasm.go +++ b/runtime/internal/runtime/proc_wasm.go @@ -195,17 +195,22 @@ func resumeWasmG(old, next *g) { } func goexitBackend(gp *g) { - next := popWasmRunq() - if next == nil { - fatal("no goroutines (main called runtime.Goexit) - deadlock!") - return - } - casgstatus(gp, _Grunning, _Gdead) if wasmSched.retired != nil { fatal("runtime: unreaped WebAssembly goroutine") return } + + next := popWasmRunq() + if next == nil { + if gp.isMain { + fatal("no goroutines (main called runtime.Goexit) - deadlock!") + } else { + fatal("all goroutines are asleep - deadlock!") + } + return + } + wasmSched.retired = gp.context resumeDeadWasmG(gp, next) } diff --git a/runtime/internal/runtime/runtime2.go b/runtime/internal/runtime/runtime2.go index 06012c0ce9..e617ca3cef 100644 --- a/runtime/internal/runtime/runtime2.go +++ b/runtime/internal/runtime/runtime2.go @@ -19,7 +19,7 @@ package runtime import "unsafe" // These G and P states intentionally keep the values used by the Go runtime. -// Only states reachable by the current 1:1 backend are defined here. +// Only states reachable by the current backends are defined here. const ( _Grunnable = 1 _Grunning = 2 @@ -35,9 +35,9 @@ const ( // g holds state owned by one LLGo goroutine. // -// The current pthread backend gives every G its own M and P. Fields that only -// make sense once LLGo can suspend and resume a G (saved registers, wait state, -// and stack roots) belong here when those facilities are added. +// A backend decides the M/P ownership model: pthread gives every G its own M/P, +// while the WebAssembly fiber scheduler shares one M/P across its Gs. Suspended +// execution state is held by the backend-specific runtimeContext. type g struct { defer_ *Defer panic_ unsafe.Pointer diff --git a/runtime/internal/runtime/stubs.go b/runtime/internal/runtime/stubs.go index 61d9013b89..9c164d3ffb 100644 --- a/runtime/internal/runtime/stubs.go +++ b/runtime/internal/runtime/stubs.go @@ -7,6 +7,7 @@ package runtime import ( "unsafe" + c "github.com/goplus/llgo/runtime/internal/clite" "github.com/goplus/llgo/runtime/internal/clite/sync/atomic" "github.com/goplus/llgo/runtime/internal/clite/time" "github.com/goplus/llgo/runtime/internal/runtime/math" @@ -118,6 +119,7 @@ func memclrNoHeapPointers(ptr unsafe.Pointer, n uintptr) { func fatal(s string) { print("fatal error: ", s, "\n") + c.Exit(2) } func throw(s string) { From 6c7c3a79e576f6acc802f813811ee82f5e287c1f Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 02:33:38 +0800 Subject: [PATCH 06/20] runtime/wasm: add WASI single-worker scheduler --- internal/build/build.go | 22 +- internal/build/main_module.go | 30 +- internal/build/wasm_postlink.go | 90 ++++++ internal/crosscompile/crosscompile.go | 31 +- runtime/internal/runtime/g_pthread.go | 2 +- runtime/internal/runtime/g_wasm.go | 2 +- runtime/internal/runtime/os_pthread.go | 2 +- runtime/internal/runtime/os_wasm.go | 2 +- runtime/internal/runtime/proc_pthread.go | 2 +- runtime/internal/runtime/proc_wasip1.go | 272 ++++++++++++++++++ runtime/internal/runtime/proc_wasm.go | 14 +- runtime/internal/wasmcontext/context_js.go | 51 ++++ .../internal/wasmcontext/context_wasip1.go | 72 +++++ runtime/internal/wasmcontext/context_wasm.S | 121 ++++++++ runtime/internal/wasmcontext/doc.go | 19 ++ 15 files changed, 706 insertions(+), 26 deletions(-) create mode 100644 internal/build/wasm_postlink.go create mode 100644 runtime/internal/runtime/proc_wasip1.go create mode 100644 runtime/internal/wasmcontext/context_js.go create mode 100644 runtime/internal/wasmcontext/context_wasip1.go create mode 100644 runtime/internal/wasmcontext/context_wasm.S create mode 100644 runtime/internal/wasmcontext/doc.go diff --git a/internal/build/build.go b/internal/build/build.go index 6352bdd505..d245997822 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -1313,11 +1313,25 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa } linkArgs = append(linkArgs, cSharedExportArgs(ctx, linkedOrder)...) - err = linkObjFiles(ctx, outputPath, linkInputs, linkArgs, verbose) - if err != nil { + linkOutput := outputPath + if needsWasmPostLink(ctx.buildConf, &ctx.crossCompile) { + tmp, err := os.CreateTemp(filepath.Dir(outputPath), "."+filepath.Base(outputPath)+".linked-*") + if err != nil { + return err + } + linkOutput = tmp.Name() + if err := tmp.Close(); err != nil { + os.Remove(linkOutput) + return err + } + defer os.Remove(linkOutput) + } + if err := linkObjFiles(ctx, linkOutput, linkInputs, linkArgs, verbose); err != nil { return err } - + if linkOutput != outputPath { + return postLinkWasm(ctx, linkOutput, outputPath, verbose) + } return nil } @@ -2305,7 +2319,7 @@ func llvmPassPipeline(level optlevel.Level, ltoMode lto.Mode) string { } func IsWasiThreadsEnabled() bool { - return isEnvOn(llgoWasiThreads, true) + return isEnvOn(llgoWasiThreads, false) } func IsFullRpathEnabled() bool { diff --git a/internal/build/main_module.go b/internal/build/main_module.go index fa9cdb7afc..ce51f68dc4 100644 --- a/internal/build/main_module.go +++ b/internal/build/main_module.go @@ -121,10 +121,16 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g return mainAPkg } + var wasmRunMain llssa.Function + if ctx.crossCompile.WasmPostLink.Asyncify { + defineWasmMainTask(mainPkg, mainInit, mainMain) + wasmRunMain = declareNoArgFunc(mainPkg, rtPkgPath+".RunWasmMain") + } entryFn := defineEntryFunction(ctx, mainPkg, argcVar, argvVar, argvValueType, entryFunctions{ runtimeStub: runtimeStub, mainInit: mainInit, mainMain: mainMain, + wasmRunMain: wasmRunMain, pyInit: pyInit, pyFinalize: pyFinalize, rtInit: rtInit, @@ -219,6 +225,7 @@ type entryFunctions struct { runtimeStub llssa.Function mainInit llssa.Function mainMain llssa.Function + wasmRunMain llssa.Function pyInit llssa.Function pyFinalize llssa.Function rtInit llssa.Function @@ -261,8 +268,12 @@ func defineEntryFunction(ctx *context, pkg llssa.Package, argcVar, argvVar llssa b.Call(fns.abiInit.Expr) } b.Call(fns.runtimeStub.Expr) - b.Call(fns.mainInit.Expr) - b.Call(fns.mainMain.Expr) + if fns.wasmRunMain != nil { + b.Call(fns.wasmRunMain.Expr) + } else { + b.Call(fns.mainInit.Expr) + b.Call(fns.mainMain.Expr) + } if fns.pyFinalize != nil { b.Call(fns.pyFinalize.Expr) } @@ -270,6 +281,21 @@ func defineEntryFunction(ctx *context, pkg llssa.Package, argcVar, argvVar llssa return fn } +func defineWasmMainTask(pkg llssa.Package, mainInit, mainMain llssa.Function) { + prog := pkg.Prog + sig := newSignature( + []types.Type{types.Typ[types.UnsafePointer]}, + []types.Type{types.Typ[types.UnsafePointer]}, + ) + fn := pkg.NewFunc("__llgo_wasm_main", sig, llssa.InC) + fnVal := pkg.Module().NamedFunction("__llgo_wasm_main") + fnVal.SetVisibility(llvm.HiddenVisibility) + b := fn.MakeBody(1) + b.Call(mainInit.Expr) + b.Call(mainMain.Expr) + b.Return(prog.Nil(prog.VoidPtr())) +} + func defineStart(pkg llssa.Package, entry llssa.Function, argvType llssa.Type) { fn := pkg.NewFunc("_start", llssa.NoArgsNoRet, llssa.InC) pkg.Module().NamedFunction("_start").SetLinkage(llvm.WeakAnyLinkage) diff --git a/internal/build/wasm_postlink.go b/internal/build/wasm_postlink.go new file mode 100644 index 0000000000..99c9208c6f --- /dev/null +++ b/internal/build/wasm_postlink.go @@ -0,0 +1,90 @@ +//go:build !llgo + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package build + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/goplus/llgo/internal/crosscompile" +) + +func needsWasmPostLink(conf *Config, target *crosscompile.Export) bool { + return conf != nil && conf.BuildMode == BuildModeExe && + target != nil && target.WasmPostLink.Asyncify +} + +func wasmPostLinkArgs(target *crosscompile.Export, input, output string, debug bool) []string { + if target == nil || !target.WasmPostLink.Asyncify { + return nil + } + // LLVM 19 lowers Wasm SjLj through the legacy EH encoding. Asyncify + // understands that form; translate it only after instrumentation so the + // final module uses the standardized exnref-based EH instructions. + args := []string{"--asyncify", "--translate-to-exnref"} + if debug { + args = append(args, "-g") + } + return append(args, input, "-o", output) +} + +func postLinkWasm(ctx *context, input, output string, verbose bool) error { + wasmOpt := os.Getenv("WASMOPT") + if wasmOpt == "" { + wasmOpt = "wasm-opt" + } + resolved, err := exec.LookPath(wasmOpt) + if err != nil { + return fmt.Errorf("WebAssembly Asyncify requires wasm-opt; install Binaryen or set WASMOPT: %w", err) + } + + outDir := filepath.Dir(output) + tmp, err := os.CreateTemp(outDir, "."+filepath.Base(output)+".wasm-opt-*") + if err != nil { + return err + } + tmpName := tmp.Name() + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + defer os.Remove(tmpName) + + args := wasmPostLinkArgs( + &ctx.crossCompile, + input, + tmpName, + shouldEmitDebugInfo(ctx.buildConf, &ctx.crossCompile), + ) + if ctx.shouldPrintCommands(verbose) { + fmt.Fprintln(os.Stderr, resolved, args) + } + cmd := exec.Command(resolved, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("wasm-opt Asyncify failed: %w", err) + } + if err := os.Rename(tmpName, output); err != nil { + return err + } + return nil +} diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index fae2ff8102..74c20e5229 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -42,11 +42,18 @@ type Export struct { FormatDetail string // For uf2, it's uf2FamilyID Emulator string // Emulator command template (e.g., "qemu-system-arm -M {} -kernel {}") DebugInfo DebugInfoPolicy + WasmPostLink WasmPostLink // Flashing/Debugging configuration Device flash.Device // Device configuration for flashing/debugging } +// WasmPostLink describes transformations required after the core module is +// linked. Build orchestration owns tool discovery and atomic output handling. +type WasmPostLink struct { + Asyncify bool +} + // DebugInfoPolicy describes how a selected linker handles debug information. // Build orchestration consumes this typed capability instead of inferring it // from a target name or linker executable. @@ -369,6 +376,9 @@ func useWithJSWasm32(goos, goarch string, wasiThreads, forceEspClang bool, level "-matomics", "-mbulk-memory", } + if wasiThreads { + export.CCFLAGS = append(export.CCFLAGS, "-pthread") + } export.CFLAGS = []string{ "-I" + includeDir, "-Qunused-arguments", @@ -376,12 +386,20 @@ func useWithJSWasm32(goos, goarch string, wasiThreads, forceEspClang bool, level } // Add WebAssembly linker flags export.LDFLAGS = append(export.LDFLAGS, export.CCFLAGS...) + export.LDFLAGS = append(export.LDFLAGS, "-fwasm-exceptions") + if ltoMode.Enabled() { + export.LDFLAGS = append(export.LDFLAGS, "-Wl,--mllvm=-wasm-enable-sjlj") + } + export.CCFLAGS = append( + export.CCFLAGS, + "-fwasm-exceptions", + "-mllvm", "-wasm-enable-sjlj", + ) export.LDFLAGS = append(export.LDFLAGS, []string{ "-Wno-override-module", "-Wl,--error-limit=0", "-L" + libDir, "-Wl,--allow-undefined", - "-Wl,--import-memory,", // unknown import: `env::memory` has not been defined "-Wl,--export-memory", "-Wl,--initial-memory=67108864", // 64MB "-mbulk-memory", @@ -398,22 +416,19 @@ func useWithJSWasm32(goos, goarch string, wasiThreads, forceEspClang bool, level "-lwasi-emulated-getpid", "-lwasi-emulated-process-clocks", "-lwasi-emulated-signal", - "-fwasm-exceptions", - "-mllvm", "-wasm-enable-sjlj", }...) export.LLVMTarget = "wasm32-unknown-wasip1" // Add thread support if enabled if wasiThreads { - export.CCFLAGS = append( - export.CCFLAGS, - "-pthread", - ) - export.LDFLAGS = append(export.LDFLAGS, export.CCFLAGS...) + export.BuildTags = append(export.BuildTags, "llgo.wasi_threads") export.LDFLAGS = append( export.LDFLAGS, + "-Wl,--import-memory", "-lwasi-emulated-pthread", "-lpthread", ) + } else { + export.WasmPostLink.Asyncify = true } case "js": diff --git a/runtime/internal/runtime/g_pthread.go b/runtime/internal/runtime/g_pthread.go index abf8b127b7..9023bc50bb 100644 --- a/runtime/internal/runtime/g_pthread.go +++ b/runtime/internal/runtime/g_pthread.go @@ -1,4 +1,4 @@ -//go:build llgo && !baremetal && (!js || !wasm) +//go:build llgo && !baremetal && (!wasm || (wasip1 && llgo.wasi_threads)) /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/g_wasm.go b/runtime/internal/runtime/g_wasm.go index 9ec5a4f221..78746da0e5 100644 --- a/runtime/internal/runtime/g_wasm.go +++ b/runtime/internal/runtime/g_wasm.go @@ -1,4 +1,4 @@ -//go:build llgo && js && wasm +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/os_pthread.go b/runtime/internal/runtime/os_pthread.go index 81500f272f..cedcc58d2c 100644 --- a/runtime/internal/runtime/os_pthread.go +++ b/runtime/internal/runtime/os_pthread.go @@ -1,4 +1,4 @@ -//go:build !llgo || !js || !wasm +//go:build !llgo || !wasm || (wasip1 && llgo.wasi_threads) /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/os_wasm.go b/runtime/internal/runtime/os_wasm.go index 956031b417..02af93043b 100644 --- a/runtime/internal/runtime/os_wasm.go +++ b/runtime/internal/runtime/os_wasm.go @@ -1,4 +1,4 @@ -//go:build llgo && js && wasm +//go:build llgo && wasm && !(wasip1 && llgo.wasi_threads) /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/proc_pthread.go b/runtime/internal/runtime/proc_pthread.go index f3f96fccf7..bebc2f7740 100644 --- a/runtime/internal/runtime/proc_pthread.go +++ b/runtime/internal/runtime/proc_pthread.go @@ -1,4 +1,4 @@ -//go:build !llgo || !js || !wasm +//go:build !llgo || !wasm || (wasip1 && llgo.wasi_threads) /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/proc_wasip1.go b/runtime/internal/runtime/proc_wasip1.go new file mode 100644 index 0000000000..513caab15e --- /dev/null +++ b/runtime/internal/runtime/proc_wasip1.go @@ -0,0 +1,272 @@ +//go:build llgo && wasip1 && wasm && !llgo.wasi_threads + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/runqueue" + "github.com/goplus/llgo/runtime/internal/wasmcontext" +) + +const ( + defaultWasmGStackSize = 64 << 10 + defaultWasmAsyncifyStackSize = 64 << 10 +) + +type runtimeContextPlatform struct { + context wasmcontext.Context + stack unsafe.Pointer + asyncifyStack unsafe.Pointer +} + +var wasmSched struct { + m m + p p + runq runqueue.Queue[*g] + started bool + mainExited bool +} + +func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { + gp := initG(ctx, callergp, status) + if status == _Grunning { + initWasmScheduler(gp) + } + return gp +} + +func initWasmScheduler(gp *g) { + if wasmSched.started { + fatal("runtime: WebAssembly scheduler initialized twice") + return + } + wasmSched.started = true + mp := &wasmSched.m + pp := &wasmSched.p + mp.curg = gp + mp.p = pp + mp.id = nextMid(mp) + pp.id = nextPid(pp) + setpstatus(pp, _Prunning) + pp.m = mp + gp.m = mp +} + +//go:linkname wasmMainTask __llgo_wasm_main +func wasmMainTask(unsafe.Pointer) unsafe.Pointer + +// RunWasmMain runs package initialization and main.main as the first +// Asyncify task. It remains on the system stack and dispatches one G at a time. +func RunWasmMain() { + gp := getg() + if gp == nil || !gp.isMain { + fatal("runtime: invalid WebAssembly main goroutine") + return + } + initWasmContext(gp, wasmcontext.Entry(wasmMainTask), nil, 0) + + for { + runWasmContext(gp) + status := readgstatus(gp) + if gp.isMain && status == _Grunning { + casgstatus(gp, _Grunning, _Gdead) + releaseWasmContext(gp) + return + } + releaseWasmOwnership(gp) + if status == _Gdead { + releaseWasmContext(gp) + } + + gp = wasmSched.runq.Pop() + if gp == nil { + if wasmSched.mainExited { + fatal("no goroutines (main called runtime.Goexit) - deadlock!") + } else { + fatal("all goroutines are asleep - deadlock!") + } + return + } + } +} + +func runWasmContext(gp *g) { + if readgstatus(gp) == _Grunnable { + casgstatus(gp, _Grunnable, _Grunning) + } + mp := &wasmSched.m + pp := &wasmSched.p + mp.curg = gp + pp.m = mp + gp.m = mp + setg(gp) + gp.context.platform.context.Resume() +} + +func releaseWasmOwnership(gp *g) { + if gp != nil { + gp.m = nil + } + wasmSched.m.curg = nil +} + +func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, callergp *g) { + gp := newproc1(fn, arg, callergp) + initWasmContext(gp, wasmcontext.Entry(wasmGStart), unsafe.Pointer(gp), stackSize) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func initWasmContext(gp *g, entry wasmcontext.Entry, arg unsafe.Pointer, stackSize uintptr) { + if stackSize == 0 { + stackSize = defaultWasmGStackSize + } + stackSize = alignWasmStackSize(stackSize) + asyncifySize := uintptr(defaultWasmAsyncifyStackSize) + if stackSize > asyncifySize { + asyncifySize = stackSize + } + + platform := &gp.context.platform + platform.stack = allocWasmStack(stackSize) + platform.asyncifyStack = allocWasmStack(asyncifySize) + platform.context.Init( + entry, + arg, + platform.stack, + stackSize, + platform.asyncifyStack, + asyncifySize, + ) +} + +func alignWasmStackSize(size uintptr) uintptr { + const alignment = uintptr(16) + return (size + alignment - 1) &^ (alignment - 1) +} + +func allocWasmStack(size uintptr) unsafe.Pointer { + stack := AllocRoot(size) + if stack == nil { + panic("runtime: failed to allocate WebAssembly goroutine stack") + } + return stack +} + +func releaseWasmContext(gp *g) { + if gp == nil || gp.context == nil { + return + } + ctx := gp.context + platform := &ctx.platform + if platform.stack != nil { + FreeRoot(platform.stack) + platform.stack = nil + } + if platform.asyncifyStack != nil { + FreeRoot(platform.asyncifyStack) + platform.asyncifyStack = nil + } + freeRuntimeContext(ctx) +} + +func wasmGStart(arg unsafe.Pointer) unsafe.Pointer { + gp := (*g)(arg) + if gp == nil || getg() != gp { + fatal("runtime: invalid WebAssembly goroutine entry") + return nil + } + fn, fnarg := gp.startfn, gp.startarg + gp.startfn = nil + gp.startarg = nil + ret := fn(fnarg) + goexitBackend(gp) + return ret +} + +func goschedBackend() { + gp := getg() + casgstatus(gp, _Grunning, _Grunnable) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + return + } + gp.context.platform.context.Suspend() +} + +func gopark() { + gp := getg() + casgstatus(gp, _Grunning, _Gwaiting) + gp.context.platform.context.Suspend() +} + +func goready(gp *g) { + if gp == nil { + fatal("runtime: ready of nil goroutine") + return + } + casgstatus(gp, _Gwaiting, _Grunnable) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func goexitBackend(gp *g) { + casgstatus(gp, _Grunning, _Gdead) + if gp.isMain { + wasmSched.mainExited = true + } + gp.context.platform.context.Suspend() + fatal("runtime: resumed dead WebAssembly goroutine") +} + +// CurrentGForTesting returns an opaque handle suitable for ReadyForTesting. +func CurrentGForTesting() unsafe.Pointer { + return unsafe.Pointer(getg()) +} + +// ParkForTesting parks the current G until another G marks it runnable. +func ParkForTesting() { + gopark() +} + +// ReadyForTesting makes a G previously parked by ParkForTesting runnable. +func ReadyForTesting(handle unsafe.Pointer) { + goready((*g)(handle)) +} + +// SchedulerStateForTesting reports single-worker queue and ownership state. +func SchedulerStateForTesting() (runq uintptr, mid int64, pid int32) { + return wasmSched.runq.Len(), wasmSched.m.id, wasmSched.p.id +} + +func GMPForTesting() (goid, parentGoid uint64, mid int64, pid int32, gstatus, pstatus uint32, linked bool) { + gp := getg() + if gp == nil || gp.m == nil || gp.m.p == nil { + return + } + mp := gp.m + pp := mp.p + ctx := gp.context + return gp.goid, gp.parentGoid, mp.id, pp.id, readgstatus(gp), readpstatus(pp), + mp == &wasmSched.m && pp == &wasmSched.p && + mp.curg == gp && pp.m == mp && ctx != nil && &ctx.g == gp +} diff --git a/runtime/internal/runtime/proc_wasm.go b/runtime/internal/runtime/proc_wasm.go index 18a91a5e38..fc247d743d 100644 --- a/runtime/internal/runtime/proc_wasm.go +++ b/runtime/internal/runtime/proc_wasm.go @@ -21,8 +21,8 @@ package runtime import ( "unsafe" - "github.com/goplus/llgo/runtime/internal/clite/emscripten" "github.com/goplus/llgo/runtime/internal/runqueue" + "github.com/goplus/llgo/runtime/internal/wasmcontext" ) const ( @@ -31,7 +31,7 @@ const ( ) type runtimeContextPlatform struct { - fiber emscripten.Fiber + context wasmcontext.Context stack unsafe.Pointer asyncifyStack unsafe.Pointer } @@ -90,8 +90,8 @@ func initWasmFiber(gp *g, stackSize uintptr) { platform := &gp.context.platform platform.stack = allocWasmStack(stackSize) platform.asyncifyStack = allocWasmStack(asyncifySize) - platform.fiber.Init( - emscripten.FiberEntry(wasmGStart), + platform.context.Init( + wasmcontext.Entry(wasmGStart), unsafe.Pointer(gp), platform.stack, stackSize, @@ -119,7 +119,7 @@ func ensureCurrentWasmFiber(gp *g) { return } platform.asyncifyStack = allocWasmStack(defaultWasmAsyncifyStackSize) - platform.fiber.InitCurrent(platform.asyncifyStack, defaultWasmAsyncifyStackSize) + platform.context.InitCurrent(platform.asyncifyStack, defaultWasmAsyncifyStackSize) } func wasmGStart(arg unsafe.Pointer) { @@ -190,7 +190,7 @@ func resumeWasmG(old, next *g) { next.m = mp mp.curg = next setg(next) - old.context.platform.fiber.Swap(&next.context.platform.fiber) + old.context.platform.context.Swap(&next.context.platform.context) reapRetiredWasmG() } @@ -223,7 +223,7 @@ func resumeDeadWasmG(old, next *g) { next.m = mp mp.curg = next setg(next) - old.context.platform.fiber.Swap(&next.context.platform.fiber) + old.context.platform.context.Swap(&next.context.platform.context) fatal("runtime: resumed dead WebAssembly goroutine") } diff --git a/runtime/internal/wasmcontext/context_js.go b/runtime/internal/wasmcontext/context_js.go new file mode 100644 index 0000000000..8def06f26b --- /dev/null +++ b/runtime/internal/wasmcontext/context_js.go @@ -0,0 +1,51 @@ +//go:build llgo && js && wasm + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package wasmcontext + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/clite/emscripten" +) + +type Entry = emscripten.FiberEntry + +// Context wraps the Emscripten Fiber ABI used by JavaScript hosts. +type Context struct { + fiber emscripten.Fiber +} + +func (ctx *Context) Init(entry Entry, arg, stack unsafe.Pointer, stackSize uintptr, asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { + ctx.fiber.Init( + entry, + arg, + stack, + stackSize, + asyncifyStack, + asyncifyStackSize, + ) +} + +func (ctx *Context) InitCurrent(asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { + ctx.fiber.InitCurrent(asyncifyStack, asyncifyStackSize) +} + +func (ctx *Context) Swap(next *Context) { + ctx.fiber.Swap(&next.fiber) +} diff --git a/runtime/internal/wasmcontext/context_wasip1.go b/runtime/internal/wasmcontext/context_wasip1.go new file mode 100644 index 0000000000..4226e510bf --- /dev/null +++ b/runtime/internal/wasmcontext/context_wasip1.go @@ -0,0 +1,72 @@ +//go:build llgo && wasip1 && wasm && !llgo.wasi_threads + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package wasmcontext + +import ( + "unsafe" + + c "github.com/goplus/llgo/runtime/internal/clite" +) + +//llgo:type C +type Entry func(unsafe.Pointer) unsafe.Pointer + +// Context is the state consumed by Binaryen Asyncify. The first five fields +// have fixed wasm32 offsets shared with context_wasm.S. +type Context struct { + entry unsafe.Pointer + arg unsafe.Pointer + asyncifyStack unsafe.Pointer + asyncifyEnd unsafe.Pointer + stackPointer unsafe.Pointer + launched bool +} + +func (ctx *Context) Init(entry Entry, arg, stack unsafe.Pointer, stackSize uintptr, asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { + ctx.entry = c.Func(entry) + ctx.arg = arg + ctx.asyncifyStack = asyncifyStack + ctx.asyncifyEnd = unsafe.Add(asyncifyStack, asyncifyStackSize) + ctx.stackPointer = unsafe.Add(stack, stackSize) + ctx.launched = false +} + +func (ctx *Context) Resume() { + if !ctx.launched { + contextLaunch(ctx) + ctx.launched = true + return + } + contextRewind(ctx) +} + +func (ctx *Context) Suspend() { + contextUnwind(ctx) +} + +//go:linkname contextLaunch C.__llgo_wasm_context_launch +func contextLaunch(*Context) + +//go:linkname contextRewind C.__llgo_wasm_context_rewind +func contextRewind(*Context) + +//go:linkname contextUnwind C.__llgo_wasm_context_unwind +func contextUnwind(*Context) + +const LLGoFiles = "context_wasm.S" diff --git a/runtime/internal/wasmcontext/context_wasm.S b/runtime/internal/wasmcontext/context_wasm.S new file mode 100644 index 0000000000..bc3b33034c --- /dev/null +++ b/runtime/internal/wasmcontext/context_wasm.S @@ -0,0 +1,121 @@ +// Copyright (c) 2018-2026 The TinyGo Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// * Neither the name of the copyright holder nor the names of its contributors +// may be used to endorse or promote products derived from this software +// without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +// +// This file was adapted for LLGo's wasmcontext ABI and wasm32 WASI scheduler. + +.globaltype __stack_pointer, i32 + +.functype start_unwind (i32) -> () +.import_module start_unwind, asyncify +.import_name start_unwind, start_unwind +.functype stop_unwind () -> () +.import_module stop_unwind, asyncify +.import_name stop_unwind, stop_unwind +.functype start_rewind (i32) -> () +.import_module start_rewind, asyncify +.import_name start_rewind, start_rewind +.functype stop_rewind () -> () +.import_module stop_rewind, asyncify +.import_name stop_rewind, stop_rewind + +.global __llgo_wasm_context_unwind +.hidden __llgo_wasm_context_unwind +.type __llgo_wasm_context_unwind,@function +__llgo_wasm_context_unwind: + .functype __llgo_wasm_context_unwind (i32) -> () + i32.const 0 + i32.load8_u __llgo_wasm_context_rewinding + if + call stop_rewind + i32.const 0 + i32.const 0 + i32.store8 __llgo_wasm_context_rewinding + else + local.get 0 + global.get __stack_pointer + i32.store 16 + local.get 0 + i32.const 8 + i32.add + call start_unwind + end_if + return + end_function + +.global __llgo_wasm_context_launch +.hidden __llgo_wasm_context_launch +.type __llgo_wasm_context_launch,@function +__llgo_wasm_context_launch: + .functype __llgo_wasm_context_launch (i32) -> () + global.get __stack_pointer + local.get 0 + i32.load 16 + global.set __stack_pointer + local.get 0 + i32.load 4 + local.get 0 + i32.load 0 + call_indirect (i32) -> (i32) + drop + call stop_unwind + global.set __stack_pointer + return + end_function + +.global __llgo_wasm_context_rewind +.hidden __llgo_wasm_context_rewind +.type __llgo_wasm_context_rewind,@function +__llgo_wasm_context_rewind: + .functype __llgo_wasm_context_rewind (i32) -> () + global.get __stack_pointer + local.get 0 + i32.load 16 + global.set __stack_pointer + local.get 0 + i32.load 4 + local.get 0 + i32.load 0 + i32.const 0 + i32.const 1 + i32.store8 __llgo_wasm_context_rewinding + local.get 0 + i32.const 8 + i32.add + call start_rewind + call_indirect (i32) -> (i32) + drop + call stop_unwind + global.set __stack_pointer + return + end_function + +.hidden __llgo_wasm_context_rewinding +.type __llgo_wasm_context_rewinding,@object +.section .bss.__llgo_wasm_context_rewinding,"",@ +.globl __llgo_wasm_context_rewinding +__llgo_wasm_context_rewinding: + .int8 0 + .size __llgo_wasm_context_rewinding, 1 diff --git a/runtime/internal/wasmcontext/doc.go b/runtime/internal/wasmcontext/doc.go new file mode 100644 index 0000000000..688f6da9b7 --- /dev/null +++ b/runtime/internal/wasmcontext/doc.go @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package wasmcontext provides suspended execution contexts for WebAssembly +// runtime schedulers. +package wasmcontext From dfe6bcb9d2d921e99136e93fcfd45c351bd31318 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 02:33:43 +0800 Subject: [PATCH 07/20] test(runtime): exercise WASI Asyncify scheduler --- .github/actions/setup-binaryen/action.yml | 25 +++ .github/workflows/llgo.yml | 39 ++++- internal/build/build_test.go | 11 ++ internal/build/main_module_test.go | 41 +++++ .../build/testdata/wasm-scheduler/main.go | 15 ++ .../build/testdata/wasm-scheduler/model_go.go | 14 +- internal/build/wasm_postlink_test.go | 145 ++++++++++++++++++ internal/crosscompile/crosscompile_test.go | 32 ++++ 8 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 .github/actions/setup-binaryen/action.yml create mode 100644 internal/build/wasm_postlink_test.go diff --git a/.github/actions/setup-binaryen/action.yml b/.github/actions/setup-binaryen/action.yml new file mode 100644 index 0000000000..c5c8c41163 --- /dev/null +++ b/.github/actions/setup-binaryen/action.yml @@ -0,0 +1,25 @@ +name: "Setup Binaryen" +description: "Install a pinned Binaryen release" +inputs: + version: + description: "Binaryen release version" + required: false + default: "131" + +runs: + using: "composite" + steps: + - name: Install Binaryen + shell: bash + run: | + set -euo pipefail + + version="${{ inputs.version }}" + archive="binaryen-version_${version}-x86_64-linux.tar.gz" + base_url="https://github.com/WebAssembly/binaryen/releases/download/version_${version}" + cd "$RUNNER_TEMP" + curl --retry 3 --retry-all-errors -fsSLO "${base_url}/${archive}" + curl --retry 3 --retry-all-errors -fsSLO "${base_url}/${archive}.sha256" + sha256sum --check "${archive}.sha256" + tar -xzf "$archive" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/binaryen-version_${version}/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index c00ac9bee8..5bd5e1ebdc 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -366,6 +366,9 @@ jobs: - name: Set up Go for building llgo uses: ./.github/actions/setup-go + - name: Set up Binaryen + uses: ./.github/actions/setup-binaryen + - name: Install wamr run: | git clone --branch WAMR-2.4.4 --depth 1 https://github.com/bytecodealliance/wasm-micro-runtime.git @@ -419,6 +422,19 @@ jobs: with: node-version: "25" + - name: Set up Binaryen + uses: ./.github/actions/setup-binaryen + + - name: Set up Wasmtime + uses: bytecodealliance/actions/wasmtime/setup@v1 + with: + version: "39.0.1" + + - name: Set up wasm-tools + uses: bytecodealliance/actions/wasm-tools/setup@v1 + with: + version: "1.243.0" + - name: Set up Go for building llgo uses: ./.github/actions/setup-go @@ -446,10 +462,31 @@ jobs: grep -Fq "fatal error: all goroutines are asleep - deadlock!" <<<"$output" } + run_wasi_scheduler() { + local module="$1" + local output + wasm-tools validate --features all "$module" + output=$(wasmtime run -W exceptions=y "$module" 2>&1) + grep -Fq "wasm scheduler ok" <<<"$output" + if output=$(wasmtime run -W exceptions=y \ + --env LLGO_WASM_SCHEDULER_DEADLOCK=1 "$module" 2>&1); then + echo "deadlock scheduler fixture unexpectedly succeeded" + return 1 + fi + grep -Fq "fatal error: all goroutines are asleep - deadlock!" <<<"$output" + } + GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-js.wasm" ./internal/build/testdata/wasm-runtime GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/runtime-wasip1.wasm" ./internal/build/testdata/wasm-runtime + LLGO_WASI_THREADS=1 GOOS=wasip1 GOARCH=wasm llgo build \ + -o "$RUNNER_TEMP/runtime-wasip1-threads.wasm" ./internal/build/testdata/wasm-runtime + test "$(wasmtime run -W exceptions=y "$RUNNER_TEMP/runtime-wasip1.wasm" 2>&1)" = "wasip1" GOOS=js GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-scheduler-go.mjs" ./internal/build/testdata/wasm-scheduler run_wasm_scheduler "$RUNNER_TEMP/wasm-scheduler-go.mjs" llgo build -target wasm -o "$RUNNER_TEMP/wasm-scheduler.mjs" ./internal/build/testdata/wasm-scheduler run_wasm_scheduler "$RUNNER_TEMP/wasm-scheduler.mjs" - file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" + GOOS=wasip1 GOARCH=wasm llgo build -o "$RUNNER_TEMP/wasm-scheduler-wasip1.wasm" ./internal/build/testdata/wasm-scheduler + run_wasi_scheduler "$RUNNER_TEMP/wasm-scheduler-wasip1.wasm" + file "$RUNNER_TEMP/runtime-js.wasm" \ + "$RUNNER_TEMP/runtime-wasip1.wasm" \ + "$RUNNER_TEMP/runtime-wasip1-threads.wasm" diff --git a/internal/build/build_test.go b/internal/build/build_test.go index b7c540fad5..297ea2b746 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -806,6 +806,17 @@ func TestApplyBuildModeCompileFlags(t *testing.T) { applyBuildModeCompileFlags(BuildModeCShared, nil) } +func TestWASIThreadsAreOptIn(t *testing.T) { + t.Setenv(llgoWasiThreads, "") + if IsWasiThreadsEnabled() { + t.Fatal("WASI threads are enabled by default") + } + t.Setenv(llgoWasiThreads, "1") + if !IsWasiThreadsEnabled() { + t.Fatal("WASI threads opt-in was ignored") + } +} + func TestCHeaderPackagesExcludesStandardRuntime(t *testing.T) { prog := llssa.NewProgram(nil) defer prog.Dispose() diff --git a/internal/build/main_module_test.go b/internal/build/main_module_test.go index 7793d5fbfa..9cb4c3f56f 100644 --- a/internal/build/main_module_test.go +++ b/internal/build/main_module_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/goplus/llgo/internal/crosscompile" "github.com/xgo-dev/llvm" "github.com/goplus/llgo/internal/packages" @@ -55,6 +56,46 @@ func TestGenMainModuleExecutable(t *testing.T) { ) } +func TestGenMainModuleWASIAsyncifyEntry(t *testing.T) { + llvm.InitializeAllTargets() + t.Setenv(llgoStdioNobuf, "") + ctx := &context{ + prog: llssa.NewProgram(nil), + buildConf: &Config{ + BuildMode: BuildModeExe, + Goos: "wasip1", + Goarch: "wasm", + }, + crossCompile: crosscompile.Export{ + WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}, + }, + } + pkg := &packages.Package{PkgPath: "example.com/foo", ExportFile: "foo.a"} + mod := genMainModule(ctx, llssa.PkgRuntime, pkg, &genConfig{rtInit: true}) + ir := mod.LPkg.String() + checks := []string{ + `define hidden ptr @__llgo_wasm_main(ptr %0)`, + `call void @"example.com/foo.init"()`, + `call void @"example.com/foo.main"()`, + `call void @"github.com/goplus/llgo/runtime/internal/runtime.RunWasmMain"()`, + } + for _, want := range checks { + if !strings.Contains(ir, want) { + t.Fatalf("WASI main module IR missing %q:\n%s", want, ir) + } + } + entryStart := strings.Index(ir, "define hidden i32 @__main_argc_argv(") + if entryStart < 0 { + t.Fatalf("WASI main module missing host entry:\n%s", ir) + } + entry := ir[entryStart:] + entry = entry[:strings.Index(entry, "}\n")+2] + if strings.Contains(entry, `call void @"example.com/foo.init"()`) || + strings.Contains(entry, `call void @"example.com/foo.main"()`) { + t.Fatalf("WASI system-stack entry calls package main directly:\n%s", entry) + } +} + func TestGenMainModuleLibrary(t *testing.T) { llvm.InitializeAllTargets() t.Setenv(llgoStdioNobuf, "") diff --git a/internal/build/testdata/wasm-scheduler/main.go b/internal/build/testdata/wasm-scheduler/main.go index 12fd8c81ae..399a5b39c5 100644 --- a/internal/build/testdata/wasm-scheduler/main.go +++ b/internal/build/testdata/wasm-scheduler/main.go @@ -30,6 +30,7 @@ var ( eventLog [8]int eventCount int done int + lifecycle int ) func event(value int) { @@ -134,9 +135,23 @@ func main() { if seenGCount != len(seenG) { panic("not all goroutines ran") } + testGoroutineLifecycle() println("wasm scheduler ok") } +func testGoroutineLifecycle() { + const count = 5000 + for i := 1; i <= count; i++ { + want := i + go func() { + lifecycle = want + }() + for lifecycle != want { + runtime.Gosched() + } + } +} + func testParkedMainDeadlock() { go func() {}() parkForTesting() diff --git a/internal/build/testdata/wasm-scheduler/model_go.go b/internal/build/testdata/wasm-scheduler/model_go.go index 73781131c4..9cde6cc3f5 100644 --- a/internal/build/testdata/wasm-scheduler/model_go.go +++ b/internal/build/testdata/wasm-scheduler/model_go.go @@ -2,9 +2,21 @@ package main -import "unsafe" +import ( + "runtime" + "unsafe" +) func checkWasmModel() { + if runtime.GOOS == "wasip1" { + if unsafe.Sizeof(uintptr(0)) != 4 { + panic("GOOS=wasip1 GOARCH=wasm must use 32-bit words") + } + if cLongSize() != 4 { + panic("GOOS=wasip1 GOARCH=wasm must use the wasm32 C data model") + } + return + } if unsafe.Sizeof(uintptr(0)) != 8 { panic("GOOS/GOARCH wasm must use 64-bit words") } diff --git a/internal/build/wasm_postlink_test.go b/internal/build/wasm_postlink_test.go new file mode 100644 index 0000000000..67e037836d --- /dev/null +++ b/internal/build/wasm_postlink_test.go @@ -0,0 +1,145 @@ +//go:build !llgo + +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package build + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/goplus/llgo/internal/crosscompile" +) + +func TestWasmPostLinkArgs(t *testing.T) { + target := &crosscompile.Export{WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}} + if got, want := wasmPostLinkArgs(target, "in.wasm", "out.wasm", false), + []string{"--asyncify", "--translate-to-exnref", "in.wasm", "-o", "out.wasm"}; !reflect.DeepEqual(got, want) { + t.Fatalf("wasmPostLinkArgs() = %v, want %v", got, want) + } + if got, want := wasmPostLinkArgs(target, "in.wasm", "out.wasm", true), + []string{"--asyncify", "--translate-to-exnref", "-g", "in.wasm", "-o", "out.wasm"}; !reflect.DeepEqual(got, want) { + t.Fatalf("wasmPostLinkArgs(debug) = %v, want %v", got, want) + } + if got := wasmPostLinkArgs(&crosscompile.Export{}, "in", "out", false); got != nil { + t.Fatalf("wasmPostLinkArgs(disabled) = %v, want nil", got) + } +} + +func TestNeedsWasmPostLink(t *testing.T) { + target := &crosscompile.Export{WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}} + tests := []struct { + name string + conf *Config + want bool + }{ + {name: "executable", conf: &Config{BuildMode: BuildModeExe}, want: true}, + {name: "archive", conf: &Config{BuildMode: BuildModeCArchive}}, + {name: "shared", conf: &Config{BuildMode: BuildModeCShared}}, + {name: "nil config"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := needsWasmPostLink(test.conf, target); got != test.want { + t.Fatalf("needsWasmPostLink() = %v, want %v", got, test.want) + } + }) + } + if needsWasmPostLink(&Config{BuildMode: BuildModeExe}, nil) { + t.Fatal("needsWasmPostLink() enabled for a nil target") + } +} + +func TestPostLinkWasmPublishesOutput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + dir := t.TempDir() + input := filepath.Join(dir, "linked.wasm") + output := filepath.Join(dir, "app.wasm") + argsFile := filepath.Join(dir, "args") + if err := os.WriteFile(input, []byte("core module"), 0o644); err != nil { + t.Fatal(err) + } + + tool := filepath.Join(dir, "wasm-opt") + script := `#!/bin/sh +printf '%s\n' "$@" > "$ARGS_FILE" +input= +output= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + output="$2" + shift 2 + ;; + -*) + shift + ;; + *) + input="$1" + shift + ;; + esac +done +cp "$input" "$output" +` + if err := os.WriteFile(tool, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("WASMOPT", tool) + t.Setenv("ARGS_FILE", argsFile) + + ctx := &context{ + buildConf: &Config{Mode: ModeBuild, LinkOptions: LinkOptions{DWARF: DWARFOmit}}, + crossCompile: crosscompile.Export{ + WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}, + }, + } + if err := postLinkWasm(ctx, input, output, false); err != nil { + t.Fatal(err) + } + if data, err := os.ReadFile(output); err != nil || string(data) != "core module" { + t.Fatalf("published output = %q, %v", data, err) + } + args, err := os.ReadFile(argsFile) + if err != nil { + t.Fatal(err) + } + if got := string(args); !strings.Contains(got, "--asyncify\n--translate-to-exnref\n") || + !strings.Contains(got, input+"\n-o\n") { + t.Fatalf("wasm-opt args = %q", got) + } +} + +func TestPostLinkWasmReportsMissingTool(t *testing.T) { + t.Setenv("WASMOPT", filepath.Join(t.TempDir(), "missing-wasm-opt")) + ctx := &context{ + buildConf: &Config{}, + crossCompile: crosscompile.Export{ + WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}, + }, + } + err := postLinkWasm(ctx, "input", filepath.Join(t.TempDir(), "output"), false) + if err == nil || !strings.Contains(err.Error(), "install Binaryen or set WASMOPT") { + t.Fatalf("postLinkWasm() error = %v", err) + } +} diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index 66be579201..0b3d7385f7 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -125,6 +125,16 @@ func TestUseCrossCompileSDK(t *testing.T) { if !hasResourceDir { t.Error("Missing -resource-dir flag in CCFLAGS") } + if !slices.Contains(export.CCFLAGS, "-fwasm-exceptions") || + !hasMllvmOption(export.CCFLAGS, "-wasm-enable-sjlj") { + t.Errorf("CCFLAGS do not enable WebAssembly SjLj lowering: %v", export.CCFLAGS) + } + if !export.WasmPostLink.Asyncify { + t.Error("WASI target does not request Asyncify post-link processing") + } + if slices.Contains(export.LDFLAGS, "-Wl,--import-memory") { + t.Errorf("single-worker WASI imports host memory: %v", export.LDFLAGS) + } } else if tc.name == "Same Platform" { // For same platform, we expect sysroot only on macOS if runtime.GOOS == "darwin" && !hasSysroot { @@ -173,6 +183,28 @@ func TestUseCrossCompileSDK(t *testing.T) { } } +func TestUseWASIThreadsImportsMemory(t *testing.T) { + if testing.Short() { + t.Skip("requires WASI SDK") + } + export, err := use("wasip1", "wasm", true, false, optlevel.O2, lto.Off, false) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(export.CCFLAGS, "-pthread") { + t.Fatalf("CCFLAGS do not enable WASI threads: %v", export.CCFLAGS) + } + if !slices.Contains(export.BuildTags, "llgo.wasi_threads") { + t.Fatalf("BuildTags do not select the WASI pthread backend: %v", export.BuildTags) + } + if !slices.Contains(export.LDFLAGS, "-Wl,--import-memory") { + t.Fatalf("LDFLAGS do not import shared host memory: %v", export.LDFLAGS) + } + if export.WasmPostLink.Asyncify { + t.Fatal("WASI pthread mode requests single-worker Asyncify processing") + } +} + func TestUseJSSupportsNode(t *testing.T) { export, err := use("js", "wasm", false, false, optlevel.Oz, lto.Off, false) if err != nil { From 2cdb3cb32c304d6858e2dfc13a1f0db3c3afac98 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 02:40:38 +0800 Subject: [PATCH 08/20] fix(runtime/wasm): initialize scheduler for minimal P1 mains --- internal/build/main_module.go | 2 +- internal/build/main_module_test.go | 3 ++- runtime/internal/wasmcontext/{ => _asm}/context_wasm.S | 0 runtime/internal/wasmcontext/context_wasip1.go | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) rename runtime/internal/wasmcontext/{ => _asm}/context_wasm.S (100%) diff --git a/internal/build/main_module.go b/internal/build/main_module.go index ce51f68dc4..fb6fe558d9 100644 --- a/internal/build/main_module.go +++ b/internal/build/main_module.go @@ -89,7 +89,7 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g } var rtInit llssa.Function - if cfg.rtInit { + if cfg.rtInit || ctx.crossCompile.WasmPostLink.Asyncify { rtInit = declareNoArgFunc(mainPkg, rtPkgPath+".init") } diff --git a/internal/build/main_module_test.go b/internal/build/main_module_test.go index 9cb4c3f56f..509e340ee4 100644 --- a/internal/build/main_module_test.go +++ b/internal/build/main_module_test.go @@ -71,10 +71,11 @@ func TestGenMainModuleWASIAsyncifyEntry(t *testing.T) { }, } pkg := &packages.Package{PkgPath: "example.com/foo", ExportFile: "foo.a"} - mod := genMainModule(ctx, llssa.PkgRuntime, pkg, &genConfig{rtInit: true}) + mod := genMainModule(ctx, llssa.PkgRuntime, pkg, &genConfig{}) ir := mod.LPkg.String() checks := []string{ `define hidden ptr @__llgo_wasm_main(ptr %0)`, + `call void @"github.com/goplus/llgo/runtime/internal/runtime.init"()`, `call void @"example.com/foo.init"()`, `call void @"example.com/foo.main"()`, `call void @"github.com/goplus/llgo/runtime/internal/runtime.RunWasmMain"()`, diff --git a/runtime/internal/wasmcontext/context_wasm.S b/runtime/internal/wasmcontext/_asm/context_wasm.S similarity index 100% rename from runtime/internal/wasmcontext/context_wasm.S rename to runtime/internal/wasmcontext/_asm/context_wasm.S diff --git a/runtime/internal/wasmcontext/context_wasip1.go b/runtime/internal/wasmcontext/context_wasip1.go index 4226e510bf..63177044cc 100644 --- a/runtime/internal/wasmcontext/context_wasip1.go +++ b/runtime/internal/wasmcontext/context_wasip1.go @@ -69,4 +69,4 @@ func contextRewind(*Context) //go:linkname contextUnwind C.__llgo_wasm_context_unwind func contextUnwind(*Context) -const LLGoFiles = "context_wasm.S" +const LLGoFiles = "_asm/context_wasm.S" From 16feeaa4075780c572ca44185cc64a5346d0e29c Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 02:46:06 +0800 Subject: [PATCH 09/20] ci: install Binaryen for wasm cache tests --- .github/actions/setup-binaryen/action.yml | 19 +++++++++++++++++-- .github/workflows/build-cache.yml | 3 +++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-binaryen/action.yml b/.github/actions/setup-binaryen/action.yml index c5c8c41163..ed76713576 100644 --- a/.github/actions/setup-binaryen/action.yml +++ b/.github/actions/setup-binaryen/action.yml @@ -15,11 +15,26 @@ runs: set -euo pipefail version="${{ inputs.version }}" - archive="binaryen-version_${version}-x86_64-linux.tar.gz" + case "$(uname -s):$(uname -m)" in + Linux:x86_64) platform="x86_64-linux" ;; + Linux:aarch64|Linux:arm64) platform="aarch64-linux" ;; + Darwin:x86_64) platform="x86_64-macos" ;; + Darwin:arm64) platform="arm64-macos" ;; + *) + echo "Unsupported Binaryen host: $(uname -s) $(uname -m)" >&2 + exit 1 + ;; + esac + + archive="binaryen-version_${version}-${platform}.tar.gz" base_url="https://github.com/WebAssembly/binaryen/releases/download/version_${version}" cd "$RUNNER_TEMP" curl --retry 3 --retry-all-errors -fsSLO "${base_url}/${archive}" curl --retry 3 --retry-all-errors -fsSLO "${base_url}/${archive}.sha256" - sha256sum --check "${archive}.sha256" + if command -v sha256sum >/dev/null; then + sha256sum --check "${archive}.sha256" + else + shasum -a 256 --check "${archive}.sha256" + fi tar -xzf "$archive" -C "$RUNNER_TEMP" echo "$RUNNER_TEMP/binaryen-version_${version}/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/build-cache.yml b/.github/workflows/build-cache.yml index 5b3fb374ea..6663946239 100644 --- a/.github/workflows/build-cache.yml +++ b/.github/workflows/build-cache.yml @@ -35,6 +35,9 @@ jobs: - name: Set up Go uses: ./.github/actions/setup-go + - name: Set up Binaryen + uses: ./.github/actions/setup-binaryen + - name: Install wamr (for wasm tests) if: startsWith(matrix.os, 'macos') run: | From e3e8ea8f96fe94920df17286f0e387a575f8439e Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 04:00:08 +0800 Subject: [PATCH 10/20] runtime/wasm: keep fiber host calls out of method roots --- runtime/internal/clite/emscripten/fiber.go | 12 ++++++------ runtime/internal/clite/emscripten/fiber_test.go | 7 +++++++ runtime/internal/wasmcontext/context_js.go | 7 ++++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/runtime/internal/clite/emscripten/fiber.go b/runtime/internal/clite/emscripten/fiber.go index 00a4fe87f7..95e6ae2410 100644 --- a/runtime/internal/clite/emscripten/fiber.go +++ b/runtime/internal/clite/emscripten/fiber.go @@ -29,14 +29,14 @@ type Fiber struct { //llgo:type C type FiberEntry func(c.Pointer) -// llgo:link (*Fiber).Init C.emscripten_fiber_init -func (fiber *Fiber) Init(entry FiberEntry, arg, stack c.Pointer, stackSize uintptr, asyncifyStack c.Pointer, asyncifyStackSize uintptr) { +// llgo:link FiberInit C.emscripten_fiber_init +func FiberInit(fiber *Fiber, entry FiberEntry, arg, stack c.Pointer, stackSize uintptr, asyncifyStack c.Pointer, asyncifyStackSize uintptr) { } -// llgo:link (*Fiber).InitCurrent C.emscripten_fiber_init_from_current_context -func (fiber *Fiber) InitCurrent(asyncifyStack c.Pointer, asyncifyStackSize uintptr) { +// llgo:link FiberInitCurrent C.emscripten_fiber_init_from_current_context +func FiberInitCurrent(fiber *Fiber, asyncifyStack c.Pointer, asyncifyStackSize uintptr) { } -// llgo:link (*Fiber).Swap C.emscripten_fiber_swap -func (fiber *Fiber) Swap(next *Fiber) { +// llgo:link FiberSwap C.emscripten_fiber_swap +func FiberSwap(fiber, next *Fiber) { } diff --git a/runtime/internal/clite/emscripten/fiber_test.go b/runtime/internal/clite/emscripten/fiber_test.go index da2456dbc1..42c41eb5bc 100644 --- a/runtime/internal/clite/emscripten/fiber_test.go +++ b/runtime/internal/clite/emscripten/fiber_test.go @@ -1,6 +1,7 @@ package emscripten import ( + "reflect" "testing" "unsafe" ) @@ -10,3 +11,9 @@ func TestFiberStorageUsesEightWords(t *testing.T) { t.Fatalf("Fiber size = %d, want %d", got, want) } } + +func TestFiberHasNoReflectableHostMethods(t *testing.T) { + if got := reflect.TypeOf(Fiber{}).NumMethod(); got != 0 { + t.Fatalf("Fiber has %d reflectable methods, want 0", got) + } +} diff --git a/runtime/internal/wasmcontext/context_js.go b/runtime/internal/wasmcontext/context_js.go index 8def06f26b..4550c38320 100644 --- a/runtime/internal/wasmcontext/context_js.go +++ b/runtime/internal/wasmcontext/context_js.go @@ -32,7 +32,8 @@ type Context struct { } func (ctx *Context) Init(entry Entry, arg, stack unsafe.Pointer, stackSize uintptr, asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { - ctx.fiber.Init( + emscripten.FiberInit( + &ctx.fiber, entry, arg, stack, @@ -43,9 +44,9 @@ func (ctx *Context) Init(entry Entry, arg, stack unsafe.Pointer, stackSize uintp } func (ctx *Context) InitCurrent(asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { - ctx.fiber.InitCurrent(asyncifyStack, asyncifyStackSize) + emscripten.FiberInitCurrent(&ctx.fiber, asyncifyStack, asyncifyStackSize) } func (ctx *Context) Swap(next *Context) { - ctx.fiber.Swap(&next.fiber) + emscripten.FiberSwap(&ctx.fiber, &next.fiber) } From 22f3cef84ac689a7dbee58f59631caee4e2f89fd Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 21:23:24 +0800 Subject: [PATCH 11/20] runtime: isolate non-moving GC platform hooks --- ...c_baremetal.go => runtime_gc_nonmoving.go} | 5 +- runtime/internal/runtime/tinygogc/gc.go | 10 ++-- runtime/internal/runtime/tinygogc/gc_link.go | 40 ++++++++++++-- .../internal/runtime/tinygogc/gc_tinygo.go | 54 +++++++++++-------- ...efer_baremetal.go => z_defer_nonmoving.go} | 5 +- .../{z_gc_baremetal.go => z_gc_nonmoving.go} | 12 ++--- 6 files changed, 81 insertions(+), 45 deletions(-) rename runtime/internal/lib/runtime/{runtime_gc_baremetal.go => runtime_gc_nonmoving.go} (90%) rename runtime/internal/runtime/{z_defer_baremetal.go => z_defer_nonmoving.go} (77%) rename runtime/internal/runtime/{z_gc_baremetal.go => z_gc_nonmoving.go} (74%) diff --git a/runtime/internal/lib/runtime/runtime_gc_baremetal.go b/runtime/internal/lib/runtime/runtime_gc_nonmoving.go similarity index 90% rename from runtime/internal/lib/runtime/runtime_gc_baremetal.go rename to runtime/internal/lib/runtime/runtime_gc_nonmoving.go index f384b6d0f2..b12c146609 100644 --- a/runtime/internal/lib/runtime/runtime_gc_baremetal.go +++ b/runtime/internal/lib/runtime/runtime_gc_nonmoving.go @@ -1,4 +1,4 @@ -//go:build !nogc && baremetal +//go:build baremetal && !nogc package runtime @@ -9,6 +9,9 @@ import ( ) func ReadMemStats(m *runtime.MemStats) { + if m == nil { + return + } stats := tinygogc.ReadGCStats() m.Alloc = stats.Alloc m.TotalAlloc = stats.TotalAlloc diff --git a/runtime/internal/runtime/tinygogc/gc.go b/runtime/internal/runtime/tinygogc/gc.go index b83b8569d6..2af66f6b7b 100644 --- a/runtime/internal/runtime/tinygogc/gc.go +++ b/runtime/internal/runtime/tinygogc/gc.go @@ -1,9 +1,7 @@ -//go:build baremetal +//go:build baremetal && !nogc package tinygogc -import "unsafe" - type GCStats struct { // General statistics. @@ -150,6 +148,7 @@ func ReadGCStats() GCStats { var heapInuse, heapIdle uint64 lock(&gcMutex) + lazyInit() for block := uintptr(0); block < endBlock; block++ { bstate := gcStateOf(block) @@ -160,8 +159,7 @@ func ReadGCStats() GCStats { } } - stackEnd := uintptr(unsafe.Pointer(&_stackEnd)) - stackSys := stackTop - stackEnd + stackInuse, stackSys := gcStackStats() stats := GCStats{ Alloc: (gcTotalBlocks - gcFreedBlocks) * uint64(bytesPerBlock), @@ -173,7 +171,7 @@ func ReadGCStats() GCStats { HeapSys: heapInuse + heapIdle, HeapIdle: heapIdle, HeapInuse: heapInuse, - StackInuse: uint64(stackTop - uintptr(getsp())), + StackInuse: uint64(stackInuse), StackSys: uint64(stackSys), GCSys: uint64(heapEnd - uintptr(metadataStart)), } diff --git a/runtime/internal/runtime/tinygogc/gc_link.go b/runtime/internal/runtime/tinygogc/gc_link.go index 9921cd5c7b..550341e897 100644 --- a/runtime/internal/runtime/tinygogc/gc_link.go +++ b/runtime/internal/runtime/tinygogc/gc_link.go @@ -1,4 +1,4 @@ -//go:build baremetal +//go:build baremetal && !nogc package tinygogc @@ -32,9 +32,6 @@ func __wrap_realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { return Realloc(ptr, size) } -//go:linkname getsp llgo.stackSave -func getsp() unsafe.Pointer - //go:linkname _heapStart _heapStart var _heapStart [0]byte @@ -52,3 +49,38 @@ var _globals_start [0]byte //go:linkname _globals_end _globals_end var _globals_end [0]byte + +func gcMemoryLayout() (heapStart, heapEnd, globalsStart, globalsEnd, stackTop uintptr) { + // Reserve 2 KiB for libc internal allocation that cannot be wrapped. + return uintptr(unsafe.Pointer(&_heapStart)) + 2048, + uintptr(unsafe.Pointer(&_heapEnd)), + uintptr(unsafe.Pointer(&_globals_start)), + uintptr(unsafe.Pointer(&_globals_end)), + uintptr(unsafe.Pointer(&_stackStart)) +} + +func gcGrowMemory(oldHeapEnd uintptr) uintptr { + return oldHeapEnd +} + +func gcMarkReachable() { + sp := uintptr(getsp()) + if sp < stackTop { + markRoots(sp, stackTop) + } + if globalsStart < globalsEnd { + markRoots(globalsStart, globalsEnd) + } +} + +func gcStackStats() (inuse, sys uintptr) { + sp := uintptr(getsp()) + if sp < stackTop { + inuse = stackTop - sp + } + stackEnd := uintptr(unsafe.Pointer(&_stackEnd)) + if stackEnd < stackTop { + sys = stackTop - stackEnd + } + return +} diff --git a/runtime/internal/runtime/tinygogc/gc_tinygo.go b/runtime/internal/runtime/tinygogc/gc_tinygo.go index cca6f9fccd..fef3866151 100644 --- a/runtime/internal/runtime/tinygogc/gc_tinygo.go +++ b/runtime/internal/runtime/tinygogc/gc_tinygo.go @@ -1,4 +1,4 @@ -//go:build baremetal +//go:build baremetal && !nogc /* * Copyright (c) 2018-2025 The TinyGo Authors. All rights reserved. @@ -18,14 +18,11 @@ */ // Package tinygogc implements a conservative mark-and-sweep garbage collector -// for baremetal environments where the standard Go runtime and bdwgc are unavailable. +// for targets where the standard Go runtime and bdwgc are unavailable. // // This implementation is based on TinyGo's GC and is designed for resource-constrained // embedded systems. It uses a block-based allocator with conservative pointer scanning. // -// Build tags: -// - baremetal: Enables this GC for baremetal targets -// // Memory Layout: // The heap is divided into fixed-size blocks (32 bytes on 64-bit). Metadata is stored // at the end of the heap, using 2 bits per block to track state (free/head/tail/mark). @@ -95,18 +92,20 @@ const ( // this function MUST be initalized first, which means it's required to be initalized before runtime func initGC() { - // reserve 2K blocks for libc internal malloc, we cannot wrap those internal functions - heapStart = uintptr(unsafe.Pointer(&_heapStart)) + 2048 - heapEnd = uintptr(unsafe.Pointer(&_heapEnd)) - globalsStart = uintptr(unsafe.Pointer(&_globals_start)) - globalsEnd = uintptr(unsafe.Pointer(&_globals_end)) + heapStart, heapEnd, globalsStart, globalsEnd, stackTop = gcMemoryLayout() + if heapStart >= heapEnd { + gcPanic(c.Str("gc: invalid heap range")) + } + configureHeap() + metadataSize := heapEnd - uintptr(metadataStart) + c.Memset(metadataStart, 0, metadataSize) +} + +func configureHeap() { totalSize := heapEnd - heapStart metadataSize := (totalSize + blocksPerStateByte*bytesPerBlock) / (1 + blocksPerStateByte*bytesPerBlock) metadataStart = unsafe.Pointer(heapEnd - metadataSize) endBlock = (uintptr(metadataStart) - heapStart) / bytesPerBlock - stackTop = uintptr(unsafe.Pointer(&_stackStart)) - - c.Memset(metadataStart, 0, metadataSize) } func lazyInit() { @@ -362,12 +361,12 @@ func Realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { newAlloc := Alloc(size) c.Memcpy(newAlloc, ptr, oldSize) - free(ptr) + freeObject(ptr) return newAlloc } -func free(ptr unsafe.Pointer) { +func freeObject(ptr unsafe.Pointer) { // TODO: free blocks on request, when the compiler knows they're unused. } @@ -555,15 +554,28 @@ func sweep() (freeBytes uintptr) { // growHeap tries to grow the heap size. It returns true if it succeeds, false // otherwise. func growHeap() bool { - // On baremetal, there is no way the heap can be grown. - return false -} + oldHeapEnd := heapEnd + oldMetadataStart := metadataStart + oldMetadataSize := oldHeapEnd - uintptr(oldMetadataStart) + newHeapEnd := gcGrowMemory(oldHeapEnd) + if newHeapEnd <= oldHeapEnd { + return false + } -func gcMarkReachable() { - markRoots(uintptr(getsp()), stackTop) - markRoots(globalsStart, globalsEnd) + heapEnd = newHeapEnd + configureHeap() + newMetadataSize := heapEnd - uintptr(metadataStart) + if newMetadataSize < oldMetadataSize { + gcPanic(c.Str("gc: metadata shrank while growing heap")) + } + c.Memmove(metadataStart, oldMetadataStart, oldMetadataSize) + c.Memset(unsafe.Add(metadataStart, oldMetadataSize), 0, newMetadataSize-oldMetadataSize) + return true } func gcResumeWorld() { // Nothing to do here (single threaded). } + +//go:linkname getsp llgo.stackSave +func getsp() unsafe.Pointer diff --git a/runtime/internal/runtime/z_defer_baremetal.go b/runtime/internal/runtime/z_defer_nonmoving.go similarity index 77% rename from runtime/internal/runtime/z_defer_baremetal.go rename to runtime/internal/runtime/z_defer_nonmoving.go index 0af31efef4..a6c9b8467c 100644 --- a/runtime/internal/runtime/z_defer_baremetal.go +++ b/runtime/internal/runtime/z_defer_nonmoving.go @@ -20,9 +20,6 @@ package runtime import "unsafe" -// FreeDeferNode is a no-op in baremetal environment. -// Defer nodes become unreachable after being unlinked from the chain, -// and tinygogc will reclaim them in the next GC cycle. +// FreeDeferNode leaves unreachable nodes for the collector. func FreeDeferNode(ptr unsafe.Pointer) { - // no-op: let tinygogc collect } diff --git a/runtime/internal/runtime/z_gc_baremetal.go b/runtime/internal/runtime/z_gc_nonmoving.go similarity index 74% rename from runtime/internal/runtime/z_gc_baremetal.go rename to runtime/internal/runtime/z_gc_nonmoving.go index 09fdb2a69d..b4de4952e3 100644 --- a/runtime/internal/runtime/z_gc_baremetal.go +++ b/runtime/internal/runtime/z_gc_nonmoving.go @@ -1,4 +1,4 @@ -//go:build !nogc && baremetal +//go:build baremetal && !nogc /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. @@ -24,14 +24,12 @@ import ( "github.com/goplus/llgo/runtime/internal/runtime/tinygogc" ) -// AllocU allocates uninitialized memory. func AllocU(size uintptr) unsafe.Pointer { ret := tinygogc.Alloc(size) recordMemProfileAlloc(size) return ret } -// AllocZ allocates zero-initialized memory. func AllocZ(size uintptr) unsafe.Pointer { ret := tinygogc.Alloc(size) recordMemProfileAlloc(size) @@ -45,11 +43,7 @@ func AllocRoot(size uintptr) unsafe.Pointer { func FreeRoot(ptr unsafe.Pointer) { } -// AddCleanupPtr is not implemented in baremetal builds because tinygogc -// does not support finalizers. Cleanup functions will never be called. -// -// Returns: a no-op cancel function +// AddCleanupPtr is not implemented by the non-moving collector. func AddCleanupPtr(ptr unsafe.Pointer, cleanup func()) (cancel func()) { - // Not implemented: tinygogc does not support finalizers - return func() {} // no-op cancel + return func() {} } From f362bdfafb97e3d31848cf156823729b37d42b07 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 21:24:34 +0800 Subject: [PATCH 12/20] runtime/wasm: add opt-in non-moving collector --- internal/build/build.go | 22 +++++ internal/build/build_test.go | 27 +++++- internal/build/testdata/wasm-gc/abi.c | 23 +++++ internal/build/testdata/wasm-gc/abi.go | 8 ++ internal/build/testdata/wasm-gc/main.go | 89 +++++++++++++++++++ runtime/internal/clite/pthread/pthread_gc.go | 2 +- .../internal/clite/pthread/pthread_nogc.go | 2 +- runtime/internal/clite/tls/tls_gc.go | 2 +- runtime/internal/clite/tls/tls_nogc.go | 2 +- runtime/internal/lib/runtime/mfinal.go | 2 +- runtime/internal/lib/runtime/mfinal_nogc.go | 2 +- runtime/internal/lib/runtime/mfinal_wasm.go | 8 ++ runtime/internal/lib/runtime/runtime_gc.go | 2 +- .../lib/runtime/runtime_gc_nonmoving.go | 2 +- runtime/internal/lib/runtime/runtime_nogc.go | 2 +- .../internal/runtime/tinygogc/_wrap/gc_wasm.c | 53 +++++++++++ runtime/internal/runtime/tinygogc/gc.go | 2 +- .../internal/runtime/tinygogc/gc_tinygo.go | 2 +- runtime/internal/runtime/tinygogc/gc_wasm.go | 88 ++++++++++++++++++ .../internal/runtime/tinygogc/gc_wasm_js.go | 74 +++++++++++++++ .../runtime/tinygogc/gc_wasm_wasip1.go | 30 +++++++ runtime/internal/runtime/z_defer_gc.go | 2 +- runtime/internal/runtime/z_defer_nogc.go | 2 +- runtime/internal/runtime/z_defer_nonmoving.go | 2 +- runtime/internal/runtime/z_gc.go | 2 +- runtime/internal/runtime/z_gc_nonmoving.go | 2 +- runtime/internal/runtime/z_nogc.go | 3 +- 27 files changed, 437 insertions(+), 20 deletions(-) create mode 100644 internal/build/testdata/wasm-gc/abi.c create mode 100644 internal/build/testdata/wasm-gc/abi.go create mode 100644 internal/build/testdata/wasm-gc/main.go create mode 100644 runtime/internal/lib/runtime/mfinal_wasm.go create mode 100644 runtime/internal/runtime/tinygogc/_wrap/gc_wasm.c create mode 100644 runtime/internal/runtime/tinygogc/gc_wasm.go create mode 100644 runtime/internal/runtime/tinygogc/gc_wasm_js.go create mode 100644 runtime/internal/runtime/tinygogc/gc_wasm_wasip1.go diff --git a/internal/build/build.go b/internal/build/build.go index 6352bdd505..54ccbbd5d9 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -35,6 +35,7 @@ import ( "strings" "sync" "sync/atomic" + "unicode" "golang.org/x/tools/go/ssa" @@ -329,6 +330,7 @@ func Do(args []string, conf *Config) ([]Package, error) { if conf.Target != "" && export.GOARCH != "" { conf.Goarch = export.GOARCH } + applyWasmGCLinkFlags(conf, &export) if conf.AppExt == "" { conf.AppExt = defaultAppExt(conf) } @@ -688,6 +690,26 @@ func defaultBuildTags(goarch, target string) string { return tags } +func applyWasmGCLinkFlags(conf *Config, export *crosscompile.Export) { + if conf.Goos != "js" || conf.Goarch != "wasm" || !hasBuildTag(conf.Tags, "llgo_wasm_gc") { + return + } + if !slices.Contains(export.LDFLAGS, "-sMALLOC=none") { + export.LDFLAGS = append(export.LDFLAGS, "-sMALLOC=none") + } +} + +func hasBuildTag(tags, want string) bool { + for _, tag := range strings.FieldsFunc(tags, func(r rune) bool { + return r == ',' || unicode.IsSpace(r) + }) { + if tag == want { + return true + } + } + return false +} + func effectiveTypeSizes(sizes types.Sizes, goos, goarch, target string) types.Sizes { // Named wasm targets use the native wasm32 data model. The raw js/wasm // entry point keeps Go's 64-bit word model and is emitted as Memory64. diff --git a/internal/build/build_test.go b/internal/build/build_test.go index b7c540fad5..60add87d8c 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -142,6 +142,28 @@ func TestEffectiveWasmTypeSizes(t *testing.T) { } } +func TestApplyWasmGCLinkFlags(t *testing.T) { + tests := []struct { + name string + conf Config + want bool + }{ + {name: "wasm32", conf: Config{Goos: "js", Goarch: "wasm", Tags: "llgo_wasm_gc"}, want: true}, + {name: "comma separated tags", conf: Config{Goos: "js", Goarch: "wasm", Tags: "other,llgo_wasm_gc"}, want: true}, + {name: "default wasm", conf: Config{Goos: "js", Goarch: "wasm"}}, + {name: "WASI", conf: Config{Goos: "wasip1", Goarch: "wasm", Tags: "llgo_wasm_gc"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + export := crosscompile.Export{} + applyWasmGCLinkFlags(&test.conf, &export) + if got := slices.Contains(export.LDFLAGS, "-sMALLOC=none"); got != test.want { + t.Fatalf("MALLOC=none present = %v, want %v", got, test.want) + } + }) + } +} + func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { runtimeDir := filepath.Join(env.LLGoRuntimeDir(), "internal", "lib", "runtime") for _, goos := range []string{"js", "wasip1"} { @@ -149,7 +171,7 @@ func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { ctx := gobuild.Default ctx.GOOS = goos ctx.GOARCH = "wasm" - ctx.BuildTags = []string{"llgo", "nogc"} + ctx.BuildTags = []string{"llgo", "nogc", "llgo_wasm_gc"} pkg, err := ctx.ImportDir(runtimeDir, 0) if err != nil { t.Fatal(err) @@ -176,8 +198,9 @@ func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { } for _, name := range []string{ - "mfinal_nogc.go", + "mfinal_wasm.go", "runtime_baremetal.go", + "runtime_gc_nonmoving.go", "signal_baremetal_llgo.go", "time_wasm_llgo.go", "unwind_wasm_llgo.go", diff --git a/internal/build/testdata/wasm-gc/abi.c b/internal/build/testdata/wasm-gc/abi.c new file mode 100644 index 0000000000..998721d414 --- /dev/null +++ b/internal/build/testdata/wasm-gc/abi.c @@ -0,0 +1,23 @@ +#include +#include + +#if defined(__EMSCRIPTEN__) +#include +#endif + +int llgo_test_gc_aligned_alloc(void) { +#if defined(__EMSCRIPTEN__) + void *ptr = emscripten_builtin_memalign(65536, 257); + if (ptr == NULL || (uintptr_t)ptr % 65536 != 0) { + return 0; + } + unsigned char *bytes = ptr; + bytes[0] = 0x5a; + bytes[256] = 0xa5; + if (bytes[0] != 0x5a || bytes[256] != 0xa5) { + return 0; + } + emscripten_builtin_free(ptr); +#endif + return 1; +} diff --git a/internal/build/testdata/wasm-gc/abi.go b/internal/build/testdata/wasm-gc/abi.go new file mode 100644 index 0000000000..4eb18638a2 --- /dev/null +++ b/internal/build/testdata/wasm-gc/abi.go @@ -0,0 +1,8 @@ +package main + +import _ "unsafe" + +const LLGoFiles = "abi.c" + +//go:linkname testAlignedAlloc C.llgo_test_gc_aligned_alloc +func testAlignedAlloc() int32 diff --git a/internal/build/testdata/wasm-gc/main.go b/internal/build/testdata/wasm-gc/main.go new file mode 100644 index 0000000000..3167781a85 --- /dev/null +++ b/internal/build/testdata/wasm-gc/main.go @@ -0,0 +1,89 @@ +package main + +import "runtime" + +type payload struct { + value uint64 +} + +var ( + globalRoot *payload + garbage *payload + liveChunks [][]byte +) + +func main() { + runtime.ReadMemStats(nil) + if testAlignedAlloc() == 0 { + panic("aligned allocation failed") + } + testRoots() + testReclamation() + testHeapGrowth() + println("wasm gc ok") +} + +func testRoots() { + globalRoot = &payload{value: 0x12345678} + runtime.GC() + if globalRoot.value != 0x12345678 { + panic("global root was not retained") + } + globalRoot = nil +} + +//go:noinline +func allocateGarbage() { + for i := 0; i < 1024; i++ { + garbage = &payload{value: uint64(i)} + } + garbage = nil +} + +func testReclamation() { + runtime.GC() + var before runtime.MemStats + runtime.ReadMemStats(&before) + + allocateGarbage() + runtime.GC() + + var after runtime.MemStats + runtime.ReadMemStats(&after) + if after.TotalAlloc <= before.TotalAlloc || after.Mallocs <= before.Mallocs { + panic("allocation statistics did not advance") + } + if after.Frees <= before.Frees { + panic("unreachable objects were not reclaimed") + } +} + +func testHeapGrowth() { + var before runtime.MemStats + runtime.ReadMemStats(&before) + + const ( + chunkSize = 1 << 20 + chunkCount = 24 + ) + liveChunks = make([][]byte, 0, chunkCount) + for i := 0; i < chunkCount; i++ { + chunk := make([]byte, chunkSize) + chunk[0] = byte(i + 1) + chunk[len(chunk)-1] = byte(i + 2) + liveChunks = append(liveChunks, chunk) + } + + var after runtime.MemStats + runtime.ReadMemStats(&after) + if after.HeapSys <= before.HeapSys { + panic("heap did not grow") + } + runtime.GC() + for i, chunk := range liveChunks { + if chunk[0] != byte(i+1) || chunk[len(chunk)-1] != byte(i+2) { + panic("live object was corrupted during heap growth") + } + } + liveChunks = nil +} diff --git a/runtime/internal/clite/pthread/pthread_gc.go b/runtime/internal/clite/pthread/pthread_gc.go index 88409bc1c3..fdc067d2d2 100644 --- a/runtime/internal/clite/pthread/pthread_gc.go +++ b/runtime/internal/clite/pthread/pthread_gc.go @@ -1,4 +1,4 @@ -//go:build !nogc && !baremetal +//go:build !nogc && !baremetal && !wasm /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/clite/pthread/pthread_nogc.go b/runtime/internal/clite/pthread/pthread_nogc.go index d61d39a3be..c72f1d597e 100644 --- a/runtime/internal/clite/pthread/pthread_nogc.go +++ b/runtime/internal/clite/pthread/pthread_nogc.go @@ -1,4 +1,4 @@ -//go:build nogc || baremetal +//go:build nogc || baremetal || wasm /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/clite/tls/tls_gc.go b/runtime/internal/clite/tls/tls_gc.go index afd10494de..dee28069a9 100644 --- a/runtime/internal/clite/tls/tls_gc.go +++ b/runtime/internal/clite/tls/tls_gc.go @@ -1,4 +1,4 @@ -//go:build llgo && !baremetal && !nogc +//go:build llgo && !baremetal && !wasm && !nogc /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/clite/tls/tls_nogc.go b/runtime/internal/clite/tls/tls_nogc.go index 00923d4105..4e7283b41f 100644 --- a/runtime/internal/clite/tls/tls_nogc.go +++ b/runtime/internal/clite/tls/tls_nogc.go @@ -1,4 +1,4 @@ -//go:build llgo && (nogc || baremetal) +//go:build llgo && (nogc || baremetal || wasm) /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/lib/runtime/mfinal.go b/runtime/internal/lib/runtime/mfinal.go index a62305c8a2..70d43c918e 100644 --- a/runtime/internal/lib/runtime/mfinal.go +++ b/runtime/internal/lib/runtime/mfinal.go @@ -1,4 +1,4 @@ -//go:build !nogc +//go:build !nogc && !wasm // Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style diff --git a/runtime/internal/lib/runtime/mfinal_nogc.go b/runtime/internal/lib/runtime/mfinal_nogc.go index c85111ea65..b4e55a948d 100644 --- a/runtime/internal/lib/runtime/mfinal_nogc.go +++ b/runtime/internal/lib/runtime/mfinal_nogc.go @@ -1,4 +1,4 @@ -//go:build nogc +//go:build nogc && (!wasm || !llgo_wasm_gc) package runtime diff --git a/runtime/internal/lib/runtime/mfinal_wasm.go b/runtime/internal/lib/runtime/mfinal_wasm.go new file mode 100644 index 0000000000..e18f6f6b7f --- /dev/null +++ b/runtime/internal/lib/runtime/mfinal_wasm.go @@ -0,0 +1,8 @@ +//go:build wasm && llgo_wasm_gc + +package runtime + +// SetFinalizer is not implemented by the initial WebAssembly collector. +func SetFinalizer(obj any, finalizer any) { + _, _ = obj, finalizer +} diff --git a/runtime/internal/lib/runtime/runtime_gc.go b/runtime/internal/lib/runtime/runtime_gc.go index d8656f93a4..7de1695009 100644 --- a/runtime/internal/lib/runtime/runtime_gc.go +++ b/runtime/internal/lib/runtime/runtime_gc.go @@ -1,4 +1,4 @@ -//go:build !nogc && !baremetal +//go:build !nogc && !baremetal && !wasm package runtime diff --git a/runtime/internal/lib/runtime/runtime_gc_nonmoving.go b/runtime/internal/lib/runtime/runtime_gc_nonmoving.go index b12c146609..a5c79953fd 100644 --- a/runtime/internal/lib/runtime/runtime_gc_nonmoving.go +++ b/runtime/internal/lib/runtime/runtime_gc_nonmoving.go @@ -1,4 +1,4 @@ -//go:build baremetal && !nogc +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) package runtime diff --git a/runtime/internal/lib/runtime/runtime_nogc.go b/runtime/internal/lib/runtime/runtime_nogc.go index 3f11426023..50c039b10f 100644 --- a/runtime/internal/lib/runtime/runtime_nogc.go +++ b/runtime/internal/lib/runtime/runtime_nogc.go @@ -1,4 +1,4 @@ -//go:build nogc +//go:build nogc && (!wasm || !llgo_wasm_gc) package runtime diff --git a/runtime/internal/runtime/tinygogc/_wrap/gc_wasm.c b/runtime/internal/runtime/tinygogc/_wrap/gc_wasm.c new file mode 100644 index 0000000000..d6ae8fd3d4 --- /dev/null +++ b/runtime/internal/runtime/tinygogc/_wrap/gc_wasm.c @@ -0,0 +1,53 @@ +#include +#include + +#if defined(__EMSCRIPTEN__) +#include +#include +#else +extern unsigned char __stack_high; +#endif + +extern unsigned char __data_end; +extern unsigned char __global_base; +extern unsigned char __heap_base; + +#define LLGO_WASM_PAGE_SIZE 65536 + +uintptr_t llgo_gc_globals_start(void) { + return (uintptr_t)&__global_base; +} + +uintptr_t llgo_gc_globals_end(void) { + return (uintptr_t)&__data_end; +} + +uintptr_t llgo_gc_heap_base(void) { + return (uintptr_t)&__heap_base; +} + +uintptr_t llgo_gc_stack_top(void) { +#if defined(__EMSCRIPTEN__) + return (uintptr_t)emscripten_stack_get_base(); +#else + return (uintptr_t)&__stack_high; +#endif +} + +uintptr_t llgo_gc_memory_size(void) { + return (uintptr_t)__builtin_wasm_memory_size(0) * LLGO_WASM_PAGE_SIZE; +} + +int llgo_gc_grow_memory(uintptr_t required) { +#if defined(__EMSCRIPTEN__) + return emscripten_resize_heap(required); +#else + uintptr_t current = llgo_gc_memory_size(); + if (required <= current) { + return 1; + } + uintptr_t pages = (required - current + LLGO_WASM_PAGE_SIZE - 1) / + LLGO_WASM_PAGE_SIZE; + return __builtin_wasm_memory_grow(0, pages) != (size_t)-1; +#endif +} diff --git a/runtime/internal/runtime/tinygogc/gc.go b/runtime/internal/runtime/tinygogc/gc.go index 2af66f6b7b..8163f7ff10 100644 --- a/runtime/internal/runtime/tinygogc/gc.go +++ b/runtime/internal/runtime/tinygogc/gc.go @@ -1,4 +1,4 @@ -//go:build baremetal && !nogc +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) package tinygogc diff --git a/runtime/internal/runtime/tinygogc/gc_tinygo.go b/runtime/internal/runtime/tinygogc/gc_tinygo.go index fef3866151..65aceb822e 100644 --- a/runtime/internal/runtime/tinygogc/gc_tinygo.go +++ b/runtime/internal/runtime/tinygogc/gc_tinygo.go @@ -1,4 +1,4 @@ -//go:build baremetal && !nogc +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) /* * Copyright (c) 2018-2025 The TinyGo Authors. All rights reserved. diff --git a/runtime/internal/runtime/tinygogc/gc_wasm.go b/runtime/internal/runtime/tinygogc/gc_wasm.go new file mode 100644 index 0000000000..c1b7e6300a --- /dev/null +++ b/runtime/internal/runtime/tinygogc/gc_wasm.go @@ -0,0 +1,88 @@ +//go:build wasm && llgo_wasm_gc + +package tinygogc + +import ( + _ "unsafe" + + c "github.com/goplus/llgo/runtime/internal/clite" +) + +const LLGoFiles = "_wrap/gc_wasm.c" + +const wasmPageSize = uintptr(64 << 10) + +func gcMemoryLayout() (heapStart, heapEnd, globalsStart, globalsEnd, stackTop uintptr) { + heapStart = alignUp(gcWasmHeapBase(), bytesPerBlock) + heapEnd = alignDown(gcWasmMemorySize(), bytesPerBlock) + globalsStart = gcWasmGlobalsStart() + globalsEnd = gcWasmGlobalsEnd() + stackTop = gcWasmStackTop() + return +} + +func gcGrowMemory(oldHeapEnd uintptr) uintptr { + current := gcWasmMemorySize() + if current < oldHeapEnd { + return oldHeapEnd + } + growth := oldHeapEnd - heapStart + if growth < 1<<20 { + growth = 1 << 20 + } + if current > ^uintptr(0)-growth { + return oldHeapEnd + } + required := alignUp(current+growth, wasmPageSize) + if gcWasmGrowMemory(required) == 0 { + return oldHeapEnd + } + return alignDown(gcWasmMemorySize(), bytesPerBlock) +} + +func gcMarkReachable() { + sp := uintptr(getsp()) + top := gcWasmStackTop() + if sp < top { + markRoots(sp, top) + } + if globalsStart < globalsEnd { + markRoots(globalsStart, globalsEnd) + } +} + +func gcStackStats() (inuse, sys uintptr) { + sp := uintptr(getsp()) + top := gcWasmStackTop() + if sp < top { + inuse = top - sp + sys = inuse + } + return +} + +func alignUp(value, alignment uintptr) uintptr { + return (value + alignment - 1) &^ (alignment - 1) +} + +func alignDown(value, alignment uintptr) uintptr { + return value &^ (alignment - 1) +} + +//go:linkname gcWasmGlobalsStart C.llgo_gc_globals_start +func gcWasmGlobalsStart() uintptr + +//go:linkname gcWasmGlobalsEnd C.llgo_gc_globals_end +func gcWasmGlobalsEnd() uintptr + +//go:linkname gcWasmHeapBase C.llgo_gc_heap_base +func gcWasmHeapBase() uintptr + +//go:linkname gcWasmStackTop C.llgo_gc_stack_top +func gcWasmStackTop() uintptr + +//go:linkname gcWasmMemorySize C.llgo_gc_memory_size +func gcWasmMemorySize() uintptr + +//go:linkname gcWasmGrowMemory C.llgo_gc_grow_memory +func gcWasmGrowMemory(required uintptr) c.Int diff --git a/runtime/internal/runtime/tinygogc/gc_wasm_js.go b/runtime/internal/runtime/tinygogc/gc_wasm_js.go new file mode 100644 index 0000000000..50731fff1f --- /dev/null +++ b/runtime/internal/runtime/tinygogc/gc_wasm_js.go @@ -0,0 +1,74 @@ +//go:build js && wasm && llgo_wasm_gc + +package tinygogc + +import "unsafe" + +func wasmCalloc(nmemb, size uintptr) unsafe.Pointer { + totalSize := nmemb * size + if nmemb != 0 && totalSize/nmemb != size { + return nil + } + return Alloc(totalSize) +} + +func wasmMemalign(alignment, size uintptr) unsafe.Pointer { + if alignment < unsafe.Sizeof(uintptr(0)) || alignment&(alignment-1) != 0 { + return nil + } + if alignment <= bytesPerBlock { + return Alloc(size) + } + if size > ^uintptr(0)-(alignment-1) { + return nil + } + return unsafe.Pointer(alignUp(uintptr(Alloc(size+alignment-1)), alignment)) +} + +//export malloc +func malloc(size uintptr) unsafe.Pointer { + return Alloc(size) +} + +//export free +func free(ptr unsafe.Pointer) { +} + +//export calloc +func calloc(nmemb, size uintptr) unsafe.Pointer { + return wasmCalloc(nmemb, size) +} + +//export realloc +func realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { + return Realloc(ptr, size) +} + +//export memalign +func memalign(alignment, size uintptr) unsafe.Pointer { + return wasmMemalign(alignment, size) +} + +//export emscripten_builtin_malloc +func emscripten_builtin_malloc(size uintptr) unsafe.Pointer { + return Alloc(size) +} + +//export emscripten_builtin_free +func emscripten_builtin_free(ptr unsafe.Pointer) { +} + +//export emscripten_builtin_calloc +func emscripten_builtin_calloc(nmemb, size uintptr) unsafe.Pointer { + return wasmCalloc(nmemb, size) +} + +//export emscripten_builtin_realloc +func emscripten_builtin_realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { + return Realloc(ptr, size) +} + +//export emscripten_builtin_memalign +func emscripten_builtin_memalign(alignment, size uintptr) unsafe.Pointer { + return wasmMemalign(alignment, size) +} diff --git a/runtime/internal/runtime/tinygogc/gc_wasm_wasip1.go b/runtime/internal/runtime/tinygogc/gc_wasm_wasip1.go new file mode 100644 index 0000000000..9dd5db8874 --- /dev/null +++ b/runtime/internal/runtime/tinygogc/gc_wasm_wasip1.go @@ -0,0 +1,30 @@ +//go:build wasip1 && wasm && llgo_wasm_gc + +package tinygogc + +import "unsafe" + +const LLGoPackage = "link: -Wl,--wrap=malloc -Wl,--wrap=free -Wl,--wrap=realloc -Wl,--wrap=calloc" + +//export __wrap_malloc +func __wrap_malloc(size uintptr) unsafe.Pointer { + return Alloc(size) +} + +//export __wrap_free +func __wrap_free(ptr unsafe.Pointer) { +} + +//export __wrap_calloc +func __wrap_calloc(nmemb, size uintptr) unsafe.Pointer { + totalSize := nmemb * size + if nmemb != 0 && totalSize/nmemb != size { + return nil + } + return Alloc(totalSize) +} + +//export __wrap_realloc +func __wrap_realloc(ptr unsafe.Pointer, size uintptr) unsafe.Pointer { + return Realloc(ptr, size) +} diff --git a/runtime/internal/runtime/z_defer_gc.go b/runtime/internal/runtime/z_defer_gc.go index 88ee5eeae2..2739768e08 100644 --- a/runtime/internal/runtime/z_defer_gc.go +++ b/runtime/internal/runtime/z_defer_gc.go @@ -1,4 +1,4 @@ -//go:build !nogc && !baremetal +//go:build !nogc && !baremetal && !wasm /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/z_defer_nogc.go b/runtime/internal/runtime/z_defer_nogc.go index e7acb7bc6d..b16c883dce 100644 --- a/runtime/internal/runtime/z_defer_nogc.go +++ b/runtime/internal/runtime/z_defer_nogc.go @@ -1,4 +1,4 @@ -//go:build nogc && !baremetal +//go:build nogc && !baremetal && (!wasm || !llgo_wasm_gc) /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/z_defer_nonmoving.go b/runtime/internal/runtime/z_defer_nonmoving.go index a6c9b8467c..3142e3250c 100644 --- a/runtime/internal/runtime/z_defer_nonmoving.go +++ b/runtime/internal/runtime/z_defer_nonmoving.go @@ -1,4 +1,4 @@ -//go:build baremetal && !nogc +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) /* * Copyright (c) 2025 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/z_gc.go b/runtime/internal/runtime/z_gc.go index 4b4d144678..32ba207edc 100644 --- a/runtime/internal/runtime/z_gc.go +++ b/runtime/internal/runtime/z_gc.go @@ -1,4 +1,4 @@ -//go:build !nogc && !baremetal +//go:build !nogc && !baremetal && !wasm /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/z_gc_nonmoving.go b/runtime/internal/runtime/z_gc_nonmoving.go index b4de4952e3..5186cb7b62 100644 --- a/runtime/internal/runtime/z_gc_nonmoving.go +++ b/runtime/internal/runtime/z_gc_nonmoving.go @@ -1,4 +1,4 @@ -//go:build baremetal && !nogc +//go:build (baremetal && !nogc) || (wasm && llgo_wasm_gc) /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/z_nogc.go b/runtime/internal/runtime/z_nogc.go index e716ec1e94..2300034d4b 100644 --- a/runtime/internal/runtime/z_nogc.go +++ b/runtime/internal/runtime/z_nogc.go @@ -1,5 +1,4 @@ -//go:build nogc -// +build nogc +//go:build nogc && (!wasm || !llgo_wasm_gc) /* * Copyright (c) 2024 The XGo Authors (xgo.dev). All rights reserved. From 1dfae340be6e28382135a7a07d9a7e4297b422ce Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 21:24:40 +0800 Subject: [PATCH 13/20] ci: exercise opt-in wasm collector --- .github/workflows/llgo.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index c00ac9bee8..f998d9d70f 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -452,4 +452,9 @@ jobs: run_wasm_scheduler "$RUNNER_TEMP/wasm-scheduler-go.mjs" llgo build -target wasm -o "$RUNNER_TEMP/wasm-scheduler.mjs" ./internal/build/testdata/wasm-scheduler run_wasm_scheduler "$RUNNER_TEMP/wasm-scheduler.mjs" - file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" + GOOS=js GOARCH=wasm llgo build -tags=llgo_wasm_gc -o "$RUNNER_TEMP/wasm-gc-go.mjs" ./internal/build/testdata/wasm-gc + node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-gc-go.mjs'; await Module();" + llgo build -target wasm -tags=llgo_wasm_gc -o "$RUNNER_TEMP/wasm-gc.mjs" ./internal/build/testdata/wasm-gc + node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-gc.mjs'; await Module();" + GOOS=wasip1 GOARCH=wasm llgo build -tags=llgo_wasm_gc -o "$RUNNER_TEMP/wasm-gc-wasip1.wasm" ./internal/build/testdata/wasm-gc + file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" "$RUNNER_TEMP/wasm-gc-wasip1.wasm" From abbc8b7f68adaf7867a24f7dc5dceb9971d78f80 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 21:36:43 +0800 Subject: [PATCH 14/20] test: size wasm heap growth probe dynamically --- internal/build/testdata/wasm-gc/main.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/build/testdata/wasm-gc/main.go b/internal/build/testdata/wasm-gc/main.go index 3167781a85..d7062bcf25 100644 --- a/internal/build/testdata/wasm-gc/main.go +++ b/internal/build/testdata/wasm-gc/main.go @@ -62,10 +62,11 @@ func testHeapGrowth() { var before runtime.MemStats runtime.ReadMemStats(&before) - const ( - chunkSize = 1 << 20 - chunkCount = 24 - ) + const chunkSize = 1 << 20 + chunkCount := int(before.HeapSys/chunkSize) + 8 + if chunkCount > 128 { + panic("initial heap is too large for the bounded growth test") + } liveChunks = make([][]byte, 0, chunkCount) for i := 0; i < chunkCount; i++ { chunk := make([]byte, chunkSize) From 8bb5f646e954f195cccc696ea264c46ec886febb Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 22:26:44 +0800 Subject: [PATCH 15/20] build/wasm: reject GC with threaded WASI --- internal/build/build.go | 24 ++++++++++++++++++------ internal/build/build_test.go | 18 ++++++++++++++++-- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/internal/build/build.go b/internal/build/build.go index 54ccbbd5d9..6b5fd122b2 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -330,7 +330,9 @@ func Do(args []string, conf *Config) ([]Package, error) { if conf.Target != "" && export.GOARCH != "" { conf.Goarch = export.GOARCH } - applyWasmGCLinkFlags(conf, &export) + if err := configureWasmGC(conf, &export); err != nil { + return nil, err + } if conf.AppExt == "" { conf.AppExt = defaultAppExt(conf) } @@ -690,13 +692,23 @@ func defaultBuildTags(goarch, target string) string { return tags } -func applyWasmGCLinkFlags(conf *Config, export *crosscompile.Export) { - if conf.Goos != "js" || conf.Goarch != "wasm" || !hasBuildTag(conf.Tags, "llgo_wasm_gc") { - return +func configureWasmGC(conf *Config, export *crosscompile.Export) error { + if conf.Goarch != "wasm" || !hasBuildTag(conf.Tags, "llgo_wasm_gc") { + return nil } - if !slices.Contains(export.LDFLAGS, "-sMALLOC=none") { - export.LDFLAGS = append(export.LDFLAGS, "-sMALLOC=none") + switch conf.Goos { + case "js": + if !slices.Contains(export.LDFLAGS, "-sMALLOC=none") { + export.LDFLAGS = append(export.LDFLAGS, "-sMALLOC=none") + } + case "wasip1": + if IsWasiThreadsEnabled() { + return errors.New("llgo_wasm_gc requires single-worker WASI (set LLGO_WASI_THREADS=0)") + } + default: + return fmt.Errorf("llgo_wasm_gc does not support GOOS=%s", conf.Goos) } + return nil } func hasBuildTag(tags, want string) bool { diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 60add87d8c..e65219b619 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -142,21 +142,27 @@ func TestEffectiveWasmTypeSizes(t *testing.T) { } } -func TestApplyWasmGCLinkFlags(t *testing.T) { +func TestConfigureWasmGC(t *testing.T) { + t.Setenv("LLGO_WASI_THREADS", "0") tests := []struct { name string conf Config want bool + err bool }{ {name: "wasm32", conf: Config{Goos: "js", Goarch: "wasm", Tags: "llgo_wasm_gc"}, want: true}, {name: "comma separated tags", conf: Config{Goos: "js", Goarch: "wasm", Tags: "other,llgo_wasm_gc"}, want: true}, {name: "default wasm", conf: Config{Goos: "js", Goarch: "wasm"}}, {name: "WASI", conf: Config{Goos: "wasip1", Goarch: "wasm", Tags: "llgo_wasm_gc"}}, + {name: "unsupported host", conf: Config{Goos: "linux", Goarch: "wasm", Tags: "llgo_wasm_gc"}, err: true}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { export := crosscompile.Export{} - applyWasmGCLinkFlags(&test.conf, &export) + err := configureWasmGC(&test.conf, &export) + if (err != nil) != test.err { + t.Fatalf("configureWasmGC error = %v, want error %v", err, test.err) + } if got := slices.Contains(export.LDFLAGS, "-sMALLOC=none"); got != test.want { t.Fatalf("MALLOC=none present = %v, want %v", got, test.want) } @@ -164,6 +170,14 @@ func TestApplyWasmGCLinkFlags(t *testing.T) { } } +func TestConfigureWasmGCRejectsWASIThreads(t *testing.T) { + t.Setenv("LLGO_WASI_THREADS", "1") + conf := Config{Goos: "wasip1", Goarch: "wasm", Tags: "llgo_wasm_gc"} + if err := configureWasmGC(&conf, &crosscompile.Export{}); err == nil { + t.Fatal("expected llgo_wasm_gc with WASI threads to fail") + } +} + func TestWasmRuntimeAvoidsNativeHostDependencies(t *testing.T) { runtimeDir := filepath.Join(env.LLGoRuntimeDir(), "internal", "lib", "runtime") for _, goos := range []string{"js", "wasip1"} { From b7e6cfa29f4da7078ca30696338e82b9303d70cf Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 22:29:36 +0800 Subject: [PATCH 16/20] ci: select single-worker P1 for wasm GC --- .github/workflows/llgo.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index f998d9d70f..101ba6a4f6 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -456,5 +456,5 @@ jobs: node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-gc-go.mjs'; await Module();" llgo build -target wasm -tags=llgo_wasm_gc -o "$RUNNER_TEMP/wasm-gc.mjs" ./internal/build/testdata/wasm-gc node --input-type=module -e "import Module from '$RUNNER_TEMP/wasm-gc.mjs'; await Module();" - GOOS=wasip1 GOARCH=wasm llgo build -tags=llgo_wasm_gc -o "$RUNNER_TEMP/wasm-gc-wasip1.wasm" ./internal/build/testdata/wasm-gc + GOOS=wasip1 GOARCH=wasm LLGO_WASI_THREADS=0 llgo build -tags=llgo_wasm_gc -o "$RUNNER_TEMP/wasm-gc-wasip1.wasm" ./internal/build/testdata/wasm-gc file "$RUNNER_TEMP/runtime-js.wasm" "$RUNNER_TEMP/runtime-wasip1.wasm" "$RUNNER_TEMP/wasm-gc-wasip1.wasm" From 64cef9a56bbcc61f5127471aa003d410a677f63b Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 22:47:13 +0800 Subject: [PATCH 17/20] runtime: preserve ReadMemStats nil panic --- internal/build/testdata/wasm-gc/main.go | 1 - runtime/internal/lib/runtime/runtime_gc_nonmoving.go | 3 --- 2 files changed, 4 deletions(-) diff --git a/internal/build/testdata/wasm-gc/main.go b/internal/build/testdata/wasm-gc/main.go index d7062bcf25..f66fdd51fb 100644 --- a/internal/build/testdata/wasm-gc/main.go +++ b/internal/build/testdata/wasm-gc/main.go @@ -13,7 +13,6 @@ var ( ) func main() { - runtime.ReadMemStats(nil) if testAlignedAlloc() == 0 { panic("aligned allocation failed") } diff --git a/runtime/internal/lib/runtime/runtime_gc_nonmoving.go b/runtime/internal/lib/runtime/runtime_gc_nonmoving.go index a5c79953fd..e91c06c75b 100644 --- a/runtime/internal/lib/runtime/runtime_gc_nonmoving.go +++ b/runtime/internal/lib/runtime/runtime_gc_nonmoving.go @@ -9,9 +9,6 @@ import ( ) func ReadMemStats(m *runtime.MemStats) { - if m == nil { - return - } stats := tinygogc.ReadGCStats() m.Alloc = stats.Alloc m.TotalAlloc = stats.TotalAlloc From cf0377e699daf43733a2c151ec5b6687d0431469 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 01:05:32 +0800 Subject: [PATCH 18/20] build/wasm: centralize post-link output lifecycle --- internal/build/build.go | 21 +-- internal/build/wasm_postlink.go | 47 +++++- internal/build/wasm_postlink_test.go | 178 ++++++++++++++++----- internal/crosscompile/crosscompile_test.go | 13 ++ 4 files changed, 199 insertions(+), 60 deletions(-) diff --git a/internal/build/build.go b/internal/build/build.go index d245997822..c3f9277c84 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -1313,26 +1313,15 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa } linkArgs = append(linkArgs, cSharedExportArgs(ctx, linkedOrder)...) - linkOutput := outputPath - if needsWasmPostLink(ctx.buildConf, &ctx.crossCompile) { - tmp, err := os.CreateTemp(filepath.Dir(outputPath), "."+filepath.Base(outputPath)+".linked-*") - if err != nil { - return err - } - linkOutput = tmp.Name() - if err := tmp.Close(); err != nil { - os.Remove(linkOutput) - return err - } - defer os.Remove(linkOutput) + linkOutput, err := prepareWasmLinkOutput(ctx.buildConf, &ctx.crossCompile, outputPath) + if err != nil { + return err } + defer cleanupWasmLinkOutput(linkOutput, outputPath) if err := linkObjFiles(ctx, linkOutput, linkInputs, linkArgs, verbose); err != nil { return err } - if linkOutput != outputPath { - return postLinkWasm(ctx, linkOutput, outputPath, verbose) - } - return nil + return publishWasmLinkOutput(ctx, linkOutput, outputPath, verbose) } func linkedModuleGlobals(pkgs []Package) map[string]none { diff --git a/internal/build/wasm_postlink.go b/internal/build/wasm_postlink.go index 99c9208c6f..b460f153b1 100644 --- a/internal/build/wasm_postlink.go +++ b/internal/build/wasm_postlink.go @@ -46,6 +46,42 @@ func wasmPostLinkArgs(target *crosscompile.Export, input, output string, debug b return append(args, input, "-o", output) } +func prepareWasmLinkOutput(conf *Config, target *crosscompile.Export, output string) (string, error) { + if !needsWasmPostLink(conf, target) { + return output, nil + } + return createClosedTemp( + filepath.Dir(output), + "."+filepath.Base(output)+".linked-*", + ) +} + +func cleanupWasmLinkOutput(input, output string) { + if input != output { + os.Remove(input) + } +} + +func publishWasmLinkOutput(ctx *context, input, output string, verbose bool) error { + if input == output { + return nil + } + return postLinkWasm(ctx, input, output, verbose) +} + +func createClosedTemp(dir, pattern string) (string, error) { + tmp, err := os.CreateTemp(dir, pattern) + if err != nil { + return "", err + } + name := tmp.Name() + if err := tmp.Close(); err != nil { + os.Remove(name) + return "", err + } + return name, nil +} + func postLinkWasm(ctx *context, input, output string, verbose bool) error { wasmOpt := os.Getenv("WASMOPT") if wasmOpt == "" { @@ -56,16 +92,13 @@ func postLinkWasm(ctx *context, input, output string, verbose bool) error { return fmt.Errorf("WebAssembly Asyncify requires wasm-opt; install Binaryen or set WASMOPT: %w", err) } - outDir := filepath.Dir(output) - tmp, err := os.CreateTemp(outDir, "."+filepath.Base(output)+".wasm-opt-*") + tmpName, err := createClosedTemp( + filepath.Dir(output), + "."+filepath.Base(output)+".wasm-opt-*", + ) if err != nil { return err } - tmpName := tmp.Name() - if err := tmp.Close(); err != nil { - os.Remove(tmpName) - return err - } defer os.Remove(tmpName) args := wasmPostLinkArgs( diff --git a/internal/build/wasm_postlink_test.go b/internal/build/wasm_postlink_test.go index 67e037836d..0a00327425 100644 --- a/internal/build/wasm_postlink_test.go +++ b/internal/build/wasm_postlink_test.go @@ -29,6 +29,27 @@ import ( "github.com/goplus/llgo/internal/crosscompile" ) +func wasmPostLinkTestContext() *context { + return &context{ + buildConf: &Config{LinkOptions: LinkOptions{DWARF: DWARFOmit}}, + crossCompile: crosscompile.Export{ + WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}, + }, + } +} + +func writeWasmOptTestTool(t *testing.T, dir, script string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("test helper uses a POSIX shell") + } + tool := filepath.Join(dir, "wasm-opt") + if err := os.WriteFile(tool, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return tool +} + func TestWasmPostLinkArgs(t *testing.T) { target := &crosscompile.Export{WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}} if got, want := wasmPostLinkArgs(target, "in.wasm", "out.wasm", false), @@ -68,10 +89,48 @@ func TestNeedsWasmPostLink(t *testing.T) { } } -func TestPostLinkWasmPublishesOutput(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("test helper uses a POSIX shell") +func TestPrepareWasmLinkOutput(t *testing.T) { + dir := t.TempDir() + output := filepath.Join(dir, "app.wasm") + target := &crosscompile.Export{WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}} + + input, err := prepareWasmLinkOutput(&Config{BuildMode: BuildModeExe}, target, output) + if err != nil { + t.Fatal(err) + } + if input == output || filepath.Dir(input) != dir { + t.Fatalf("temporary link output = %q, want a distinct file in %q", input, dir) + } + if _, err := os.Stat(input); err != nil { + t.Fatalf("temporary link output was not created: %v", err) + } + cleanupWasmLinkOutput(input, output) + if _, err := os.Stat(input); !os.IsNotExist(err) { + t.Fatalf("temporary link output remains after cleanup: %v", err) + } + + if err := os.WriteFile(output, []byte("final"), 0o644); err != nil { + t.Fatal(err) + } + input, err = prepareWasmLinkOutput(&Config{BuildMode: BuildModeCArchive}, target, output) + if err != nil || input != output { + t.Fatalf("disabled post-link output = %q, %v; want %q, nil", input, err, output) + } + cleanupWasmLinkOutput(input, output) + if data, err := os.ReadFile(output); err != nil || string(data) != "final" { + t.Fatalf("cleanup removed final output: %q, %v", data, err) + } + if err := publishWasmLinkOutput(nil, output, output, false); err != nil { + t.Fatalf("disabled publish failed: %v", err) } + + missingOutput := filepath.Join(dir, "missing", "app.wasm") + if _, err := prepareWasmLinkOutput(&Config{BuildMode: BuildModeExe}, target, missingOutput); err == nil { + t.Fatal("prepareWasmLinkOutput succeeded with a missing output directory") + } +} + +func TestPostLinkWasmPublishesOutput(t *testing.T) { dir := t.TempDir() input := filepath.Join(dir, "linked.wasm") output := filepath.Join(dir, "app.wasm") @@ -80,43 +139,35 @@ func TestPostLinkWasmPublishesOutput(t *testing.T) { t.Fatal(err) } - tool := filepath.Join(dir, "wasm-opt") script := `#!/bin/sh printf '%s\n' "$@" > "$ARGS_FILE" -input= -output= -while [ "$#" -gt 0 ]; do - case "$1" in - -o) - output="$2" - shift 2 - ;; - -*) - shift - ;; - *) - input="$1" - shift - ;; - esac -done -cp "$input" "$output" +cp "$3" "$5" ` - if err := os.WriteFile(tool, []byte(script), 0o755); err != nil { + tool := writeWasmOptTestTool(t, dir, script) + t.Setenv("WASMOPT", "") + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("ARGS_FILE", argsFile) + + ctx := wasmPostLinkTestContext() + stderr, err := os.CreateTemp(dir, "stderr") + if err != nil { t.Fatal(err) } - t.Setenv("WASMOPT", tool) - t.Setenv("ARGS_FILE", argsFile) + oldStderr := os.Stderr + os.Stderr = stderr + t.Cleanup(func() { os.Stderr = oldStderr }) - ctx := &context{ - buildConf: &Config{Mode: ModeBuild, LinkOptions: LinkOptions{DWARF: DWARFOmit}}, - crossCompile: crosscompile.Export{ - WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}, - }, + if err := publishWasmLinkOutput(ctx, input, output, true); err != nil { + t.Fatal(err) } - if err := postLinkWasm(ctx, input, output, false); err != nil { + if err := stderr.Close(); err != nil { t.Fatal(err) } + if got, err := os.ReadFile(stderr.Name()); err != nil || + !strings.Contains(string(got), tool) || + !strings.Contains(string(got), "--asyncify") { + t.Fatalf("verbose command = %q, %v", got, err) + } if data, err := os.ReadFile(output); err != nil || string(data) != "core module" { t.Fatalf("published output = %q, %v", data, err) } @@ -130,16 +181,69 @@ cp "$input" "$output" } } +func TestPostLinkWasmReportsToolFailure(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "linked.wasm") + output := filepath.Join(dir, "app.wasm") + if err := os.WriteFile(input, []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(output, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + tool := writeWasmOptTestTool(t, dir, "#!/bin/sh\nexit 7\n") + t.Setenv("WASMOPT", tool) + + ctx := wasmPostLinkTestContext() + err := postLinkWasm(ctx, input, output, false) + if err == nil || !strings.Contains(err.Error(), "wasm-opt Asyncify failed") { + t.Fatalf("postLinkWasm() error = %v", err) + } + if data, err := os.ReadFile(output); err != nil || string(data) != "old" { + t.Fatalf("failed post-link changed final output: %q, %v", data, err) + } +} + +func TestPostLinkWasmReportsPublishFailure(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "linked.wasm") + output := filepath.Join(dir, "existing-directory") + if err := os.WriteFile(input, []byte("core module"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(output, 0o755); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\ncp \"$3\" \"$5\"\n" + tool := writeWasmOptTestTool(t, dir, script) + t.Setenv("WASMOPT", tool) + + ctx := wasmPostLinkTestContext() + err := postLinkWasm(ctx, input, output, false) + if err == nil { + t.Fatal("postLinkWasm succeeded when the final output was a directory") + } + if strings.Contains(err.Error(), "wasm-opt Asyncify failed") { + t.Fatalf("postLinkWasm failed before publishing output: %v", err) + } +} + func TestPostLinkWasmReportsMissingTool(t *testing.T) { t.Setenv("WASMOPT", filepath.Join(t.TempDir(), "missing-wasm-opt")) - ctx := &context{ - buildConf: &Config{}, - crossCompile: crosscompile.Export{ - WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}, - }, - } + ctx := wasmPostLinkTestContext() err := postLinkWasm(ctx, "input", filepath.Join(t.TempDir(), "output"), false) if err == nil || !strings.Contains(err.Error(), "install Binaryen or set WASMOPT") { t.Fatalf("postLinkWasm() error = %v", err) } } + +func TestPostLinkWasmReportsInvalidOutputDirectory(t *testing.T) { + dir := t.TempDir() + tool := writeWasmOptTestTool(t, dir, "") + t.Setenv("WASMOPT", tool) + ctx := wasmPostLinkTestContext() + err := postLinkWasm(ctx, "input", filepath.Join(dir, "missing", "output"), false) + if err == nil { + t.Fatal("postLinkWasm succeeded with a missing output directory") + } +} diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index 0b3d7385f7..f811bf3e9b 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -205,6 +205,19 @@ func TestUseWASIThreadsImportsMemory(t *testing.T) { } } +func TestUseWASILTOEnablesSjLjAtLink(t *testing.T) { + if testing.Short() { + t.Skip("requires WASI SDK") + } + export, err := use("wasip1", "wasm", false, false, optlevel.O2, lto.Thin, false) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(export.LDFLAGS, "-Wl,--mllvm=-wasm-enable-sjlj") { + t.Fatalf("LDFLAGS do not enable Wasm SjLj for LTO: %v", export.LDFLAGS) + } +} + func TestUseJSSupportsNode(t *testing.T) { export, err := use("js", "wasm", false, false, optlevel.Oz, lto.Off, false) if err != nil { From 95fb9a74afe72ba37cca5cf2f550ad4340902771 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 03:04:59 +0800 Subject: [PATCH 19/20] ci: allow wasm runtime macOS tests to finish --- .github/workflows/llgo.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index c00ac9bee8..d778a8db19 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -180,7 +180,9 @@ jobs: test: name: test (${{ matrix.lane }}, ${{ matrix.os }}, LLVM ${{ matrix.llvm }}, Go ${{ matrix.go }}, shard ${{ matrix.shard }}) continue-on-error: ${{ matrix.lane == 'compatibility' }} - timeout-minutes: 30 + # macOS runs the full package set in one shard and is normally just over + # 30 minutes once the wasm runtime sources are present. + timeout-minutes: 40 strategy: matrix: # Keep compatibility and primary toolchains pinned to exact patches. From 93eb05ed6882e2aafad5613bc6d726068555e752 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 23:36:51 +0800 Subject: [PATCH 20/20] ssa/wasm: use static defer continuation dispatch --- ssa/eh.go | 32 +++++++++++++++++++++++++++++--- ssa/eh_defer_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/ssa/eh.go b/ssa/eh.go index b8ead4eb64..989371c75c 100644 --- a/ssa/eh.go +++ b/ssa/eh.go @@ -548,15 +548,41 @@ func (b Builder) RunDefers() { return } blk := b.Func.MakeBlock() + next := len(self.rundsNext) self.rundsNext = append(self.rundsNext, blk) - b.Store(self.rundPtr, blk.Addr()) + b.storeRunDefersTarget(self.rundPtr, next, blk) b.Jump(self.procBlk) b.SetBlockEx(blk, AtEnd, false) b.blk.last = blk.last } +func (b Builder) storeRunDefersTarget(ptr Expr, index int, target BasicBlock) { + value := target.Addr() + if b.Prog.target.GOARCH == "wasm" { + value = b.PtrCast(b.Prog.VoidPtr(), b.Prog.Val(uintptr(index))) + } + b.Store(ptr, value) +} + +func (b Builder) jumpRunDefersTarget(ptr Expr, targets []BasicBlock) { + target := b.Load(ptr) + if b.Prog.target.GOARCH != "wasm" { + b.IndirectJump(target, targets) + return + } + + index := b.Convert(b.Prog.Uintptr(), target) + invalid := b.Func.MakeBlock() + sw := b.impl.CreateSwitch(index.impl, invalid.first, len(targets)) + for i, target := range targets { + sw.AddCase(b.Prog.Val(uintptr(i)).impl, target.first) + } + b.SetBlockEx(invalid, AtEnd, false) + b.Unreachable() +} + func (p Function) endDefer(b Builder) { self := p.defer_ if self == nil { @@ -593,10 +619,10 @@ func (p Function) endDefer(b Builder) { } link := b.getField(b.Load(self.data), deferLink) b.Call(b.Pkg.rtFunc("SetThreadDefer"), link) - b.IndirectJump(b.Load(rundPtr), nexts) + b.jumpRunDefersTarget(rundPtr, nexts) b.SetBlockEx(panicBlk, AtEnd, false) // panicBlk: exec runDefers and rethrow - b.Store(rundPtr, rethrowBlk.Addr()) + b.storeRunDefersTarget(rundPtr, 0, rethrowBlk) b.IndirectJump(b.Load(rethPtr), rethsNext) } diff --git a/ssa/eh_defer_test.go b/ssa/eh_defer_test.go index 5f99729b1e..764134695c 100644 --- a/ssa/eh_defer_test.go +++ b/ssa/eh_defer_test.go @@ -156,3 +156,31 @@ func TestConditionalDeferIR(t *testing.T) { t.Fatalf("expected conditional defer bitmask operations in IR, got:\n%s", ir) } } + +func TestWasmRunDefersUsesStaticDispatch(t *testing.T) { + prog := ssatest.NewProgram(t, nil) + prog.Target().GOOS = "js" + prog.Target().GOARCH = "wasm" + pkg := prog.NewPackage("foo", "foo") + + callee := pkg.NewFunc("callee", ssa.NoArgsNoRet, ssa.InGo) + cb := callee.MakeBody(1) + cb.Return() + cb.EndBuild() + + fn := pkg.NewFunc("main", ssa.NoArgsNoRet, ssa.InGo) + b := fn.MakeBody(1) + fn.SetRecover(fn.MakeBlock()) + b.Defer(ssa.DeferAlways, callee.Expr, ssa.Builder.Call) + b.RunDefers() + b.Return() + b.EndBuild() + + ir := pkg.Module().String() + if !strings.Contains(ir, "switch i64") { + t.Fatalf("expected wasm RunDefers selector dispatch in IR, got:\n%s", ir) + } + if got := strings.Count(ir, "indirectbr"); got != 1 { + t.Fatalf("got %d indirect branches, want only the rethrow dispatch:\n%s", got, ir) + } +}