Skip to content

runtime,syscall,internal/poll,os: wasip1 poll_oneoff scheduler integration + net.FileListener#5386

Open
achille-roussel wants to merge 2 commits into
tinygo-org:devfrom
achille-roussel:wasip1-net
Open

runtime,syscall,internal/poll,os: wasip1 poll_oneoff scheduler integration + net.FileListener#5386
achille-roussel wants to merge 2 commits into
tinygo-org:devfrom
achille-roussel:wasip1-net

Conversation

@achille-roussel
Copy link
Copy Markdown
Contributor

On wasip1 today every syscall.Read/Write blocks the entire wasm module — the cooperative scheduler invokes poll_oneoff only for sleep/timer wakeups, and there's no path from the net package to a working TCP server. This PR fixes both: it threads poll_oneoff through the scheduler's idle path so a goroutine doing FD I/O parks instead of blocking the module, and it provides enough internal/poll / os / syscall surface that upstream Go's net.FileListener / net.FileConn works on a host-pre-opened TCP socket.

End to end:

$ tinygo build -target=wasip1 -o tcpecho.wasm ./tcpecho.go
$ wasmtime run -Spreview2=false -Stcplisten=127.0.0.1:9999 ./tcpecho.wasm &
listening on FD 3

$ echo hello | nc 127.0.0.1 9999
hello                                  # echoed by the wasm

Concurrent connections work too — multiple nc clients hit the server back-to-back; both got accepted and echoed while the cooperative scheduler kept running goroutines parked on sock_recv. The tcpecho.go source is the minimal upstream-Go-idiomatic TCP echo server, no TinyGo net override required:

f := os.NewFile(3, "tcplisten")
ln, _ := net.FileListener(f)
for {
    c, _ := ln.Accept()
    go func(c net.Conn) { defer c.Close(); io.Copy(c, c) }(c)
}

Architecture

The cooperative scheduler's idle path now calls poll_oneoff with one combined subscription array: a clock subscription for the next timer/sleep deadline, plus one FD subscription per goroutine that's parked waiting on I/O.

syscall.Read / Write   ─EAGAIN─►  internal/poll registry  ─►  task.Pause()
                                          │
                                          ▼
                    scheduler idle  ──►  pollIO(timeoutNs)
                                          │
                                          ├─ build subs: [clock, fd1, fd2, …]
                                          ├─ poll_oneoff(...)
                                          └─ wake matched tasks → run queue

Upstream net's net.FileListener(f) flow plumbs through these layers:

  1. (*os.File).PollFD() returns a cached *poll.FD stored in a new pfd pollFD field on the shared file struct. pollFD is a per-target alias — *poll.FD on wasip1, literal struct{} (zero bytes, non-trailing) on every other target.
  2. (*poll.FD).Copy() increments a SysFile refcount so f.Close() and ln.Close() cooperatively release the syscall FD.
  3. Upstream net/file_wasip1.go calls fd_fdstat_get_type (linknamed into our syscall) to detect FILETYPE_SOCKET_STREAM.
  4. Listener.Accept(*poll.FD).Acceptsyscall.Acceptsock_accept wasmimport. EAGAIN parks the goroutine via the runtime netpoll registry.
  5. Conn.Read/Write see isSocket() == true (cached SysFile.Filetype) and dispatch to sock_recv / sock_send direct wasmimports with park-on-EAGAIN + deadline support.
  6. Conn.Close triggers Shutdown (→ sock_shutdown) and refcount-aware Close.

Non-goals (deferred)

  • net.Dial("tcp", ...), net.Listen("tcp", ...) — wasip1 has no sock_connect / bind / listen (only sock_accept / recv / send / shutdown). Outbound TCP requires the wasi-sockets proposal (preview2+) or a runtime-specific extension. The relevant stubs return ENOSYS so callers see a clean error.
  • UDP / PacketConn — upstream's filePacketConn returns ENOPROTOOPT on wasip1.
  • DNS resolution — FileListener / FileConn paths bypass the resolver entirely.
  • wasip2 — uses pollable resources, structurally different. Future PR.

@achille-roussel achille-roussel force-pushed the wasip1-net branch 2 times, most recently from 2dde177 to 9014ec4 Compare May 11, 2026 07:48
Comment thread src/sync/map.go
achille-roussel and others added 2 commits May 11, 2026 16:08
…ation + net.FileListener

On wasip1 today every syscall.Read/Write blocks the entire wasm module — the
cooperative scheduler invokes poll_oneoff only for sleep/timer wakeups, and
there's no path from the net package to a working TCP server. This change
fixes both: it threads poll_oneoff through the scheduler's idle path so a
goroutine doing FD I/O parks instead of blocking the module, and it provides
enough internal/poll / os / syscall surface that upstream Go's
net.FileListener / net.FileConn works on a host-pre-opened TCP socket.

End to end:

  $ tinygo build -target=wasip1 -o tcpecho.wasm ./tcpecho.go
  $ wasmtime run -Spreview2=false -Stcplisten=127.0.0.1:9999 ./tcpecho.wasm &
  listening on FD 3

  $ echo hello | nc 127.0.0.1 9999
  hello                                  # echoed by the wasm

Concurrent connections work too — multiple nc clients hit the server back-to-
back; both got accepted and echoed while the cooperative scheduler kept
running goroutines parked on sock_recv. The tcpecho.go source is the minimal
upstream-Go-idiomatic TCP echo server, no TinyGo net override required:

  f := os.NewFile(3, "tcplisten")
  ln, _ := net.FileListener(f)
  for {
      c, _ := ln.Accept()
      go func(c net.Conn) { defer c.Close(); io.Copy(c, c) }(c)
  }

Architecture:

The cooperative scheduler's idle path now calls poll_oneoff with one combined
subscription array: a clock subscription for the next timer/sleep deadline,
plus one FD subscription per goroutine that's parked waiting on I/O.

  syscall.Read/Write ─EAGAIN─► internal/poll registry ─► task.Pause()
                                       │
                                       ▼
                  scheduler idle  ──►  pollIO(timeoutNs)
                                       │
                                       ├─ build subs: [clock, fd1, fd2, …]
                                       ├─ poll_oneoff(...)
                                       └─ wake matched tasks → run queue

Upstream net's net.FileListener(f) flow plumbs through these layers:

  1. (*os.File).PollFD() returns a cached *poll.FD stored in a new
     pfd pollFD field on the shared file struct. pollFD is a per-target
     alias — *poll.FD on wasip1, literal struct{} (zero bytes, non-
     trailing) on every other target.
  2. (*poll.FD).Copy() increments a SysFile refcount so f.Close() and
     ln.Close() cooperatively release the syscall FD.
  3. Upstream net/file_wasip1.go calls fd_fdstat_get_type (linknamed into
     our syscall) to detect FILETYPE_SOCKET_STREAM.
  4. Listener.Accept → (*poll.FD).Accept → syscall.Accept → sock_accept
     wasmimport. EAGAIN parks the goroutine via the runtime netpoll
     registry.
  5. Conn.Read/Write see isSocket() == true (cached SysFile.Filetype) and
     dispatch to sock_recv / sock_send direct wasmimports with park-on-
     EAGAIN + deadline support.
  6. Conn.Close triggers Shutdown (→ sock_shutdown) and refcount-aware
     Close.

Non-goals (deferred):

- net.Dial("tcp", ...), net.Listen("tcp", ...) — wasip1 has no
  sock_connect / bind / listen (only sock_accept / recv / send /
  shutdown). Outbound TCP requires the wasi-sockets proposal (preview2+)
  or a runtime-specific extension. The relevant stubs return ENOSYS so
  callers see a clean error.
- UDP / PacketConn — upstream's filePacketConn returns ENOPROTOOPT on
  wasip1.
- DNS resolution — FileListener / FileConn paths bypass the resolver
  entirely.
- wasip2 — uses pollable resources, structurally different. Future PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TestBinarySize/hifive1b/examples/echo regressed by 32 bytes after the
previous commit routed the scheduler's idle wait through a
schedulerIdleWait helper. The extra call frame + branch landed on every
non-wasip1 cooperative target, where the original direct sleepTicks /
waitForEvents calls compile to a single inlined call.

Push the wasip1-specific FD-polling logic down into the wasip1 sleepTicks
/ waitForEvents definitions themselves and revert scheduler_cooperative.go
to its pre-PR shape. Non-wasip1 targets now produce the exact same code
they did before this PR. On wasip1, sleepTicks routes through pollIO
when FD waiters are registered (unchanged behavior, just relocated).

- scheduler_cooperative.go: back to sleepTicks / waitForEvents.
- scheduler_idle_wasip1.go: defines the integrated sleepTicks +
  waitForEvents for wasip1 cooperative builds.
- scheduler_idle_wasip1_none.go: fallback definitions for wasip1 builds
  that don't use the cooperative scheduler (-scheduler=none/threads).
- runtime_wasip1.go: drop the now-relocated sleepTicks.
- wait_other.go: exclude wasip1 (covered by the two files above).
- scheduler_idle_other.go: removed; no longer needed.
achille-roussel added a commit to achille-roussel/tinygo that referenced this pull request May 11, 2026
Mirrors PR tinygo-org#5386's wasip1 work for wasip2. The cooperative scheduler's
idle path now calls wasi:io/poll.Poll over a combined list of (clock
pollable, registered pollables) instead of blocking the wasm module on
a single monotonic-clock subscription, so goroutines doing TCP I/O can
park while the scheduler runs other goroutines.

Plumbing components:

- runtime/netpoll_wasip2.go: pollable-keyed pollDesc registry; pollIO
  builds one combined wasi:io/poll.Poll call (clock pollable + active
  pollables). Linkname-exposed runtime_netpoll_addpollable_wasip2 /
  done / pdfired / wake for internal/poll and future net.
- runtime/scheduler_idle_wasip2.go + scheduler_idle_wasip2_none.go:
  cooperative-variant sleepTicks / waitForEvents that route through
  pollIO; non-coop fallback uses monotonicclock.Block. Mirrors the
  wasip1 structure introduced in 7000e7b.
- runtime/runtime_wasip2.go: sleepTicks moved out to the
  scheduler_idle_wasip2*.go files.
- runtime/wait_other.go: build tag tightened to exclude wasip2.

internal/poll surface:

- internal/poll/fd_wasip2.go: WasipNFD wraps a (TcpSocket, InputStream,
  OutputStream) triple. DialTCPWasip2, ListenTCPWasip2, Accept, Read,
  Write, Close, SetDeadline*. Each blocking op tries the wasi call,
  on would-block subscribes, parks, retries — same pattern as the
  wasip1 internal/poll.FD but pollable-keyed. Linkname-friendly
  Wasip2TCP{Listen,Dial,Accept,Read,Write,Close,SetDeadline} wrappers
  for test / future net callers.
- internal/poll/errors_wasip.go: ErrFileClosing / ErrNetClosing /
  ErrDeadlineExceeded / ErrNoDeadline extracted from fd_wasip1.go to
  a wasip1||wasip2 shared file.

Loader change:

- loader/goroot.go: listGorootMergeLinks now filters TinyGo files by
  //go:build constraints (via go/build.Context.MatchFile) before
  deciding "TinyGo owns this directory". Files that don't match the
  current target no longer cause upstream Go files at the same level
  to be dropped. Unblocks per-target overrides in directories like
  src/net/ for future net.wasip2 work without disturbing wasip1.

End-to-end verification:

  $ wasmtime run -Sinherit-network -Stcp ./tcpecho_wasip2.wasm &
  listening on 127.0.0.1:9999
  tick 1
  tick 2
  tick 3
  $ echo hello | nc 127.0.0.1 9999
  hello                                    # echoed by the wasm
  $ # two concurrent clients echo cleanly while ticker keeps ticking

The test program (not shipped) uses //go:linkname to drive the
internal/poll TCP helpers directly, since TinyGo doesn't yet have a
net.Listen / net.Dial path on wasip2 (upstream Go's net doesn't build
for wasip2 due to cgo_linux.go reaching for Linux headers). The
src/net/ wasip2 wrappers are out of scope for this PR and tracked as
follow-up — once they land, callers will use net.Listen / Dial
directly and the linkname wrappers can drop.

Wasip1 regression sweep: tcpecho.wasm still passes; time.Sleep / parkfile
/ parksynth unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@achille-roussel achille-roussel changed the title runtime,syscall,internal/poll,os,sync: wasip1 poll_oneoff scheduler integration + net.FileListener runtime,syscall,internal/poll,os: wasip1 poll_oneoff scheduler integration + net.FileListener May 11, 2026
@achille-roussel
Copy link
Copy Markdown
Contributor Author

@dgryski @jakebailey any feedback on this change?

@jakebailey
Copy link
Copy Markdown
Member

I don't think I have the required experience here, though I have a bad feeling that these wasip1 calls might not be fully implemented on some hosts and break people running stuff in a browser, e.g. https://github.com/bjorn3/browser_wasi_shim/blob/b068ec2c22d68581c48f2592f8cca1681bf71a98/src/wasi.ts#L837

@achille-roussel
Copy link
Copy Markdown
Contributor Author

poll_oneoff is part of the standard (and frozen) wasip1 spec, wazero and wasmtime implement them, and big-Go relies on it as well.

If some random wasip1 port decided not to be spec-compilant, that's unfortunate but maybe better to fix those than limit TinyGo, what do you think?

@jakebailey
Copy link
Copy Markdown
Member

You asked for my feedback! 😄

If BigGo does it, then fine; that's what I use with the above library anyway, so I must be avoiding using net anyway.

@achille-roussel
Copy link
Copy Markdown
Contributor Author

I didn't mean to sound like I discarded your feedback, you called out the right risk here, I just wanted to share more info that I had to make sure we were aligned, thanks for calling it out!

@jakebailey
Copy link
Copy Markdown
Member

Yes, sorry, don't take my joking seriously 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants