Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ work test `os_waits == 0` and `discovery_polls <= 1` (exactly 1 where the backen
no cached work); require both zero without native operations. For all-call totals
and bounds use their sum. Preserve the raw `zero_event_waits` counter across both
categories and every §10 rule 4a no-spin bound. Callback/Event-helper queue draining
is neither kind of native call.
is neither kind of native call. A wake produced only by the backend's own deadline
source (timerfd, IOCP deadline packet, WASI deadline pollable/subtask) is a
zero-event call, not native work; simultaneous I/O or notifier events keep it
non-empty.
See [Backend revision 2](docs/BACKEND_REVISION_2.md#blocking-waits-and-nonblocking-discovery-tl-i01b).

## Workspace discovery and test metadata
Expand Down
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ No other Perry change is needed for P0.

*Queued work* means completions already queued before the native step: posts, blocking-pool and external-wait results, synchronous and terminal completions, including a timer's `Cancelled`/`Closed`; timer start, reset, cancel and close are therefore pure churn. A timer expiry that is due when the turn starts is not queued work: the effective wait is zero, so the turn may spend its one call on a zero-timeout poll. *Native operations* are operations submitted on native handles (sockets, pipes, files, processes, signals, web fetch/WebSocket), plus requests a backend accepts natively (WASI 0.2 DNS, and WASI filesystem requests, including path requests). Typed file requests on the native blocking pool are not native operations: their results arrive as queued work, like host blocking jobs. `TurnInfo::os_waits` counts blocking waits and `TurnInfo::discovery_polls` counts zero-timeout discovery polls; their sum is at most one per turn. Web callback draining and Windows Event-helper queue draining make neither kind of call. **Contract tests:** a queued post beside an idle UDP receive delivers with `os_waits == 0`, `discovery_polls == 1`, then byte-verified I/O; queued posts and timer `Cancelled`/`Closed` with no native operation record zero of both; a sustained producer keeps fresh I/O progressing within two seconds.
4. **Ticks:** no fixed-interval ticks and no minimum wait floor.
4a. **No spin.** A turn with nothing ready and a future deadline blocks until that deadline, at the precision in §7.6. It never returns immediately and never degrades into a zero-timeout poll loop. Hosts must pass exact deadlines (`Instant`, not truncated milliseconds). The Linux A/B measured what happens otherwise: 37,607 turns for 50 timers, ~70× user instructions. **Contract test:** with an idle registered socket and a 0.5 ms / 2 ms / 10 ms timer, turns per expiry ≤ 2 and zero-event OS waits ≤ 1 per expiry, on every backend.
4a. **No spin.** A turn with nothing ready and a future deadline blocks until that deadline, at the precision in §7.6. It never returns immediately and never degrades into a zero-timeout poll loop. Hosts must pass exact deadlines (`Instant`, not truncated milliseconds). The Linux A/B measured what happens otherwise: 37,607 turns for 50 timers, ~70× user instructions. A backend whose timeout is implemented by a private wakeup source — epoll's timerfd, the IOCP deadline packet, the WASI 0.2 deadline pollable, the WASI 0.3 deadline subtask — reports that wake as a **zero-event** wait, exactly as a timed OS wait that returned nothing; real I/O or notifier events arriving in the same call still make it non-empty. **Contract tests:** with an idle registered socket and a 0.5 ms / 2 ms / 10 ms timer, turns per expiry ≤ 2 and zero-event OS waits ≤ 1 per expiry, on every backend; and (tl-i02) each of the sixty expiries at those delays costs exactly one blocking wait that observed no native event, identically with the loop idle and with a registered-but-idle native operation, with no allocation, while a call that carries real bytes is not counted empty.
5. **Instruction budgets per operation** (Linux, cgu=1, `perf stat -e instructions:u,instructions:k`): TCP read / write / accept, timer start + cancel, notify + turn round trip, blocking job round trip, idle turn. **Values to be set from the attribution run of today's tokio bridge**, with a target below the tokio-bridge cost and within X % of a hand-written epoll loop. The CI gate compares against a committed baseline, with a control probe that must not move.

**Rule 3 rationale (tl-i01b, spec-owner decision, 2026-09-15).** The original rule 3 ("none when completions are already queued") forbade even a zero-timeout poll. Backend revision 2 has no separate no-wait discovery primitive: `poll(Duration::ZERO)` is the only way to learn about fresh readiness or completions, and on Unix, after cached readiness reaches `EAGAIN`, only the poller marks a resource ready again. libuv and Node make the same trade: `uv_run` computes `uv_backend_timeout()`, which is zero while pending, idle or closing work exists, and still calls `uv__io_poll` with that zero timeout (see the libuv [loop API](https://docs.libuv.org/en/v1.x/loop.html) and [`src/unix/core.c`](https://github.com/libuv/libuv/blob/v1.x/src/unix/core.c)). The [tl-i01 probes](docs/lanes/tl-i01.md#evidence--specification-decision) showed that skipping discovery breaks the unchanged fairness contract: skipping the native step whenever work was queued failed the timer/I/O/post fairness test after two seconds, and a guard that only drained cached work delivered 64 posts with zero reads and failed the same fairness test. A separate no-wait collection API on all six backends is not justified before measurement. Rule 4a and its raw zero-event accounting are unchanged: an empty discovery poll still counts toward the same no-spin bound.
Expand Down
162 changes: 162 additions & 0 deletions crates/turnloop-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ mod native {
no_spin::<B>();
}
#[test]
fn quiet_deadlines_account_identically_idle_and_registered() {
quiet_deadline_accounting::<B>();
}
#[test]
fn timer_bounds() {
timer_precision::<B>();
}
Expand Down Expand Up @@ -1292,6 +1296,164 @@ pub fn io_and_posts_progress_with_repeating_timers<B: Backend>() {
assert_eq!((read, wrote), (64, 1));
}

/// Run the three design delays (0.5 ms, 2 ms, 10 ms) twenty times each against
/// `l`, reusing the caller's storage, and return
/// `[os_waits, discovery_polls, zero_event_waits, expiries]`.
///
/// DESIGN §10 rules 3, 4 and 4a. A deadline with nothing else ready is *one*
/// native call that observes *no* native event, and the timer is the only
/// completion. A backend that reported its own private timeout source as native
/// work would record `zero_event_waits == 0` here: epoll normalizes its timerfd,
/// IOCP its deadline packet, WASI 0.2 its deadline pollable and WASI 0.3 its
/// deadline subtask. Each of the sixty measured expiries is a real blocking
/// wait; a round whose own setup outran the delay is already due at turn entry,
/// which DESIGN rule 3 lets spend its one call on a zero-timeout poll instead,
/// so it is asserted in that shape and re-run rather than measured (a cold or
/// preempted process does that a few times; sixteen consecutive failures mean
/// the delay itself can no longer be measured). `native_pending` states whether a
/// native operation is registered, the only thing allowed to differ afterwards:
/// the queued `Closed` turn may then spend its one call on a discovery poll.
pub fn quiet_deadlines<B: Backend>(
l: &mut Driver<B>,
out: &mut Completions,
native_pending: bool,
) -> [u32; 4] {
let mut totals = [0; 4];
let mut late = Duration::ZERO;
for delay in [
Duration::from_micros(500),
Duration::from_millis(2),
Duration::from_millis(10),
] {
for round in 0..20 {
let token = Token(500 + delay.as_micros() as u64 + round);
let mut measured = false;
let mut attempt = 0;
while !measured {
attempt += 1;
assert!(
attempt <= 16,
"{delay:?}: the timer setup never finished inside its own delay"
);
let at = l.now() + delay;
let h = l.timer(at, None, token).expect("timer");
let op = l.timer_op(h).expect("timer operation");
let info = l.turn(Timeout::Until(at), out).expect("quiet wait");
// One native call, and it observed no native event at all.
assert_eq!(
(info.os_waits + info.discovery_polls, info.zero_event_waits),
(1, 1),
"{delay:?} deadline: one native call observing no native event"
);
// A blocking wait is the measurable case. A preempted setup that
// outran its own delay leaves the expiry already due at turn
// entry, which DESIGN rule 3 lets spend the one call on a
// zero-timeout poll; that round is re-run, never counted, so a
// backend that only ever polls can never reach sixty expiries.
let quiet = info.os_waits == 1;
// Exactly one turn per expiry: the completion is already here.
assert_eq!(out.len(), 1, "{delay:?} deadline needed a second turn");
assert_eq!(
(out[0].handle, out[0].op, out[0].token),
(Some(h), Some(op), token)
);
assert!(matches!(out[0].result, OpResult::Timer));
// The host deadline is honoured exactly, with no wait floor
// rounding it up: sub-millisecond delays expire like the others.
assert!(l.now() >= at, "{delay:?} deadline fired early");
late = late.max(l.now().saturating_duration_since(at));
assert!(
l.now().saturating_duration_since(at) < Duration::from_millis(100),
"{delay:?} deadline overshot its wait"
);
if quiet {
totals[0] += info.os_waits;
totals[1] += info.discovery_polls;
totals[2] += info.zero_event_waits;
totals[3] += 1;
measured = true;
}
l.close(h, token).expect("close timer");
let info = l.turn(Timeout::Now, out).expect("queued close");
assert_eq!(info.os_waits, 0, "a queued close must not block");
assert!(
info.discovery_polls <= u32::from(native_pending),
"queued close polled without a pending native operation"
);
assert!(info.zero_event_waits <= info.discovery_polls);
assert_eq!(out.len(), 1);
assert!(matches!(out[0].result, OpResult::Closed));
}
}
}
// Never print here: allocation gates call this with their counter armed.
assert!(late < Duration::from_millis(100));
totals
}

/// DESIGN §10 rule 4a and the `PollInfo` contract: quiet deadline accounting is
/// the same whether the loop is idle or has registered-but-idle native services.
pub fn quiet_deadline_accounting<B: Backend>() {
let mut l = Driver::<B>::new(Config::default()).expect("loop");
let mut out = Completions::with_capacity(1);
let idle = quiet_deadlines(&mut l, &mut out, false);
let (_, sender, receiver) = pair(&mut l);
let read = l
.read(receiver, ReadBuf::Pooled, Token(2))
.expect("idle read");
// Submit the read before measuring: the registered phase must differ from the
// idle one by a pending native operation only, not by a first-submission turn.
let info = l.turn(Timeout::Now, &mut out).expect("arm the idle read");
assert!(out.is_empty(), "the registered read must stay idle");
assert_eq!(info.os_waits, 0, "a zero timeout never blocks");
let registered = quiet_deadlines(&mut l, &mut out, true);
assert_eq!(
idle, registered,
"registered-but-idle services changed quiet-deadline accounting"
);
assert_eq!(
idle,
[60, 0, 60, 60],
"sixty expiries, each one blocking wait that saw no native event"
);
// The same registered read now takes real bytes inside a deadline-bounded
// wait. Normalizing the private deadline must not empty a call that found
// native work: this is the control the accounting above needs.
l.write(sender, WriteBuf::Owned(vec![0x2a]), Token(3))
.expect("write");
let until = l.now() + Duration::from_secs(5);
let (mut calls, mut empty, mut bytes, mut wrote) = (0, 0, 0, 0);
while bytes == 0 || wrote == 0 {
assert!(l.now() < until, "the loopback exchange stalled");
let info = l.turn(Timeout::Until(until), &mut out).expect("exchange");
assert!(info.os_waits + info.discovery_polls <= 1);
calls += info.os_waits + info.discovery_polls;
empty += info.zero_event_waits;
for c in out.drain() {
match c.result {
OpResult::Wrote(1) => wrote += 1,
OpResult::Read {
n: 1,
lease: Some(b),
} => {
assert_eq!(c.op, Some(read), "the idle read survived every expiry");
assert_eq!(b.as_slice(), [0x2a]);
bytes += 1;
}
other => panic!("unexpected {other:?}"),
}
}
}
assert!(calls > 0, "the exchange must make a native call");
assert_eq!(
empty, 0,
"a native call that carried real I/O is not a zero-event wait"
);
eprintln!(
"quiet deadlines idle={idle:?} registered={registered:?}; exchange calls={calls} empty={empty}"
);
}

/// DESIGN §10 rule 4a: an idle registered stream must not turn timers into polling.
pub fn no_spin<B: Backend>() {
let mut driver = Driver::<B>::new(Config::default()).expect("loop");
Expand Down
63 changes: 26 additions & 37 deletions crates/turnloop-contract/tests/allocations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1107,54 +1107,43 @@ fn signal_exit_and_external_notification_delivery_allocate_nothing() {
l.signal_stop(signal, Token(4)).expect("stop");
}

#[cfg(not(target_os = "wasi"))]
#[test]
fn quiet_deadline_waits_have_identical_accounting_without_allocations() {
let mut l = Loop::new(Config::default()).expect("loop");
let mut out = Completions::with_capacity(1);
let mut expiries = 0;
ALLOCS.with(|v| v.set(0));
ACTIVE.with(|v| v.set(true));
for duration in [
Duration::from_micros(500),
Duration::from_millis(2),
Duration::from_millis(10),
] {
for _ in 0..20 {
let at = l.now() + duration;
let token = Token(expiries);
let h = l.timer(at, None, token).expect("timer");
let op = l.timer_op(h).expect("timer operation");
let info = l.turn(Timeout::Until(at), &mut out).expect("quiet wait");
assert_eq!(
(info.os_waits, info.discovery_polls, info.zero_event_waits),
(1, 0, 1)
);
assert!(l.now() >= at);
assert_eq!(out.len(), 1);
assert_eq!(
(out[0].handle, out[0].op, out[0].token),
(Some(h), Some(op), token)
);
assert!(matches!(out[0].result, OpResult::Timer));
l.close(h, token).expect("close timer");
let info = l.turn(Timeout::Now, &mut out).expect("queued close");
assert_eq!(
(info.os_waits, info.discovery_polls, info.zero_event_waits),
(0, 0, 0)
);
assert_eq!(out.len(), 1);
assert!(matches!(out[0].result, OpResult::Closed));
expiries += 1;
}
}
let idle = turnloop_contract::quiet_deadlines(&mut l, &mut out, false);
ACTIVE.with(|v| v.set(false));
assert_eq!(
ALLOCS.with(Cell::get),
0,
"idle deadline waits reuse reserved storage"
);
// Registering an idle native operation must not change the accounting, and
// must not make the same deadline waits allocate.
let (_, _sender, receiver) = turnloop_contract::pair(&mut l);
let read = l
.read(receiver, ReadBuf::Pooled, Token(2))
.expect("idle read");
l.turn(Timeout::Now, &mut out).expect("arm the idle read");
assert!(out.is_empty(), "the registered read must stay idle");
ALLOCS.with(|v| v.set(0));
ACTIVE.with(|v| v.set(true));
let registered = turnloop_contract::quiet_deadlines(&mut l, &mut out, true);
ACTIVE.with(|v| v.set(false));
assert_eq!(expiries, 60);
assert_eq!(
idle, registered,
"registered-but-idle services changed quiet-deadline accounting"
);
assert_eq!(idle, [60, 0, 60, 60]);
eprintln!("quiet deadline allocations: idle={idle:?} registered={registered:?}");
assert_eq!(
ALLOCS.with(Cell::get),
0,
"deadline waits reuse reserved storage"
"deadline waits beside an idle native operation reuse reserved storage"
);
assert!(l.cancel(read), "the registered read stayed pending");
}

#[cfg(target_os = "wasi")]
Expand Down
6 changes: 6 additions & 0 deletions crates/turnloop-contract/tests/wasi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ fn timers_io_posts() {
fn no_spin() {
contract::no_spin::<Platform>();
}
/// DESIGN §10.4a and the `PollInfo` contract: the private WASI deadline
/// pollable (0.2) / subtask (0.3) is this wait's timeout, not native work.
#[test]
fn quiet_deadline_accounting() {
contract::quiet_deadline_accounting::<Platform>();
}

#[test]
fn repeated_eof_shutdown_and_empty_datagram() {
Expand Down
1 change: 1 addition & 0 deletions crates/turnloop-contract/tests/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ contract!(
refused_connect_once,
ref_unref,
no_spin,
quiet_deadline_accounting,
timer_precision,
udp_round_trip,
detach_inflight,
Expand Down
13 changes: 10 additions & 3 deletions crates/turnloop/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,12 @@
//! counts positive-timeout/infinite waits; `discovery_polls` counts zero-time
//! native polls. Their sum is at most one. `zero_event_waits`
//! counts raw empty calls across both counters, including EINTR.
//! Private timeout events (e.g. timerfd expiry) count as zero-event waits, just
//! like a timed OS wait returning zero; never infer this from user completions.
//! Private timeout events count as zero-event waits, just like a timed OS wait
//! returning zero; never infer this from user completions. Every backend that
//! implements its timeout with a private wakeup source normalizes it: epoll's
//! timerfd, the IOCP deadline packet, the WASI 0.2 deadline pollable and the
//! WASI 0.3 deadline subtask. Real I/O or notifier events arriving in the same
//! call still make it non-empty.
//! * Cached readiness ending in EAGAIN with no completion must retain the original
//! timeout for the one permitted OS wait. It must not force a zero-timeout turn.
//! * `has_work` covers queued completions and cached runnable I/O. `wake` is called
Expand Down Expand Up @@ -253,7 +257,10 @@ pub struct PollInfo {
pub discovery_polls: u32,
/// Native wait/discovery invocations with no I/O or notifier events, including EINTR.
/// Bounded by `waits + discovery_polls`; retains revision-2 raw empty-poll accounting.
/// Private timeout events, such as timerfd expiry, are not native work.
/// Private timeout events are not native work: epoll's timerfd, the IOCP
/// deadline packet, the WASI 0.2 deadline pollable and the WASI 0.3 deadline
/// subtask all count as zero-event calls, while simultaneous I/O or notifier
/// events keep the call non-empty.
pub zero_event_waits: u32,
}

Expand Down
Loading