Summary: runtime: netpoll_wasip1 throws "poll_oneoff failed" in WASM runtimes that return ENOTSUP, crashing plugins without open file descriptors
What version of Go are you using?
go1.26.1 (reproducible from go1.24+ where standard Go wasip1 support was introduced)
What operating system and processor architecture are you using?
GOOS=wasip1 GOARCH=wasm
What did you do?
Built a Go proxy-WASM HTTP filter using GOOS=wasip1 -buildmode=c-shared and deployed it in Envoy via the envoy.filters.http.wasm filter. The plugin:
- Has no open file descriptors
- Makes no network calls
- Uses no
time.Sleep or channels
- Does allocate ~200–400 bytes of heap per request (Go struct + JSON marshal)
After some hours of traffic (enough to trigger a GC cycle), Envoy logs:
fatal error: poll_oneoff failed
runtime stack:
runtime.throw2({0x...})
src/runtime/panic.go:1023
runtime.netpoll(0x0)
src/runtime/netpoll_wasip1.go:216
runtime.startTheWorldWithSema(...)
src/runtime/proc.go:1753
runtime.gcStart(...)
src/runtime/mgc.go:...
Envoy returns ENOTSUP (errno 52) for poll_oneoff. The Go runtime treats any non-EINTR error as fatal.
What did you expect to see?
The Go runtime should not crash. For WASM plugins with no open FDs, poll_oneoff is called only as a scheduler/timer sleep mechanism — not for real I/O. Returning 0 events is the correct and harmless answer.
What did you see instead?
Fatal crash, WASM VM permanently dead, all subsequent requests on that Envoy worker return 503.
Root cause
The trigger is os package initialisation. Any real-world plugin uses the proxywasm SDK, which imports fmt, which imports os. The os package initialises os.Stdout/os.Stderr as pollable file descriptors, which calls:
poll.FD.Init → serverInit.Do(runtime_pollServerInit) → netpollGenericInit()
This sets netpollinited = true at process startup, before any request is served.
From that point, every GC cycle calls netpoll(0) from startTheWorldWithSema:
// src/runtime/proc.go
if netpollinited() {
list, delta := netpoll(0) // non-blocking poll
...
}
In netpoll_wasip1.go, any poll_oneoff error except EINTR results in throw:
errno := poll_oneoff(&pollsubs[0], &evts[0], uint32(len(pollsubs)), &nevents)
if errno != 0 {
if errno != _EINTR {
println("errno=", errno, " len(pollsubs)=", len(pollsubs))
throw("poll_oneoff failed") // ← fatal crash
}
Envoy's proxy-WASM runtime exports poll_oneoff (the module loads fine), but returns ENOTSUP because it is a non-blocking event-driven runtime that cannot allow WASM to block. The crash only manifests after hours of traffic because GC requires the heap to fill — with small per-request allocations it takes a long time, making this hard to catch in short-lived integration tests.
Note: it is impractical to avoid the os/fmt import chain. Any proxy-WASM plugin of non-trivial complexity will pull these in transitively, and there is no supported mechanism to prevent os.Stdout/Stderr initialisation as pollable FDs.
Is this an Envoy bug?
Partially — Envoy's stub is arguably non-conformant: it exports poll_oneoff but returns ENOTSUP rather than simply not exporting it (which would produce a clear load-time error). However, fixing Envoy does not help: any event-driven proxy-WASM runtime has the same constraint, since allowing WASM to block on a poll would deadlock the worker thread. The correct long-term fix is in the Go runtime.
Proposed fix (minimal — runtime)
Change netpoll_wasip1.go to return 0 events instead of throwing when poll_oneoff returns ENOTSUP:
if errno != 0 {
if errno != _EINTR {
if errno == _ENOTSUP {
// Some WASM runtimes (e.g. Envoy proxy-WASM) implement poll_oneoff as a
// stub that returns ENOTSUP rather than a true implementation. Return 0
// ready events — for plugins with no open FDs this is always correct.
unlock(&mtx)
return gList{}, 0
}
println("errno=", errno, " len(pollsubs)=", len(pollsubs))
throw("poll_oneoff failed")
}
Proposed fix (stronger — build-time opt-out)
Add a mechanism to select netpoll_fake.go-style behaviour for wasip1 builds that have no need for real I/O polling. The infrastructure already exists: src/runtime/netpoll_fake.go (//go:build js && wasm) is a complete no-op that returns gList{}, 0 for all netpoll calls. The same file or equivalent behaviour should be selectable for wasip1 via a build tag (e.g. nonetpoll) or a GOWASINETPOLL=off environment variable.
This would benefit:
- proxy-WASM plugins (Envoy, MOSN, etc.)
- Sandboxed WASM runtimes (gVisor, Fastly Compute, etc.)
- Any wasip1 binary embedded in an event-driven host that cannot support blocking I/O
This is related to #32009 ("runtime: optionally (reliably) avoid netpoller"), filed by the gVisor project in 2019 and still open. That issue requests a nonetpoll build tag or a test-time assertion for the same reason — avoiding netpoller initialisation in embedded/sandboxed contexts. Our case adds a second concrete use case with a more severe failure mode: not just CPU overhead (gVisor's concern) but an unrecoverable fatal crash.
Prior art
src/runtime/netpoll_fake.go (//go:build js && wasm) already does exactly this for the js/wasm target — netpoll is a complete no-op returning gList{}, 0. The comment says "Should never be used, because js/wasm network connections do not honor SetNonblock." The same reasoning applies to proxy-WASM plugins that perform no file I/O.
gVisor goes to significant lengths to avoid importing the net package in its WASM components for exactly this reason (#32009) — it should not require such heroics.
Workaround
We currently patch the compiled WASM binary post-build: a Go tool parses the WASM binary format, locates runtime.poll_oneoff via the name section, and replaces its code section body with a no-op stub that writes nevents=0 and errno=0 without calling the WASI import. The import declaration is kept so the module still loads. This is fragile and should not be necessary.
Summary:
runtime: netpoll_wasip1 throws "poll_oneoff failed" in WASM runtimes that return ENOTSUP, crashing plugins without open file descriptorsWhat version of Go are you using?
go1.26.1 (reproducible from go1.24+ where standard Go wasip1 support was introduced)
What operating system and processor architecture are you using?
GOOS=wasip1 GOARCH=wasmWhat did you do?
Built a Go proxy-WASM HTTP filter using
GOOS=wasip1 -buildmode=c-sharedand deployed it in Envoy via theenvoy.filters.http.wasmfilter. The plugin:time.Sleepor channelsAfter some hours of traffic (enough to trigger a GC cycle), Envoy logs:
Envoy returns ENOTSUP (errno 52) for
poll_oneoff. The Go runtime treats any non-EINTR error as fatal.What did you expect to see?
The Go runtime should not crash. For WASM plugins with no open FDs,
poll_oneoffis called only as a scheduler/timer sleep mechanism — not for real I/O. Returning 0 events is the correct and harmless answer.What did you see instead?
Fatal crash, WASM VM permanently dead, all subsequent requests on that Envoy worker return 503.
Root cause
The trigger is
ospackage initialisation. Any real-world plugin uses theproxywasmSDK, which importsfmt, which importsos. Theospackage initialisesos.Stdout/os.Stderras pollable file descriptors, which calls:This sets
netpollinited = trueat process startup, before any request is served.From that point, every GC cycle calls
netpoll(0)fromstartTheWorldWithSema:In
netpoll_wasip1.go, anypoll_oneofferror exceptEINTRresults inthrow:Envoy's proxy-WASM runtime exports
poll_oneoff(the module loads fine), but returns ENOTSUP because it is a non-blocking event-driven runtime that cannot allow WASM to block. The crash only manifests after hours of traffic because GC requires the heap to fill — with small per-request allocations it takes a long time, making this hard to catch in short-lived integration tests.Note: it is impractical to avoid the
os/fmtimport chain. Any proxy-WASM plugin of non-trivial complexity will pull these in transitively, and there is no supported mechanism to preventos.Stdout/Stderrinitialisation as pollable FDs.Is this an Envoy bug?
Partially — Envoy's stub is arguably non-conformant: it exports
poll_oneoffbut returns ENOTSUP rather than simply not exporting it (which would produce a clear load-time error). However, fixing Envoy does not help: any event-driven proxy-WASM runtime has the same constraint, since allowing WASM to block on a poll would deadlock the worker thread. The correct long-term fix is in the Go runtime.Proposed fix (minimal — runtime)
Change
netpoll_wasip1.goto return 0 events instead of throwing whenpoll_oneoffreturns ENOTSUP:Proposed fix (stronger — build-time opt-out)
Add a mechanism to select
netpoll_fake.go-style behaviour for wasip1 builds that have no need for real I/O polling. The infrastructure already exists:src/runtime/netpoll_fake.go(//go:build js && wasm) is a complete no-op that returnsgList{}, 0for allnetpollcalls. The same file or equivalent behaviour should be selectable for wasip1 via a build tag (e.g.nonetpoll) or aGOWASINETPOLL=offenvironment variable.This would benefit:
This is related to #32009 ("runtime: optionally (reliably) avoid netpoller"), filed by the gVisor project in 2019 and still open. That issue requests a
nonetpollbuild tag or a test-time assertion for the same reason — avoiding netpoller initialisation in embedded/sandboxed contexts. Our case adds a second concrete use case with a more severe failure mode: not just CPU overhead (gVisor's concern) but an unrecoverable fatal crash.Prior art
src/runtime/netpoll_fake.go(//go:build js && wasm) already does exactly this for thejs/wasmtarget —netpollis a complete no-op returninggList{}, 0. The comment says "Should never be used, because js/wasm network connections do not honor SetNonblock." The same reasoning applies to proxy-WASM plugins that perform no file I/O.gVisorgoes to significant lengths to avoid importing thenetpackage in its WASM components for exactly this reason (#32009) — it should not require such heroics.Workaround
We currently patch the compiled WASM binary post-build: a Go tool parses the WASM binary format, locates
runtime.poll_oneoffvia the name section, and replaces its code section body with a no-op stub that writesnevents=0anderrno=0without calling the WASI import. The import declaration is kept so the module still loads. This is fragile and should not be necessary.