From d9e08594c09b0fc32b1d7cc9a03260b70327a8d2 Mon Sep 17 00:00:00 2001 From: Achille Roussel Date: Mon, 11 May 2026 01:53:04 -0700 Subject: [PATCH] runtime,internal/poll,loader: wasip2 pollable poll-integration + TCP I/O MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors PR #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 7000e7be. - 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. --- loader/goroot.go | 25 +- src/internal/poll/errors_wasip.go | 24 ++ src/internal/poll/fd_wasip1.go | 15 - src/internal/poll/fd_wasip2.go | 436 ++++++++++++++++++++++ src/os/poll_link_wasip2.go | 11 + src/runtime/netpoll_wasip2.go | 267 +++++++++++++ src/runtime/runtime_wasip2.go | 5 - src/runtime/scheduler_idle_wasip2.go | 37 ++ src/runtime/scheduler_idle_wasip2_none.go | 24 ++ src/runtime/wait_other.go | 2 +- 10 files changed, 823 insertions(+), 23 deletions(-) create mode 100644 src/internal/poll/errors_wasip.go create mode 100644 src/internal/poll/fd_wasip2.go create mode 100644 src/os/poll_link_wasip2.go create mode 100644 src/runtime/netpoll_wasip2.go create mode 100644 src/runtime/scheduler_idle_wasip2.go create mode 100644 src/runtime/scheduler_idle_wasip2_none.go diff --git a/loader/goroot.go b/loader/goroot.go index 0aab0a0e13..30cae55232 100644 --- a/loader/goroot.go +++ b/loader/goroot.go @@ -16,6 +16,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "go/build" "io" "io/fs" "os" @@ -48,7 +49,7 @@ func GetCachedGoroot(config *compileopts.Config) (string, error) { overrides := pathsToOverride(config.GoMinorVersion, needsSyscallPackage(config.BuildTags())) // Resolve the merge links within the goroot. - merge, err := listGorootMergeLinks(goroot, tinygoroot, overrides) + merge, err := listGorootMergeLinks(goroot, tinygoroot, overrides, config) if err != nil { return "", err } @@ -143,10 +144,22 @@ func GetCachedGoroot(config *compileopts.Config) (string, error) { } // listGorootMergeLinks searches goroot and tinygoroot for all symlinks that must be created within the merged goroot. -func listGorootMergeLinks(goroot, tinygoroot string, overrides map[string]bool) (map[string]string, error) { +func listGorootMergeLinks(goroot, tinygoroot string, overrides map[string]bool, config *compileopts.Config) (map[string]string, error) { goSrc := filepath.Join(goroot, "src") tinygoSrc := filepath.Join(tinygoroot, "src") merges := make(map[string]string) + + // buildContext is used to evaluate //go:build constraints on TinyGo + // source files. A TinyGo file that doesn't match the current target + // (e.g. a *_wasip2.go file in a wasip1 build) must not be treated as + // "TinyGo owns this directory" — otherwise wasip1 builds would lose + // upstream Go files at the same level. + bctx := build.Default + bctx.GOOS = config.GOOS() + bctx.GOARCH = config.GOARCH() + bctx.BuildTags = config.BuildTags() + bctx.Compiler = "gc" + for dir, merge := range overrides { if !merge { // Use the TinyGo version. @@ -168,6 +181,14 @@ func listGorootMergeLinks(goroot, tinygoroot string, overrides map[string]bool) // Link this file. name := e.Name() + // Only count this file as a TinyGo override of the directory + // when its build tags match the current target. Files with a + // non-matching //go:build are invisible to this build anyway + // (the compiler would skip them), so they shouldn't cause + // upstream files to be dropped from the merge. + if matched, _ := bctx.MatchFile(tinygoDir, name); !matched { + continue + } merges[filepath.Join("src", dir, name)] = filepath.Join(tinygoDir, name) hasTinyGoFiles = true diff --git a/src/internal/poll/errors_wasip.go b/src/internal/poll/errors_wasip.go new file mode 100644 index 0000000000..e7c389fbc3 --- /dev/null +++ b/src/internal/poll/errors_wasip.go @@ -0,0 +1,24 @@ +//go:build wasip1 || wasip2 + +// Shared error sentinels for the wasip1 and wasip2 internal/poll +// implementations. The error values are part of the public API surface +// upstream net relies on; sharing them keeps fd_wasip1.go and +// fd_wasip2.go free of redundant declarations. + +package poll + +import "errors" + +// ErrFileClosing is returned when a Read or Write is started on a closed FD. +var ErrFileClosing = errors.New("use of closed file") + +// ErrNetClosing is returned for network operations on a closed FD. +var ErrNetClosing = errors.New("use of closed network connection") + +// ErrDeadlineExceeded is returned by Read/Write when a deadline expired. +// Matches the error returned by os.IsTimeout-style helpers. +var ErrDeadlineExceeded = errors.New("i/o timeout") + +// ErrNoDeadline is returned if SetDeadline is called on an FD whose +// underlying type does not support deadlines. +var ErrNoDeadline = errors.New("file type does not support deadline") diff --git a/src/internal/poll/fd_wasip1.go b/src/internal/poll/fd_wasip1.go index bfb0d4d007..31a93cf52e 100644 --- a/src/internal/poll/fd_wasip1.go +++ b/src/internal/poll/fd_wasip1.go @@ -20,27 +20,12 @@ package poll import ( - "errors" "internal/task" "syscall" "time" "unsafe" ) -// ErrFileClosing is returned when a Read or Write is started on a closed FD. -var ErrFileClosing = errors.New("use of closed file") - -// ErrNetClosing is returned for network operations on a closed FD. -var ErrNetClosing = errors.New("use of closed network connection") - -// ErrDeadlineExceeded is returned by Read/Write when a deadline expired. -// Matches the error returned by os.IsTimeout-style helpers. -var ErrDeadlineExceeded = errors.New("i/o timeout") - -// ErrNoDeadline is returned if SetDeadline is called on an FD whose -// underlying type does not support deadlines. -var ErrNoDeadline = errors.New("file type does not support deadline") - // pollMode constants must mirror runtime/netpoll_wasip1.go's pollRead/ // pollWrite values. const ( diff --git a/src/internal/poll/fd_wasip2.go b/src/internal/poll/fd_wasip2.go new file mode 100644 index 0000000000..630b3d4265 --- /dev/null +++ b/src/internal/poll/fd_wasip2.go @@ -0,0 +1,436 @@ +//go:build wasip2 + +// fd_wasip2.go is the wasip2 sibling of fd_wasip1.go: it backs net.TCPListener +// and net.TCPConn with the runtime's pollable-keyed netpoll registry (see +// runtime/netpoll_wasip2.go). The Sysfd of fd_wasip1.go is replaced with a +// triple of wasi resource handles: the tcp-socket itself plus, for an +// accepted/connected connection, the (input-stream, output-stream) pair. + +package poll + +import ( + "errors" + "internal/cm" + "internal/task" + "internal/wasi/io/v0.2.0/poll" + wasistreams "internal/wasi/io/v0.2.0/streams" + "internal/wasi/sockets/v0.2.0/instance-network" + wasinet "internal/wasi/sockets/v0.2.0/network" + wasitcp "internal/wasi/sockets/v0.2.0/tcp" + wasitcpcreate "internal/wasi/sockets/v0.2.0/tcp-create-socket" + "time" + "unsafe" +) + +// ErrFileClosingWasip2 distinguishes the wasip2 close error; reusing the +// wasip1 ErrFileClosing keeps callers in upstream net happy. + +//go:linkname runtime_netpoll_addpollable_wasip2 runtime.runtime_netpoll_addpollable_wasip2 +func runtime_netpoll_addpollable_wasip2(pollable uint32) uintptr + +//go:linkname runtime_netpoll_done_wasip2 runtime.runtime_netpoll_done_wasip2 +func runtime_netpoll_done_wasip2(pd uintptr) + +//go:linkname runtime_netpoll_pdfired_wasip2 runtime.runtime_netpoll_pdfired_wasip2 +func runtime_netpoll_pdfired_wasip2(pd uintptr) bool + +//go:linkname runtime_netpoll_wake_wasip2 runtime.runtime_netpoll_wake_wasip2 +func runtime_netpoll_wake_wasip2(pd uintptr) + +// network is the lazy-initialised wasi:sockets/instance-network handle. +// All TCP operations need a network; instance-network() returns the host's +// default network. We hold a single handle for the program's lifetime. +var ( + wasip2Network wasinet.Network + wasip2NetworkInit bool +) + +func wasip2GetNetwork() wasinet.Network { + if !wasip2NetworkInit { + wasip2Network = instancenetwork.InstanceNetwork() + wasip2NetworkInit = true + } + return wasip2Network +} + +// WasipNFD (named to avoid colliding with the wasip1 FD type that ships in +// the same package via fd_wasip1.go) is the wasip2 file descriptor wrapper. +// Each FD is either a listener (input/output zero-valued) or a connection +// (all three valid). +// +// Public field naming mirrors fd_wasip1.go where it makes sense; callers in +// src/net/*_wasip2.go construct it via the open / dial / listen helpers +// below rather than struct literal. +type WasipNFD struct { + socket wasitcp.TCPSocket + input wasistreams.InputStream + output wasistreams.OutputStream + isListener bool + closed bool + + rDeadline time.Time + wDeadline time.Time +} + +// errorCodeToError maps a wasi network ErrorCode into a Go error. +func errorCodeToError(c wasinet.ErrorCode) error { + if c == wasinet.ErrorCodeWouldBlock { + return errWasip2WouldBlock + } + return errors.New("wasip2 network: " + c.String()) +} + +var errWasip2WouldBlock = errors.New("would block") + +// DialTCPWasip2 creates a TCP socket, starts a connect to remote, parks +// until finish-connect succeeds, and returns the resulting connection FD. +func DialTCPWasip2(remoteIPv4 [4]byte, remotePort uint16) (*WasipNFD, error) { + sockRes := wasitcpcreate.CreateTCPSocket(wasinet.IPAddressFamilyIPv4) + if sockRes.IsErr() { + return nil, errorCodeToError(*sockRes.Err()) + } + sock := *sockRes.OK() + + addr := wasinet.IPSocketAddressIPv4(wasinet.IPv4SocketAddress{ + Port: remotePort, + Address: remoteIPv4, + }) + + startRes := sock.StartConnect(wasip2GetNetwork(), addr) + if startRes.IsErr() { + sock.ResourceDrop() + return nil, errorCodeToError(*startRes.Err()) + } + + for { + finRes := sock.FinishConnect() + if !finRes.IsErr() { + tup := finRes.OK() + return &WasipNFD{ + socket: sock, + input: tup.F0, + output: tup.F1, + }, nil + } + ec := *finRes.Err() + if ec != wasinet.ErrorCodeWouldBlock { + sock.ResourceDrop() + return nil, errorCodeToError(ec) + } + // park on the socket's pollable until connect completes + waitOnPollable(sock.Subscribe()) + } +} + +// ListenTCPWasip2 creates a TCP socket, binds it to localIPv4:localPort, +// puts it in listening mode, and returns the listener FD. +func ListenTCPWasip2(localIPv4 [4]byte, localPort uint16) (*WasipNFD, error) { + sockRes := wasitcpcreate.CreateTCPSocket(wasinet.IPAddressFamilyIPv4) + if sockRes.IsErr() { + return nil, errorCodeToError(*sockRes.Err()) + } + sock := *sockRes.OK() + + addr := wasinet.IPSocketAddressIPv4(wasinet.IPv4SocketAddress{ + Port: localPort, + Address: localIPv4, + }) + + if r := sock.StartBind(wasip2GetNetwork(), addr); r.IsErr() { + sock.ResourceDrop() + return nil, errorCodeToError(*r.Err()) + } + for { + r := sock.FinishBind() + if !r.IsErr() { + break + } + ec := *r.Err() + if ec != wasinet.ErrorCodeWouldBlock { + sock.ResourceDrop() + return nil, errorCodeToError(ec) + } + waitOnPollable(sock.Subscribe()) + } + + if r := sock.StartListen(); r.IsErr() { + sock.ResourceDrop() + return nil, errorCodeToError(*r.Err()) + } + for { + r := sock.FinishListen() + if !r.IsErr() { + break + } + ec := *r.Err() + if ec != wasinet.ErrorCodeWouldBlock { + sock.ResourceDrop() + return nil, errorCodeToError(ec) + } + waitOnPollable(sock.Subscribe()) + } + + return &WasipNFD{socket: sock, isListener: true}, nil +} + +// LocalAddr returns the bound local address of this FD (listener or +// connection). Returns nil if the wasi runtime doesn't surface it. +func (fd *WasipNFD) LocalAddr() (ip [4]byte, port uint16, ok bool) { + r := fd.socket.LocalAddress() + if r.IsErr() { + return ip, 0, false + } + addr := r.OK() + if v4 := addr.IPv4(); v4 != nil { + return v4.Address, v4.Port, true + } + return ip, 0, false +} + +// Accept blocks until an incoming connection is available, then returns +// the connection FD. Honours fd.rDeadline. +func (fd *WasipNFD) Accept() (*WasipNFD, error) { + if fd.closed { + return nil, ErrFileClosing + } + deadline := fd.rDeadline + for { + if !deadline.IsZero() && !time.Now().Before(deadline) { + return nil, ErrDeadlineExceeded + } + r := fd.socket.Accept() + if !r.IsErr() { + tup := r.OK() + return &WasipNFD{ + socket: tup.F0, + input: tup.F1, + output: tup.F2, + }, nil + } + ec := *r.Err() + if ec != wasinet.ErrorCodeWouldBlock { + return nil, errorCodeToError(ec) + } + if deadline.IsZero() { + waitOnPollable(fd.socket.Subscribe()) + } else { + if err := waitOnPollableUntil(fd.socket.Subscribe(), deadline); err != nil { + return nil, err + } + } + } +} + +// Read reads from the connection's input stream. Returns (0, nil) on EOF. +func (fd *WasipNFD) Read(p []byte) (int, error) { + if fd.closed { + return 0, ErrFileClosing + } + if len(p) == 0 { + return 0, nil + } + if fd.isListener { + return 0, errors.New("read on listener FD") + } + deadline := fd.rDeadline + for { + if !deadline.IsZero() && !time.Now().Before(deadline) { + return 0, ErrDeadlineExceeded + } + r := fd.input.Read(uint64(len(p))) + if r.IsErr() { + se := r.Err() + if se.Closed() { + return 0, nil // EOF + } + return 0, errors.New("wasip2 stream read failed") + } + data := r.OK().Slice() + if len(data) > 0 { + n := copy(p, data) + return n, nil + } + // No data available — park on the input stream's pollable. + if deadline.IsZero() { + waitOnPollable(fd.input.Subscribe()) + } else { + if err := waitOnPollableUntil(fd.input.Subscribe(), deadline); err != nil { + return 0, err + } + } + } +} + +// Write writes p to the connection's output stream. Loops until all of p +// is written or an error occurs. Honours fd.wDeadline. +func (fd *WasipNFD) Write(p []byte) (int, error) { + if fd.closed { + return 0, ErrFileClosing + } + if fd.isListener { + return 0, errors.New("write on listener FD") + } + deadline := fd.wDeadline + var nn int + for nn < len(p) { + if !deadline.IsZero() && !time.Now().Before(deadline) { + return nn, ErrDeadlineExceeded + } + cw := fd.output.CheckWrite() + if cw.IsErr() { + se := cw.Err() + if se.Closed() { + return nn, errors.New("wasip2 stream closed") + } + return nn, errors.New("wasip2 stream write check failed") + } + canWrite := uint64(*cw.OK()) + if canWrite == 0 { + if deadline.IsZero() { + waitOnPollable(fd.output.Subscribe()) + } else { + if err := waitOnPollableUntil(fd.output.Subscribe(), deadline); err != nil { + return nn, err + } + } + continue + } + chunk := uint64(len(p) - nn) + if chunk > canWrite { + chunk = canWrite + } + wr := fd.output.Write(cm.ToList(p[nn : nn+int(chunk)])) + if wr.IsErr() { + se := wr.Err() + if se.Closed() { + return nn, errors.New("wasip2 stream closed") + } + return nn, errors.New("wasip2 stream write failed") + } + nn += int(chunk) + } + return nn, nil +} + +// Close drops all wasi resources held by the FD. Idempotent in the sense +// that a second call returns ErrFileClosing without re-dropping (resource +// drop is once-only in the component model). +func (fd *WasipNFD) Close() error { + if fd.closed { + return ErrFileClosing + } + fd.closed = true + // Drop streams first (they reference the socket). + var zeroIn wasistreams.InputStream + if fd.input != zeroIn { + fd.input.ResourceDrop() + } + var zeroOut wasistreams.OutputStream + if fd.output != zeroOut { + fd.output.ResourceDrop() + } + fd.socket.ResourceDrop() + return nil +} + +func (fd *WasipNFD) SetDeadline(t time.Time) error { + fd.rDeadline = t + fd.wDeadline = t + return nil +} + +func (fd *WasipNFD) SetReadDeadline(t time.Time) error { + fd.rDeadline = t + return nil +} + +func (fd *WasipNFD) SetWriteDeadline(t time.Time) error { + fd.wDeadline = t + return nil +} + +// waitOnPollable transfers ownership of the pollable to the runtime +// registry, parks the current goroutine, and returns when the runtime +// drops the pollable + wakes us. +func waitOnPollable(p poll.Pollable) { + handle := cm.Reinterpret[uint32](p) + pd := runtime_netpoll_addpollable_wasip2(handle) + task.Pause() + runtime_netpoll_done_wasip2(pd) +} + +// waitOnPollableUntil parks on the pollable but arms a time.AfterFunc that +// wakes the task if the deadline expires first. Mirrors the wasip1 parkUntil +// pattern from fd_wasip1.go. +func waitOnPollableUntil(p poll.Pollable, deadline time.Time) error { + d := time.Until(deadline) + if d <= 0 { + // Don't even register: drop the pollable and report timeout. + p.ResourceDrop() + return ErrDeadlineExceeded + } + handle := cm.Reinterpret[uint32](p) + pd := runtime_netpoll_addpollable_wasip2(handle) + timer := time.AfterFunc(d, func() { + runtime_netpoll_wake_wasip2(pd) + }) + task.Pause() + timer.Stop() + runtime_netpoll_done_wasip2(pd) + return nil +} + +// Linkname-friendly wrappers around the WasipNFD methods. They use +// uintptr for the FD pointer so callers can hold the FD via a raw +// handle without needing the WasipNFD type in scope (the type itself +// can't easily be linknamed). Used by tests / future net package code. +// +//go:linkname Wasip2TCPListen +func Wasip2TCPListen(ipv4 [4]byte, port uint16) (uintptr, error) { + fd, err := ListenTCPWasip2(ipv4, port) + if err != nil { + return 0, err + } + return uintptr(unsafe.Pointer(fd)), nil +} + +//go:linkname Wasip2TCPDial +func Wasip2TCPDial(ipv4 [4]byte, port uint16) (uintptr, error) { + fd, err := DialTCPWasip2(ipv4, port) + if err != nil { + return 0, err + } + return uintptr(unsafe.Pointer(fd)), nil +} + +//go:linkname Wasip2TCPAccept +func Wasip2TCPAccept(listener uintptr) (uintptr, error) { + fd := (*WasipNFD)(unsafe.Pointer(listener)) + accepted, err := fd.Accept() + if err != nil { + return 0, err + } + return uintptr(unsafe.Pointer(accepted)), nil +} + +//go:linkname Wasip2TCPRead +func Wasip2TCPRead(conn uintptr, p []byte) (int, error) { + fd := (*WasipNFD)(unsafe.Pointer(conn)) + return fd.Read(p) +} + +//go:linkname Wasip2TCPWrite +func Wasip2TCPWrite(conn uintptr, p []byte) (int, error) { + fd := (*WasipNFD)(unsafe.Pointer(conn)) + return fd.Write(p) +} + +//go:linkname Wasip2TCPClose +func Wasip2TCPClose(fd uintptr) error { + return (*WasipNFD)(unsafe.Pointer(fd)).Close() +} + +//go:linkname Wasip2TCPSetDeadline +func Wasip2TCPSetDeadline(fd uintptr, t time.Time) error { + return (*WasipNFD)(unsafe.Pointer(fd)).SetDeadline(t) +} diff --git a/src/os/poll_link_wasip2.go b/src/os/poll_link_wasip2.go new file mode 100644 index 0000000000..7b2ba2e170 --- /dev/null +++ b/src/os/poll_link_wasip2.go @@ -0,0 +1,11 @@ +//go:build wasip2 + +package os + +import ( + // Pulls internal/poll into the build for wasip2 so its TCP/pollable + // surface (WasipNFD, DialTCPWasip2, ListenTCPWasip2) is linkable from + // user code via //go:linkname. Once wasip2 net.Listen/Dial land in + // the stdlib this blank import will be replaced by a real consumer. + _ "internal/poll" +) diff --git a/src/runtime/netpoll_wasip2.go b/src/runtime/netpoll_wasip2.go new file mode 100644 index 0000000000..ad33243757 --- /dev/null +++ b/src/runtime/netpoll_wasip2.go @@ -0,0 +1,267 @@ +//go:build wasip2 && (scheduler.tasks || scheduler.asyncify) + +package runtime + +import ( + "internal/cm" + "internal/task" + monotonicclock "internal/wasi/clocks/v0.2.0/monotonic-clock" + "internal/wasi/io/v0.2.0/poll" + "unsafe" +) + +// pollMode is unused on wasip2 (each pollable encodes its own direction at +// subscribe time — InputStream.Subscribe vs OutputStream.Subscribe vs +// TcpSocket.Subscribe). Kept for API parity with the wasip1 netpoll +// callers that pass a mode constant. +type pollMode uint8 + +const ( + pollRead pollMode = 1 + pollWrite pollMode = 2 +) + +// pollDesc tracks one parked goroutine waiting for a wasi pollable to +// become ready. It owns the pollable handle and is responsible for +// dropping it (either inside pollIO when the pollable fires, or via +// netpollDone for an unfired desc). +type pollDesc struct { + pollable uint32 // poll.Pollable resource handle (cm.Resource = uint32) + fired bool + task *task.Task + bnxt *pollDesc +} + +var ( + activePolls *pollDesc + pollCount int + + // Scratch buffers for the next Poll() call. Grown on demand, never + // shrunk — the working set settles on a stable max. + pollList []poll.Pollable // pollables passed to Poll + pollDescList []*pollDesc // parallel scratch: same indices as pollList; nil entry == clock pollable + pollResult cm.List[uint32] // index list returned by Poll +) + +// netpollAddPollable registers the calling goroutine's interest in a +// pollable and returns a descriptor identifying the wait. The caller +// transfers ownership of the pollable to the runtime: pollIO drops it +// when it fires; netpollDone drops it when the caller gives up. +// +// The caller must: +// +// 1. call task.Pause() to suspend until the pollable is ready (or the +// task is woken for some other reason — timer, manual scheduleTask), +// and +// 2. call netpollDone(pd) after Pause returns. +func netpollAddPollable(p uint32) *pollDesc { + pd := &pollDesc{ + pollable: p, + task: task.Current(), + bnxt: activePolls, + } + activePolls = pd + pollCount++ + return pd +} + +// netpollDone deregisters pd. If the pollable hasn't fired yet, its +// resource is dropped. Idempotent. +func netpollDone(pd *pollDesc) { + if pd.fired { + // pollIO already dropped the pollable and unlinked the desc. + return + } + pp := &activePolls + for *pp != nil { + if *pp == pd { + *pp = pd.bnxt + pd.bnxt = nil + pollCount-- + pd.fired = true + (poll.Pollable)(cm.Reinterpret[poll.Pollable](pd.pollable)).ResourceDrop() + return + } + pp = &(*pp).bnxt + } +} + +// pollIO is the cooperative scheduler's blocking wait on wasip2. It +// invokes wasi:io/poll.Poll with one pollable per active pollDesc, plus +// optionally a clock pollable. +// +// timeoutNs > 0 : subscribe a fresh monotonic-clock pollable with this +// duration; include it in the Poll list. +// timeoutNs == 0 : non-blocking poll — use Pollable.Ready() on each +// registered pollable. Wake any that are ready; +// return without calling Poll. +// timeoutNs < 0 : block until any FD pollable fires. Caller must ensure +// pollCount > 0 — Poll with zero pollables would +// block forever with no way out. +func pollIO(timeoutNs int64) { + addClock := timeoutNs > 0 + if !addClock && pollCount == 0 { + // Non-blocking poll with nothing to check, or block-forever with + // nothing to block on — caller should have caught this. + return + } + + if timeoutNs == 0 { + // Non-blocking fast path: Ready() each registered pollable and + // wake any that are already ready. + pp := &activePolls + for *pp != nil { + pd := *pp + pollable := cm.Reinterpret[poll.Pollable](pd.pollable) + if pollable.Ready() { + *pp = pd.bnxt + pd.bnxt = nil + pollCount-- + pd.fired = true + pollable.ResourceDrop() + runqueue.Push(pd.task) + continue + } + pp = &(*pp).bnxt + } + return + } + + // Build pollList in this order: [clock?, active pollables...]. The + // pollDescList parallel slice maps each index back to its pd (nil for + // the clock). + n := pollCount + if addClock { + n++ + } + if cap(pollList) < n { + pollList = make([]poll.Pollable, n) + pollDescList = make([]*pollDesc, n) + } else { + pollList = pollList[:n] + pollDescList = pollDescList[:n] + } + + i := 0 + var clockPollable poll.Pollable + if addClock { + clockPollable = monotonicclock.SubscribeDuration(monotonicclock.Duration(timeoutNs)) + pollList[i] = clockPollable + pollDescList[i] = nil + i++ + } + for pd := activePolls; pd != nil; pd = pd.bnxt { + pollList[i] = cm.Reinterpret[poll.Pollable](pd.pollable) + pollDescList[i] = pd + i++ + } + + pollResult = poll.Poll(cm.ToList(pollList)) + + // Walk the returned indices. Any pd that fired is unlinked + woken; + // its pollable is dropped. The clock pollable is always dropped at + // the end of this call, fired or not. + for _, idx := range pollResult.Slice() { + if int(idx) >= len(pollDescList) { + continue // defensive + } + pd := pollDescList[idx] + if pd == nil { + // Clock pollable fired — drop happens unconditionally below. + continue + } + if pd.fired { + continue + } + pd.fired = true + // Unlink from activePolls. + pp := &activePolls + for *pp != nil { + if *pp == pd { + *pp = pd.bnxt + pd.bnxt = nil + pollCount-- + break + } + pp = &(*pp).bnxt + } + cm.Reinterpret[poll.Pollable](pd.pollable).ResourceDrop() + runqueue.Push(pd.task) + } + if addClock { + // Drop the clock pollable whether or not it fired — it was + // freshly subscribed for this call only. + clockPollable.ResourceDrop() + } + + // Clear pointers in the scratch slices so we don't pin pollDescs in + // memory between calls. + for i := range pollDescList { + pollDescList[i] = nil + } +} + +// runtime_netpoll_addpollable_wasip2 is the linkname target used by +// internal/poll and other stdlib callers that hold a poll.Pollable handle +// (as a raw uint32) and want to park the current goroutine until it +// becomes ready. Returns an opaque uintptr (the pollDesc pointer); +// pass it back to runtime_netpoll_done_wasip2. +// +//go:linkname runtime_netpoll_addpollable_wasip2 +func runtime_netpoll_addpollable_wasip2(pollable uint32) uintptr { + return uintptr(unsafe.Pointer(netpollAddPollable(pollable))) +} + +// runtime_netpoll_done_wasip2 releases a pollDesc previously returned by +// runtime_netpoll_addpollable_wasip2. Idempotent. +// +//go:linkname runtime_netpoll_done_wasip2 +func runtime_netpoll_done_wasip2(pd uintptr) { + if pd == 0 { + return + } + netpollDone((*pollDesc)(unsafe.Pointer(pd))) +} + +// runtime_netpoll_pdfired_wasip2 reports whether the given pollDesc has +// already been woken. Used by deadline-driven cancellation paths to +// avoid double-waking a task. +// +//go:linkname runtime_netpoll_pdfired_wasip2 +func runtime_netpoll_pdfired_wasip2(pd uintptr) bool { + if pd == 0 { + return true + } + return (*pollDesc)(unsafe.Pointer(pd)).fired +} + +// runtime_netpoll_wake_wasip2 wakes the task parked on pd from outside +// the Poll event loop — for example, from a deadline timer's callback. +// Idempotent: a second call (or a race with pollIO firing the same pd) +// is a no-op thanks to the pd.fired flag. +// +// wasip2 is single-threaded so we don't need atomic ops here. +// +//go:linkname runtime_netpoll_wake_wasip2 +func runtime_netpoll_wake_wasip2(pd uintptr) { + if pd == 0 { + return + } + p := (*pollDesc)(unsafe.Pointer(pd)) + if p.fired { + return + } + p.fired = true + pp := &activePolls + for *pp != nil { + if *pp == p { + *pp = p.bnxt + p.bnxt = nil + pollCount-- + break + } + pp = &(*pp).bnxt + } + cm.Reinterpret[poll.Pollable](p.pollable).ResourceDrop() + runqueue.Push(p.task) +} diff --git a/src/runtime/runtime_wasip2.go b/src/runtime/runtime_wasip2.go index 46ce3d853b..de18ad6f45 100644 --- a/src/runtime/runtime_wasip2.go +++ b/src/runtime/runtime_wasip2.go @@ -42,11 +42,6 @@ func nanosecondsToTicks(ns int64) timeUnit { return timeUnit(ns) } -func sleepTicks(d timeUnit) { - p := monotonicclock.SubscribeDuration(monotonicclock.Duration(d)) - p.Block() -} - func ticks() timeUnit { return timeUnit(monotonicclock.Now()) } diff --git a/src/runtime/scheduler_idle_wasip2.go b/src/runtime/scheduler_idle_wasip2.go new file mode 100644 index 0000000000..9ce6d737c2 --- /dev/null +++ b/src/runtime/scheduler_idle_wasip2.go @@ -0,0 +1,37 @@ +//go:build wasip2 && (scheduler.tasks || scheduler.asyncify) + +package runtime + +import ( + monotonicclock "internal/wasi/clocks/v0.2.0/monotonic-clock" +) + +// sleepTicks is the cooperative scheduler's "wait until the next deadline" +// primitive on wasip2. It is only called by the scheduler when the run queue +// is empty and there's a sleeping task or pending timer due in d ticks. +// +// If any pollables are registered via netpollAddPollable, this routes through +// pollIO so the same wasi:io/poll.Poll call observes both the clock +// subscription and the registered pollables. With no pollables it falls +// back to the cheap monotonic-clock-Block path. +func sleepTicks(d timeUnit) { + if pollCount > 0 { + pollIO(ticksToNanoseconds(d)) + return + } + p := monotonicclock.SubscribeDuration(monotonicclock.Duration(d)) + p.Block() + p.ResourceDrop() +} + +// waitForEvents is the cooperative scheduler's "wait until something external +// happens" primitive. It is only called when both the run queue and the +// timer/sleep queues are empty. With no pollables registered this is a +// genuine deadlock; with pollables we block until any of them is ready. +func waitForEvents() { + if pollCount > 0 { + pollIO(-1) + return + } + runtimePanic("deadlocked: no event source") +} diff --git a/src/runtime/scheduler_idle_wasip2_none.go b/src/runtime/scheduler_idle_wasip2_none.go new file mode 100644 index 0000000000..e80e9c9e45 --- /dev/null +++ b/src/runtime/scheduler_idle_wasip2_none.go @@ -0,0 +1,24 @@ +//go:build wasip2 && !scheduler.tasks && !scheduler.asyncify + +package runtime + +import ( + monotonicclock "internal/wasi/clocks/v0.2.0/monotonic-clock" +) + +// sleepTicks blocks the current execution context for d ticks. This is the +// fallback used when no cooperative scheduler is configured on wasip2 — it +// has no pollable-polling integration, see scheduler_idle_wasip2.go for the +// cooperative variant. +func sleepTicks(d timeUnit) { + p := monotonicclock.SubscribeDuration(monotonicclock.Duration(d)) + p.Block() + p.ResourceDrop() +} + +// waitForEvents is only meaningful when there's an event source available. +// Without the cooperative scheduler running poll on registered pollables, +// wasip2 has nothing to wake on, so this is a hard deadlock. +func waitForEvents() { + runtimePanic("deadlocked: no event source") +} diff --git a/src/runtime/wait_other.go b/src/runtime/wait_other.go index ebac63d743..d307903ba6 100644 --- a/src/runtime/wait_other.go +++ b/src/runtime/wait_other.go @@ -1,4 +1,4 @@ -//go:build !tinygo.riscv && !cortexm && !(linux && !baremetal && !tinygo.wasm && !nintendoswitch) && !darwin && !wasip1 +//go:build !tinygo.riscv && !cortexm && !(linux && !baremetal && !tinygo.wasm && !nintendoswitch) && !darwin && !wasip1 && !wasip2 package runtime