From f5df05a2fe8fdac3a4d5e25a5e127d19efbf7e8d Mon Sep 17 00:00:00 2001 From: Joe Shaw Date: Mon, 19 Oct 2026 19:12:37 +0000 Subject: [PATCH 1/3] builder: allow -buildmode=wasi-legacy with any scheduler Programs that start goroutines can now use -buildmode=wasi-legacy. Their _start returns when main returns instead of calling proc_exit, so the module stays open and the host can call the program's //export functions afterwards, which is what wasi-legacy was added for in #4734. Until now wasi-legacy required -scheduler=none, which cannot compile a go statement, so these programs could not use it at all. Returning from _start is also much cheaper than exiting. When proc_exit unwinds out of the module, wazero and wasmtime build a stack trace and symbolize it if debug info is enabled, which is the default in wazero and opt in for wasmtime. For a fresh instance of a 1.6MB program, wazero with its default config took 0.5ms instead of 3.6ms, and wasmtime with backtrace details on took 0.5ms instead of 11.5ms. Goroutines need no other change, because the wasi-legacy _start already runs the scheduler like the default _start does. The C stdio buffers are now flushed when main returns, as _start did before it started calling proc_exit. --- builder/build.go | 4 ---- src/runtime/runtime_tinygowasm.go | 5 +++++ src/runtime/runtime_tinygowasmp2.go | 3 +++ src/runtime/runtime_wasm_unknown.go | 3 +++ src/runtime/runtime_wasmentry.go | 6 +++++- webassembly_test.go | 11 +++++++++++ 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/builder/build.go b/builder/build.go index 266179baaa..7a1f4acf07 100644 --- a/builder/build.go +++ b/builder/build.go @@ -816,10 +816,6 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe if !strings.HasPrefix(config.Triple(), "wasm32-") { return result, fmt.Errorf("buildmode wasi-legacy is only supported on wasm") } - - if config.Options.Scheduler != "none" { - return result, fmt.Errorf("buildmode wasi-legacy only supports scheduler=none") - } } // Add compiler-rt dependency if needed. Usually this is a simple load from diff --git a/src/runtime/runtime_tinygowasm.go b/src/runtime/runtime_tinygowasm.go index fedf80e777..ae64262749 100644 --- a/src/runtime/runtime_tinygowasm.go +++ b/src/runtime/runtime_tinygowasm.go @@ -91,6 +91,11 @@ func mainReturnExit() { syscall_Exit(0) } +// flushStdio flushes the C stdio buffers without exiting. +func flushStdio() { + __stdio_exit() +} + // TinyGo does not yet support any form of parallelism on WebAssembly, so these // can be left empty. diff --git a/src/runtime/runtime_tinygowasmp2.go b/src/runtime/runtime_tinygowasmp2.go index 10b9fbd5e5..0442e1ea78 100644 --- a/src/runtime/runtime_tinygowasmp2.go +++ b/src/runtime/runtime_tinygowasmp2.go @@ -60,6 +60,9 @@ func syscall_Exit(code int) { exit.Exit(code != 0) } +// WASIp2 does not use wasi-libc's stdio. +func flushStdio() {} + func mainReturnExit() { // WASIp2 does not use _start, instead it uses _initialize and a custom // WASIp2-specific main function. So this should never be called in diff --git a/src/runtime/runtime_wasm_unknown.go b/src/runtime/runtime_wasm_unknown.go index 70c42f2a98..04d4add2b2 100644 --- a/src/runtime/runtime_wasm_unknown.go +++ b/src/runtime/runtime_wasm_unknown.go @@ -29,6 +29,9 @@ func ticks() timeUnit { return timeUnit(0) } +// There is no C stdio to flush. +func flushStdio() {} + func mainReturnExit() { // Don't exit explicitly here. We can't (there is no environment with an // exit call) but also it's not needed. We can just let _start and main.main diff --git a/src/runtime/runtime_wasmentry.go b/src/runtime/runtime_wasmentry.go index 91a979a3aa..810528f7aa 100644 --- a/src/runtime/runtime_wasmentry.go +++ b/src/runtime/runtime_wasmentry.go @@ -51,13 +51,17 @@ func wasmEntryReactor() { } } -// This is the _start entry point, when using -buildmode=wasi-legacy. +// This is the _start entry point, when using -buildmode=wasi-legacy. It returns +// when main returns instead of exiting, so the module stays usable. func wasmEntryLegacy() { // These need to be initialized early so that the heap can be initialized. initializeCalled = true heapStart = uintptr(unsafe.Pointer(&heapStartSymbol)) heapEnd = uintptr(wasm_memory_size(0) * wasmPageSize) run() + if mainExited { + flushStdio() + } } // Whether the runtime was initialized by a call to _initialize or _start. diff --git a/webassembly_test.go b/webassembly_test.go index 4dcdd748d5..f54cb2f140 100644 --- a/webassembly_test.go +++ b/webassembly_test.go @@ -137,6 +137,13 @@ func TestWasmExport(t *testing.T) { file: "wasmexport-noscheduler.go", command: true, }, + // Test buildmode=wasi-legacy with the default scheduler. + { + name: "WASIp1-legacy-scheduler", + target: "wasip1", + buildMode: "wasi-legacy", + command: true, + }, } for _, tc := range tests { @@ -254,6 +261,10 @@ func TestWasmExport(t *testing.T) { t.Error("failed to run _start:", err) } } + if tc.buildMode == "wasi-legacy" && mod.IsClosed() { + // A legacy command returns from _start without exiting. + t.Error("module exited in _start") + } } else { // Run the _initialize call, because this is reactor mode wasm. mustCall(mod.ExportedFunction("_initialize").Call(ctx)) From ffdd52b94fcb7e9504e8a61ec1b7f3379a1c02d9 Mon Sep 17 00:00:00 2001 From: Joe Shaw Date: Thu, 22 Oct 2026 17:39:02 +0000 Subject: [PATCH 2/3] main: test calling an //export function after a wasi-legacy _start TestWasmExport runs its export calls from inside main, so with wasi-legacy it could only check that the module stays open after _start. The new test builds a program whose main waits for a goroutine, runs _start, and then calls an //export function to read the value the goroutine set. --- testdata/wasi-legacy.go | 23 +++++++++++++++++++++ testdata/wasi-legacy.txt | 1 + webassembly_test.go | 44 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 testdata/wasi-legacy.go create mode 100644 testdata/wasi-legacy.txt diff --git a/testdata/wasi-legacy.go b/testdata/wasi-legacy.go new file mode 100644 index 0000000000..fc7284d93f --- /dev/null +++ b/testdata/wasi-legacy.go @@ -0,0 +1,23 @@ +package main + +import "time" + +var result int32 + +func main() { + done := make(chan int32) + go func() { + time.Sleep(time.Millisecond) + done <- 42 + }() + result = <-done + println("main done") +} + +// Unlike a //go:wasmexport function, an //export function can still be called +// after main returns. +// +//export getResult +func getResult() int32 { + return result +} diff --git a/testdata/wasi-legacy.txt b/testdata/wasi-legacy.txt new file mode 100644 index 0000000000..fb3903e0ed --- /dev/null +++ b/testdata/wasi-legacy.txt @@ -0,0 +1 @@ +main done diff --git a/webassembly_test.go b/webassembly_test.go index f54cb2f140..d4302a5da5 100644 --- a/webassembly_test.go +++ b/webassembly_test.go @@ -280,6 +280,50 @@ func TestWasmExport(t *testing.T) { } } +// Test that with -buildmode=wasi-legacy and the default scheduler, _start +// returns and the host can call an //export function afterwards. +func TestWasmLegacyExport(t *testing.T) { + t.Parallel() + + // Build the wasm binary. + options := optionsFromTarget("wasip1", sema) + options.BuildMode = "wasi-legacy" + buildConfig, err := builder.NewConfig(&options) + if err != nil { + t.Fatal(err) + } + result, err := builder.Build("testdata/wasi-legacy.go", ".wasm", t.TempDir(), buildConfig) + if err != nil { + t.Fatal("failed to build binary:", err) + } + data, err := os.ReadFile(result.Binary) + if err != nil { + t.Fatal("could not read wasm binary:", err) + } + + // Run _start, which waits for a goroutine to set the result. + output := &bytes.Buffer{} + ctx := context.Background() + r := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfigInterpreter()) + defer r.Close(ctx) + wasi_snapshot_preview1.MustInstantiate(ctx, r) + config := wazero.NewModuleConfig().WithStdout(output).WithStderr(output) + mod, err := r.InstantiateWithConfig(ctx, data, config) + if err != nil { + t.Fatal("failed to run _start:", err) + } + + // Call the //export function after _start has returned. + results, err := mod.ExportedFunction("getResult").Call(ctx) + if err != nil { + t.Fatal("failed to call getResult after _start:", err) + } + if len(results) != 1 || results[0] != 42 { + t.Errorf("getResult() = %v, want [42]", results) + } + checkOutput(t, "testdata/wasi-legacy.txt", output.Bytes()) +} + // Test js.FuncOf (for syscall/js). // This test might be extended in the future to cover more cases in syscall/js. func TestWasmFuncOf(t *testing.T) { From 7a4a59ecca6e3f5c64ca6b22bfd18d4ac451ed98 Mon Sep 17 00:00:00 2001 From: Joe Shaw Date: Tue, 27 Oct 2026 20:11:13 +0000 Subject: [PATCH 3/3] runtime: explain blocking in an //export function with Asyncify An //export function runs outside any goroutine, so with the Asyncify scheduler a blocking call there found no current task and failed with a nil pointer dereference. The blocking paths now get the current task from task.CurrentToBlock, which panics with "trying to do blocking operation in exported function" when there is none. Other schedulers return the current task as before, so their code size does not change. This happens in every build mode, and with -buildmode=wasi-legacy after main returns. //go:wasmexport functions run in a goroutine and can block. --- src/internal/task/futex-cooperative.go | 2 +- src/internal/task/mutex-cooperative.go | 2 +- src/internal/task/task_asyncify.go | 12 ++++++++++++ src/internal/task/task_toblock.go | 8 ++++++++ src/runtime/chan.go | 6 +++--- src/runtime/netpoll_wasip1.go | 2 +- src/runtime/scheduler_cooperative.go | 4 ++-- src/sync/cond.go | 2 +- testdata/wasi-legacy.go | 5 +++++ webassembly_test.go | 9 +++++++++ 10 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 src/internal/task/task_toblock.go diff --git a/src/internal/task/futex-cooperative.go b/src/internal/task/futex-cooperative.go index ae9efb5a73..d13197c11f 100644 --- a/src/internal/task/futex-cooperative.go +++ b/src/internal/task/futex-cooperative.go @@ -21,7 +21,7 @@ func (f *Futex) Wait(cmp uint32) (awoken bool) { } // Push the current goroutine onto the waiter stack. - f.waiters.Push(Current()) + f.waiters.Push(CurrentToBlock()) // Pause until the waiters are awoken by Wake/WakeAll. Pause() diff --git a/src/internal/task/mutex-cooperative.go b/src/internal/task/mutex-cooperative.go index 57d66d0139..f3b2669f78 100644 --- a/src/internal/task/mutex-cooperative.go +++ b/src/internal/task/mutex-cooperative.go @@ -10,7 +10,7 @@ type Mutex struct { func (m *Mutex) Lock() { if m.locked { // Push self onto stack of blocked tasks, and wait to be resumed. - m.blocked.Push(Current()) + m.blocked.Push(CurrentToBlock()) Pause() return } diff --git a/src/internal/task/task_asyncify.go b/src/internal/task/task_asyncify.go index 05c3b9b0a3..1b39face1d 100644 --- a/src/internal/task/task_asyncify.go +++ b/src/internal/task/task_asyncify.go @@ -130,6 +130,18 @@ func Current() *Task { return currentTask } +//go:linkname blockingPanic runtime.blockingPanic +func blockingPanic() + +// CurrentToBlock returns the goroutine that the caller is about to block. It +// panics when there is none, as in an //export function. +func CurrentToBlock() *Task { + if currentTask == nil { + blockingPanic() + } + return currentTask +} + // Pause suspends the current task and returns to the scheduler. // This function may only be called when running on a goroutine stack, not when running on the system stack. func Pause() { diff --git a/src/internal/task/task_toblock.go b/src/internal/task/task_toblock.go new file mode 100644 index 0000000000..f20eb1cb2e --- /dev/null +++ b/src/internal/task/task_toblock.go @@ -0,0 +1,8 @@ +//go:build !scheduler.asyncify + +package task + +// CurrentToBlock returns the goroutine that the caller is about to block. +func CurrentToBlock() *Task { + return Current() +} diff --git a/src/runtime/chan.go b/src/runtime/chan.go index bcad567770..74d25a9f7b 100644 --- a/src/runtime/chan.go +++ b/src/runtime/chan.go @@ -267,7 +267,7 @@ func chanSend(ch *channel, value unsafe.Pointer, op *channelOp) { } // Can't proceed. Add us to the list of senders and wait until we're awoken. - t := task.Current() + t := task.CurrentToBlock() t.SetDataUint32(chanOperationWaiting) op.task = t op.index = 0 @@ -351,7 +351,7 @@ func chanRecv(ch *channel, value unsafe.Pointer, op *channelOp) bool { // We can't proceed, so we add ourselves to the list of receivers and wait // until we're awoken. - t := task.Current() + t := task.CurrentToBlock() t.Ptr = value t.SetDataUint32(chanOperationWaiting) op.task = t @@ -602,7 +602,7 @@ func chanSelect(recvbuf unsafe.Pointer, states []chanSelectState, ops []channelO // first one to complete. Only one will successfully complete, because // senders and receivers use a compare-and-exchange atomic operation on // t.Data so that only one will be able to "take" this select operation. - t := task.Current() + t := task.CurrentToBlock() t.Ptr = recvbuf t.SetDataUint32(chanOperationWaiting) for i, state := range states { diff --git a/src/runtime/netpoll_wasip1.go b/src/runtime/netpoll_wasip1.go index a5b4cc66c9..71ffee93c1 100644 --- a/src/runtime/netpoll_wasip1.go +++ b/src/runtime/netpoll_wasip1.go @@ -54,7 +54,7 @@ func netpollAddWait(fd uint32, mode pollMode) *pollDesc { pd := &pollDesc{ fd: fd, mode: mode, - task: task.Current(), + task: task.CurrentToBlock(), bnxt: activePolls, } activePolls = pd diff --git a/src/runtime/scheduler_cooperative.go b/src/runtime/scheduler_cooperative.go index 5cf9dd1a44..3ebbe625d5 100644 --- a/src/runtime/scheduler_cooperative.go +++ b/src/runtime/scheduler_cooperative.go @@ -100,7 +100,7 @@ func scheduleTaskNoWake(t *task.Task) { } func Gosched() { - runqueue.Push(task.Current()) + runqueue.Push(task.CurrentToBlock()) task.Pause() } @@ -303,7 +303,7 @@ func sleep(duration int64) { if synctestSleep(duration) { return } - addSleepTask(task.Current(), nanosecondsToTicks(duration)) + addSleepTask(task.CurrentToBlock(), nanosecondsToTicks(duration)) task.Pause() } diff --git a/src/sync/cond.go b/src/sync/cond.go index 611925951e..b5e7124d03 100644 --- a/src/sync/cond.go +++ b/src/sync/cond.go @@ -61,7 +61,7 @@ func (c *Cond) Broadcast() { func (c *Cond) Wait() { // Mark us as not yet signalled or sleeping. - t := task.Current() + t := task.CurrentToBlock() dataPtr := (*task.Uint32)(unsafe.Pointer(&t.Data)) dataPtr.Store(condWaiting) diff --git a/testdata/wasi-legacy.go b/testdata/wasi-legacy.go index fc7284d93f..0ef690bd20 100644 --- a/testdata/wasi-legacy.go +++ b/testdata/wasi-legacy.go @@ -21,3 +21,8 @@ func main() { func getResult() int32 { return result } + +//export sleep +func sleep() { + time.Sleep(time.Millisecond) +} diff --git a/webassembly_test.go b/webassembly_test.go index d4302a5da5..d8660a932b 100644 --- a/webassembly_test.go +++ b/webassembly_test.go @@ -322,6 +322,15 @@ func TestWasmLegacyExport(t *testing.T) { t.Errorf("getResult() = %v, want [42]", results) } checkOutput(t, "testdata/wasi-legacy.txt", output.Bytes()) + + // An //export function runs outside any goroutine, so it cannot block. + output.Reset() + if _, err := mod.ExportedFunction("sleep").Call(ctx); err == nil { + t.Error("sleep() after _start did not fail") + } + if !strings.Contains(output.String(), "blocking operation in exported function") { + t.Errorf("sleep() after _start: unexpected output %q", output.String()) + } } // Test js.FuncOf (for syscall/js).