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

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.

* runtime: keep scheduler_cooperative idle-wait calls direct

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.
This commit is contained in:
Achille
2026-05-26 01:12:55 -07:00
committed by GitHub
parent 85d223c5e2
commit d01a932201
17 changed files with 1404 additions and 51 deletions
+230
View File
@@ -0,0 +1,230 @@
//go:build wasip1 && (scheduler.tasks || scheduler.asyncify)
package runtime
import (
"internal/task"
"unsafe"
)
// pollMode identifies the I/O direction a goroutine is waiting on.
// Zero is intentionally invalid so an uninitialized pollDesc cannot
// silently look like a read waiter.
type pollMode uint8
const (
pollRead pollMode = 1
pollWrite pollMode = 2
)
// pollDesc tracks one parked goroutine waiting for an FD to become ready.
// It is created by netpollAddWait, kept alive by activePolls, and freed
// (eventually GC'd) once unlinked.
type pollDesc struct {
fd uint32
mode pollMode
fired bool // set by pollIO when the wait is satisfied; netpollDone uses this for idempotency
task *task.Task
bnxt *pollDesc // chain in activePolls
}
var (
// activePolls is the singly-linked list of all currently-parked FD
// waiters. wasip1 is single-threaded — every mutation happens from
// the running goroutine or the scheduler loop, never both.
activePolls *pollDesc
pollCount int
// Scratch buffers for poll_oneoff. Grown on demand, never shrunk —
// the working set settles on a stable max.
pollSubs []__wasi_subscription_t
pollEvents []__wasi_event_t
)
// netpollAddWait registers the calling goroutine's interest in fd / mode
// and returns a descriptor identifying the wait. The caller must:
//
// 1. call task.Pause() to suspend until the FD is ready (or the task is
// woken for some other reason — timer, manual scheduleTask), and
// 2. call netpollDone(pd) after Pause returns to deregister.
//
// Multiple waiters on the same (fd, mode) pair are supported; each gets
// its own pollDesc and its own subscription in the next poll_oneoff call.
func netpollAddWait(fd uint32, mode pollMode) *pollDesc {
pd := &pollDesc{
fd: fd,
mode: mode,
task: task.Current(),
bnxt: activePolls,
}
activePolls = pd
pollCount++
return pd
}
// netpollDone removes pd from activePolls if it is still registered.
// Idempotent — if pollIO has already woken the waiter, pd.fired is true
// and this is a no-op.
func netpollDone(pd *pollDesc) {
if pd.fired {
return
}
pp := &activePolls
for *pp != nil {
if *pp == pd {
*pp = pd.bnxt
pd.bnxt = nil
pollCount--
return
}
pp = &(*pp).bnxt
}
}
// pollIO is the cooperative scheduler's blocking wait on wasip1. It
// invokes poll_oneoff with one subscription per pollDesc currently in
// activePolls, plus optionally a clock subscription.
//
// timeoutNs > 0 : add a clock subscription with this nanosecond timeout.
// timeoutNs == 0 : non-blocking poll, no clock sub. (Forward-looking; the
// v1 scheduler does not invoke this path.)
// timeoutNs < 0 : block until any FD is ready, no clock sub. Caller must
// ensure pollCount > 0 — calling poll_oneoff with zero
// subscriptions returns EINVAL.
//
// Tasks whose subscriptions fire are pushed onto runqueue. The caller
// (the scheduler) re-walks the sleep / timer queues on its next loop
// iteration to handle clock fires.
func pollIO(timeoutNs int64) {
addClock := timeoutNs > 0
nsubs := pollCount
if addClock {
nsubs++
}
if nsubs == 0 {
// Caller is responsible for not invoking pollIO with nothing to
// wait on; bail out rather than calling poll_oneoff with zero
// subscriptions.
return
}
if cap(pollSubs) < nsubs {
pollSubs = make([]__wasi_subscription_t, nsubs)
pollEvents = make([]__wasi_event_t, nsubs)
} else {
pollSubs = pollSubs[:nsubs]
pollEvents = pollEvents[:nsubs]
}
i := 0
for pd := activePolls; pd != nil; pd = pd.bnxt {
var et __wasi_eventtype_t
if pd.mode == pollRead {
et = __wasi_eventtype_t_fd_read
} else {
et = __wasi_eventtype_t_fd_write
}
pollSubs[i].userData = uint64(uintptr(unsafe.Pointer(pd)))
pollSubs[i].u.setFDReadWrite(et, pd.fd)
i++
}
if addClock {
pollSubs[i].userData = 0
pollSubs[i].u.setClock(0, uint64(timeoutNs), timePrecisionNanoseconds, 0)
i++
}
var nevents uint32
poll_oneoff(&pollSubs[0], &pollEvents[0], uint32(nsubs), &nevents)
for k := uint32(0); k < nevents; k++ {
ev := &pollEvents[k]
if ev.userData == 0 {
continue
}
pd := (*pollDesc)(unsafe.Pointer(uintptr(ev.userData)))
if pd.fired {
continue
}
pd.fired = true
pp := &activePolls
for *pp != nil {
if *pp == pd {
*pp = pd.bnxt
pd.bnxt = nil
pollCount--
break
}
pp = &(*pp).bnxt
}
runqueue.Push(pd.task)
}
}
// runtime_netpoll_addwait is the linkname target used by package syscall
// (and any future package using //go:linkname into runtime) to register
// a wait on an FD without sharing the runtime's pollDesc / pollMode
// types. The returned uintptr is an opaque pollDesc pointer; callers
// must pass it back to runtime_netpoll_done.
//
// mode must be one of pollRead (1) or pollWrite (2).
//
//go:linkname runtime_netpoll_addwait
func runtime_netpoll_addwait(fd uint32, mode uint8) uintptr {
return uintptr(unsafe.Pointer(netpollAddWait(fd, pollMode(mode))))
}
// runtime_netpoll_done is the linkname target used by package syscall to
// release a pollDesc previously returned by runtime_netpoll_addwait.
// Idempotent; safe to call whether or not pollIO has already woken the
// waiter.
//
//go:linkname runtime_netpoll_done
func runtime_netpoll_done(pd uintptr) {
if pd == 0 {
return
}
netpollDone((*pollDesc)(unsafe.Pointer(pd)))
}
// runtime_netpoll_pdfired reports whether the given pollDesc has already
// been woken (either by a poll_oneoff event or by a manual wake). Used
// by deadline-driven cancellation paths to avoid double-waking a task.
//
//go:linkname runtime_netpoll_pdfired
func runtime_netpoll_pdfired(pd uintptr) bool {
if pd == 0 {
return true
}
return (*pollDesc)(unsafe.Pointer(pd)).fired
}
// runtime_netpoll_wake wakes the task parked on pd from outside the
// poll_oneoff 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.
//
// wasip1 is single-threaded so we don't need atomic ops here.
//
//go:linkname runtime_netpoll_wake
func runtime_netpoll_wake(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
}
runqueue.Push(p.task)
}
+38 -12
View File
@@ -78,11 +78,6 @@ var (
sleepTicksNEvents uint32
)
func sleepTicks(d timeUnit) {
sleepTicksSubscription.u.u.timeout = uint64(d)
poll_oneoff(&sleepTicksSubscription, &sleepTicksResult, 1, &sleepTicksNEvents)
}
func ticks() timeUnit {
var nano uint64
clock_time_get(0, timePrecisionNanoseconds, &nano)
@@ -106,9 +101,9 @@ func poll_oneoff(in *__wasi_subscription_t, out *__wasi_event_t, nsubscriptions
type __wasi_eventtype_t = uint8
const (
__wasi_eventtype_t_clock __wasi_eventtype_t = 0
// TODO: __wasi_eventtype_t_fd_read __wasi_eventtype_t = 1
// TODO: __wasi_eventtype_t_fd_write __wasi_eventtype_t = 2
__wasi_eventtype_t_clock __wasi_eventtype_t = iota
__wasi_eventtype_t_fd_read
__wasi_eventtype_t_fd_write
)
type (
@@ -118,10 +113,12 @@ type (
u __wasi_subscription_u_t
}
// The union payload is sized by the largest variant (clock, 32 bytes after
// the tag and its 7-byte alignment pad). FD read/write subscriptions reuse
// the same memory via setFDReadWrite.
__wasi_subscription_u_t struct {
tag __wasi_eventtype_t
// TODO: support fd_read/fd_write event
u __wasi_subscription_clock_t
}
@@ -134,6 +131,28 @@ type (
}
)
// __wasi_subscription_fd_readwrite_t is the FD variant of the subscription
// union payload. It overlays the first 4 bytes of the clock variant.
type __wasi_subscription_fd_readwrite_t struct {
fd uint32
}
func (s *__wasi_subscription_u_t) setClock(id uint32, timeoutNs, precision uint64, flags uint16) {
s.tag = __wasi_eventtype_t_clock
s.u = __wasi_subscription_clock_t{
id: id,
timeout: timeoutNs,
precision: precision,
flags: flags,
}
}
func (s *__wasi_subscription_u_t) setFDReadWrite(eventType __wasi_eventtype_t, fd uint32) {
s.tag = eventType
s.u = __wasi_subscription_clock_t{}
(*__wasi_subscription_fd_readwrite_t)(unsafe.Pointer(&s.u)).fd = fd
}
type (
// https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#-event-record
__wasi_event_t struct {
@@ -141,11 +160,18 @@ type (
errno uint16
eventType __wasi_eventtype_t
// only used for fd_read or fd_write events
// TODO: support fd_read/fd_write event
_ struct {
// fdReadWrite is populated by poll_oneoff for fd_read / fd_write events.
// For clock events the field is zero. Reading nBytes/flags after a
// clock event is meaningless but not unsafe.
fdReadWrite struct {
nBytes uint64
flags uint16
}
}
)
// Compile-time size assertions for the wasip1 ABI. If these fail to compile
// the struct layout drifted from the spec and poll_oneoff would corrupt
// memory.
var _ [0]byte = [48 - unsafe.Sizeof(__wasi_subscription_t{})]byte{}
var _ [0]byte = [32 - unsafe.Sizeof(__wasi_event_t{})]byte{}
+32
View File
@@ -0,0 +1,32 @@
//go:build wasip1 && (scheduler.tasks || scheduler.asyncify)
package runtime
// sleepTicks is the cooperative scheduler's "wait until the next deadline"
// primitive on wasip1. 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 FD waiters are registered via netpollAddWait, this routes through
// pollIO so the same poll_oneoff call observes both the clock subscription
// and the FD subscriptions. With no FD waiters it falls back to the cheap
// single-clock-subscription path.
func sleepTicks(d timeUnit) {
if pollCount > 0 {
pollIO(ticksToNanoseconds(d))
return
}
sleepTicksSubscription.u.u.timeout = uint64(d)
poll_oneoff(&sleepTicksSubscription, &sleepTicksResult, 1, &sleepTicksNEvents)
}
// 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 FD waiters this is a genuine
// deadlock; with FD waiters we block until any of them is ready.
func waitForEvents() {
if pollCount > 0 {
pollIO(-1)
return
}
runtimePanic("deadlocked: no event source")
}
+19
View File
@@ -0,0 +1,19 @@
//go:build wasip1 && !scheduler.tasks && !scheduler.asyncify
package runtime
// sleepTicks blocks the current execution context for d ticks. This is the
// fallback used when no cooperative scheduler is configured (-scheduler=none
// or -scheduler=threads on wasip1) and it has no FD-polling integration —
// see scheduler_idle_wasip1.go for the cooperative variant.
func sleepTicks(d timeUnit) {
sleepTicksSubscription.u.u.timeout = uint64(d)
poll_oneoff(&sleepTicksSubscription, &sleepTicksResult, 1, &sleepTicksNEvents)
}
// waitForEvents is only meaningful when there's an event source available.
// Without the cooperative scheduler running poll_oneoff on FDs, wasip1 has
// nothing to wake on, so this is a hard deadlock.
func waitForEvents() {
runtimePanic("deadlocked: no event source")
}
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !tinygo.riscv && !cortexm && !(linux && !baremetal && !tinygo.wasm && !nintendoswitch) && !darwin
//go:build !tinygo.riscv && !cortexm && !(linux && !baremetal && !tinygo.wasm && !nintendoswitch) && !darwin && !wasip1
package runtime