`-scheduler=none` wasip1 build fails: time.goFunc kept live in whole-program graph (regression vs 0.41-era builds)
The bug
Building for -target=wasi -scheduler=none fails whenever a program uses any
fmt stream-output call (fmt.Println, fmt.Printf, fmt.Fprint(os.Stdout), …):
$ tinygo build -target=wasi -scheduler=none -o repro.wasm .
/usr/local/go/src/time/sleep.go:182:2: attempted to start a goroutine without a schedulertime.goFunc (the only body on that time/sleep.go line) is referenced only
by time.AfterFunc, and this program never calls time.AfterFunc — nor any
timer, time.Sleep, or goroutine. The reference comes from TinyGo's own WASI
poll integration: $(TINYGOROOT)/src/internal/poll/fd_wasip1.go:542 uses
time.AfterFunc(d, func() { … }) to wake parked goroutines on deadline writes
(writeWithDeadline). Under scheduler=none the compiler keeps that callback
live once fmt's interface-dispatched write path is retained, and the build
aborts.
Minimal reproduction
Three files, no dependencies:
mkdir wasi-fmt-repro && cd wasi-fmt-reprogo.mod:
module repro
go 1.24.0main.go:
package main
import "fmt"
func main() { fmt.Println("x") }tinygo build -target=wasi -scheduler=none -o repro.wasm .Expected: a 0-byte repro.wasm. Actual: the error above.
The module's go directive does not matter (reproduces at 1.24.0); the failure
comes from the host Go 1.27 stdlib + TinyGo's internal/poll overlay.
Control matrix (same one-line programs, same flags)
main.go body |
Result |
|---|---|
fmt.Println("x") |
FAILS |
fmt.Printf("%s\n", "x") |
FAILS |
fmt.Fprint(os.Stdout, "x") |
FAILS |
fmt.Fprintln(os.Stdout, "x") |
FAILS |
fmt.Fprintf(os.Stderr, "x\n") |
FAILS |
os.Stdout.Write([]byte("x")) |
builds |
os.Stderr.Write([]byte("x")) |
builds |
log.Fatalf("e") (live behind a runtime-false branch) |
builds |
fmt.Sprintf/fmt.Errorf/fmt.Sprint only |
builds |
time.Now(), crypto/rand.Read, os.Args, os.Getenv only |
builds |
The contrast is sharp and reproducible in one line each:
os.Stdout.Write(...) compiles; fmt.Fprint(os.Stdout, ...) does not.
Concrete-type calls to (*os.File).Write get eliminated; fmt's
io.Writer interface dispatch retains writeWithDeadline → keeps
time.AfterFunc → keeps goFunc's go statement → scheduler=none check trips.
Why this matters
scheduler=noneis the default (and recommended) scheduler for thewasitarget, andfmt.Println/fmt.Printfare the most common output idiom in Go. Today any such program simply cannot be built for WASI, with an error message that names neither the construct (fmt.Println) nor the path (fd_wasip1 deadline machinery) at fault.- This is a regression from the 0.41-era builds: the same sources built fine
with the pre-0.42 toolchain; 0.42's
wasip1 poll_oneoff scheduler integration (#5386)introduced thetime.AfterFuncusage, and the 0.42.0 release notes themselves mention "skip flaky and Go 1.27 affected tests for now", suggestingscheduler=noneWASI CI coverage may be missing. - Related scheduler=none foot-guns found while bisecting, in case they share
the same root:
fmt.Sscanfcan also poison a build depending on the rest of the graph, andcrypto/randon wasip1 (nogetrandom(2)) falls back toos.Filereads which need the same deadline machinery — guests therefore cannot use it once the graph keepsoswriters live.
Suggestions
- Make the error actionable: when
scheduler=nonetrips, report which program construct imports the goroutine (a path like "fmt.Println → os.File.Write → poll fd deadline"), not justtime/sleep.go:182. - Prefer a scheduler-free wake mechanism inside
fd_wasip1.go(e.g. rely onpoll_oneoff's own timeout argument rather thantime.AfterFunc), so plainfmt.Printlnprograms link underscheduler=none. - Add CI coverage for
-target=wasi -scheduler=nonewith a program that usesfmt.Printlnandfmt.Sscanf.
Environment
- TinyGo 0.42.0 (Homebrew, darwin-arm64, LLVM 22.1.4)
- Host Go 1.27.1 (failure is the same with any module
godirective tested: 1.24.0–1.27.1) - Command:
tinygo build -target=wasi -scheduler=none -o repro.wasm .
Source: tinygo-org/tinygo