runtime,syscall,internal/poll,os: wasip1 poll_oneoff scheduler integration + net.FileListener#5386
runtime,syscall,internal/poll,os: wasip1 poll_oneoff scheduler integration + net.FileListener#5386achille-roussel wants to merge 2 commits into
Conversation
2dde177 to
9014ec4
Compare
…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.
7000e7b to
81b2957
Compare
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>
|
@dgryski @jakebailey any feedback on this change? |
|
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 |
|
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? |
|
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. |
|
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! |
|
Yes, sorry, don't take my joking seriously 😅 |
On wasip1 today every
syscall.Read/Writeblocks the entire wasm module — the cooperative scheduler invokespoll_oneoffonly for sleep/timer wakeups, and there's no path from thenetpackage to a working TCP server. This PR fixes both: it threadspoll_oneoffthrough the scheduler's idle path so a goroutine doing FD I/O parks instead of blocking the module, and it provides enoughinternal/poll/os/syscallsurface that upstream Go'snet.FileListener/net.FileConnworks on a host-pre-opened TCP socket.End to end:
Concurrent connections work too — multiple
ncclients hit the server back-to-back; both got accepted and echoed while the cooperative scheduler kept running goroutines parked onsock_recv. Thetcpecho.gosource is the minimal upstream-Go-idiomatic TCP echo server, no TinyGonetoverride required:Architecture
The cooperative scheduler's idle path now calls
poll_oneoffwith 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.Upstream net's
net.FileListener(f)flow plumbs through these layers:(*os.File).PollFD()returns a cached*poll.FDstored in a newpfd pollFDfield on the sharedfilestruct.pollFDis a per-target alias —*poll.FDon wasip1, literalstruct{}(zero bytes, non-trailing) on every other target.(*poll.FD).Copy()increments aSysFilerefcount sof.Close()andln.Close()cooperatively release the syscall FD.net/file_wasip1.gocallsfd_fdstat_get_type(linknamed into our syscall) to detectFILETYPE_SOCKET_STREAM.Listener.Accept→(*poll.FD).Accept→syscall.Accept→sock_acceptwasmimport. EAGAIN parks the goroutine via the runtime netpoll registry.Conn.Read/WriteseeisSocket() == true(cachedSysFile.Filetype) and dispatch tosock_recv/sock_senddirect wasmimports with park-on-EAGAIN + deadline support.Conn.ClosetriggersShutdown(→sock_shutdown) and refcount-awareClose.Non-goals (deferred)
net.Dial("tcp", ...),net.Listen("tcp", ...)— wasip1 has nosock_connect/bind/listen(onlysock_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.PacketConn— upstream'sfilePacketConnreturnsENOPROTOOPTon wasip1.FileListener/FileConnpaths bypass the resolver entirely.pollableresources, structurally different. Future PR.