From 65d1b25519b1f772c0f0e03cb307d1ae2ed7d51c Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 12:42:46 +0800 Subject: [PATCH 01/40] 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 | 14 +- 18 files changed, 668 insertions(+), 129 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 c2f80c4136..4d9a4c8dc4 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -727,11 +727,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 401f8aaa17..71823fa552 100644 --- a/runtime/internal/runtime/z_default.go +++ b/runtime/internal/runtime/z_default.go @@ -28,19 +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) - } - leaveCurrentLocalContext() - exitCurrentM() + goexitBackend(gp) } } From 822b4da47cf2224b86f5c5db3de62254d3eef84a Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 12:42:53 +0800 Subject: [PATCH 02/40] 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 ce6886bfe8..f444a71d90 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -430,4 +430,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 cf3054c1e9..7c2b6e35ff 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -109,7 +109,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 46f9a1d7baa8b4cce4419e911789c3bcee1b990c Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 13:10:27 +0800 Subject: [PATCH 03/40] 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 | 24 ++++++++++++ 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, 259 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 f444a71d90..cd6eeee81d 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -412,6 +412,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 @@ -430,6 +435,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 49ce34aa6c..d08d96bd7d 100644 --- a/.github/workflows/targets.yml +++ b/.github/workflows/targets.yml @@ -27,6 +27,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 4d9a4c8dc4..48d24e0d29 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -298,9 +298,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 } @@ -339,6 +336,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 } @@ -418,10 +418,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() var syntaxErr error @@ -734,6 +731,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 7c2b6e35ff..5aa00971a8 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -119,6 +119,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 a4654cc752..84d214f759 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 38085a627b..179dee909f 100644 --- a/ssa/ssa_test.go +++ b/ssa/ssa_test.go @@ -2713,6 +2713,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 a4957d4425da914bdbd1a7088645f7593ddf3430 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 15:59:33 +0800 Subject: [PATCH 04/40] 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 0b99f6f54126dec7cde68454b71609087885847d Mon Sep 17 00:00:00 2001 From: Li Jie Date: Mon, 27 Jul 2026 23:57:30 +0800 Subject: [PATCH 05/40] 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 cd6eeee81d..f347e2e8b8 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -433,10 +433,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 48a896d1a2076ddc5566a18bd4bcf0c0c9323ab2 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 02:33:38 +0800 Subject: [PATCH 06/40] 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 48d24e0d29..d07c68b8d1 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -1362,11 +1362,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 } @@ -2363,7 +2377,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 cb0c2a418b..0002227fb2 100644 --- a/internal/build/main_module.go +++ b/internal/build/main_module.go @@ -127,10 +127,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, @@ -225,6 +231,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 @@ -272,8 +279,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) } @@ -284,6 +295,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 a2ff09abb3bc41fcadd7af6cd74d3bb0f3bdc869 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 02:33:43 +0800 Subject: [PATCH 07/40] 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 f347e2e8b8..89fe52dab9 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -364,6 +364,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 @@ -417,6 +420,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 @@ -444,10 +460,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 5aa00971a8..8324e6618e 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -860,6 +860,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 4e0b16c907..4f42e3e0cc 100644 --- a/internal/build/main_module_test.go +++ b/internal/build/main_module_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/goplus/llgo/internal/crosscompile" "github.com/xgo-dev/llvm" "github.com/goplus/llgo/internal/packages" @@ -57,6 +58,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 a9df87310201000fe93767cfb2c5985a25f7ab0b Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 02:40:38 +0800 Subject: [PATCH 08/40] 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 0002227fb2..4b6a87e150 100644 --- a/internal/build/main_module.go +++ b/internal/build/main_module.go @@ -88,7 +88,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 4f42e3e0cc..b577b12cdd 100644 --- a/internal/build/main_module_test.go +++ b/internal/build/main_module_test.go @@ -73,10 +73,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 e7b44d9c087a45f7a51a82c255984e74974c95e1 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 02:46:06 +0800 Subject: [PATCH 09/40] 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 62429be59d..570d1e0260 100644 --- a/.github/workflows/build-cache.yml +++ b/.github/workflows/build-cache.yml @@ -33,6 +33,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 874c18e7b4c3f16090b32b20f5b0f9d8c1108263 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 04:00:08 +0800 Subject: [PATCH 10/40] 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 5b3a49ddedd4e37b547e6152118d4d3a8acfb3fc Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 01:05:32 +0800 Subject: [PATCH 11/40] 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 d07c68b8d1..2c66f635b8 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -1362,26 +1362,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 e06a7b40e485178259f74c18a2013db8d31adb09 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 28 Jul 2026 23:36:51 +0800 Subject: [PATCH 12/40] 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) + } +} From 4abf7ed1302c86a36192c7b53051a41f7d7c3b02 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 06:59:04 +0800 Subject: [PATCH 13/40] runtime/wasm: isolate scheduler state from native Gs --- runtime/internal/runtime/fatal_default.go | 7 +++++++ runtime/internal/runtime/fatal_wasm.go | 10 ++++++++++ runtime/internal/runtime/proc_wasm.go | 18 ++++++++++++++++++ runtime/internal/runtime/runtime2.go | 23 ----------------------- runtime/internal/runtime/stubs.go | 6 ------ 5 files changed, 35 insertions(+), 29 deletions(-) create mode 100644 runtime/internal/runtime/fatal_default.go create mode 100644 runtime/internal/runtime/fatal_wasm.go diff --git a/runtime/internal/runtime/fatal_default.go b/runtime/internal/runtime/fatal_default.go new file mode 100644 index 0000000000..0046a9ec36 --- /dev/null +++ b/runtime/internal/runtime/fatal_default.go @@ -0,0 +1,7 @@ +//go:build !llgo || !js || !wasm + +package runtime + +func fatal(s string) { + print("fatal error: ", s, "\n") +} diff --git a/runtime/internal/runtime/fatal_wasm.go b/runtime/internal/runtime/fatal_wasm.go new file mode 100644 index 0000000000..3482947aab --- /dev/null +++ b/runtime/internal/runtime/fatal_wasm.go @@ -0,0 +1,10 @@ +//go:build llgo && js && wasm + +package runtime + +import c "github.com/goplus/llgo/runtime/internal/clite" + +func fatal(s string) { + print("fatal error: ", s, "\n") + c.Exit(2) +} diff --git a/runtime/internal/runtime/proc_wasm.go b/runtime/internal/runtime/proc_wasm.go index fc247d743d..bdcd5e7dda 100644 --- a/runtime/internal/runtime/proc_wasm.go +++ b/runtime/internal/runtime/proc_wasm.go @@ -34,6 +34,8 @@ type runtimeContextPlatform struct { context wasmcontext.Context stack unsafe.Pointer asyncifyStack unsafe.Pointer + runqNext *g + runqQueued bool } var wasmSched struct { @@ -44,6 +46,22 @@ var wasmSched struct { started bool } +func (gp *g) RunqueueNext() *g { + return gp.context.platform.runqNext +} + +func (gp *g) SetRunqueueNext(next *g) { + gp.context.platform.runqNext = next +} + +func (gp *g) RunqueueQueued() bool { + return gp.context.platform.runqQueued +} + +func (gp *g) SetRunqueueQueued(queued bool) { + gp.context.platform.runqQueued = queued +} + func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { gp := initG(ctx, callergp, status) if status == _Grunning { diff --git a/runtime/internal/runtime/runtime2.go b/runtime/internal/runtime/runtime2.go index e617ca3cef..584ca37f3a 100644 --- a/runtime/internal/runtime/runtime2.go +++ b/runtime/internal/runtime/runtime2.go @@ -55,29 +55,6 @@ 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. diff --git a/runtime/internal/runtime/stubs.go b/runtime/internal/runtime/stubs.go index 9c164d3ffb..6e50b2518d 100644 --- a/runtime/internal/runtime/stubs.go +++ b/runtime/internal/runtime/stubs.go @@ -7,7 +7,6 @@ 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" @@ -117,11 +116,6 @@ func memclrHasPointers(ptr unsafe.Pointer, n uintptr) { func memclrNoHeapPointers(ptr unsafe.Pointer, n uintptr) { } -func fatal(s string) { - print("fatal error: ", s, "\n") - c.Exit(2) -} - func throw(s string) { print("fatal error: ", s, "\n") } From 728ca07490037ad096018e66a69f26a30bfa3f60 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 07:39:55 +0800 Subject: [PATCH 14/40] runtime/wasm: encapsulate continuation storage --- runtime/internal/runtime/fatal_default.go | 2 +- runtime/internal/runtime/fatal_wasm.go | 2 +- runtime/internal/runtime/proc_wasip1.go | 53 ++------- runtime/internal/runtime/proc_wasm.go | 81 +++---------- runtime/internal/runtime/runqueue_wasm.go | 19 ++++ runtime/internal/wasmcontext/context_js.go | 34 +++++- .../internal/wasmcontext/context_wasip1.go | 20 +++- runtime/internal/wasmcontext/doc.go | 5 +- runtime/internal/wasmcontext/storage.go | 61 ++++++++++ runtime/internal/wasmcontext/storage_test.go | 106 ++++++++++++++++++ 10 files changed, 260 insertions(+), 123 deletions(-) create mode 100644 runtime/internal/runtime/runqueue_wasm.go create mode 100644 runtime/internal/wasmcontext/storage.go create mode 100644 runtime/internal/wasmcontext/storage_test.go diff --git a/runtime/internal/runtime/fatal_default.go b/runtime/internal/runtime/fatal_default.go index 0046a9ec36..1ef2c713a5 100644 --- a/runtime/internal/runtime/fatal_default.go +++ b/runtime/internal/runtime/fatal_default.go @@ -1,4 +1,4 @@ -//go:build !llgo || !js || !wasm +//go:build !llgo || !wasm || (!js && !wasip1) package runtime diff --git a/runtime/internal/runtime/fatal_wasm.go b/runtime/internal/runtime/fatal_wasm.go index 3482947aab..ae71593fd7 100644 --- a/runtime/internal/runtime/fatal_wasm.go +++ b/runtime/internal/runtime/fatal_wasm.go @@ -1,4 +1,4 @@ -//go:build llgo && js && wasm +//go:build llgo && wasm && (js || wasip1) package runtime diff --git a/runtime/internal/runtime/proc_wasip1.go b/runtime/internal/runtime/proc_wasip1.go index 513caab15e..cfa88861e4 100644 --- a/runtime/internal/runtime/proc_wasip1.go +++ b/runtime/internal/runtime/proc_wasip1.go @@ -25,15 +25,10 @@ import ( "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 + context wasmcontext.Context + runqNext *g + runqQueued bool } var wasmSched struct { @@ -136,39 +131,15 @@ func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, cal } 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( + if !gp.context.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 { + AllocRoot, + FreeRoot, + ) { panic("runtime: failed to allocate WebAssembly goroutine stack") } - return stack } func releaseWasmContext(gp *g) { @@ -176,15 +147,7 @@ func releaseWasmContext(gp *g) { 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 - } + ctx.platform.context.Close(FreeRoot) freeRuntimeContext(ctx) } diff --git a/runtime/internal/runtime/proc_wasm.go b/runtime/internal/runtime/proc_wasm.go index bdcd5e7dda..d0f07791d3 100644 --- a/runtime/internal/runtime/proc_wasm.go +++ b/runtime/internal/runtime/proc_wasm.go @@ -25,17 +25,10 @@ import ( "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 - runqNext *g - runqQueued bool + context wasmcontext.Context + runqNext *g + runqQueued bool } var wasmSched struct { @@ -46,22 +39,6 @@ var wasmSched struct { started bool } -func (gp *g) RunqueueNext() *g { - return gp.context.platform.runqNext -} - -func (gp *g) SetRunqueueNext(next *g) { - gp.context.platform.runqNext = next -} - -func (gp *g) RunqueueQueued() bool { - return gp.context.platform.runqQueued -} - -func (gp *g) SetRunqueueQueued(queued bool) { - gp.context.platform.runqQueued = queued -} - func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { gp := initG(ctx, callergp, status) if status == _Grunning { @@ -96,48 +73,26 @@ func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, stackSize uintptr, cal } 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.context.Init( + if !platform.context.Init( wasmcontext.Entry(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 { + AllocRoot, + FreeRoot, + ) { panic("runtime: failed to allocate WebAssembly goroutine stack") } - return stack } func ensureCurrentWasmFiber(gp *g) { - platform := &gp.context.platform - if platform.asyncifyStack != nil { + context := &gp.context.platform.context + if context.Ready() { return } - platform.asyncifyStack = allocWasmStack(defaultWasmAsyncifyStackSize) - platform.context.InitCurrent(platform.asyncifyStack, defaultWasmAsyncifyStackSize) + if !context.InitCurrent(AllocRoot) { + panic("runtime: failed to allocate WebAssembly goroutine stack") + } } func wasmGStart(arg unsafe.Pointer) { @@ -197,7 +152,7 @@ func resumeWasmG(old, next *g) { return } ensureCurrentWasmFiber(old) - if next.context.platform.asyncifyStack == nil { + if !next.context.platform.context.Ready() { fatal("runtime: uninitialized WebAssembly goroutine context") return } @@ -251,15 +206,7 @@ func reapRetiredWasmG() { 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 - } + ctx.platform.context.Close(FreeRoot) freeRuntimeContext(ctx) } diff --git a/runtime/internal/runtime/runqueue_wasm.go b/runtime/internal/runtime/runqueue_wasm.go new file mode 100644 index 0000000000..c5e5e0e7f4 --- /dev/null +++ b/runtime/internal/runtime/runqueue_wasm.go @@ -0,0 +1,19 @@ +//go:build llgo && wasm && (js || (wasip1 && !llgo.wasi_threads)) + +package runtime + +func (gp *g) RunqueueNext() *g { + return gp.context.platform.runqNext +} + +func (gp *g) SetRunqueueNext(next *g) { + gp.context.platform.runqNext = next +} + +func (gp *g) RunqueueQueued() bool { + return gp.context.platform.runqQueued +} + +func (gp *g) SetRunqueueQueued(queued bool) { + gp.context.platform.runqQueued = queued +} diff --git a/runtime/internal/wasmcontext/context_js.go b/runtime/internal/wasmcontext/context_js.go index 4550c38320..56e267c420 100644 --- a/runtime/internal/wasmcontext/context_js.go +++ b/runtime/internal/wasmcontext/context_js.go @@ -28,10 +28,18 @@ type Entry = emscripten.FiberEntry // Context wraps the Emscripten Fiber ABI used by JavaScript hosts. type Context struct { - fiber emscripten.Fiber + fiber emscripten.Fiber + stack unsafe.Pointer + asyncifyStack unsafe.Pointer } -func (ctx *Context) Init(entry Entry, arg, stack unsafe.Pointer, stackSize uintptr, asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { +func (ctx *Context) Init(entry Entry, arg unsafe.Pointer, stackSize uintptr, alloc func(uintptr) unsafe.Pointer, free func(unsafe.Pointer)) bool { + stack, stackSize, asyncifyStack, asyncifySize, ok := allocStorage(stackSize, alloc, free) + if !ok { + return false + } + ctx.stack = stack + ctx.asyncifyStack = asyncifyStack emscripten.FiberInit( &ctx.fiber, entry, @@ -39,12 +47,28 @@ func (ctx *Context) Init(entry Entry, arg, stack unsafe.Pointer, stackSize uintp stack, stackSize, asyncifyStack, - asyncifyStackSize, + asyncifySize, ) + return true } -func (ctx *Context) InitCurrent(asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { - emscripten.FiberInitCurrent(&ctx.fiber, asyncifyStack, asyncifyStackSize) +func (ctx *Context) InitCurrent(alloc func(uintptr) unsafe.Pointer) bool { + asyncifyStack := alloc(defaultAsyncifyStackSize) + if asyncifyStack == nil { + return false + } + ctx.asyncifyStack = asyncifyStack + emscripten.FiberInitCurrent(&ctx.fiber, asyncifyStack, defaultAsyncifyStackSize) + return true +} + +func (ctx *Context) Ready() bool { + return ctx.asyncifyStack != nil +} + +func (ctx *Context) Close(free func(unsafe.Pointer)) { + freeStorage(ctx.stack, ctx.asyncifyStack, free) + *ctx = Context{} } func (ctx *Context) Swap(next *Context) { diff --git a/runtime/internal/wasmcontext/context_wasip1.go b/runtime/internal/wasmcontext/context_wasip1.go index 63177044cc..837b42981b 100644 --- a/runtime/internal/wasmcontext/context_wasip1.go +++ b/runtime/internal/wasmcontext/context_wasip1.go @@ -36,15 +36,31 @@ type Context struct { asyncifyEnd unsafe.Pointer stackPointer unsafe.Pointer launched bool + stack unsafe.Pointer } -func (ctx *Context) Init(entry Entry, arg, stack unsafe.Pointer, stackSize uintptr, asyncifyStack unsafe.Pointer, asyncifyStackSize uintptr) { +func (ctx *Context) Init(entry Entry, arg unsafe.Pointer, stackSize uintptr, alloc func(uintptr) unsafe.Pointer, free func(unsafe.Pointer)) bool { + stack, stackSize, asyncifyStack, asyncifySize, ok := allocStorage(stackSize, alloc, free) + if !ok { + return false + } ctx.entry = c.Func(entry) ctx.arg = arg ctx.asyncifyStack = asyncifyStack - ctx.asyncifyEnd = unsafe.Add(asyncifyStack, asyncifyStackSize) + ctx.asyncifyEnd = unsafe.Add(asyncifyStack, asyncifySize) ctx.stackPointer = unsafe.Add(stack, stackSize) ctx.launched = false + ctx.stack = stack + return true +} + +func (ctx *Context) Ready() bool { + return ctx.asyncifyStack != nil +} + +func (ctx *Context) Close(free func(unsafe.Pointer)) { + freeStorage(ctx.stack, ctx.asyncifyStack, free) + *ctx = Context{} } func (ctx *Context) Resume() { diff --git a/runtime/internal/wasmcontext/doc.go b/runtime/internal/wasmcontext/doc.go index 688f6da9b7..1f0446f7b0 100644 --- a/runtime/internal/wasmcontext/doc.go +++ b/runtime/internal/wasmcontext/doc.go @@ -14,6 +14,7 @@ * limitations under the License. */ -// Package wasmcontext provides suspended execution contexts for WebAssembly -// runtime schedulers. +// Package wasmcontext owns suspended WebAssembly execution contexts and their +// backend-specific storage. Runtime schedulers provide root-aware allocation +// callbacks during context creation and do not inspect the resulting buffers. package wasmcontext diff --git a/runtime/internal/wasmcontext/storage.go b/runtime/internal/wasmcontext/storage.go new file mode 100644 index 0000000000..6b5010dbf0 --- /dev/null +++ b/runtime/internal/wasmcontext/storage.go @@ -0,0 +1,61 @@ +/* + * 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" + +const ( + defaultStackSize = uintptr(64 << 10) + defaultAsyncifyStackSize = uintptr(64 << 10) + stackAlignment = uintptr(16) +) + +func allocStorage(stackSize uintptr, alloc func(uintptr) unsafe.Pointer, free func(unsafe.Pointer)) (stack unsafe.Pointer, normalizedStackSize uintptr, asyncifyStack unsafe.Pointer, asyncifySize uintptr, ok bool) { + if stackSize == 0 { + stackSize = defaultStackSize + } + stackSize = alignStackSize(stackSize) + asyncifySize = defaultAsyncifyStackSize + if stackSize > asyncifySize { + asyncifySize = stackSize + } + + stack = alloc(stackSize) + if stack == nil { + return + } + asyncifyStack = alloc(asyncifySize) + if asyncifyStack == nil { + free(stack) + stack = nil + return + } + return stack, stackSize, asyncifyStack, asyncifySize, true +} + +func freeStorage(stack, asyncifyStack unsafe.Pointer, free func(unsafe.Pointer)) { + if stack != nil { + free(stack) + } + if asyncifyStack != nil { + free(asyncifyStack) + } +} + +func alignStackSize(size uintptr) uintptr { + return (size + stackAlignment - 1) &^ (stackAlignment - 1) +} diff --git a/runtime/internal/wasmcontext/storage_test.go b/runtime/internal/wasmcontext/storage_test.go new file mode 100644 index 0000000000..f938bb0574 --- /dev/null +++ b/runtime/internal/wasmcontext/storage_test.go @@ -0,0 +1,106 @@ +/* + * 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 ( + "testing" + "unsafe" +) + +func TestStorageLifecycle(t *testing.T) { + var allocated []uintptr + var freed []unsafe.Pointer + buffers := make([][]byte, 0, 2) + alloc := func(size uintptr) unsafe.Pointer { + allocated = append(allocated, size) + buf := make([]byte, size) + buffers = append(buffers, buf) + return unsafe.Pointer(&buf[0]) + } + free := func(ptr unsafe.Pointer) { + freed = append(freed, ptr) + } + + stack, stackSize, asyncify, asyncifySize, ok := allocStorage(defaultStackSize+1, alloc, free) + if !ok { + t.Fatal("init failed") + } + wantSize := defaultStackSize + stackAlignment + if len(allocated) != 2 || allocated[0] != wantSize || allocated[1] != wantSize { + t.Fatalf("allocated sizes = %v, want [%d %d]", allocated, wantSize, wantSize) + } + if stackSize != wantSize || asyncifySize != wantSize { + t.Fatalf("returned sizes = %d/%d, want %d/%d", stackSize, asyncifySize, wantSize, wantSize) + } + + freeStorage(stack, asyncify, free) + if len(freed) != 2 || freed[0] != stack || freed[1] != asyncify { + t.Fatalf("freed pointers = %v, want [%p %p]", freed, stack, asyncify) + } +} + +func TestStorageInitFailure(t *testing.T) { + buf := make([]byte, defaultStackSize) + stack := unsafe.Pointer(&buf[0]) + for _, failAt := range []int{1, 2} { + allocations := 0 + alloc := func(uintptr) unsafe.Pointer { + allocations++ + if allocations == failAt { + return nil + } + return stack + } + var freed unsafe.Pointer + + stackResult, _, asyncifyResult, _, ok := allocStorage(0, alloc, func(ptr unsafe.Pointer) { freed = ptr }) + if ok { + t.Fatalf("allocation %d failure succeeded", failAt) + } + wantFreed := unsafe.Pointer(nil) + if failAt == 2 { + wantFreed = stack + } + if freed != wantFreed { + t.Fatalf("allocation %d freed pointer = %p, want %p", failAt, freed, wantFreed) + } + if stackResult != nil || asyncifyResult != nil { + t.Fatalf("allocation %d failure returned storage", failAt) + } + } +} + +func TestStorageDefaultSize(t *testing.T) { + var sizes []uintptr + buffers := make([][]byte, 0, 2) + stack, stackSize, asyncify, asyncifySize, ok := allocStorage(0, func(size uintptr) unsafe.Pointer { + sizes = append(sizes, size) + buf := make([]byte, size) + buffers = append(buffers, buf) + return unsafe.Pointer(&buf[0]) + }, func(unsafe.Pointer) {}) + if !ok { + t.Fatal("init failed") + } + if stackSize != defaultStackSize || asyncifySize != defaultAsyncifyStackSize { + t.Fatalf("default sizes = %d/%d", stackSize, asyncifySize) + } + if len(sizes) != 2 || sizes[0] != defaultStackSize || sizes[1] != defaultAsyncifyStackSize { + t.Fatalf("requested sizes = %v", sizes) + } + freeStorage(stack, asyncify, func(unsafe.Pointer) {}) +} From 951475bd301f98e734974d907a9c1e6a781b2698 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 10:08:44 +0800 Subject: [PATCH 15/40] runtime/wasm: preserve explicit WASI thread fatal behavior --- runtime/internal/runtime/fatal_default.go | 2 +- runtime/internal/runtime/fatal_wasm.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/runtime/internal/runtime/fatal_default.go b/runtime/internal/runtime/fatal_default.go index 1ef2c713a5..32b9c9dc96 100644 --- a/runtime/internal/runtime/fatal_default.go +++ b/runtime/internal/runtime/fatal_default.go @@ -1,4 +1,4 @@ -//go:build !llgo || !wasm || (!js && !wasip1) +//go:build !llgo || !wasm || (!js && !wasip1) || (wasip1 && llgo.wasi_threads) package runtime diff --git a/runtime/internal/runtime/fatal_wasm.go b/runtime/internal/runtime/fatal_wasm.go index ae71593fd7..1455d0ce08 100644 --- a/runtime/internal/runtime/fatal_wasm.go +++ b/runtime/internal/runtime/fatal_wasm.go @@ -1,4 +1,4 @@ -//go:build llgo && wasm && (js || wasip1) +//go:build llgo && wasm && (js || (wasip1 && !llgo.wasi_threads)) package runtime From 247ca99d6bb4eb4c15ef7d5b36db716e52a32f71 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 14:31:08 +0800 Subject: [PATCH 16/40] runtime/wasm: define resumable frame dispatch contract --- runtime/internal/wasmresume/resume.go | 86 +++++++++++ runtime/internal/wasmresume/resume_test.go | 157 +++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 runtime/internal/wasmresume/resume.go create mode 100644 runtime/internal/wasmresume/resume_test.go diff --git a/runtime/internal/wasmresume/resume.go b/runtime/internal/wasmresume/resume.go new file mode 100644 index 0000000000..abdd888c96 --- /dev/null +++ b/runtime/internal/wasmresume/resume.go @@ -0,0 +1,86 @@ +/* + * 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 wasmresume defines the runtime half of LLGo's experimental +// WebAssembly resumable call ABI. +package wasmresume + +// Action tells Context what a resume entry did. +type Action uint8 + +const ( + // Continue means that execution can continue immediately. The resume entry + // may have pushed a child frame or advanced within the current frame. + Continue Action = iota + + // Return means that the current frame completed normally. + Return + + // Suspend returns control to the scheduler without changing the frame chain. + Suspend +) + +// Resume is the common indirect-call signature for generated resume entries. +type Resume func(*Context, *Frame) Action + +// Descriptor contains immutable state shared by every invocation of a +// generated function. +type Descriptor struct { + Resume Resume +} + +// Frame is the common prefix of every generated function frame. Generated +// frame types must embed Frame as their first field. +type Frame struct { + Parent *Frame + Descriptor *Descriptor + PC uint32 +} + +// Context owns the active frame chain for one logical goroutine. +type Context struct { + top *Frame +} + +// Top returns the active frame. +func (c *Context) Top() *Frame { + return c.top +} + +// Push links frame as the active child of the current frame. +func (c *Context) Push(frame *Frame, descriptor *Descriptor) { + frame.Parent = c.top + frame.Descriptor = descriptor + frame.PC = 0 + c.top = frame +} + +// Run resumes the active frame chain until it completes or suspends. +func (c *Context) Run() Action { + for c.top != nil { + frame := c.top + switch frame.Descriptor.Resume(c, frame) { + case Continue: + case Return: + c.top = frame.Parent + case Suspend: + return Suspend + default: + panic("wasmresume: invalid resume action") + } + } + return Return +} diff --git a/runtime/internal/wasmresume/resume_test.go b/runtime/internal/wasmresume/resume_test.go new file mode 100644 index 0000000000..43402ef7c5 --- /dev/null +++ b/runtime/internal/wasmresume/resume_test.go @@ -0,0 +1,157 @@ +package wasmresume + +import ( + "testing" + "unsafe" +) + +type testRootFrame struct { + Frame + direct testLeafFrame + indirect testLeafFrame + value int +} + +type testLeafFrame struct { + Frame + value int +} + +var ( + testRootDescriptor = Descriptor{Resume: resumeTestRoot} + testAddDescriptor = Descriptor{Resume: resumeTestAdd} + testMulDescriptor = Descriptor{Resume: resumeTestMul} +) + +func resumeTestRoot(ctx *Context, raw *Frame) Action { + frame := (*testRootFrame)(unsafe.Pointer(raw)) + switch frame.PC { + case 0: + frame.PC = 1 + frame.direct.value = 4 + ctx.Push(&frame.direct.Frame, &testAddDescriptor) + return Continue + case 1: + frame.value = frame.direct.value + frame.PC = 2 + frame.indirect.value = frame.value + descriptor := &testAddDescriptor + if frame.value == 7 { + descriptor = &testMulDescriptor + } + ctx.Push(&frame.indirect.Frame, descriptor) + return Continue + case 2: + frame.value = frame.indirect.value + return Return + default: + panic("unexpected root resume PC") + } +} + +func resumeTestAdd(_ *Context, raw *Frame) Action { + frame := (*testLeafFrame)(unsafe.Pointer(raw)) + frame.value += 3 + return Return +} + +func resumeTestMul(_ *Context, raw *Frame) Action { + frame := (*testLeafFrame)(unsafe.Pointer(raw)) + switch frame.PC { + case 0: + frame.PC = 1 + return Suspend + case 1: + frame.value *= 2 + return Return + default: + panic("unexpected leaf resume PC") + } +} + +func TestContextRunDirectAndIndirectCalls(t *testing.T) { + var ( + ctx Context + frame testRootFrame + ) + ctx.Push(&frame.Frame, &testRootDescriptor) + + if action := ctx.Run(); action != Suspend { + t.Fatalf("first Run action = %d, want Suspend", action) + } + if ctx.Top() != &frame.indirect.Frame { + t.Fatal("suspended child is not the active frame") + } + if frame.indirect.Parent != &frame.Frame { + t.Fatal("child frame is not linked to its caller") + } + + if action := ctx.Run(); action != Return { + t.Fatalf("second Run action = %d, want Return", action) + } + if ctx.Top() != nil { + t.Fatal("completed frame chain remains active") + } + if frame.value != 14 { + t.Fatalf("result = %d, want 14", frame.value) + } +} + +func TestContextRunEmpty(t *testing.T) { + var ctx Context + if action := ctx.Run(); action != Return { + t.Fatalf("empty Run action = %d, want Return", action) + } +} + +func TestContextPushInitializesHeader(t *testing.T) { + parent := Frame{} + child := Frame{Parent: &parent, Descriptor: &testMulDescriptor, PC: 9} + ctx := Context{top: &parent} + ctx.Push(&child, &testAddDescriptor) + if child.Parent != &parent { + t.Fatal("Push did not link the parent frame") + } + if child.Descriptor != &testAddDescriptor { + t.Fatal("Push did not set the descriptor") + } + if child.PC != 0 { + t.Fatalf("Push PC = %d, want 0", child.PC) + } +} + +func TestContextRejectsInvalidAction(t *testing.T) { + descriptor := Descriptor{Resume: func(*Context, *Frame) Action { + return Action(255) + }} + var ( + ctx Context + frame Frame + ) + ctx.Push(&frame, &descriptor) + defer func() { + if recover() == nil { + t.Fatal("Run accepted an invalid resume action") + } + }() + ctx.Run() +} + +func BenchmarkContextDispatch(b *testing.B) { + descriptor := Descriptor{Resume: func(_ *Context, frame *Frame) Action { + if frame.PC == 0 { + frame.PC = 1 + return Continue + } + return Return + }} + var ( + ctx Context + frame Frame + ) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + ctx.Push(&frame, &descriptor) + ctx.Run() + } +} From f3acf70acf047e15fa329ce1574db5c8d93c2f90 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 14:42:36 +0800 Subject: [PATCH 17/40] ssa/wasm: inventory resumable Go calls --- ssa/decl.go | 15 ++++--- ssa/expr.go | 2 + ssa/package.go | 1 + ssa/wasm_resume.go | 62 ++++++++++++++++++++++++++ ssa/wasm_resume_test.go | 99 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 ssa/wasm_resume.go create mode 100644 ssa/wasm_resume_test.go diff --git a/ssa/decl.go b/ssa/decl.go index a575dd1bee..7ea6f01efb 100644 --- a/ssa/decl.go +++ b/ssa/decl.go @@ -246,10 +246,11 @@ type aFunction struct { nextDeferID uintptr recov BasicBlock - params []Type - freeVars Expr - base int // base = 1 if hasFreeVars; base = 0 otherwise - hasVArg bool + params []Type + freeVars Expr + base int // base = 1 if hasFreeVars; base = 0 otherwise + hasVArg bool + background Background fakeUses []llvm.Value fakeUseSet map[llvm.Value]struct{} @@ -275,6 +276,7 @@ func (p Package) NewFuncEx(name string, sig *types.Signature, bg Background, has fn := llvm.AddFunction(p.mod, name, t.ll) if bg == InGo { fn.AddFunctionAttr(p.nullPointerIsValidAttr) + p.Prog.markWasmResumeFunction(fn) // Keep frame pointers so the runtime can walk real stacks (FP chain) // for Callers/panic tracebacks instead of shadow-stack bookkeeping. // Only where that unwinder exists: on embedded targets the attribute @@ -290,7 +292,7 @@ func (p Package) NewFuncEx(name string, sig *types.Signature, bg Background, has if p.isPreservedName(name) { p.markLLVMUsed(fn) } - ret := newFunction(fn, t, p, p.Prog, hasFreeVars) + ret := newFunction(fn, t, p, p.Prog, bg, hasFreeVars) p.fns[name] = ret return ret } @@ -300,7 +302,7 @@ func (p Package) FuncOf(name string) Function { return p.fns[name] } -func newFunction(fn llvm.Value, t Type, pkg Package, prog Program, hasFreeVars bool) Function { +func newFunction(fn llvm.Value, t Type, pkg Package, prog Program, bg Background, hasFreeVars bool) Function { params, hasVArg := newParams(t, prog) base := 0 if hasFreeVars { @@ -313,6 +315,7 @@ func newFunction(fn llvm.Value, t Type, pkg Package, prog Program, hasFreeVars b params: params, base: base, hasVArg: hasVArg, + background: bg, fakeUses: make([]llvm.Value, 0, 4), fakeUseSet: make(map[llvm.Value]struct{}), } diff --git a/ssa/expr.go b/ssa/expr.go index 6f476c9eac..cd351e0cae 100644 --- a/ssa/expr.go +++ b/ssa/expr.go @@ -1245,6 +1245,7 @@ func (b Builder) Call(fn Expr, args ...Expr) (ret Expr) { } ll = b.Prog.FuncDecl(sigCtx, InC).ll ret.impl = llvm.CreateCall(b.impl, ll, fn.impl, llvmParamsEx(data, args, sigCtx.Params(), b)) + b.markWasmResumeCall(ret.impl, InGo) return ret case vkFuncPtr: sig = raw.Underlying().(*types.Signature) @@ -1264,6 +1265,7 @@ func (b Builder) Call(fn Expr, args ...Expr) (ret Expr) { } ret.Type = b.Prog.retType(sig) ret.impl = llvm.CreateCall(b.impl, ll, fn.impl, llvmParamsEx(data, args, sig.Params(), b)) + b.markWasmResumeCall(ret.impl, b.directCallBackground(fn)) if reflectCheck.Kind&ReflectMethodByName != 0 && reflectCheck.Name == "" { nameArgIndex := len(args) - 1 if !data.IsNil() { diff --git a/ssa/package.go b/ssa/package.go index 64263ea9fc..a0344ca77f 100644 --- a/ssa/package.go +++ b/ssa/package.go @@ -247,6 +247,7 @@ type aProgram struct { enableFuncInfoMetadata bool enableFuncInfoSites bool + enableWasmResumeABI bool debugInfoOptimized bool } diff --git a/ssa/wasm_resume.go b/ssa/wasm_resume.go new file mode 100644 index 0000000000..c19b003a0c --- /dev/null +++ b/ssa/wasm_resume.go @@ -0,0 +1,62 @@ +/* + * 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 ssa + +import "github.com/xgo-dev/llvm" + +const ( + wasmResumeFunctionAttr = "llgo.wasm.resume" + wasmResumeCallMetadata = "llgo.wasm.resume.call" +) + +// EnableWasmResumeABI controls emission of the function and call inventory +// consumed by the experimental WebAssembly resumable ABI lowering. +func (p Program) EnableWasmResumeABI(enable bool) { + p.enableWasmResumeABI = enable +} + +// WasmResumeABIEnabled reports whether resumable ABI lowering is enabled for a +// WebAssembly target. +func (p Program) WasmResumeABIEnabled() bool { + return p.enableWasmResumeABI && p.target != nil && p.target.GOARCH == "wasm" +} + +func (p Program) markWasmResumeFunction(fn llvm.Value) { + if !p.WasmResumeABIEnabled() { + return + } + fn.AddFunctionAttr(p.ctx.CreateStringAttribute(wasmResumeFunctionAttr, "1")) +} + +func (b Builder) markWasmResumeCall(call llvm.Value, background Background) { + if background != InGo || !b.Prog.WasmResumeABIEnabled() { + return + } + kind := b.Prog.ctx.MDKindID(wasmResumeCallMetadata) + version := llvm.ConstInt(b.Prog.Int32().ll, 1, false).ConstantAsMetadata() + call.SetMetadata(kind, b.Prog.ctx.MDNode([]llvm.Metadata{version})) +} + +func (b Builder) directCallBackground(fn Expr) Background { + if fn.kind != vkFuncDecl { + return inUnknown + } + if decl := b.Pkg.FuncOf(fn.impl.Name()); decl != nil { + return decl.background + } + return inUnknown +} diff --git a/ssa/wasm_resume_test.go b/ssa/wasm_resume_test.go new file mode 100644 index 0000000000..fd148158cc --- /dev/null +++ b/ssa/wasm_resume_test.go @@ -0,0 +1,99 @@ +//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 ssa + +import ( + "strings" + "testing" +) + +func TestWasmResumeABIInventoriesGoCalls(t *testing.T) { + prog := NewProgram(&Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + + pkg := prog.NewPackage("p", "example.com/p") + goFn := pkg.NewFunc("goFn", NoArgsNoRet, InGo) + gb := goFn.MakeBody(1) + gb.Return() + + cFn := pkg.NewFunc("cFn", NoArgsNoRet, InC) + caller := pkg.NewFunc("caller", NoArgsNoRet, InGo) + b := caller.MakeBody(1) + b.Call(goFn.Expr) + b.Call(b.MakeClosure(goFn.Expr, nil)) + b.Call(cFn.Expr) + b.Return() + + ir := pkg.String() + if got := strings.Count(ir, "!"+wasmResumeCallMetadata); got != 2 { + t.Fatalf("resumable call marker count = %d, want 2:\n%s", got, ir) + } + if !strings.Contains(ir, `"`+wasmResumeFunctionAttr+`"="1"`) { + t.Fatalf("Go functions are not marked for resumable lowering:\n%s", ir) + } + var foundCCall bool + for _, line := range strings.Split(ir, "\n") { + if strings.Contains(line, "call void @cFn") && strings.Contains(line, wasmResumeCallMetadata) { + t.Fatalf("C call was marked resumable: %s", line) + } + if strings.Contains(line, "call void @cFn") { + foundCCall = true + } + } + if !foundCCall { + t.Fatalf("C call is missing from test IR:\n%s", ir) + } + if got := b.directCallBackground(Builtin("len")); got != inUnknown { + t.Fatalf("builtin call background = %d, want unknown", got) + } + delete(pkg.fns, cFn.Name()) + if got := b.directCallBackground(cFn.Expr); got != inUnknown { + t.Fatalf("untracked declaration background = %d, want unknown", got) + } +} + +func TestWasmResumeABIDoesNotChangeDefaultOrNativeIR(t *testing.T) { + tests := []struct { + name string + target *Target + enable bool + }{ + {name: "wasm disabled", target: &Target{GOOS: "wasip1", GOARCH: "wasm"}}, + {name: "native enabled", target: &Target{GOOS: "darwin", GOARCH: "arm64"}, enable: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + prog := NewProgram(test.target) + defer prog.Dispose() + prog.EnableWasmResumeABI(test.enable) + pkg := prog.NewPackage("p", "example.com/p") + callee := pkg.NewFunc("callee", NoArgsNoRet, InGo) + caller := pkg.NewFunc("caller", NoArgsNoRet, InGo) + b := caller.MakeBody(1) + b.Call(callee.Expr) + b.Return() + + ir := pkg.String() + if strings.Contains(ir, wasmResumeFunctionAttr) || strings.Contains(ir, wasmResumeCallMetadata) { + t.Fatalf("inactive resumable ABI changed IR:\n%s", ir) + } + }) + } +} From f46e8c994763cc26a4dc45e84527c08792ea3a60 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 15:03:52 +0800 Subject: [PATCH 18/40] internal/wasmresume: number generated Go calls --- internal/wasmresume/inventory.go | 123 +++++++++++++++++++++ internal/wasmresume/inventory_test.go | 152 ++++++++++++++++++++++++++ ssa/wasm_resume.go | 14 +-- ssa/wasm_resume_test.go | 10 +- 4 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 internal/wasmresume/inventory.go create mode 100644 internal/wasmresume/inventory_test.go diff --git a/internal/wasmresume/inventory.go b/internal/wasmresume/inventory.go new file mode 100644 index 0000000000..5215ee1906 --- /dev/null +++ b/internal/wasmresume/inventory.go @@ -0,0 +1,123 @@ +/* + * 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 wasmresume plans the compiler half of LLGo's experimental +// WebAssembly resumable call ABI. +package wasmresume + +import ( + "fmt" + + "github.com/xgo-dev/llvm" +) + +const ( + FunctionAttribute = "llgo.wasm.resume" + CallMetadata = "llgo.wasm.resume.call" + MarkerVersion = 1 + maxResumeID = 1<<16 - 1 +) + +// Function describes the resumable calls in one generated Go function. +type Function struct { + Name string + Calls []Call +} + +// Call describes one generated Go call and its in-function resume ID. +type Call struct { + ID uint32 + Callee string + Indirect bool +} + +// Inventory scans the actual LLVM calls produced by the frontend and assigns +// deterministic, function-local resume IDs. +func Inventory(mod llvm.Module) ([]Function, error) { + ctx := mod.Context() + kind := ctx.MDKindID(CallMetadata) + var functions []Function + for fn := mod.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { + markedFunction := hasFunctionMarker(fn) + var calls []Call + for block := fn.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + for instr := block.FirstInstruction(); !instr.IsNil(); instr = llvm.NextInstruction(instr) { + if !hasMetadata(instr, kind) { + continue + } + if instr.InstructionOpcode() != llvm.Call { + return nil, fmt.Errorf("%s: resumable marker is attached to a non-call instruction", fn.Name()) + } + if !markedFunction { + return nil, fmt.Errorf("%s: resumable call is in an unmarked function", fn.Name()) + } + if err := validateCallMarker(instr.Metadata(kind)); err != nil { + return nil, fmt.Errorf("%s: %w", fn.Name(), err) + } + if len(calls) == maxResumeID { + return nil, fmt.Errorf("%s: too many resumable calls", fn.Name()) + } + target := instr.CalledValue() + callee := "" + if !target.IsAFunction().IsNil() { + callee = target.Name() + } + call := Call{ + ID: uint32(len(calls) + 1), + Callee: callee, + Indirect: callee == "", + } + calls = append(calls, call) + setCallMarker(ctx, instr, kind, call.ID) + } + } + if markedFunction { + functions = append(functions, Function{Name: fn.Name(), Calls: calls}) + } + } + return functions, nil +} + +func hasFunctionMarker(fn llvm.Value) bool { + for _, attr := range fn.GetFunctionAttributes() { + if attr.IsString() && attr.GetStringKind() == FunctionAttribute { + return attr.GetStringValue() == "1" + } + } + return false +} + +func hasMetadata(instr llvm.Value, kind int) bool { + return instr.HasMetadata() && !instr.Metadata(kind).IsNil() +} + +func validateCallMarker(marker llvm.Value) error { + fields := marker.MDNodeOperands() + if len(fields) < 1 || len(fields) > 2 || fields[0].IsAConstantInt().IsNil() || + fields[0].ZExtValue() != MarkerVersion { + return fmt.Errorf("invalid resumable call marker") + } + return nil +} + +func setCallMarker(ctx llvm.Context, call llvm.Value, kind int, id uint32) { + i32 := ctx.Int32Type() + fields := []llvm.Metadata{ + llvm.ConstInt(i32, MarkerVersion, false).ConstantAsMetadata(), + llvm.ConstInt(i32, uint64(id), false).ConstantAsMetadata(), + } + call.SetMetadata(kind, ctx.MDNode(fields)) +} diff --git a/internal/wasmresume/inventory_test.go b/internal/wasmresume/inventory_test.go new file mode 100644 index 0000000000..30aebe7775 --- /dev/null +++ b/internal/wasmresume/inventory_test.go @@ -0,0 +1,152 @@ +package wasmresume + +import ( + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestInventoryNumbersDirectAndIndirectCalls(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("resume") + defer mod.Dispose() + + voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) + callee := llvm.AddFunction(mod, "callee", voidFn) + callerType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{callee.Type()}, false) + fn := llvm.AddFunction(mod, "caller", callerType) + fn.AddFunctionAttr(ctx.CreateStringAttribute(FunctionAttribute, "1")) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + + direct := builder.CreateCall(voidFn, callee, nil, "") + markCall(ctx, direct) + target := fn.Param(0) + indirect := builder.CreateCall(voidFn, target, nil, "") + markCall(ctx, indirect) + builder.CreateRetVoid() + + functions, err := Inventory(mod) + if err != nil { + t.Fatal(err) + } + if len(functions) != 1 || functions[0].Name != "caller" { + t.Fatalf("functions = %+v", functions) + } + calls := functions[0].Calls + if len(calls) != 2 { + t.Fatalf("calls = %+v", calls) + } + if calls[0].ID != 1 || calls[0].Callee != "callee" || calls[0].Indirect { + t.Fatalf("direct call = %+v", calls[0]) + } + if calls[1].ID != 2 || calls[1].Callee != "" || !calls[1].Indirect { + t.Fatalf("indirect call = %+v", calls[1]) + } + + ir := mod.String() + if !strings.Contains(ir, "!"+CallMetadata+" !0") || + !strings.Contains(ir, "!"+CallMetadata+" !1") || + !strings.Contains(ir, "!0 = !{i32 1, i32 1}") || + !strings.Contains(ir, "!1 = !{i32 1, i32 2}") { + t.Fatalf("resume IDs were not written to call metadata:\n%s", ir) + } +} + +func TestInventoryRejectsMarkerInUnmarkedFunction(t *testing.T) { + ctx, mod, fn, builder := newInventoryTestFunction(t, false) + defer ctx.Dispose() + defer mod.Dispose() + defer builder.Dispose() + call := builder.CreateCall(llvm.FunctionType(ctx.VoidType(), nil, false), fn, nil, "") + markCall(ctx, call) + builder.CreateRetVoid() + + if _, err := Inventory(mod); err == nil || !strings.Contains(err.Error(), "unmarked function") { + t.Fatalf("Inventory error = %v", err) + } +} + +func TestInventoryRejectsMarkerOnNonCall(t *testing.T) { + ctx, mod, _, builder := newInventoryTestFunction(t, true) + defer ctx.Dispose() + defer mod.Dispose() + defer builder.Dispose() + ret := builder.CreateRetVoid() + markCall(ctx, ret) + + if _, err := Inventory(mod); err == nil || !strings.Contains(err.Error(), "non-call") { + t.Fatalf("Inventory error = %v", err) + } +} + +func TestInventoryIgnoresUnmarkedDeclarations(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("empty") + defer mod.Dispose() + llvm.AddFunction(mod, "declaration", llvm.FunctionType(ctx.VoidType(), nil, false)) + functions, err := Inventory(mod) + if err != nil { + t.Fatal(err) + } + if len(functions) != 0 { + t.Fatalf("functions = %+v, want empty", functions) + } +} + +func TestInventoryIncludesMarkedLeafFunction(t *testing.T) { + ctx, mod, _, builder := newInventoryTestFunction(t, true) + defer ctx.Dispose() + defer mod.Dispose() + defer builder.Dispose() + builder.CreateRetVoid() + + functions, err := Inventory(mod) + if err != nil { + t.Fatal(err) + } + if len(functions) != 1 || functions[0].Name != "function" || len(functions[0].Calls) != 0 { + t.Fatalf("functions = %+v", functions) + } +} + +func TestInventoryRejectsInvalidMarker(t *testing.T) { + ctx, mod, fn, builder := newInventoryTestFunction(t, true) + defer ctx.Dispose() + defer mod.Dispose() + defer builder.Dispose() + call := builder.CreateCall(llvm.FunctionType(ctx.VoidType(), nil, false), fn, nil, "") + kind := ctx.MDKindID(CallMetadata) + version := llvm.ConstInt(ctx.Int32Type(), MarkerVersion+1, false).ConstantAsMetadata() + call.SetMetadata(kind, ctx.MDNode([]llvm.Metadata{version})) + builder.CreateRetVoid() + + if _, err := Inventory(mod); err == nil || !strings.Contains(err.Error(), "invalid resumable call marker") { + t.Fatalf("Inventory error = %v", err) + } +} + +func newInventoryTestFunction(t *testing.T, marked bool) (llvm.Context, llvm.Module, llvm.Value, llvm.Builder) { + t.Helper() + ctx := llvm.NewContext() + mod := ctx.NewModule("invalid") + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(ctx.VoidType(), nil, false)) + if marked { + fn.AddFunctionAttr(ctx.CreateStringAttribute(FunctionAttribute, "1")) + } + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + builder.SetInsertPointAtEnd(block) + return ctx, mod, fn, builder +} + +func markCall(ctx llvm.Context, instr llvm.Value) { + kind := ctx.MDKindID(CallMetadata) + version := llvm.ConstInt(ctx.Int32Type(), MarkerVersion, false).ConstantAsMetadata() + instr.SetMetadata(kind, ctx.MDNode([]llvm.Metadata{version})) +} diff --git a/ssa/wasm_resume.go b/ssa/wasm_resume.go index c19b003a0c..80dc3a8684 100644 --- a/ssa/wasm_resume.go +++ b/ssa/wasm_resume.go @@ -16,11 +16,9 @@ package ssa -import "github.com/xgo-dev/llvm" - -const ( - wasmResumeFunctionAttr = "llgo.wasm.resume" - wasmResumeCallMetadata = "llgo.wasm.resume.call" +import ( + "github.com/goplus/llgo/internal/wasmresume" + "github.com/xgo-dev/llvm" ) // EnableWasmResumeABI controls emission of the function and call inventory @@ -39,15 +37,15 @@ func (p Program) markWasmResumeFunction(fn llvm.Value) { if !p.WasmResumeABIEnabled() { return } - fn.AddFunctionAttr(p.ctx.CreateStringAttribute(wasmResumeFunctionAttr, "1")) + fn.AddFunctionAttr(p.ctx.CreateStringAttribute(wasmresume.FunctionAttribute, "1")) } func (b Builder) markWasmResumeCall(call llvm.Value, background Background) { if background != InGo || !b.Prog.WasmResumeABIEnabled() { return } - kind := b.Prog.ctx.MDKindID(wasmResumeCallMetadata) - version := llvm.ConstInt(b.Prog.Int32().ll, 1, false).ConstantAsMetadata() + kind := b.Prog.ctx.MDKindID(wasmresume.CallMetadata) + version := llvm.ConstInt(b.Prog.Int32().ll, wasmresume.MarkerVersion, false).ConstantAsMetadata() call.SetMetadata(kind, b.Prog.ctx.MDNode([]llvm.Metadata{version})) } diff --git a/ssa/wasm_resume_test.go b/ssa/wasm_resume_test.go index fd148158cc..460c8ee71d 100644 --- a/ssa/wasm_resume_test.go +++ b/ssa/wasm_resume_test.go @@ -21,6 +21,8 @@ package ssa import ( "strings" "testing" + + "github.com/goplus/llgo/internal/wasmresume" ) func TestWasmResumeABIInventoriesGoCalls(t *testing.T) { @@ -42,15 +44,15 @@ func TestWasmResumeABIInventoriesGoCalls(t *testing.T) { b.Return() ir := pkg.String() - if got := strings.Count(ir, "!"+wasmResumeCallMetadata); got != 2 { + if got := strings.Count(ir, "!"+wasmresume.CallMetadata); got != 2 { t.Fatalf("resumable call marker count = %d, want 2:\n%s", got, ir) } - if !strings.Contains(ir, `"`+wasmResumeFunctionAttr+`"="1"`) { + if !strings.Contains(ir, `"`+wasmresume.FunctionAttribute+`"="1"`) { t.Fatalf("Go functions are not marked for resumable lowering:\n%s", ir) } var foundCCall bool for _, line := range strings.Split(ir, "\n") { - if strings.Contains(line, "call void @cFn") && strings.Contains(line, wasmResumeCallMetadata) { + if strings.Contains(line, "call void @cFn") && strings.Contains(line, wasmresume.CallMetadata) { t.Fatalf("C call was marked resumable: %s", line) } if strings.Contains(line, "call void @cFn") { @@ -91,7 +93,7 @@ func TestWasmResumeABIDoesNotChangeDefaultOrNativeIR(t *testing.T) { b.Return() ir := pkg.String() - if strings.Contains(ir, wasmResumeFunctionAttr) || strings.Contains(ir, wasmResumeCallMetadata) { + if strings.Contains(ir, wasmresume.FunctionAttribute) || strings.Contains(ir, wasmresume.CallMetadata) { t.Fatalf("inactive resumable ABI changed IR:\n%s", ir) } }) From 645e81485a92a8b2526ad94aad786ebac98c6db6 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 15:17:12 +0800 Subject: [PATCH 19/40] internal/wasmresume: plan persistent frame values --- internal/wasmresume/frameplan.go | 384 ++++++++++++++++++++++++++ internal/wasmresume/frameplan_test.go | 373 +++++++++++++++++++++++++ 2 files changed, 757 insertions(+) create mode 100644 internal/wasmresume/frameplan.go create mode 100644 internal/wasmresume/frameplan_test.go diff --git a/internal/wasmresume/frameplan.go b/internal/wasmresume/frameplan.go new file mode 100644 index 0000000000..3717e2e7be --- /dev/null +++ b/internal/wasmresume/frameplan.go @@ -0,0 +1,384 @@ +/* + * 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 wasmresume + +import ( + "fmt" + "sort" + + "github.com/xgo-dev/llvm" +) + +type slotKind uint8 + +const ( + slotParameter slotKind = iota + slotFunctionResult + slotAlloca + slotValue +) + +type frameSlot struct { + id uint32 + kind slotKind + typ llvm.Type + value llvm.Value + dynamic bool +} + +type callSite struct { + id uint32 + call llvm.Value + live []uint32 + resultSlot uint32 +} + +type framePlan struct { + function llvm.Value + slots []frameSlot + resultSlot uint32 + calls []callSite +} + +type blockLiveness struct { + def valueSet + use valueSet + liveIn valueSet + liveOut valueSet +} + +type valueSet map[llvm.Value]struct{} + +// planFrames computes the persistent values needed by each generated frame. +// Inventory runs first so every resumable call has a stable in-function ID. +func planFrames(mod llvm.Module) ([]framePlan, error) { + if _, err := Inventory(mod); err != nil { + return nil, err + } + + kind := mod.Context().MDKindID(CallMetadata) + var plans []framePlan + for fn := mod.FirstFunction(); !fn.IsNil(); fn = llvm.NextFunction(fn) { + if !hasFunctionMarker(fn) { + continue + } + plan, err := planFunctionFrame(fn, kind) + if err != nil { + return nil, fmt.Errorf("%s: %w", fn.Name(), err) + } + plans = append(plans, plan) + } + return plans, nil +} + +func planFunctionFrame(fn llvm.Value, metadataKind int) (framePlan, error) { + values, candidates, kinds := frameCandidates(fn) + blocks, liveness := analyzeLiveness(fn, candidates) + + var rawCalls []struct { + id uint32 + call llvm.Value + live valueSet + result llvm.Value + } + needed := make(valueSet) + for _, block := range blocks { + live := cloneSet(liveness[block].liveOut) + for instr := block.LastInstruction(); !instr.IsNil(); instr = llvm.PrevInstruction(instr) { + if hasMetadata(instr, metadataKind) { + id, err := resumeID(instr.Metadata(metadataKind)) + if err != nil { + return framePlan{}, err + } + across := cloneSet(live) + var result llvm.Value + if _, ok := across[instr]; ok { + result = instr + delete(across, instr) + needed[instr] = struct{}{} + } + unionInto(across, referencedAllocas(instr, candidates)) + for value := range across { + needed[value] = struct{}{} + } + rawCalls = append(rawCalls, struct { + id uint32 + call llvm.Value + live valueSet + result llvm.Value + }{id: id, call: instr, live: across, result: result}) + } + + delete(live, instr) + if instr.InstructionOpcode() != llvm.PHI { + addLocalOperands(live, instr, candidates) + } + } + } + + sort.Slice(rawCalls, func(i, j int) bool { + return rawCalls[i].id < rawCalls[j].id + }) + + plan := framePlan{function: fn} + slots := make(map[llvm.Value]uint32) + addSlot := func(kind slotKind, typ llvm.Type, value llvm.Value, dynamic bool) uint32 { + id := uint32(len(plan.slots) + 1) + plan.slots = append(plan.slots, frameSlot{ + id: id, kind: kind, typ: typ, value: value, dynamic: dynamic, + }) + if !value.IsNil() { + slots[value] = id + } + return id + } + for _, value := range values { + if kinds[value] == slotParameter { + addSlot(slotParameter, value.Type(), value, false) + } + } + if typ := fn.GlobalValueType().ReturnType(); typ.TypeKind() != llvm.VoidTypeKind { + plan.resultSlot = addSlot(slotFunctionResult, typ, llvm.Value{}, false) + } + for _, value := range values { + if kinds[value] == slotParameter { + continue + } + if _, ok := needed[value]; !ok { + continue + } + typ, dynamic := persistentSlotType(value, kinds[value]) + addSlot(kinds[value], typ, value, dynamic) + } + + for _, raw := range rawCalls { + site := callSite{id: raw.id, call: raw.call} + for _, value := range values { + if _, ok := raw.live[value]; ok { + site.live = append(site.live, slots[value]) + } + } + if !raw.result.IsNil() { + site.resultSlot = slots[raw.result] + } + plan.calls = append(plan.calls, site) + } + return plan, nil +} + +func persistentSlotType(value llvm.Value, kind slotKind) (llvm.Type, bool) { + if kind != slotAlloca { + return value.Type(), false + } + elem := value.AllocatedType() + if value.OperandsCount() == 0 { + return elem, false + } + count := value.Operand(0).IsAConstantInt() + if count.IsNil() { + return value.Type(), true + } + n := count.ZExtValue() + if n == 1 { + return elem, false + } + return llvm.ArrayType(elem, int(n)), false +} + +func frameCandidates(fn llvm.Value) ([]llvm.Value, valueSet, map[llvm.Value]slotKind) { + var values []llvm.Value + candidates := make(valueSet) + kinds := make(map[llvm.Value]slotKind) + add := func(value llvm.Value, kind slotKind) { + values = append(values, value) + candidates[value] = struct{}{} + kinds[value] = kind + } + for param := fn.FirstParam(); !param.IsNil(); param = llvm.NextParam(param) { + add(param, slotParameter) + } + for block := fn.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + for instr := block.FirstInstruction(); !instr.IsNil(); instr = llvm.NextInstruction(instr) { + if instr.Type().TypeKind() == llvm.VoidTypeKind { + continue + } + kind := slotValue + if !instr.IsAAllocaInst().IsNil() { + kind = slotAlloca + } + add(instr, kind) + } + } + return values, candidates, kinds +} + +func analyzeLiveness(fn llvm.Value, candidates valueSet) ([]llvm.BasicBlock, map[llvm.BasicBlock]*blockLiveness) { + var blocks []llvm.BasicBlock + info := make(map[llvm.BasicBlock]*blockLiveness) + for block := fn.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + blocks = append(blocks, block) + state := &blockLiveness{ + def: make(valueSet), + use: make(valueSet), + liveIn: make(valueSet), + liveOut: make(valueSet), + } + info[block] = state + for instr := block.FirstInstruction(); !instr.IsNil(); instr = llvm.NextInstruction(instr) { + if instr.InstructionOpcode() != llvm.PHI { + for operand := range localOperands(instr, candidates) { + if _, defined := state.def[operand]; !defined { + state.use[operand] = struct{}{} + } + } + } + if _, ok := candidates[instr]; ok { + state.def[instr] = struct{}{} + } + } + } + + changed := true + for changed { + changed = false + for i := len(blocks) - 1; i >= 0; i-- { + block := blocks[i] + state := info[block] + out := make(valueSet) + terminator := block.LastInstruction() + for successorIndex := 0; successorIndex < terminator.SuccessorsCount(); successorIndex++ { + successor := terminator.Successor(successorIndex) + unionInto(out, info[successor].liveIn) + addPhiEdgeUses(out, successor, block, candidates) + } + in := cloneSet(state.use) + for value := range out { + if _, defined := state.def[value]; !defined { + in[value] = struct{}{} + } + } + if !equalSet(out, state.liveOut) || !equalSet(in, state.liveIn) { + state.liveOut = out + state.liveIn = in + changed = true + } + } + } + return blocks, info +} + +func addPhiEdgeUses(dst valueSet, successor, predecessor llvm.BasicBlock, candidates valueSet) { + for phi := successor.FirstInstruction(); !phi.IsNil() && phi.InstructionOpcode() == llvm.PHI; phi = llvm.NextInstruction(phi) { + for i := 0; i < phi.IncomingCount(); i++ { + value := phi.IncomingValue(i) + if phi.IncomingBlock(i) == predecessor { + if _, ok := candidates[value]; ok { + dst[value] = struct{}{} + } + } + } + } +} + +func localOperands(instr llvm.Value, candidates valueSet) valueSet { + operands := make(valueSet) + addLocalOperands(operands, instr, candidates) + return operands +} + +func addLocalOperands(dst valueSet, instr llvm.Value, candidates valueSet) { + for i := 0; i < instr.OperandsCount(); i++ { + operand := instr.Operand(i) + if _, ok := candidates[operand]; ok { + dst[operand] = struct{}{} + } + } +} + +func referencedAllocas(call llvm.Value, candidates valueSet) valueSet { + allocas := make(valueSet) + visited := make(valueSet) + var visit func(llvm.Value) + visit = func(value llvm.Value) { + if _, ok := visited[value]; ok { + return + } + visited[value] = struct{}{} + if !value.IsAAllocaInst().IsNil() { + allocas[value] = struct{}{} + return + } + if _, ok := candidates[value]; !ok { + return + } + if value.IsAInstruction().IsNil() { + return + } + switch value.InstructionOpcode() { + case llvm.Call, llvm.Load: + return + } + for i := 0; i < value.OperandsCount(); i++ { + visit(value.Operand(i)) + } + } + + callee := call.CalledValue() + for i := 0; i < call.OperandsCount(); i++ { + operand := call.Operand(i) + if operand != callee { + visit(operand) + } + } + return allocas +} + +func resumeID(marker llvm.Value) (uint32, error) { + fields := marker.MDNodeOperands() + if len(fields) != 2 || fields[1].IsAConstantInt().IsNil() { + return 0, fmt.Errorf("resumable call marker has no resume ID") + } + id := fields[1].ZExtValue() + if id == 0 || id > maxResumeID { + return 0, fmt.Errorf("invalid resume ID %d", id) + } + return uint32(id), nil +} + +func cloneSet(src valueSet) valueSet { + dst := make(valueSet, len(src)) + unionInto(dst, src) + return dst +} + +func unionInto(dst, src valueSet) { + for value := range src { + dst[value] = struct{}{} + } +} + +func equalSet(a, b valueSet) bool { + if len(a) != len(b) { + return false + } + for value := range a { + if _, ok := b[value]; !ok { + return false + } + } + return true +} diff --git a/internal/wasmresume/frameplan_test.go b/internal/wasmresume/frameplan_test.go new file mode 100644 index 0000000000..64f0467f90 --- /dev/null +++ b/internal/wasmresume/frameplan_test.go @@ -0,0 +1,373 @@ +package wasmresume + +import ( + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestPlanFramesStraightLineValues(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("straight") + defer mod.Dispose() + + i32 := ctx.Int32Type() + callee := llvm.AddFunction(mod, "callee", llvm.FunctionType(i32, []llvm.Type{i32}, false)) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(i32, []llvm.Type{i32}, false)) + markFunction(ctx, fn) + fn.Param(0).SetName("input") + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + before := builder.CreateAdd(fn.Param(0), llvm.ConstInt(i32, 1, false), "before") + call := builder.CreateCall(callee.GlobalValueType(), callee, []llvm.Value{before}, "result") + markCall(ctx, call) + after := builder.CreateAdd(before, call, "after") + builder.CreateRet(after) + + plans, err := planFrames(mod) + if err != nil { + t.Fatal(err) + } + plan := onlyFramePlan(t, plans) + assertSlots(t, plan, []slotWant{ + {name: "input", kind: slotParameter}, + {kind: slotFunctionResult}, + {name: "before", kind: slotValue}, + {name: "result", kind: slotValue}, + }) + if len(plan.calls) != 1 { + t.Fatalf("calls = %+v", plan.calls) + } + if got, want := plan.calls[0].live, []uint32{3}; !equalIDs(got, want) { + t.Fatalf("live slots = %v, want %v", got, want) + } + if plan.resultSlot != 2 { + t.Fatalf("function result slot = %d, want 2", plan.resultSlot) + } + if plan.calls[0].resultSlot != 4 { + t.Fatalf("call result slot = %d, want 4", plan.calls[0].resultSlot) + } +} + +func TestPlanFramesKeepsParameterAndAllocaAcrossCall(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("alloca") + defer mod.Dispose() + + i32 := ctx.Int32Type() + voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) + callee := llvm.AddFunction(mod, "callee", voidFn) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(i32, []llvm.Type{i32}, false)) + markFunction(ctx, fn) + fn.Param(0).SetName("input") + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + local := builder.CreateAlloca(i32, "local") + builder.CreateStore(fn.Param(0), local) + call := builder.CreateCall(voidFn, callee, nil, "") + markCall(ctx, call) + loaded := builder.CreateLoad(i32, local, "loaded") + after := builder.CreateAdd(fn.Param(0), loaded, "after") + builder.CreateRet(after) + + plans, err := planFrames(mod) + if err != nil { + t.Fatal(err) + } + plan := onlyFramePlan(t, plans) + assertSlots(t, plan, []slotWant{ + {name: "input", kind: slotParameter}, + {kind: slotFunctionResult}, + {name: "local", kind: slotAlloca}, + }) + if got, want := plan.calls[0].live, []uint32{1, 3}; !equalIDs(got, want) { + t.Fatalf("live slots = %v, want %v", got, want) + } + if plan.calls[0].resultSlot != 0 { + t.Fatalf("void call result slot = %d, want 0", plan.calls[0].resultSlot) + } + if plan.slots[2].typ != i32 || plan.slots[2].dynamic { + t.Fatalf("static alloca slot = %+v, want embedded i32", plan.slots[2]) + } +} + +func TestPlanFramesKeepsAllocaReferencedOnlyByCallArgument(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("alloca-argument") + defer mod.Dispose() + + i32 := ctx.Int32Type() + ptr := llvm.PointerType(i32, 0) + calleeType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr}, false) + callee := llvm.AddFunction(mod, "callee", calleeType) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), nil, false)) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + local := builder.CreateAlloca(i32, "local") + derived := builder.CreateGEP(i32, local, []llvm.Value{llvm.ConstInt(ctx.Int32Type(), 0, false)}, "derived") + call := builder.CreateCall(calleeType, callee, []llvm.Value{derived}, "") + markCall(ctx, call) + builder.CreateRetVoid() + + plans, err := planFrames(mod) + if err != nil { + t.Fatal(err) + } + plan := onlyFramePlan(t, plans) + assertSlots(t, plan, []slotWant{{name: "local", kind: slotAlloca}}) + if got, want := plan.calls[0].live, []uint32{1}; !equalIDs(got, want) { + t.Fatalf("live slots = %v, want %v", got, want) + } +} + +func TestPlanFramesMarksDynamicAllocaStorage(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("dynamic-alloca") + defer mod.Dispose() + + i32 := ctx.Int32Type() + ptr := llvm.PointerType(i32, 0) + calleeType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr}, false) + callee := llvm.AddFunction(mod, "callee", calleeType) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32}, false)) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + local := builder.CreateArrayAlloca(i32, fn.Param(0), "local") + call := builder.CreateCall(calleeType, callee, []llvm.Value{local}, "") + markCall(ctx, call) + builder.CreateRetVoid() + + plans, err := planFrames(mod) + if err != nil { + t.Fatal(err) + } + plan := onlyFramePlan(t, plans) + assertSlots(t, plan, []slotWant{ + {kind: slotParameter}, + {name: "local", kind: slotAlloca}, + }) + if plan.slots[1].typ != ptr || !plan.slots[1].dynamic { + t.Fatalf("dynamic alloca slot = %+v, want pointer storage", plan.slots[1]) + } +} + +func TestPlanFramesDoesNotPersistCopiedCallArguments(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("copied-arguments") + defer mod.Dispose() + + i32 := ctx.Int32Type() + calleeType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32, i32}, false) + callee := llvm.AddFunction(mod, "callee", calleeType) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32}, false)) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + local := builder.CreateAlloca(i32, "local") + builder.CreateStore(llvm.ConstInt(i32, 7, false), local) + loaded := builder.CreateLoad(i32, local, "loaded") + call := builder.CreateCall(calleeType, callee, []llvm.Value{fn.Param(0), loaded}, "") + markCall(ctx, call) + builder.CreateRetVoid() + + plans, err := planFrames(mod) + if err != nil { + t.Fatal(err) + } + plan := onlyFramePlan(t, plans) + assertSlots(t, plan, []slotWant{{kind: slotParameter}}) + if len(plan.calls[0].live) != 0 { + t.Fatalf("copied arguments created persistent slots: %+v", plan) + } +} + +func TestPlanFramesTracksPhiUseOnPredecessorEdge(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("phi") + defer mod.Dispose() + + i1 := ctx.Int1Type() + i32 := ctx.Int32Type() + voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) + callee := llvm.AddFunction(mod, "callee", voidFn) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(i32, []llvm.Type{i1, i32}, false)) + markFunction(ctx, fn) + fn.Param(1).SetName("input") + entry := ctx.AddBasicBlock(fn, "entry") + left := ctx.AddBasicBlock(fn, "left") + right := ctx.AddBasicBlock(fn, "right") + merge := ctx.AddBasicBlock(fn, "merge") + builder := ctx.NewBuilder() + defer builder.Dispose() + + builder.SetInsertPointAtEnd(entry) + builder.CreateCondBr(fn.Param(0), left, right) + builder.SetInsertPointAtEnd(left) + call := builder.CreateCall(voidFn, callee, nil, "") + markCall(ctx, call) + builder.CreateBr(merge) + builder.SetInsertPointAtEnd(right) + builder.CreateBr(merge) + builder.SetInsertPointAtEnd(merge) + phi := builder.CreatePHI(i32, "selected") + phi.AddIncoming( + []llvm.Value{fn.Param(1), llvm.ConstInt(i32, 0, false)}, + []llvm.BasicBlock{left, right}, + ) + builder.CreateRet(phi) + + plans, err := planFrames(mod) + if err != nil { + t.Fatal(err) + } + plan := onlyFramePlan(t, plans) + assertSlots(t, plan, []slotWant{ + {kind: slotParameter}, + {name: "input", kind: slotParameter}, + {kind: slotFunctionResult}, + }) + if got, want := plan.calls[0].live, []uint32{2}; !equalIDs(got, want) { + t.Fatalf("live slots = %v, want %v", got, want) + } +} + +func TestPlanFramesIncludesMarkedLeaf(t *testing.T) { + ctx, mod, _, builder := newInventoryTestFunction(t, true) + defer ctx.Dispose() + defer mod.Dispose() + defer builder.Dispose() + builder.CreateRetVoid() + + plans, err := planFrames(mod) + if err != nil { + t.Fatal(err) + } + plan := onlyFramePlan(t, plans) + if len(plan.slots) != 0 || len(plan.calls) != 0 { + t.Fatalf("leaf plan = %+v", plan) + } +} + +func TestPlanFramesReservesLeafParametersAndResult(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("leaf-abi") + defer mod.Dispose() + + i32 := ctx.Int32Type() + fn := llvm.AddFunction(mod, "leaf", llvm.FunctionType(i32, []llvm.Type{i32}, false)) + markFunction(ctx, fn) + fn.Param(0).SetName("input") + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateRet(fn.Param(0)) + + plans, err := planFrames(mod) + if err != nil { + t.Fatal(err) + } + plan := onlyFramePlan(t, plans) + assertSlots(t, plan, []slotWant{ + {name: "input", kind: slotParameter}, + {kind: slotFunctionResult}, + }) + if plan.resultSlot != 2 { + t.Fatalf("result slot = %d, want 2", plan.resultSlot) + } +} + +func TestPlanFramesOrdersCallsByResumeID(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("order") + defer mod.Dispose() + + voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) + callee := llvm.AddFunction(mod, "callee", voidFn) + fn := llvm.AddFunction(mod, "caller", voidFn) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + first := builder.CreateCall(voidFn, callee, nil, "") + markCall(ctx, first) + second := builder.CreateCall(voidFn, callee, nil, "") + markCall(ctx, second) + builder.CreateRetVoid() + + plans, err := planFrames(mod) + if err != nil { + t.Fatal(err) + } + calls := onlyFramePlan(t, plans).calls + if len(calls) != 2 || calls[0].id != 1 || calls[1].id != 2 { + t.Fatalf("calls = %+v", calls) + } +} + +type slotWant struct { + name string + kind slotKind +} + +func onlyFramePlan(t *testing.T, plans []framePlan) framePlan { + t.Helper() + if len(plans) != 1 { + t.Fatalf("plans = %+v", plans) + } + return plans[0] +} + +func assertSlots(t *testing.T, plan framePlan, want []slotWant) { + t.Helper() + if len(plan.slots) != len(want) { + t.Fatalf("slots = %+v, want %+v", plan.slots, want) + } + for i, slot := range plan.slots { + name := "" + if !slot.value.IsNil() { + name = slot.value.Name() + } + if slot.id != uint32(i+1) || name != want[i].name || slot.kind != want[i].kind { + t.Fatalf("slot %d = {id:%d name:%q kind:%d}, want {id:%d name:%q kind:%d}", + i, slot.id, name, slot.kind, i+1, want[i].name, want[i].kind) + } + } +} + +func equalIDs(a, b []uint32) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func markFunction(ctx llvm.Context, fn llvm.Value) { + fn.AddFunctionAttr(ctx.CreateStringAttribute(FunctionAttribute, "1")) +} From bc51e9a33a9a6cf3b28cadfdf301568ad945f1fd Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 15:25:21 +0800 Subject: [PATCH 20/40] runtime/wasm: retain completed resume frames --- runtime/internal/wasmresume/resume.go | 15 +++++++++++- runtime/internal/wasmresume/resume_test.go | 28 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/runtime/internal/wasmresume/resume.go b/runtime/internal/wasmresume/resume.go index abdd888c96..3d7dae12b6 100644 --- a/runtime/internal/wasmresume/resume.go +++ b/runtime/internal/wasmresume/resume.go @@ -52,7 +52,8 @@ type Frame struct { // Context owns the active frame chain for one logical goroutine. type Context struct { - top *Frame + top *Frame + returned *Frame } // Top returns the active frame. @@ -62,12 +63,23 @@ func (c *Context) Top() *Frame { // Push links frame as the active child of the current frame. func (c *Context) Push(frame *Frame, descriptor *Descriptor) { + if c.top == nil { + c.returned = nil + } frame.Parent = c.top frame.Descriptor = descriptor frame.PC = 0 c.top = frame } +// TakeReturned returns the child frame that completed immediately before the +// active frame resumed. It also transfers ownership back to the caller. +func (c *Context) TakeReturned() *Frame { + frame := c.returned + c.returned = nil + return frame +} + // Run resumes the active frame chain until it completes or suspends. func (c *Context) Run() Action { for c.top != nil { @@ -76,6 +88,7 @@ func (c *Context) Run() Action { case Continue: case Return: c.top = frame.Parent + c.returned = frame case Suspend: return Suspend default: diff --git a/runtime/internal/wasmresume/resume_test.go b/runtime/internal/wasmresume/resume_test.go index 43402ef7c5..d18ed6479e 100644 --- a/runtime/internal/wasmresume/resume_test.go +++ b/runtime/internal/wasmresume/resume_test.go @@ -32,6 +32,9 @@ func resumeTestRoot(ctx *Context, raw *Frame) Action { ctx.Push(&frame.direct.Frame, &testAddDescriptor) return Continue case 1: + if returned := ctx.TakeReturned(); returned != &frame.direct.Frame { + panic("unexpected direct child frame") + } frame.value = frame.direct.value frame.PC = 2 frame.indirect.value = frame.value @@ -42,6 +45,9 @@ func resumeTestRoot(ctx *Context, raw *Frame) Action { ctx.Push(&frame.indirect.Frame, descriptor) return Continue case 2: + if returned := ctx.TakeReturned(); returned != &frame.indirect.Frame { + panic("unexpected indirect child frame") + } frame.value = frame.indirect.value return Return default: @@ -92,6 +98,9 @@ func TestContextRunDirectAndIndirectCalls(t *testing.T) { if ctx.Top() != nil { t.Fatal("completed frame chain remains active") } + if returned := ctx.TakeReturned(); returned != &frame.Frame { + t.Fatal("completed root frame was not returned to its owner") + } if frame.value != 14 { t.Fatalf("result = %d, want 14", frame.value) } @@ -102,6 +111,9 @@ func TestContextRunEmpty(t *testing.T) { if action := ctx.Run(); action != Return { t.Fatalf("empty Run action = %d, want Return", action) } + if returned := ctx.TakeReturned(); returned != nil { + t.Fatalf("empty Run returned frame %p", returned) + } } func TestContextPushInitializesHeader(t *testing.T) { @@ -120,6 +132,22 @@ func TestContextPushInitializesHeader(t *testing.T) { } } +func TestContextPushClearsCompletedChain(t *testing.T) { + var ( + ctx Context + first Frame + next Frame + ) + ctx.Push(&first, &testAddDescriptor) + if action := ctx.Run(); action != Return { + t.Fatalf("first Run action = %d, want Return", action) + } + ctx.Push(&next, &testAddDescriptor) + if returned := ctx.TakeReturned(); returned != nil { + t.Fatalf("new root retained completed frame %p", returned) + } +} + func TestContextRejectsInvalidAction(t *testing.T) { descriptor := Descriptor{Resume: func(*Context, *Frame) Action { return Action(255) From 51f54e76f5eb674dccdecc2b206e653d7037d458 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 15:27:06 +0800 Subject: [PATCH 21/40] internal/wasmresume: lay out persistent frames --- internal/wasmresume/layout.go | 62 ++++++++++++++++ internal/wasmresume/layout_test.go | 109 +++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 internal/wasmresume/layout.go create mode 100644 internal/wasmresume/layout_test.go diff --git a/internal/wasmresume/layout.go b/internal/wasmresume/layout.go new file mode 100644 index 0000000000..50733a94af --- /dev/null +++ b/internal/wasmresume/layout.go @@ -0,0 +1,62 @@ +/* + * 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 wasmresume + +import "github.com/xgo-dev/llvm" + +const frameHeaderFields = 3 + +type frameLayout struct { + plan framePlan + typ llvm.Type + size uint64 + alignment int +} + +func layoutFrames(mod llvm.Module, targetData llvm.TargetData) ([]frameLayout, error) { + plans, err := planFrames(mod) + if err != nil { + return nil, err + } + ctx := mod.Context() + ptr := llvm.PointerType(ctx.Int8Type(), 0) + header := []llvm.Type{ptr, ptr, ctx.Int32Type()} + + layouts := make([]frameLayout, len(plans)) + for i, plan := range plans { + fields := make([]llvm.Type, frameHeaderFields, frameHeaderFields+len(plan.slots)) + copy(fields, header) + for _, slot := range plan.slots { + fields = append(fields, slot.typ) + } + typ := ctx.StructType(fields, false) + layouts[i] = frameLayout{ + plan: plan, + typ: typ, + size: targetData.TypeAllocSize(typ), + alignment: targetData.ABITypeAlignment(typ), + } + } + return layouts, nil +} + +func (l frameLayout) fieldIndex(slotID uint32) int { + if slotID == 0 || int(slotID) > len(l.plan.slots) { + return -1 + } + return frameHeaderFields + int(slotID) - 1 +} diff --git a/internal/wasmresume/layout_test.go b/internal/wasmresume/layout_test.go new file mode 100644 index 0000000000..e9a658860e --- /dev/null +++ b/internal/wasmresume/layout_test.go @@ -0,0 +1,109 @@ +package wasmresume + +import ( + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestLayoutFramesUsesRuntimeHeaderAndStableSlots(t *testing.T) { + for _, test := range []struct { + name string + dataLayout string + wantSize uint64 + wantAlign int + wantOffset []uint64 + }{ + { + name: "wasm32", + dataLayout: "e-m:e-p:32:32-i64:64-n32:64-S128", + wantSize: 32, + wantAlign: 8, + wantOffset: []uint64{0, 4, 8, 16, 24}, + }, + { + name: "wasm64", + dataLayout: "e-m:e-p:64:64-i64:64-n32:64-S128", + wantSize: 40, + wantAlign: 8, + wantOffset: []uint64{0, 8, 16, 24, 32}, + }, + } { + t.Run(test.name, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule(test.name) + defer mod.Dispose() + targetData := llvm.NewTargetData(test.dataLayout) + defer targetData.Dispose() + + i64 := ctx.Int64Type() + fn := llvm.AddFunction(mod, "leaf", llvm.FunctionType(i64, []llvm.Type{i64}, false)) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateRet(fn.Param(0)) + + layouts, err := layoutFrames(mod, targetData) + if err != nil { + t.Fatal(err) + } + if len(layouts) != 1 { + t.Fatalf("layouts = %+v", layouts) + } + layout := layouts[0] + if layout.size != test.wantSize || layout.alignment != test.wantAlign { + t.Fatalf("layout size/alignment = %d/%d, want %d/%d", + layout.size, layout.alignment, test.wantSize, test.wantAlign) + } + for field, want := range test.wantOffset { + if got := targetData.ElementOffset(layout.typ, field); got != want { + t.Errorf("field %d offset = %d, want %d", field, got, want) + } + } + if layout.fieldIndex(1) != 3 || layout.fieldIndex(2) != 4 { + t.Fatalf("slot fields = %d/%d, want 3/4", layout.fieldIndex(1), layout.fieldIndex(2)) + } + if layout.fieldIndex(0) != -1 || layout.fieldIndex(3) != -1 { + t.Fatalf("invalid slot fields = %d/%d, want -1/-1", + layout.fieldIndex(0), layout.fieldIndex(3)) + } + }) + } +} + +func TestLayoutFramesKeepsDynamicAllocaAsPointer(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("dynamic") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") + defer targetData.Dispose() + + i32 := ctx.Int32Type() + ptr := llvm.PointerType(i32, 0) + calleeType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr}, false) + callee := llvm.AddFunction(mod, "callee", calleeType) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32}, false)) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + local := builder.CreateArrayAlloca(i32, fn.Param(0), "local") + call := builder.CreateCall(calleeType, callee, []llvm.Value{local}, "") + markCall(ctx, call) + builder.CreateRetVoid() + + layouts, err := layoutFrames(mod, targetData) + if err != nil { + t.Fatal(err) + } + layout := layouts[0] + fields := layout.typ.StructElementTypes() + if len(fields) != 5 || fields[4].TypeKind() != llvm.PointerTypeKind { + t.Fatalf("frame fields = %v, want dynamic alloca pointer at field 4", fields) + } +} From fda516a8de5f74484436945921b54e34d1fd90da Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 15:32:21 +0800 Subject: [PATCH 22/40] runtime/wasm: define generated frame descriptors --- runtime/internal/wasmresume/resume.go | 8 ++++++-- runtime/internal/wasmresume/resume_test.go | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/runtime/internal/wasmresume/resume.go b/runtime/internal/wasmresume/resume.go index 3d7dae12b6..1772aed83c 100644 --- a/runtime/internal/wasmresume/resume.go +++ b/runtime/internal/wasmresume/resume.go @@ -33,13 +33,17 @@ const ( Suspend ) -// Resume is the common indirect-call signature for generated resume entries. +// Resume is the non-suspending indirect-call signature for generated entries. +// +//llgo:type C type Resume func(*Context, *Frame) Action // Descriptor contains immutable state shared by every invocation of a // generated function. type Descriptor struct { - Resume Resume + Resume Resume + FrameSize uintptr + FrameAlign uintptr } // Frame is the common prefix of every generated function frame. Generated diff --git a/runtime/internal/wasmresume/resume_test.go b/runtime/internal/wasmresume/resume_test.go index d18ed6479e..834e5a7a41 100644 --- a/runtime/internal/wasmresume/resume_test.go +++ b/runtime/internal/wasmresume/resume_test.go @@ -132,6 +132,18 @@ func TestContextPushInitializesHeader(t *testing.T) { } } +func TestDescriptorCarriesFrameLayout(t *testing.T) { + descriptor := Descriptor{ + Resume: resumeTestAdd, + FrameSize: unsafe.Sizeof(testLeafFrame{}), + FrameAlign: unsafe.Alignof(testLeafFrame{}), + } + if descriptor.Resume == nil || descriptor.FrameSize != unsafe.Sizeof(testLeafFrame{}) || + descriptor.FrameAlign != unsafe.Alignof(testLeafFrame{}) { + t.Fatalf("descriptor = %+v", descriptor) + } +} + func TestContextPushClearsCompletedChain(t *testing.T) { var ( ctx Context From 83197cee17c7bcf3868a945c1f5ae3e86f1e5d19 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 15:32:32 +0800 Subject: [PATCH 23/40] internal/wasmresume: emit leaf resume entries --- internal/wasmresume/leaf.go | 102 +++++++++++++++++++++++++++++ internal/wasmresume/leaf_test.go | 109 +++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 internal/wasmresume/leaf.go create mode 100644 internal/wasmresume/leaf_test.go diff --git a/internal/wasmresume/leaf.go b/internal/wasmresume/leaf.go new file mode 100644 index 0000000000..7ecf811173 --- /dev/null +++ b/internal/wasmresume/leaf.go @@ -0,0 +1,102 @@ +/* + * 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 wasmresume + +import ( + "fmt" + + "github.com/xgo-dev/llvm" +) + +const ( + resumeEntryPrefix = "__llgo_wasm_resume." + descriptorPrefix = "__llgo_wasm_resume_desc." + actionReturn = 1 +) + +type loweredLeaf struct { + layout frameLayout + entry llvm.Value + descriptor llvm.Value +} + +// emitLeafEntries emits the descriptor ABI for functions which cannot suspend +// below their own frame. Non-leaf state-machine lowering is a later stage. +func emitLeafEntries(mod llvm.Module, targetData llvm.TargetData) ([]loweredLeaf, error) { + layouts, err := layoutFrames(mod, targetData) + if err != nil { + return nil, err + } + + ctx := mod.Context() + ptr := llvm.PointerType(ctx.Int8Type(), 0) + uintptrType := ctx.IntType(targetData.PointerSize() * 8) + entryType := llvm.FunctionType(ctx.Int8Type(), []llvm.Type{ptr, ptr}, false) + descriptorType := ctx.StructType([]llvm.Type{ptr, uintptrType, uintptrType}, false) + + var lowered []loweredLeaf + for _, layout := range layouts { + fn := layout.plan.function + if fn.IsDeclaration() || len(layout.plan.calls) != 0 { + continue + } + entryName := resumeEntryPrefix + fn.Name() + descriptorName := descriptorPrefix + fn.Name() + if !mod.NamedFunction(entryName).IsNil() || !mod.NamedGlobal(descriptorName).IsNil() { + return nil, fmt.Errorf("%s: duplicate resumable descriptor", fn.Name()) + } + + entry := llvm.AddFunction(mod, entryName, entryType) + entry.SetLinkage(llvm.InternalLinkage) + block := ctx.AddBasicBlock(entry, "entry") + builder := ctx.NewBuilder() + builder.SetInsertPointAtEnd(block) + + rawFrame := entry.Param(1) + params := make([]llvm.Value, 0, fn.ParamsCount()) + for _, slot := range layout.plan.slots { + if slot.kind != slotParameter { + continue + } + field := builder.CreateStructGEP(layout.typ, rawFrame, layout.fieldIndex(slot.id), "") + params = append(params, builder.CreateLoad(slot.typ, field, slot.value.Name())) + } + call := builder.CreateCall(fn.GlobalValueType(), fn, params, "") + call.SetInstructionCallConv(fn.FunctionCallConv()) + if layout.plan.resultSlot != 0 { + field := builder.CreateStructGEP( + layout.typ, rawFrame, layout.fieldIndex(layout.plan.resultSlot), "", + ) + builder.CreateStore(call, field) + } + builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionReturn, false)) + builder.Dispose() + + descriptor := llvm.AddGlobal(mod, descriptorType, descriptorName) + descriptor.SetGlobalConstant(true) + descriptor.SetInitializer(ctx.ConstStruct([]llvm.Value{ + entry, + llvm.ConstInt(uintptrType, layout.size, false), + llvm.ConstInt(uintptrType, uint64(layout.alignment), false), + }, false)) + + lowered = append(lowered, loweredLeaf{ + layout: layout, entry: entry, descriptor: descriptor, + }) + } + return lowered, nil +} diff --git a/internal/wasmresume/leaf_test.go b/internal/wasmresume/leaf_test.go new file mode 100644 index 0000000000..a40e21d560 --- /dev/null +++ b/internal/wasmresume/leaf_test.go @@ -0,0 +1,109 @@ +package wasmresume + +import ( + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestEmitLeafEntriesLoadsParametersAndStoresResult(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("leaf") + defer mod.Dispose() + mod.SetDataLayout("e-m:e-p:32:32-i64:64-n32:64-S128") + targetData := llvm.NewTargetData(mod.DataLayout()) + defer targetData.Dispose() + + i32 := ctx.Int32Type() + fn := llvm.AddFunction(mod, "leaf", llvm.FunctionType(i32, []llvm.Type{i32}, false)) + markFunction(ctx, fn) + fn.Param(0).SetName("input") + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateRet(builder.CreateAdd(fn.Param(0), llvm.ConstInt(i32, 1, false), "value")) + + lowered, err := emitLeafEntries(mod, targetData) + if err != nil { + t.Fatal(err) + } + if len(lowered) != 1 || lowered[0].layout.size != 20 || lowered[0].layout.alignment != 4 { + t.Fatalf("lowered leaves = %+v", lowered) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify lowered leaf: %v\n%s", err, mod.String()) + } + + ir := mod.String() + for _, want := range []string{ + `@__llgo_wasm_resume_desc.leaf = constant { ptr, i32, i32 }`, + `{ ptr @__llgo_wasm_resume.leaf, i32 20, i32 4 }`, + `define internal i8 @__llgo_wasm_resume.leaf(ptr %0, ptr %1)`, + `load i32, ptr %2`, + `call i32 @leaf(i32 %input)`, + `store i32 %3, ptr %4`, + `ret i8 1`, + } { + if !strings.Contains(ir, want) { + t.Errorf("lowered leaf is missing %q:\n%s", want, ir) + } + } +} + +func TestEmitLeafEntriesSkipsNonLeafAndDeclarations(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("skip") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") + defer targetData.Dispose() + + voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) + declaration := llvm.AddFunction(mod, "declaration", voidFn) + markFunction(ctx, declaration) + callee := llvm.AddFunction(mod, "callee", voidFn) + nonLeaf := llvm.AddFunction(mod, "nonleaf", voidFn) + markFunction(ctx, nonLeaf) + block := ctx.AddBasicBlock(nonLeaf, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + call := builder.CreateCall(voidFn, callee, nil, "") + markCall(ctx, call) + builder.CreateRetVoid() + + lowered, err := emitLeafEntries(mod, targetData) + if err != nil { + t.Fatal(err) + } + if len(lowered) != 0 { + t.Fatalf("lowered leaves = %+v, want empty", lowered) + } +} + +func TestEmitLeafEntriesRejectsDuplicateSymbols(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("duplicate") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") + defer targetData.Dispose() + + voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) + fn := llvm.AddFunction(mod, "leaf", voidFn) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateRetVoid() + llvm.AddFunction(mod, resumeEntryPrefix+"leaf", llvm.FunctionType(ctx.Int8Type(), nil, false)) + + if _, err := emitLeafEntries(mod, targetData); err == nil || + !strings.Contains(err.Error(), "duplicate resumable descriptor") { + t.Fatalf("emitLeafEntries error = %v", err) + } +} From 0922d33a2a6685069817199f3d5da8aaa49e66dd Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 15:47:15 +0800 Subject: [PATCH 24/40] internal/wasmresume: split call continuations safely --- internal/wasmresume/ir.go | 99 ++++++++++++++++++++++++++++++++++ internal/wasmresume/ir_test.go | 91 +++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 internal/wasmresume/ir.go create mode 100644 internal/wasmresume/ir_test.go diff --git a/internal/wasmresume/ir.go b/internal/wasmresume/ir.go new file mode 100644 index 0000000000..917e912917 --- /dev/null +++ b/internal/wasmresume/ir.go @@ -0,0 +1,99 @@ +/* + * 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 wasmresume + +import ( + "fmt" + + "github.com/xgo-dev/llvm" +) + +func splitBlockAfter(ctx llvm.Context, call llvm.Value, name string) (llvm.BasicBlock, error) { + block := call.InstructionParent() + if block.IsNil() || call.InstructionOpcode() != llvm.Call { + return llvm.BasicBlock{}, fmt.Errorf("split point is not a call instruction") + } + firstMoved := llvm.NextInstruction(call) + if firstMoved.IsNil() { + return llvm.BasicBlock{}, fmt.Errorf("call has no continuation") + } + + terminator := block.LastInstruction() + successors := make([]llvm.BasicBlock, terminator.SuccessorsCount()) + for i := range successors { + successors[i] = terminator.Successor(i) + } + + fn := block.Parent() + continuation := ctx.AddBasicBlock(fn, name) + continuation.MoveAfter(block) + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(continuation) + for instr := firstMoved; !instr.IsNil(); { + next := llvm.NextInstruction(instr) + instrName := instr.Name() + instr.RemoveFromParentAsInstruction() + if instrName == "" { + builder.Insert(instr) + } else { + builder.InsertWithName(instr, instrName) + } + instr = next + } + builder.SetInsertPointAtEnd(block) + builder.CreateBr(continuation) + + for _, successor := range successors { + replacePhiPredecessor(ctx, successor, block, continuation) + } + return continuation, nil +} + +func replacePhiPredecessor(ctx llvm.Context, block, old, replacement llvm.BasicBlock) { + var phis []llvm.Value + for phi := block.FirstInstruction(); !phi.IsNil() && phi.InstructionOpcode() == llvm.PHI; phi = llvm.NextInstruction(phi) { + phis = append(phis, phi) + } + builder := ctx.NewBuilder() + defer builder.Dispose() + for _, phi := range phis { + values := make([]llvm.Value, phi.IncomingCount()) + blocks := make([]llvm.BasicBlock, len(values)) + changed := false + for i := range values { + values[i] = phi.IncomingValue(i) + blocks[i] = phi.IncomingBlock(i) + if blocks[i] == old { + blocks[i] = replacement + changed = true + } + } + if !changed { + continue + } + builder.SetInsertPointBefore(phi) + next := builder.CreatePHI(phi.Type(), "") + next.AddIncoming(values, blocks) + next.InstructionSetDebugLoc(phi.InstructionDebugLoc()) + name := phi.Name() + phi.SetName("") + next.SetName(name) + phi.ReplaceAllUsesWith(next) + phi.EraseFromParentAsInstruction() + } +} diff --git a/internal/wasmresume/ir_test.go b/internal/wasmresume/ir_test.go new file mode 100644 index 0000000000..d59c549639 --- /dev/null +++ b/internal/wasmresume/ir_test.go @@ -0,0 +1,91 @@ +package wasmresume + +import ( + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestSplitBlockAfterMovesContinuationAndRewritesPhi(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("split") + defer mod.Dispose() + + i1 := ctx.Int1Type() + i32 := ctx.Int32Type() + voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) + callee := llvm.AddFunction(mod, "callee", voidFn) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(i32, []llvm.Type{i1, i32}, false)) + entry := ctx.AddBasicBlock(fn, "entry") + other := ctx.AddBasicBlock(fn, "other") + merge := ctx.AddBasicBlock(fn, "merge") + builder := ctx.NewBuilder() + defer builder.Dispose() + + builder.SetInsertPointAtEnd(entry) + call := builder.CreateCall(voidFn, callee, nil, "") + value := builder.CreateAdd(fn.Param(1), llvm.ConstInt(i32, 2, false), "value") + builder.CreateBr(merge) + builder.SetInsertPointAtEnd(other) + builder.CreateBr(merge) + builder.SetInsertPointAtEnd(merge) + phi := builder.CreatePHI(i32, "selected") + phi.AddIncoming( + []llvm.Value{value, llvm.ConstInt(i32, 0, false)}, + []llvm.BasicBlock{entry, other}, + ) + builder.CreateRet(phi) + + continuation, err := splitBlockAfter(ctx, call, "resume.1") + if err != nil { + t.Fatal(err) + } + if continuation != llvm.NextBasicBlock(entry) { + t.Fatal("continuation was not placed after the split block") + } + if got := llvm.NextInstruction(call).InstructionOpcode(); got != llvm.Br { + t.Fatalf("split block terminator = %v, want br", got) + } + if got := continuation.FirstInstruction().Name(); got != "value" { + t.Fatalf("first continuation instruction = %q, want value:\n%s", got, mod.String()) + } + nextPhi := merge.FirstInstruction() + if nextPhi.InstructionOpcode() != llvm.PHI || nextPhi.Name() != "selected" { + t.Fatalf("replacement phi = %v %q", nextPhi.InstructionOpcode(), nextPhi.Name()) + } + if nextPhi.IncomingBlock(0) != continuation || nextPhi.IncomingBlock(1) != other { + t.Fatal("replacement phi has incorrect predecessors") + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify split module: %v\n%s", err, mod.String()) + } +} + +func TestSplitBlockAfterRejectsInvalidPoints(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("invalid-split") + defer mod.Dispose() + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(ctx.VoidType(), nil, false)) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + ret := builder.CreateRetVoid() + + if _, err := splitBlockAfter(ctx, ret, "resume"); err == nil || + !strings.Contains(err.Error(), "not a call") { + t.Fatalf("non-call split error = %v", err) + } + + callBlock := ctx.AddBasicBlock(fn, "unterminated") + builder.SetInsertPointAtEnd(callBlock) + callee := llvm.AddFunction(mod, "callee", llvm.FunctionType(ctx.VoidType(), nil, false)) + call := builder.CreateCall(callee.GlobalValueType(), callee, nil, "") + if _, err := splitBlockAfter(ctx, call, "resume"); err == nil || + !strings.Contains(err.Error(), "no continuation") { + t.Fatalf("terminal call split error = %v", err) + } +} From dfb14ba035766e31ddeea7b458c3916092eeb468 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 15:54:08 +0800 Subject: [PATCH 25/40] internal/wasmresume: canonicalize live values in frames --- internal/wasmresume/spill.go | 108 ++++++++++++++ internal/wasmresume/spill_test.go | 237 ++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 internal/wasmresume/spill.go create mode 100644 internal/wasmresume/spill_test.go diff --git a/internal/wasmresume/spill.go b/internal/wasmresume/spill.go new file mode 100644 index 0000000000..897ce2c966 --- /dev/null +++ b/internal/wasmresume/spill.go @@ -0,0 +1,108 @@ +/* + * 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 wasmresume + +import ( + "fmt" + + "github.com/xgo-dev/llvm" +) + +func spillValue(ctx llvm.Context, targetData llvm.TargetData, value, field llvm.Value) error { + if value.IsAInstruction().IsNil() { + replaceValueUsesWithLoads(ctx, value, field, llvm.Value{}) + return nil + } + if !value.IsAAllocaInst().IsNil() { + if _, dynamic := persistentSlotType(value, slotAlloca); dynamic { + return fmt.Errorf("dynamic alloca %q requires separate frame storage", value.Name()) + } + if value.Alignment() > targetData.ABITypeAlignment(value.AllocatedType()) { + return fmt.Errorf("over-aligned alloca %q is not supported", value.Name()) + } + value.ReplaceAllUsesWith(field) + value.EraseFromParentAsInstruction() + return nil + } + if value.InstructionOpcode() == llvm.Call { + return fmt.Errorf("call result %q must be stored by its resume block", value.Name()) + } + + builder := ctx.NewBuilder() + defer builder.Dispose() + if value.InstructionOpcode() == llvm.PHI { + next := value + for !next.IsNil() && next.InstructionOpcode() == llvm.PHI { + next = llvm.NextInstruction(next) + } + if next.IsNil() { + return fmt.Errorf("phi %q has no insertion point", value.Name()) + } + builder.SetInsertPointBefore(next) + } else { + next := llvm.NextInstruction(value) + if next.IsNil() { + return fmt.Errorf("value %q has no insertion point", value.Name()) + } + builder.SetInsertPointBefore(next) + } + store := builder.CreateStore(value, field) + store.InstructionSetDebugLoc(value.InstructionDebugLoc()) + replaceValueUsesWithLoads(ctx, value, field, store) + return nil +} + +func replaceValueUsesWithLoads(ctx llvm.Context, value, field, skip llvm.Value) { + var users []llvm.Value + seen := make(map[llvm.Value]struct{}) + for use := value.FirstUse(); !use.IsNil(); use = use.NextUse() { + user := use.User() + if user == skip { + continue + } + if _, ok := seen[user]; !ok { + seen[user] = struct{}{} + users = append(users, user) + } + } + + builder := ctx.NewBuilder() + defer builder.Dispose() + for _, user := range users { + if user.InstructionOpcode() == llvm.PHI { + for i := 0; i < user.IncomingCount(); i++ { + if user.IncomingValue(i) != value { + continue + } + terminator := user.IncomingBlock(i).LastInstruction() + builder.SetInsertPointBefore(terminator) + load := builder.CreateLoad(value.Type(), field, value.Name()+".reload") + load.InstructionSetDebugLoc(user.InstructionDebugLoc()) + user.SetOperand(i, load) + } + continue + } + builder.SetInsertPointBefore(user) + load := builder.CreateLoad(value.Type(), field, value.Name()+".reload") + load.InstructionSetDebugLoc(user.InstructionDebugLoc()) + for i := 0; i < user.OperandsCount(); i++ { + if user.Operand(i) == value { + user.SetOperand(i, load) + } + } + } +} diff --git a/internal/wasmresume/spill_test.go b/internal/wasmresume/spill_test.go new file mode 100644 index 0000000000..6dd220d533 --- /dev/null +++ b/internal/wasmresume/spill_test.go @@ -0,0 +1,237 @@ +package wasmresume + +import ( + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestSpillValueStoresDefinitionAndReloadsUses(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("spill") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") + defer targetData.Dispose() + + i32 := ctx.Int32Type() + frameType := ctx.StructType([]llvm.Type{i32}, false) + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(i32, []llvm.Type{ + llvm.PointerType(frameType, 0), i32, + }, false)) + fn.Param(1).SetName("input") + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + field := builder.CreateStructGEP(frameType, fn.Param(0), 0, "field") + value := builder.CreateAdd(fn.Param(1), llvm.ConstInt(i32, 1, false), "value") + result := builder.CreateMul(value, value, "result") + builder.CreateRet(result) + + if err := spillValue(ctx, targetData, value, field); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify spilled module: %v\n%s", err, mod.String()) + } + ir := mod.String() + if !strings.Contains(ir, "store i32 %value, ptr %field") || + strings.Count(ir, "load i32, ptr %field") != 1 || + !strings.Contains(ir, "mul i32 %value.reload, %value.reload") { + t.Fatalf("value was not canonicalized through the frame:\n%s", ir) + } +} + +func TestSpillValueReloadsParameter(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("spill-parameter") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") + defer targetData.Dispose() + + i32 := ctx.Int32Type() + frameType := ctx.StructType([]llvm.Type{i32}, false) + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(i32, []llvm.Type{ + llvm.PointerType(frameType, 0), i32, + }, false)) + fn.Param(1).SetName("input") + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + field := builder.CreateStructGEP(frameType, fn.Param(0), 0, "field") + builder.CreateRet(fn.Param(1)) + + if err := spillValue(ctx, targetData, fn.Param(1), field); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify parameter reload: %v\n%s", err, mod.String()) + } + if !strings.Contains(mod.String(), "ret i32 %input.reload") { + t.Fatalf("parameter use was not loaded from the frame:\n%s", mod.String()) + } +} + +func TestSpillValueStoresPhiAfterPhiGroup(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("spill-phi-definition") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") + defer targetData.Dispose() + + i1 := ctx.Int1Type() + i32 := ctx.Int32Type() + frameType := ctx.StructType([]llvm.Type{i32}, false) + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(i32, []llvm.Type{ + llvm.PointerType(frameType, 0), i1, i32, + }, false)) + entry := ctx.AddBasicBlock(fn, "entry") + left := ctx.AddBasicBlock(fn, "left") + right := ctx.AddBasicBlock(fn, "right") + merge := ctx.AddBasicBlock(fn, "merge") + builder := ctx.NewBuilder() + defer builder.Dispose() + + builder.SetInsertPointAtEnd(entry) + field := builder.CreateStructGEP(frameType, fn.Param(0), 0, "field") + builder.CreateCondBr(fn.Param(1), left, right) + builder.SetInsertPointAtEnd(left) + builder.CreateBr(merge) + builder.SetInsertPointAtEnd(right) + builder.CreateBr(merge) + builder.SetInsertPointAtEnd(merge) + phi := builder.CreatePHI(i32, "selected") + phi.AddIncoming([]llvm.Value{ + fn.Param(2), llvm.ConstInt(i32, 0, false), + }, []llvm.BasicBlock{left, right}) + result := builder.CreateAdd(phi, llvm.ConstInt(i32, 1, false), "result") + builder.CreateRet(result) + + if err := spillValue(ctx, targetData, phi, field); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify phi spill: %v\n%s", err, mod.String()) + } + ir := mod.String() + if !strings.Contains(ir, "store i32 %selected, ptr %field") || + !strings.Contains(ir, "add i32 %selected.reload, 1") { + t.Fatalf("phi was not canonicalized through the frame:\n%s", ir) + } +} + +func TestSpillValueReloadsPhiOnIncomingEdge(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("spill-phi") + defer mod.Dispose() + + i1 := ctx.Int1Type() + i32 := ctx.Int32Type() + frameType := ctx.StructType([]llvm.Type{i32}, false) + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(i32, []llvm.Type{ + llvm.PointerType(frameType, 0), i1, i32, + }, false)) + entry := ctx.AddBasicBlock(fn, "entry") + left := ctx.AddBasicBlock(fn, "left") + right := ctx.AddBasicBlock(fn, "right") + merge := ctx.AddBasicBlock(fn, "merge") + builder := ctx.NewBuilder() + defer builder.Dispose() + + builder.SetInsertPointAtEnd(entry) + field := builder.CreateStructGEP(frameType, fn.Param(0), 0, "field") + store := builder.CreateStore(fn.Param(2), field) + builder.CreateCondBr(fn.Param(1), left, right) + builder.SetInsertPointAtEnd(left) + builder.CreateBr(merge) + builder.SetInsertPointAtEnd(right) + builder.CreateBr(merge) + builder.SetInsertPointAtEnd(merge) + phi := builder.CreatePHI(i32, "selected") + phi.AddIncoming( + []llvm.Value{fn.Param(2), llvm.ConstInt(i32, 0, false)}, + []llvm.BasicBlock{left, right}, + ) + builder.CreateRet(phi) + + replaceValueUsesWithLoads(ctx, fn.Param(2), field, store) + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify phi reload: %v\n%s", err, mod.String()) + } + if incoming := merge.FirstInstruction().IncomingValue(0); incoming.InstructionParent() != left { + t.Fatalf("phi reload is not on the incoming edge:\n%s", mod.String()) + } +} + +func TestSpillValueReplacesAllocaWithFrameAddress(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("spill-alloca") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") + defer targetData.Dispose() + + i32 := ctx.Int32Type() + frameType := ctx.StructType([]llvm.Type{i32}, false) + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(i32, []llvm.Type{ + llvm.PointerType(frameType, 0), + }, false)) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + field := builder.CreateStructGEP(frameType, fn.Param(0), 0, "field") + local := builder.CreateAlloca(i32, "local") + builder.CreateStore(llvm.ConstInt(i32, 9, false), local) + builder.CreateRet(builder.CreateLoad(i32, local, "result")) + + if err := spillValue(ctx, targetData, local, field); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify alloca frame address: %v\n%s", err, mod.String()) + } + if strings.Contains(mod.String(), " = alloca ") { + t.Fatalf("alloca remains after frame replacement:\n%s", mod.String()) + } +} + +func TestSpillValueRejectsUnsupportedDefinitions(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("spill-errors") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") + defer targetData.Dispose() + + i32 := ctx.Int32Type() + frameType := ctx.StructType([]llvm.Type{i32}, false) + callee := llvm.AddFunction(mod, "callee", llvm.FunctionType(i32, nil, false)) + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32}, false)) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + field := builder.CreateStructGEP(frameType, builder.CreateAlloca(frameType, "frame"), 0, "field") + call := builder.CreateCall(callee.GlobalValueType(), callee, nil, "call") + local := builder.CreateAlloca(i32, "aligned") + local.SetAlignment(16) + dynamic := builder.CreateArrayAlloca(i32, fn.Param(0), "dynamic") + builder.CreateRetVoid() + + if err := spillValue(ctx, targetData, call, field); err == nil || !strings.Contains(err.Error(), "resume block") { + t.Fatalf("call spill error = %v", err) + } + if err := spillValue(ctx, targetData, local, field); err == nil || !strings.Contains(err.Error(), "over-aligned") { + t.Fatalf("alloca spill error = %v", err) + } + if err := spillValue(ctx, targetData, dynamic, field); err == nil || !strings.Contains(err.Error(), "separate frame storage") { + t.Fatalf("dynamic alloca spill error = %v", err) + } +} From 98f9b116ecd11d40a1f7c02db9b2855863d93d2d Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 16:52:36 +0800 Subject: [PATCH 26/40] internal/wasmresume: lower direct resumable calls --- internal/wasmresume/abi.go | 74 ++++++ internal/wasmresume/leaf.go | 40 +--- internal/wasmresume/state.go | 302 +++++++++++++++++++++++ internal/wasmresume/state_test.go | 382 ++++++++++++++++++++++++++++++ 4 files changed, 767 insertions(+), 31 deletions(-) create mode 100644 internal/wasmresume/abi.go create mode 100644 internal/wasmresume/state.go create mode 100644 internal/wasmresume/state_test.go diff --git a/internal/wasmresume/abi.go b/internal/wasmresume/abi.go new file mode 100644 index 0000000000..076a1b9ee2 --- /dev/null +++ b/internal/wasmresume/abi.go @@ -0,0 +1,74 @@ +/* + * 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 wasmresume + +import ( + "fmt" + + "github.com/xgo-dev/llvm" +) + +const ( + resumeEntryPrefix = "__llgo_wasm_resume." + descriptorPrefix = "__llgo_wasm_resume_desc." + actionContinue = 0 + actionReturn = 1 +) + +type resumeABI struct { + ctx llvm.Context + ptr llvm.Type + uintptrType llvm.Type + entryType llvm.Type + descriptorType llvm.Type + contextType llvm.Type +} + +func newResumeABI(ctx llvm.Context, targetData llvm.TargetData) resumeABI { + ptr := llvm.PointerType(ctx.Int8Type(), 0) + uintptrType := ctx.IntType(targetData.PointerSize() * 8) + return resumeABI{ + ctx: ctx, + ptr: ptr, + uintptrType: uintptrType, + entryType: llvm.FunctionType(ctx.Int8Type(), []llvm.Type{ptr, ptr}, false), + descriptorType: ctx.StructType([]llvm.Type{ptr, uintptrType, uintptrType}, false), + contextType: ctx.StructType([]llvm.Type{ptr, ptr}, false), + } +} + +func (abi resumeABI) defineEntryAndDescriptor( + mod llvm.Module, layout frameLayout, +) (entry, descriptor llvm.Value, err error) { + fn := layout.plan.function + entryName := resumeEntryPrefix + fn.Name() + descriptorName := descriptorPrefix + fn.Name() + if !mod.NamedFunction(entryName).IsNil() || !mod.NamedGlobal(descriptorName).IsNil() { + return llvm.Value{}, llvm.Value{}, fmt.Errorf("%s: duplicate resumable descriptor", fn.Name()) + } + + entry = llvm.AddFunction(mod, entryName, abi.entryType) + entry.SetLinkage(llvm.InternalLinkage) + descriptor = llvm.AddGlobal(mod, abi.descriptorType, descriptorName) + descriptor.SetGlobalConstant(true) + descriptor.SetInitializer(abi.ctx.ConstStruct([]llvm.Value{ + entry, + llvm.ConstInt(abi.uintptrType, layout.size, false), + llvm.ConstInt(abi.uintptrType, uint64(layout.alignment), false), + }, false)) + return entry, descriptor, nil +} diff --git a/internal/wasmresume/leaf.go b/internal/wasmresume/leaf.go index 7ecf811173..c1bd7faea1 100644 --- a/internal/wasmresume/leaf.go +++ b/internal/wasmresume/leaf.go @@ -16,17 +16,7 @@ package wasmresume -import ( - "fmt" - - "github.com/xgo-dev/llvm" -) - -const ( - resumeEntryPrefix = "__llgo_wasm_resume." - descriptorPrefix = "__llgo_wasm_resume_desc." - actionReturn = 1 -) +import "github.com/xgo-dev/llvm" type loweredLeaf struct { layout frameLayout @@ -41,27 +31,23 @@ func emitLeafEntries(mod llvm.Module, targetData llvm.TargetData) ([]loweredLeaf if err != nil { return nil, err } + return emitLeafEntriesForLayouts(mod, newResumeABI(mod.Context(), targetData), layouts) +} +func emitLeafEntriesForLayouts( + mod llvm.Module, abi resumeABI, layouts []frameLayout, +) ([]loweredLeaf, error) { ctx := mod.Context() - ptr := llvm.PointerType(ctx.Int8Type(), 0) - uintptrType := ctx.IntType(targetData.PointerSize() * 8) - entryType := llvm.FunctionType(ctx.Int8Type(), []llvm.Type{ptr, ptr}, false) - descriptorType := ctx.StructType([]llvm.Type{ptr, uintptrType, uintptrType}, false) - var lowered []loweredLeaf for _, layout := range layouts { fn := layout.plan.function if fn.IsDeclaration() || len(layout.plan.calls) != 0 { continue } - entryName := resumeEntryPrefix + fn.Name() - descriptorName := descriptorPrefix + fn.Name() - if !mod.NamedFunction(entryName).IsNil() || !mod.NamedGlobal(descriptorName).IsNil() { - return nil, fmt.Errorf("%s: duplicate resumable descriptor", fn.Name()) + entry, descriptor, err := abi.defineEntryAndDescriptor(mod, layout) + if err != nil { + return nil, err } - - entry := llvm.AddFunction(mod, entryName, entryType) - entry.SetLinkage(llvm.InternalLinkage) block := ctx.AddBasicBlock(entry, "entry") builder := ctx.NewBuilder() builder.SetInsertPointAtEnd(block) @@ -86,14 +72,6 @@ func emitLeafEntries(mod llvm.Module, targetData llvm.TargetData) ([]loweredLeaf builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionReturn, false)) builder.Dispose() - descriptor := llvm.AddGlobal(mod, descriptorType, descriptorName) - descriptor.SetGlobalConstant(true) - descriptor.SetInitializer(ctx.ConstStruct([]llvm.Value{ - entry, - llvm.ConstInt(uintptrType, layout.size, false), - llvm.ConstInt(uintptrType, uint64(layout.alignment), false), - }, false)) - lowered = append(lowered, loweredLeaf{ layout: layout, entry: entry, descriptor: descriptor, }) diff --git a/internal/wasmresume/state.go b/internal/wasmresume/state.go new file mode 100644 index 0000000000..44de56c9f6 --- /dev/null +++ b/internal/wasmresume/state.go @@ -0,0 +1,302 @@ +/* + * 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 wasmresume + +import ( + "fmt" + + "github.com/xgo-dev/llvm" +) + +const ( + frameAllocName = "__llgo_wasm_resume_alloc" + frameFreeName = "__llgo_wasm_resume_free" +) + +type loweredState struct { + layout frameLayout + entry llvm.Value + descriptor llvm.Value +} + +func lowerPrototype(mod llvm.Module, targetData llvm.TargetData) ([]loweredState, error) { + layouts, err := layoutFrames(mod, targetData) + if err != nil { + return nil, err + } + for _, layout := range layouts { + if layout.plan.function.IsDeclaration() || len(layout.plan.calls) == 0 { + continue + } + if err := validateStateLayout(layout, targetData); err != nil { + return nil, fmt.Errorf("%s: %w", layout.plan.function.Name(), err) + } + } + + abi := newResumeABI(mod.Context(), targetData) + if _, err := emitLeafEntriesForLayouts(mod, abi, layouts); err != nil { + return nil, err + } + var lowered []loweredState + for _, layout := range layouts { + if layout.plan.function.IsDeclaration() || len(layout.plan.calls) == 0 { + continue + } + entry, descriptor, err := abi.defineEntryAndDescriptor(mod, layout) + if err != nil { + return nil, err + } + lowered = append(lowered, loweredState{ + layout: layout, entry: entry, descriptor: descriptor, + }) + } + for i := range lowered { + if err := lowerDirectStateMachine(mod, targetData, abi, &lowered[i]); err != nil { + return nil, err + } + } + return lowered, nil +} + +func validateStateLayout(layout frameLayout, targetData llvm.TargetData) error { + for _, slot := range layout.plan.slots { + if slot.kind != slotAlloca { + continue + } + if slot.dynamic { + return fmt.Errorf("dynamic alloca %q is not supported", slot.value.Name()) + } + if slot.value.Alignment() > targetData.ABITypeAlignment(slot.value.AllocatedType()) { + return fmt.Errorf("over-aligned alloca %q is not supported", slot.value.Name()) + } + } + for _, site := range layout.plan.calls { + call := site.call + if call.CalledValue().IsAFunction().IsNil() { + return fmt.Errorf("resume call %d is indirect", site.id) + } + if call.CalledFunctionType().IsFunctionVarArg() { + return fmt.Errorf("resume call %d is variadic", site.id) + } + if llvm.NextInstruction(call).IsNil() { + return fmt.Errorf("resume call %d has no continuation", site.id) + } + } + return nil +} + +func lowerDirectStateMachine( + mod llvm.Module, targetData llvm.TargetData, abi resumeABI, lowered *loweredState, +) error { + layout := lowered.layout + fn := layout.plan.function + ctx := mod.Context() + + var blocks []llvm.BasicBlock + var returns []llvm.Value + for block := fn.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + blocks = append(blocks, block) + for instr := block.FirstInstruction(); !instr.IsNil(); instr = llvm.NextInstruction(instr) { + if !instr.IsAReturnInst().IsNil() { + returns = append(returns, instr) + } + } + } + if len(blocks) == 0 { + return fmt.Errorf("%s: resumable definition has no body", fn.Name()) + } + originalEntry := blocks[0] + + dispatch := ctx.AddBasicBlock(lowered.entry, "dispatch") + for _, block := range blocks { + block.RemoveFromParent() + llvm.AppendExistingBasicBlock(lowered.entry, block) + } + + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(dispatch) + rawFrame := lowered.entry.Param(1) + fields := make(map[uint32]llvm.Value, len(layout.plan.slots)) + for _, slot := range layout.plan.slots { + fields[slot.id] = builder.CreateStructGEP( + layout.typ, rawFrame, layout.fieldIndex(slot.id), "", + ) + } + + for _, slot := range layout.plan.slots { + switch slot.kind { + case slotFunctionResult: + continue + case slotValue: + if slot.value.InstructionOpcode() == llvm.Call { + continue + } + } + if err := spillValue(ctx, targetData, slot.value, fields[slot.id]); err != nil { + return fmt.Errorf("%s: %w", fn.Name(), err) + } + } + + continuations := make(map[uint32]llvm.BasicBlock, len(layout.plan.calls)) + for _, site := range layout.plan.calls { + continuation, err := splitBlockAfter(ctx, site.call, fmt.Sprintf("resume.%d", site.id)) + if err != nil { + return fmt.Errorf("%s: call %d: %w", fn.Name(), site.id, err) + } + continuations[site.id] = continuation + if err := lowerDirectCall( + mod, abi, layout, fields, lowered.entry, site, continuation, + ); err != nil { + return fmt.Errorf("%s: call %d: %w", fn.Name(), site.id, err) + } + } + + for _, ret := range returns { + builder.SetInsertPointBefore(ret) + if layout.plan.resultSlot != 0 { + builder.CreateStore(ret.Operand(0), fields[layout.plan.resultSlot]) + } + next := builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionReturn, false)) + next.InstructionSetDebugLoc(ret.InstructionDebugLoc()) + ret.EraseFromParentAsInstruction() + } + + invalid := ctx.AddBasicBlock(lowered.entry, "invalid-pc") + builder.SetInsertPointAtEnd(invalid) + builder.CreateUnreachable() + builder.SetInsertPointAtEnd(dispatch) + pcField := builder.CreateStructGEP(layout.typ, rawFrame, 2, "") + pc := builder.CreateLoad(ctx.Int32Type(), pcField, "pc") + switchPC := builder.CreateSwitch(pc, invalid, len(continuations)+1) + switchPC.AddCase(llvm.ConstInt(ctx.Int32Type(), 0, false), originalEntry) + for _, site := range layout.plan.calls { + switchPC.AddCase( + llvm.ConstInt(ctx.Int32Type(), uint64(site.id), false), + continuations[site.id], + ) + } + return nil +} + +func lowerDirectCall( + mod llvm.Module, + abi resumeABI, + parentLayout frameLayout, + parentFields map[uint32]llvm.Value, + entry llvm.Value, + site callSite, + continuation llvm.BasicBlock, +) error { + ctx := mod.Context() + call := site.call + callBlock := call.InstructionParent() + callee := call.CalledValue() + descriptor := mod.NamedGlobal(descriptorPrefix + callee.Name()) + if descriptor.IsNil() { + descriptor = llvm.AddGlobal(mod, abi.descriptorType, descriptorPrefix+callee.Name()) + } + + alloc := declareFrameAllocator(mod, abi) + free := declareFrameFree(mod, abi) + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointBefore(call) + + sizeField := builder.CreateStructGEP(abi.descriptorType, descriptor, 1, "") + alignField := builder.CreateStructGEP(abi.descriptorType, descriptor, 2, "") + size := builder.CreateLoad(abi.uintptrType, sizeField, "child.size") + align := builder.CreateLoad(abi.uintptrType, alignField, "child.align") + child := builder.CreateCall(alloc.GlobalValueType(), alloc, []llvm.Value{size, align}, "child") + builder.CreateIntrinsic(ctx.VoidType(), llvm.LookupIntrinsicID("llvm.memset"), []llvm.Value{ + child, + llvm.ConstInt(ctx.Int8Type(), 0, false), + size, + llvm.ConstInt(ctx.Int1Type(), 0, false), + }, "") + + childType := callFramePrefix(ctx, call.CalledFunctionType()) + builder.CreateStore(entry.Param(1), builder.CreateStructGEP(childType, child, 0, "")) + builder.CreateStore(descriptor, builder.CreateStructGEP(childType, child, 1, "")) + builder.CreateStore( + llvm.ConstInt(ctx.Int32Type(), 0, false), + builder.CreateStructGEP(childType, child, 2, ""), + ) + for i := 0; i < call.CalledFunctionType().ParamTypesCount(); i++ { + builder.CreateStore( + call.Operand(i), + builder.CreateStructGEP(childType, child, frameHeaderFields+i, ""), + ) + } + builder.CreateStore( + llvm.ConstInt(ctx.Int32Type(), uint64(site.id), false), + builder.CreateStructGEP(parentLayout.typ, entry.Param(1), 2, ""), + ) + contextTop := builder.CreateStructGEP(abi.contextType, entry.Param(0), 0, "") + builder.CreateStore(child, contextTop) + + builder.SetInsertPointBefore(continuation.FirstInstruction()) + returnedField := builder.CreateStructGEP(abi.contextType, entry.Param(0), 1, "") + returned := builder.CreateLoad(abi.ptr, returnedField, "returned") + builder.CreateStore(llvm.ConstNull(abi.ptr), returnedField) + if site.resultSlot != 0 { + resultField := frameHeaderFields + call.CalledFunctionType().ParamTypesCount() + result := builder.CreateLoad( + call.Type(), builder.CreateStructGEP(childType, returned, resultField, ""), "call.result", + ) + builder.CreateStore(result, parentFields[site.resultSlot]) + replaceValueUsesWithLoads(ctx, call, parentFields[site.resultSlot], llvm.Value{}) + } + builder.CreateCall(free.GlobalValueType(), free, []llvm.Value{returned}, "") + + call.EraseFromParentAsInstruction() + terminator := callBlock.LastInstruction() + builder.SetInsertPointBefore(terminator) + builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionContinue, false)) + terminator.EraseFromParentAsInstruction() + return nil +} + +func callFramePrefix(ctx llvm.Context, typ llvm.Type) llvm.Type { + ptr := llvm.PointerType(ctx.Int8Type(), 0) + fields := []llvm.Type{ptr, ptr, ctx.Int32Type()} + fields = append(fields, typ.ParamTypes()...) + if result := typ.ReturnType(); result.TypeKind() != llvm.VoidTypeKind { + fields = append(fields, result) + } + return ctx.StructType(fields, false) +} + +func declareFrameAllocator(mod llvm.Module, abi resumeABI) llvm.Value { + fn := mod.NamedFunction(frameAllocName) + if fn.IsNil() { + fn = llvm.AddFunction(mod, frameAllocName, llvm.FunctionType( + abi.ptr, []llvm.Type{abi.uintptrType, abi.uintptrType}, false, + )) + } + return fn +} + +func declareFrameFree(mod llvm.Module, abi resumeABI) llvm.Value { + fn := mod.NamedFunction(frameFreeName) + if fn.IsNil() { + fn = llvm.AddFunction(mod, frameFreeName, llvm.FunctionType( + abi.ctx.VoidType(), []llvm.Type{abi.ptr}, false, + )) + } + return fn +} diff --git a/internal/wasmresume/state_test.go b/internal/wasmresume/state_test.go new file mode 100644 index 0000000000..59b121b905 --- /dev/null +++ b/internal/wasmresume/state_test.go @@ -0,0 +1,382 @@ +package wasmresume + +import ( + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestLowerPrototypeExecutesDirectCallStateMachine(t *testing.T) { + llvm.LinkInMCJIT() + if err := llvm.InitializeNativeTarget(); err != nil { + t.Fatal(err) + } + if err := llvm.InitializeNativeAsmPrinter(); err != nil { + t.Fatal(err) + } + + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("state-execution") + moduleOwned := true + defer func() { + if moduleOwned { + mod.Dispose() + } + }() + + triple := llvm.DefaultTargetTriple() + target, err := llvm.GetTargetFromTriple(triple) + if err != nil { + t.Fatal(err) + } + machine := target.CreateTargetMachine( + triple, "", "", llvm.CodeGenLevelNone, llvm.RelocDefault, llvm.CodeModelJITDefault, + ) + defer machine.Dispose() + targetData := machine.CreateTargetData() + defer targetData.Dispose() + mod.SetTarget(triple) + mod.SetDataLayout(targetData.String()) + + i32 := ctx.Int32Type() + sig := llvm.FunctionType(i32, []llvm.Type{i32}, false) + callee := llvm.AddFunction(mod, "callee", sig) + markFunction(ctx, callee) + calleeBlock := ctx.AddBasicBlock(callee, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(calleeBlock) + builder.CreateRet(builder.CreateAdd(callee.Param(0), llvm.ConstInt(i32, 1, false), "sum")) + + caller := llvm.AddFunction(mod, "caller", sig) + markFunction(ctx, caller) + callerBlock := ctx.AddBasicBlock(caller, "entry") + builder.SetInsertPointAtEnd(callerBlock) + before := builder.CreateAdd(caller.Param(0), llvm.ConstInt(i32, 2, false), "before") + call := builder.CreateCall(sig, callee, []llvm.Value{before}, "called") + markCall(ctx, call) + builder.CreateRet(builder.CreateMul(before, call, "result")) + + lowered, err := lowerPrototype(mod, targetData) + if err != nil { + t.Fatal(err) + } + if len(lowered) != 1 { + t.Fatalf("lowered states = %d, want 1", len(lowered)) + } + harness := defineStateMachineHarness(mod, targetData, lowered[0], 5) + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify executable state machine: %v\n%s", err, mod.String()) + } + + options := llvm.NewMCJITCompilerOptions() + options.SetMCJITOptimizationLevel(0) + engine, err := llvm.NewMCJITCompiler(mod, options) + if err != nil { + t.Fatal(err) + } + moduleOwned = false + defer engine.Dispose() + + result := engine.RunFunction(harness, nil) + defer result.Dispose() + if got := result.Int(true); got != 56 { + t.Fatalf("state machine result = %d, want 56", got) + } +} + +func TestLowerPrototypeBuildsDirectCallStateMachine(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("state") + defer mod.Dispose() + mod.SetDataLayout("e-m:e-p:32:32-i64:64-n32:64-S128") + targetData := llvm.NewTargetData(mod.DataLayout()) + defer targetData.Dispose() + + i32 := ctx.Int32Type() + sig := llvm.FunctionType(i32, []llvm.Type{i32}, false) + callee := llvm.AddFunction(mod, "callee", sig) + markFunction(ctx, callee) + calleeBlock := ctx.AddBasicBlock(callee, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(calleeBlock) + builder.CreateRet(builder.CreateAdd(callee.Param(0), llvm.ConstInt(i32, 1, false), "sum")) + + caller := llvm.AddFunction(mod, "caller", sig) + markFunction(ctx, caller) + caller.Param(0).SetName("input") + callerBlock := ctx.AddBasicBlock(caller, "entry") + builder.SetInsertPointAtEnd(callerBlock) + before := builder.CreateAdd(caller.Param(0), llvm.ConstInt(i32, 2, false), "before") + call := builder.CreateCall(sig, callee, []llvm.Value{before}, "called") + markCall(ctx, call) + result := builder.CreateMul(before, call, "result") + builder.CreateRet(result) + + lowered, err := lowerPrototype(mod, targetData) + if err != nil { + t.Fatal(err) + } + if len(lowered) != 1 || lowered[0].layout.plan.function != caller { + t.Fatalf("lowered states = %+v", lowered) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify state machine: %v\n%s", err, mod.String()) + } + + ir := mod.String() + for _, want := range []string{ + `@__llgo_wasm_resume_desc.callee = constant`, + `@__llgo_wasm_resume_desc.caller = constant`, + `define internal i8 @__llgo_wasm_resume.caller`, + `switch i32 %pc, label %invalid-pc [`, + `i32 0, label %entry`, + `i32 1, label %resume.1`, + `call ptr @__llgo_wasm_resume_alloc`, + `call void @llvm.memset`, + `ret i8 0`, + `%returned = load ptr`, + `call void @__llgo_wasm_resume_free`, + `ret i8 1`, + } { + if !strings.Contains(ir, want) { + t.Errorf("state machine is missing %q:\n%s", want, ir) + } + } +} + +func TestLowerPrototypeEmitsWasmObject(t *testing.T) { + llvm.InitializeAllTargetInfos() + llvm.InitializeAllTargets() + llvm.InitializeAllTargetMCs() + llvm.InitializeAllAsmPrinters() + + for _, triple := range []string{"wasm32-unknown-unknown", "wasm64-unknown-unknown"} { + t.Run(triple, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule(triple) + defer mod.Dispose() + + target, err := llvm.GetTargetFromTriple(triple) + if err != nil { + t.Fatal(err) + } + machine := target.CreateTargetMachine( + triple, "", "", llvm.CodeGenLevelNone, llvm.RelocDefault, llvm.CodeModelDefault, + ) + defer machine.Dispose() + targetData := machine.CreateTargetData() + defer targetData.Dispose() + mod.SetTarget(triple) + mod.SetDataLayout(targetData.String()) + + i32 := ctx.Int32Type() + sig := llvm.FunctionType(i32, []llvm.Type{i32}, false) + callee := llvm.AddFunction(mod, "callee", sig) + markFunction(ctx, callee) + calleeBlock := ctx.AddBasicBlock(callee, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(calleeBlock) + builder.CreateRet(callee.Param(0)) + + caller := llvm.AddFunction(mod, "caller", sig) + markFunction(ctx, caller) + callerBlock := ctx.AddBasicBlock(caller, "entry") + builder.SetInsertPointAtEnd(callerBlock) + call := builder.CreateCall(sig, callee, []llvm.Value{caller.Param(0)}, "called") + markCall(ctx, call) + builder.CreateRet(call) + + if _, err := lowerPrototype(mod, targetData); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify %s state machine: %v\n%s", triple, err, mod.String()) + } + object, err := machine.EmitToMemoryBuffer(mod, llvm.ObjectFile) + if err != nil { + t.Fatalf("emit %s state machine: %v\n%s", triple, err, mod.String()) + } + defer object.Dispose() + if data := object.Bytes(); len(data) < 4 || string(data[:4]) != "\x00asm" { + t.Fatalf("%s object does not have the WebAssembly header", triple) + } + }) + } +} + +func defineStateMachineHarness( + mod llvm.Module, targetData llvm.TargetData, lowered loweredState, input uint64, +) llvm.Value { + ctx := mod.Context() + abi := newResumeABI(ctx, targetData) + i8 := ctx.Int8Type() + i32 := ctx.Int32Type() + + childStorageType := llvm.ArrayType(i8, 1024) + childStorage := llvm.AddGlobal(mod, childStorageType, "child.storage") + childStorage.SetInitializer(llvm.ConstNull(childStorageType)) + childStorage.SetAlignment(16) + + alloc := mod.NamedFunction(frameAllocName) + block := ctx.AddBasicBlock(alloc, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateRet(childStorage) + + free := mod.NamedFunction(frameFreeName) + block = ctx.AddBasicBlock(free, "entry") + builder.SetInsertPointAtEnd(block) + builder.CreateRetVoid() + + root := llvm.AddGlobal(mod, lowered.layout.typ, "root.frame") + root.SetInitializer(llvm.ConstNull(lowered.layout.typ)) + root.SetAlignment(lowered.layout.alignment) + context := llvm.AddGlobal(mod, abi.contextType, "resume.context") + context.SetInitializer(llvm.ConstNull(abi.contextType)) + + run := llvm.AddFunction(mod, "run.state.machine", llvm.FunctionType(i32, nil, false)) + entryBlock := ctx.AddBasicBlock(run, "entry") + loopBlock := ctx.AddBasicBlock(run, "loop") + resumeBlock := ctx.AddBasicBlock(run, "resume") + popBlock := ctx.AddBasicBlock(run, "pop") + doneBlock := ctx.AddBasicBlock(run, "done") + failedBlock := ctx.AddBasicBlock(run, "failed") + + builder.SetInsertPointAtEnd(entryBlock) + builder.CreateStore( + llvm.ConstNull(abi.ptr), + builder.CreateStructGEP(lowered.layout.typ, root, 0, ""), + ) + builder.CreateStore( + lowered.descriptor, + builder.CreateStructGEP(lowered.layout.typ, root, 1, ""), + ) + builder.CreateStore( + llvm.ConstInt(i32, 0, false), + builder.CreateStructGEP(lowered.layout.typ, root, 2, ""), + ) + for _, slot := range lowered.layout.plan.slots { + if slot.kind == slotParameter { + builder.CreateStore( + llvm.ConstInt(slot.typ, input, false), + builder.CreateStructGEP( + lowered.layout.typ, root, lowered.layout.fieldIndex(slot.id), "", + ), + ) + } + } + builder.CreateStore(root, builder.CreateStructGEP(abi.contextType, context, 0, "")) + builder.CreateStore( + llvm.ConstNull(abi.ptr), + builder.CreateStructGEP(abi.contextType, context, 1, ""), + ) + builder.CreateBr(loopBlock) + + builder.SetInsertPointAtEnd(loopBlock) + topField := builder.CreateStructGEP(abi.contextType, context, 0, "") + top := builder.CreateLoad(abi.ptr, topField, "top") + builder.CreateCondBr( + builder.CreateICmp(llvm.IntNE, top, llvm.ConstNull(abi.ptr), ""), + resumeBlock, + doneBlock, + ) + + framePrefix := ctx.StructType([]llvm.Type{abi.ptr, abi.ptr, i32}, false) + builder.SetInsertPointAtEnd(resumeBlock) + descriptor := builder.CreateLoad( + abi.ptr, builder.CreateStructGEP(framePrefix, top, 1, ""), "descriptor", + ) + resume := builder.CreateLoad( + abi.ptr, builder.CreateStructGEP(abi.descriptorType, descriptor, 0, ""), "resume.entry", + ) + action := builder.CreateCall(abi.entryType, resume, []llvm.Value{context, top}, "action") + switchAction := builder.CreateSwitch(action, failedBlock, 2) + switchAction.AddCase(llvm.ConstInt(i8, actionContinue, false), loopBlock) + switchAction.AddCase(llvm.ConstInt(i8, actionReturn, false), popBlock) + + builder.SetInsertPointAtEnd(popBlock) + parent := builder.CreateLoad( + abi.ptr, builder.CreateStructGEP(framePrefix, top, 0, ""), "parent", + ) + builder.CreateStore(parent, topField) + builder.CreateStore(top, builder.CreateStructGEP(abi.contextType, context, 1, "")) + builder.CreateBr(loopBlock) + + builder.SetInsertPointAtEnd(doneBlock) + builder.CreateRet(builder.CreateLoad( + i32, + builder.CreateStructGEP( + lowered.layout.typ, root, + lowered.layout.fieldIndex(lowered.layout.plan.resultSlot), "", + ), + "result", + )) + + builder.SetInsertPointAtEnd(failedBlock) + builder.CreateRet(llvm.ConstInt(i32, ^uint64(0), true)) + return run +} + +func TestLowerPrototypeRejectsIndirectCalls(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("indirect") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") + defer targetData.Dispose() + + voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), []llvm.Type{ + llvm.PointerType(voidFn, 0), + }, false)) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + call := builder.CreateCall(voidFn, fn.Param(0), nil, "") + markCall(ctx, call) + builder.CreateRetVoid() + + if _, err := lowerPrototype(mod, targetData); err == nil || + !strings.Contains(err.Error(), "indirect") { + t.Fatalf("lowerPrototype error = %v", err) + } +} + +func TestLowerPrototypeRejectsDynamicAlloca(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("dynamic") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") + defer targetData.Dispose() + + i32 := ctx.Int32Type() + ptr := llvm.PointerType(i32, 0) + calleeType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr}, false) + callee := llvm.AddFunction(mod, "callee", calleeType) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32}, false)) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + local := builder.CreateArrayAlloca(i32, fn.Param(0), "local") + call := builder.CreateCall(calleeType, callee, []llvm.Value{local}, "") + markCall(ctx, call) + builder.CreateRetVoid() + + if _, err := lowerPrototype(mod, targetData); err == nil || + !strings.Contains(err.Error(), "dynamic alloca") { + t.Fatalf("lowerPrototype error = %v", err) + } +} From 3533954d969e715a67a78a23270b5e6ab0ac6ae2 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 16:57:26 +0800 Subject: [PATCH 27/40] internal/wasmresume: exercise nested direct resumes --- internal/wasmresume/state_test.go | 41 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/internal/wasmresume/state_test.go b/internal/wasmresume/state_test.go index 59b121b905..9fbcb04556 100644 --- a/internal/wasmresume/state_test.go +++ b/internal/wasmresume/state_test.go @@ -50,12 +50,22 @@ func TestLowerPrototypeExecutesDirectCallStateMachine(t *testing.T) { builder.SetInsertPointAtEnd(calleeBlock) builder.CreateRet(builder.CreateAdd(callee.Param(0), llvm.ConstInt(i32, 1, false), "sum")) + middle := llvm.AddFunction(mod, "middle", sig) + markFunction(ctx, middle) + middleBlock := ctx.AddBasicBlock(middle, "entry") + builder.SetInsertPointAtEnd(middleBlock) + first := builder.CreateCall(sig, callee, []llvm.Value{middle.Param(0)}, "first") + markCall(ctx, first) + second := builder.CreateCall(sig, callee, []llvm.Value{first}, "second") + markCall(ctx, second) + builder.CreateRet(builder.CreateMul(second, llvm.ConstInt(i32, 2, false), "middle.result")) + caller := llvm.AddFunction(mod, "caller", sig) markFunction(ctx, caller) callerBlock := ctx.AddBasicBlock(caller, "entry") builder.SetInsertPointAtEnd(callerBlock) before := builder.CreateAdd(caller.Param(0), llvm.ConstInt(i32, 2, false), "before") - call := builder.CreateCall(sig, callee, []llvm.Value{before}, "called") + call := builder.CreateCall(sig, middle, []llvm.Value{before}, "called") markCall(ctx, call) builder.CreateRet(builder.CreateMul(before, call, "result")) @@ -63,10 +73,20 @@ func TestLowerPrototypeExecutesDirectCallStateMachine(t *testing.T) { if err != nil { t.Fatal(err) } - if len(lowered) != 1 { - t.Fatalf("lowered states = %d, want 1", len(lowered)) + if len(lowered) != 2 { + t.Fatalf("lowered states = %d, want 2", len(lowered)) + } + var root loweredState + for _, state := range lowered { + if state.layout.plan.function.Name() == "caller" { + root = state + break + } } - harness := defineStateMachineHarness(mod, targetData, lowered[0], 5) + if root.entry.IsNil() { + t.Fatal("caller state machine was not lowered") + } + harness := defineStateMachineHarness(mod, targetData, root, 5) if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { t.Fatalf("verify executable state machine: %v\n%s", err, mod.String()) } @@ -82,8 +102,8 @@ func TestLowerPrototypeExecutesDirectCallStateMachine(t *testing.T) { result := engine.RunFunction(harness, nil) defer result.Dispose() - if got := result.Int(true); got != 56 { - t.Fatalf("state machine result = %d, want 56", got) + if got := result.Int(true); got != 126 { + t.Fatalf("state machine result = %d, want 126", got) } } @@ -223,13 +243,20 @@ func defineStateMachineHarness( childStorage := llvm.AddGlobal(mod, childStorageType, "child.storage") childStorage.SetInitializer(llvm.ConstNull(childStorageType)) childStorage.SetAlignment(16) + childOffset := llvm.AddGlobal(mod, i32, "child.offset") + childOffset.SetInitializer(llvm.ConstInt(i32, 0, false)) alloc := mod.NamedFunction(frameAllocName) block := ctx.AddBasicBlock(alloc, "entry") builder := ctx.NewBuilder() defer builder.Dispose() builder.SetInsertPointAtEnd(block) - builder.CreateRet(childStorage) + offset := builder.CreateLoad(i32, childOffset, "offset") + builder.CreateStore( + builder.CreateAdd(offset, llvm.ConstInt(i32, 256, false), ""), + childOffset, + ) + builder.CreateRet(builder.CreateInBoundsGEP(i8, childStorage, []llvm.Value{offset}, "frame")) free := mod.NamedFunction(frameFreeName) block = ctx.AddBasicBlock(free, "entry") From 0e1f84e29b487b81b9e9b52e84556ba46e3421e1 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 17:05:12 +0800 Subject: [PATCH 28/40] internal/wasmresume: link descriptors across packages --- internal/wasmresume/abi.go | 1 + internal/wasmresume/abi_test.go | 120 ++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 internal/wasmresume/abi_test.go diff --git a/internal/wasmresume/abi.go b/internal/wasmresume/abi.go index 076a1b9ee2..812bd78849 100644 --- a/internal/wasmresume/abi.go +++ b/internal/wasmresume/abi.go @@ -64,6 +64,7 @@ func (abi resumeABI) defineEntryAndDescriptor( entry = llvm.AddFunction(mod, entryName, abi.entryType) entry.SetLinkage(llvm.InternalLinkage) descriptor = llvm.AddGlobal(mod, abi.descriptorType, descriptorName) + descriptor.SetLinkage(fn.Linkage()) descriptor.SetGlobalConstant(true) descriptor.SetInitializer(abi.ctx.ConstStruct([]llvm.Value{ entry, diff --git a/internal/wasmresume/abi_test.go b/internal/wasmresume/abi_test.go new file mode 100644 index 0000000000..a23a068b7e --- /dev/null +++ b/internal/wasmresume/abi_test.go @@ -0,0 +1,120 @@ +package wasmresume + +import ( + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestDescriptorLinksAcrossModules(t *testing.T) { + llvm.InitializeAllTargetInfos() + llvm.InitializeAllTargets() + llvm.InitializeAllTargetMCs() + llvm.InitializeAllAsmPrinters() + + for _, triple := range []string{"wasm32-unknown-unknown", "wasm64-unknown-unknown"} { + t.Run(triple, func(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + + target, err := llvm.GetTargetFromTriple(triple) + if err != nil { + t.Fatal(err) + } + machine := target.CreateTargetMachine( + triple, "", "", llvm.CodeGenLevelNone, llvm.RelocDefault, llvm.CodeModelDefault, + ) + defer machine.Dispose() + targetData := machine.CreateTargetData() + defer targetData.Dispose() + + producer := ctx.NewModule("producer") + producerOwned := true + defer func() { + if producerOwned { + producer.Dispose() + } + }() + configureWasmModule(producer, triple, targetData) + + i32 := ctx.Int32Type() + sig := llvm.FunctionType(i32, []llvm.Type{i32}, false) + callee := llvm.AddFunction(producer, "example.com/dep.callee", sig) + callee.SetLinkage(llvm.LinkOnceAnyLinkage) + markFunction(ctx, callee) + block := ctx.AddBasicBlock(callee, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateRet(callee.Param(0)) + + if _, err := lowerPrototype(producer, targetData); err != nil { + t.Fatal(err) + } + descriptorName := descriptorPrefix + callee.Name() + definedDescriptor := producer.NamedGlobal(descriptorName) + if definedDescriptor.IsNil() || definedDescriptor.Initializer().IsNil() { + t.Fatal("producer descriptor is not defined") + } + if got := definedDescriptor.Linkage(); got != llvm.LinkOnceAnyLinkage { + t.Fatalf("producer descriptor linkage = %v, want linkonce", got) + } + + consumer := ctx.NewModule("consumer") + defer consumer.Dispose() + configureWasmModule(consumer, triple, targetData) + calleeDeclaration := llvm.AddFunction(consumer, callee.Name(), sig) + markFunction(ctx, calleeDeclaration) + caller := llvm.AddFunction(consumer, "example.com/main.caller", sig) + markFunction(ctx, caller) + block = ctx.AddBasicBlock(caller, "entry") + builder.SetInsertPointAtEnd(block) + call := builder.CreateCall(sig, calleeDeclaration, []llvm.Value{caller.Param(0)}, "called") + markCall(ctx, call) + builder.CreateRet(call) + + if _, err := lowerPrototype(consumer, targetData); err != nil { + t.Fatal(err) + } + referencedDescriptor := consumer.NamedGlobal(descriptorName) + if referencedDescriptor.IsNil() || !referencedDescriptor.Initializer().IsNil() { + t.Fatal("consumer descriptor is not an external declaration") + } + requireWasmObject(t, machine, producer) + requireWasmObject(t, machine, consumer) + + if err := llvm.LinkModules(consumer, producer); err != nil { + t.Fatal(err) + } + producerOwned = false + linkedDescriptor := consumer.NamedGlobal(descriptorName) + if linkedDescriptor.IsNil() || linkedDescriptor.Initializer().IsNil() { + t.Fatal("linked descriptor remains unresolved") + } + if got := linkedDescriptor.Linkage(); got != llvm.LinkOnceAnyLinkage { + t.Fatalf("linked descriptor linkage = %v, want linkonce", got) + } + if err := llvm.VerifyModule(consumer, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify linked module: %v\n%s", err, consumer.String()) + } + requireWasmObject(t, machine, consumer) + }) + } +} + +func configureWasmModule(mod llvm.Module, triple string, targetData llvm.TargetData) { + mod.SetTarget(triple) + mod.SetDataLayout(targetData.String()) +} + +func requireWasmObject(t *testing.T, machine llvm.TargetMachine, mod llvm.Module) { + t.Helper() + object, err := machine.EmitToMemoryBuffer(mod, llvm.ObjectFile) + if err != nil { + t.Fatalf("emit %s: %v\n%s", mod.Target(), err, mod.String()) + } + defer object.Dispose() + if data := object.Bytes(); len(data) < 4 || string(data[:4]) != "\x00asm" { + t.Fatalf("%s object does not have the WebAssembly header", mod.Target()) + } +} From 6f53fe396da9de807aafcbb5fb9ae949f9b4c40f Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 17:18:12 +0800 Subject: [PATCH 29/40] internal/wasmresume: lower indirect resumable calls --- internal/wasmresume/abi.go | 1 + internal/wasmresume/abi_test.go | 11 +++ internal/wasmresume/start.go | 91 +++++++++++++++++++ internal/wasmresume/start_test.go | 32 +++++++ internal/wasmresume/state.go | 79 ++++++++++------- internal/wasmresume/state_test.go | 143 +++++++++++++++++++++++++----- 6 files changed, 301 insertions(+), 56 deletions(-) create mode 100644 internal/wasmresume/start.go create mode 100644 internal/wasmresume/start_test.go diff --git a/internal/wasmresume/abi.go b/internal/wasmresume/abi.go index 812bd78849..0ee0c44bb9 100644 --- a/internal/wasmresume/abi.go +++ b/internal/wasmresume/abi.go @@ -24,6 +24,7 @@ import ( const ( resumeEntryPrefix = "__llgo_wasm_resume." + startEntryPrefix = "__llgo_wasm_start." descriptorPrefix = "__llgo_wasm_resume_desc." actionContinue = 0 actionReturn = 1 diff --git a/internal/wasmresume/abi_test.go b/internal/wasmresume/abi_test.go index a23a068b7e..8a987bb17a 100644 --- a/internal/wasmresume/abi_test.go +++ b/internal/wasmresume/abi_test.go @@ -47,6 +47,13 @@ func TestDescriptorLinksAcrossModules(t *testing.T) { defer builder.Dispose() builder.SetInsertPointAtEnd(block) builder.CreateRet(callee.Param(0)) + abi := newResumeABI(ctx, targetData) + startType := llvm.FunctionType( + abi.ptr, []llvm.Type{abi.ptr, i32}, false, + ) + startDeclaration := llvm.AddFunction( + producer, startEntryPrefix+callee.Name(), startType, + ) if _, err := lowerPrototype(producer, targetData); err != nil { t.Fatal(err) @@ -59,6 +66,10 @@ func TestDescriptorLinksAcrossModules(t *testing.T) { if got := definedDescriptor.Linkage(); got != llvm.LinkOnceAnyLinkage { t.Fatalf("producer descriptor linkage = %v, want linkonce", got) } + if startDeclaration.IsDeclaration() || + startDeclaration.Linkage() != llvm.LinkOnceAnyLinkage { + t.Fatal("producer did not define its predeclared start entry") + } consumer := ctx.NewModule("consumer") defer consumer.Dispose() diff --git a/internal/wasmresume/start.go b/internal/wasmresume/start.go new file mode 100644 index 0000000000..8f80fe3ba5 --- /dev/null +++ b/internal/wasmresume/start.go @@ -0,0 +1,91 @@ +/* + * 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 wasmresume + +import ( + "fmt" + + "github.com/xgo-dev/llvm" +) + +func emitStartEntriesForLayouts( + mod llvm.Module, abi resumeABI, layouts []frameLayout, +) error { + ctx := mod.Context() + var alloc llvm.Value + for _, layout := range layouts { + fn := layout.plan.function + if fn.IsDeclaration() || fn.GlobalValueType().IsFunctionVarArg() { + continue + } + descriptor := mod.NamedGlobal(descriptorPrefix + fn.Name()) + if descriptor.IsNil() || descriptor.Initializer().IsNil() { + return fmt.Errorf("%s: resumable descriptor is not defined", fn.Name()) + } + + params := append([]llvm.Type{abi.ptr}, fn.GlobalValueType().ParamTypes()...) + startType := llvm.FunctionType(abi.ptr, params, false) + startName := startEntryPrefix + fn.Name() + start := mod.NamedFunction(startName) + if start.IsNil() { + start = llvm.AddFunction(mod, startName, startType) + } else if !start.IsDeclaration() || start.GlobalValueType() != startType { + return fmt.Errorf("%s: incompatible resumable start entry", fn.Name()) + } + start.SetLinkage(fn.Linkage()) + if alloc.IsNil() { + alloc = declareFrameAllocator(mod, abi) + } + + block := ctx.AddBasicBlock(start, "entry") + builder := ctx.NewBuilder() + builder.SetInsertPointAtEnd(block) + child := builder.CreateCall(alloc.GlobalValueType(), alloc, []llvm.Value{ + llvm.ConstInt(abi.uintptrType, layout.size, false), + llvm.ConstInt(abi.uintptrType, uint64(layout.alignment), false), + }, "child") + builder.CreateIntrinsic(ctx.VoidType(), llvm.LookupIntrinsicID("llvm.memset"), []llvm.Value{ + child, + llvm.ConstInt(ctx.Int8Type(), 0, false), + llvm.ConstInt(abi.uintptrType, layout.size, false), + llvm.ConstInt(ctx.Int1Type(), 0, false), + }, "") + + contextTop := builder.CreateStructGEP(abi.contextType, start.Param(0), 0, "") + parent := builder.CreateLoad(abi.ptr, contextTop, "parent") + builder.CreateStore(parent, builder.CreateStructGEP(layout.typ, child, 0, "")) + builder.CreateStore(descriptor, builder.CreateStructGEP(layout.typ, child, 1, "")) + builder.CreateStore( + llvm.ConstInt(ctx.Int32Type(), 0, false), + builder.CreateStructGEP(layout.typ, child, 2, ""), + ) + param := 1 + for _, slot := range layout.plan.slots { + if slot.kind != slotParameter { + continue + } + builder.CreateStore( + start.Param(param), + builder.CreateStructGEP(layout.typ, child, layout.fieldIndex(slot.id), ""), + ) + param++ + } + builder.CreateRet(child) + builder.Dispose() + } + return nil +} diff --git a/internal/wasmresume/start_test.go b/internal/wasmresume/start_test.go new file mode 100644 index 0000000000..a205149628 --- /dev/null +++ b/internal/wasmresume/start_test.go @@ -0,0 +1,32 @@ +package wasmresume + +import ( + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestStartEntryRejectsIncompatibleDeclaration(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("incompatible-start") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") + defer targetData.Dispose() + + voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) + fn := llvm.AddFunction(mod, "leaf", voidFn) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateRetVoid() + llvm.AddFunction(mod, startEntryPrefix+fn.Name(), voidFn) + + if _, err := lowerPrototype(mod, targetData); err == nil || + !strings.Contains(err.Error(), "incompatible resumable start entry") { + t.Fatalf("lowerPrototype error = %v", err) + } +} diff --git a/internal/wasmresume/state.go b/internal/wasmresume/state.go index 44de56c9f6..b8d5c2ab8c 100644 --- a/internal/wasmresume/state.go +++ b/internal/wasmresume/state.go @@ -64,8 +64,11 @@ func lowerPrototype(mod llvm.Module, targetData llvm.TargetData) ([]loweredState layout: layout, entry: entry, descriptor: descriptor, }) } + if err := emitStartEntriesForLayouts(mod, abi, layouts); err != nil { + return nil, err + } for i := range lowered { - if err := lowerDirectStateMachine(mod, targetData, abi, &lowered[i]); err != nil { + if err := lowerStateMachine(mod, targetData, abi, &lowered[i]); err != nil { return nil, err } } @@ -86,9 +89,6 @@ func validateStateLayout(layout frameLayout, targetData llvm.TargetData) error { } for _, site := range layout.plan.calls { call := site.call - if call.CalledValue().IsAFunction().IsNil() { - return fmt.Errorf("resume call %d is indirect", site.id) - } if call.CalledFunctionType().IsFunctionVarArg() { return fmt.Errorf("resume call %d is variadic", site.id) } @@ -99,7 +99,7 @@ func validateStateLayout(layout frameLayout, targetData llvm.TargetData) error { return nil } -func lowerDirectStateMachine( +func lowerStateMachine( mod llvm.Module, targetData llvm.TargetData, abi resumeABI, lowered *loweredState, ) error { layout := lowered.layout @@ -159,7 +159,7 @@ func lowerDirectStateMachine( return fmt.Errorf("%s: call %d: %w", fn.Name(), site.id, err) } continuations[site.id] = continuation - if err := lowerDirectCall( + if err := lowerResumeCall( mod, abi, layout, fields, lowered.entry, site, continuation, ); err != nil { return fmt.Errorf("%s: call %d: %w", fn.Name(), site.id, err) @@ -193,7 +193,7 @@ func lowerDirectStateMachine( return nil } -func lowerDirectCall( +func lowerResumeCall( mod llvm.Module, abi resumeABI, parentLayout frameLayout, @@ -206,41 +206,52 @@ func lowerDirectCall( call := site.call callBlock := call.InstructionParent() callee := call.CalledValue() - descriptor := mod.NamedGlobal(descriptorPrefix + callee.Name()) - if descriptor.IsNil() { - descriptor = llvm.AddGlobal(mod, abi.descriptorType, descriptorPrefix+callee.Name()) - } - - alloc := declareFrameAllocator(mod, abi) free := declareFrameFree(mod, abi) builder := ctx.NewBuilder() defer builder.Dispose() builder.SetInsertPointBefore(call) - sizeField := builder.CreateStructGEP(abi.descriptorType, descriptor, 1, "") - alignField := builder.CreateStructGEP(abi.descriptorType, descriptor, 2, "") - size := builder.CreateLoad(abi.uintptrType, sizeField, "child.size") - align := builder.CreateLoad(abi.uintptrType, alignField, "child.align") - child := builder.CreateCall(alloc.GlobalValueType(), alloc, []llvm.Value{size, align}, "child") - builder.CreateIntrinsic(ctx.VoidType(), llvm.LookupIntrinsicID("llvm.memset"), []llvm.Value{ - child, - llvm.ConstInt(ctx.Int8Type(), 0, false), - size, - llvm.ConstInt(ctx.Int1Type(), 0, false), - }, "") - childType := callFramePrefix(ctx, call.CalledFunctionType()) - builder.CreateStore(entry.Param(1), builder.CreateStructGEP(childType, child, 0, "")) - builder.CreateStore(descriptor, builder.CreateStructGEP(childType, child, 1, "")) - builder.CreateStore( - llvm.ConstInt(ctx.Int32Type(), 0, false), - builder.CreateStructGEP(childType, child, 2, ""), - ) - for i := 0; i < call.CalledFunctionType().ParamTypesCount(); i++ { + var child llvm.Value + if callee.IsAFunction().IsNil() { + params := append([]llvm.Type{abi.ptr}, call.CalledFunctionType().ParamTypes()...) + startType := llvm.FunctionType(abi.ptr, params, false) + args := make([]llvm.Value, call.CalledFunctionType().ParamTypesCount()+1) + args[0] = entry.Param(0) + for i := 1; i < len(args); i++ { + args[i] = call.Operand(i - 1) + } + child = builder.CreateCall(startType, callee, args, "child") + } else { + alloc := declareFrameAllocator(mod, abi) + descriptor := mod.NamedGlobal(descriptorPrefix + callee.Name()) + if descriptor.IsNil() { + descriptor = llvm.AddGlobal(mod, abi.descriptorType, descriptorPrefix+callee.Name()) + } + sizeField := builder.CreateStructGEP(abi.descriptorType, descriptor, 1, "") + alignField := builder.CreateStructGEP(abi.descriptorType, descriptor, 2, "") + size := builder.CreateLoad(abi.uintptrType, sizeField, "child.size") + align := builder.CreateLoad(abi.uintptrType, alignField, "child.align") + child = builder.CreateCall(alloc.GlobalValueType(), alloc, []llvm.Value{size, align}, "child") + builder.CreateIntrinsic(ctx.VoidType(), llvm.LookupIntrinsicID("llvm.memset"), []llvm.Value{ + child, + llvm.ConstInt(ctx.Int8Type(), 0, false), + size, + llvm.ConstInt(ctx.Int1Type(), 0, false), + }, "") + + builder.CreateStore(entry.Param(1), builder.CreateStructGEP(childType, child, 0, "")) + builder.CreateStore(descriptor, builder.CreateStructGEP(childType, child, 1, "")) builder.CreateStore( - call.Operand(i), - builder.CreateStructGEP(childType, child, frameHeaderFields+i, ""), + llvm.ConstInt(ctx.Int32Type(), 0, false), + builder.CreateStructGEP(childType, child, 2, ""), ) + for i := 0; i < call.CalledFunctionType().ParamTypesCount(); i++ { + builder.CreateStore( + call.Operand(i), + builder.CreateStructGEP(childType, child, frameHeaderFields+i, ""), + ) + } } builder.CreateStore( llvm.ConstInt(ctx.Int32Type(), uint64(site.id), false), diff --git a/internal/wasmresume/state_test.go b/internal/wasmresume/state_test.go index 9fbcb04556..841cd5d2aa 100644 --- a/internal/wasmresume/state_test.go +++ b/internal/wasmresume/state_test.go @@ -86,7 +86,9 @@ func TestLowerPrototypeExecutesDirectCallStateMachine(t *testing.T) { if root.entry.IsNil() { t.Fatal("caller state machine was not lowered") } - harness := defineStateMachineHarness(mod, targetData, root, 5) + harness := defineStateMachineHarness( + mod, targetData, root, []llvm.Value{llvm.ConstInt(i32, 5, false)}, + ) if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { t.Fatalf("verify executable state machine: %v\n%s", err, mod.String()) } @@ -205,11 +207,13 @@ func TestLowerPrototypeEmitsWasmObject(t *testing.T) { builder.SetInsertPointAtEnd(calleeBlock) builder.CreateRet(callee.Param(0)) - caller := llvm.AddFunction(mod, "caller", sig) + ptr := llvm.PointerType(ctx.Int8Type(), 0) + callerType := llvm.FunctionType(i32, []llvm.Type{ptr, i32}, false) + caller := llvm.AddFunction(mod, "caller", callerType) markFunction(ctx, caller) callerBlock := ctx.AddBasicBlock(caller, "entry") builder.SetInsertPointAtEnd(callerBlock) - call := builder.CreateCall(sig, callee, []llvm.Value{caller.Param(0)}, "called") + call := builder.CreateCall(sig, caller.Param(0), []llvm.Value{caller.Param(1)}, "called") markCall(ctx, call) builder.CreateRet(call) @@ -231,8 +235,99 @@ func TestLowerPrototypeEmitsWasmObject(t *testing.T) { } } +func TestLowerPrototypeExecutesIndirectStart(t *testing.T) { + llvm.LinkInMCJIT() + if err := llvm.InitializeNativeTarget(); err != nil { + t.Fatal(err) + } + if err := llvm.InitializeNativeAsmPrinter(); err != nil { + t.Fatal(err) + } + + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("indirect-execution") + moduleOwned := true + defer func() { + if moduleOwned { + mod.Dispose() + } + }() + + triple := llvm.DefaultTargetTriple() + target, err := llvm.GetTargetFromTriple(triple) + if err != nil { + t.Fatal(err) + } + machine := target.CreateTargetMachine( + triple, "", "", llvm.CodeGenLevelNone, llvm.RelocDefault, llvm.CodeModelJITDefault, + ) + defer machine.Dispose() + targetData := machine.CreateTargetData() + defer targetData.Dispose() + mod.SetTarget(triple) + mod.SetDataLayout(targetData.String()) + + i32 := ctx.Int32Type() + sig := llvm.FunctionType(i32, []llvm.Type{i32}, false) + callee := llvm.AddFunction(mod, "callee", sig) + markFunction(ctx, callee) + block := ctx.AddBasicBlock(callee, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateRet(builder.CreateAdd(callee.Param(0), llvm.ConstInt(i32, 1, false), "result")) + + ptr := llvm.PointerType(ctx.Int8Type(), 0) + callerType := llvm.FunctionType(i32, []llvm.Type{ptr, i32}, false) + caller := llvm.AddFunction(mod, "caller", callerType) + markFunction(ctx, caller) + block = ctx.AddBasicBlock(caller, "entry") + builder.SetInsertPointAtEnd(block) + call := builder.CreateCall(sig, caller.Param(0), []llvm.Value{caller.Param(1)}, "called") + markCall(ctx, call) + builder.CreateRet(builder.CreateMul(call, llvm.ConstInt(i32, 2, false), "result")) + + lowered, err := lowerPrototype(mod, targetData) + if err != nil { + t.Fatal(err) + } + var root loweredState + for _, state := range lowered { + if state.layout.plan.function == caller { + root = state + break + } + } + start := mod.NamedFunction(startEntryPrefix + callee.Name()) + if root.entry.IsNil() || start.IsNil() { + t.Fatal("indirect state machine entries were not emitted") + } + harness := defineStateMachineHarness(mod, targetData, root, []llvm.Value{ + start, llvm.ConstInt(i32, 5, false), + }) + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify indirect state machine: %v\n%s", err, mod.String()) + } + + options := llvm.NewMCJITCompilerOptions() + options.SetMCJITOptimizationLevel(0) + engine, err := llvm.NewMCJITCompiler(mod, options) + if err != nil { + t.Fatal(err) + } + moduleOwned = false + defer engine.Dispose() + + result := engine.RunFunction(harness, nil) + defer result.Dispose() + if got := result.Int(true); got != 12 { + t.Fatalf("indirect state machine result = %d, want 12", got) + } +} + func defineStateMachineHarness( - mod llvm.Module, targetData llvm.TargetData, lowered loweredState, input uint64, + mod llvm.Module, targetData llvm.TargetData, lowered loweredState, params []llvm.Value, ) llvm.Value { ctx := mod.Context() abi := newResumeABI(ctx, targetData) @@ -290,14 +385,16 @@ func defineStateMachineHarness( llvm.ConstInt(i32, 0, false), builder.CreateStructGEP(lowered.layout.typ, root, 2, ""), ) + param := 0 for _, slot := range lowered.layout.plan.slots { if slot.kind == slotParameter { builder.CreateStore( - llvm.ConstInt(slot.typ, input, false), + params[param], builder.CreateStructGEP( lowered.layout.typ, root, lowered.layout.fieldIndex(slot.id), "", ), ) + param++ } } builder.CreateStore(root, builder.CreateStructGEP(abi.contextType, context, 0, "")) @@ -352,58 +449,60 @@ func defineStateMachineHarness( return run } -func TestLowerPrototypeRejectsIndirectCalls(t *testing.T) { +func TestLowerPrototypeRejectsDynamicAlloca(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() - mod := ctx.NewModule("indirect") + mod := ctx.NewModule("dynamic") defer mod.Dispose() targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") defer targetData.Dispose() - voidFn := llvm.FunctionType(ctx.VoidType(), nil, false) - fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), []llvm.Type{ - llvm.PointerType(voidFn, 0), - }, false)) + i32 := ctx.Int32Type() + ptr := llvm.PointerType(i32, 0) + calleeType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr}, false) + callee := llvm.AddFunction(mod, "callee", calleeType) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32}, false)) markFunction(ctx, fn) block := ctx.AddBasicBlock(fn, "entry") builder := ctx.NewBuilder() defer builder.Dispose() builder.SetInsertPointAtEnd(block) - call := builder.CreateCall(voidFn, fn.Param(0), nil, "") + local := builder.CreateArrayAlloca(i32, fn.Param(0), "local") + call := builder.CreateCall(calleeType, callee, []llvm.Value{local}, "") markCall(ctx, call) builder.CreateRetVoid() if _, err := lowerPrototype(mod, targetData); err == nil || - !strings.Contains(err.Error(), "indirect") { + !strings.Contains(err.Error(), "dynamic alloca") { t.Fatalf("lowerPrototype error = %v", err) } } -func TestLowerPrototypeRejectsDynamicAlloca(t *testing.T) { +func TestLowerPrototypeRejectsVariadicResumeCall(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() - mod := ctx.NewModule("dynamic") + mod := ctx.NewModule("variadic") defer mod.Dispose() targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") defer targetData.Dispose() i32 := ctx.Int32Type() - ptr := llvm.PointerType(i32, 0) - calleeType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr}, false) - callee := llvm.AddFunction(mod, "callee", calleeType) - fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32}, false)) + variadicType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32}, true) + callee := llvm.AddFunction(mod, "callee", variadicType) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), nil, false)) markFunction(ctx, fn) block := ctx.AddBasicBlock(fn, "entry") builder := ctx.NewBuilder() defer builder.Dispose() builder.SetInsertPointAtEnd(block) - local := builder.CreateArrayAlloca(i32, fn.Param(0), "local") - call := builder.CreateCall(calleeType, callee, []llvm.Value{local}, "") + call := builder.CreateCall( + variadicType, callee, []llvm.Value{llvm.ConstInt(i32, 1, false)}, "", + ) markCall(ctx, call) builder.CreateRetVoid() if _, err := lowerPrototype(mod, targetData); err == nil || - !strings.Contains(err.Error(), "dynamic alloca") { + !strings.Contains(err.Error(), "variadic") { t.Fatalf("lowerPrototype error = %v", err) } } From 64e72cddbbd8f93039ae30390bc7204bcdcd111c Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 17:46:48 +0800 Subject: [PATCH 30/40] ssa/wasm: route function values through resume entries --- internal/wasmresume/abi.go | 5 ++ internal/wasmresume/start.go | 2 +- internal/wasmresume/state.go | 3 +- internal/wasmresume/state_test.go | 15 +++-- ssa/abitype.go | 2 + ssa/closure_wrap.go | 6 +- ssa/expr.go | 10 +++- ssa/wasm_resume.go | 14 +++++ ssa/wasm_resume_test.go | 99 ++++++++++++++++++++++++++++++- 9 files changed, 143 insertions(+), 13 deletions(-) diff --git a/internal/wasmresume/abi.go b/internal/wasmresume/abi.go index 0ee0c44bb9..7002b6f737 100644 --- a/internal/wasmresume/abi.go +++ b/internal/wasmresume/abi.go @@ -30,6 +30,11 @@ const ( actionReturn = 1 ) +// StartSymbol returns the resumable start entry for a Go function symbol. +func StartSymbol(function string) string { + return startEntryPrefix + function +} + type resumeABI struct { ctx llvm.Context ptr llvm.Type diff --git a/internal/wasmresume/start.go b/internal/wasmresume/start.go index 8f80fe3ba5..fe455e6508 100644 --- a/internal/wasmresume/start.go +++ b/internal/wasmresume/start.go @@ -39,7 +39,7 @@ func emitStartEntriesForLayouts( params := append([]llvm.Type{abi.ptr}, fn.GlobalValueType().ParamTypes()...) startType := llvm.FunctionType(abi.ptr, params, false) - startName := startEntryPrefix + fn.Name() + startName := StartSymbol(fn.Name()) start := mod.NamedFunction(startName) if start.IsNil() { start = llvm.AddFunction(mod, startName, startType) diff --git a/internal/wasmresume/state.go b/internal/wasmresume/state.go index b8d5c2ab8c..b2a1fd1a13 100644 --- a/internal/wasmresume/state.go +++ b/internal/wasmresume/state.go @@ -18,6 +18,7 @@ package wasmresume import ( "fmt" + "strings" "github.com/xgo-dev/llvm" ) @@ -213,7 +214,7 @@ func lowerResumeCall( childType := callFramePrefix(ctx, call.CalledFunctionType()) var child llvm.Value - if callee.IsAFunction().IsNil() { + if callee.IsAFunction().IsNil() || strings.HasPrefix(callee.Name(), startEntryPrefix) { params := append([]llvm.Type{abi.ptr}, call.CalledFunctionType().ParamTypes()...) startType := llvm.FunctionType(abi.ptr, params, false) args := make([]llvm.Value, call.CalledFunctionType().ParamTypesCount()+1) diff --git a/internal/wasmresume/state_test.go b/internal/wasmresume/state_test.go index 841cd5d2aa..6320871a71 100644 --- a/internal/wasmresume/state_test.go +++ b/internal/wasmresume/state_test.go @@ -279,14 +279,18 @@ func TestLowerPrototypeExecutesIndirectStart(t *testing.T) { builder.CreateRet(builder.CreateAdd(callee.Param(0), llvm.ConstInt(i32, 1, false), "result")) ptr := llvm.PointerType(ctx.Int8Type(), 0) + startType := llvm.FunctionType(ptr, []llvm.Type{ptr, i32}, false) + start := llvm.AddFunction(mod, StartSymbol(callee.Name()), startType) callerType := llvm.FunctionType(i32, []llvm.Type{ptr, i32}, false) caller := llvm.AddFunction(mod, "caller", callerType) markFunction(ctx, caller) block = ctx.AddBasicBlock(caller, "entry") builder.SetInsertPointAtEnd(block) - call := builder.CreateCall(sig, caller.Param(0), []llvm.Value{caller.Param(1)}, "called") - markCall(ctx, call) - builder.CreateRet(builder.CreateMul(call, llvm.ConstInt(i32, 2, false), "result")) + dynamicCall := builder.CreateCall(sig, caller.Param(0), []llvm.Value{caller.Param(1)}, "dynamic") + markCall(ctx, dynamicCall) + constantCall := builder.CreateCall(sig, start, []llvm.Value{dynamicCall}, "constant") + markCall(ctx, constantCall) + builder.CreateRet(builder.CreateMul(constantCall, llvm.ConstInt(i32, 2, false), "result")) lowered, err := lowerPrototype(mod, targetData) if err != nil { @@ -299,7 +303,6 @@ func TestLowerPrototypeExecutesIndirectStart(t *testing.T) { break } } - start := mod.NamedFunction(startEntryPrefix + callee.Name()) if root.entry.IsNil() || start.IsNil() { t.Fatal("indirect state machine entries were not emitted") } @@ -321,8 +324,8 @@ func TestLowerPrototypeExecutesIndirectStart(t *testing.T) { result := engine.RunFunction(harness, nil) defer result.Dispose() - if got := result.Int(true); got != 12 { - t.Fatalf("indirect state machine result = %d, want 12", got) + if got := result.Int(true); got != 14 { + t.Fatalf("indirect state machine result = %d, want 14", got) } } diff --git a/ssa/abitype.go b/ssa/abitype.go index 8d5e728c59..bec470ce16 100644 --- a/ssa/abitype.go +++ b/ssa/abitype.go @@ -511,6 +511,8 @@ func (b Builder) abiUncommonMethods(t types.Type, methods []*types.Selection) ll pSig := types.NewSignature(pRecv, mSig.Params(), mSig.Results(), mSig.Variadic()) ifn = b.abiMethodFunc(anonymous, pkg, mName, pSig).impl } + ifn = b.Pkg.wasmResumeStart(ifn) + tfn = b.Pkg.wasmResumeStart(tfn) var values []llvm.Value values = append(values, name) ftyp := funcType(prog, m.Type()) diff --git a/ssa/closure_wrap.go b/ssa/closure_wrap.go index 470a299caf..f2a245f589 100644 --- a/ssa/closure_wrap.go +++ b/ssa/closure_wrap.go @@ -76,7 +76,11 @@ func (p Package) closureWrapDecl(fn Expr, sig *types.Signature) Function { } ctx := types.NewParam(token.NoPos, nil, closureCtx, types.Typ[types.UnsafePointer]) sigCtx := FuncAddCtx(ctx, sig) - wrap := p.NewFunc(name, sigCtx, InC) + background := InC + if p.Prog.WasmResumeABIEnabled() { + background = InGo + } + wrap := p.NewFunc(name, sigCtx, background) wrap.impl.SetLinkage(llvm.LinkOnceAnyLinkage) b := wrap.MakeBody(1) args := closureWrapArgs(wrap) diff --git a/ssa/expr.go b/ssa/expr.go index cd351e0cae..dc972c3482 100644 --- a/ssa/expr.go +++ b/ssa/expr.go @@ -1202,7 +1202,12 @@ func (b Builder) MakeClosure(fn Expr, bindings []Expr) Expr { ptr := b.aggregateAllocU(prog.rawType(tctx), llvmFields(bindings, tctx, b)...) data = ptr } - return b.aggregateValue(prog.Closure(removeCtx(sig)), fn.impl, data) + code := fn.impl + if prog.WasmResumeABIEnabled() && closureCtxParam(sig) == nil { + code = b.Pkg.closureWrapDecl(fn, sig).impl + } + code = b.Pkg.wasmResumeStart(code) + return b.aggregateValue(prog.Closure(removeCtx(sig)), code, data) } // ----------------------------------------------------------------------------- @@ -1746,6 +1751,9 @@ func checkExpr(v Expr, t types.Type, b Builder) Expr { v, data = b.Pkg.closureStub(b, v, sig, origKind) } } + if origKind == vkFuncDecl { + v.impl = b.Pkg.wasmResumeStart(v.impl) + } return b.aggregateValue(tclosure, v.impl, data.impl) } if types.Identical(v.raw.Type, t) || !types.AssignableTo(v.raw.Type, t) { diff --git a/ssa/wasm_resume.go b/ssa/wasm_resume.go index 80dc3a8684..015b4940b6 100644 --- a/ssa/wasm_resume.go +++ b/ssa/wasm_resume.go @@ -40,6 +40,20 @@ func (p Program) markWasmResumeFunction(fn llvm.Value) { fn.AddFunctionAttr(p.ctx.CreateStringAttribute(wasmresume.FunctionAttribute, "1")) } +func (p Package) wasmResumeStart(fn llvm.Value) llvm.Value { + if !p.Prog.WasmResumeABIEnabled() { + return fn + } + name := wasmresume.StartSymbol(fn.Name()) + if start := p.mod.NamedFunction(name); !start.IsNil() { + return start + } + fnType := fn.GlobalValueType() + params := append([]llvm.Type{p.Prog.tyVoidPtr()}, fnType.ParamTypes()...) + startType := llvm.FunctionType(p.Prog.tyVoidPtr(), params, false) + return llvm.AddFunction(p.mod, name, startType) +} + func (b Builder) markWasmResumeCall(call llvm.Value, background Background) { if background != InGo || !b.Prog.WasmResumeABIEnabled() { return diff --git a/ssa/wasm_resume_test.go b/ssa/wasm_resume_test.go index 460c8ee71d..0c13de664e 100644 --- a/ssa/wasm_resume_test.go +++ b/ssa/wasm_resume_test.go @@ -19,6 +19,9 @@ package ssa import ( + "go/importer" + "go/token" + "go/types" "strings" "testing" @@ -44,12 +47,15 @@ func TestWasmResumeABIInventoriesGoCalls(t *testing.T) { b.Return() ir := pkg.String() - if got := strings.Count(ir, "!"+wasmresume.CallMetadata); got != 2 { - t.Fatalf("resumable call marker count = %d, want 2:\n%s", got, ir) + if got := strings.Count(ir, "!"+wasmresume.CallMetadata); got != 3 { + t.Fatalf("resumable call marker count = %d, want 3:\n%s", got, ir) } if !strings.Contains(ir, `"`+wasmresume.FunctionAttribute+`"="1"`) { t.Fatalf("Go functions are not marked for resumable lowering:\n%s", ir) } + if !strings.Contains(ir, "@"+wasmresume.StartSymbol("__llgo_stub.goFn")) { + t.Fatalf("closure does not reference its resumable start entry:\n%s", ir) + } var foundCCall bool for _, line := range strings.Split(ir, "\n") { if strings.Contains(line, "call void @cFn") && strings.Contains(line, wasmresume.CallMetadata) { @@ -90,12 +96,99 @@ func TestWasmResumeABIDoesNotChangeDefaultOrNativeIR(t *testing.T) { caller := pkg.NewFunc("caller", NoArgsNoRet, InGo) b := caller.MakeBody(1) b.Call(callee.Expr) + b.Call(b.MakeClosure(callee.Expr, nil)) b.Return() ir := pkg.String() - if strings.Contains(ir, wasmresume.FunctionAttribute) || strings.Contains(ir, wasmresume.CallMetadata) { + if strings.Contains(ir, wasmresume.FunctionAttribute) || + strings.Contains(ir, wasmresume.CallMetadata) || + strings.Contains(ir, wasmresume.StartSymbol("")) { t.Fatalf("inactive resumable ABI changed IR:\n%s", ir) } }) } } + +func TestWasmResumeABIClosureWithContextUsesStartEntry(t *testing.T) { + prog := NewProgram(&Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + prog.TypeSizes(types.SizesFor("gc", "wasm")) + prog.SetRuntime(func() *types.Package { + pkg, err := importer.For("source", nil).Import(PkgRuntime) + if err != nil { + t.Fatal(err) + } + return pkg + }) + + pkg := prog.NewPackage("p", "example.com/p") + fields := []*types.Var{ + types.NewField(token.NoPos, nil, "value", types.Typ[types.Int], false), + } + ctxType := types.NewStruct(fields, nil) + ctx := types.NewParam(token.NoPos, nil, closureCtx, types.NewPointer(ctxType)) + sig := types.NewSignatureType( + nil, nil, nil, types.NewTuple(ctx), nil, false, + ) + inner := pkg.NewFunc("inner", sig, InGo) + inner.MakeBody(1).Return() + + outer := pkg.NewFunc("outer", NoArgsNoRet, InGo) + b := outer.MakeBody(1) + b.Call(b.MakeClosure(inner.Expr, []Expr{prog.Val(42)})) + b.Return() + + ir := pkg.String() + if !strings.Contains(ir, "@"+wasmresume.StartSymbol("inner")) { + t.Fatalf("capturing closure does not reference its start entry:\n%s", ir) + } + if strings.Contains(ir, wasmresume.StartSymbol(closureStub+"inner")) { + t.Fatalf("capturing closure was wrapped unnecessarily:\n%s", ir) + } +} + +func TestWasmResumeABIMethodMetadataUsesStartEntries(t *testing.T) { + prog := NewProgram(&Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + prog.TypeSizes(types.SizesFor("gc", "wasm")) + prog.SetRuntime(func() *types.Package { + pkg, err := importer.For("source", nil).Import(PkgRuntime) + if err != nil { + t.Fatal(err) + } + return pkg + }) + + pkg := prog.NewPackage("p", "example.com/p") + goPkg := types.NewPackage("example.com/p", "p") + named := types.NewNamed( + types.NewTypeName(token.NoPos, goPkg, "S", nil), + types.NewStruct(nil, nil), + nil, + ) + recv := types.NewVar(token.NoPos, goPkg, "", named) + method := types.NewFunc( + token.NoPos, + goPkg, + "M", + types.NewSignatureType(recv, nil, nil, nil, nil, false), + ) + named.AddMethod(method) + + use := pkg.NewFunc("use", NoArgsNoRet, InGo) + b := use.MakeBody(1) + b.abiType(named) + b.Return() + + ir := pkg.String() + for _, want := range []string{ + wasmresume.StartSymbol("example.com/p.(*S).M"), + wasmresume.StartSymbol(closureStub + "example.com/p.S.M"), + } { + if !strings.Contains(ir, want) { + t.Fatalf("method metadata does not reference %s:\n%s", want, ir) + } + } +} From bc429816abddf5574f560d425185e2017e6f8bfd Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 17:59:33 +0800 Subject: [PATCH 31/40] wasmresume: lower scheduler suspension points --- internal/wasmresume/abi.go | 1 + internal/wasmresume/inventory.go | 1 + internal/wasmresume/state.go | 37 +++++++++++++++++++++++++++ internal/wasmresume/state_test.go | 26 ++++++++++++++++--- runtime/internal/wasmresume/resume.go | 7 +++++ ssa/wasm_resume_test.go | 33 ++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 3 deletions(-) diff --git a/internal/wasmresume/abi.go b/internal/wasmresume/abi.go index 7002b6f737..0ce41d14a8 100644 --- a/internal/wasmresume/abi.go +++ b/internal/wasmresume/abi.go @@ -28,6 +28,7 @@ const ( descriptorPrefix = "__llgo_wasm_resume_desc." actionContinue = 0 actionReturn = 1 + actionSuspend = 2 ) // StartSymbol returns the resumable start entry for a Go function symbol. diff --git a/internal/wasmresume/inventory.go b/internal/wasmresume/inventory.go index 5215ee1906..433a19f233 100644 --- a/internal/wasmresume/inventory.go +++ b/internal/wasmresume/inventory.go @@ -27,6 +27,7 @@ import ( const ( FunctionAttribute = "llgo.wasm.resume" CallMetadata = "llgo.wasm.resume.call" + SuspendSymbol = "github.com/goplus/llgo/runtime/internal/wasmresume.SuspendCurrent" MarkerVersion = 1 maxResumeID = 1<<16 - 1 ) diff --git a/internal/wasmresume/state.go b/internal/wasmresume/state.go index b2a1fd1a13..3cd2f39fe6 100644 --- a/internal/wasmresume/state.go +++ b/internal/wasmresume/state.go @@ -34,6 +34,17 @@ type loweredState struct { descriptor llvm.Value } +// Lower replaces marked Go functions and calls with the experimental +// WebAssembly resumable ABI. +func Lower(mod llvm.Module, targetData llvm.TargetData) error { + triple := mod.Target() + if !strings.HasPrefix(triple, "wasm32-") && !strings.HasPrefix(triple, "wasm64-") { + return fmt.Errorf("wasmresume: target %q is not WebAssembly", triple) + } + _, err := lowerPrototype(mod, targetData) + return err +} + func lowerPrototype(mod llvm.Module, targetData llvm.TargetData) ([]loweredState, error) { layouts, err := layoutFrames(mod, targetData) if err != nil { @@ -160,6 +171,10 @@ func lowerStateMachine( return fmt.Errorf("%s: call %d: %w", fn.Name(), site.id, err) } continuations[site.id] = continuation + if site.call.CalledValue().Name() == SuspendSymbol { + lowerSuspendCall(ctx, layout, lowered.entry, site) + continue + } if err := lowerResumeCall( mod, abi, layout, fields, lowered.entry, site, continuation, ); err != nil { @@ -194,6 +209,28 @@ func lowerStateMachine( return nil } +func lowerSuspendCall( + ctx llvm.Context, + parentLayout frameLayout, + entry llvm.Value, + site callSite, +) { + call := site.call + callBlock := call.InstructionParent() + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointBefore(call) + builder.CreateStore( + llvm.ConstInt(ctx.Int32Type(), uint64(site.id), false), + builder.CreateStructGEP(parentLayout.typ, entry.Param(1), 2, ""), + ) + call.EraseFromParentAsInstruction() + terminator := callBlock.LastInstruction() + builder.SetInsertPointBefore(terminator) + builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionSuspend, false)) + terminator.EraseFromParentAsInstruction() +} + func lowerResumeCall( mod llvm.Module, abi resumeABI, diff --git a/internal/wasmresume/state_test.go b/internal/wasmresume/state_test.go index 6320871a71..896eff6505 100644 --- a/internal/wasmresume/state_test.go +++ b/internal/wasmresume/state_test.go @@ -171,7 +171,7 @@ func TestLowerPrototypeBuildsDirectCallStateMachine(t *testing.T) { } } -func TestLowerPrototypeEmitsWasmObject(t *testing.T) { +func TestLowerEmitsWasmObject(t *testing.T) { llvm.InitializeAllTargetInfos() llvm.InitializeAllTargets() llvm.InitializeAllTargetMCs() @@ -217,7 +217,7 @@ func TestLowerPrototypeEmitsWasmObject(t *testing.T) { markCall(ctx, call) builder.CreateRet(call) - if _, err := lowerPrototype(mod, targetData); err != nil { + if err := Lower(mod, targetData); err != nil { t.Fatal(err) } if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { @@ -235,6 +235,21 @@ func TestLowerPrototypeEmitsWasmObject(t *testing.T) { } } +func TestLowerRejectsNonWasmTarget(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("native") + defer mod.Dispose() + mod.SetTarget("aarch64-unknown-linux-gnu") + targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") + defer targetData.Dispose() + + if err := Lower(mod, targetData); err == nil || + !strings.Contains(err.Error(), "is not WebAssembly") { + t.Fatalf("Lower error = %v", err) + } +} + func TestLowerPrototypeExecutesIndirectStart(t *testing.T) { llvm.LinkInMCJIT() if err := llvm.InitializeNativeTarget(); err != nil { @@ -281,6 +296,8 @@ func TestLowerPrototypeExecutesIndirectStart(t *testing.T) { ptr := llvm.PointerType(ctx.Int8Type(), 0) startType := llvm.FunctionType(ptr, []llvm.Type{ptr, i32}, false) start := llvm.AddFunction(mod, StartSymbol(callee.Name()), startType) + suspend := llvm.AddFunction(mod, SuspendSymbol, llvm.FunctionType(ctx.VoidType(), nil, false)) + markFunction(ctx, suspend) callerType := llvm.FunctionType(i32, []llvm.Type{ptr, i32}, false) caller := llvm.AddFunction(mod, "caller", callerType) markFunction(ctx, caller) @@ -290,6 +307,8 @@ func TestLowerPrototypeExecutesIndirectStart(t *testing.T) { markCall(ctx, dynamicCall) constantCall := builder.CreateCall(sig, start, []llvm.Value{dynamicCall}, "constant") markCall(ctx, constantCall) + suspendCall := builder.CreateCall(suspend.GlobalValueType(), suspend, nil, "") + markCall(ctx, suspendCall) builder.CreateRet(builder.CreateMul(constantCall, llvm.ConstInt(i32, 2, false), "result")) lowered, err := lowerPrototype(mod, targetData) @@ -425,9 +444,10 @@ func defineStateMachineHarness( abi.ptr, builder.CreateStructGEP(abi.descriptorType, descriptor, 0, ""), "resume.entry", ) action := builder.CreateCall(abi.entryType, resume, []llvm.Value{context, top}, "action") - switchAction := builder.CreateSwitch(action, failedBlock, 2) + switchAction := builder.CreateSwitch(action, failedBlock, 3) switchAction.AddCase(llvm.ConstInt(i8, actionContinue, false), loopBlock) switchAction.AddCase(llvm.ConstInt(i8, actionReturn, false), popBlock) + switchAction.AddCase(llvm.ConstInt(i8, actionSuspend, false), loopBlock) builder.SetInsertPointAtEnd(popBlock) parent := builder.CreateLoad( diff --git a/runtime/internal/wasmresume/resume.go b/runtime/internal/wasmresume/resume.go index 1772aed83c..23e2ea7cfe 100644 --- a/runtime/internal/wasmresume/resume.go +++ b/runtime/internal/wasmresume/resume.go @@ -18,6 +18,13 @@ // WebAssembly resumable call ABI. package wasmresume +// SuspendCurrent yields the active resumable frame to its scheduler. The +// compiler replaces calls to SuspendCurrent with a frame-PC transition; no +// function body is linked into the final WebAssembly module. +func SuspendCurrent() { + panic("wasmresume: SuspendCurrent was not lowered") +} + // Action tells Context what a resume entry did. type Action uint8 diff --git a/ssa/wasm_resume_test.go b/ssa/wasm_resume_test.go index 0c13de664e..9b4edd9397 100644 --- a/ssa/wasm_resume_test.go +++ b/ssa/wasm_resume_test.go @@ -26,6 +26,7 @@ import ( "testing" "github.com/goplus/llgo/internal/wasmresume" + "github.com/xgo-dev/llvm" ) func TestWasmResumeABIInventoriesGoCalls(t *testing.T) { @@ -192,3 +193,35 @@ func TestWasmResumeABIMethodMetadataUsesStartEntries(t *testing.T) { } } } + +func TestWasmResumeABILowersSuspendCurrent(t *testing.T) { + prog := NewProgram(&Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + + pkg := prog.NewPackage("p", "example.com/p") + suspend := pkg.NewFunc(wasmresume.SuspendSymbol, NoArgsNoRet, InGo) + caller := pkg.NewFunc("caller", NoArgsNoRet, InGo) + b := caller.MakeBody(1) + b.Call(suspend.Expr) + b.Return() + + if err := wasmresume.Lower(pkg.Module(), prog.TargetData()); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify lowered suspend module: %v\n%s", err, pkg.String()) + } + ir := pkg.String() + if strings.Contains(ir, "call void @"+wasmresume.SuspendSymbol) { + t.Fatalf("SuspendCurrent call remains after lowering:\n%s", ir) + } + for _, want := range []string{ + "ret i8 2", + "i32 1, label %resume.1", + } { + if !strings.Contains(ir, want) { + t.Fatalf("lowered suspend module is missing %q:\n%s", want, ir) + } + } +} From 9c7c2917945485eaf4a73fbf57b525345db03454 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 18:11:37 +0800 Subject: [PATCH 32/40] runtime/wasm: scope resume frame storage per context --- internal/wasmresume/start.go | 1 + internal/wasmresume/state.go | 12 +- internal/wasmresume/state_test.go | 4 +- runtime/internal/wasmresume/resume.go | 25 +++ runtime/internal/wasmresume/resume_test.go | 9 + runtime/internal/wasmresume/storage.go | 151 ++++++++++++++ runtime/internal/wasmresume/storage_test.go | 205 ++++++++++++++++++++ 7 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 runtime/internal/wasmresume/storage.go create mode 100644 runtime/internal/wasmresume/storage_test.go diff --git a/internal/wasmresume/start.go b/internal/wasmresume/start.go index fe455e6508..c9c4f71a5d 100644 --- a/internal/wasmresume/start.go +++ b/internal/wasmresume/start.go @@ -55,6 +55,7 @@ func emitStartEntriesForLayouts( builder := ctx.NewBuilder() builder.SetInsertPointAtEnd(block) child := builder.CreateCall(alloc.GlobalValueType(), alloc, []llvm.Value{ + start.Param(0), llvm.ConstInt(abi.uintptrType, layout.size, false), llvm.ConstInt(abi.uintptrType, uint64(layout.alignment), false), }, "child") diff --git a/internal/wasmresume/state.go b/internal/wasmresume/state.go index 3cd2f39fe6..7540184042 100644 --- a/internal/wasmresume/state.go +++ b/internal/wasmresume/state.go @@ -270,7 +270,9 @@ func lowerResumeCall( alignField := builder.CreateStructGEP(abi.descriptorType, descriptor, 2, "") size := builder.CreateLoad(abi.uintptrType, sizeField, "child.size") align := builder.CreateLoad(abi.uintptrType, alignField, "child.align") - child = builder.CreateCall(alloc.GlobalValueType(), alloc, []llvm.Value{size, align}, "child") + child = builder.CreateCall( + alloc.GlobalValueType(), alloc, []llvm.Value{entry.Param(0), size, align}, "child", + ) builder.CreateIntrinsic(ctx.VoidType(), llvm.LookupIntrinsicID("llvm.memset"), []llvm.Value{ child, llvm.ConstInt(ctx.Int8Type(), 0, false), @@ -310,7 +312,9 @@ func lowerResumeCall( builder.CreateStore(result, parentFields[site.resultSlot]) replaceValueUsesWithLoads(ctx, call, parentFields[site.resultSlot], llvm.Value{}) } - builder.CreateCall(free.GlobalValueType(), free, []llvm.Value{returned}, "") + builder.CreateCall( + free.GlobalValueType(), free, []llvm.Value{entry.Param(0), returned}, "", + ) call.EraseFromParentAsInstruction() terminator := callBlock.LastInstruction() @@ -334,7 +338,7 @@ func declareFrameAllocator(mod llvm.Module, abi resumeABI) llvm.Value { fn := mod.NamedFunction(frameAllocName) if fn.IsNil() { fn = llvm.AddFunction(mod, frameAllocName, llvm.FunctionType( - abi.ptr, []llvm.Type{abi.uintptrType, abi.uintptrType}, false, + abi.ptr, []llvm.Type{abi.ptr, abi.uintptrType, abi.uintptrType}, false, )) } return fn @@ -344,7 +348,7 @@ func declareFrameFree(mod llvm.Module, abi resumeABI) llvm.Value { fn := mod.NamedFunction(frameFreeName) if fn.IsNil() { fn = llvm.AddFunction(mod, frameFreeName, llvm.FunctionType( - abi.ctx.VoidType(), []llvm.Type{abi.ptr}, false, + abi.ctx.VoidType(), []llvm.Type{abi.ptr, abi.ptr}, false, )) } return fn diff --git a/internal/wasmresume/state_test.go b/internal/wasmresume/state_test.go index 896eff6505..3bc564ebc0 100644 --- a/internal/wasmresume/state_test.go +++ b/internal/wasmresume/state_test.go @@ -158,11 +158,11 @@ func TestLowerPrototypeBuildsDirectCallStateMachine(t *testing.T) { `switch i32 %pc, label %invalid-pc [`, `i32 0, label %entry`, `i32 1, label %resume.1`, - `call ptr @__llgo_wasm_resume_alloc`, + `call ptr @__llgo_wasm_resume_alloc(ptr %0,`, `call void @llvm.memset`, `ret i8 0`, `%returned = load ptr`, - `call void @__llgo_wasm_resume_free`, + `call void @__llgo_wasm_resume_free(ptr %0, ptr %returned)`, `ret i8 1`, } { if !strings.Contains(ir, want) { diff --git a/runtime/internal/wasmresume/resume.go b/runtime/internal/wasmresume/resume.go index 23e2ea7cfe..1e60fa1d0e 100644 --- a/runtime/internal/wasmresume/resume.go +++ b/runtime/internal/wasmresume/resume.go @@ -18,6 +18,8 @@ // WebAssembly resumable call ABI. package wasmresume +import "unsafe" + // SuspendCurrent yields the active resumable frame to its scheduler. The // compiler replaces calls to SuspendCurrent with a frame-PC transition; no // function body is linked into the final WebAssembly module. @@ -65,6 +67,29 @@ type Frame struct { type Context struct { top *Frame returned *Frame + storage frameStorage +} + +// AllocateFrame allocates stable, root-scanned storage for a generated frame. +func (c *Context) AllocateFrame( + size, align uintptr, allocate func(uintptr) unsafe.Pointer, +) unsafe.Pointer { + return c.storage.allocate(size, align, allocate) +} + +// ReleaseFrame reclaims the most recently completed generated frame. +func (c *Context) ReleaseFrame(frame *Frame, release func(unsafe.Pointer)) { + if frame == nil || frame.Descriptor == nil { + panic("wasmresume: invalid completed frame") + } + c.storage.release(unsafe.Pointer(frame), frame.Descriptor.FrameSize, release) +} + +// Close releases every frame storage segment owned by the context. +func (c *Context) Close(release func(unsafe.Pointer)) { + c.storage.close(release) + c.top = nil + c.returned = nil } // Top returns the active frame. diff --git a/runtime/internal/wasmresume/resume_test.go b/runtime/internal/wasmresume/resume_test.go index 834e5a7a41..71a2553901 100644 --- a/runtime/internal/wasmresume/resume_test.go +++ b/runtime/internal/wasmresume/resume_test.go @@ -116,6 +116,15 @@ func TestContextRunEmpty(t *testing.T) { } } +func TestSuspendCurrentRequiresCompilerLowering(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("SuspendCurrent fallback did not panic") + } + }() + SuspendCurrent() +} + func TestContextPushInitializesHeader(t *testing.T) { parent := Frame{} child := Frame{Parent: &parent, Descriptor: &testMulDescriptor, PC: 9} diff --git a/runtime/internal/wasmresume/storage.go b/runtime/internal/wasmresume/storage.go new file mode 100644 index 0000000000..98b94dbb66 --- /dev/null +++ b/runtime/internal/wasmresume/storage.go @@ -0,0 +1,151 @@ +/* + * 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 wasmresume + +import "unsafe" + +const defaultFrameBlockSize = uintptr(2 << 10) + +type frameBlock struct { + prev *frameBlock + begin, end uintptr + stackPointer uintptr +} + +type frameStorage struct { + current *frameBlock +} + +func (s *frameStorage) allocate( + size, align uintptr, allocate func(uintptr) unsafe.Pointer, +) unsafe.Pointer { + if size == 0 || align == 0 || align&(align-1) != 0 || allocate == nil { + return nil + } + if frame, ok := allocateFromBlock(s.current, size, align); ok { + return frame + } + + payload, ok := addUintptr(size, unsafe.Sizeof(uintptr(0))) + if !ok { + return nil + } + payload, ok = addUintptr(payload, align-1) + if !ok { + return nil + } + if payload < defaultFrameBlockSize { + payload = defaultFrameBlockSize + } + total, ok := addUintptr(unsafe.Sizeof(frameBlock{}), payload) + if !ok { + return nil + } + raw := allocate(total) + if raw == nil { + return nil + } + block := (*frameBlock)(raw) + block.prev = s.current + block.begin, ok = addUintptr(uintptr(raw), unsafe.Sizeof(frameBlock{})) + if !ok { + panic("wasmresume: frame block address overflow") + } + block.end, ok = addUintptr(uintptr(raw), total) + if !ok { + panic("wasmresume: frame block address overflow") + } + block.stackPointer = block.begin + s.current = block + frame, ok := allocateFromBlock(block, size, align) + if !ok { + panic("wasmresume: new frame block is too small") + } + return frame +} + +func allocateFromBlock(block *frameBlock, size, align uintptr) (unsafe.Pointer, bool) { + if block == nil { + return nil, false + } + header, ok := addUintptr(block.stackPointer, unsafe.Sizeof(uintptr(0))) + if !ok { + return nil, false + } + frame, ok := alignUintptr(header, align) + if !ok { + return nil, false + } + next, ok := addUintptr(frame, size) + if !ok || next > block.end { + return nil, false + } + *(*uintptr)(unsafe.Pointer(frame - unsafe.Sizeof(uintptr(0)))) = block.stackPointer + block.stackPointer = next + return unsafe.Pointer(frame), true +} + +func (s *frameStorage) release( + frame unsafe.Pointer, size uintptr, release func(unsafe.Pointer), +) { + block := s.current + if block == nil || frame == nil || size == 0 { + panic("wasmresume: invalid frame release") + } + address := uintptr(frame) + end, ok := addUintptr(address, size) + if !ok || address < block.begin || end != block.stackPointer { + panic("wasmresume: frames must be released in LIFO order") + } + previous := *(*uintptr)(unsafe.Pointer(address - unsafe.Sizeof(uintptr(0)))) + if previous < block.begin || previous >= address { + panic("wasmresume: invalid frame allocation header") + } + block.stackPointer = previous + if previous == block.begin && block.prev != nil { + if release == nil { + panic("wasmresume: missing frame block reclaimer") + } + s.current = block.prev + release(unsafe.Pointer(block)) + } +} + +func (s *frameStorage) close(release func(unsafe.Pointer)) { + if s.current != nil && release == nil { + panic("wasmresume: missing frame block reclaimer") + } + for block := s.current; block != nil; { + previous := block.prev + release(unsafe.Pointer(block)) + block = previous + } + s.current = nil +} + +func addUintptr(left, right uintptr) (uintptr, bool) { + sum := left + right + return sum, sum >= left +} + +func alignUintptr(value, align uintptr) (uintptr, bool) { + next, ok := addUintptr(value, align-1) + if !ok { + return 0, false + } + return next &^ (align - 1), true +} diff --git a/runtime/internal/wasmresume/storage_test.go b/runtime/internal/wasmresume/storage_test.go new file mode 100644 index 0000000000..84862dfaff --- /dev/null +++ b/runtime/internal/wasmresume/storage_test.go @@ -0,0 +1,205 @@ +package wasmresume + +import ( + "testing" + "unsafe" +) + +type testFrameRoots struct { + blocks map[unsafe.Pointer][]byte + allocs int + frees int +} + +func (r *testFrameRoots) allocate(size uintptr) unsafe.Pointer { + block := make([]byte, size) + if len(block) == 0 { + return nil + } + ptr := unsafe.Pointer(&block[0]) + if r.blocks == nil { + r.blocks = make(map[unsafe.Pointer][]byte) + } + r.blocks[ptr] = block + r.allocs++ + return ptr +} + +func (r *testFrameRoots) release(ptr unsafe.Pointer) { + if _, ok := r.blocks[ptr]; !ok { + panic("released unknown root block") + } + delete(r.blocks, ptr) + r.frees++ +} + +func TestFrameStorageAlignsAndReusesFrames(t *testing.T) { + var ( + storage frameStorage + roots testFrameRoots + ) + first := storage.allocate(31, 8, roots.allocate) + second := storage.allocate(64, 64, roots.allocate) + if first == nil || second == nil { + t.Fatal("frame allocation failed") + } + if uintptr(first)%8 != 0 || uintptr(second)%64 != 0 { + t.Fatalf("unaligned frames: first=%p second=%p", first, second) + } + if roots.allocs != 1 { + t.Fatalf("root block allocations = %d, want 1", roots.allocs) + } + + storage.release(second, 64, roots.release) + reused := storage.allocate(64, 64, roots.allocate) + if reused != second { + t.Fatalf("frame was not reused: got %p, want %p", reused, second) + } + storage.release(reused, 64, roots.release) + storage.release(first, 31, roots.release) + storage.close(roots.release) + if roots.frees != 1 || len(roots.blocks) != 0 { + t.Fatalf("released roots = %d, remaining = %d", roots.frees, len(roots.blocks)) + } +} + +func TestFrameStorageAddsAndReleasesSegments(t *testing.T) { + var ( + storage frameStorage + roots testFrameRoots + ) + first := storage.allocate(defaultFrameBlockSize, 16, roots.allocate) + second := storage.allocate(128, 16, roots.allocate) + if first == nil || second == nil || roots.allocs != 2 { + t.Fatalf("allocations = %d, first=%p second=%p", roots.allocs, first, second) + } + storage.release(second, 128, roots.release) + if roots.frees != 1 { + t.Fatalf("released child segments = %d, want 1", roots.frees) + } + storage.release(first, defaultFrameBlockSize, roots.release) + storage.close(roots.release) + if roots.frees != 2 || len(roots.blocks) != 0 { + t.Fatalf("released roots = %d, remaining = %d", roots.frees, len(roots.blocks)) + } +} + +func TestContextOwnsGeneratedFrameStorage(t *testing.T) { + var ( + ctx Context + roots testFrameRoots + ) + size := unsafe.Sizeof(testLeafFrame{}) + raw := ctx.AllocateFrame(size, unsafe.Alignof(testLeafFrame{}), roots.allocate) + if raw == nil { + t.Fatal("Context.AllocateFrame failed") + } + frame := (*testLeafFrame)(raw) + frame.Descriptor = &Descriptor{FrameSize: size} + ctx.ReleaseFrame(&frame.Frame, roots.release) + ctx.Close(roots.release) + if roots.allocs != 1 || roots.frees != 1 { + t.Fatalf("root lifecycle = %d allocs, %d frees", roots.allocs, roots.frees) + } +} + +func TestContextKeepsGeneratedABIPrefix(t *testing.T) { + if got, want := unsafe.Offsetof(Context{}.storage), 2*unsafe.Sizeof(uintptr(0)); got != want { + t.Fatalf("Context storage offset = %d, want %d", got, want) + } +} + +func TestFrameStorageRejectsInvalidOperations(t *testing.T) { + var ( + storage frameStorage + roots testFrameRoots + ) + if storage.allocate(0, 8, roots.allocate) != nil || + storage.allocate(8, 3, roots.allocate) != nil || + storage.allocate(^uintptr(0), 8, roots.allocate) != nil || + storage.allocate(8, 8, nil) != nil { + t.Fatal("invalid allocation was accepted") + } + if storage.allocate(8, 8, func(uintptr) unsafe.Pointer { return nil }) != nil { + t.Fatal("failed root allocation returned a frame") + } + + first := storage.allocate(8, 8, roots.allocate) + storage.allocate(8, 8, roots.allocate) + defer func() { + if recover() == nil { + t.Fatal("out-of-order frame release did not panic") + } + storage.close(roots.release) + }() + storage.release(first, 8, roots.release) +} + +func TestFrameStorageRejectsInvalidReleaseState(t *testing.T) { + assertPanic := func(name string, operation func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("operation did not panic") + } + }() + operation() + }) + } + + assertPanic("empty", func() { + var storage frameStorage + storage.release(unsafe.Pointer(new(byte)), 1, nil) + }) + + var ( + storage frameStorage + roots testFrameRoots + ) + frame := storage.allocate(8, 8, roots.allocate) + assertPanic("nil frame", func() { + storage.release(nil, 8, roots.release) + }) + assertPanic("zero size", func() { + storage.release(frame, 0, roots.release) + }) + assertPanic("invalid header", func() { + header := unsafe.Pointer(uintptr(frame) - unsafe.Sizeof(uintptr(0))) + *(*uintptr)(header) = 0 + storage.release(frame, 8, roots.release) + }) + storage.close(roots.release) + + var noRelease frameStorage + noRelease.allocate(8, 8, roots.allocate) + assertPanic("close without reclaimer", func() { + noRelease.close(nil) + }) + noRelease.close(roots.release) +} + +func TestContextRejectsFrameWithoutDescriptor(t *testing.T) { + var ctx Context + defer func() { + if recover() == nil { + t.Fatal("ReleaseFrame accepted an untyped frame") + } + }() + ctx.ReleaseFrame(&Frame{}, nil) +} + +func BenchmarkFrameStorageHotAllocateRelease(b *testing.B) { + var ( + storage frameStorage + roots testFrameRoots + ) + frame := storage.allocate(64, 16, roots.allocate) + storage.release(frame, 64, roots.release) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + frame = storage.allocate(64, 16, roots.allocate) + storage.release(frame, 64, roots.release) + } +} From b6df8119ee3d7da35c8be422b8bc853a65f7e0c2 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 18:20:58 +0800 Subject: [PATCH 33/40] internal/wasmresume: execute required wasm profiles --- internal/wasmresume/execution_test.go | 171 ++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 internal/wasmresume/execution_test.go diff --git a/internal/wasmresume/execution_test.go b/internal/wasmresume/execution_test.go new file mode 100644 index 0000000000..95296a54c8 --- /dev/null +++ b/internal/wasmresume/execution_test.go @@ -0,0 +1,171 @@ +package wasmresume + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestLowerExecutesRequiredWasmProfiles(t *testing.T) { + wasmLD, err := exec.LookPath("wasm-ld") + if err != nil { + t.Skip("wasm-ld is not installed") + } + node, nodeErr := exec.LookPath("node") + wasmtime, wasmtimeErr := exec.LookPath("wasmtime") + + llvm.InitializeAllTargetInfos() + llvm.InitializeAllTargets() + llvm.InitializeAllTargetMCs() + llvm.InitializeAllAsmPrinters() + + tests := []struct { + name string + triple string + run func(*testing.T, string) ([]byte, error) + }{ + { + name: "J32", + triple: "wasm32-unknown-emscripten", + run: func(t *testing.T, wasm string) ([]byte, error) { + if nodeErr != nil { + t.Skip("node is not installed") + } + script := `const fs=require("fs");WebAssembly.instantiate(fs.readFileSync(process.argv[1])).then(({instance})=>console.log(instance.exports["run.state.machine"]()))` + return exec.Command(node, "-e", script, wasm).CombinedOutput() + }, + }, + { + name: "J64", + triple: "wasm64-unknown-emscripten", + run: func(t *testing.T, wasm string) ([]byte, error) { + if nodeErr != nil { + t.Skip("node is not installed") + } + script := `const fs=require("fs");WebAssembly.instantiate(fs.readFileSync(process.argv[1])).then(({instance})=>console.log(instance.exports["run.state.machine"]()))` + return exec.Command(node, "-e", script, wasm).CombinedOutput() + }, + }, + { + name: "P1", + triple: "wasm32-unknown-wasip1", + run: func(t *testing.T, wasm string) ([]byte, error) { + if wasmtimeErr != nil { + t.Skip("wasmtime is not installed") + } + return exec.Command(wasmtime, "run", "--invoke", "run.state.machine", wasm).CombinedOutput() + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + object := buildExecutableWasmResumeObject(t, test.triple) + dir := t.TempDir() + objectPath := filepath.Join(dir, "resume.o") + wasmPath := filepath.Join(dir, "resume.wasm") + if err := os.WriteFile(objectPath, object, 0o600); err != nil { + t.Fatal(err) + } + linkArgs := []string{ + "--no-entry", + "--export=run.state.machine", + "-o", wasmPath, + objectPath, + } + if strings.HasPrefix(test.triple, "wasm64-") { + linkArgs = append([]string{"-mwasm64"}, linkArgs...) + } + if output, err := exec.Command(wasmLD, linkArgs...).CombinedOutput(); err != nil { + t.Fatalf("link %s: %v\n%s", test.name, err, output) + } + output, err := test.run(t, wasmPath) + if err != nil { + t.Fatalf("execute %s: %v\n%s", test.name, err, output) + } + fields := strings.Fields(string(output)) + if len(fields) == 0 || fields[len(fields)-1] != "14" { + t.Fatalf("%s result = %q, want 14", test.name, output) + } + }) + } +} + +func buildExecutableWasmResumeObject(t *testing.T, triple string) []byte { + t.Helper() + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule(triple) + defer mod.Dispose() + + target, err := llvm.GetTargetFromTriple(triple) + if err != nil { + t.Fatal(err) + } + machine := target.CreateTargetMachine( + triple, "", "", llvm.CodeGenLevelNone, llvm.RelocDefault, llvm.CodeModelDefault, + ) + defer machine.Dispose() + targetData := machine.CreateTargetData() + defer targetData.Dispose() + mod.SetTarget(triple) + mod.SetDataLayout(targetData.String()) + + i32 := ctx.Int32Type() + sig := llvm.FunctionType(i32, []llvm.Type{i32}, false) + callee := llvm.AddFunction(mod, "callee", sig) + markFunction(ctx, callee) + block := ctx.AddBasicBlock(callee, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateRet(builder.CreateAdd(callee.Param(0), llvm.ConstInt(i32, 1, false), "result")) + + suspend := llvm.AddFunction(mod, SuspendSymbol, llvm.FunctionType(ctx.VoidType(), nil, false)) + markFunction(ctx, suspend) + caller := llvm.AddFunction(mod, "caller", sig) + markFunction(ctx, caller) + block = ctx.AddBasicBlock(caller, "entry") + builder.SetInsertPointAtEnd(block) + call := builder.CreateCall(sig, callee, []llvm.Value{caller.Param(0)}, "called") + markCall(ctx, call) + suspendCall := builder.CreateCall(suspend.GlobalValueType(), suspend, nil, "") + markCall(ctx, suspendCall) + builder.CreateRet(builder.CreateMul(call, llvm.ConstInt(i32, 2, false), "result")) + + lowered, err := lowerPrototype(mod, targetData) + if err != nil { + t.Fatal(err) + } + var root loweredState + for _, state := range lowered { + if state.layout.plan.function == caller { + root = state + break + } + } + if root.entry.IsNil() { + t.Fatal("caller state machine was not emitted") + } + defineStateMachineHarness(mod, targetData, root, []llvm.Value{ + llvm.ConstInt(i32, 6, false), + }) + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify %s executable: %v\n%s", triple, err, mod.String()) + } + options := llvm.NewPassBuilderOptions() + defer options.Dispose() + if err := mod.RunPasses("default", machine, options); err != nil { + t.Fatalf("optimize %s executable: %v\n%s", triple, err, mod.String()) + } + buffer, err := machine.EmitToMemoryBuffer(mod, llvm.ObjectFile) + if err != nil { + t.Fatalf("emit %s executable: %v\n%s", triple, err, mod.String()) + } + defer buffer.Dispose() + return append([]byte(nil), buffer.Bytes()...) +} From ad87eefd60a742d8ec5d2cb96e7972b790e8f20b Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 21:21:14 +0800 Subject: [PATCH 34/40] wasmresume: preserve synchronous runtime boundaries --- internal/wasmresume/abi.go | 5 +- internal/wasmresume/boundary.go | 41 +++++++ internal/wasmresume/boundary_test.go | 29 +++++ internal/wasmresume/compat.go | 167 +++++++++++++++++++++++++++ internal/wasmresume/state.go | 13 +++ internal/wasmresume/state_test.go | 15 ++- ssa/goroutine.go | 12 +- ssa/wasm_resume.go | 18 ++- ssa/wasm_resume_test.go | 56 +++++++++ 9 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 internal/wasmresume/boundary.go create mode 100644 internal/wasmresume/boundary_test.go create mode 100644 internal/wasmresume/compat.go diff --git a/internal/wasmresume/abi.go b/internal/wasmresume/abi.go index 0ce41d14a8..8ee19c442c 100644 --- a/internal/wasmresume/abi.go +++ b/internal/wasmresume/abi.go @@ -26,6 +26,7 @@ const ( resumeEntryPrefix = "__llgo_wasm_resume." startEntryPrefix = "__llgo_wasm_start." descriptorPrefix = "__llgo_wasm_resume_desc." + frameCloseName = "__llgo_wasm_resume_close" actionContinue = 0 actionReturn = 1 actionSuspend = 2 @@ -54,7 +55,9 @@ func newResumeABI(ctx llvm.Context, targetData llvm.TargetData) resumeABI { uintptrType: uintptrType, entryType: llvm.FunctionType(ctx.Int8Type(), []llvm.Type{ptr, ptr}, false), descriptorType: ctx.StructType([]llvm.Type{ptr, uintptrType, uintptrType}, false), - contextType: ctx.StructType([]llvm.Type{ptr, ptr}, false), + // The first two fields are the public dispatch ABI. The trailing pointer + // is runtime-owned per-context frame storage. + contextType: ctx.StructType([]llvm.Type{ptr, ptr, ptr}, false), } } diff --git a/internal/wasmresume/boundary.go b/internal/wasmresume/boundary.go new file mode 100644 index 0000000000..e219234a19 --- /dev/null +++ b/internal/wasmresume/boundary.go @@ -0,0 +1,41 @@ +/* + * 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 wasmresume + +import "strings" + +const ( + runtimeResumePrefix = "github.com/goplus/llgo/runtime/internal/wasmresume." + runtimeAllocRoot = "github.com/goplus/llgo/runtime/internal/runtime.AllocRoot" + runtimeFreeRoot = "github.com/goplus/llgo/runtime/internal/runtime.FreeRoot" + runtimeRunWasmMain = "github.com/goplus/llgo/runtime/internal/runtime.RunWasmMain" +) + +// IsRuntimeABIImplementation reports functions which implement the resumable +// ABI itself and therefore cannot be lowered through that same ABI. +func IsRuntimeABIImplementation(name string) bool { + return strings.HasPrefix(name, runtimeResumePrefix) +} + +// IsNonSuspendingBoundary reports leaf runtime entry points which remain +// callable without allocating a resumable frame. +func IsNonSuspendingBoundary(name string) bool { + return (IsRuntimeABIImplementation(name) && name != SuspendSymbol) || + name == runtimeAllocRoot || + name == runtimeFreeRoot || + name == runtimeRunWasmMain +} diff --git a/internal/wasmresume/boundary_test.go b/internal/wasmresume/boundary_test.go new file mode 100644 index 0000000000..a470b281c2 --- /dev/null +++ b/internal/wasmresume/boundary_test.go @@ -0,0 +1,29 @@ +package wasmresume + +import "testing" + +func TestRuntimeBoundaries(t *testing.T) { + for _, name := range []string{ + runtimeResumePrefix + "Context.Run", + runtimeResumePrefix + "Context.AllocateFrame", + } { + if !IsRuntimeABIImplementation(name) || !IsNonSuspendingBoundary(name) { + t.Fatalf("%q is not a non-suspending ABI implementation", name) + } + } + if !IsRuntimeABIImplementation(SuspendSymbol) { + t.Fatal("SuspendCurrent is not recognized as an ABI implementation") + } + if IsNonSuspendingBoundary(SuspendSymbol) { + t.Fatal("SuspendCurrent was classified as non-suspending") + } + for _, name := range []string{runtimeAllocRoot, runtimeFreeRoot, runtimeRunWasmMain} { + if !IsNonSuspendingBoundary(name) { + t.Fatalf("%q is not a non-suspending boundary", name) + } + } + if IsRuntimeABIImplementation("example.com/p.Run") || + IsNonSuspendingBoundary("example.com/p.Run") { + t.Fatal("ordinary Go function was classified as a runtime boundary") + } +} diff --git a/internal/wasmresume/compat.go b/internal/wasmresume/compat.go new file mode 100644 index 0000000000..846fe12da9 --- /dev/null +++ b/internal/wasmresume/compat.go @@ -0,0 +1,167 @@ +/* + * 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 wasmresume + +import ( + "fmt" + + "github.com/xgo-dev/llvm" +) + +// emitCompatibilityWrapper keeps the original Go symbol callable from +// non-resumable runtime and C boundaries. Such a call owns a temporary context +// and must run to completion; observing Suspend is a boundary violation. +func emitCompatibilityWrapper( + mod llvm.Module, targetData llvm.TargetData, abi resumeABI, lowered *loweredState, +) error { + fn := lowered.layout.plan.function + if !fn.IsDeclaration() { + return fmt.Errorf("%s: compatibility wrapper still has a body", fn.Name()) + } + + ctx := mod.Context() + entry := ctx.AddBasicBlock(fn, "wasm.resume.compat") + dispatch := ctx.AddBasicBlock(fn, "wasm.resume.dispatch") + resume := ctx.AddBasicBlock(fn, "wasm.resume.call") + continued := ctx.AddBasicBlock(fn, "wasm.resume.continue") + returned := ctx.AddBasicBlock(fn, "wasm.resume.return") + finished := ctx.AddBasicBlock(fn, "wasm.resume.finished") + suspended := ctx.AddBasicBlock(fn, "wasm.resume.suspended") + invalid := ctx.AddBasicBlock(fn, "wasm.resume.invalid") + + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(entry) + context := builder.CreateAlloca(abi.contextType, "resume.context") + context.SetAlignment(targetData.ABITypeAlignment(abi.contextType)) + root := builder.CreateAlloca(lowered.layout.typ, "resume.root") + root.SetAlignment(lowered.layout.alignment) + builder.CreateIntrinsic(ctx.VoidType(), llvm.LookupIntrinsicID("llvm.memset"), []llvm.Value{ + context, + llvm.ConstInt(ctx.Int8Type(), 0, false), + llvm.ConstInt(abi.uintptrType, targetData.TypeAllocSize(abi.contextType), false), + llvm.ConstInt(ctx.Int1Type(), 0, false), + }, "") + builder.CreateIntrinsic(ctx.VoidType(), llvm.LookupIntrinsicID("llvm.memset"), []llvm.Value{ + root, + llvm.ConstInt(ctx.Int8Type(), 0, false), + llvm.ConstInt(abi.uintptrType, lowered.layout.size, false), + llvm.ConstInt(ctx.Int1Type(), 0, false), + }, "") + builder.CreateStore( + llvm.ConstNull(abi.ptr), + builder.CreateStructGEP(lowered.layout.typ, root, 0, ""), + ) + builder.CreateStore( + lowered.descriptor, + builder.CreateStructGEP(lowered.layout.typ, root, 1, ""), + ) + for _, slot := range lowered.layout.plan.slots { + if slot.kind != slotParameter { + continue + } + builder.CreateStore( + fn.Param(parameterIndex(lowered.layout.plan, slot.id)), + builder.CreateStructGEP( + lowered.layout.typ, root, lowered.layout.fieldIndex(slot.id), "", + ), + ) + } + topField := builder.CreateStructGEP(abi.contextType, context, 0, "") + returnedField := builder.CreateStructGEP(abi.contextType, context, 1, "") + builder.CreateStore(root, topField) + builder.CreateBr(dispatch) + + framePrefix := ctx.StructType([]llvm.Type{abi.ptr, abi.ptr, ctx.Int32Type()}, false) + builder.SetInsertPointAtEnd(dispatch) + top := builder.CreateLoad(abi.ptr, topField, "top") + builder.CreateCondBr( + builder.CreateICmp(llvm.IntNE, top, llvm.ConstNull(abi.ptr), ""), + resume, + finished, + ) + + builder.SetInsertPointAtEnd(resume) + descriptor := builder.CreateLoad( + abi.ptr, builder.CreateStructGEP(framePrefix, top, 1, ""), "descriptor", + ) + resumeEntry := builder.CreateLoad( + abi.ptr, + builder.CreateStructGEP(abi.descriptorType, descriptor, 0, ""), + "resume.entry", + ) + action := builder.CreateCall(abi.entryType, resumeEntry, []llvm.Value{context, top}, "action") + actionSwitch := builder.CreateSwitch(action, invalid, 3) + actionSwitch.AddCase(llvm.ConstInt(ctx.Int8Type(), actionContinue, false), continued) + actionSwitch.AddCase(llvm.ConstInt(ctx.Int8Type(), actionReturn, false), returned) + actionSwitch.AddCase(llvm.ConstInt(ctx.Int8Type(), actionSuspend, false), suspended) + + builder.SetInsertPointAtEnd(continued) + builder.CreateBr(dispatch) + + builder.SetInsertPointAtEnd(returned) + parent := builder.CreateLoad( + abi.ptr, builder.CreateStructGEP(framePrefix, top, 0, ""), "parent", + ) + builder.CreateStore(parent, topField) + builder.CreateStore(top, returnedField) + builder.CreateBr(dispatch) + + builder.SetInsertPointAtEnd(finished) + builder.CreateCall( + declareFrameClose(mod, abi).GlobalValueType(), + declareFrameClose(mod, abi), + []llvm.Value{context}, + "", + ) + if lowered.layout.plan.resultSlot == 0 { + builder.CreateRetVoid() + } else { + result := builder.CreateLoad( + fn.GlobalValueType().ReturnType(), + builder.CreateStructGEP( + lowered.layout.typ, + root, + lowered.layout.fieldIndex(lowered.layout.plan.resultSlot), + "", + ), + "result", + ) + builder.CreateRet(result) + } + + builder.SetInsertPointAtEnd(suspended) + builder.CreateIntrinsic(ctx.VoidType(), llvm.LookupIntrinsicID("llvm.trap"), nil, "") + builder.CreateUnreachable() + builder.SetInsertPointAtEnd(invalid) + builder.CreateUnreachable() + return nil +} + +func parameterIndex(plan framePlan, slotID uint32) int { + index := 0 + for _, slot := range plan.slots { + if slot.kind != slotParameter { + continue + } + if slot.id == slotID { + return index + } + index++ + } + return -1 +} diff --git a/internal/wasmresume/state.go b/internal/wasmresume/state.go index 7540184042..7228ad7b6a 100644 --- a/internal/wasmresume/state.go +++ b/internal/wasmresume/state.go @@ -83,6 +83,9 @@ func lowerPrototype(mod llvm.Module, targetData llvm.TargetData) ([]loweredState if err := lowerStateMachine(mod, targetData, abi, &lowered[i]); err != nil { return nil, err } + if err := emitCompatibilityWrapper(mod, targetData, abi, &lowered[i]); err != nil { + return nil, err + } } return lowered, nil } @@ -353,3 +356,13 @@ func declareFrameFree(mod llvm.Module, abi resumeABI) llvm.Value { } return fn } + +func declareFrameClose(mod llvm.Module, abi resumeABI) llvm.Value { + fn := mod.NamedFunction(frameCloseName) + if fn.IsNil() { + fn = llvm.AddFunction(mod, frameCloseName, llvm.FunctionType( + abi.ctx.VoidType(), []llvm.Type{abi.ptr}, false, + )) + } + return fn +} diff --git a/internal/wasmresume/state_test.go b/internal/wasmresume/state_test.go index 3bc564ebc0..22cc0b068c 100644 --- a/internal/wasmresume/state_test.go +++ b/internal/wasmresume/state_test.go @@ -107,6 +107,14 @@ func TestLowerPrototypeExecutesDirectCallStateMachine(t *testing.T) { if got := result.Int(true); got != 126 { t.Fatalf("state machine result = %d, want 126", got) } + + arg := llvm.NewGenericValueFromInt(i32, 5, true) + defer arg.Dispose() + result = engine.RunFunction(caller, []llvm.GenericValue{arg}) + defer result.Dispose() + if got := result.Int(true); got != 126 { + t.Fatalf("compatibility wrapper result = %d, want 126", got) + } } func TestLowerPrototypeBuildsDirectCallStateMachine(t *testing.T) { @@ -356,7 +364,7 @@ func defineStateMachineHarness( i8 := ctx.Int8Type() i32 := ctx.Int32Type() - childStorageType := llvm.ArrayType(i8, 1024) + childStorageType := llvm.ArrayType(i8, 4096) childStorage := llvm.AddGlobal(mod, childStorageType, "child.storage") childStorage.SetInitializer(llvm.ConstNull(childStorageType)) childStorage.SetAlignment(16) @@ -380,6 +388,11 @@ func defineStateMachineHarness( builder.SetInsertPointAtEnd(block) builder.CreateRetVoid() + close := mod.NamedFunction(frameCloseName) + block = ctx.AddBasicBlock(close, "entry") + builder.SetInsertPointAtEnd(block) + builder.CreateRetVoid() + root := llvm.AddGlobal(mod, lowered.layout.typ, "root.frame") root.SetInitializer(llvm.ConstNull(lowered.layout.typ)) root.SetAlignment(lowered.layout.alignment) diff --git a/ssa/goroutine.go b/ssa/goroutine.go index 8460d69ca0..07d9c82eee 100644 --- a/ssa/goroutine.go +++ b/ssa/goroutine.go @@ -83,7 +83,11 @@ func (p Package) routineName() string { func (p Package) routine(t Type, fn Expr, buildCall func(Builder, Expr, ...Expr) Expr, n int) Expr { prog := p.Prog - routine := p.NewFunc(p.routineName(), prog.tyRoutine(), InC) + background := InC + if prog.WasmResumeABIEnabled() { + background = InGo + } + routine := p.NewFunc(p.routineName(), prog.tyRoutine(), background) b := routine.MakeBody(1) var localCtx, previousLocalCtx Expr hasLocalContext := prog.NeedsLocalContext() @@ -110,7 +114,11 @@ func (p Package) routine(t Type, fn Expr, buildCall func(Builder, Expr, ...Expr) } b.Return(prog.Nil(prog.VoidPtr())) } - return routine.Expr + ret := routine.Expr + if prog.WasmResumeABIEnabled() { + ret.impl = p.wasmResumeStart(ret.impl) + } + return ret } // ----------------------------------------------------------------------------- diff --git a/ssa/wasm_resume.go b/ssa/wasm_resume.go index 015b4940b6..d19110754c 100644 --- a/ssa/wasm_resume.go +++ b/ssa/wasm_resume.go @@ -34,14 +34,18 @@ func (p Program) WasmResumeABIEnabled() bool { } func (p Program) markWasmResumeFunction(fn llvm.Value) { - if !p.WasmResumeABIEnabled() { + if !p.WasmResumeABIEnabled() || + wasmresume.IsRuntimeABIImplementation(fn.Name()) || + wasmresume.IsNonSuspendingBoundary(fn.Name()) { return } fn.AddFunctionAttr(p.ctx.CreateStringAttribute(wasmresume.FunctionAttribute, "1")) } func (p Package) wasmResumeStart(fn llvm.Value) llvm.Value { - if !p.Prog.WasmResumeABIEnabled() { + if !p.Prog.WasmResumeABIEnabled() || + wasmresume.IsRuntimeABIImplementation(fn.Name()) || + wasmresume.IsNonSuspendingBoundary(fn.Name()) { return fn } name := wasmresume.StartSymbol(fn.Name()) @@ -55,7 +59,15 @@ func (p Package) wasmResumeStart(fn llvm.Value) llvm.Value { } func (b Builder) markWasmResumeCall(call llvm.Value, background Background) { - if background != InGo || !b.Prog.WasmResumeABIEnabled() { + if background != InGo || !b.Prog.WasmResumeABIEnabled() || + b.Func == nil || b.Func.background != InGo || + wasmresume.IsRuntimeABIImplementation(b.Func.Name()) || + wasmresume.IsNonSuspendingBoundary(b.Func.Name()) { + return + } + callee := call.CalledValue() + if !callee.IsAFunction().IsNil() && + wasmresume.IsNonSuspendingBoundary(callee.Name()) { return } kind := b.Prog.ctx.MDKindID(wasmresume.CallMetadata) diff --git a/ssa/wasm_resume_test.go b/ssa/wasm_resume_test.go index 9b4edd9397..3bb0bea5ff 100644 --- a/ssa/wasm_resume_test.go +++ b/ssa/wasm_resume_test.go @@ -225,3 +225,59 @@ func TestWasmResumeABILowersSuspendCurrent(t *testing.T) { } } } + +func TestWasmResumeABIKeepsRuntimeBoundariesSynchronous(t *testing.T) { + prog := NewProgram(&Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + + pkg := prog.NewPackage("p", "example.com/p") + ordinary := pkg.NewFunc("ordinary", NoArgsNoRet, InGo) + ordinary.MakeBody(1).Return() + + boundary := pkg.NewFunc( + "github.com/goplus/llgo/runtime/internal/wasmresume.Context.Run", + NoArgsNoRet, + InGo, + ) + b := boundary.MakeBody(1) + b.Call(ordinary.Expr) + b.Return() + + root := pkg.NewFunc("root", NoArgsNoRet, InC) + b = root.MakeBody(1) + b.Call(ordinary.Expr) + b.Return() + + caller := pkg.NewFunc("caller", NoArgsNoRet, InGo) + b = caller.MakeBody(1) + b.Call(boundary.Expr) + b.Return() + + for _, function := range []Function{boundary, root} { + for _, attr := range function.impl.GetFunctionAttributes() { + if attr.IsString() && attr.GetStringKind() == wasmresume.FunctionAttribute { + t.Fatalf("%s was marked resumable:\n%s", function.Name(), pkg.String()) + } + } + } + var callerMarked bool + for _, attr := range caller.impl.GetFunctionAttributes() { + if attr.IsString() && attr.GetStringKind() == wasmresume.FunctionAttribute { + callerMarked = true + } + } + if !callerMarked { + t.Fatalf("ordinary Go caller was not marked resumable:\n%s", pkg.String()) + } + kind := prog.ctx.MDKindID(wasmresume.CallMetadata) + for _, function := range []Function{boundary, root, caller} { + for block := function.impl.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + for instr := block.FirstInstruction(); !instr.IsNil(); instr = llvm.NextInstruction(instr) { + if instr.HasMetadata() && !instr.Metadata(kind).IsNil() { + t.Fatalf("%s contains a resumable boundary call:\n%s", function.Name(), pkg.String()) + } + } + } + } +} From 6aaf87800c0037b37b6ee2ad3cd4c5e26142ec84 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 23:00:57 +0800 Subject: [PATCH 35/40] internal/build: separate wasm exception translation from Asyncify --- internal/build/wasm_postlink.go | 24 ++++++++++++++-------- internal/build/wasm_postlink_test.go | 24 ++++++++++++++++++---- internal/crosscompile/crosscompile.go | 4 +++- internal/crosscompile/crosscompile_test.go | 3 +++ 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/internal/build/wasm_postlink.go b/internal/build/wasm_postlink.go index b460f153b1..2a90340f71 100644 --- a/internal/build/wasm_postlink.go +++ b/internal/build/wasm_postlink.go @@ -29,17 +29,25 @@ import ( func needsWasmPostLink(conf *Config, target *crosscompile.Export) bool { return conf != nil && conf.BuildMode == BuildModeExe && - target != nil && target.WasmPostLink.Asyncify + target != nil && + (target.WasmPostLink.Asyncify || target.WasmPostLink.TranslateToExnref) } func wasmPostLinkArgs(target *crosscompile.Export, input, output string, debug bool) []string { - if target == nil || !target.WasmPostLink.Asyncify { + if target == nil || + (!target.WasmPostLink.Asyncify && !target.WasmPostLink.TranslateToExnref) { 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"} + var args []string + if target.WasmPostLink.Asyncify { + args = append(args, "--asyncify") + } + // LLVM 19 lowers Wasm SjLj through the legacy EH encoding. When Asyncify + // is enabled, translate only after instrumentation so the final module + // uses the standardized exnref-based EH instructions. + if target.WasmPostLink.TranslateToExnref { + args = append(args, "--translate-to-exnref") + } if debug { args = append(args, "-g") } @@ -89,7 +97,7 @@ func postLinkWasm(ctx *context, input, output string, verbose bool) error { } resolved, err := exec.LookPath(wasmOpt) if err != nil { - return fmt.Errorf("WebAssembly Asyncify requires wasm-opt; install Binaryen or set WASMOPT: %w", err) + return fmt.Errorf("WebAssembly post-link requires wasm-opt; install Binaryen or set WASMOPT: %w", err) } tmpName, err := createClosedTemp( @@ -114,7 +122,7 @@ func postLinkWasm(ctx *context, input, output string, verbose bool) error { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("wasm-opt Asyncify failed: %w", err) + return fmt.Errorf("wasm-opt post-link failed: %w", err) } if err := os.Rename(tmpName, output); err != nil { return err diff --git a/internal/build/wasm_postlink_test.go b/internal/build/wasm_postlink_test.go index 0a00327425..406183dd38 100644 --- a/internal/build/wasm_postlink_test.go +++ b/internal/build/wasm_postlink_test.go @@ -33,7 +33,10 @@ func wasmPostLinkTestContext() *context { return &context{ buildConf: &Config{LinkOptions: LinkOptions{DWARF: DWARFOmit}}, crossCompile: crosscompile.Export{ - WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}, + WasmPostLink: crosscompile.WasmPostLink{ + Asyncify: true, + TranslateToExnref: true, + }, }, } } @@ -51,7 +54,10 @@ func writeWasmOptTestTool(t *testing.T, dir, script string) string { } func TestWasmPostLinkArgs(t *testing.T) { - target := &crosscompile.Export{WasmPostLink: crosscompile.WasmPostLink{Asyncify: true}} + target := &crosscompile.Export{WasmPostLink: crosscompile.WasmPostLink{ + Asyncify: true, + TranslateToExnref: 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) @@ -63,6 +69,11 @@ func TestWasmPostLinkArgs(t *testing.T) { if got := wasmPostLinkArgs(&crosscompile.Export{}, "in", "out", false); got != nil { t.Fatalf("wasmPostLinkArgs(disabled) = %v, want nil", got) } + target.WasmPostLink.Asyncify = false + if got, want := wasmPostLinkArgs(target, "in.wasm", "out.wasm", false), + []string{"--translate-to-exnref", "in.wasm", "-o", "out.wasm"}; !reflect.DeepEqual(got, want) { + t.Fatalf("wasmPostLinkArgs(translate only) = %v, want %v", got, want) + } } func TestNeedsWasmPostLink(t *testing.T) { @@ -87,6 +98,11 @@ func TestNeedsWasmPostLink(t *testing.T) { if needsWasmPostLink(&Config{BuildMode: BuildModeExe}, nil) { t.Fatal("needsWasmPostLink() enabled for a nil target") } + target.WasmPostLink.Asyncify = false + target.WasmPostLink.TranslateToExnref = true + if !needsWasmPostLink(&Config{BuildMode: BuildModeExe}, target) { + t.Fatal("needsWasmPostLink() disabled for exnref translation") + } } func TestPrepareWasmLinkOutput(t *testing.T) { @@ -196,7 +212,7 @@ func TestPostLinkWasmReportsToolFailure(t *testing.T) { ctx := wasmPostLinkTestContext() err := postLinkWasm(ctx, input, output, false) - if err == nil || !strings.Contains(err.Error(), "wasm-opt Asyncify failed") { + if err == nil || !strings.Contains(err.Error(), "wasm-opt post-link failed") { t.Fatalf("postLinkWasm() error = %v", err) } if data, err := os.ReadFile(output); err != nil || string(data) != "old" { @@ -223,7 +239,7 @@ func TestPostLinkWasmReportsPublishFailure(t *testing.T) { if err == nil { t.Fatal("postLinkWasm succeeded when the final output was a directory") } - if strings.Contains(err.Error(), "wasm-opt Asyncify failed") { + if strings.Contains(err.Error(), "wasm-opt post-link failed") { t.Fatalf("postLinkWasm failed before publishing output: %v", err) } } diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index 74c20e5229..7990214ffd 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -51,7 +51,8 @@ type Export struct { // WasmPostLink describes transformations required after the core module is // linked. Build orchestration owns tool discovery and atomic output handling. type WasmPostLink struct { - Asyncify bool + Asyncify bool + TranslateToExnref bool } // DebugInfoPolicy describes how a selected linker handles debug information. @@ -429,6 +430,7 @@ func useWithJSWasm32(goos, goarch string, wasiThreads, forceEspClang bool, level ) } else { export.WasmPostLink.Asyncify = true + export.WasmPostLink.TranslateToExnref = true } case "js": diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index f811bf3e9b..f46b13c7b1 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -132,6 +132,9 @@ func TestUseCrossCompileSDK(t *testing.T) { if !export.WasmPostLink.Asyncify { t.Error("WASI target does not request Asyncify post-link processing") } + if !export.WasmPostLink.TranslateToExnref { + t.Error("WASI target does not request standardized exception encoding") + } if slices.Contains(export.LDFLAGS, "-Wl,--import-memory") { t.Errorf("single-worker WASI imports host memory: %v", export.LDFLAGS) } From 8efa13ad15690e78b738bf3eaeff2b1671a55869 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 23:41:59 +0800 Subject: [PATCH 36/40] wasmresume: complete persistent frame lifecycle --- internal/wasmresume/abi.go | 18 +- internal/wasmresume/blockaddress.go | 73 ++++++ internal/wasmresume/blockaddress_test.go | 50 ++++ internal/wasmresume/boundary.go | 22 +- internal/wasmresume/boundary_test.go | 17 +- internal/wasmresume/dynamic.go | 123 ++++++++++ internal/wasmresume/dynamic_test.go | 99 ++++++++ internal/wasmresume/frameplan.go | 65 +++++- internal/wasmresume/inventory.go | 12 +- internal/wasmresume/layout.go | 53 ++++- internal/wasmresume/layout_test.go | 39 ++++ internal/wasmresume/leaf.go | 2 +- internal/wasmresume/leaf_test.go | 4 +- internal/wasmresume/spill.go | 9 +- internal/wasmresume/spill_test.go | 96 +++++--- internal/wasmresume/state.go | 73 ++++-- internal/wasmresume/state_test.go | 11 +- internal/wasmresume/unwind.go | 66 ++++++ internal/wasmresume/unwind_test.go | 245 ++++++++++++++++++++ runtime/internal/wasmresume/resume.go | 67 +++++- runtime/internal/wasmresume/resume_test.go | 36 ++- runtime/internal/wasmresume/storage.go | 31 ++- runtime/internal/wasmresume/storage_test.go | 125 ++++++++-- 23 files changed, 1205 insertions(+), 131 deletions(-) create mode 100644 internal/wasmresume/blockaddress.go create mode 100644 internal/wasmresume/blockaddress_test.go create mode 100644 internal/wasmresume/dynamic.go create mode 100644 internal/wasmresume/dynamic_test.go create mode 100644 internal/wasmresume/unwind.go create mode 100644 internal/wasmresume/unwind_test.go diff --git a/internal/wasmresume/abi.go b/internal/wasmresume/abi.go index 8ee19c442c..37ea167cc7 100644 --- a/internal/wasmresume/abi.go +++ b/internal/wasmresume/abi.go @@ -50,11 +50,17 @@ func newResumeABI(ctx llvm.Context, targetData llvm.TargetData) resumeABI { ptr := llvm.PointerType(ctx.Int8Type(), 0) uintptrType := ctx.IntType(targetData.PointerSize() * 8) return resumeABI{ - ctx: ctx, - ptr: ptr, - uintptrType: uintptrType, - entryType: llvm.FunctionType(ctx.Int8Type(), []llvm.Type{ptr, ptr}, false), - descriptorType: ctx.StructType([]llvm.Type{ptr, uintptrType, uintptrType}, false), + ctx: ctx, + ptr: ptr, + uintptrType: uintptrType, + entryType: llvm.FunctionType(ctx.Int8Type(), []llvm.Type{ptr, ptr}, false), + descriptorType: ctx.StructType([]llvm.Type{ + ptr, + uintptrType, + uintptrType, + uintptrType, + ctx.Int32Type(), + }, false), // The first two fields are the public dispatch ABI. The trailing pointer // is runtime-owned per-context frame storage. contextType: ctx.StructType([]llvm.Type{ptr, ptr, ptr}, false), @@ -80,6 +86,8 @@ func (abi resumeABI) defineEntryAndDescriptor( entry, llvm.ConstInt(abi.uintptrType, layout.size, false), llvm.ConstInt(abi.uintptrType, uint64(layout.alignment), false), + llvm.ConstInt(abi.uintptrType, layout.unwindOffset, false), + llvm.ConstInt(abi.ctx.Int32Type(), uint64(layout.plan.unwindPC), false), }, false)) return entry, descriptor, nil } diff --git a/internal/wasmresume/blockaddress.go b/internal/wasmresume/blockaddress.go new file mode 100644 index 0000000000..be0a782458 --- /dev/null +++ b/internal/wasmresume/blockaddress.go @@ -0,0 +1,73 @@ +/* + * 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 wasmresume + +import "github.com/xgo-dev/llvm" + +type movedBlockAddress struct { + value llvm.Value + block llvm.BasicBlock +} + +func collectMovedBlockAddresses(function llvm.Value, blocks []llvm.BasicBlock) []movedBlockAddress { + found := make(map[llvm.Value]llvm.BasicBlock) + seen := make(map[llvm.Value]struct{}) + var visit func(llvm.Value) + visit = func(value llvm.Value) { + if value.IsNil() { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + if value.IsAUser().IsNil() { + return + } + if value.OperandsCount() == 2 && + value.Operand(0) == function && + value.Operand(1).IsBasicBlock() { + found[value] = value.Operand(1).AsBasicBlock() + return + } + if value.IsAConstant().IsNil() { + return + } + for i := 0; i < value.OperandsCount(); i++ { + visit(value.Operand(i)) + } + } + for _, block := range blocks { + for instruction := block.FirstInstruction(); !instruction.IsNil(); instruction = llvm.NextInstruction(instruction) { + for i := 0; i < instruction.OperandsCount(); i++ { + visit(instruction.Operand(i)) + } + } + } + + addresses := make([]movedBlockAddress, 0, len(found)) + for value, block := range found { + addresses = append(addresses, movedBlockAddress{value: value, block: block}) + } + return addresses +} + +func remapMovedBlockAddresses(function llvm.Value, addresses []movedBlockAddress) { + for _, address := range addresses { + address.value.ReplaceAllUsesWith(llvm.BlockAddress(function, address.block)) + } +} diff --git a/internal/wasmresume/blockaddress_test.go b/internal/wasmresume/blockaddress_test.go new file mode 100644 index 0000000000..07b843b327 --- /dev/null +++ b/internal/wasmresume/blockaddress_test.go @@ -0,0 +1,50 @@ +package wasmresume + +import ( + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestLowerRemapsBlockAddressesToResumeEntry(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("block-address") + defer mod.Dispose() + mod.SetTarget("wasm32-unknown-unknown") + targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") + defer targetData.Dispose() + + callee := llvm.AddFunction(mod, "callee", llvm.FunctionType(ctx.VoidType(), nil, false)) + markFunction(ctx, callee) + calleeBlock := ctx.AddBasicBlock(callee, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(calleeBlock) + builder.CreateRetVoid() + + caller := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), nil, false)) + markFunction(ctx, caller) + entry := ctx.AddBasicBlock(caller, "entry") + target := ctx.AddBasicBlock(caller, "target") + builder.SetInsertPointAtEnd(entry) + call := builder.CreateCall(callee.GlobalValueType(), callee, nil, "") + markCall(ctx, call) + indirect := builder.CreateIndirectBr(llvm.BlockAddress(caller, target), 1) + indirect.AddDest(target) + builder.SetInsertPointAtEnd(target) + builder.CreateRetVoid() + + if err := Lower(mod, targetData); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify lowered module: %v\n%s", err, mod.String()) + } + ir := mod.String() + if strings.Contains(ir, "blockaddress(@caller,") || + !strings.Contains(ir, "blockaddress(@__llgo_wasm_resume.caller,") { + t.Fatalf("block address was not remapped to the resume entry:\n%s", ir) + } +} diff --git a/internal/wasmresume/boundary.go b/internal/wasmresume/boundary.go index e219234a19..2ee817d3c8 100644 --- a/internal/wasmresume/boundary.go +++ b/internal/wasmresume/boundary.go @@ -23,6 +23,10 @@ const ( runtimeAllocRoot = "github.com/goplus/llgo/runtime/internal/runtime.AllocRoot" runtimeFreeRoot = "github.com/goplus/llgo/runtime/internal/runtime.FreeRoot" runtimeRunWasmMain = "github.com/goplus/llgo/runtime/internal/runtime.RunWasmMain" + runtimeFrameAlloc = "__llgo_wasm_resume_alloc" + runtimeDynamicAlloc = "__llgo_wasm_resume_alloc_dynamic" + runtimeFrameFree = "__llgo_wasm_resume_free" + runtimeFrameClose = "__llgo_wasm_resume_close" ) // IsRuntimeABIImplementation reports functions which implement the resumable @@ -34,8 +38,24 @@ func IsRuntimeABIImplementation(name string) bool { // IsNonSuspendingBoundary reports leaf runtime entry points which remain // callable without allocating a resumable frame. func IsNonSuspendingBoundary(name string) bool { + switch name { + case "github.com/goplus/llgo/runtime/internal/runtime.ClearThreadDefer", + "github.com/goplus/llgo/runtime/internal/runtime.FreeDeferNode", + "github.com/goplus/llgo/runtime/internal/runtime.GetThreadDefer", + "github.com/goplus/llgo/runtime/internal/runtime.Goexit", + "github.com/goplus/llgo/runtime/internal/runtime.Panic", + "github.com/goplus/llgo/runtime/internal/runtime.Recover", + "github.com/goplus/llgo/runtime/internal/runtime.Rethrow", + "github.com/goplus/llgo/runtime/internal/runtime.SetThreadDefer", + "runtime.Goexit": + return true + } return (IsRuntimeABIImplementation(name) && name != SuspendSymbol) || name == runtimeAllocRoot || name == runtimeFreeRoot || - name == runtimeRunWasmMain + name == runtimeRunWasmMain || + name == runtimeFrameAlloc || + name == runtimeDynamicAlloc || + name == runtimeFrameFree || + name == runtimeFrameClose } diff --git a/internal/wasmresume/boundary_test.go b/internal/wasmresume/boundary_test.go index a470b281c2..9c445383f1 100644 --- a/internal/wasmresume/boundary_test.go +++ b/internal/wasmresume/boundary_test.go @@ -17,7 +17,22 @@ func TestRuntimeBoundaries(t *testing.T) { if IsNonSuspendingBoundary(SuspendSymbol) { t.Fatal("SuspendCurrent was classified as non-suspending") } - for _, name := range []string{runtimeAllocRoot, runtimeFreeRoot, runtimeRunWasmMain} { + for _, name := range []string{ + runtimeAllocRoot, + runtimeFreeRoot, + runtimeRunWasmMain, + runtimeFrameAlloc, + runtimeDynamicAlloc, + runtimeFrameFree, + runtimeFrameClose, + "github.com/goplus/llgo/runtime/internal/runtime.GetThreadDefer", + "github.com/goplus/llgo/runtime/internal/runtime.SetThreadDefer", + "github.com/goplus/llgo/runtime/internal/runtime.Panic", + "github.com/goplus/llgo/runtime/internal/runtime.Recover", + "github.com/goplus/llgo/runtime/internal/runtime.Rethrow", + "github.com/goplus/llgo/runtime/internal/runtime.Goexit", + "runtime.Goexit", + } { if !IsNonSuspendingBoundary(name) { t.Fatalf("%q is not a non-suspending boundary", name) } diff --git a/internal/wasmresume/dynamic.go b/internal/wasmresume/dynamic.go new file mode 100644 index 0000000000..f45a10097f --- /dev/null +++ b/internal/wasmresume/dynamic.go @@ -0,0 +1,123 @@ +/* + * 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 wasmresume + +import ( + "fmt" + "strings" + + "github.com/xgo-dev/llvm" +) + +func lowerDynamicAlloca( + mod llvm.Module, + targetData llvm.TargetData, + abi resumeABI, + entry llvm.Value, + alloca llvm.Value, + field llvm.Value, +) error { + if alloca.IsAAllocaInst().IsNil() || alloca.OperandsCount() == 0 { + return fmt.Errorf("invalid dynamic alloca %q", alloca.Name()) + } + + ctx := mod.Context() + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointBefore(alloca) + + count := alloca.Operand(0) + switch { + case count.Type().IntTypeWidth() < abi.uintptrType.IntTypeWidth(): + count = builder.CreateZExt(count, abi.uintptrType, "alloca.count") + case count.Type().IntTypeWidth() > abi.uintptrType.IntTypeWidth(): + count = builder.CreateTrunc(count, abi.uintptrType, "alloca.count") + } + size := count + if elementSize := targetData.TypeAllocSize(alloca.AllocatedType()); elementSize != 1 { + size = builder.CreateMul( + count, + llvm.ConstInt(abi.uintptrType, elementSize, false), + "alloca.size", + ) + } + one := llvm.ConstInt(abi.uintptrType, 1, false) + size = builder.CreateSelect( + builder.CreateICmp(llvm.IntEQ, size, llvm.ConstNull(abi.uintptrType), ""), + one, + size, + "alloca.nonzero.size", + ) + align := targetData.ABITypeAlignment(alloca.AllocatedType()) + if alloca.Alignment() > align { + align = alloca.Alignment() + } + allocate := declareDynamicAllocator(mod, abi) + value := builder.CreateCall(allocate.GlobalValueType(), allocate, []llvm.Value{ + entry.Param(0), + size, + llvm.ConstInt(abi.uintptrType, uint64(align), false), + }, alloca.Name()+".frame") + store := builder.CreateStore(value, field) + replaceValueUsesWithLoads(ctx, alloca, field, store) + alloca.EraseFromParentAsInstruction() + return nil +} + +func isStackSave(value llvm.Value) bool { + return isCallToIntrinsic(value, "llvm.stacksave") +} + +func lowerPersistentStackSave(save llvm.Value) error { + var restores []llvm.Value + seen := make(map[llvm.Value]struct{}) + for use := save.FirstUse(); !use.IsNil(); use = use.NextUse() { + user := use.User() + if !isCallToIntrinsic(user, "llvm.stackrestore") { + return fmt.Errorf("persistent stacksave has unsupported use %q", user.Name()) + } + if _, ok := seen[user]; !ok { + seen[user] = struct{}{} + restores = append(restores, user) + } + } + for _, restore := range restores { + restore.EraseFromParentAsInstruction() + } + save.EraseFromParentAsInstruction() + return nil +} + +func isCallToIntrinsic(value llvm.Value, name string) bool { + if value.IsNil() || value.IsAInstruction().IsNil() || + value.InstructionOpcode() != llvm.Call { + return false + } + callee := value.CalledValue() + return !callee.IsAFunction().IsNil() && + (callee.Name() == name || strings.HasPrefix(callee.Name(), name+".")) +} + +func declareDynamicAllocator(mod llvm.Module, abi resumeABI) llvm.Value { + fn := mod.NamedFunction(frameDynamicAllocName) + if fn.IsNil() { + fn = llvm.AddFunction(mod, frameDynamicAllocName, llvm.FunctionType( + abi.ptr, []llvm.Type{abi.ptr, abi.uintptrType, abi.uintptrType}, false, + )) + } + return fn +} diff --git a/internal/wasmresume/dynamic_test.go b/internal/wasmresume/dynamic_test.go new file mode 100644 index 0000000000..a4e05afc53 --- /dev/null +++ b/internal/wasmresume/dynamic_test.go @@ -0,0 +1,99 @@ +package wasmresume + +import ( + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestLowerMovesPersistentDynamicAllocaIntoContextStorage(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("dynamic-alloca") + defer mod.Dispose() + mod.SetTarget("wasm32-unknown-unknown") + targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") + defer targetData.Dispose() + + i8 := ctx.Int8Type() + ptr := llvm.PointerType(i8, 0) + calleeType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr}, false) + callee := llvm.AddFunction(mod, "callee", calleeType) + markFunction(ctx, callee) + calleeBlock := ctx.AddBasicBlock(callee, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(calleeBlock) + builder.CreateRetVoid() + + caller := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), []llvm.Type{ctx.Int32Type()}, false)) + markFunction(ctx, caller) + block := ctx.AddBasicBlock(caller, "entry") + builder.SetInsertPointAtEnd(block) + buffer := builder.CreateArrayAlloca(i8, caller.Param(0), "buffer") + call := builder.CreateCall(calleeType, callee, []llvm.Value{buffer}, "") + markCall(ctx, call) + builder.CreateRetVoid() + + if err := Lower(mod, targetData); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify lowered module: %v\n%s", err, mod.String()) + } + ir := mod.String() + if strings.Contains(ir, "%buffer = alloca") || + !strings.Contains(ir, "call ptr @__llgo_wasm_resume_alloc_dynamic") { + t.Fatalf("dynamic alloca was not moved into context storage:\n%s", ir) + } +} + +func TestLowerRemovesStackLifetimeAcrossResume(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("stack-lifetime") + defer mod.Dispose() + mod.SetTarget("wasm32-unknown-unknown") + targetData := llvm.NewTargetData("e-m:e-p:32:32-i64:64-n32:64-S128") + defer targetData.Dispose() + + callee := llvm.AddFunction(mod, "callee", llvm.FunctionType(ctx.VoidType(), nil, false)) + markFunction(ctx, callee) + calleeBlock := ctx.AddBasicBlock(callee, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(calleeBlock) + builder.CreateRetVoid() + + caller := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), nil, false)) + markFunction(ctx, caller) + block := ctx.AddBasicBlock(caller, "entry") + builder.SetInsertPointAtEnd(block) + saved := builder.CreateIntrinsic( + llvm.PointerType(ctx.Int8Type(), 0), + llvm.LookupIntrinsicID("llvm.stacksave"), + nil, + "", + ) + call := builder.CreateCall(callee.GlobalValueType(), callee, nil, "") + markCall(ctx, call) + builder.CreateIntrinsic( + ctx.VoidType(), + llvm.LookupIntrinsicID("llvm.stackrestore"), + []llvm.Value{saved}, + "", + ) + builder.CreateRetVoid() + + if err := Lower(mod, targetData); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify lowered module: %v\n%s", err, mod.String()) + } + if ir := mod.String(); strings.Contains(ir, "call ptr @llvm.stacksave") || + strings.Contains(ir, "call void @llvm.stackrestore") { + t.Fatalf("native stack lifetime crosses a resume point:\n%s", ir) + } +} diff --git a/internal/wasmresume/frameplan.go b/internal/wasmresume/frameplan.go index 3717e2e7be..63b58a915f 100644 --- a/internal/wasmresume/frameplan.go +++ b/internal/wasmresume/frameplan.go @@ -30,6 +30,7 @@ const ( slotFunctionResult slotAlloca slotValue + slotUnwind ) type frameSlot struct { @@ -48,10 +49,13 @@ type callSite struct { } type framePlan struct { - function llvm.Value - slots []frameSlot - resultSlot uint32 - calls []callSite + function llvm.Value + slots []frameSlot + resultSlot uint32 + calls []callSite + unwindSlot uint32 + unwindPC uint32 + unwindBlock llvm.BasicBlock } type blockLiveness struct { @@ -76,6 +80,9 @@ func planFrames(mod llvm.Module) ([]framePlan, error) { if !hasFunctionMarker(fn) { continue } + if err := llvm.VerifyFunction(fn, llvm.ReturnStatusAction); err != nil { + return nil, fmt.Errorf("%s: invalid resumable function: %w", fn.Name(), err) + } plan, err := planFunctionFrame(fn, kind) if err != nil { return nil, fmt.Errorf("%s: %w", fn.Name(), err) @@ -86,6 +93,10 @@ func planFrames(mod llvm.Module) ([]framePlan, error) { } func planFunctionFrame(fn llvm.Value, metadataKind int) (framePlan, error) { + unwind, err := findUnwindPlan(fn) + if err != nil { + return framePlan{}, err + } values, candidates, kinds := frameCandidates(fn) blocks, liveness := analyzeLiveness(fn, candidates) @@ -96,6 +107,9 @@ func planFunctionFrame(fn llvm.Value, metadataKind int) (framePlan, error) { result llvm.Value } needed := make(valueSet) + if !unwind.block.IsNil() { + unionInto(needed, liveness[unwind.block].liveIn) + } for _, block := range blocks { live := cloneSet(liveness[block].liveOut) for instr := block.LastInstruction(); !instr.IsNil(); instr = llvm.PrevInstruction(instr) { @@ -164,6 +178,17 @@ func planFunctionFrame(fn llvm.Value, metadataKind int) (framePlan, error) { typ, dynamic := persistentSlotType(value, kinds[value]) addSlot(kinds[value], typ, value, dynamic) } + if !unwind.block.IsNil() { + plan.unwindSlot = addSlot(slotUnwind, unwind.typ, llvm.Value{}, false) + plan.unwindPC = 1 + if len(rawCalls) != 0 { + plan.unwindPC = rawCalls[len(rawCalls)-1].id + 1 + } + if plan.unwindPC > maxResumeID { + return framePlan{}, fmt.Errorf("unwind state exceeds maximum resume ID") + } + plan.unwindBlock = unwind.block + } for _, raw := range rawCalls { site := callSite{id: raw.id, call: raw.call} @@ -180,6 +205,38 @@ func planFunctionFrame(fn llvm.Value, metadataKind int) (framePlan, error) { return plan, nil } +type unwindPlan struct { + block llvm.BasicBlock + typ llvm.Type +} + +func findUnwindPlan(fn llvm.Value) (unwindPlan, error) { + var plan unwindPlan + for block := fn.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + for instr := block.FirstInstruction(); !instr.IsNil(); instr = llvm.NextInstruction(instr) { + if instr.InstructionOpcode() != llvm.Call || + instr.CalledValue().Name() != RegisterUnwindSymbol { + continue + } + if !plan.block.IsNil() { + return unwindPlan{}, fmt.Errorf("multiple unwind registrations") + } + if instr.OperandsCount() < 3 { + return unwindPlan{}, fmt.Errorf("invalid unwind registration") + } + address := instr.Operand(1) + if address.OperandsCount() != 2 || + address.Operand(0) != fn || + !address.Operand(1).IsBasicBlock() { + return unwindPlan{}, fmt.Errorf("invalid unwind handler") + } + plan.block = address.Operand(1).AsBasicBlock() + plan.typ = instr.Operand(0).Type() + } + } + return plan, nil +} + func persistentSlotType(value llvm.Value, kind slotKind) (llvm.Type, bool) { if kind != slotAlloca { return value.Type(), false diff --git a/internal/wasmresume/inventory.go b/internal/wasmresume/inventory.go index 433a19f233..9c5889cc34 100644 --- a/internal/wasmresume/inventory.go +++ b/internal/wasmresume/inventory.go @@ -25,11 +25,13 @@ import ( ) const ( - FunctionAttribute = "llgo.wasm.resume" - CallMetadata = "llgo.wasm.resume.call" - SuspendSymbol = "github.com/goplus/llgo/runtime/internal/wasmresume.SuspendCurrent" - MarkerVersion = 1 - maxResumeID = 1<<16 - 1 + FunctionAttribute = "llgo.wasm.resume" + CallMetadata = "llgo.wasm.resume.call" + SuspendSymbol = "github.com/goplus/llgo/runtime/internal/wasmresume.SuspendCurrent" + RegisterUnwindSymbol = "github.com/goplus/llgo/runtime/internal/wasmresume.RegisterUnwind" + ClearUnwindSymbol = "github.com/goplus/llgo/runtime/internal/wasmresume.ClearUnwind" + MarkerVersion = 1 + maxResumeID = 1<<16 - 1 ) // Function describes the resumable calls in one generated Go function. diff --git a/internal/wasmresume/layout.go b/internal/wasmresume/layout.go index 50733a94af..6e81d3550c 100644 --- a/internal/wasmresume/layout.go +++ b/internal/wasmresume/layout.go @@ -21,10 +21,12 @@ import "github.com/xgo-dev/llvm" const frameHeaderFields = 3 type frameLayout struct { - plan framePlan - typ llvm.Type - size uint64 - alignment int + plan framePlan + typ llvm.Type + size uint64 + alignment int + fields []int + unwindOffset uint64 } func layoutFrames(mod llvm.Module, targetData llvm.TargetData) ([]frameLayout, error) { @@ -38,25 +40,52 @@ func layoutFrames(mod llvm.Module, targetData llvm.TargetData) ([]frameLayout, e layouts := make([]frameLayout, len(plans)) for i, plan := range plans { - fields := make([]llvm.Type, frameHeaderFields, frameHeaderFields+len(plan.slots)) - copy(fields, header) + fields := append([]llvm.Type(nil), header...) + fieldIndices := make([]int, len(plan.slots)+1) + headerType := ctx.StructType(header, false) + frameAlign := targetData.ABITypeAlignment(headerType) for _, slot := range plan.slots { + align := targetData.ABITypeAlignment(slot.typ) + if slot.kind == slotAlloca && slot.value.Alignment() > align { + align = slot.value.Alignment() + } + if align > frameAlign { + frameAlign = align + } + withSlot := append(append([]llvm.Type(nil), fields...), slot.typ) + naturalOffset := targetData.ElementOffset( + ctx.StructType(withSlot, false), len(withSlot)-1, + ) + if padding := alignmentPadding(naturalOffset, uint64(align)); padding != 0 { + fields = append(fields, llvm.ArrayType(ctx.Int8Type(), int(padding))) + } + fieldIndices[slot.id] = len(fields) fields = append(fields, slot.typ) } typ := ctx.StructType(fields, false) + var unwindOffset uint64 + if plan.unwindSlot != 0 { + unwindOffset = targetData.ElementOffset(typ, fieldIndices[plan.unwindSlot]) + } layouts[i] = frameLayout{ - plan: plan, - typ: typ, - size: targetData.TypeAllocSize(typ), - alignment: targetData.ABITypeAlignment(typ), + plan: plan, + typ: typ, + size: targetData.TypeAllocSize(typ), + alignment: frameAlign, + fields: fieldIndices, + unwindOffset: unwindOffset, } } return layouts, nil } func (l frameLayout) fieldIndex(slotID uint32) int { - if slotID == 0 || int(slotID) > len(l.plan.slots) { + if slotID == 0 || int(slotID) >= len(l.fields) { return -1 } - return frameHeaderFields + int(slotID) - 1 + return l.fields[slotID] +} + +func alignmentPadding(offset, align uint64) uint64 { + return -offset & (align - 1) } diff --git a/internal/wasmresume/layout_test.go b/internal/wasmresume/layout_test.go index e9a658860e..b710c520cb 100644 --- a/internal/wasmresume/layout_test.go +++ b/internal/wasmresume/layout_test.go @@ -107,3 +107,42 @@ func TestLayoutFramesKeepsDynamicAllocaAsPointer(t *testing.T) { t.Fatalf("frame fields = %v, want dynamic alloca pointer at field 4", fields) } } + +func TestLayoutFramesPreservesAllocaAlignment(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("aligned") + defer mod.Dispose() + targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") + defer targetData.Dispose() + + i32 := ctx.Int32Type() + ptr := llvm.PointerType(i32, 0) + calleeType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr}, false) + callee := llvm.AddFunction(mod, "callee", calleeType) + fn := llvm.AddFunction(mod, "caller", llvm.FunctionType(ctx.VoidType(), nil, false)) + markFunction(ctx, fn) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + local := builder.CreateAlloca(i32, "local") + local.SetAlignment(32) + call := builder.CreateCall(calleeType, callee, []llvm.Value{local}, "") + markCall(ctx, call) + builder.CreateRetVoid() + + layouts, err := layoutFrames(mod, targetData) + if err != nil { + t.Fatal(err) + } + layout := layouts[0] + if layout.alignment != 32 { + t.Fatalf("frame alignment = %d, want 32", layout.alignment) + } + slot := layout.plan.slots[0] + offset := targetData.ElementOffset(layout.typ, layout.fieldIndex(slot.id)) + if offset%32 != 0 { + t.Fatalf("aligned alloca offset = %d, want a multiple of 32", offset) + } +} diff --git a/internal/wasmresume/leaf.go b/internal/wasmresume/leaf.go index c1bd7faea1..7e9ed02b3e 100644 --- a/internal/wasmresume/leaf.go +++ b/internal/wasmresume/leaf.go @@ -41,7 +41,7 @@ func emitLeafEntriesForLayouts( var lowered []loweredLeaf for _, layout := range layouts { fn := layout.plan.function - if fn.IsDeclaration() || len(layout.plan.calls) != 0 { + if fn.IsDeclaration() || needsStateMachine(layout) { continue } entry, descriptor, err := abi.defineEntryAndDescriptor(mod, layout) diff --git a/internal/wasmresume/leaf_test.go b/internal/wasmresume/leaf_test.go index a40e21d560..1b571b365c 100644 --- a/internal/wasmresume/leaf_test.go +++ b/internal/wasmresume/leaf_test.go @@ -39,8 +39,8 @@ func TestEmitLeafEntriesLoadsParametersAndStoresResult(t *testing.T) { ir := mod.String() for _, want := range []string{ - `@__llgo_wasm_resume_desc.leaf = constant { ptr, i32, i32 }`, - `{ ptr @__llgo_wasm_resume.leaf, i32 20, i32 4 }`, + `@__llgo_wasm_resume_desc.leaf = constant { ptr, i32, i32, i32, i32 }`, + `{ ptr @__llgo_wasm_resume.leaf, i32 20, i32 4, i32 0, i32 0 }`, `define internal i8 @__llgo_wasm_resume.leaf(ptr %0, ptr %1)`, `load i32, ptr %2`, `call i32 @leaf(i32 %input)`, diff --git a/internal/wasmresume/spill.go b/internal/wasmresume/spill.go index 897ce2c966..7e5a57c999 100644 --- a/internal/wasmresume/spill.go +++ b/internal/wasmresume/spill.go @@ -22,7 +22,7 @@ import ( "github.com/xgo-dev/llvm" ) -func spillValue(ctx llvm.Context, targetData llvm.TargetData, value, field llvm.Value) error { +func spillValue(ctx llvm.Context, value, field llvm.Value) error { if value.IsAInstruction().IsNil() { replaceValueUsesWithLoads(ctx, value, field, llvm.Value{}) return nil @@ -31,17 +31,10 @@ func spillValue(ctx llvm.Context, targetData llvm.TargetData, value, field llvm. if _, dynamic := persistentSlotType(value, slotAlloca); dynamic { return fmt.Errorf("dynamic alloca %q requires separate frame storage", value.Name()) } - if value.Alignment() > targetData.ABITypeAlignment(value.AllocatedType()) { - return fmt.Errorf("over-aligned alloca %q is not supported", value.Name()) - } value.ReplaceAllUsesWith(field) value.EraseFromParentAsInstruction() return nil } - if value.InstructionOpcode() == llvm.Call { - return fmt.Errorf("call result %q must be stored by its resume block", value.Name()) - } - builder := ctx.NewBuilder() defer builder.Dispose() if value.InstructionOpcode() == llvm.PHI { diff --git a/internal/wasmresume/spill_test.go b/internal/wasmresume/spill_test.go index 6dd220d533..126ff9a098 100644 --- a/internal/wasmresume/spill_test.go +++ b/internal/wasmresume/spill_test.go @@ -12,9 +12,6 @@ func TestSpillValueStoresDefinitionAndReloadsUses(t *testing.T) { defer ctx.Dispose() mod := ctx.NewModule("spill") defer mod.Dispose() - targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") - defer targetData.Dispose() - i32 := ctx.Int32Type() frameType := ctx.StructType([]llvm.Type{i32}, false) fn := llvm.AddFunction(mod, "function", llvm.FunctionType(i32, []llvm.Type{ @@ -30,7 +27,7 @@ func TestSpillValueStoresDefinitionAndReloadsUses(t *testing.T) { result := builder.CreateMul(value, value, "result") builder.CreateRet(result) - if err := spillValue(ctx, targetData, value, field); err != nil { + if err := spillValue(ctx, value, field); err != nil { t.Fatal(err) } if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { @@ -49,9 +46,6 @@ func TestSpillValueReloadsParameter(t *testing.T) { defer ctx.Dispose() mod := ctx.NewModule("spill-parameter") defer mod.Dispose() - targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") - defer targetData.Dispose() - i32 := ctx.Int32Type() frameType := ctx.StructType([]llvm.Type{i32}, false) fn := llvm.AddFunction(mod, "function", llvm.FunctionType(i32, []llvm.Type{ @@ -65,7 +59,7 @@ func TestSpillValueReloadsParameter(t *testing.T) { field := builder.CreateStructGEP(frameType, fn.Param(0), 0, "field") builder.CreateRet(fn.Param(1)) - if err := spillValue(ctx, targetData, fn.Param(1), field); err != nil { + if err := spillValue(ctx, fn.Param(1), field); err != nil { t.Fatal(err) } if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { @@ -81,9 +75,6 @@ func TestSpillValueStoresPhiAfterPhiGroup(t *testing.T) { defer ctx.Dispose() mod := ctx.NewModule("spill-phi-definition") defer mod.Dispose() - targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") - defer targetData.Dispose() - i1 := ctx.Int1Type() i32 := ctx.Int32Type() frameType := ctx.StructType([]llvm.Type{i32}, false) @@ -112,7 +103,7 @@ func TestSpillValueStoresPhiAfterPhiGroup(t *testing.T) { result := builder.CreateAdd(phi, llvm.ConstInt(i32, 1, false), "result") builder.CreateRet(result) - if err := spillValue(ctx, targetData, phi, field); err != nil { + if err := spillValue(ctx, phi, field); err != nil { t.Fatal(err) } if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { @@ -174,9 +165,6 @@ func TestSpillValueReplacesAllocaWithFrameAddress(t *testing.T) { defer ctx.Dispose() mod := ctx.NewModule("spill-alloca") defer mod.Dispose() - targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") - defer targetData.Dispose() - i32 := ctx.Int32Type() frameType := ctx.StructType([]llvm.Type{i32}, false) fn := llvm.AddFunction(mod, "function", llvm.FunctionType(i32, []llvm.Type{ @@ -191,7 +179,7 @@ func TestSpillValueReplacesAllocaWithFrameAddress(t *testing.T) { builder.CreateStore(llvm.ConstInt(i32, 9, false), local) builder.CreateRet(builder.CreateLoad(i32, local, "result")) - if err := spillValue(ctx, targetData, local, field); err != nil { + if err := spillValue(ctx, local, field); err != nil { t.Fatal(err) } if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { @@ -207,31 +195,81 @@ func TestSpillValueRejectsUnsupportedDefinitions(t *testing.T) { defer ctx.Dispose() mod := ctx.NewModule("spill-errors") defer mod.Dispose() - targetData := llvm.NewTargetData("e-m:e-p:64:64-i64:64-n32:64-S128") - defer targetData.Dispose() - i32 := ctx.Int32Type() frameType := ctx.StructType([]llvm.Type{i32}, false) - callee := llvm.AddFunction(mod, "callee", llvm.FunctionType(i32, nil, false)) fn := llvm.AddFunction(mod, "function", llvm.FunctionType(ctx.VoidType(), []llvm.Type{i32}, false)) block := ctx.AddBasicBlock(fn, "entry") builder := ctx.NewBuilder() defer builder.Dispose() builder.SetInsertPointAtEnd(block) field := builder.CreateStructGEP(frameType, builder.CreateAlloca(frameType, "frame"), 0, "field") - call := builder.CreateCall(callee.GlobalValueType(), callee, nil, "call") - local := builder.CreateAlloca(i32, "aligned") - local.SetAlignment(16) dynamic := builder.CreateArrayAlloca(i32, fn.Param(0), "dynamic") builder.CreateRetVoid() - if err := spillValue(ctx, targetData, call, field); err == nil || !strings.Contains(err.Error(), "resume block") { - t.Fatalf("call spill error = %v", err) + if err := spillValue(ctx, dynamic, field); err == nil || !strings.Contains(err.Error(), "separate frame storage") { + t.Fatalf("dynamic alloca spill error = %v", err) } - if err := spillValue(ctx, targetData, local, field); err == nil || !strings.Contains(err.Error(), "over-aligned") { - t.Fatalf("alloca spill error = %v", err) +} + +func TestSpillValueStoresOrdinaryCallResult(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("spill-call") + defer mod.Dispose() + i32 := ctx.Int32Type() + frameType := ctx.StructType([]llvm.Type{i32}, false) + callee := llvm.AddFunction(mod, "callee", llvm.FunctionType(i32, nil, false)) + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(i32, []llvm.Type{ + llvm.PointerType(frameType, 0), + }, false)) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + field := builder.CreateStructGEP(frameType, fn.Param(0), 0, "field") + call := builder.CreateCall(callee.GlobalValueType(), callee, nil, "call") + builder.CreateRet(call) + + if err := spillValue(ctx, call, field); err != nil { + t.Fatal(err) } - if err := spillValue(ctx, targetData, dynamic, field); err == nil || !strings.Contains(err.Error(), "separate frame storage") { - t.Fatalf("dynamic alloca spill error = %v", err) + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify spilled call result: %v\n%s", err, mod.String()) + } + ir := mod.String() + if !strings.Contains(ir, "store i32 %call, ptr %field") || + !strings.Contains(ir, "ret i32 %call.reload") { + t.Fatalf("ordinary call result was not stored in the frame:\n%s", ir) + } +} + +func TestSpillValueReplacesOverAlignedAlloca(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("spill-aligned-alloca") + defer mod.Dispose() + i32 := ctx.Int32Type() + frameType := ctx.StructType([]llvm.Type{i32}, false) + fn := llvm.AddFunction(mod, "function", llvm.FunctionType(ctx.VoidType(), nil, false)) + block := ctx.AddBasicBlock(fn, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + frame := builder.CreateAlloca(frameType, "frame") + frame.SetAlignment(32) + field := builder.CreateStructGEP(frameType, frame, 0, "field") + local := builder.CreateAlloca(i32, "local") + local.SetAlignment(32) + builder.CreateStore(llvm.ConstInt(i32, 9, false), local) + builder.CreateRetVoid() + + if err := spillValue(ctx, local, field); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify aligned alloca frame address: %v\n%s", err, mod.String()) + } + if strings.Contains(mod.String(), "%local = alloca") { + t.Fatalf("over-aligned alloca remains after frame replacement:\n%s", mod.String()) } } diff --git a/internal/wasmresume/state.go b/internal/wasmresume/state.go index 7228ad7b6a..81e14abc5d 100644 --- a/internal/wasmresume/state.go +++ b/internal/wasmresume/state.go @@ -24,8 +24,9 @@ import ( ) const ( - frameAllocName = "__llgo_wasm_resume_alloc" - frameFreeName = "__llgo_wasm_resume_free" + frameAllocName = "__llgo_wasm_resume_alloc" + frameDynamicAllocName = "__llgo_wasm_resume_alloc_dynamic" + frameFreeName = "__llgo_wasm_resume_free" ) type loweredState struct { @@ -51,21 +52,20 @@ func lowerPrototype(mod llvm.Module, targetData llvm.TargetData) ([]loweredState return nil, err } for _, layout := range layouts { - if layout.plan.function.IsDeclaration() || len(layout.plan.calls) == 0 { + if layout.plan.function.IsDeclaration() || !needsStateMachine(layout) { continue } - if err := validateStateLayout(layout, targetData); err != nil { + if err := validateStateLayout(layout); err != nil { return nil, fmt.Errorf("%s: %w", layout.plan.function.Name(), err) } } - abi := newResumeABI(mod.Context(), targetData) if _, err := emitLeafEntriesForLayouts(mod, abi, layouts); err != nil { return nil, err } var lowered []loweredState for _, layout := range layouts { - if layout.plan.function.IsDeclaration() || len(layout.plan.calls) == 0 { + if layout.plan.function.IsDeclaration() || !needsStateMachine(layout) { continue } entry, descriptor, err := abi.defineEntryAndDescriptor(mod, layout) @@ -90,18 +90,11 @@ func lowerPrototype(mod llvm.Module, targetData llvm.TargetData) ([]loweredState return lowered, nil } -func validateStateLayout(layout frameLayout, targetData llvm.TargetData) error { - for _, slot := range layout.plan.slots { - if slot.kind != slotAlloca { - continue - } - if slot.dynamic { - return fmt.Errorf("dynamic alloca %q is not supported", slot.value.Name()) - } - if slot.value.Alignment() > targetData.ABITypeAlignment(slot.value.AllocatedType()) { - return fmt.Errorf("over-aligned alloca %q is not supported", slot.value.Name()) - } - } +func needsStateMachine(layout frameLayout) bool { + return len(layout.plan.calls) != 0 || layout.plan.unwindSlot != 0 +} + +func validateStateLayout(layout frameLayout) error { for _, site := range layout.plan.calls { call := site.call if call.CalledFunctionType().IsFunctionVarArg() { @@ -135,12 +128,14 @@ func lowerStateMachine( return fmt.Errorf("%s: resumable definition has no body", fn.Name()) } originalEntry := blocks[0] + blockAddresses := collectMovedBlockAddresses(fn, blocks) dispatch := ctx.AddBasicBlock(lowered.entry, "dispatch") for _, block := range blocks { block.RemoveFromParent() llvm.AppendExistingBasicBlock(lowered.entry, block) } + remapMovedBlockAddresses(lowered.entry, blockAddresses) builder := ctx.NewBuilder() defer builder.Dispose() @@ -154,18 +149,41 @@ func lowerStateMachine( } for _, slot := range layout.plan.slots { + if slot.kind == slotUnwind { + continue + } + if isStackSave(slot.value) { + if err := lowerPersistentStackSave(slot.value); err != nil { + return fmt.Errorf("%s: %w", fn.Name(), err) + } + continue + } + if slot.kind == slotAlloca && slot.dynamic { + if err := lowerDynamicAlloca( + mod, targetData, abi, lowered.entry, slot.value, fields[slot.id], + ); err != nil { + return fmt.Errorf("%s: %w", fn.Name(), err) + } + continue + } switch slot.kind { case slotFunctionResult: continue case slotValue: - if slot.value.InstructionOpcode() == llvm.Call { + if slot.value.InstructionOpcode() == llvm.Call && + isResumeCallResult(layout.plan, slot.id) { continue } } - if err := spillValue(ctx, targetData, slot.value, fields[slot.id]); err != nil { + if err := spillValue(ctx, slot.value, fields[slot.id]); err != nil { return fmt.Errorf("%s: %w", fn.Name(), err) } } + if err := lowerUnwindMarkers( + ctx, lowered.entry, layout.plan, fields[layout.plan.unwindSlot], + ); err != nil { + return fmt.Errorf("%s: %w", fn.Name(), err) + } continuations := make(map[uint32]llvm.BasicBlock, len(layout.plan.calls)) for _, site := range layout.plan.calls { @@ -209,9 +227,24 @@ func lowerStateMachine( continuations[site.id], ) } + if layout.plan.unwindPC != 0 { + switchPC.AddCase( + llvm.ConstInt(ctx.Int32Type(), uint64(layout.plan.unwindPC), false), + layout.plan.unwindBlock, + ) + } return nil } +func isResumeCallResult(plan framePlan, slotID uint32) bool { + for _, site := range plan.calls { + if site.resultSlot == slotID { + return true + } + } + return false +} + func lowerSuspendCall( ctx llvm.Context, parentLayout frameLayout, diff --git a/internal/wasmresume/state_test.go b/internal/wasmresume/state_test.go index 22cc0b068c..6b77f63e96 100644 --- a/internal/wasmresume/state_test.go +++ b/internal/wasmresume/state_test.go @@ -485,7 +485,7 @@ func defineStateMachineHarness( return run } -func TestLowerPrototypeRejectsDynamicAlloca(t *testing.T) { +func TestLowerPrototypeSupportsDynamicAlloca(t *testing.T) { ctx := llvm.NewContext() defer ctx.Dispose() mod := ctx.NewModule("dynamic") @@ -508,9 +508,12 @@ func TestLowerPrototypeRejectsDynamicAlloca(t *testing.T) { markCall(ctx, call) builder.CreateRetVoid() - if _, err := lowerPrototype(mod, targetData); err == nil || - !strings.Contains(err.Error(), "dynamic alloca") { - t.Fatalf("lowerPrototype error = %v", err) + if _, err := lowerPrototype(mod, targetData); err != nil { + t.Fatal(err) + } + if ir := mod.String(); strings.Contains(ir, "%local = alloca") || + !strings.Contains(ir, "@__llgo_wasm_resume_alloc_dynamic") { + t.Fatalf("dynamic alloca was not lowered:\n%s", ir) } } diff --git a/internal/wasmresume/unwind.go b/internal/wasmresume/unwind.go new file mode 100644 index 0000000000..5fe33c0ed2 --- /dev/null +++ b/internal/wasmresume/unwind.go @@ -0,0 +1,66 @@ +/* + * 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 wasmresume + +import ( + "fmt" + + "github.com/xgo-dev/llvm" +) + +func lowerUnwindMarkers( + ctx llvm.Context, entry llvm.Value, plan framePlan, unwindField llvm.Value, +) error { + var markers []llvm.Value + for block := entry.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + for instr := block.FirstInstruction(); !instr.IsNil(); instr = llvm.NextInstruction(instr) { + if instr.InstructionOpcode() != llvm.Call { + continue + } + switch instr.CalledValue().Name() { + case RegisterUnwindSymbol, ClearUnwindSymbol: + markers = append(markers, instr) + } + } + } + if len(markers) == 0 { + if plan.unwindSlot != 0 { + return fmt.Errorf("unwind frame has no registration marker") + } + return nil + } + if plan.unwindSlot == 0 || unwindField.IsNil() { + return fmt.Errorf("unwind marker has no frame slot") + } + + builder := ctx.NewBuilder() + defer builder.Dispose() + unwindType := plan.slots[plan.unwindSlot-1].typ + for _, marker := range markers { + builder.SetInsertPointBefore(marker) + value := llvm.ConstNull(unwindType) + if marker.CalledValue().Name() == RegisterUnwindSymbol { + if marker.OperandsCount() < 3 { + return fmt.Errorf("invalid unwind registration marker") + } + value = marker.Operand(0) + } + builder.CreateStore(value, unwindField) + marker.EraseFromParentAsInstruction() + } + return nil +} diff --git a/internal/wasmresume/unwind_test.go b/internal/wasmresume/unwind_test.go new file mode 100644 index 0000000000..2441b1bc14 --- /dev/null +++ b/internal/wasmresume/unwind_test.go @@ -0,0 +1,245 @@ +package wasmresume + +import ( + "strings" + "testing" + + "github.com/xgo-dev/llvm" +) + +func TestLowerUnwindMarkersStoresAndClearsFrameSlot(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("unwind-markers") + defer mod.Dispose() + + ptr := llvm.PointerType(ctx.Int8Type(), 0) + entryType := llvm.FunctionType(ctx.Int8Type(), []llvm.Type{ptr, ptr}, false) + entry := llvm.AddFunction(mod, "resume", entryType) + block := ctx.AddBasicBlock(entry, "entry") + handler := ctx.AddBasicBlock(entry, "handler") + registerType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr, ptr}, false) + register := llvm.AddFunction(mod, RegisterUnwindSymbol, registerType) + clearType := llvm.FunctionType(ctx.VoidType(), nil, false) + clear := llvm.AddFunction(mod, ClearUnwindSymbol, clearType) + token := llvm.AddGlobal(mod, ctx.Int8Type(), "token") + + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + field := builder.CreateAlloca(ptr, "unwind.slot") + builder.CreateCall(registerType, register, []llvm.Value{ + token, + llvm.BlockAddress(entry, handler), + }, "") + builder.CreateCall(clearType, clear, nil, "") + builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionReturn, false)) + builder.SetInsertPointAtEnd(handler) + builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionReturn, false)) + + plan := framePlan{ + slots: []frameSlot{{id: 1, kind: slotUnwind, typ: ptr}}, + unwindSlot: 1, + } + if err := lowerUnwindMarkers(ctx, entry, plan, field); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyFunction(entry, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify lowered marker function: %v\n%s", err, mod.String()) + } + ir := mod.String() + if strings.Contains(ir, "call void @"+RegisterUnwindSymbol) || + strings.Contains(ir, "call void @"+ClearUnwindSymbol) { + t.Fatalf("unwind marker call remains:\n%s", ir) + } + for _, want := range []string{ + "store ptr @token, ptr %unwind.slot", + "store ptr null, ptr %unwind.slot", + } { + if !strings.Contains(ir, want) { + t.Fatalf("lowered unwind markers are missing %q:\n%s", want, ir) + } + } +} + +func TestLowerUnwindMarkersValidatesPlan(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + ptr := llvm.PointerType(ctx.Int8Type(), 0) + + t.Run("missing marker", func(t *testing.T) { + mod := ctx.NewModule("missing-marker") + defer mod.Dispose() + entry := llvm.AddFunction(mod, "resume", llvm.FunctionType(ctx.Int8Type(), nil, false)) + block := ctx.AddBasicBlock(entry, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + field := builder.CreateAlloca(ptr, "unwind.slot") + builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionReturn, false)) + + plan := framePlan{ + slots: []frameSlot{{id: 1, kind: slotUnwind, typ: ptr}}, + unwindSlot: 1, + } + if err := lowerUnwindMarkers(ctx, entry, plan, field); err == nil { + t.Fatal("lowerUnwindMarkers accepted a missing registration") + } + if err := lowerUnwindMarkers(ctx, entry, framePlan{}, llvm.Value{}); err != nil { + t.Fatalf("marker-free frame returned %v", err) + } + }) + + t.Run("missing slot", func(t *testing.T) { + mod := ctx.NewModule("missing-slot") + defer mod.Dispose() + registerType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr, ptr}, false) + register := llvm.AddFunction(mod, RegisterUnwindSymbol, registerType) + entry := llvm.AddFunction(mod, "resume", llvm.FunctionType(ctx.Int8Type(), nil, false)) + block := ctx.AddBasicBlock(entry, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + builder.CreateCall(registerType, register, []llvm.Value{ + llvm.ConstNull(ptr), + llvm.ConstNull(ptr), + }, "") + builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionReturn, false)) + if err := lowerUnwindMarkers(ctx, entry, framePlan{}, llvm.Value{}); err == nil { + t.Fatal("lowerUnwindMarkers accepted a marker without a slot") + } + }) + + t.Run("invalid register", func(t *testing.T) { + mod := ctx.NewModule("invalid-register") + defer mod.Dispose() + registerType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr}, false) + register := llvm.AddFunction(mod, RegisterUnwindSymbol, registerType) + entry := llvm.AddFunction(mod, "resume", llvm.FunctionType(ctx.Int8Type(), nil, false)) + block := ctx.AddBasicBlock(entry, "entry") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(block) + field := builder.CreateAlloca(ptr, "unwind.slot") + builder.CreateCall(registerType, register, []llvm.Value{llvm.ConstNull(ptr)}, "") + builder.CreateRet(llvm.ConstInt(ctx.Int8Type(), actionReturn, false)) + plan := framePlan{ + slots: []frameSlot{{id: 1, kind: slotUnwind, typ: ptr}}, + unwindSlot: 1, + } + if err := lowerUnwindMarkers(ctx, entry, plan, field); err == nil { + t.Fatal("lowerUnwindMarkers accepted an invalid registration") + } + }) +} + +func TestFindUnwindPlan(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + ptr := llvm.PointerType(ctx.Int8Type(), 0) + registerType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr, ptr}, false) + + newFunction := func(mod llvm.Module, handler llvm.Value) (llvm.Value, llvm.Value) { + register := llvm.AddFunction(mod, RegisterUnwindSymbol, registerType) + fn := llvm.AddFunction(mod, "f", llvm.FunctionType(ctx.VoidType(), nil, false)) + entry := ctx.AddBasicBlock(fn, "entry") + target := ctx.AddBasicBlock(fn, "target") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(entry) + call := builder.CreateCall(registerType, register, []llvm.Value{ + llvm.ConstNull(ptr), + handler, + }, "") + builder.CreateBr(target) + builder.SetInsertPointAtEnd(target) + builder.CreateRetVoid() + return fn, call + } + + mod := ctx.NewModule("valid-unwind") + fn := llvm.AddFunction(mod, "f", llvm.FunctionType(ctx.VoidType(), nil, false)) + entry := ctx.AddBasicBlock(fn, "entry") + target := ctx.AddBasicBlock(fn, "target") + register := llvm.AddFunction(mod, RegisterUnwindSymbol, registerType) + builder := ctx.NewBuilder() + builder.SetInsertPointAtEnd(entry) + builder.CreateCall(registerType, register, []llvm.Value{ + llvm.ConstNull(ptr), + llvm.BlockAddress(fn, target), + }, "") + builder.CreateBr(target) + builder.SetInsertPointAtEnd(target) + builder.CreateRetVoid() + builder.Dispose() + plan, err := findUnwindPlan(fn) + if err != nil || plan.block != target || plan.typ != ptr { + t.Fatalf("findUnwindPlan = %+v, %v", plan, err) + } + mod.Dispose() + + mod = ctx.NewModule("invalid-unwind") + fn, call := newFunction(mod, llvm.ConstNull(ptr)) + if _, err := findUnwindPlan(fn); err == nil { + t.Fatal("findUnwindPlan accepted a non-block handler") + } + call.EraseFromParentAsInstruction() + mod.Dispose() +} + +func TestUnwindOnlyFunctionUsesStateMachine(t *testing.T) { + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("unwind-only") + defer mod.Dispose() + mod.SetTarget("wasm32-unknown-unknown") + mod.SetDataLayout("e-m:e-p:32:32-i64:64-n32:64-S128") + targetData := llvm.NewTargetData(mod.DataLayout()) + defer targetData.Dispose() + + ptr := llvm.PointerType(ctx.Int8Type(), 0) + registerType := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ptr, ptr}, false) + register := llvm.AddFunction(mod, RegisterUnwindSymbol, registerType) + clearType := llvm.FunctionType(ctx.VoidType(), nil, false) + clear := llvm.AddFunction(mod, ClearUnwindSymbol, clearType) + fn := llvm.AddFunction(mod, "with.c.defer", llvm.FunctionType(ctx.VoidType(), nil, false)) + markFunction(ctx, fn) + entry := ctx.AddBasicBlock(fn, "entry") + handler := ctx.AddBasicBlock(fn, "handler") + done := ctx.AddBasicBlock(fn, "done") + builder := ctx.NewBuilder() + defer builder.Dispose() + builder.SetInsertPointAtEnd(entry) + builder.CreateCall(registerType, register, []llvm.Value{ + llvm.ConstNull(ptr), + llvm.BlockAddress(fn, handler), + }, "") + builder.CreateBr(done) + builder.SetInsertPointAtEnd(handler) + builder.CreateCall(clearType, clear, nil, "") + builder.CreateRetVoid() + builder.SetInsertPointAtEnd(done) + builder.CreateCall(clearType, clear, nil, "") + builder.CreateRetVoid() + + if err := Lower(mod, targetData); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify unwind-only state machine: %v\n%s", err, mod.String()) + } + ir := mod.String() + for _, want := range []string{ + "define internal i8 @__llgo_wasm_resume.with.c.defer", + "define void @with.c.defer()", + "i32 1, label %handler", + } { + if !strings.Contains(ir, want) { + t.Fatalf("unwind-only state machine is missing %q:\n%s", want, ir) + } + } + if strings.Contains(ir, "call void @"+RegisterUnwindSymbol) || + strings.Contains(ir, "call void @"+ClearUnwindSymbol) { + t.Fatalf("unwind marker remains in unwind-only state machine:\n%s", ir) + } +} diff --git a/runtime/internal/wasmresume/resume.go b/runtime/internal/wasmresume/resume.go index 1e60fa1d0e..a03d3a72fd 100644 --- a/runtime/internal/wasmresume/resume.go +++ b/runtime/internal/wasmresume/resume.go @@ -47,12 +47,24 @@ const ( //llgo:type C type Resume func(*Context, *Frame) Action +// Allocator allocates one GC-scanned root block. +// +//llgo:type C +type Allocator func(uintptr) unsafe.Pointer + +// Releaser releases one block previously returned by Allocator. +// +//llgo:type C +type Releaser func(unsafe.Pointer) + // Descriptor contains immutable state shared by every invocation of a // generated function. type Descriptor struct { - Resume Resume - FrameSize uintptr - FrameAlign uintptr + Resume Resume + FrameSize uintptr + FrameAlign uintptr + UnwindOffset uintptr + UnwindPC uint32 } // Frame is the common prefix of every generated function frame. Generated @@ -70,23 +82,64 @@ type Context struct { storage frameStorage } +// Start installs the root frame of a new logical goroutine. +func (c *Context) Start(frame *Frame) { + if frame == nil || frame.Parent != nil || frame.Descriptor == nil || c.top != nil { + panic("wasmresume: invalid root frame") + } + c.returned = nil + c.top = frame +} + // AllocateFrame allocates stable, root-scanned storage for a generated frame. func (c *Context) AllocateFrame( - size, align uintptr, allocate func(uintptr) unsafe.Pointer, + size, align uintptr, allocate Allocator, ) unsafe.Pointer { return c.storage.allocate(size, align, allocate) } // ReleaseFrame reclaims the most recently completed generated frame. -func (c *Context) ReleaseFrame(frame *Frame, release func(unsafe.Pointer)) { +func (c *Context) ReleaseFrame(frame *Frame, release Releaser) { if frame == nil || frame.Descriptor == nil { panic("wasmresume: invalid completed frame") } - c.storage.release(unsafe.Pointer(frame), frame.Descriptor.FrameSize, release) + c.storage.releaseFrame(unsafe.Pointer(frame), frame.Descriptor.FrameSize, release) +} + +// Unwind discards frames above the defer owner and redirects that owner to its +// generated panic/defer state. +func (c *Context) Unwind(deferFrame unsafe.Pointer, release Releaser) bool { + if deferFrame == nil { + return false + } + var owner *Frame + for frame := c.top; frame != nil; frame = frame.Parent { + descriptor := frame.Descriptor + if descriptor == nil || descriptor.UnwindOffset == 0 || + descriptor.UnwindPC == 0 { + continue + } + slot := (*unsafe.Pointer)(unsafe.Add(unsafe.Pointer(frame), descriptor.UnwindOffset)) + if *slot == deferFrame { + owner = frame + break + } + } + if owner == nil { + return false + } + for c.top != owner { + frame := c.top + c.top = frame.Parent + c.storage.releaseFrame(unsafe.Pointer(frame), frame.Descriptor.FrameSize, release) + } + c.returned = nil + owner.PC = owner.Descriptor.UnwindPC + return true } // Close releases every frame storage segment owned by the context. -func (c *Context) Close(release func(unsafe.Pointer)) { +func (c *Context) Close(release Releaser) { c.storage.close(release) c.top = nil c.returned = nil diff --git a/runtime/internal/wasmresume/resume_test.go b/runtime/internal/wasmresume/resume_test.go index 71a2553901..2463429abe 100644 --- a/runtime/internal/wasmresume/resume_test.go +++ b/runtime/internal/wasmresume/resume_test.go @@ -125,6 +125,31 @@ func TestSuspendCurrentRequiresCompilerLowering(t *testing.T) { SuspendCurrent() } +func TestContextStart(t *testing.T) { + descriptor := &Descriptor{} + root := &Frame{Descriptor: descriptor} + var context Context + context.Start(root) + if context.Top() != root { + t.Fatalf("Top() = %p, want %p", context.Top(), root) + } + for _, frame := range []*Frame{ + nil, + {Descriptor: descriptor}, + {Parent: root, Descriptor: descriptor}, + {}, + } { + func() { + defer func() { + if recover() == nil { + t.Fatalf("Start(%+v) did not panic", frame) + } + }() + context.Start(frame) + }() + } +} + func TestContextPushInitializesHeader(t *testing.T) { parent := Frame{} child := Frame{Parent: &parent, Descriptor: &testMulDescriptor, PC: 9} @@ -143,12 +168,15 @@ func TestContextPushInitializesHeader(t *testing.T) { func TestDescriptorCarriesFrameLayout(t *testing.T) { descriptor := Descriptor{ - Resume: resumeTestAdd, - FrameSize: unsafe.Sizeof(testLeafFrame{}), - FrameAlign: unsafe.Alignof(testLeafFrame{}), + Resume: resumeTestAdd, + FrameSize: unsafe.Sizeof(testLeafFrame{}), + FrameAlign: unsafe.Alignof(testLeafFrame{}), + UnwindOffset: unsafe.Sizeof(Frame{}), + UnwindPC: 3, } if descriptor.Resume == nil || descriptor.FrameSize != unsafe.Sizeof(testLeafFrame{}) || - descriptor.FrameAlign != unsafe.Alignof(testLeafFrame{}) { + descriptor.FrameAlign != unsafe.Alignof(testLeafFrame{}) || + descriptor.UnwindOffset != unsafe.Sizeof(Frame{}) || descriptor.UnwindPC != 3 { t.Fatalf("descriptor = %+v", descriptor) } } diff --git a/runtime/internal/wasmresume/storage.go b/runtime/internal/wasmresume/storage.go index 98b94dbb66..04db9c870c 100644 --- a/runtime/internal/wasmresume/storage.go +++ b/runtime/internal/wasmresume/storage.go @@ -31,7 +31,7 @@ type frameStorage struct { } func (s *frameStorage) allocate( - size, align uintptr, allocate func(uintptr) unsafe.Pointer, + size, align uintptr, allocate Allocator, ) unsafe.Pointer { if size == 0 || align == 0 || align&(align-1) != 0 || allocate == nil { return nil @@ -99,22 +99,37 @@ func allocateFromBlock(block *frameBlock, size, align uintptr) (unsafe.Pointer, return unsafe.Pointer(frame), true } -func (s *frameStorage) release( - frame unsafe.Pointer, size uintptr, release func(unsafe.Pointer), +func (s *frameStorage) releaseFrame( + frame unsafe.Pointer, size uintptr, release Releaser, ) { - block := s.current - if block == nil || frame == nil || size == 0 { + if s.current == nil || frame == nil || size == 0 { panic("wasmresume: invalid frame release") } address := uintptr(frame) end, ok := addUintptr(address, size) - if !ok || address < block.begin || end != block.stackPointer { - panic("wasmresume: frames must be released in LIFO order") + if !ok { + panic("wasmresume: invalid frame release") + } + + block := s.current + for block != nil && (address < block.begin || end > block.stackPointer) { + block = block.prev + } + if block == nil { + panic("wasmresume: frame is not owned by this context") } previous := *(*uintptr)(unsafe.Pointer(address - unsafe.Sizeof(uintptr(0)))) if previous < block.begin || previous >= address { panic("wasmresume: invalid frame allocation header") } + if block != s.current && release == nil { + panic("wasmresume: missing frame block reclaimer") + } + for s.current != block { + current := s.current + s.current = current.prev + release(unsafe.Pointer(current)) + } block.stackPointer = previous if previous == block.begin && block.prev != nil { if release == nil { @@ -125,7 +140,7 @@ func (s *frameStorage) release( } } -func (s *frameStorage) close(release func(unsafe.Pointer)) { +func (s *frameStorage) close(release Releaser) { if s.current != nil && release == nil { panic("wasmresume: missing frame block reclaimer") } diff --git a/runtime/internal/wasmresume/storage_test.go b/runtime/internal/wasmresume/storage_test.go index 84862dfaff..76cfd3a00d 100644 --- a/runtime/internal/wasmresume/storage_test.go +++ b/runtime/internal/wasmresume/storage_test.go @@ -11,6 +11,11 @@ type testFrameRoots struct { frees int } +type testUnwindFrame struct { + Frame + deferFrame unsafe.Pointer +} + func (r *testFrameRoots) allocate(size uintptr) unsafe.Pointer { block := make([]byte, size) if len(block) == 0 { @@ -50,13 +55,13 @@ func TestFrameStorageAlignsAndReusesFrames(t *testing.T) { t.Fatalf("root block allocations = %d, want 1", roots.allocs) } - storage.release(second, 64, roots.release) + storage.releaseFrame(second, 64, roots.release) reused := storage.allocate(64, 64, roots.allocate) if reused != second { t.Fatalf("frame was not reused: got %p, want %p", reused, second) } - storage.release(reused, 64, roots.release) - storage.release(first, 31, roots.release) + storage.releaseFrame(reused, 64, roots.release) + storage.releaseFrame(first, 31, roots.release) storage.close(roots.release) if roots.frees != 1 || len(roots.blocks) != 0 { t.Fatalf("released roots = %d, remaining = %d", roots.frees, len(roots.blocks)) @@ -73,11 +78,11 @@ func TestFrameStorageAddsAndReleasesSegments(t *testing.T) { if first == nil || second == nil || roots.allocs != 2 { t.Fatalf("allocations = %d, first=%p second=%p", roots.allocs, first, second) } - storage.release(second, 128, roots.release) + storage.releaseFrame(second, 128, roots.release) if roots.frees != 1 { t.Fatalf("released child segments = %d, want 1", roots.frees) } - storage.release(first, defaultFrameBlockSize, roots.release) + storage.releaseFrame(first, defaultFrameBlockSize, roots.release) storage.close(roots.release) if roots.frees != 2 || len(roots.blocks) != 0 { t.Fatalf("released roots = %d, remaining = %d", roots.frees, len(roots.blocks)) @@ -103,6 +108,91 @@ func TestContextOwnsGeneratedFrameStorage(t *testing.T) { } } +func TestContextReleaseFrameDiscardsDynamicStorage(t *testing.T) { + var ( + ctx Context + roots testFrameRoots + ) + const frameSize = uintptr(32) + raw := ctx.AllocateFrame(frameSize, 8, roots.allocate) + frame := (*Frame)(raw) + frame.Descriptor = &Descriptor{FrameSize: frameSize} + if ctx.AllocateFrame(64, 16, roots.allocate) == nil || + ctx.AllocateFrame(defaultFrameBlockSize, 16, roots.allocate) == nil { + t.Fatal("dynamic frame storage allocation failed") + } + + ctx.ReleaseFrame(frame, roots.release) + reused := ctx.AllocateFrame(frameSize, 8, roots.allocate) + if reused != raw { + t.Fatalf("frame storage was not rewound: got %p, want %p", reused, raw) + } + ctx.Close(roots.release) + if roots.allocs != roots.frees || len(roots.blocks) != 0 { + t.Fatalf("root lifecycle = %d allocs, %d frees, %d remaining", + roots.allocs, roots.frees, len(roots.blocks)) + } +} + +func TestContextUnwindReclaimsChildrenAndRedirectsOwner(t *testing.T) { + var ( + ctx Context + roots testFrameRoots + token byte + ) + size := unsafe.Sizeof(testUnwindFrame{}) + align := unsafe.Alignof(testUnwindFrame{}) + owner := (*testUnwindFrame)(ctx.AllocateFrame(size, align, roots.allocate)) + child := (*testUnwindFrame)(ctx.AllocateFrame(size, align, roots.allocate)) + ownerDescriptor := &Descriptor{ + FrameSize: size, + UnwindOffset: unsafe.Offsetof(testUnwindFrame{}.deferFrame), + UnwindPC: 7, + } + childDescriptor := &Descriptor{FrameSize: size} + owner.Descriptor = ownerDescriptor + owner.deferFrame = unsafe.Pointer(&token) + child.Parent = &owner.Frame + child.Descriptor = childDescriptor + ctx.top = &child.Frame + + if !ctx.Unwind(unsafe.Pointer(&token), roots.release) { + t.Fatal("Context.Unwind did not find the defer owner") + } + if ctx.top != &owner.Frame || owner.PC != ownerDescriptor.UnwindPC { + t.Fatalf("unwind result: top=%p PC=%d", ctx.top, owner.PC) + } + reused := ctx.AllocateFrame(size, align, roots.allocate) + if reused != unsafe.Pointer(child) { + t.Fatalf("discarded child storage was not reused: got %p, want %p", reused, child) + } + ctx.Close(roots.release) +} + +func TestContextUnwindRejectsMissingOwner(t *testing.T) { + var ctx Context + if ctx.Unwind(nil, nil) || ctx.Unwind(unsafe.Pointer(new(byte)), nil) { + t.Fatal("Context.Unwind accepted a missing defer owner") + } +} + +func TestContextUnwindIgnoresIncompleteDescriptor(t *testing.T) { + var ( + ctx Context + token byte + frame testUnwindFrame + ) + frame.deferFrame = unsafe.Pointer(&token) + frame.Descriptor = &Descriptor{ + FrameSize: unsafe.Sizeof(frame), + UnwindOffset: unsafe.Offsetof(frame.deferFrame), + } + ctx.top = &frame.Frame + if ctx.Unwind(unsafe.Pointer(&token), nil) { + t.Fatal("Context.Unwind accepted a descriptor without an unwind PC") + } +} + func TestContextKeepsGeneratedABIPrefix(t *testing.T) { if got, want := unsafe.Offsetof(Context{}.storage), 2*unsafe.Sizeof(uintptr(0)); got != want { t.Fatalf("Context storage offset = %d, want %d", got, want) @@ -124,15 +214,7 @@ func TestFrameStorageRejectsInvalidOperations(t *testing.T) { t.Fatal("failed root allocation returned a frame") } - first := storage.allocate(8, 8, roots.allocate) - storage.allocate(8, 8, roots.allocate) - defer func() { - if recover() == nil { - t.Fatal("out-of-order frame release did not panic") - } - storage.close(roots.release) - }() - storage.release(first, 8, roots.release) + storage.close(roots.release) } func TestFrameStorageRejectsInvalidReleaseState(t *testing.T) { @@ -150,7 +232,7 @@ func TestFrameStorageRejectsInvalidReleaseState(t *testing.T) { assertPanic("empty", func() { var storage frameStorage - storage.release(unsafe.Pointer(new(byte)), 1, nil) + storage.releaseFrame(unsafe.Pointer(new(byte)), 1, nil) }) var ( @@ -159,15 +241,18 @@ func TestFrameStorageRejectsInvalidReleaseState(t *testing.T) { ) frame := storage.allocate(8, 8, roots.allocate) assertPanic("nil frame", func() { - storage.release(nil, 8, roots.release) + storage.releaseFrame(nil, 8, roots.release) }) assertPanic("zero size", func() { - storage.release(frame, 0, roots.release) + storage.releaseFrame(frame, 0, roots.release) + }) + assertPanic("foreign frame", func() { + storage.releaseFrame(unsafe.Pointer(new(byte)), 1, roots.release) }) assertPanic("invalid header", func() { header := unsafe.Pointer(uintptr(frame) - unsafe.Sizeof(uintptr(0))) *(*uintptr)(header) = 0 - storage.release(frame, 8, roots.release) + storage.releaseFrame(frame, 8, roots.release) }) storage.close(roots.release) @@ -195,11 +280,11 @@ func BenchmarkFrameStorageHotAllocateRelease(b *testing.B) { roots testFrameRoots ) frame := storage.allocate(64, 16, roots.allocate) - storage.release(frame, 64, roots.release) + storage.releaseFrame(frame, 64, roots.release) b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { frame = storage.allocate(64, 16, roots.allocate) - storage.release(frame, 64, roots.release) + storage.releaseFrame(frame, 64, roots.release) } } From fdc5dcb905dd088da0c642bbad17975f8996c953 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 23:42:13 +0800 Subject: [PATCH 37/40] ssa/wasm: emit resumable goroutine and defer state --- ssa/closure_wrap.go | 9 ++- ssa/eh.go | 22 +++++-- ssa/expr.go | 9 ++- ssa/goroutine.go | 26 ++++++++- ssa/package.go | 2 +- ssa/wasm_resume.go | 43 ++++++++++++-- ssa/wasm_resume_test.go | 124 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 216 insertions(+), 19 deletions(-) diff --git a/ssa/closure_wrap.go b/ssa/closure_wrap.go index f2a245f589..de6246a578 100644 --- a/ssa/closure_wrap.go +++ b/ssa/closure_wrap.go @@ -70,14 +70,21 @@ func closureWrapReturn(b Builder, sig *types.Signature, ret Expr) { // closureWrapDecl wraps a function declaration that lacks __llgo_ctx. // It directly calls the target symbol and ignores the ctx parameter. func (p Package) closureWrapDecl(fn Expr, sig *types.Signature) Function { + return p.closureWrapDeclFor(fn, sig, p.Prog.WasmResumeABIEnabled()) +} + +func (p Package) closureWrapDeclFor(fn Expr, sig *types.Signature, resumable bool) Function { name := closureStub + fn.impl.Name() + if p.Prog.WasmResumeABIEnabled() && !resumable { + name = closureStub + "sync." + fn.impl.Name() + } if wrap := p.FuncOf(name); wrap != nil { return wrap } ctx := types.NewParam(token.NoPos, nil, closureCtx, types.Typ[types.UnsafePointer]) sigCtx := FuncAddCtx(ctx, sig) background := InC - if p.Prog.WasmResumeABIEnabled() { + if resumable { background = InGo } wrap := p.NewFunc(name, sigCtx, background) diff --git a/ssa/eh.go b/ssa/eh.go index 989371c75c..62965faf91 100644 --- a/ssa/eh.go +++ b/ssa/eh.go @@ -202,14 +202,14 @@ func (b Builder) getDefer(kind DoAction) *aDefer { blks := self.MakeBlocks(2) procBlk, rethrowBlk := blks[0], blks[1] - deferState, link, retval := b.initDeferState(procBlk, rethrowBlk) - czero := b.Prog.IntVal(0, b.Prog.CInt()) if kind != DeferAlways { panicBlk = self.MakeBlock() } else { blks = self.MakeBlocks(2) next, panicBlk = blks[0], blks[1] } + deferState, link, retval := b.initDeferState(procBlk, rethrowBlk, panicBlk) + czero := b.Prog.IntVal(0, b.Prog.CInt()) b.If(b.BinOp(token.EQL, retval, czero), next, panicBlk) deferState.panicBlk = panicBlk @@ -244,7 +244,7 @@ func (b Builder) getDeferInCurrentBlock() *aDefer { logicalBlk := b.blk blks := self.MakeBlocks(4) procBlk, rethrowBlk, next, panicBlk := blks[0], blks[1], blks[2], blks[3] - deferState, link, retval := b.initDeferState(procBlk, rethrowBlk) + deferState, link, retval := b.initDeferState(procBlk, rethrowBlk, panicBlk) czero := b.Prog.IntVal(0, b.Prog.CInt()) b.If(b.BinOp(token.EQL, retval, czero), next, panicBlk) deferState.panicBlk = panicBlk @@ -261,15 +261,21 @@ func (b Builder) getDeferInCurrentBlock() *aDefer { return self.defer_ } -func (b Builder) initDeferState(procBlk, rethrowBlk BasicBlock) (*aDefer, Expr, Expr) { +func (b Builder) initDeferState( + procBlk, rethrowBlk, panicBlk BasicBlock, +) (*aDefer, Expr, Expr) { self := b.Func prog := b.Prog zero := prog.Val(uintptr(0)) link := b.Call(b.Pkg.rtFunc("GetThreadDefer")) - jb := b.AllocaSigjmpBuf() + jb := prog.Nil(prog.VoidPtr()) + if !b.wasmResumeFunctionEnabled() { + jb = b.AllocaSigjmpBuf() + } ptr := b.aggregateAllocU(prog.Defer(), jb.impl, zero.impl, link.impl, procBlk.Addr().impl) deferData := Expr{ptr, prog.DeferPtr()} b.Call(b.Pkg.rtFunc("SetThreadDefer"), deferData) + b.registerWasmResumeUnwind(deferData, panicBlk) bitsPtr := b.FieldAddr(deferData, deferBits) rethPtr := b.FieldAddr(deferData, deferRethrow) rundPtr := b.FieldAddr(deferData, deferRunDefers) @@ -279,7 +285,10 @@ func (b Builder) initDeferState(procBlk, rethrowBlk BasicBlock) (*aDefer, Expr, b.Store(argsPtr, prog.Nil(prog.VoidPtr())) czero := prog.IntVal(0, prog.CInt()) - retval := b.Sigsetjmp(jb, czero) + retval := czero + if !b.wasmResumeFunctionEnabled() { + retval = b.Sigsetjmp(jb, czero) + } self.defer_ = &aDefer{ data: deferData, @@ -619,6 +628,7 @@ func (p Function) endDefer(b Builder) { } link := b.getField(b.Load(self.data), deferLink) b.Call(b.Pkg.rtFunc("SetThreadDefer"), link) + b.clearWasmResumeUnwind() b.jumpRunDefersTarget(rundPtr, nexts) b.SetBlockEx(panicBlk, AtEnd, false) // panicBlk: exec runDefers and rethrow diff --git a/ssa/expr.go b/ssa/expr.go index dc972c3482..c906346469 100644 --- a/ssa/expr.go +++ b/ssa/expr.go @@ -1203,10 +1203,13 @@ func (b Builder) MakeClosure(fn Expr, bindings []Expr) Expr { data = ptr } code := fn.impl + resumable := b.wasmResumeFunctionEnabled() if prog.WasmResumeABIEnabled() && closureCtxParam(sig) == nil { - code = b.Pkg.closureWrapDecl(fn, sig).impl + code = b.Pkg.closureWrapDeclFor(fn, sig, resumable).impl + } + if resumable { + code = b.Pkg.wasmResumeStart(code) } - code = b.Pkg.wasmResumeStart(code) return b.aggregateValue(prog.Closure(removeCtx(sig)), code, data) } @@ -1751,7 +1754,7 @@ func checkExpr(v Expr, t types.Type, b Builder) Expr { v, data = b.Pkg.closureStub(b, v, sig, origKind) } } - if origKind == vkFuncDecl { + if origKind == vkFuncDecl && b.wasmResumeFunctionEnabled() { v.impl = b.Pkg.wasmResumeStart(v.impl) } return b.aggregateValue(tclosure, v.impl, data.impl) diff --git a/ssa/goroutine.go b/ssa/goroutine.go index 07d9c82eee..98c198db2f 100644 --- a/ssa/goroutine.go +++ b/ssa/goroutine.go @@ -54,11 +54,17 @@ func (b Builder) Go(fn Expr, buildCall func(Builder, Expr, ...Expr) Expr, args . if fn != Nil && fn.kind != vkBuiltin { offset = 1 } + resumableDirectCall := offset == 1 && + b.wasmResumeFunctionEnabled() && + b.directCallBackground(fn) == InGo typs := make([]Type, len(args)+offset) flds := make([]llvm.Value, len(args)+offset) if offset == 1 { typs[0] = fn.Type flds[0] = fn.impl + if resumableDirectCall { + flds[0] = pkg.wasmResumeStart(flds[0]) + } } for i, arg := range args { typs[i+offset] = arg.Type @@ -73,7 +79,12 @@ func (b Builder) Go(fn Expr, buildCall func(Builder, Expr, ...Expr) Expr, args . aggregateInit(b.impl, dataPtr, t.ll, flds...) data := Expr{dataPtr, voidPtr} stackSize := prog.IntVal(prog.pthreadStackSize, prog.Uintptr()) - b.Call(pkg.rtFunc("NewProc"), pkg.routine(t, fn, buildCall, len(args)), data, stackSize) + b.Call( + pkg.rtFunc("NewProc"), + pkg.routine(t, fn, buildCall, len(args), resumableDirectCall), + data, + stackSize, + ) } func (p Package) routineName() string { @@ -81,7 +92,13 @@ func (p Package) routineName() string { return p.Path() + "._llgo_routine$" + strconv.Itoa(p.iRoutine) } -func (p Package) routine(t Type, fn Expr, buildCall func(Builder, Expr, ...Expr) Expr, n int) Expr { +func (p Package) routine( + t Type, + fn Expr, + buildCall func(Builder, Expr, ...Expr) Expr, + n int, + resumableDirectCall bool, +) Expr { prog := p.Prog background := InC if prog.WasmResumeABIEnabled() { @@ -106,7 +123,10 @@ func (p Package) routine(t Type, fn Expr, buildCall func(Builder, Expr, ...Expr) args[i] = b.getField(data, i+offset) } b.Call(p.rtFunc("FreeRoot"), param) - buildCall(b, fn, args...) + call := buildCall(b, fn, args...) + if resumableDirectCall && !call.impl.IsNil() { + b.markWasmResumeCall(call.impl, InGo) + } lastInst := b.impl.GetInsertBlock().LastInstruction() if lastInst.IsNil() || lastInst.IsAUnreachableInst().IsNil() { if hasLocalContext { diff --git a/ssa/package.go b/ssa/package.go index a0344ca77f..b968835fc9 100644 --- a/ssa/package.go +++ b/ssa/package.go @@ -925,7 +925,7 @@ func (p Package) closureStub(b Builder, fn Expr, sig *types.Signature, origKind prog := b.Prog switch origKind { case vkFuncDecl: - wrap := p.closureWrapDecl(fn, sig) + wrap := p.closureWrapDeclFor(fn, sig, b.wasmResumeFunctionEnabled()) return wrap.Expr, prog.Nil(prog.VoidPtr()) case vkFuncPtr: wrap := p.closureWrapPtr(sig) diff --git a/ssa/wasm_resume.go b/ssa/wasm_resume.go index d19110754c..52b5d84eae 100644 --- a/ssa/wasm_resume.go +++ b/ssa/wasm_resume.go @@ -59,10 +59,7 @@ func (p Package) wasmResumeStart(fn llvm.Value) llvm.Value { } func (b Builder) markWasmResumeCall(call llvm.Value, background Background) { - if background != InGo || !b.Prog.WasmResumeABIEnabled() || - b.Func == nil || b.Func.background != InGo || - wasmresume.IsRuntimeABIImplementation(b.Func.Name()) || - wasmresume.IsNonSuspendingBoundary(b.Func.Name()) { + if background != InGo || !b.wasmResumeFunctionEnabled() { return } callee := call.CalledValue() @@ -75,8 +72,44 @@ func (b Builder) markWasmResumeCall(call llvm.Value, background Background) { call.SetMetadata(kind, b.Prog.ctx.MDNode([]llvm.Metadata{version})) } +func (b Builder) wasmResumeFunctionEnabled() bool { + return b != nil && b.Prog.WasmResumeABIEnabled() && + b.Func != nil && b.Func.background == InGo && + !wasmresume.IsRuntimeABIImplementation(b.Func.Name()) && + !wasmresume.IsNonSuspendingBoundary(b.Func.Name()) +} + +func (b Builder) registerWasmResumeUnwind(frame Expr, handler BasicBlock) { + if !b.wasmResumeFunctionEnabled() { + return + } + ctx := b.Prog.ctx + typ := llvm.FunctionType(ctx.VoidType(), []llvm.Type{ + b.Prog.tyVoidPtr(), + b.Prog.tyVoidPtr(), + }, false) + fn := b.Pkg.mod.NamedFunction(wasmresume.RegisterUnwindSymbol) + if fn.IsNil() { + fn = llvm.AddFunction(b.Pkg.mod, wasmresume.RegisterUnwindSymbol, typ) + } + llvm.CreateCall(b.impl, typ, fn, []llvm.Value{frame.impl, handler.Addr().impl}) +} + +func (b Builder) clearWasmResumeUnwind() { + if !b.wasmResumeFunctionEnabled() { + return + } + ctx := b.Prog.ctx + typ := llvm.FunctionType(ctx.VoidType(), nil, false) + fn := b.Pkg.mod.NamedFunction(wasmresume.ClearUnwindSymbol) + if fn.IsNil() { + fn = llvm.AddFunction(b.Pkg.mod, wasmresume.ClearUnwindSymbol, typ) + } + llvm.CreateCall(b.impl, typ, fn, nil) +} + func (b Builder) directCallBackground(fn Expr) Background { - if fn.kind != vkFuncDecl { + if fn.impl.IsNil() || fn.impl.IsAFunction().IsNil() { return inUnknown } if decl := b.Pkg.FuncOf(fn.impl.Name()); decl != nil { diff --git a/ssa/wasm_resume_test.go b/ssa/wasm_resume_test.go index 3bb0bea5ff..58d163b002 100644 --- a/ssa/wasm_resume_test.go +++ b/ssa/wasm_resume_test.go @@ -22,6 +22,7 @@ import ( "go/importer" "go/token" "go/types" + "regexp" "strings" "testing" @@ -226,6 +227,124 @@ func TestWasmResumeABILowersSuspendCurrent(t *testing.T) { } } +func TestWasmResumeABILowersDeferUnwindState(t *testing.T) { + prog := NewProgram(&Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + prog.TypeSizes(types.SizesFor("gc", "wasm")) + prog.SetRuntime(func() *types.Package { + pkg, err := importer.For("source", nil).Import(PkgRuntime) + if err != nil { + t.Fatal(err) + } + return pkg + }) + + pkg := prog.NewPackage("p", "example.com/p") + deferred := pkg.NewFunc("deferred", NoArgsNoRet, InGo) + deferred.MakeBody(1).Return() + suspend := pkg.NewFunc(wasmresume.SuspendSymbol, NoArgsNoRet, InGo) + fn := pkg.NewFunc("withDefer", NoArgsNoRet, InGo) + b := fn.MakeBody(1) + recoverBlock := fn.MakeBlock() + fn.SetRecover(recoverBlock) + b.SetBlockEx(recoverBlock, AtEnd, true) + b.Return() + b.SetBlockEx(fn.Block(0), AtEnd, true) + b.Defer(DeferAlways, deferred.Expr, Builder.Call) + b.Call(suspend.Expr) + b.RunDefers() + b.Return() + b.EndBuild() + + before := pkg.String() + if strings.Contains(before, "setjmp") { + t.Fatalf("resumable defer allocated a native jump buffer:\n%s", before) + } + for _, marker := range []string{ + wasmresume.RegisterUnwindSymbol, + wasmresume.ClearUnwindSymbol, + } { + if !strings.Contains(before, marker) { + t.Fatalf("resumable defer is missing %s:\n%s", marker, before) + } + } + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify defer module before lowering: %v\n%s", err, before) + } + + if err := wasmresume.Lower(pkg.Module(), prog.TargetData()); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify lowered defer module: %v\n%s", err, pkg.String()) + } + ir := pkg.String() + for _, marker := range []string{ + wasmresume.RegisterUnwindSymbol, + wasmresume.ClearUnwindSymbol, + } { + if strings.Contains(ir, "call void @"+marker) { + t.Fatalf("unwind marker %s remains after lowering:\n%s", marker, ir) + } + } + descriptor := regexp.MustCompile( + `@__llgo_wasm_resume_desc\.withDefer = constant \{ ptr, i32, i32, i32, i32 \} ` + + `\{ ptr @__llgo_wasm_resume\.withDefer, i32 [1-9][0-9]*, i32 [1-9][0-9]*, ` + + `i32 [1-9][0-9]*, i32 [1-9][0-9]* \}`, + ) + if !descriptor.MatchString(ir) { + t.Fatalf("resumable defer descriptor has no unwind state:\n%s", ir) + } +} + +func TestWasmResumeABIGoroutineUsesResumableTarget(t *testing.T) { + prog := NewProgram(&Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + prog.TypeSizes(types.SizesFor("gc", "wasm")) + prog.SetRuntime(func() *types.Package { + pkg, err := importer.For("source", nil).Import(PkgRuntime) + if err != nil { + t.Fatal(err) + } + return pkg + }) + + pkg := prog.NewPackage("p", "example.com/p") + worker := pkg.NewFunc("worker", NoArgsNoRet, InGo) + worker.MakeBody(1).Return() + caller := pkg.NewFunc("caller", NoArgsNoRet, InGo) + b := caller.MakeBody(1) + workerPointer := worker.Expr + workerPointer.kind = vkFuncPtr + b.Go(workerPointer, func(b Builder, fn Expr, args ...Expr) Expr { + return b.Call(fn, args...) + }) + b.Return() + + ir := pkg.String() + if !strings.Contains(ir, "store ptr @"+wasmresume.StartSymbol("worker")) { + t.Fatalf("goroutine startup record does not contain the worker start entry:\n%s", ir) + } + routine := pkg.FuncOf("example.com/p._llgo_routine$1") + if routine == nil { + t.Fatalf("goroutine wrapper is missing:\n%s", ir) + } + kind := prog.ctx.MDKindID(wasmresume.CallMetadata) + var marked bool + for block := routine.impl.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + for instruction := block.FirstInstruction(); !instruction.IsNil(); instruction = llvm.NextInstruction(instruction) { + if instruction.HasMetadata() && !instruction.Metadata(kind).IsNil() { + marked = true + } + } + } + if !marked { + t.Fatalf("goroutine wrapper call is not resumable:\n%s", ir) + } +} + func TestWasmResumeABIKeepsRuntimeBoundariesSynchronous(t *testing.T) { prog := NewProgram(&Target{GOOS: "wasip1", GOARCH: "wasm"}) defer prog.Dispose() @@ -242,6 +361,7 @@ func TestWasmResumeABIKeepsRuntimeBoundariesSynchronous(t *testing.T) { ) b := boundary.MakeBody(1) b.Call(ordinary.Expr) + b.Call(b.MakeClosure(ordinary.Expr, nil)) b.Return() root := pkg.NewFunc("root", NoArgsNoRet, InC) @@ -270,6 +390,10 @@ func TestWasmResumeABIKeepsRuntimeBoundariesSynchronous(t *testing.T) { if !callerMarked { t.Fatalf("ordinary Go caller was not marked resumable:\n%s", pkg.String()) } + if ir := pkg.String(); !strings.Contains(ir, "@"+closureStub+"sync.ordinary") || + strings.Contains(ir, "@"+wasmresume.StartSymbol(closureStub+"sync.ordinary")) { + t.Fatalf("runtime boundary function value does not use its synchronous wrapper:\n%s", ir) + } kind := prog.ctx.MDKindID(wasmresume.CallMetadata) for _, function := range []Function{boundary, root, caller} { for block := function.impl.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { From 30317bc54d84c0b746b7d19e2a1d09698227dde1 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 23:42:21 +0800 Subject: [PATCH 38/40] runtime/wasm: schedule resumable goroutines --- internal/build/build.go | 15 + internal/build/collect.go | 1 + internal/build/main_module.go | 23 +- internal/build/main_module_test.go | 34 +++ internal/build/wasm_resume.go | 61 ++++ internal/build/wasm_resume_test.go | 134 +++++++++ .../runtime/goroutine_func_default.go | 11 + .../runtime/goroutine_func_wasm_resume.go | 14 + runtime/internal/runtime/proc.go | 6 - runtime/internal/runtime/proc_wasip1.go | 2 +- runtime/internal/runtime/proc_wasm.go | 2 +- runtime/internal/runtime/proc_wasm_resume.go | 267 ++++++++++++++++++ runtime/internal/runtime/z_default.go | 2 +- runtime/internal/runtime/z_wasm_resume.go | 39 +++ 14 files changed, 595 insertions(+), 16 deletions(-) create mode 100644 internal/build/wasm_resume.go create mode 100644 internal/build/wasm_resume_test.go create mode 100644 runtime/internal/runtime/goroutine_func_default.go create mode 100644 runtime/internal/runtime/goroutine_func_wasm_resume.go create mode 100644 runtime/internal/runtime/proc_wasm_resume.go create mode 100644 runtime/internal/runtime/z_wasm_resume.go diff --git a/internal/build/build.go b/internal/build/build.go index 2c66f635b8..8a01066055 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -336,6 +336,9 @@ func Do(args []string, conf *Config) ([]Package, error) { if conf.Target != "" && export.GOARCH != "" { conf.Goarch = export.GOARCH } + if err := configureWasmResume(conf, &export); err != nil { + return nil, err + } if conf.AppExt == "" { conf.AppExt = defaultAppExt(conf) } @@ -393,6 +396,7 @@ func Do(args []string, conf *Config) ([]Package, error) { } prog := llssa.NewProgram(target) + prog.EnableWasmResumeABI(IsWasmResumeEnabled()) prog.DisableBoundsChecks(conf.DisableBoundsChecks) if conf.Mode != ModeGen { // ModeGen callers (llgen and the golden suites) read LPkg.String() @@ -1330,6 +1334,9 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, outputPa pcLineInfo: pcLineInfo, funcInfoStubs: funcInfoStubs, }) + if err := lowerWasmResumeModule(ctx, entryPkg.LPkg.Module()); err != nil { + return fmt.Errorf("entry main: %w", err) + } entryObjFile, err := exportObject(ctx, "entry_main", entryPkg.ExportFile, entryPkg.LPkg) if err != nil { return err @@ -1728,6 +1735,9 @@ func buildPkg(ctx *context, aPkg *aPackage, verbose bool) error { return nil } + if err := lowerWasmResumeModule(ctx, ret.Module()); err != nil { + return fmt.Errorf("%s: %w", pkgPath, err) + } ctx.cTransformer.SetSkipFuncs(cabiSkipFuncsForPlan9Asm(ctx, pkgPath, ret.Module())) llabi.LowerLargeAggregates(ctx.prog.TargetData(), ret.Module()) ctx.cTransformer.TransformModule(ret.Path(), ret.Module()) @@ -2288,6 +2298,7 @@ const llgoFuncInfoSites = "LLGO_FUNCINFO_SITES" const llgoTrace = "LLGO_TRACE" const llgoOptimize = "LLGO_OPTIMIZE" const llgoWasmRuntime = "LLGO_WASM_RUNTIME" +const llgoWasmResume = "LLGO_WASM_RESUME" const llgoWasiThreads = "LLGO_WASI_THREADS" const llgoStdioNobuf = "LLGO_STDIO_NOBUF" const llgoFullRpath = "LLGO_FULL_RPATH" @@ -2369,6 +2380,10 @@ func IsWasiThreadsEnabled() bool { return isEnvOn(llgoWasiThreads, false) } +func IsWasmResumeEnabled() bool { + return isEnvOn(llgoWasmResume, false) +} + func IsFullRpathEnabled() bool { return isEnvOn(llgoFullRpath, true) } diff --git a/internal/build/collect.go b/internal/build/collect.go index 11735574da..1d07802876 100644 --- a/internal/build/collect.go +++ b/internal/build/collect.go @@ -85,6 +85,7 @@ func (c *context) collectEnvInputs(m *manifestBuilder) { llgoTrace, llgoOptimize, llgoWasmRuntime, + llgoWasmResume, llgoWasiThreads, llgoStdioNobuf, llgoFullRpath, diff --git a/internal/build/main_module.go b/internal/build/main_module.go index 4b6a87e150..94ded5307e 100644 --- a/internal/build/main_module.go +++ b/internal/build/main_module.go @@ -87,8 +87,9 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g pyFinalize = declareNoArgFunc(mainPkg, "Py_Finalize") } + wasmScheduler := ctx.crossCompile.WasmPostLink.Asyncify || ctx.prog.WasmResumeABIEnabled() var rtInit llssa.Function - if cfg.rtInit || ctx.crossCompile.WasmPostLink.Asyncify { + if cfg.rtInit || wasmScheduler { rtInit = declareNoArgFunc(mainPkg, rtPkgPath+".init") } @@ -109,8 +110,12 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g pkgPath = pkg.PkgPath } - mainInit := declareNoArgFunc(mainPkg, pkgPath+".init") - mainMain := declareNoArgFunc(mainPkg, pkgPath+".main") + mainBackground := llssa.InC + if ctx.prog.WasmResumeABIEnabled() { + mainBackground = llssa.InGo + } + mainInit := mainPkg.NewFunc(pkgPath+".init", llssa.NoArgsNoRet, mainBackground) + mainMain := mainPkg.NewFunc(pkgPath+".main", llssa.NoArgsNoRet, mainBackground) if ctx.buildConf.BuildMode != BuildModeExe { initArraySection := "" @@ -128,8 +133,8 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g } var wasmRunMain llssa.Function - if ctx.crossCompile.WasmPostLink.Asyncify { - defineWasmMainTask(mainPkg, mainInit, mainMain) + if wasmScheduler { + defineWasmMainTask(mainPkg, mainInit, mainMain, ctx.prog.WasmResumeABIEnabled()) wasmRunMain = declareNoArgFunc(mainPkg, rtPkgPath+".RunWasmMain") } entryFn := defineEntryFunction(ctx, mainPkg, argcVar, argvVar, argvValueType, entryFunctions{ @@ -295,13 +300,17 @@ func defineEntryFunction(ctx *context, pkg llssa.Package, argcVar, argvVar llssa return fn } -func defineWasmMainTask(pkg llssa.Package, mainInit, mainMain llssa.Function) { +func defineWasmMainTask(pkg llssa.Package, mainInit, mainMain llssa.Function, resumable bool) { 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) + background := llssa.InC + if resumable { + background = llssa.InGo + } + fn := pkg.NewFunc("__llgo_wasm_main", sig, background) fnVal := pkg.Module().NamedFunction("__llgo_wasm_main") fnVal.SetVisibility(llvm.HiddenVisibility) b := fn.MakeBody(1) diff --git a/internal/build/main_module_test.go b/internal/build/main_module_test.go index b577b12cdd..36bd82fa85 100644 --- a/internal/build/main_module_test.go +++ b/internal/build/main_module_test.go @@ -99,6 +99,40 @@ func TestGenMainModuleWASIAsyncifyEntry(t *testing.T) { } } +func TestGenMainModuleWasmResumeEntry(t *testing.T) { + llvm.InitializeAllTargets() + t.Setenv(llgoStdioNobuf, "") + prog := llssa.NewProgram(&llssa.Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + ctx := &context{ + prog: prog, + buildConf: &Config{ + BuildMode: BuildModeExe, + Goos: "wasip1", + Goarch: "wasm", + }, + } + pkg := &packages.Package{PkgPath: "example.com/foo", ExportFile: "foo.a"} + mod := genMainModule(ctx, llssa.PkgRuntime, pkg, &genConfig{}) + if err := lowerWasmResumeModule(ctx, mod.LPkg.Module()); err != nil { + t.Fatal(err) + } + ir := mod.LPkg.String() + for _, want := range []string{ + `define ptr @__llgo_wasm_start.__llgo_wasm_main`, + `define internal i8 @__llgo_wasm_resume.__llgo_wasm_main`, + `@"__llgo_wasm_resume_desc.example.com/foo.init" = external global`, + `@"__llgo_wasm_resume_desc.example.com/foo.main" = external global`, + `define hidden ptr @__llgo_wasm_main(ptr %0)`, + `call void @"github.com/goplus/llgo/runtime/internal/runtime.RunWasmMain"()`, + } { + if !strings.Contains(ir, want) { + t.Fatalf("resumable main module IR missing %q:\n%s", want, ir) + } + } +} + func TestGenMainModuleLibrary(t *testing.T) { llvm.InitializeAllTargets() t.Setenv(llgoStdioNobuf, "") diff --git a/internal/build/wasm_resume.go b/internal/build/wasm_resume.go new file mode 100644 index 0000000000..16d0aa0d52 --- /dev/null +++ b/internal/build/wasm_resume.go @@ -0,0 +1,61 @@ +/* + * 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" + "slices" + + "github.com/goplus/llgo/internal/crosscompile" + "github.com/goplus/llgo/internal/wasmresume" + "github.com/xgo-dev/llvm" +) + +const wasmResumeBuildTag = "llgo.wasm_resume" + +func configureWasmResume(conf *Config, export *crosscompile.Export) error { + if !IsWasmResumeEnabled() { + return nil + } + if conf == nil || conf.Goarch != "wasm" { + return fmt.Errorf("%s requires GOARCH=wasm", llgoWasmResume) + } + if conf.Goos != "js" && conf.Goos != "wasip1" { + return fmt.Errorf("%s does not support GOOS=%s", llgoWasmResume, conf.Goos) + } + if IsWasiThreadsEnabled() { + return fmt.Errorf("%s is incompatible with %s", llgoWasmResume, llgoWasiThreads) + } + if !slices.Contains(export.BuildTags, wasmResumeBuildTag) { + export.BuildTags = append(export.BuildTags, wasmResumeBuildTag) + } + export.WasmPostLink.Asyncify = false + export.LDFLAGS = slices.DeleteFunc(export.LDFLAGS, func(flag string) bool { + return flag == "-sASYNCIFY=1" + }) + return nil +} + +func lowerWasmResumeModule(ctx *context, mod llvm.Module) error { + if ctx == nil || !ctx.prog.WasmResumeABIEnabled() { + return nil + } + if err := wasmresume.Lower(mod, ctx.prog.TargetData()); err != nil { + return fmt.Errorf("lower WebAssembly resumable ABI: %w", err) + } + return nil +} diff --git a/internal/build/wasm_resume_test.go b/internal/build/wasm_resume_test.go new file mode 100644 index 0000000000..478575da1f --- /dev/null +++ b/internal/build/wasm_resume_test.go @@ -0,0 +1,134 @@ +package build + +import ( + "slices" + "strings" + "testing" + + "github.com/goplus/llgo/internal/crosscompile" + llssa "github.com/goplus/llgo/ssa" + "github.com/xgo-dev/llvm" +) + +func TestConfigureWasmResume(t *testing.T) { + t.Setenv(llgoWasmResume, "1") + t.Setenv(llgoWasiThreads, "") + export := crosscompile.Export{ + BuildTags: []string{"existing"}, + LDFLAGS: []string{"before", "-sASYNCIFY=1", "after"}, + WasmPostLink: crosscompile.WasmPostLink{ + Asyncify: true, + TranslateToExnref: true, + }, + } + conf := &Config{Goos: "wasip1", Goarch: "wasm"} + if err := configureWasmResume(conf, &export); err != nil { + t.Fatal(err) + } + if !slices.Contains(export.BuildTags, wasmResumeBuildTag) { + t.Fatalf("build tags = %v", export.BuildTags) + } + if export.WasmPostLink.Asyncify || slices.Contains(export.LDFLAGS, "-sASYNCIFY=1") { + t.Fatalf("Asyncify remains enabled: %+v", export) + } + if !export.WasmPostLink.TranslateToExnref { + t.Fatal("resumable WASI build disabled SjLj exception translation") + } + if err := configureWasmResume(conf, &export); err != nil { + t.Fatal(err) + } + count := 0 + for _, tag := range export.BuildTags { + if tag == wasmResumeBuildTag { + count++ + } + } + if count != 1 { + t.Fatalf("resumable build tag count = %d, tags = %v", count, export.BuildTags) + } +} + +func TestConfigureWasmResumeRejectsUnsupportedModes(t *testing.T) { + t.Setenv(llgoWasmResume, "1") + for _, test := range []struct { + name string + conf Config + threads bool + want string + }{ + {name: "native", conf: Config{Goos: "linux", Goarch: "amd64"}, want: "requires GOARCH=wasm"}, + {name: "host", conf: Config{Goos: "linux", Goarch: "wasm"}, want: "does not support GOOS=linux"}, + {name: "threads", conf: Config{Goos: "wasip1", Goarch: "wasm"}, threads: true, want: llgoWasiThreads}, + } { + t.Run(test.name, func(t *testing.T) { + if test.threads { + t.Setenv(llgoWasiThreads, "1") + } else { + t.Setenv(llgoWasiThreads, "") + } + err := configureWasmResume(&test.conf, &crosscompile.Export{}) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("configureWasmResume error = %v, want %q", err, test.want) + } + }) + } +} + +func TestLowerWasmResumeModule(t *testing.T) { + llvm.InitializeAllTargets() + prog := llssa.NewProgram(&llssa.Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + pkg := prog.NewPackage("p", "example.com/p") + callee := pkg.NewFunc("callee", llssa.NoArgsNoRet, llssa.InGo) + callee.MakeBody(1).Return() + caller := pkg.NewFunc("caller", llssa.NoArgsNoRet, llssa.InGo) + b := caller.MakeBody(1) + b.Call(callee.Expr) + b.Return() + + ctx := &context{prog: prog} + if err := lowerWasmResumeModule(ctx, pkg.Module()); err != nil { + t.Fatal(err) + } + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify lowered module: %v\n%s", err, pkg.String()) + } + for _, want := range []string{ + "define internal i8 @__llgo_wasm_resume.caller", + "define void @caller()", + "define ptr @__llgo_wasm_start.caller", + } { + if !strings.Contains(pkg.String(), want) { + t.Fatalf("lowered module is missing %q:\n%s", want, pkg.String()) + } + } +} + +func TestLowerWasmResumeModuleDisabled(t *testing.T) { + if err := lowerWasmResumeModule(nil, llvm.Module{}); err != nil { + t.Fatal(err) + } + prog := llssa.NewProgram(nil) + defer prog.Dispose() + ctx := &context{prog: prog} + if err := lowerWasmResumeModule(ctx, prog.NewPackage("p", "example.com/p").Module()); err != nil { + t.Fatal(err) + } +} + +func TestLowerWasmResumeModuleReportsLoweringError(t *testing.T) { + prog := llssa.NewProgram(&llssa.Target{GOOS: "wasip1", GOARCH: "wasm"}) + defer prog.Dispose() + prog.EnableWasmResumeABI(true) + ctx := llvm.NewContext() + defer ctx.Dispose() + mod := ctx.NewModule("native") + defer mod.Dispose() + mod.SetTarget("aarch64-apple-darwin") + + err := lowerWasmResumeModule(&context{prog: prog}, mod) + if err == nil || !strings.Contains(err.Error(), "target") { + t.Fatalf("lowerWasmResumeModule error = %v", err) + } +} diff --git a/runtime/internal/runtime/goroutine_func_default.go b/runtime/internal/runtime/goroutine_func_default.go new file mode 100644 index 0000000000..42dd35cd03 --- /dev/null +++ b/runtime/internal/runtime/goroutine_func_default.go @@ -0,0 +1,11 @@ +//go:build !llgo || !wasm || !llgo.wasm_resume || (!js && !wasip1) || (wasip1 && llgo.wasi_threads) + +package runtime + +import "unsafe" + +// goroutineFunc is the target-independent entry ABI between compiler-generated +// goroutine wrappers and the selected stackful scheduler. +// +//llgo:type C +type goroutineFunc func(unsafe.Pointer) unsafe.Pointer diff --git a/runtime/internal/runtime/goroutine_func_wasm_resume.go b/runtime/internal/runtime/goroutine_func_wasm_resume.go new file mode 100644 index 0000000000..d2b7786c95 --- /dev/null +++ b/runtime/internal/runtime/goroutine_func_wasm_resume.go @@ -0,0 +1,14 @@ +//go:build llgo && wasm && llgo.wasm_resume && (js || wasip1) && !(wasip1 && llgo.wasi_threads) + +package runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/wasmresume" +) + +// goroutineFunc is a compiler-generated start entry for one resumable G. +// +//llgo:type C +type goroutineFunc func(*wasmresume.Context, unsafe.Pointer) *wasmresume.Frame diff --git a/runtime/internal/runtime/proc.go b/runtime/internal/runtime/proc.go index ae66de6057..fc38269f37 100644 --- a/runtime/internal/runtime/proc.go +++ b/runtime/internal/runtime/proc.go @@ -22,12 +22,6 @@ import ( c "github.com/goplus/llgo/runtime/internal/clite" ) -// goroutineFunc is the target-independent entry ABI between compiler-generated -// goroutine wrappers and the runtime scheduler. -// -//llgo:type C -type goroutineFunc func(unsafe.Pointer) unsafe.Pointer - // 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. diff --git a/runtime/internal/runtime/proc_wasip1.go b/runtime/internal/runtime/proc_wasip1.go index cfa88861e4..6acb25b611 100644 --- a/runtime/internal/runtime/proc_wasip1.go +++ b/runtime/internal/runtime/proc_wasip1.go @@ -1,4 +1,4 @@ -//go:build llgo && wasip1 && wasm && !llgo.wasi_threads +//go:build llgo && wasip1 && wasm && !llgo.wasi_threads && !llgo.wasm_resume /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/proc_wasm.go b/runtime/internal/runtime/proc_wasm.go index d0f07791d3..10eefcee2a 100644 --- a/runtime/internal/runtime/proc_wasm.go +++ b/runtime/internal/runtime/proc_wasm.go @@ -1,4 +1,4 @@ -//go:build llgo && js && wasm +//go:build llgo && js && wasm && !llgo.wasm_resume /* * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. diff --git a/runtime/internal/runtime/proc_wasm_resume.go b/runtime/internal/runtime/proc_wasm_resume.go new file mode 100644 index 0000000000..f66f5d6f24 --- /dev/null +++ b/runtime/internal/runtime/proc_wasm_resume.go @@ -0,0 +1,267 @@ +//go:build llgo && wasm && llgo.wasm_resume && (js || wasip1) && !(wasip1 && 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" + + c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/runqueue" + "github.com/goplus/llgo/runtime/internal/wasmresume" +) + +type runtimeContextPlatform struct { + context wasmresume.Context + runqNext *g + runqQueued bool + unwind 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 wasmMainStart C.__llgo_wasm_start.__llgo_wasm_main +func wasmMainStart(*wasmresume.Context, unsafe.Pointer) *wasmresume.Frame + +// RunWasmMain runs package initialization and main.main through the resumable +// ABI while the host entry remains on its original stack. +func RunWasmMain() { + gp := getg() + if gp == nil || !gp.isMain { + fatal("runtime: invalid WebAssembly main goroutine") + return + } + gp.context.platform.context.Start( + wasmMainStart(&gp.context.platform.context, nil), + ) + + for { + action := runWasmResumeContext(gp) + status := readgstatus(gp) + if action == wasmresume.Return { + if status != _Grunning { + fatal("runtime: invalid completed WebAssembly goroutine") + return + } + casgstatus(gp, _Grunning, _Gdead) + status = _Gdead + if gp.isMain { + releaseWasmContext(gp) + return + } + } else if action != wasmresume.Suspend || status == _Grunning { + fatal("runtime: invalid WebAssembly resume action") + 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 runWasmResumeContext(gp *g) wasmresume.Action { + 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) + + platform := &gp.context.platform + unwind := c.AllocaSigjmpBuf() + previous := platform.unwind + platform.unwind = unwind + if c.Sigsetjmp(unwind, 0) != 0 { + if !platform.context.Unwind(unsafe.Pointer(gp.defer_), FreeRoot) { + platform.unwind = previous + if gp.goexit { + casgstatus(gp, _Grunning, _Gdead) + if gp.isMain { + wasmSched.mainExited = true + } + return wasmresume.Suspend + } + Rethrow(nil) + return wasmresume.Return + } + } + action := platform.context.Run() + platform.unwind = previous + return action +} + +func releaseWasmOwnership(gp *g) { + if gp != nil { + gp.m = nil + } + wasmSched.m.curg = nil +} + +func newprocBackend(fn goroutineFunc, arg unsafe.Pointer, _ uintptr, callergp *g) { + gp := newproc1(fn, arg, callergp) + gp.startfn = nil + gp.startarg = nil + gp.context.platform.context.Start(fn(&gp.context.platform.context, arg)) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + } +} + +func releaseWasmContext(gp *g) { + if gp == nil || gp.context == nil { + return + } + ctx := gp.context + ctx.platform.context.Close(FreeRoot) + freeRuntimeContext(ctx) +} + +func goschedBackend() { + gp := getg() + casgstatus(gp, _Grunning, _Grunnable) + if !wasmSched.runq.Push(gp) { + fatal("runtime: invalid run queue insertion") + return + } + wasmresume.SuspendCurrent() +} + +func gopark() { + gp := getg() + casgstatus(gp, _Grunning, _Gwaiting) + wasmresume.SuspendCurrent() +} + +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 + } + wasmresume.SuspendCurrent() + fatal("runtime: resumed dead WebAssembly goroutine") +} + +//go:linkname wasmResumeAlloc __llgo_wasm_resume_alloc +func wasmResumeAlloc(ctx *wasmresume.Context, size, align uintptr) unsafe.Pointer { + return ctx.AllocateFrame(size, align, AllocRoot) +} + +//go:linkname wasmResumeAllocDynamic __llgo_wasm_resume_alloc_dynamic +func wasmResumeAllocDynamic(ctx *wasmresume.Context, size, align uintptr) unsafe.Pointer { + return ctx.AllocateFrame(size, align, AllocRoot) +} + +//go:linkname wasmResumeFree __llgo_wasm_resume_free +func wasmResumeFree(ctx *wasmresume.Context, frame *wasmresume.Frame) { + ctx.ReleaseFrame(frame, FreeRoot) +} + +//go:linkname wasmResumeClose __llgo_wasm_resume_close +func wasmResumeClose(ctx *wasmresume.Context) { + ctx.Close(FreeRoot) +} + +// 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/z_default.go b/runtime/internal/runtime/z_default.go index 71823fa552..e4bcc48431 100644 --- a/runtime/internal/runtime/z_default.go +++ b/runtime/internal/runtime/z_default.go @@ -1,4 +1,4 @@ -//go:build !baremetal +//go:build !baremetal && !(llgo && wasm && llgo.wasm_resume && (js || wasip1) && !(wasip1 && llgo.wasi_threads)) package runtime diff --git a/runtime/internal/runtime/z_wasm_resume.go b/runtime/internal/runtime/z_wasm_resume.go new file mode 100644 index 0000000000..0c78c38bcf --- /dev/null +++ b/runtime/internal/runtime/z_wasm_resume.go @@ -0,0 +1,39 @@ +//go:build llgo && wasm && llgo.wasm_resume && (js || wasip1) && !(wasip1 && llgo.wasi_threads) + +package runtime + +import ( + c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/clite/debug" +) + +var ( + printFormatPrefixInt = c.Str("%lld") + printFormatPrefixUInt = c.Str("%llu") + printFormatPrefixHex = c.Str("%llx") +) + +// Rethrow transfers pending panic/Goexit processing to the scheduler's active +// native catch. The scheduler then redirects the explicit frame chain to the +// defer owner recorded by the compiler. +func Rethrow(link *Defer) { + gp := getg() + if gp.panic_ == nil && !gp.goexit { + return + } + gp.defer_ = link + if unwind := gp.context.platform.unwind; unwind != nil { + c.Siglongjmp(unwind, 1) + return + } + if ptr := gp.panic_; ptr != nil { + TracePanic(*(*any)(ptr)) + if PanicTraceback == nil || !PanicTraceback(2) { + debug.PrintStack(2) + } + c.Free(ptr) + c.Exit(2) + return + } + fatal("runtime: Goexit outside WebAssembly scheduler") +} From 622a8964c6c07728bdf5eb940ca4d21b07b363bf Mon Sep 17 00:00:00 2001 From: Li Jie Date: Thu, 30 Jul 2026 23:42:25 +0800 Subject: [PATCH 39/40] ci/wasm: exercise resumable scheduler profiles --- .github/workflows/llgo.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/llgo.yml b/.github/workflows/llgo.yml index 89fe52dab9..075ffb02a4 100644 --- a/.github/workflows/llgo.yml +++ b/.github/workflows/llgo.yml @@ -485,6 +485,15 @@ jobs: run_wasm_scheduler "$RUNNER_TEMP/wasm-scheduler.mjs" 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" + LLGO_WASM_RESUME=1 GOOS=js GOARCH=wasm llgo build \ + -o "$RUNNER_TEMP/wasm-resume-scheduler-go.mjs" ./internal/build/testdata/wasm-scheduler + run_wasm_scheduler "$RUNNER_TEMP/wasm-resume-scheduler-go.mjs" + LLGO_WASM_RESUME=1 llgo build -target wasm \ + -o "$RUNNER_TEMP/wasm-resume-scheduler.mjs" ./internal/build/testdata/wasm-scheduler + run_wasm_scheduler "$RUNNER_TEMP/wasm-resume-scheduler.mjs" + LLGO_WASM_RESUME=1 GOOS=wasip1 GOARCH=wasm llgo build \ + -o "$RUNNER_TEMP/wasm-resume-scheduler-wasip1.wasm" ./internal/build/testdata/wasm-scheduler + run_wasi_scheduler "$RUNNER_TEMP/wasm-resume-scheduler-wasip1.wasm" file "$RUNNER_TEMP/runtime-js.wasm" \ "$RUNNER_TEMP/runtime-wasip1.wasm" \ "$RUNNER_TEMP/runtime-wasip1-threads.wasm" From c7ffc2cd9ecfedb2f6fd4a8ace5fb2fae6255187 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Fri, 31 Jul 2026 00:44:51 +0800 Subject: [PATCH 40/40] test/wasmresume: tolerate Node without table64 --- internal/wasmresume/execution_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/wasmresume/execution_test.go b/internal/wasmresume/execution_test.go index 95296a54c8..0b8d31f869 100644 --- a/internal/wasmresume/execution_test.go +++ b/internal/wasmresume/execution_test.go @@ -85,6 +85,14 @@ func TestLowerExecutesRequiredWasmProfiles(t *testing.T) { } output, err := test.run(t, wasmPath) if err != nil { + if test.name == "J64" && + strings.Contains(string(output), "invalid table elements limits flags") { + version, _ := exec.Command(node, "--version").CombinedOutput() + t.Skipf( + "node %s does not support LLVM wasm64 table limits", + strings.TrimSpace(string(version)), + ) + } t.Fatalf("execute %s: %v\n%s", test.name, err, output) } fields := strings.Fields(string(output))