Skip to content

AcceptedOwner sparkles:event-horizon

sparkles:event-horizon — Specification ​

Abstract ​

sparkles:event-horizon is the event loop Sparkles programs use for asynchronous work: network and file I/O, timers, subprocesses, and window-system input wait at a single point. The loop is completion-based. A program hands the operating system whole operations and learns when each one finished, over io_uring on Linux, kqueue on macOS, and I/O completion ports on Windows. One loop serves three programming styles, from allocation-free callbacks to blocking-looking code on fibers whose child tasks are always joined and cancelled together. The services a program uses are passed in as replaceable values, so the same code runs against the real system or against deterministic test doubles with virtual time.

Introduction ​

Interactive tools and servers spend most of their time waiting: for a socket, a child process's output, a file change, a timer, a keypress, or a window event. A terminal emulator must react to whichever of these arrives first without stalling its next frame; a server must keep many operations in flight at once. Both are best served by one wait point per thread. A second loop beside it, such as a helper thread polling a child or a toolkit's own run loop, adds latency, locking, and shutdown races.

Building that wait point well leaves several questions open. Readiness-based loops such as epoll report that a descriptor may be usable and leave the I/O to the program. Completion-based kernel interfaces such as io_uring accept the whole operation instead, so the loop must keep each buffer alive until the kernel is done with it and must be able to cancel work the kernel still holds. Above the loop, no programming model is free: callbacks are fast but scatter logic across handlers, async/awaitcolors every function it touches, and fibers read like blocking code but leak work unless cancellation is structured. Code wired directly to a live loop is also hard to test deterministically.

This library answers with a completion contract shared by every backend: a program submits an operation, and the operation ends in exactly one final result, a byte count or an error code, even when it is cancelled. A multishot operation, such as accepting connections repeatedly, delivers further results before that final one. Backends that only report readiness, such as kqueue, wait for readiness, perform the I/O themselves, and report the outcome as a completion. Buffers move into an operation, stay with the loop until its final result, and come back with that result, so the program and the kernel never own one at the same time. Failures are returned as values on every tier.

Three tiers of API share that one loop. The callback tier allocates nothing and throws nothing. The fiber tier parks a fiber on each I/O call and resumes it on completion, inside structured-concurrency scopes that always join their children. A thin effect-description tier lowers onto fibers. The clock, network, and process services arrive as a capability row: a statically typed record of handler values, where swapping a handler means passing a value of a different type. This is the library's form of algebraic effects. Each effect operation is an ordinary call on the current fiber, resolved at compile time; no handler ever captures a continuation. Tests therefore substitute virtual time or a simulated network at no runtime cost.

On Linux the library requires io_uring on a 6.1 or newer kernel and has no epoll fallback; a system without a working io_uring gets a structured error at loop creation. Platform coverage beyond Linux is stated per section: the Windows backend, for instance, covers sockets, timers, and cross-thread wake-ups but not subprocesses. A program may run several worker threads, each with its own loop, but a started fiber never moves between them; work stealing is limited to tasks that have not yet started. The capability row is a testing and least-authority convention, not a security boundary, because D code can still make any system call. Protocols and windowing live in other libraries, such as sparkles:http and sparkles:wsi; this library supplies only the wait they attach to. The effect-description tier is specified as a non-normative sketch.

Sections 1–4 give the overview, the module layout, the backends and how they are probed, and the vocabulary of operations and completions. Sections 5–7 specify the callback tier, buffer ownership, and the fiber tier; sections 8–10 cover structured concurrency, errors, and capabilities. Section 11 covers multi-threaded topologies and section 12 the effect tier. Sections 13–15 cover subprocesses, channels, and the integration that lets a UI application make this its only loop. Section 16 lists the public API, section 17 the fork server that runs requests in disposable child processes, and the final section the tutorial conveniences. Delivery order and progress live in PLAN.md, unresolved questions in open-issues.md, and measured results in benchmarks.md. The research behind each decision is in the async-io and algebraic-effects surveys.

1. Overview ​

sparkles:event-horizon is a completion-first (proactor) event loop with a native algebraic-effect layer. On Linux it drives io_uring directly (via the during binding) — there is no epoll fallback: a Linux system without a working io_uring fails loop creation with a structured error (§3.4). macOS (kqueue) and Windows (IOCP) are peer backends behind the same seam (§3.1), not degraded fallbacks.

One loop, three programming tiers, each a public API:

TierModelModulesSuspension
Acallback / completionloop, opnone — completion callbacks
Bdirect-style fiberssched, iocore.thread.Fiber park/resume
CEffect!T descriptionseffect (§12)lowered onto tier B

Tier A is the substrate the other tiers build on and a supported public API (@nogc nothrow, function-pointer callbacks, no fibers). Tier B gives blocking-looking code with no function coloring. Tier C is a thin monadic veneer whose combinators are defined in terms of tier B (§12).

The target ergonomics, end to end (tier B) — a self-contained loopback echo over the live capability row (env.net) and a structured scope:

d
#!/usr/bin/env dub
/+ dub.sdl:
    name "event_horizon_overview"
    dependency "sparkles:event-horizon" version="*"
+/
import core.lifetime : move;
import std.stdio : writeln;
import sparkles.base.buffer : UniqueBuffer;
import sparkles.event_horizon;

void main()
{
    LoopGroup group;
    if (LoopGroup.start(group, LoopGroupConfig(topology: Topology.single)).hasError)
        assert(false, "io_uring unavailable");
    scope (exit) group.shutdown();

    bool echoed;
    group.run((ref RootScope sc, ref Env env) {
        // Bind on an ephemeral loopback port; a daemon serves one client.
        auto listener = env.net.listen(ipv4("127.0.0.1", 0)).value;
        const port = listener.boundPort;

        sc.spawnDaemon({
            auto conn = listener.accept;          // parks; resumes on CQE
            if (conn.hasValue)
                echo(conn.value);                 // serve the connection
        });

        // The client: connect, send, receive the echo, verify.
        auto client = env.net.connect(ipv4("127.0.0.1", port)).value;
        scope (exit) client.close();
        UniqueBuffer!(ubyte, 64) msg;
        msg ~= cast(const(ubyte)[]) "hello, event horizon";
        client = client.sendAll(move(msg));

        UniqueBuffer!(ubyte, 64) back;
        back.length = 64;
        auto got = client.recv(move(back));       // parks until the echo
        echoed = !got.res.hasError && got.res.value == 20;

        listener.close();
        sc.cancel(Interrupt(InterruptKind.shutdown)); // reap the daemon
    });

    writeln(echoed ? "ok: echoed 20 bytes" : "echo failed");
}

void echo(Stream conn)
{
    scope (exit) conn.close();                    // runs even on Interrupt
    UniqueBuffer!(ubyte, 4096) buf;
    buf.length = 4096;                            // grow-with-default (base helper)
    auto r = conn.recv(move(buf));                // buffer moves in …
    buf = move(r.buf);                            // … and comes back
    if (r.res.hasError || r.res.value == 0)
        return;                                   // error / interrupt / EOF
    buf.length = r.res.value;
    cast(void) conn.send(move(buf));              // echo it back
}

// Small helpers used above (part of the library surface):
ushort boundPort(ref Listener l) @trusted
{
    import core.sys.posix.arpa.inet : ntohs;
    import core.sys.posix.netinet.in_ : sockaddr_in;
    import core.sys.posix.sys.socket : getsockname, sockaddr, socklen_t;

    sockaddr_in a;
    socklen_t len = a.sizeof;
    getsockname(l.fd, cast(sockaddr*) &a, &len);
    return ntohs(a.sin_port);
}

Stream sendAll(Buf)(Stream s, Buf buf)
{
    auto w = s.send(move(buf));
    return s;
}
Output
ok: echoed 20 bytes

2. Package and module layout ​

IdentifierValue
Dub sub-packagesparkles:event-horizon
Source rootlibs/event-horizon/src/sparkles/event_horizon/
Package modulesparkles.event_horizon

Modules are split into two strata by an import firewall: effects-side modules never import ring, loop, or scheduler modules (nor during), so a future extraction into a standalone sparkles:effects package is mechanical. Loop-side modules may import anything.

ModuleStratumContents
errorseffects-sidere-exports sparkles.base.io.errors (IoError, ErrorKind, OpKind, IoErrorStage, NoGcHook, IoResult, ioOk/ioErr) and adds fromRes (§9.1) — leaf
causeeffects-sideCause, Interrupt, Outcome, widen; FiberContext, CancelContext, CancelFn, cancelTree (§8, §9.2)
capabilityeffects-sideisCapability, Ctx, hasCaps, CtxOf; the isWaker and isFiberExecutor seams (§10)
scope_effects-sideScope, withScope, withDeadline, protect, checkCancellation, JoinHandle (§8)
scheduleeffects-sideSchedule values + retry/repeat/timeout/race drivers (§10.4)
clockeffects-sideisClock + TestClock (§10.3)
neteffects-sideSockAddr + helpers (ipv4, …), isNet, isByteStream + SimNet (§10.3)
testingeffects-sideTestSched (deterministic executor) + advanceAndSettle (§10.3)
bufferloop-sideBuf, BufOrigin, BufGroupId, BufResult, BufferPool, BufRing, isOwnedIoBuf (§6)
oploop-sideop descriptors, KernelTimespec, OpToken, OpClass, OpSlot/OpSlab, Completion, OpCallback (§4); re-exports SockAddr
handleloop-sideLoopHandle — opt-in type-erased loop access for loop-side plumbing and -betterC users (§5.5)
backend.conceptloop-sideisCompletionBackend + optional-capability traits, RawCompletion, BackendConfig, Waker (§3.1)
backend.probeloop-sideBackendCaps, LoopMode, ModePolicy, probeSystem (§3.2–3.4)
backend.uringloop-sideUringBackend over during (§3.5); backend.kqueue / backend.iocp follow in M10/M11
looploop-sideEventLoop!Backend, LoopConfig, DefaultLoop — tier A (§5)
schedloop-sideSched, SchedOptions, FiberTask, currentTask, RootScope — tier B scheduler (§7)
ioloop-sidedirect-style verbs (read/write/recv/send/accept/connect/sleep) and the Stream/Listener/DgramSocket handles (§7.3)
liveloop-sidering-backed capability implementations (RingClock, RingNet, RingProc), the process spawn machinery (§13), and the Env row
proceffects-sideprocess vocabulary (StdioMode, StdioSpec, ProcessConfig, ExitStatus), isProc, SimProc (§13)
channeleffects-sideChannel!T — bounded intra-worker fiber channel (§14)
vfsloop-sideRingVfs, the capability VFS backend over the loop, and the fs member of the row (§10.5)
signals, watchloop-sideconcrete ring-driven modules (M7): SignalFd, Watcher; concept seams follow demand (§10.3)
grouploop-sideLoopGroup, LoopGroupConfig, Topology (§11)
raw_poolloop-sidepersistent fixed-capacity closure-free CPU jobs (RawCpuPool, §11.1)
blocking_poolloop-sidescheduler-integrated pool for blocking host calls (BlockingPool, §13.8): public lane + termination-critical lane, shared pool
cgrouploop-sideLinux cgroup v2 containment for supervised runs (§13.7): tier probe (none/owned/accounted), lane-assigned create / migrate / kill / cleanup, populated evidence
samplingloop-sidethe bounded, tiered, transactional tree sampler (§13.8): fail-closed root anchor, bounded CPU ledger, per-sample work budget; the Darwin stub
superviseloop-sidesupervised runs (§13.5–§13.8): the shielded workers, the one-shot clocks, the sampler handshake, the tree-state machine and the terminal sequence
effecteffects-sidethe Effect!T veneer (§12); lands in M12
package—public re-exports

Foundation: sparkles:base supplies the Buffer family (staging buffers, test helpers) and recycledErrorInstance; the expected package (~>0.4.1) supplies Expected. during (~>0.5.0) is the Linux substrate and is not re-exported — user code never sees an SQE.

3. Backends and capability probing ​

3.1 The completion-backend concept ​

A backend is a struct satisfying the isCompletionBackend!B capability trait — no interface, no vtable. The loop is EventLoop!Backend; submission lowering is a statically dispatched overload set that inlines. The concept is defined by CQE semantics, not by io_uring: a backend that synthesizes completion over readiness (kqueue) must still deliver (userData, res, flags) triples where res is a byte count / fd or -errno, and multishot streams set the more flag on every non-final completion.

d
/// What the backend hands the loop per completion.
struct RawCompletion { ulong userData; int res; uint rawFlags; }

enum bool isCompletionBackend(B) = __traits(compiles, {
    BackendConfig cfg;
    IoResult!void o = lvalueOf!B.open(cfg);
    auto caps = lvalueOf!B.caps();
    bool queued = lvalueOf!B.trySubmit(OpNop(), OpToken.init); // false = full
    IoResult!uint f = lvalueOf!B.flush();
    IoResult!uint w = lvalueOf!B.submitAndWait(1u, cast(const(KernelTimespec)*) null);
    uint n = lvalueOf!B.reap((ref const RawCompletion c) {});  // non-blocking drain
    auto wk = lvalueOf!B.waker();                              // thread-safe wake
    lvalueOf!B.close();
}) && canSubmitOp!(B, OpNop);

/// Per-descriptor submission capability; the backend owns the lowering.
/// The probe passes an rvalue: descriptors carrying an owned Buf are
/// move-only, so submission moves the op (§5.2) — an lvalue probe would
/// statically reject every buffer-carrying op.
enum bool canSubmitOp(B, Op) = __traits(compiles, {
    bool r = lvalueOf!B.trySubmit(Op.init, OpToken.init);
});

/// Thread-safe wake handle — the ONLY backend object callable off-thread.
struct Waker { void wake() shared const nothrow @nogc; }

Two contracts the expressions cannot state, both load-bearing (O27): trySubmit returns false for backpressure only — a submission resource is full, so flush() and retry. A kernel rejection arrives as an ordinary completion carrying -errno (a bad SQE on uring, an EV_ERROR change-list entry on kqueue). flush() returns submission-side units flushed, backend-defined — SQEs on uring, change entries on kqueue.

Optional capabilities are separate traits; absence degrades, never breaks:

TraitGates
hasRegisteredBuffers!BREAD_FIXED/WRITE_FIXED lowering via BufferPool (§6.3)
hasBufRing!Bprovided-buffer rings (§6.4)
hasMultishot!(B, kind)multishot accept/recv (runtime-caps-backed)
hasNativeCancel!BASYNC_CANCEL submission (§8.5)
hasNativeTimeout!Bin-ring TIMEOUT ops; absent → the loop's timer heap (§5.3)
hasMsgRing!Bcross-ring completion posting (§11)
hasDirectFds!Bregistered file descriptors

3.2 Operating modes ​

d
enum LoopMode : ubyte
{
    exclusive,    /// SINGLE_ISSUER + DEFER_TASKRUN — the thread-per-core default
    cooperative,  /// COOP_TASKRUN + TASKRUN_FLAG — the single-threaded default
}

enum ModePolicy : ubyte
{
    exact,         /// requested mode unavailable → error(stage: probe)
    bestAvailable, /// degrade exclusive → cooperative; result recorded in caps
}

exclusive mode makes the loop's thread affinity structural: the kernel enforces one submitting thread (-EEXIST otherwise), and completions are delivered only inside runOnce (§5.4).

3.3 The kernel baseline and the three probe tiers ​

The v1 Linux baseline is kernel ≥ 6.1. This guarantees both operating modes and the whole tier-3 feature set at or below 5.19 (registered buffers/files, provided buffer rings, multishot accept) plus multishot recv and SINGLE_ISSUER (6.0) and DEFER_TASKRUN (6.1). Consequently there are no per-op degradation paths on Linux for baseline features — the probe verifies the floor once. Features newer than the floor remain probe-gated:

FeatureKernelOn absence
futex ops (FUTEX_WAIT/WAKE)6.7idle parking degrades to ring-wait (§11)
RECV_ZC (zero-copy receive)6.15not in v1 (post-v1 series)

(SEND_ZC, kernel 6.0, is floor-guaranteed but deliberately outside v1 for lifetime-complexity reasons — open-issues O6.)

Probing is resolved once, at loop creation, into an immutable BackendCaps value, in three tiers:

  1. Backend selection — io_uring_setup succeeds, or creation fails (§3.4). Peer platforms select kqueue/IOCP here.
  2. Opcode probe — IORING_REGISTER_PROBE gates the portable OpKind set; caps.supports(kind) answers per-op availability.
  3. Feature flags — negotiated IORING_FEAT_* bits and accepted setup flags (e.g. extArg selects inline-timespec waits; nodrop is asserted).
d
struct BackendCaps
{
    BackendId backend;              // uring | kqueue | iocp
    KernelVersion kernel;
    LoopMode mode;                  // mode actually negotiated
    ulong opBits;                   // bit per OpKind
    bool singleIssuer, deferTaskrun, coopTaskrun;
    bool registeredBuffers, registeredFiles, bufRing;
    bool multishotAccept, multishotRecv;
    bool futexOps, msgRing, extArg, nodrop;
    bool supports(OpKind k) const @safe pure nothrow @nogc;
}

Static traits answer "does this backend have the code"; BackendCaps answers "does this kernel/OS have the feature". A feature path runs only when both agree.

3.4 Hard-error semantics on Linux ​

If io_uring_setup fails — ENOSYS (kernel too old or compiled out), EPERM/EACCES (seccomp, the io_uring_disabled sysctl, container lockdown) — or the kernel is below the 6.1 floor, loop creation returns ioError(errno, OpKind.none, IoErrorStage.setup, "io_uring unavailable") (or stage: probe for the floor). There is no epoll fallback. Tests for this path call skipTest (sparkles:test-runner) on hosts where the condition cannot be produced, so a degraded host reports skips rather than passes.

3.5 The uring backend ​

UringBackend wraps a during Uring handle. Lowering is an overload set: each trySubmit(op, token) fills the next SQE via the matching prepXxx helper and sets user_data = token.raw. Buf.origin == registered selects prepReadFixed/prepWriteFixed automatically — "fixed" is an optimization the user never spells. submitAndWait uses EXT_ARG inline timespecs when negotiated. (Known substrate defects in during 0.5.0, worked around in the backend until upstream tags fixes: prepCancel's default-flag path is mis-typed, so the ASYNC_CANCEL SQE is hand-rolled; and submitAndWait(want, args) silently drops args when the submission queue is empty — a deadline wait would block unboundedly — so the backend flushes and calls wait(want, args) explicitly.)

kqueue (M10) synthesizes completions: readiness arms a non-blocking syscall performed by the backend at reap time; regular-file ops go to a small worker pool. Registrations accumulate in a change list and ride down with the kevent(2) that waits, so a steady-state loop pays one syscall per turn rather than one per op (O27). A rejected change is an EV_ERROR completion, not a trySubmit false. Registrations use EV_DISPATCH rather than EV_ONESHOT: delivery disables the knote, a multishot re-arm is an EV_ENABLE change entry, and slot release emits an explicit EV_DELETE (O28). The Darwin-only EV_UDATA_SPECIFIC/EV_VANISHED pair is not used — libkqueue does not implement them, and Android ships that shim as the default. Worker-pool results are pushed onto a thread-safe completed queue owned by the backend; reap drains that queue into the same RawCompletion stream as readiness-synthesized completions, so the loop never distinguishes the two paths, and a worker that finishes while the loop is blocked in submitAndWait wakes it via the backend's own waker.

IOCP (M11) is natively completion-based: each op slot's kernel-stable operand store embeds the op's OVERLAPPED; at GetQueuedCompletionStatusEx time the backend maps the returned OVERLAPPED* back to its containing slot and emits the slot's OpToken as userData — the OVERLAPPED shares exactly the slot-and-buffer lifetime invariant of §4.3.

4. Operations and completions ​

4.1 Descriptors ​

One POD struct per operation kind; op.d is portable (no backend imports). A descriptor may contain (a) values copied into the SQE, (b) an owned Buf (moves into the op slot, pinned until the terminal completion), (c) values needing kernel-stable storage (sockaddr, timespec) — copied into the slot's operand store at submit, so the descriptor itself may die immediately.

d
struct OpNop     { enum kind = OpKind.nop; }
struct OpRead    { enum kind = OpKind.read;    int fd; Buf buf; ulong offset; }
struct OpWrite   { enum kind = OpKind.write;   int fd; Buf buf; ulong offset; }
struct OpRecv    { enum kind = OpKind.recv;    int fd; Buf buf; }
struct OpSend    { enum kind = OpKind.send;    int fd; Buf buf; }
struct OpConnect { enum kind = OpKind.connect; int fd; SockAddr addr; }
struct OpAccept  { enum kind = OpKind.accept;  int listenFd; }
struct OpAcceptMultishot { enum kind = OpKind.acceptMultishot; int listenFd; }

/// Datagram (UDP) ops. The peer address rides the slot's operand store on
/// send; on receive it is written there by the kernel and copied into the
/// completion. msghdr/iovec-based sendMsg/recvMsg are deferred
/// (open-issues O19).
struct OpSendTo   { enum kind = OpKind.sendTo;   int fd; Buf buf; SockAddr to; }
struct OpRecvFrom { enum kind = OpKind.recvFrom; int fd; Buf buf; }

/// recv with kernel buffer selection: NO buffer at submit; the completion
/// carries a ring-leased Buf (§6.4). multishot keeps one submission armed.
struct OpRecvSelect { enum kind = OpKind.recvSelect; int fd; BufGroupId group; uint maxLen; bool multishot; }

/// Foreign-fd readiness (§15.1): completes when `fd` is ready for `events`
/// (a portable POLLIN/POLLOUT/POLLERR/POLLHUP subset). No buffer, no
/// syscall on the fd — the integration op for descriptors whose I/O a
/// foreign library performs itself (a Wayland/X11 connection, a GPGME fd).
/// multishot keeps one submission armed (flags.more per readiness edge).
struct OpPollAdd { enum kind = OpKind.pollAdd; int fd; PollEvents events; bool multishot; }

enum bool isOpDesc(Op) = __traits(compiles, { enum OpKind k = Op.kind; });

SockAddr (a 128-byte sockaddr_storage-sized POD plus a length) lives in net with its construction helpers (ipv4, …) — effects-side address vocabulary usable by test doubles — and is re-exported by op; KernelTimespec (a __kernel_timespec mirror) is a library-owned POD in op. Peer backends satisfy the concept without importing during.

accept returns the new fd only; the peer address is fetched on demand (getpeername) rather than inflating every op slot by a sockaddr_storage.

4.2 Tokens and the op-slot slab ​

user_data carries a packed slot token, never a raw pointer:

d
/// ABA-safe packed user_data: | class:8 | generation:24 | index:32 |.
struct OpToken
{
    ulong raw;   // raw == 0 is reserved invalid
    uint index() const;
    uint generation() const;
    OpClass cls() const;    // user | timer | wake | internal
}

/// Public, copyable reference to an in-flight op (cancel/detach target).
struct OpHandle { /* wraps an OpToken */ }

Slots live in a fixed slab allocated at loop creation through the loop's Allocator parameter (makeArray!OpSlot; default Mallocator; default capacity 2 × cqEntries). A slot holds the completion target (callback + context — tier B stores its fiber-resume trampoline in the same fields), the lifetime state, the cancel provenance (interrupt vs linked timeout, §8.5), the pinned Buf, and the kernel-stable operand store. Resolution is one indexed load plus a generation compare; a stale token (recycled slot) never resolves.

4.3 The slot lifetime state machine ​

free ──submit──▶ armed ──terminal CQE──▶ (callback) ──▶ free
armed ──CQE with flags.more──▶ armed              (multishot fan-out)
armed ──cancel()──▶ cancelRequested ──terminal CQE──▶ (callback) ──▶ free
armed | cancelRequested ──detach()──▶ detached ──terminal CQE──▶ free (silent)

(Buffering of non-terminal multishot completions and their backpressure policy: open-issues O16.)

Invariant: a slot and its pinned buffer stay alive until the terminal completion arrives — always, including after cancellation (the kernel may still write to the buffer until the CQE lands) and after detach (the Monoio "Ignored" discipline). A linked pair (op + LINK_TIMEOUT) expects two CQEs before the slot is released.

4.4 Completions ​

d
enum CompletionFlags : uint
{
    none           = 0,
    more           = 1 << 0,  /// multishot: not the last completion
    bufferSelected = 1 << 1,  /// buf carries a ring lease (§6.4)
}

/// Passed by ref: the callback may `move(done.buf)` to keep the buffer;
/// otherwise the loop recycles it to its origin.
struct Completion
{
    OpToken token;
    OpKind kind;
    int res;                       /// raw result: >= 0 payload or -errno
    CompletionFlags flags;
    Buf buf;
    IoResult!uint result() @safe pure nothrow @nogc;  // fromRes(res, kind)
    bool isFinal() const @safe pure nothrow @nogc;    // !(flags & more)
}

alias OpCallback = void function(void* ctx, ref Completion done) nothrow @nogc;

Callbacks are @nogc nothrow function pointers with an explicit context pointer, not delegates: the callback is stored until an arbitrary future completion, which scope/dip1000 cannot check for a closure, and the function-pointer shape is the -betterC/C-ABI floor. (Synchronous sinks that are not stored — e.g. the backend's reap sink — are scope delegates.)

Multishot is a property of completion semantics (flags.more), not an io_uring-ism: kqueue's EV_CLEAR readiness synthesizes it naturally.

5. The callback tier (tier A) ​

5.1 The loop ​

d
struct LoopConfig
{
    BackendConfig backend;   // sqEntries (256), cqEntries (0 = 2× sq), mode, modePolicy
    uint opSlots = 0;        // 0 = 2 × cqEntries
}

/// runOnce's report: what ended the iteration.
enum RunStatus : ubyte { dispatched, timedOut, stopped, drained }

struct EventLoop(Backend = UringBackend, Allocator = Mallocator)
if (isCompletionBackend!Backend)
{
    @disable this(this);

    /// Out-parameter factory (the during `setup(ref Uring, …)` shape):
    /// EventLoop is move-only, and IoResult cannot return a non-copyable
    /// payload by value (§9.1).
    static IoResult!void create(out EventLoop loop, in LoopConfig cfg = LoopConfig());
    void destroy();                        // requires inFlight == 0

    ref const(BackendCaps) caps() const return;

    IoResult!OpHandle submit(Op)(Op op, OpCallback cb, void* ctx)
    if (isOpDesc!Op && canSubmitOp!(Backend, Op));

    IoResult!OpHandle submitAfter(Duration rel, OpCallback cb, void* ctx);
    IoResult!OpHandle submitAt(MonoTime deadline, OpCallback cb, void* ctx);

    IoResult!void cancel(OpHandle h);      // fire-and-forget; see §8.5
    static if (hasTerminalCancel!Backend)
        IoResult!void cancelAndWait(OpHandle h); // owner-thread teardown barrier
    void detach(OpHandle h);               // callback never runs; silent recycle

    Waker waker();                         // the only off-thread door

    IoResult!RunStatus runOnce(Duration timeout = Duration.max);
    IoResult!void run();                   // until stop() or drained
    void stop();

    uint inFlight() const;
    uint sqSpace() const;
    MonoTime now() const;                  // cached per runOnce iteration
}

version (linux) alias DefaultLoop = EventLoop!UringBackend;

The loop is thread-affine and non-copyable (SINGLE_ISSUER made structural): every member except Waker.wake must be called from the owning thread.

5.2 Submission and backpressure ​

submit moves buffer fields in (the op is taken by value — the kernel escapes the pointers, so scope must not be claimed); the buffer comes back via Completion.buf. Submission is non-blocking: on a full submission queue the loop performs one implicit flush retry, then returns IoError(EAGAIN, kind, submit, "sq full"). An exhausted slot slab returns IoError(ENOBUFS, kind, submit, "op slab full"). trySubmit's false is backpressure only — a rejected operation (bad fd, already-reaped pid) is queued and completes with -errno, the same shape a bad SQE already has on io_uring. The loop's retry path depends on this: a false it cannot clear by flushing is an infinite retry.

Cancellation is guaranteed on a driven loop. cancel never fails on backpressure: internal cancels serialize through one reserved slot outside the user slab budget (index opSlots, its own lifecycle and generation, never on the user free list), and a request that cannot be submitted right now — the reserved slot is busy, or the backend queue is full — moves the target to cancelQueued on the loop's pending set instead of being refused. The set is threaded through dedicated slot links (never the free-list link) and a queued target that completes naturally is unlinked before its slot is released and its callback runs, so a callback may reuse the index at once; a nonterminal multishot completion keeps the links. Repeating a request is idempotent. detach clears callback ownership only: a queued or submitted cancel for a detached op still reaches its terminal completion, so a detached non-completing read is still driven to completion and the loop can still drain. The guarantee is conditional on the owner continuing to drive the loop (§5.4).

Hard backend failure is process-fatal. A submission, flush, or wait error that is not backpressure (EAGAIN/EBUSY/EINTR/ENOBUFS) has no recoverable structured-cleanup contract: an accepted, non-completing op can never reach a terminal CQE once new submissions (cancels included) are rejected, and no userspace-visible backend abort can prove buffer-ownership return. The loop therefore invokes LoopConfig.fatalHook — a noreturn, nothrow @nogc function pointer defaulting to a raw-write(2) diagnostic plus abort() — immediately at every detection site (a public submit, a cancel, the drive, or any of those from inside a completion callback) and never returns an IoError for it: returning would let destructors, scope guards, and callers run against parked fibers, kernel-owned buffers, and in-flight pool jobs. The hook must not acquire application locks; embedders may install one that logs before _exit.

5.3 Timers ​

Timers are in-ring TIMEOUT operations when hasNativeTimeout!Backend; otherwise the loop degrades to a binary-heap timer feeding submitAndWait's deadline. Handles and cancellation behave identically either way. The clock is MonoTime (monotonic). (Coalescing many sleepers onto one armed deadline instead of one TIMEOUT op per timer: open-issues O18.)

5.4 Running and re-entrancy ​

runOnce performs: flush → submitAndWait(1, timeout) → two-phase reap (batch-copy CQEs, advance the CQ head, then dispatch callbacks). Under exclusive mode this is the only point where completions are delivered — the DEFER_TASKRUN contract. Callbacks run on the loop thread and may submit but must not re-enter runOnce (contract-checked). CQ overflow (-EBADR under nodrop) triggers an internal drain cycle before surfacing an error. run() loops runOnce until stop() or until the loop is drained (no live ops, no timers). Off-thread stop: set the flag, then waker().wake().

Queued cancellations (§5.2) are retried by the loop itself — not by a scheduler above it — at three points: before every wait, after every dispatch batch (which frees the reserved slot and backend queue space), and when an internal cancel completes. A stopped runOnce still performs the one nonblocking pre-wait retry before returning stopped; stop() leaves live and cancel-pending operations legal, and drained is never reported while a queued target or an in-flight internal cancel exists. destroy additionally requires the pending set empty and the reserved slot idle.

5.5 Type-erased access ​

The backend seam is DbI-only; erasure exists one level up, opt-in. LoopHandle (handle.d) is a betterC-safe function-pointer table over a concrete loop — one indirect call per submit, no descriptor-specific inlining. It exists for loop-side plumbing that must not be templated on the backend and for -betterC consumers; it is itself loop-side (it imports op). Effects-side modules never hold a LoopHandle — they reach the scheduler only through the isFiberExecutor/isWaker seams (§10.3). Hot paths hold the concrete EventLoop.

5.6 The external waker ​

loop.waker() returns the one loop object callable off-thread (§3.1). The first call arms the backend-native wake source when the backend implements nativeWaker (O29): kqueue registers one EVFILT_USER knote that wake() triggers with NOTE_TRIGGER; uring parks an in-ring FUTEX_WAIT that wake() releases with a process-private FUTEX_WAKE (and a word increment so a re-arm cannot lose the wake); IOCP needs no armed op at all — wake() posts a zero-byte PostQueuedCompletionStatus packet. The portable fallback (a backend with no nativeWaker) is an eventfd / pipe and a persistently armed internal read.

The returned Waker is a copyable value. Waker.wake() is thread-safe, nothrow @nogc, coalescing, and never blocks. The fd-write fallback is also async-signal-safe; native kevent/futex triggers are not promised signal-safe. N wakes before the loop runs deliver at least one wake completion, not N.

Accounting. The armed wake op is OpClass.wake and is excluded from drain accounting: inFlight() and the RunStatus.drained decision count user and timer ops only, so a loop whose only live op is its waker still reports drained from run() — an armed waker is infrastructure, not work. runOnce(timeout) on such a loop does block: that is precisely the "park until something external happens" entry point (§15.3). destroy() disarms: native wake releases the routing slot (the knote / futex / port post dies with the backend); the fd fallback cancels the internal read, drains to its terminal CQE, and closes the eventfd. The §4.3 invariant applies to the wake slot like any other.

The waker completes two contracts stated earlier: off-thread stop() (§5.4 — set the flag, then waker().wake()) and the group-level idle nudge (§11). It deliberately carries no payload: a wake means "re-check your state," and any cross-thread data travels through its own channel (a mailbox the woken code polls, or the M9 MSG_RING/futex machinery).

6. Buffers ​

6.1 The pinned-buffer currency ​

Completion I/O forces ownership transfer: the kernel holds the buffer pointer from submission to terminal completion, so the bytes must not move or be freed in that window. The tier-A currency is Buf — a move-only handle over pinned, stable memory:

d
enum BufOrigin : ubyte { none, pool, registered, ringLease, foreign }

struct Buf
{
    @disable this(this);                       // exactly one owner, or the kernel
    inout(ubyte)[] opSlice() inout return scope;   // dip1000: views can't escape
    @property uint length() const;             // valid bytes (CQE res on receive)
    @property uint capacity() const;
    @property BufOrigin origin() const;
    static Buf fromForeign(ubyte[] mem, /* deleter */) @system;
    void release();                            // return to origin; ~this() calls it
}

IMPORTANT

A buffer with inline storage is not a valid tier-A transfer currency: the small-buffer optimization stores the payload inline, so moving the struct relocates the bytes — under a kernel-held pointer that is a silent use-after-move. UniqueBuffer remains the staging/assembly type at the edges; Buf (pool, registered slot, ring lease, or pinned foreign memory) is what crosses the submission boundary at tier A.

6.2 BufResult ​

The owned-transfer result shape shared by tier-A completions and tier-B verbs — the buffer always comes back, success or failure:

d
struct BufResult(Buf)
{
    Buf buf;               /// ownership returned — the kernel is done with it
    IoResult!uint res;     /// bytes transferred, or the error
}

6.3 Pools and registered buffers ​

BufferPool!(Allocator = Mallocator) carves same-size Bufs out of one contiguous slab drawn through a composable allocator (the allocator guidelines §4 recipe: stateSize embed, monostate default, attributes inferred; slabs via makeArray/dispose). The slot-index structure stays bespoke — slot indices are the buf_index/bid currency, which a plain FreeList cannot provide. Pools destined for buffer registration draw from page-aligned parents (MmapAllocator/AscendingPageAllocator); AffixAllocator intrusive refcounts are reserved for the SEND_ZC ownership split (open-issues O6). When the backend supports registered buffers the slab is registered once and every Buf carries its slot index; lowering then selects the FIXED opcodes purely from Buf.origin. On a backend without the capability the pool works identically, unregistered (recorded in caps — same API, honest degradation).

6.4 Provided buffer rings ​

BufRing!(Allocator = Mallocator) hands the kernel a ring of buffers (a page-aligned ring plus a backing store drawn through the allocator); a recvSelect op commits no buffer while idle — the completion carries the kernel-chosen buffer id (flags.bufferSelected), and the tier-B recvSelect verb leases a ring-lease Buf from it. Releasing that Buf republishes the slot at the ring's producer tail (a release-store on the overlaid tail field) without a syscall — the ring is a single-producer (app) / single-consumer (kernel) SPSC ring. This decouples buffer count from connection count. On kqueue/IOCP the backend draws from the pool at completion-synthesis time — the API and the decoupling survive.

6.5 Tier-B generic buffers ​

Tier-B verbs are generic over any owned buffer type:

d
/// requires: `ubyte[] opSlice()` whose memory is stable while the value
/// itself is not moved.
enum bool isOwnedIoBuf(Buf) = /* exact-expression checks */;

At tier B the moved-in buffer lives in the suspended verb's stack frame, and the fiber resumes only at the terminal completion (§8.5) — so the frame, and therefore the buffer, provably outlives kernel use. This is why inline-storage types (UniqueBuffer!ubyte) are sound as tier-B transfer buffers even though they are banned at tier A, and why borrowed-slice convenience overloads (readInto(fd, scope ubyte[])) are sound at tier B. A future "kill a fiber without resumption" feature would break exactly this invariant and is therefore excluded.

7. The fiber tier (tier B) ​

7.1 Fiber tasks ​

FiberTask subclasses core.thread.Fiber (the std.concurrency.Generator pattern): the current task is one downcast of Fiber.getThis, and pooling is Fiber.reset on a terminated instance. Tasks live in a fixed slab sized at scheduler init — steady-state spawn is @nogc nothrow; slab exhaustion fails the spawn (growth is opt-in and allocates). Fiber construction (stack mmap, GC stack registration) is inherently not @nogc; the slab front-loads it.

Every live fiber stack is a conservative-GC scan root; the default stack size is a SchedOptions knob (see open-issues O4).

7.2 The scheduler ​

One Sched per worker thread, never shared — the SINGLE_ISSUER discipline extends to all scheduler state. Cross-worker traffic travels only through group-level messages (§11).

Run-queue discipline:

  • a FIFO intrusive ready queue — all CQE wakes and fresh spawns enqueue at the tail (fairness under batched CQ drains);
  • a 1-entry LIFO slot for same-worker fiber-to-fiber wakes (cache-hot handoff), bounded by a budget against starvation;
  • CQE handlers enqueue, never resume inline: a tick is drain CQ batch → run ready (budgeted) → submit accumulated SQEs → wait — one io_uring_enter per tick under exclusive mode.

SchedOptions carries the tuning knobs: defaultStackSize (see open-issues O4), maxFibers (the task-slab size), lifoBudget, and resumeBudget (fibers run per tick before re-draining the CQ). currentTask() returns the running FiberTask (one downcast of Fiber.getThis; asserts off-fiber).

Sched.run(root) wraps the root fiber in an implicit root scope; there is no detached spawn (§8.1). sched also exports the blessed live scope instantiation used across tiers B/C:

d
alias RootScope = Scope!(Sched, IoError);

Embedding (Sched.tick). run() owns the calling thread until every fiber finishes. For a host that must interleave the scheduler with a loop it does not own (a foreign windowing loop, a macOS CFRunLoop observer, an incremental app migration), tick performs exactly one iteration of run()'s body and returns:

d
/// One scheduler iteration: run ready fibers (up to resumeBudget), then —
/// if none became ready and ops are in flight — one runOnce(timeout).
/// Returns: dispatched (fibers ran or completions arrived), timedOut
/// (nothing within `timeout`), or drained (no live fibers, no user ops).
/// Must not be called from inside a fiber; run() and tick() do not mix
/// concurrently.
IoResult!RunStatus tick(Duration timeout = Duration.zero);

tick is the O7 escape hatch, not the recommended shape: an application that can invert control (its frame loop as the root fiber, §15.3) gets the single-wait-point guarantee for free and never needs it.

7.3 The await seam and the direct-style verbs ​

Every I/O verb funnels through one choke point:

  1. Checkpoint: if an interrupt is pending, return ioErr!T(ECANCELED, op, IoErrorStage.submit) without submitting (a cancelled scope does no new work).
  2. Acquire a slot with the current fiber as completion target; lower the op; user_data = token.
  3. Install the fiber's one-shot cancel function (§8.4) pointing at the slot.
  4. Park. Submission is batched into the tick.
  5. Resume on the terminal completion: clear the cancel function, map res through fromRes, surface a latched interrupt after the result is delivered (no silently lost bytes).

The verbs (module io) are thin shims over this seam; they resolve the scheduler from the current fiber, which is why only the fiber-level verbs (sleep, yieldNow) name Sched explicitly.

read and write must accept any handle that lends a descriptor (isFdBorrowable, module sparkles.event_horizon.sys.descriptor). Ownership follows cap-std: an OwnedFd is the move-only owner of a descriptor and closes it exactly once; a BorrowedFd is a copyable lend with no close at all, so no copy can close a descriptor out from under another. A pipe end, a pty master and a capability VFS File whose backend has descriptors all lend one, so a file opened through a directory capability reads through the ring with no copy of its descriptor. Stream, Listener and DgramSocket are small copyable socket handles with an explicit close(); they own no memory and carry no ring state. Sockets, listeners and files are created only through capabilities (env.net, env.fs, §10.3).

d
BufResult!Buf read(H, Buf)(auto ref H h, Buf buf, ulong offset = ulong.max)
if (isFdBorrowable!H && isOwnedIoBuf!Buf);
BufResult!Buf write(H, Buf)(auto ref H h, Buf buf, ulong offset = ulong.max)
if (isFdBorrowable!H && isOwnedIoBuf!Buf);
BufResult!Buf recv(Buf)(ref Stream s, Buf buf) if (isOwnedIoBuf!Buf);
BufResult!Buf send(Buf)(ref Stream s, Buf buf, size_t offset = 0,
    size_t len = size_t.max) if (isOwnedIoBuf!Buf);

/// Datagram verbs (UDP); msghdr-based variants are deferred (open-issues O19).
BufResult!Buf sendTo(Buf)(ref DgramSocket s, Buf buf, in SockAddr to)
if (isOwnedIoBuf!Buf);
BufResult!Buf recvFrom(Buf)(ref DgramSocket s, Buf buf, out SockAddr from)
if (isOwnedIoBuf!Buf);

/// Hot path: the kernel picks the buffer from a provided ring at completion;
/// the returned Buf is a ring lease (origin == ringLease, §6.4).
IoResult!Buf recv(ref Stream s, ref BufRing ring);

IoResult!Stream accept(ref Listener l);
IoResult!void connect(ref Stream s, in SockAddr addr);
IoResult!void sleep(ref Sched s, Duration d);
IoResult!void sleepUntil(ref Sched s, MonoTime deadline); // absolute; past = no-op
IoResult!void yieldNow(ref Sched s);      // cooperative reschedule + checkpoint

/// Foreign-fd readiness (§15.1): parks until `fd` is ready. The verbs for
/// descriptors whose I/O a foreign library performs itself.
IoResult!PollEvents waitReadable(ref Sched s, int fd);
IoResult!PollEvents waitWritable(ref Sched s, int fd);

/// The repeating frame clock (§15.3). Absolute-deadline pacing: each tick's
/// deadline is the previous one plus the period, never "now plus the
/// period", so processing time does not accumulate drift. A tick that is
/// already due returns without parking; ticks missed while the fiber was
/// busy are skipped, not replayed — the frame-clock discipline (a UI wants
/// the next frame, not a burst of stale ones) — and reported in the count.
struct Ticker
{
    static Ticker start(ref Sched s, Duration period);   // first tick: now+period
    IoResult!uint tick(ref Sched s);   // parks until due; missed ticks skipped
    void reschedule(Duration period);  // change cadence from the next tick on
}

Every verb is a cancellation checkpoint, parks at most once, and resumes only at its terminal completion (multishot consumption and its backpressure: open-issues O16).

8. Structured concurrency ​

8.1 Scopes ​

Scope transcribes Eio's Switch / Trio's nursery:

  • the body counts as a member fiber — the scope cannot finish while it runs;
  • exit joins all children, on normal and failing paths alike;
  • the first fail/die cause cancels the remaining siblings (OnChildFailure.cancelSiblings, the default) or lets them finish (collect); the first cause wins the outcome, later ones are counted (§9.2);
  • daemon children (spawnDaemon) do not keep the scope alive and are reaped with InterruptKind.daemon once only daemons remain;
  • the join itself is uncancellable — structured concurrency never abandons children; outer cancellation reaches the children instead;
  • onExit hooks run LIFO at scope exit, inside protect.
d
template withScope(alias fn)
{
    /// Runs fn(ref Scope); returns Outcome!(T, E). Exit protocol:
    /// body returns → policy cancellation → join children → reap daemons →
    /// run onExit hooks (LIFO, protected) → outcome.
    auto withScope(X)(ref X exec, ScopeOptions opts = ScopeOptions())
    if (isFiberExecutor!X);
}

struct Scope(X, E = IoError)
{
    void spawn(scope void delegate() body_);
    void spawn(scope Expected!(void, E, NoGcHook) delegate() body_); // error → Fail
    void spawnDaemon(scope void delegate() body_);
    void spawnPinned(scope void delegate() body_);      // opt out of stealing (§11)
    void fork(T)(ref JoinHandle!(T, E) handle,
        scope Expected!(T, E, NoGcHook) delegate() body_);
    void cancel(Interrupt reason = Interrupt(InterruptKind.cancelled));
    void fail(Cause!E cause);                           // Eio Switch.fail
    void onExit(scope void delegate() nothrow hook);
}

/// Exact-expression scope trait: what the §10.4/§12 drivers constrain on.
enum bool isScope(Sc) = /* spawn/fork/cancel/fail expression checks */;

struct JoinHandle(T, E = IoError)
{
    Outcome!(T, E) join();    // parks until the forked fiber finishes
}

Spawn bodies are ordinary delegates, not scope: a child runs after the spawning call returns, so a capturing closure's frame must be heap-allocated (the compiler does this automatically for non-scope delegate parameters). The tempting scope-plus-@trusted storage trick is unsound — the captured frame is typically the scope body lambda's, which dies when the body returns while children still run during the join (verified with AddressSanitizer during M5). Allocation-free spawning uses a non-capturing delegate: a member delegate over caller-frame state (JoinHandle.runShell is the blessed pattern) or a function pointer. The library's documented dip1000 escapes are the address-pinned intrusive structures (CancelContext trees, handles), whose lifetime the join guarantees.

8.2 The cancellation tree ​

Every scope owns one CancelContext node; nodes form a tree mirroring scope nesting. protect pushes a protected child node that parent cancellation does not descend into (for cleanup paths); a pending interrupt is delivered at the first checkpoint after protect returns. cancelTree(node, reason) marks non-protected nodes cancelling and fires each member fiber's one-shot cancel function exactly once (swap-to-null before invoking — the Eio discipline that makes the complete-vs-cancel race impossible by construction). Cancellation requests stop work; they do not report errors — failures travel via Scope.fail.

A scope can also host shielded children (package-internal Scope.spawnShielded): the owner places a protected node beneath the scope's node and spawns workers under it, so neither the scope's own sweep (cancel, fail, a defect) nor an enclosing cancellation or deadline reaches them. They are ordinary members for the join — the scope cannot exit until they end — and only the owner ends them (cancelTree on the shield, or a direct interrupt) at its terminal boundary. This is the shape supervision needs for workers that must outlive the cancellation of the run they serve: a drain whose read must reach its terminal completion, a reap that must consume the child. A shielded body catches every Throwable it can raise; an escaping defect fails the scope through the ordinary sweep, which does not reach the shielded siblings, so the owner's failure guard must end them. Every exiting child unlinks from the node it actually ran under, the scope's own or a shield.

8.3 Deadlines ​

A deadline is a cancel scope (Trio): withDeadline(exec, timeout, fn) arms a deadline on the scope's own CancelContext, runs withScope, disarms. Expiry surfaces as Cause.interrupted with InterruptKind.deadline.

Deadlines are a scheduler service, never kernel ops. An armed node sits on an intrusive list the Sched owns; the tick clamps its wait to the earliest armed expiry (sleeping the clamp out itself when nothing is in flight and no waker is armed — runOnce reports drained instantly on an empty slab) and sweeps expired subtrees between dispatches (cancelTree(node, Interrupt(deadline)), entry and exit of every tick). Arming is infallible, needs no backend timer — the service exists on every backend, IOCP included — and disarmDeadline(node) is a synchronous severance: it unlinks on the calling thread, so after it returns no sweep can ever observe the node.

The severance invariant is load-bearing for memory safety. The v1 design armed an in-ring TIMEOUT carrying the frame-pinned node as raw userdata and disarmed with a fire-and-forget ASYNC_CANCEL; an expiry CQE already queued at disarm time was still delivered after the scope frame died, and the sweep ran over freed fiber stack (and, in tight arm/disarm loops, over the next scope's live node in the same stack slot). §8.1's pinning argument covers only references the join can see — the kernel must never hold a frame pointer, which is the same conclusion open-issues O28 reached for kqueue udata and O18 anticipated for timer representation. Because sweeps run only between dispatches on the owning thread, an armed node is always alive: the arming scope cannot exit without disarming (a body defect included — runScope joins, disarms, and unlinks under an explicit catch (Throwable) before rethrowing).

One consequence for embedders: an armed deadline does not make the ring fd readable. Only Sched.tick/tickHosted sweep — a host must call the tick on its own cadence rather than gate it on ring-fd readiness, and loop-level drivers (EventLoop.run, cancelAndWait) never deliver deadlines.

8.4 Interrupt delivery and checkpoints ​

A fiber observes cancellation at checkpoints: every I/O verb (before submitting), yieldNow, checkCancellation, and every park. Delivery latches interrupted on the fiber's context; the fiber shell (§8.6) makes the latch authoritative — a body that swallows the ECANCELED error still yields an interrupt outcome ("you cannot silently drop a cancel").

For a fiber parked on I/O, the installed cancel function does not wake the fiber; it submits ASYNC_CANCEL for the awaited op on the owner ring (via a group message when invoked cross-worker). The single wake is always the terminal CQE. For non-I/O parks (a JoinHandle.join — the only non-I/O park in v1; a cross-fiber channel primitive is a recognized gap, see open-issues O20), the cancel function latches the interrupt and wakes immediately.

8.5 In-flight cancellation state machine ​

Using the slot states of §4.3:

armed ── cancel requested ──▶ cancelRequested ── terminal CQE ──▶ free
armed ──────────────────── terminal CQE ───────────────────────▶ free
  • cancel request: swap the fiber's cancel fn to null (one-shot), latch the interrupt, record cancel provenance on the slot (interrupt vs linked timeout), submit ASYNC_CANCEL (owner ring only), do not wake.
  • the ASYNC_CANCEL op's own CQE (0 / -ENOENT / -EALREADY) is internal bookkeeping.
  • the original op's terminal CQE always still arrives: -ECANCELED if the cancel won, the real result if it raced. Either way the parked frame — and the buffer it holds — outlives kernel use.
  • cancelAndWait is the owner-thread teardown barrier for a callback that borrows a foreign resource owner. It drives runOnce until the target and every outstanding internal cancel CQE are terminal; it may dispatch unrelated ready completions and therefore may not run from a completion callback. Plain cancel remains fire-and-forget. The method is capability-gated to uring/kqueue while IOCP lacks terminal CancelIoEx lowering.
  • provenance disambiguates -ECANCELED: interrupt provenance → the verb's caller sees the interrupt (outcome Cause.interrupted); linked-timeout provenance → a typed IoError deadline failure on the Fail channel.

8.6 The fiber shell and graceful shutdown ​

Every spawned body runs inside a shell that maps its ending to an outcome: interrupt latched → Cause.interrupted; body returned err(e) → Cause.fail(e); a Throwable escaped (caught via call(Rethrow.no)) → Cause.die(t). The shell decrements the scope's live count, reaps daemons when only daemons remain, wakes the joiner at zero, and recycles itself into the task slab.

Graceful shutdown is scope teardown writ large: cancel the accept/multishot ops, drain until every in-flight op reaches its terminal CQE, run onExit hooks, then unregister buffers/files and destroy the loop (destroy requires inFlight == 0 — the scope discipline guarantees it).

9. Errors ​

9.1 IoError and IoResult ​

Every operation of this library reports failure with the I/O error vocabulary of sparkles.base.io.errors, which the capability VFS defines and shares with every other I/O path in Sparkles. Module errors is a leaf: it re-exports that vocabulary, adds fromRes, and must not declare an error type of its own. The vocabulary mirrors sparkles.base.text.errors: a struct error, a hook, an alias and helper constructors.

d
// Defined in sparkles.base.io.errors; re-exported here.
enum OpKind : ubyte
{
    none, nop, read, write, recv, recvSelect, send, sendTo, recvFrom,
    accept, acceptMultishot, connect, shutdown,
    openAt, mkdirAt, statAt, readlinkAt, symlinkAt, unlinkAt, rmdirAt,
    renameAt, readDir, resolve, close, fsync,
    timeout, linkTimeout, cancel,
    futexWait, futexWake, msgRing, waitid, pollAdd,
}

enum IoErrorStage : ubyte { setup, probe, registration, submit, completion, cancel }

struct IoError
{
    ErrorKind kind;                           /// portable classification; what callers match on
    int code;                                 /// raw errno or NTSTATUS; 0 = not an OS error
    OpKind op = OpKind.none;
    IoErrorStage stage = IoErrorStage.completion;
    string context = null;                    /// borrowed CTFE-literal detail
}

struct NoGcHook
{
    static immutable bool enableDefaultConstructor = false;
    /// Removes Expected's copying value-accessor fallback so IoResult
    /// instantiates with move-only payloads (extraction via move(r.value)).
    static void onAccessEmptyValue(E)(E err) @safe pure nothrow @nogc
        => assert(0, "accessed value of an error IoResult");
}

alias IoResult(T) = Expected!(T, IoError, NoGcHook);

IoResult!T ioOk(T)(T value) @safe pure nothrow @nogc;
IoResult!void ioOk() @safe pure nothrow @nogc;
IoResult!T ioErr(T)(IoError error) @safe pure nothrow @nogc;
IoResult!T ioErr(T)(ErrorKind kind, OpKind op, int code = 0,
    IoErrorStage stage = IoErrorStage.completion, string context = null)
    @safe pure nothrow @nogc;

// Added by this library, each classifying an errno through `errnoKind`:
IoError ioError(int code, OpKind op = OpKind.none,
    IoErrorStage stage = IoErrorStage.completion, string context = null)
    @safe pure nothrow @nogc;
IoResult!T ioErr(T)(int code, OpKind op,
    IoErrorStage stage = IoErrorStage.completion, string context = null)
    @safe pure nothrow @nogc;
/// The single point where a raw CQE res becomes typed.
IoResult!uint fromRes(int res, OpKind op) @safe pure nothrow @nogc;

A failure's kind must be set where the raw result first becomes typed, in fromRes or ioErr, and never recomputed by a caller. File-system kinds and their mapping belong to the capability VFS (VFE2, VFN2). This library defines the network and process kinds. Only a code some caller branches on has a kind of its own; every other code must map to other, with the raw value in code.

KindPOSIX errnoWin32 and Winsock
cancelledECANCELEDERROR_OPERATION_ABORTED, ERROR_CANCELLED
wouldBlockEAGAIN, EWOULDBLOCKWSAEWOULDBLOCK
connectionResetECONNRESET, EPIPEWSAECONNRESET, WSAECONNABORTED, ERROR_BROKEN_PIPE, ERROR_NO_DATA
connectionRefusedECONNREFUSEDWSAECONNREFUSED, ERROR_CONNECTION_REFUSED
invalidArgumentEINVALWSAEINVAL, ERROR_INVALID_PARAMETER
noProcessESRCH, ECHILD—

The two tables are errnoKind and win32Kind in sparkles.event_horizon.sys.error_kinds, shared with the blocking VFS backend. A backend that reports a native code must report it as the host's errno value, or classify it with win32Kind where it holds a Win32 code.

Move-only payloads (an expected 0.4.x constraint): the hook's onAccessEmptyValue makes IoResult!T instantiable for non-copyable T (a ring-leased Buf, a future RAII handle) with extraction via move(r.value), but combinators that return the payload by value (orElse and friends) still require copyable T. Factories for non-copyable owners (EventLoop.create, LoopGroup.start) therefore use the out-parameter shape (IoResult!void create(out EventLoop loop, …) — the duringsetup(ref Uring, …) precedent). The hook is sparkles.base.text.errors.NoGcHook itself, which defines this member.

9.2 Cause and Outcome ​

Fiber outcomes use a three-way cause (ZIO's Cause, flattened for @nogc — no Then/Both tree; the first cause wins and later ones are counted):

d
enum InterruptKind : ubyte { cancelled, deadline, raceLost, shutdown, daemon }
struct Interrupt { InterruptKind kind; }

struct Cause(E = IoError)
{
    enum Kind : ubyte { fail, die, interrupt }
    Kind kind;
    // fail: the typed error E (retryable);
    // die: a borrowed Throwable defect (never retried);
    // interrupt: an Interrupt (never retried).
    ushort suppressedCount;          /// causes after the first
    static Cause fail(E e);
    static Cause die(Throwable t);
    static Cause interrupted(Interrupt i);
    bool isTimeout() const;          // interrupt && deadline
}

/// What Scope.join / JoinHandle.join / LoopGroup.run yield.
alias Outcome(T, E = IoError) = Expected!(T, Cause!E, NoGcHook);

/// Lift an op-level result into the fiber-level channel (error → Fail).
Outcome!(T, E) widen(T, E)(Expected!(T, E, NoGcHook) r);

The split is load-bearing: capability methods and verbs return IoResult!T (op-level, retryable); joins return Outcome!(T, E) because die and interrupt are not IoErrors. retry consumes only the fail channel — retrying a defect or a cancellation is always wrong (§10.4).

10. Capabilities and effect rows ​

10.1 Capabilities ​

A capability is a plain struct value naming its row label; handlers are values — swapping a handler is passing a different one:

d
enum bool isCapability(C) = is(C == struct)
    && is(typeof(C.capName) : string) && C.capName.length > 0;

Effect concepts are exact-expression capability traits (house DbI style):

d
enum bool isClock(C) = isCapability!C && C.capName == "clock"
    && __traits(compiles, (ref C c) {
        MonoTime t = c.now();
        IoResult!void r = c.sleep(msecs(1));
    });

Dispatch is monomorphized: ctx.clock.now() is a constant-offset field access plus a direct, inlinable call — the evidence lookup costs zero instructions, and every capability operation is tail-resumptive by construction (the only suspension is the fiber park inside an implementation, a scheduler service, never a reified continuation).

10.2 The row ​

d
struct Ctx(Caps...)
if (allSatisfy!(isCapability, Caps) && labelsAreUnique!Caps)
{
    Caps caps;
    ref cap(string name)() inout return;            // ctx.cap!"clock"
    ref opDispatch(string name)() inout return;     // ctx.clock
    // sub/withCap are mutable-receiver only: an inout receiver cannot
    // rebuild a row of pointer-holding (i.e. all live) capabilities —
    // inout(C) does not convert to C when C has indirections.
    auto sub(names...)() scope;                     // row projection
    auto withCap(C)(C c) scope;                     // extend / override (lexical)
}

auto ctx(Caps...)(Caps caps);
alias CtxOf(Caps...) = Ctx!(StaticSortByLabel!Caps); // canonical ordering

enum bool hasCaps(C, names...);   // structural row check

The effect-row convention: a function needing one capability takes it bare (ref Clk clock constrained by isClock); one needing several takes a Ctx constrained by hasCaps. Subset projection along template call chains is implicit — a callee constrained on a subset accepts any superset row unchanged; sub exists for non-template boundaries. withCap is the innermost-handler-wins analogue, lexically scoped. dip1000 scope/return scope on accessors keeps handles from escaping their scope.

The row is a testability and least-authority convention, not an enforcement boundary — D has ambient authority (any function can issue a syscall); the row buys swappability, auditability, and deterministic tests.

10.3 Blessed capabilities and their doubles ​

Capability modules are effects-side and ship the concept plus a deterministic test double; live ring-backed implementations live loop-side (module live) and are constructed by LoopGroup into the root row:

ConceptTest doubleLive implementation
isClockTestClock (virtual time)RingClock (TIMEOUT)
isNetSimNet (in-memory, fault-injectable, latency via any Clock)RingNet
isProcSimProc (scripted stdio + exit statuses; §13.4)RingProc (§13.4)
isVfs (§10.5)MemVfs (in memory, from sparkles:base)RingVfs
isWatch, isSignalsM7 shipped the concrete Linux modules; concepts follow demand(concrete modules)

The isProc concept shape is specified in §13.4, and the file-system capability in §10.5. isWatch/isSignals follow the isClock pattern when a consumer needs to swap them; M7 shipped their concrete ring-driven implementations (watch/signals) without concept seams, which no current consumer misses. isNet requires capName == "net", member types Stream/Listener, and listen(SockAddr)/connect(SockAddr) returning IoResults of them; isByteStream requires the owned-buffer recv/send shapes of §7.3 plus shutdown() — both as exact-expression traits, isClock-style.

TestClock.advance wakes due sleepers in deadline order; the deterministic test executor TestSched (module testing, effects-side — it builds on core.thread.Fiber directly, which the firewall permits, and satisfies isFiberExecutor) pairs it as advanceAndSettle(ref TestSched, ref TestClock, Duration): run until quiescent (no fiber ready and no due sleeper), advance, repeat. SimNet parameterized over TestClock gives fully virtual network timing. Test doubles suspend fibers through the loop-free isWaker seam:

d
/// Minimal one-shot park/wake concept; sched.d provides the live one, tests
/// may provide a synchronous stub. Wake-before-park makes the park a no-op.
enum bool isWaker(W) = is(W.Handle) && __traits(compiles, (ref W w) {
    W.Handle h = w.prepare();
    w.park(h);
    w.wake(h);
});

scope_.d drives the scheduler through the wider isFiberExecutor seam (current context, spawn, park, wake, optional deadline timer) — a mock executor makes every scope semantic unit-testable with no ring.

10.4 Schedules and the drivers ​

Schedule values are immutable PODs — state (including jitter RNG) lives in a separate State, the step is pure, and composition is by expression template, so enum policy = exponential(100.msecs) & recurs(5); folds at CTFE. Composition uses & (both continue, max delay) and | (either continues, min delay) with named aliases both/either — D does not permit overloading &&/||.

d
Recurs recurs(size_t n);
Spaced spaced(Duration interval);
Exponential exponential(Duration base, double factor = 2.0);
auto jittered(S)(S s, double lo = 0.8, double hi = 1.2, ulong seed = 0);
auto upTo(S)(S s, Duration limit);

The drivers are ordinary template functions over fibers (scope delegates — no closure allocation under dip1000; attributes infer):

d
// Sc is any scope type (isScope!Sc, §8.1) — RootScope in live code, a
// TestSched-backed scope in tests.
Outcome!(T, E) retry(S, Clk, Sc, T, E)(ref Sc sc, ref Clk clock, S policy,
    scope Expected!(T, E, NoGcHook) delegate() op)
if (isSchedule!S && isClock!Clk && isScope!Sc);
Outcome!(T, E) repeat(S, Clk, Sc, T, E)(/* mirror image, on success */);
Outcome!(T, E) timeout(Clk, Sc, T, E)(ref Sc sc, ref Clk clock, Duration limit,
    scope Expected!(T, E, NoGcHook) delegate() body_);
Outcome!(T, E) race(Sc, T, E)(ref Sc sc,
    scope Expected!(T, E, NoGcHook) delegate()[] contenders...);

retry retries only Cause.fail outcomes; sleeps ride the passed clock, so TestClock virtualizes backoff. timeout is withDeadline with a result type. race cancels losers on the first terminal contender (both-can-win races drop the straggler's result; raceWith reconciles when that matters).

10.5 The file system ​

File-system access goes through the capability VFS: a program reaches files through directory capabilities, never through path strings. This library owns that contract; its vocabulary, algorithms and in-memory backend live in sparkles:base, and its blocking backend in sparkles:event-horizon-sys, so that code which must not depend on the loop can use them (VFS §4). Here this library states what it adds:

  • The fs member. The fs member of the root row must be a RingVfs on every loop backend, including the backends without ring file operations, where RingVfs runs each operation on the blocking pool (VFB5).
  • Effect forms. The Dir and File operations must have Effect!T forms (§12), generated from the direct-style operations rather than written separately; each produces the result of its direct form. handle.effects.op(args) describes handle.op(args), and handle.effects.call!("op", ct...)(args) covers an operation with template arguments.
  • The library's own file access. The library's own reads of the file system, such as the cgroup and /proc access of §13.7 and §13.8, must go through handles obtained from openRoot, and the library must not declare a path-based openat or mkdirat of its own.
  • Watching. inotify_add_watch has no handle-relative form, so Watcher.addWatch must take a path together with an AmbientAuthority, which makes it a visible exercise of ambient authority.
  • Created entries. Files and directories the library creates must use the sharing rules of the capability VFS (VFO5), whose default is the platform's ordinary sharing less the umask.

A capability VFS File reaches the io verbs of §7.3 by lending its descriptor. A descriptor that must outlive any one handle, such as a control file a run holds open, is obtained through a directory capability and handed to an OwnedFd with File.intoOwnedFd or Dir.intoOwnedFd.

11. Scheduler topologies ​

d
enum Topology : ubyte
{
    single,        /// one loop on the calling thread; zero cross-thread machinery
    threadPerCore, /// N pinned workers, one exclusive-mode ring each; no migration
    workStealing,  /// per-worker rings; ONLY never-started tasks are stealable
}

struct LoopGroupConfig
{
    Topology topology = Topology.single;
    uint workers = 0;                        // 0 = one per online CPU
    uint sqEntries = 256;
    Flag!"pinToCpu" pinToCpu = Yes.pinToCpu;
    Flag!"futexPark" futexPark = Yes.futexPark;  // degrades below kernel 6.7
    uint fiberStackPages = 16;
    uint recvBufRingEntries = 0;             // per-worker provided ring (0 = off)
}

struct LoopGroup
{
    @disable this(this);
    /// Out-parameter factory: LoopGroup owns move-only workers (§9.1).
    static IoResult!void start(out LoopGroup group, in LoopGroupConfig cfg);
    Outcome!T run(T)(scope T delegate(ref RootScope root, ref Env env) main);
    IoResult!void postTo(size_t i, void function(void*) @nogc nothrow fn, void* ctx);
    ref DefaultLoop workerLoop(size_t i) return; // tier-A access, owner thread only
    IoResult!void shutdown(Duration grace = 5.seconds);
}

/// The live capability row handed to the root fiber.
alias Env = CtxOf!(RingClock, RingNet, RingProc);

LoopGroup.run hands the root fiber the root RootScope and the live capability row Env — all authority originates there (the Eio Eio_main.run env shape).

Lifecycle. LoopGroup owns the per-worker EventLoops and Scheds: it constructs them in start and destroys them in shutdown. run returning triggers an implicit shutdown with the default grace; calling shutdown explicitly is idempotent. Shutdown ordering: cancel the root scope → each worker drains to terminal CQEs → onExit hooks → unregister buffers/files → EventLoop.destroy → join worker threads. If the grace period expires with ops still in flight, shutdown returns IoError(ETIMEDOUT, OpKind.none, IoErrorStage.cancel) and intentionally leaks the affected ring and its slab rather than freeing kernel-visible memory — the §4.3 invariant outranks the deadline.

The spawn × topology contract.

  • single / threadPerCore: a child always runs on the spawning worker.
  • workStealing: a child enters the local queue; an idle worker may steal it only before its first resume. Once a fiber has executed, it is pinned to that worker for life. spawnPinned opts a child out of stealing entirely; postTo is the only explicit cross-ring placement.

The pinning invariant is load-bearing, forced by three independent constraints: SINGLE_ISSUER forbids remote submission (a resumed fiber immediately submits to its owner's ring); op slots, buffers, and deadline timers are ring-local; and migrating a started core.thread.Fiber between threads is undefined behavior under LDC when any live frame has cached a TLS address (druntime's CheckFiberMigration exists for exactly this). Stealing therefore balances task starts, not running fibers — the Glommio/Monoio semantics, not Tokio/Go.

Idle parking and cross-worker signalling. An idle I/O worker parks on its loop waker() (O29: EVFILT_USER / in-ring FUTEX_WAIT / IOCP post), so one wait observes CQE arrivals, steal nudges, and shutdown. enqueue pokes the owner if that worker advertised idle, or one idle thief if the owner is busy. The CPU-bound arm has no loop and keeps a spin-then-sleep backoff. Targeted MSG_RING stealing remains O2.

Cross-worker wakes, cancels, and spawn injection other than this idle nudge travel as MSG_RING messages to the owner ring; wake() asserts owner-worker identity.

11.1 Persistent fixed-capacity CPU jobs ​

Interactive hosts need to submit short CPU jobs without allocating a delegate closure or rebuilding the batch-scoped work-stealing pool. RawCpuPool is the additive, ring-independent surface for that use:

d
alias RawJobFn = void function(void*) @safe nothrow @nogc;
alias RawCompletionFn = void function(void*, bool cancelled)
    @safe nothrow @nogc;

struct RawJob
{
    RawJobFn run;
    RawCompletionFn complete; // null means no completion notification
    void* context;     // caller-owned through completion/drain
    ulong generation; // opaque value copied to RawCompletion
}

enum RawPoolResult : ubyte
{
    accepted,
    noCompletion,
    notStarted,
    alreadyStarted,
    queueFull,
    completionFull,
    shuttingDown,
    invalidJob,
    startFailed,
}

struct RawCpuPool(size_t QueueCapacity = 256,
    size_t CompletionCapacity = QueueCapacity)
{
    @disable this(this);
    static RawPoolResult start(ref RawCpuPool pool, uint workers = 0);
    RawPoolResult submit(RawJob job) @safe nothrow @nogc;
    RawPoolResult pollCompletion(out RawCompletion completion)
        @safe nothrow @nogc;
    RawPoolResult shutdown(bool drain = true) @safe nothrow @nogc;
}

start performs all allocation and thread creation before publication. Submission, execution, completion polling, and shutdown allocate nothing. The queues are bounded MPMC rings; a full submission queue returns queueFull without consuming the job. A worker never drops a completion: before accepting a job the pool reserves one completion slot when complete is non-null, so completionFull is a submission outcome rather than a worker-side loss.

The caller keeps context alive and at a stable address until its returned completion is dispatched. Shutdown joins execution but deliberately leaves completion values pollable, so it does not shorten that lifetime when a completion callback was requested. shutdown(true) rejects new jobs, executes accepted jobs, publishes their completions, joins the workers, and is idempotent. shutdown(false) cancels queued-but-unstarted jobs by publishing completions with cancelled = true; running jobs still finish. A stopped pool may be started again after outstanding completions are handled. Function pointers may consult caller-owned atomics, but the pool gives no implicit generation or cancellation semantics.

RawCpuPool does not change WorkStealingPool.run, submit, or submitBlocking. It is available anywhere DRuntime threads are available; it does not require io_uring, kqueue, or IOCP. A consumer must retain a synchronous bounded fallback for thread-start failure and queue saturation.

12. The Effect!T veneer (tier C — non-normative sketch) ​

Lands in M12; recorded here so tiers A/B are built with it in mind.

Effect!(T, E) is a lazy, typed description of a fiber computation. There is deliberately no R parameter: the environment channel is the Ctx type supplied at run, checked at compile time via hasCaps constraints and erased at run time (the Kyo lesson — ZIO's R generalizes to an open row, and D's Ctx is that row, resolved statically).

d
auto succeed(T)(T v);
auto effect(alias fn)();                       // lift a direct-style body
auto map(alias f, Eff)(Eff e);
auto andThen(alias f, Eff)(Eff e);
auto zipPar(A, B)(A a, B b);
auto withRetry(S, Eff)(Eff e, S policy);
auto withTimeout(Eff)(Eff e, Duration limit);

Outcome!(Eff.Value, Eff.Error) run(Eff, Sc, C)(Eff eff, ref Sc sc, ref C ctx)
if (isEffect!Eff && isScope!Sc);

The interpreter is a compile-time fold, not a runtime instruction loop: run static-dispatches on node types; Pure/Mapped/Chained lower to nested inlined calls on the current fiber (e.map!f.map!g fuses to g(f(…))), and only Zipped/Retried/Deadlined touch the scheduler — by calling sc.spawn/retry/timeout. The veneer has no semantics of its own; parity with the direct core holds by construction and is enforced by the M12 parity suite.

13. Subprocesses ​

Split along the import firewall the way net is: module proc (effects-side) owns the vocabulary (StdioMode, StdioSpec, ProcessConfig, ExitStatus), the isProc concept, and the SimProc double; the spawn machinery (ChildProcess, spawnProcess, spawnPty, wait) and the RingProc handler are loop-side in live (all re-exported by package, so consumer spellings do not change).

Delivery status. §§13.1–13.4 describe the shipped M15 POSIX baseline. Today it is publicly re-exported and tested on Linux; kqueue has a shipped EVFILT_PROC reap lowering, but the process modules are not yet exported or covered by the package test suite on macOS. Windows has no process surface: its IOCP backend currently covers sockets, timers, and wakes only. §§13.5–13.8 shipped in M19 on Linux (the supervise, sampling, cgroup and blocking_pool modules); §13.9 and the apps/ci port remain M19 targets — see PLAN M19 for the delivery status.

The design premise, taken from what apps/hue actually runs: background work in this repository is subprocess-shaped (git status, git diff, a resident twoslash oracle, an LLM agent, a PTY child). A spawned child's pipes are ordinary descriptors the ring already knows how to read — so once spawning is right, "async background work" is plain fiber I/O with no second application scheduler, no per-process worker, and no polling. (apps/hue's thread-backed GitStatusCache, and the nested 150 ms wait loop it forces on the TUI, are the anti-pattern this section replaces.)

13.1 Spawn configuration ​

d
/// Where one of the child's standard streams goes.
enum StdioMode : ubyte
{
    inherit,     /// share the parent's descriptor (the default for stdin/stderr)
    pipe,        /// a pipe whose parent end rides the returned handle
    nullDev,     /// /dev/null
    fd,          /// a caller-supplied descriptor (borrowed, not closed)
    mergeStdout, /// stderr only: dup onto the child's stdout, so one pipe
                 /// carries both streams in output order (the
                 /// `Redirect.stderrToStdout` shape monitored/streaming
                 /// runs need)
}

struct StdioSpec
{
    StdioMode mode = StdioMode.inherit;
    int fd = -1;                       /// used when mode == fd
}

struct ProcessConfig
{
    StdioSpec stdinSpec  = StdioSpec(StdioMode.inherit);
    StdioSpec stdoutSpec = StdioSpec(StdioMode.pipe);
    StdioSpec stderrSpec = StdioSpec(StdioMode.inherit);
    const(char[])[] env = null;   /// null = inherit; entries are "KEY=value"
    const(EnvironmentChange)[] envOverlay; /// environment edits (M19)
    const(char)[] cwd = null;     /// null = inherit
    bool newProcessGroup = false; /// setpgid(0, 0) in the child (kill the tree)
}

struct EnvironmentChange
{
    const(char)[] name;       /// non-empty; must not contain '=' or NUL
    const(char)[] value;      /// ignored when unset; must not contain NUL
    bool unset;               /// remove name instead of assigning value
}

Spawning is posix_spawn, never fork — a fiber stack is the worst possible place for a fork. The argv/env staging buffer grows on the heap (the M7 4 KiB static budget and its E2BIG are gone); spawn is a setup-phase operation and may allocate.

The shipped env field is a complete replacement when non-null. M19 adds envOverlay: start with env when non-null, otherwise the inherited environment, then apply changes in order (last change for a name wins; unset removes it). Name matching is byte-exact on POSIX and case-insensitive on Windows. An invalid name/value fails spawn before a child exists. PATH lookup uses the resulting environment. This overlay rule is shared by low-level spawn and supervised runs; callers do not need to snapshot and rebuild the parent environment to set one variable.

The PATH search is driven by the spawn itself, never by an access(2) pre-check (access answers for the real uid while exec answers for the effective one, and a fabricated ENOENT would hide every other failure) and never by posix_spawnp (whose search consults the parent's PATH and retries scripts through /bin/sh). Each candidate is attempted with posix_spawn, so its exec runs in the child's cwd with the child's credentials, under execvp's rules: ENOENT/ENOTDIR move to the next component; EACCES is remembered and reported when nothing later succeeds; any other error — ENOEXEC included, there is no shell fallback — ends the search and is reported as is. A name containing / is spawned as spelled. An empty PATH component is the child cwd; an unset PATH searches _CS_PATH. A failed attempt leaves no child behind.

13.2 The child handle ​

d
/// How the child ended.
struct ExitStatus
{
    bool signaled;   /// killed by a signal rather than exiting
    int code;        /// exit code, or the signal number when signaled
    bool ok() const @safe pure nothrow @nogc => !signaled && code == 0;
}

struct ChildProcess
{
    int pid = -1;          /// -1 after a successful wait
    OwnedFd stdinW;        /// write end of the child's stdin  (piped only)
    OwnedFd stdoutR;       /// read end of the child's stdout  (piped only)
    OwnedFd stderrR;       /// read end of the child's stderr  (piped only)
    OwnedFd ptyMaster;     /// the PTY master (spawnPty only, §13.3)

    bool opCast(T : bool)() const;              /// true while reapable
    IoResult!void kill(int sig = SIGTERM);      /// signal the child
    IoResult!void killGroup(int sig = SIGTERM); /// signal its process group
}

IoResult!ChildProcess spawnProcess(scope const(char[])[] argv,
    in ProcessConfig cfg = ProcessConfig());

/// Parks until the child exits (in-ring WAITID). Sets pid = -1 on success.
IoResult!ExitStatus wait(ref Sched s, ref ChildProcess child);

ChildProcess is move-only: its ends are owners, so an end the caller never touches is closed with it, and an explicit close() is idempotent. wait is the only reap path: there is no SIGCHLD handler and no blocking waitpid anywhere in the library. kill after a successful wait returns ESRCH — the pid is gone and never reused through this handle (no wrap-around hazard: the handle nulls the pid before the kernel can recycle it).

The run-to-completion convenience — spawn, feed stdin, drain the piped outputs concurrently (parked reads, so a chatty child can never deadlock against an undrained pipe — the hazard runCaptured's temp files work around), reap:

d
struct CapturedOutput
{
    ExitStatus status;
    SharedBuffer!(ubyte, 256) stdout_;  /// empty unless stdoutSpec piped
    SharedBuffer!(ubyte, 256) stderr_;  /// empty unless stderrSpec piped
}

IoResult!CapturedOutput capture(ref Sched s, scope const(char[])[] argv,
    in ProcessConfig cfg = ProcessConfig(),
    scope const(ubyte)[] stdinBytes = null);

No exit of capture leaves a zombie. A drain fiber that cannot be admitted, a read that fails, and a cancellation that interrupts the run each end the child (SIGKILL) and consume its reap right on the shared pool's termination-critical lane (§13.8) under protect, so a latched interrupt is delivered at the caller's next checkpoint and never between the kill and the reap. The error reported is the first cause — the admission error (ENOBUFS), the read error, or the interrupt as ECANCELED; a failed read is never mistaken for EOF and never surfaces as truncated-but-successful output. The shared pool and the scheduler's inbox waker are secured before the child is spawned, so the reap can never depend on a resource acquired afterwards.

Portability as shipped: io_uring lowers OpWaitid to WAITID; kqueue lowers it to EVFILT_PROC/NOTE_EXIT and closes the register-vs-exit race with a waitpid(WNOHANG) back-check. The kqueue lowering is not yet reachable through the public package import. IOCP process waits are an M19 target (§13.9). The descriptor is portable-shaped; peer backends implement it without changing the fiber-level wait contract.

13.3 PTY spawning ​

d
/// Spawns argv as a session leader on a fresh PTY; the master rides the
/// handle's ptyMaster. cols/rows preset the winsize; resizePty follows the
/// host's resize events (a SIGWINCH relay is the caller's policy).
IoResult!ChildProcess spawnPty(scope const(char[])[] argv,
    ushort cols, ushort rows, in ProcessConfig cfg = ProcessConfig());
IoResult!void resizePty(ref ChildProcess child, ushort cols, ushort rows);

The child becomes a session leader (POSIX_SPAWN_SETSID) and acquires the slave as its controlling terminal by opening it inside the child (a spawn file action, ordered after the setsid attribute) — the reason this is posix_spawn-expressible at all. stdinSpec/stdoutSpec/stderrSpec are ignored for a PTY spawn: all three point at the slave. The master is an OwnedFd like any other — apps/terminal's per-frame EAGAIN drain becomes one parked ring read (the pty-drain example, completed by the in-ring reap that forkpty could not offer).

13.4 The proc capability ​

Like isNet's Stream/Listener, the concept names the capability's own child type, so the effects-side concept never imports the loop-side handle:

d
enum bool isProc(P) = isCapability!P && P.capName == "proc" && is(P.Child)
    && __traits(compiles, (ref P p) {
        IoResult!(P.Child) c = p.spawn(["true"], ProcessConfig());
        IoResult!ExitStatus st = p.wait(lvalueOf!(P.Child));
    });

RingProc (module live, Child = ChildProcess) is the live handler and joins Env (§11: Env = CtxOf!(RingClock, RingNet, RingProc)). SimProc (module proc, effects-side, Child = SimChild) is the deterministic double: scripted per-command stdout bytes and exit statuses, with no process and no ring — the double that makes an application's "run git, parse, decide" path a unit test.

13.5 Supervised runs (M19) ​

The application-facing operation is one ownership boundary around spawn, stdin, output, timeout/cancellation, sampling, drain, and reap:

d
enum ProcessStream : ubyte { stdout_, stderr_ }
enum ProcessEventKind : ubyte { line, sample, exited }
enum ProcessEnd : ubyte { exited, timedOut, cancelled, spawnFailed }

struct ProcessLine
{
    ProcessStream stream;
    const(ubyte)[] bytes; /// callback-borrowed; line terminator excluded
    bool terminated;      /// false for the final EOF fragment and a truncated head
    bool truncated;       /// the first maxLineBytes payload bytes of an over-cap line
}

enum SampleSource  : ubyte { none, cgroupFull, cgroupMembers, procScan }
enum MetricSource  : ubyte { none, procfs, cgroup, rusage }
enum MetricQuality : ubyte { unmeasured, exact, lowerBound }

struct ProcessResourceUsage
{
    size_t peakRssBytes, peakProcesses, sampleCount;
    Duration wallTime, userTime, systemTime;
    bool sampled;         /// false when the host has no tree sampler

    size_t peakCgroupMemoryBytes;    /// memory.peak (page cache included)
    size_t peakTasks;                /// pids.peak — tasks, not processes
    Duration cgroupUserTime, cgroupSystemTime; /// CPU charged to the run cgroup

    MetricSource memorySource, processSource, cpuSource,
                 cgroupMemorySource, tasksSource, cgroupCpuSource;
    MetricQuality memoryQuality, processQuality, cpuQuality,
                  cgroupMemoryQuality, tasksQuality, cgroupCpuQuality;

    SampleSource source;
    bool accountingSaturated; /// a per-sample work budget was hit
    bool samplingDegraded;    /// a sample was skipped, aborted, or refused
}

struct ProcessEvent
{
    ProcessEventKind kind;
    ProcessLine line;             /// valid for line
    ProcessResourceUsage usage;   /// valid for sample
    ExitStatus status;            /// valid for exited ONLY when reap == reaped
    ReapOutcome reap;             /// valid for exited
    ProcessEnd end;               /// valid for exited
}

struct SupervisedProcessConfig
{
    ProcessConfig process;
    Duration timeout = Duration.zero;      /// zero = no deadline
    Duration terminateGrace = 5.seconds;
    Duration sampleInterval = 250.msecs;   /// zero = final accounting only
    bool collectOutput = true;
    size_t maxLineBytes = 1024 * 1024;     /// framer cap per line; 0 = unbounded
    size_t maxCapturedBytes = 64 * 1024 * 1024; /// per raw stream; 0 = unbounded
    ResidualPolicy residualPolicy = ResidualPolicy.bounded; /// after root exit (§13.7)
    Duration outputGrace = 5.seconds;     /// bounded (and wait's fallback): output grace
    Duration killDrainWindow = 1.seconds; /// post-kill / detach drain budget before forced EOF
}

struct SupervisedProcessResult
{
    ProcessEnd end;
    ExitStatus status;             /// valid once a child existed and was reaped
    ProcessResourceUsage usage;
    SharedBuffer!(ubyte, 256) stdout_;
    SharedBuffer!(ubyte, 256) stderr_;
    IoError spawnError;             /// valid only for spawnFailed
    size_t truncatedLines;          /// lines that exceeded maxLineBytes
    bool stdoutTruncated;           /// raw collection hit maxCapturedBytes
    bool stderrTruncated;
    bool eofForced;             /// a still-open pipe was forced closed at a drain window
    bool terminationDegraded;   /// a REQUESTED hard tree kill fell below the tier's promise
    IoError terminationError;   /// the primary cause (pgid error first, else cgroup)
    bool residualPolicyDegraded; /// `wait` fell back to `bounded`
    IoError residualPolicyError;
    bool cgroupCleanupLeaked;   /// the run's cgroup directory remained (removal only)
    ReapOutcome reap;           /// `status` is valid ONLY when reap == reaped
}

enum ReapOutcome : ubyte { notApplicable, reaped, lostToExternalReaper }
enum ResidualPolicy : ubyte { bounded, wait, detach }
struct KillOutcome { KillResult kind; int errnoValue; }
enum KillResult : ubyte { notAttempted, delivered, targetAbsent, failed }

alias ProcessEventSink = void delegate(in ProcessEvent event);

IoResult!SupervisedProcessResult supervise(ref Sched s,
    scope const(char[])[] argv,
    in SupervisedProcessConfig cfg = SupervisedProcessConfig(),
    scope const(ubyte)[] stdinBytes = null,
    scope ProcessEventSink onEvent = null);

supervise is a structured operation: it does not return while a child, pipe operation, process wait, timer, sample, or blocking-pool job owned by the run is live. Stdout and stderr are separate byte streams even when events interleave; mergeStdout is the explicit exception and reports all merged lines as stdout_. collectOutput controls accumulation only, never draining or event delivery. Every buffer a run keeps is bounded: maxLineBytes caps one framed line and maxCapturedBytes caps each raw collection; overflow is reported in the events and the result (§13.6), never silent. Spawn failure creates no child, emits no exited event, and is reported as spawnFailed plus spawnError; non-zero child exit is data, not an IoError.

The event sink runs synchronously on the supervising scheduler fiber and must not retain ProcessLine.bytes; it may copy them. Exactly one exited event is the final event for a child that was created; it is emitted only after both streams reached EOF and the child was reaped, even when the OS reported exit earlier. No callback runs after supervise returns. SimProc gains a scripted event/sample path so application policy remains testable without a host process. Scope cancellation remains latched on the fiber (§8.4); supervision delays its delivery only long enough to complete cleanup and publish the final event.

13.6 Line framing and stream ordering (M19) ​

Each pipe has an independent incremental framer over arbitrary bytes. LF ends a line; one immediately preceding CR is removed, so LF and CRLF produce the same payload. Embedded NUL and invalid UTF-8 are preserved. Empty lines are events. EOF emits one final event with terminated == false iff bytes remain; EOF immediately after a terminator emits nothing extra. A read may contain many lines and a line may span many reads; kernel chunking is never observable as line boundaries.

Order is preserved within each stream. There is no invented total order between separate stdout and stderr pipes: events are delivered in completion order. Callers requiring one kernel stream and one total order select mergeStdout. Framing storage is bounded by maxLineBytes (payload bytes; a CR counts only when no LF follows it, so a cap of 3 accepts "abc\r" + "\n" as the untruncated line abc). A line longer than the cap is delivered exactly once as its first maxLineBytes payload bytes with terminated == false and truncated == true; the remainder of that line is discarded through its LF and framing resumes on the next line. EOF during that discard emits nothing extra. Nothing is ever truncated silently: the result counts truncatedLines, and the raw collections stop at maxCapturedBytes with stdoutTruncated/stderrTruncated set. Framing is linear in the bytes read — a long line arriving in many reads is never rescanned. The final collected buffers contain the exact raw bytes, including original terminators, independently of the normalized line events, up to the collection cap.

13.7 Timeout, cancellation, drain, and reap (M19) ​

Timeout and scope cancellation use one idempotent state machine:

text
running -> termRequested -> grace -> killRequested -> exited -> drained -> reaped
       \-------------------------------------------> exited -> drained -> reaped

The first timeout/cancel trigger wins the reported ProcessEnd; later triggers only advance cleanup. The supervisor closes its stdin writer, sends the graceful termination request to the supervised tree, and arms one monotonic grace timer. Natural exit during grace suppresses the hard kill. Expiry sends the hard-kill request once. POSIX maps these stages to SIGTERM then SIGKILL against a fresh process group; supervise creates that group regardless of the low-level newProcessGroup default. Windows maps them as specified in §13.9.

Termination never abandons output. Both output pipes drain concurrently to EOF, the process wait completes, and the OS status is consumed exactly once before return. Exit-before-EOF and EOF-before-exit are both valid; neither alone ends the run. All paths, including event-sink failure represented by surrounding scope cancellation, converge on the same cleanup. A ChildProcess/supervisor state transition atomically consumes the reap right; a second wait, drain, or termination request is an idempotent no-op where cleanup calls need it, or ECHILD/ESRCH on the explicit low-level API. PID/handle reuse can never make a stale operation target another process.

Containment tiers (Linux). A supervised run owns one cgroup v2 directory beneath the process's own cgroup, <own>/eh-run-<pid>-<id>, and what that directory can do decides the tier:

tiermeanshard killobservation
noneno owned directory (cgroup v1, no delegation, a read-only cgroupfs)pgid only/proc scan
owneda directory with cgroup.kill, cgroup.events and cgroup.procspgid and cgroup.killmembers + cpu.stat; peaks from /proc
accountedowned, and the memory and pids controllers are enabled for the subtreeas ownedmemory.peak / pids.peak exact for the run cgroup

The probe reports what exists: cgroup.subtree_control cannot be written on a cgroup that has processes (the no-internal-process rule), so a library that finds itself in a populated delegated cgroup — the ordinary case — gets owned, and accounted only where the host prepared the controllers. Every tier keeps the private process group; the cgroup adds recursion (a setsid escapee that stayed in the cgroup) and, on every hard kill, cgroup.kill is written whenever the capability is owned — an empty subtree is an idempotent success, never a reason to skip it.

Acquisition is transactional: dirCreated is published the moment mkdir succeeds, and whoever observes it owns the bottom-up cleanup, whatever later failure lowered the tier (a failed control-file open, a failed migration). The cgroup.kill capability is a property of the created directory, retained until cleanup — never inferred from a membership snapshot: cgroup.procs lists direct members only, and a nested cgroup can hold live processes while it reads empty. cgroup.events' populated bit is the only recursive evidence (1 iff the cgroup or any descendant has a live process); a failed read is unknown, never empty. Evidence drives the residual policy, telemetry, and cleanup — it never gates a requested kill.

Lane assignment (§13.8): the pre-spawn probe and mkdir and the post-spawn cgroup.procs migration run on the public lane, and a refused submission degrades the run (the tier, or only that run's sampling) before or without a child at risk; cgroup.kill and the cleanup run on the termination-critical lane. The cleanup waits — deadline-bounded (~2 s), on the cgroup.events descriptor, never by polling the scheduler — for populated 0, then removes nested cgroups bottom-up and the run directory; a directory that remained is reported as cgroupCleanupLeaked, which describes directory removal only, never a failed kill. Cleanup completes before the final result is read.

A process the root forks before the post-spawn cgroup.procs migration lands is outside the run cgroup: the write is issued inline the moment the spawn returns (a bounded in-memory kernfs operation, microseconds against the hundreds a fresh program needs to reach its first fork), which closes the window in practice but not by construction — the private process group still covers such a fork for termination; wait and the cgroup roster do not see it. Containment by construction needs clone3(CLONE_INTO_CGROUP), a planned spawn path.

The control plane. Every worker of a run — the two drains, the stdin feeder, the root observer, the clock, the sampler and (under wait) the evidence observer — is a shielded child of one scope (§8.2) that only the supervising fiber ends. The supervising fiber is the single owner of the tree-state machine and of every kill; the root observer performs the one non-consuming WNOWAIT wait through an explicit lifecycle (notAdmitted → initializing → inFlight → observed | lost | failed, or laneOwned when the emergency owner takes the observation over with finite WNOHANG probes and alarm backoff), so no second wait for the same child can ever be submitted; the clock owns six one-shot, ring-free alarms — timeout, grace, the descendant output grace, wait's fallback grace, the post-kill drain window, the detach drain window — each armed at most once per run. Clocks and the sampler never block and never initiate termination: they publish level-triggered command bits carrying the epoch they were armed under plus one best-effort wake token, and the supervisor drains the bit set after every relay message, clearing each bit before acting on it; a command whose epoch is stale is discarded.

The tree-state machine. One machine drives the natural-root path and every requested termination:

text
live ─(root observed)─┬─ bounded, streams terminal ───────▶ killQueued
                      ├─ bounded, streams open ─▶ graceArmed(natural) ─(expiry)─▶ killQueued
                      ├─ wait ──▶ waitPopulated ─(populated 0)─▶ waited
                      │                         └─(evidence lost)─▶ graceArmed(fallback)
                      └─ detach ─▶ detached ─(drain window)─▶ forceEof ─▶ detachedDrained
graceArmed(natural|fallback) + streams terminal ──▶ killQueued   [the output grace ends with the output]
graceArmed(requested) + root observed + streams terminal ──▶ killQueued
requestTermination(any pre-kill state) ───────────▶ graceArmed(requested)  [TERM+CONT once; first trigger wins]
killQueued ─(pgid SIGKILL, then cgroup.kill)──────▶ killSettled | killFailedAwaitRoot
killFailedAwaitRoot ─(natural root observation)───▶ killSettled
killSettled + streams terminal ───────────────────▶ drainDone   [else the drain window forces EOF]
resolved: waited | detachedDrained | drainDone

ResidualPolicy (SupervisedProcessConfig.residualPolicy): bounded (the default) is an output grace — after root exit, descendants still holding an output pipe get outputGrace; once root exit and EOF both hold, the tree is killed at once, so a daemon that cleanly closed stdio gets no lifetime grace and a trivial run costs one kill(-pgid, SIGKILL) against a group holding only the zombie. wait (owned-cgroup tiers only; EOPNOTSUPP after negotiation elsewhere) stays until EOF and the run cgroup reads recursively populated 0; it is cgroup-scoped by definition — a descendant that left the cgroup while staying in the pgid is neither waited for nor killed — and degrades to bounded (with residualPolicyDegraded and its error) when the evidence is persistently unavailable or the migration failed. detach gives up termination ownership, not promptness: pipes still open at killDrainWindow after root exit are forced closed (eofForced), cleanup does not wait for the residual, and cgroupCleanupLeaked may say so.

On every killQueued entry the identity-pinned pgid SIGKILL and, whenever the run owns the capability, cgroup.kill are both issued unconditionally — evidence never suppresses a requested kill. Each action yields a recorded KillOutcome; the public truth table: pgid delivered + cgroup delivered or not owned → not degraded; pgid delivered + cgroup failed → degraded, error = cgroup errno; pgid failed (or ESRCH under the pinned zombie) → degraded, error = pgid errno, whatever the cgroup did. When both mechanisms fail while the root is still live, no ownership boundary permits returning: the run enters killFailedAwaitRoot and stays until the root's natural exit — the one state no configured duration bounds. terminationDegraded/terminationError cover every requested hard tree kill (timeout, cancellation, the residual policy) that fell below the tier's promise; cgroupCleanupLeaked describes directory removal only.

The terminal sequence. The reap predicate is the terminal predicate — streams terminal ∧ root resolved ∧ tree resolved — so the WNOWAIT-observed zombie pins the process group for the whole active run and no post-reap group signal is possible by construction. Protection is a property of that boundary: the supervisor enters protect at the terminal decision and stays protected through the control freeze, the sampler-finalization handshake (§13.8 — the final sample runs before the reap), the reap, and the worker stop; it re-enters protect for the shared post-join finalization (cgroup cleanup on the termination-critical lane, the frozen fields, the exactly-once exited event), and only then is a latched interrupt resolved: an outer cancellation keeps first-trigger precedence and is delivered at the caller's next checkpoint; the scope's own admission sweep is consumed. The reap follows a result table: an in-ring success consumes the right; ECHILD — and only ECHILD — proves it lost (ReapOutcome.lostToExternalReaper; status invalid, the exited event self-described by its reap field); transient errors retry inside the boundary; any other in-ring error goes to the termination-critical waitpid fallback while identity is still pinned; a fallback that can neither succeed nor prove loss has no ownership boundary and ends the process through the loop's fatal hook (§5.2). A defect in the supervisor, or a post-spawn admission failure, runs the same sequence as a pre-join emergency phase inside the scope body — protected as its first statement — so the join never waits on workers only the owner can end.

Return bounds (worst-case shapes, not exact paths): the drain boundary for timeout == 0 under bounded is rootExit + outputGrace + termination-lane service delay + killDrainWindow; for a requested termination, timeout + terminateGrace + termination-lane service delay + killDrainWindow; the public return adds the cleanup job's queue delay, the populated-wait deadline (~2 s) and the recursive removal work. killFailedAwaitRoot is exempt.

13.8 Resource samples and scheduler-integrated blocking work (M19) ​

Samples cover the supervised process tree, not only the root. They report peak summed RSS, peak live-process count, elapsed wall time, and best-effort user/system CPU. Linux samples /proc; Darwin uses proc_pid_rusage plus proc_listchildpids; Windows queries the Job Object (§13.9). A sample is taken before checking exit so a just-exited root remains observable. sample events carry cumulative values. Final usage is always emitted in the result; wallTime is always valid, while unsupported counters leave sampled == false rather than fabricating zero-valued measurements. At most one sample operation is in flight per run. If sampling takes longer than its interval, missed instants coalesce into the next cumulative sample rather than queuing stale tree walks. The sampler owns all sampling: every sample runs on the blocking pool's public lane (a refused submission skips that instant), never on the loop thread, and the final sample is a request/ack handshake the supervisor completes before the reap — the reap deletes /proc/<pid>. The sampler's lifecycle (notAdmitted → initializing → running → finalRequested → acked → exited, or skipped when the owner decides first) makes running → finalRequested → acked the only sampled terminal path; anything else is a degraded skip, and samplingDegraded is monotonic.

Sources and the metric truth table. A run's sample source follows its containment tier (§13.7): cgroupFull (an accounted cgroup), cgroupMembers (an owned one: the roster from cgroup.procs, cpu.stat, peaks from procfs) or procScan (no cgroup: /proc scanned for the root's descendants and its process group). Every metric carries its source and its quality — what the number is the truth about:

metricsourcequality
peakRssBytes, peakProcessesprocfs, every tierlowerBound (sampled peaks)
userTime, systemTime (tree CPU)procfs, or cgroupalways lowerBound: containment is unenforced (a same-uid descendant can leave the cgroup unobserved); fed from max(procfs accumulation, cgroup CPU)
cgroupUserTime, cgroupSystemTimecgroup cpu.statexact for the run cgroup while every read succeeds; lowerBound after a failed read
peakCgroupMemoryBytes, peakTaskscgroup peaks (accounted)exact while every read succeeds; lowerBound after a failed read

"No migration observed" never upgrades tree CPU to exact.

Fail-closed root anchor and transactional samples. The root's /proc/<pid> directory is held open from one named point — immediately after the child exists, before any worker runs — together with its identity (pid + start time read through that handle); both live until the reap and are released on every path. Acquisition failure disables pid-based sampling for the run (procfs metrics stay unmeasured, samplingDegraded; cgroup counters are unaffected). Every proc sample validates the anchor first, collects into sample-local scratch, validates again, and only then merges — a disappearance or mismatch at either validation discards the whole sample (metrics and ledger changes alike): a replacement root and its fresh descendants would otherwise satisfy the numeric descent and group tests. Members hold no persistent descriptor: each is opened, validated by start time (and, in the cgroup tiers, by its /proc/<pid>/cgroup line equal to the run path or continuing with /, read through the same handle), read, and closed within one sample.

Bounded cumulative CPU ledger. CPU is a live map with its own capacity (1024), a scalar of retired contributions, and a bounded retired-key set (4096); the total is retired + Σ live, so a sample costs O(live members). A newly discovered identity is admitted only while a live slot is free and the ledger is not sealed; a present member updates its running maximum; a member that disappeared — or whose pid now carries another start time — moves its last-known contribution (a lower bound) into the scalar and its key into the retired set; a scan omission, read failure, or exhausted budget retains the entry unchanged, never as proof of death. Once the retired set is full the ledger seals: no admission ever again, and a live entry that dies afterwards becomes a tombstone in place (cpu = 0, never summed, never re-admitted) in the same merge that moves its contribution — the total does not jump. Every saturation path degrades to a lower bound, never to an overcount.

Per-sample work budget. Entries scanned or members probed, bytes read, and a cooperative time slice are bounded per sample; every live entry is re-probed from a rotating cursor so an early budget end does not starve the same tail; a budget hit sets accountingSaturated and keeps the qualities at lowerBound. The budget is a bound on work, never a reap-latency proof.

Operations that cannot be represented by the platform completion backend run on BlockingPool, a bounded scheduler service distinct from RawCpuPool and WorkStealingPool. Submission parks the calling fiber, a persistent DRuntime worker performs the blocking host call, and completion enters the owner loop through the scheduler's own blocking-completion inbox plus Waker (the inbox is scheduler-owned: no registration, no capacity, no stale callback — a pending job implies a parked caller, which is what forbids destroying that scheduler). Public queue saturation returns EAGAIN; it never runs blocking work inline on the loop thread. A package-internal termination-critical lane admits reap and teardown jobs beyond queueCapacity: intrusive and frame-resident, so admission needs no queue slot, serviced by one reserved worker that takes nothing else, with ordinary workers helping only while the public queue is empty. Its guarantee is eventual FIFO service under finite, terminating jobs — no numeric bound — and its aggregate size is the sum of the live fiber slabs of every bound scheduler. Preparation (prepare(ref Sched), which arms the scheduler's waker and consumes one op slot) happens before a supervised child is spawned, so a later slab saturation cannot block the reap. Cancellation is the entry checkpoint only: an accepted job is uncancellable until its completion is delivered, and a latched interrupt is delivered at the caller's next checkpoint afterwards. Pool shutdown rejects new jobs, lets workers finish and post every accepted completion exactly once, then joins them; delivery still requires the owning scheduler to tick, which a scheduler with a parked run caller necessarily does. POSIX spawn portability helpers, Darwin sampling calls, and any fallback process wait use this pool; no path introduces Thread.sleep, a polling wait, or a second application scheduler.

d
alias BlockingCall = void function(void* context) nothrow;

struct BlockingPool
{
    @disable this(this);
    static IoResult!void create(out BlockingPool pool,
        uint workers = 0, uint queueCapacity = 64);
    IoResult!void run(ref Sched s, BlockingCall call, void* context);
    IoResult!void shutdown();
}

The call writes its typed result into caller-owned context; run returns only after its completion, including when cancellation arrived meanwhile. The parked frame therefore remains the ordinary lifetime proof. Sched owns or is bound to one pool rather than constructing a pool per operation.

13.9 Windows lowering (target M19) ​

Windows process parity is native Win32, not a POSIX emulation:

  • spawn uses CreateProcessW; argv is encoded with the documented CommandLineToArgvW inverse quoting rules, strings/cwd are UTF-16, and the double-NUL environment block is case-insensitively sorted after applying §13.1's overlay;
  • the child is created suspended, assigned to a per-run Job Object, then resumed, so descendants cannot escape between creation and supervision; inability to assign the requested job fails spawn and terminates/reaps the suspended child;
  • piped streams use uniquely named pipes whose parent ends are opened with FILE_FLAG_OVERLAPPED and associated with the scheduler's IOCP. Child ends alone are inheritable; STARTUPINFOEXW plus a handle list prevents accidental inheritance. ReadFile/WriteFile completions use the ordinary op-slot lifetime rule (§4.3), including CancelIoEx terminal completion;
  • process exit uses one RegisterWaitForSingleObject registration. Its callback posts the wait result to IOCP; unregister and process/thread-handle closure happen only after that completion is consumed. The process exit code is read once and represented as ExitStatus(signaled: false, code: value);
  • the Job Object has JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. newProcessGroup additionally requests CREATE_NEW_PROCESS_GROUP; graceful termination sends CTRL_BREAK_EVENT when the child has a compatible console group. If no console control channel exists, the graceful stage records that no soft signal was deliverable and still observes the configured grace. Hard kill is TerminateJobObject, covering the whole descendant tree;
  • sampling uses QueryInformationJobObject accounting and process/memory classes. If Job Object accounting is unavailable, the sample has sampled == false; wall time remains valid.

There are no anonymous blocking pipes, polling PeekNamedPipe, helper wait loops, or per-process threads in the Windows path. IOCP remains the single I/O wait. This Windows work does not alter Linux backend selection: Linux continues to require io_uring with no epoll fallback (§3.4).

14. Channels ​

Module channel (effects-side — it parks through the executor seam, never touching the ring). The producer/consumer primitive O20 deferred to its first consumer; the consumer arrived (the PTY-reader → render-fiber and input-fiber → UI-fiber handoffs of §15.3).

d
/// Bounded intra-worker SPSC-ish channel (any number of fibers may put or
/// take, but all belong to ONE executor — cross-worker channels are the
/// M9 MSG_RING work). Capacity is fixed at creation.
struct Channel(T, uint N = 16)
{
    IoResult!void put(X)(ref X exec, T item);   // parks while full
    IoResult!T take(X)(ref X exec);             // parks while empty
    bool tryPut(T item);                        // non-parking
    bool tryTake(out T item);                   // non-parking
    void close();      // wakes all: put fails EPIPE; take drains then EPIPE
    bool closed() const;
    uint length() const;
}

Semantics: put/take are cancellation checkpoints (park through the same one-shot cancel discipline as I/O parks — §8.4's "non-I/O park" clause, previously occupied by JoinHandle.join alone). close is idempotent; takers drain buffered items before observing EPIPE — no data loss on graceful shutdown. Elements move in and out (T need not be copyable). The channel itself is address-pinned like every intrusive structure (§8.1): create it in a frame that outlives all users — scope join order makes that natural.

15. The UI event loop ​

The integration layer that lets an interactive application — apps/hue, apps/terminal, the sparkles:ui-app host (ui-app spec, esp. HST9) — make event-horizon its only loop: one wait point observing input, frame pacing, subprocesses, file watches, and timers together. The window-system research (recommendations §2.2, §2.9) fixes the long-term shape — callback-first loop ownership, a frame clock folding native vsync sources; this section is the substrate those land on.

The normative native consumer is now sparkles:wsi, whose WSI3 forbids a second application loop. Linux display fds use §15.1 directly; Win32/AppKit attach their OS-owned message/run-loop source through the smallest backend-neutral host hook proven by the WSI M2 spikes. The Win32 path is concrete: EventLoop.runHostedOnce (and Sched.tickHosted) owns reset → non-blocking drain → combined wait → dispatch ordering. IOCP mirrors queued completions through one manual-reset event and narrows the host timeout to its next timer; the host combines that event with User32 through MsgWaitForMultipleObjectsEx. One shared event is safe despite each overlapped submission resetting hEvent: the backend repairs an older queued completion after issue, and the hosted loop resets before its closing drain, so both sides of the race preserve a wake.

The AppKit path uses the same seam without duplicating kqueue: the backend exposes its native descriptor as an opaque host token, the WSI host wraps that descriptor in a CFFileDescriptor source installed in kCFRunLoopCommonModes, and CFRunLoopRunInMode is the combined wait. Kqueue timers and EVFILT_USER wakes make the descriptor readable; the closing zero-time runOnce performs kevent and dispatches completions. waited.value counts a native source handled during the wait, even when the subsequent non-blocking NSEvent drain has nothing left to dequeue.

WSI supplies only dispatchPending and the platform's combined wait; it may not reproduce IOCP, kqueue, timers, channels, or wake accounting in its own package.

15.1 Foreign-fd readiness ​

OpPollAdd (§4.1) completes when a descriptor is ready rather than when I/O finishes — the op for descriptors whose I/O someone else performs: a windowing connection (Wayland/X11), a library-owned socket. The completion res carries the ready PollEvents; multishot: true keeps one submission armed and delivers a completion per readiness edge (flags.more). PollEvents is a portable POD over the POLLIN/POLLOUT/POLLERR/POLLHUP subset every backend can honour: uring lowers to POLL_ADD (+ADD_MULTI); kqueue registers EVFILT_READ/EVFILT_WRITE and — uniquely for this op — performs no syscall at readiness; IOCP has no general poll and does not lower it (canSubmitOp gates every call site, §3.1).

The readiness result is a level snapshot, not a byte count: the caller performs its own non-blocking I/O (or calls the foreign library's dispatch) and re-arms. This is deliberately the winit/calloop "loop-as-pollable-fd" integration inverted: event-horizon does not export its fd for a foreign loop to poll — foreign fds join this loop, which is what "owning the event loop" means.

The normative foreign-fd consumers are the Linux adapters in sparkles:wsi. X11 uses a single-shot poll because XCB can also hold already-buffered events after a reply read: each integrated iteration drains xcb_poll_for_event before it may block, services the readiness completion, and re-arms. Wayland uses the same op for its stricter state machine: pending dispatch → prepare_read → flush → poll → exactly one of read_events/cancel_read → pending dispatch. Writable interest is included only while flush is backpressured. Both synchronous detach paths call cancelAndWait before the native connection and callback context can be destroyed. The same cancellation barrier temporarily lends Wayland's shared display to renderer APIs such as Mesa Vulkan WSI: beginNativeIo cancels the outstanding prepared read, the foreign call may round-trip on the owner thread, and endNativeIo restores the prepare/read poll. A raw handle does not authorize two simultaneous display readers.

15.2 Wake, pace, hand off ​

Three primitives specified elsewhere combine into the loop's UI substrate: the external waker (§5.6) for off-thread nudges (a non-loop thread finishing work, a future requestFrame from another thread); Ticker (§7.3) as the frame clock — absolute-deadline pacing, missed ticks skipped; Channel (§14) for fiber-to-fiber event handoff. None of them is UI-specific; together they are sufficient.

15.3 Owning an application's loop ​

The recommended shape inverts today's while (!WindowShouldClose()): the application's frame loop runs inside the root fiber, and everything else is sibling fibers on the same scheduler. The thread's single blocking point is the ring wait; the app never blocks anywhere else.

The TUI shape (consumer: sparkles:ui-tui, then hue's workspace loop):

  • an input fiber: chunked ring reads of the raw-mode stdin feeding a resumable escape-sequence assembler (the sparkles:tui pure decoders over a buffer, not its one-byte blocking pulls); decoded sparkles:input events go into a Channel;
  • a signal fiber: SignalFd([SIGWINCH]) → resize events into the same channel (replacing the EINTR trick — the loop's reads must not rely on signal interruption);
  • subprocess fibers per background job (git status, oracles): spawn, read the pipe, post a typed result;
  • the UI fiber: take from the channel (parking — zero idle CPU, the HST9 "blocks on input" arm), or race the channel against a Ticker when animating (the idle-interval arm); paint; present.

The GUI shape (consumer: sparkles:ui-raylib, then hue --gui): the GPU surface has no event fd under raylib/GLFW, so input is sampled, not awaited — the root fiber runs a Ticker at the target rate; each tick pumps raylib's polled input synthesis (RaylibEvents.poll), folds, paints, and swaps with raylib's own pacing disabled (SetTargetFPS(0), no vsync block). Between ticks the thread parks in the ring wait, where subprocess and watch completions run — the "background work without dropping input" obligation, met by construction. Raylib is thereby demoted to render + window + input sampling; cadence, waiting, and every async source belong to event-horizon. (A sampled-input tick is the honest v1: a waited input path needs the display connection's fd, which raylib does not expose — that is the window-system replacement's §15.1 integration, not this spec's.)

The native GUI shape (consumer: sparkles:wsi): Wayland/X11 readiness and native Win32/AppKit callbacks append Regular events and wake this scheduler; the application drains them at one non-reentrant boundary. Native frame opportunities replace the fixed Ticker where reliable. The root fiber remains the one place application code runs, so a platform modal loop can perform mandatory native replies without re-entering the application.

Android. Android is Linux, but its app seccomp policy denies io_uring_setup — and there is no epoll fallback in this library by design (§3.4). The Android triple therefore selects the kqueue peer backend over mheily/libkqueue (an epoll shim the APK links statically) as its DefaultBackend — the same backend + shim combination Linux CI exercises via EventHorizonLibkqueue. The loop, scheduler, waker (eventfd), timers, poll, signalfd, and inotify all run; proc reap uses kqueue's shipped EVFILT_PROC lowering (§13.2).

Degradation. Loop creation can still fail where a UI must run (an unforeseen sandbox, a broken shim). UI adapters therefore keep their plain paced loop as an explicit fallback arm, selected once at startup on loop-creation failure — honest degradation in the §3.2 spirit, never a silent behavioral fork.

16. Public API surface ​

Re-exported from sparkles.event_horizon (package.d):

AreaSymbols
ErrorsIoError, ErrorKind, IoErrorStage, OpKind, NoGcHook, IoResult, ioOk, ioErr, fromRes
CausesCause, Interrupt, InterruptKind, Outcome, widen
Tier AEventLoop (runOnce, conditional runHostedOnce), DefaultLoop, LoopConfig, RunStatus, OpHandle, OpClass, Completion, CompletionFlags, OpCallback, op descriptors, SockAddr, KernelTimespec, BackendConfig, Waker, LoopHandle
BuffersBuf, BufOrigin, BufGroupId, BufResult, BufferPool, BufRing, isOwnedIoBuf
ProbingBackendCaps, BackendId, LoopMode, ModePolicy, probeSystem
Tier BSched (including conditional tickHosted), SchedOptions, RootScope, the io verbs (incl. sleepUntil, waitReadable, waitWritable), Ticker, Stream, Listener, DgramSocket, OwnedFd, BorrowedFd, isFdBorrowable, currentTask
SubprocessesStdioMode, StdioSpec, ProcessConfig, ExitStatus, isProc, SimProc, ChildProcess, spawnProcess, spawnPty, resizePty, wait, RingProc
Supervision (M19)EnvironmentChange, ProcessStream, ProcessLine, ProcessEventKind, ProcessEvent, ProcessEnd, ProcessResourceUsage, SampleSource, MetricSource, MetricQuality, ResidualPolicy, ReapOutcome, KillOutcome, KillResult, SupervisedProcessConfig, SupervisedProcessResult, supervise, BlockingPool
ChannelsChannel
ScopesScope, isScope, withScope, withDeadline, protect, checkCancellation, JoinHandle, ScopeOptions, OnChildFailure
CapabilitiesisCapability, Ctx, ctx, CtxOf, hasCaps, isWaker, isFiberExecutor, isClock, TestClock, isNet, isByteStream, SimNet, TestSched, advanceAndSettle, ipv4, ipv6, unixSocket, isVfs, RingVfs, Env
Schedulesrecurs, spaced, exponential, jittered, upTo, retry, repeat, timeout, race
TopologyLoopGroup, LoopGroupConfig, Topology, RawJob, RawCompletion, RawPoolResult, RawCpuPool
Veneer (M12)succeed, effect, map, andThen, zipPar, withRetry, withTimeout, run

Memory-management policy (normative): allocating types follow the composable-allocator guidelines — generic over an Allocator template parameter (stateSize embed idiom, Mallocator default, duck-typed so RCIAllocator works for runtime choice), slabs via makeArray/dispose. All library allocations are setup-phase; the M8 gate proves the steady-state hot path allocation-free with a StatsCollector-instrumented pool plus a GC.allocatedInCurrentThread delta assertion.

Attribute policy (normative): non-template functions carry explicit @safe/pure/nothrow/@nogc where true; templates and anything generic over a backend, buffer, capability, or callable let attributes infer; @trusted appears only on minimal syscall-edge lambdas and the documented address-pinning seams (§8.1). Ref-returning accessors use bare return (ReturnRef) — adjacent return scope means ReturnScope and is reserved for functions returning pointer-carrying values (Buf.opSlice); template inference masks the difference, non-templates do not. Unittests static-assert the inferred attributes of EventLoop!UringBackend.submit/runOnce and the io verbs.

17. The fork server ​

Process-level isolation with copy-on-write inheritance (sparkles.event_horizon.forkserver, Posix): a zygote child forked while the host is still single-threaded — the one moment a D process may fork safely; druntime installs no GC/malloc atfork handlers on the default GC, so a foreign thread holding a lock at fork time would deadlock the child — that runs an optional childInit once and then serves requests, forking a grandchild per request. The grandchild inherits everything the zygote initialized by CoW (the motivating client: DMD's frontend globals — dmd-fmt formatting, dmd-lsp's one-analysis-per-process limit, hue's live-types oracle), runs the registered ForkHandler over a slot of the shared arena, and _exits: its heap, its global mutations and its crashes die with it.

Transport and memory:

  • a SOCK_SEQPACKET socketpair carries fixed-size request/response frames (message boundaries preserved, so a frame travels whole or not at all); the parent's end doubles as the completion fd for pollAdd integration;
  • the arena is one anonymous shared mapping (MAP_SHARED | MAP_ANON) created before the fork — no name, no fd, no platform split; request and result bytes move zero-copy, untouched slack pages are free;
  • start refuses (an error, never an assert) in a multi-threaded process, so hosts fall back to a worker-thread backend when started late.

Failure contract: a grandchild crash reports -signal in the response status (one lost request, never a lost host); a handler that cannot fit its result reports the documented overflow exit code; a handler throw reports its own exit code. The zygote survives all three and keeps serving.

v1 (M18) is deliberately minimal: one registered handler (kind demuxes), fixed-size slots, and a serial zygote — the shipping client (hue's format preview) is single-flight by design. Parallel grandchild fan-out (per-process globals mean there is no lock to contend) is recorded follow-up, not built.

Additive tutorial ergonomics ​

The direct-style core remains available unchanged. These conveniences centralize repeated application code without changing the raw completion ownership model:

SurfaceContract
LoopGroup.runResultA body returns IoResult!T; the caller receives Outcome!T. Body failure enters the scope policy before children join. First recorded failure wins; a latched body interruption remains an interruption, including a swallowed ECANCELED. Escaped defects still unwind/join and propagate.
runApplicationOwns a pinned single-topology group in its frame, checks startup and destroys before returning. Returns an outcome; never prints or terminates the process.
sendAll, writeAll, readExactlyOperate on buf[], not spare capacity. Return ownership and the completed prefix count on every path. Premature EOF/zero progress is EIO; positioned-range overflow fails before submission. Empty requests do not submit.
readToEnd, readTextRequire an explicit byte bound. Read-to-end allocates owned storage; text additionally allocates a GC string, with no Unicode validation. Errors discard accumulated data; detecting an oversized input consumes one byte beyond the bound.
localAddress, portPOSIX socket query and IPv4/IPv6 port extraction; usable without a scheduler. Invalid/truncated families fail explicitly.
env.fs / RingVfsPresent on every loop backend (§10.5). Opens are close-on-exec; created entries follow the capability VFS sharing rules, by default the platform's ordinary sharing less the umask.
withFileSuperseded by the capability VFS's owning File, which closes exactly once (§10.5).
withSocketLinux lexical socket owner with checked, non-retried close; same borrowed-body discipline. No cleanup slot or asynchronous destructor.
Typed submit, submitAfter, submitAtGenerate a function-pointer trampoline for (ref State, ref Completion). Handler remains nothrow/@nogc. Context remains caller-owned and pinned through terminal completion or detach; cancel submission alone does not discharge its lifetime.
scope.join(handle)Uses the scope's executor and rejects handles belonging to another scope. The handle remains caller-owned and pinned.
handle.effectsThe Effect!T form of every operation of a capability VFS handle, generated from the direct form: effects.op(args), or effects.call!("op", ct...)(args) for an operation with template arguments (§10.5).
Inferred effectOf!fn()Infers value/error types for concretely typed bodies, reusing the existing interpreter and zipPar. Generic bodies retain the explicit type form.

Design decisions: retain copyable socket handle views (descriptors of files, pipes and ptys follow the owner and borrow split of §7.3), address-pinned join slots and explicit raw callback registrations. A general managed callback owner would add storage and dispatch-time destruction rules; typed-context prototypes do not justify that additional layer yet. No new task DSL or hidden fiber wrapper is introduced. Lexical resource helpers do not make escaping borrowed views safe.

Expected 0.4.1 needs map!(fn, NoGcHook) when preserving EH's custom hook. For a value-to-void transformation, use andThen returning ioOk(); this version's map cannot instantiate that transformation. Ordinary failures are not defects and are not replaced by throwing unwraps or fallback success values.