Skip to content

runtime: crash in WASM plugins without open file descriptors due to poll_oneoff call #78513

Description

@agirbal

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugReportIssues describing a possible bug in the Go implementation.FeatureRequestIssues asking for a new feature that does not need a proposal.FixPendingIssues that have a fix which has not yet been reviewed or submitted.arch-wasmWebAssembly issuescompiler/runtimeIssues related to the Go compiler and/or runtime.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    In Progress

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions