From 9d71a05c035d5f5c22b8f91f690f74f8e5af3af8 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 03:10:03 +0400 Subject: [PATCH 1/5] fix!: replace wasi:io/poll with astrid:io/poll@1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-time pre-adoption amendment of the @1.0.0 freeze. The WIT lint will flag this as a forbidden modification of a frozen file — the override is justified because nothing has been built against the @1.0.0 contracts yet (kernel scaffolding just landed, SDK and capsules have not migrated). Treating this as a draft correction rather than triggering the dual-version evolution path costs no downstream consumer and avoids dual-binding ceremony in every toolchain forever. What changes: - New: host/io@1.0.0.wit with the `pollable` resource and `poll(list>) -> result, error-code>`. Doc spells out the audit / per-principal-quota / cancel-token guarantees that make this Astrid-owned rather than wasi-owned. - host/ipc, host/net, host/http, host/process: `use` clause now imports `pollable` from `astrid:io/poll@1.0.0` instead of `wasi:io/poll@0.2.0`. Doc comments referencing `wasi:io/poll.poll` for composition updated to `astrid:io/poll.poll`. - host/sys: header rewritten — Astrid does not expose ANY `wasi:*` to capsules. The host ABI is fully Astrid-owned for two reasons: (1) every host call must be gated/audited/principal-scoped uniformly, no carve-outs for "foundation types"; (2) the unikernel target (hermit-rs) needs the WIT contract stable across host implementations — dispatching to hermit syscalls when the kernel is the unikernel itself. - README: `astrid:io@1.0.0` row added. The wit immutability rule remains in force from this commit forward. After this PR merges, the @1.0.0 files are frozen for real. Future shape changes ship as @1.1.0 alongside the @1.0.0 files. --- README.md | 1 + host/http@1.0.0.wit | 2 +- host/io@1.0.0.wit | 96 ++++++++++++++++++++++++++++++++++++++++++ host/ipc@1.0.0.wit | 4 +- host/net@1.0.0.wit | 4 +- host/process@1.0.0.wit | 2 +- host/sys@1.0.0.wit | 19 ++++++--- 7 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 host/io@1.0.0.wit diff --git a/README.md b/README.md index f674136..b410503 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Each domain is its own package, frozen at a per-file version. A capsule imports | File | Package | Description | |------|---------|-------------| | `host/fs@1.0.0.wit` | `astrid:fs@1.0.0` | Filesystem operations within the workspace boundary — whole-file IO, file handles with positional read/write, metadata, canonicalize, read-link, hard-link. | +| `host/io@1.0.0.wit` | `astrid:io@1.0.0` | Readiness multiplexing — Astrid-owned `pollable` resource returned by `subscribe-*` methods on other host resources, plus `poll(list)` for waiting on N readiness signals at once. Owned (not `wasi:io`) so every readiness operation is audited, principal-scoped, and cancellable. | | `host/ipc@1.0.0.wit` | `astrid:ipc@1.0.0` | Publish/subscribe IPC event bus. | | `host/uplink@1.0.0.wit` | `astrid:uplink@1.0.0` | Inbound message ingestion from external platforms. | | `host/kv@1.0.0.wit` | `astrid:kv@1.0.0` | Per-capsule, per-principal key-value storage with atomic compare-and-swap and paginated key listing. | diff --git a/host/http@1.0.0.wit b/host/http@1.0.0.wit index 231a14f..e3d410b 100644 --- a/host/http@1.0.0.wit +++ b/host/http@1.0.0.wit @@ -11,7 +11,7 @@ package astrid:http@1.0.0; interface host { - use wasi:io/poll@0.2.0.{pollable}; + use astrid:io/poll@1.0.0.{pollable}; /// Typed error returned from every fallible http operation. variant error-code { diff --git a/host/io@1.0.0.wit b/host/io@1.0.0.wit new file mode 100644 index 0000000..ea545e6 --- /dev/null +++ b/host/io@1.0.0.wit @@ -0,0 +1,96 @@ +/// Readiness multiplexing — Astrid-owned `pollable` resource. +/// +/// Capsules use `pollable` handles returned by other host resources +/// (e.g. `astrid:net/host.tcp-stream.subscribe-readable`, +/// `astrid:ipc/host.subscription.subscribe-readiness`, +/// `astrid:http/host.http-stream.subscribe-readable`, +/// `astrid:process/host.process-handle.subscribe-exit`) to wait on +/// heterogeneous readiness signals via a single `poll` call. This is +/// the only multiplexing primitive in the host ABI; capsules that +/// need to await one of N resources compose them through this. +/// +/// Astrid does NOT expose `wasi:io/poll` — the kernel implements its +/// own `pollable`/`poll` so every readiness operation is gated, +/// principal-scoped, audited, and cancellable. Concretely: +/// +/// - `pollable.block()` races against the calling capsule's +/// cancellation token. When the capsule is unloaded mid-block, the +/// call returns immediately rather than stranding a host task on a +/// future that may never complete. +/// - `poll(...)` and `pollable.block()` are audit-logged with the +/// calling principal and the wait duration. +/// - Per-capsule `pollable` resource handles are bounded by the +/// per-principal quota profile; exceeding it returns `quota` from +/// the host fn that would have allocated the pollable. +/// - Cross-capsule leakage is prevented by the wasmtime resource- +/// table boundary: a pollable created in capsule A's store cannot +/// be passed to capsule B. +/// +/// Forward-looking: when Astrid ships as a hermit-rs unikernel, this +/// interface dispatches to hermit's wait primitives rather than a +/// wasmtime-wasi-backed future. The WIT contract is stable across +/// host implementations. +/// +/// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes +/// ship as a new file at a new version path; never edit this file. + +package astrid:io@1.0.0; + +interface poll { + /// Typed error returned from fallible poll operations. + variant error-code { + /// `poll` was called with an empty list. Polling on nothing + /// is undefined; the host rejects rather than blocking forever. + invalid-input, + /// Pollable handle was dropped (or never valid in this store). + closed, + /// Caller exceeded a per-capsule cap (e.g. > 64 pollables in + /// a single `poll` call). Subdivide the wait set or use + /// resource-specific blocking calls. + too-large, + /// Block was cancelled because the capsule is unloading. + cancelled, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// An opaque handle to a future readiness signal. + /// + /// Pollables are *only* obtained from `subscribe-*` methods on + /// other Astrid host resources. The capsule never constructs one + /// directly — that would let untrusted guest code synthesise a + /// readiness signal the kernel can't reason about. + resource pollable { + /// Non-blocking readiness check. + /// + /// Returns `true` if a subsequent `block` would return + /// immediately, `false` otherwise. Idempotent and side-effect + /// free; not audit-recorded per call (high-volume). + ready: func() -> bool; + + /// Block the calling guest task until the pollable is ready. + /// + /// Returns when the underlying signal fires OR when the + /// capsule's cancellation token is triggered (capsule unload). + /// Returns `cancelled` in the latter case; capsules should + /// treat that as graceful shutdown and exit their loops. + /// + /// Audit: recorded with the calling principal and wait + /// duration in milliseconds. + block: func() -> result<_, error-code>; + } + + /// Wait until at least one of the given pollables is ready. + /// + /// Returns the indices (into the input list) of every pollable + /// that was ready when at least one became so. The host filters + /// out duplicates; the returned list is sorted ascending and + /// contains at least one entry on success. + /// + /// Per-call cap: 64 pollables. Larger lists return `too-large`. + /// Returns `cancelled` if the capsule unloads mid-poll. + /// + /// Audit: every `poll` call recorded (per-principal, with handle + /// count and wait duration). + poll: func(pollables: list>) -> result, error-code>; +} diff --git a/host/ipc@1.0.0.wit b/host/ipc@1.0.0.wit index f34aeb6..4bc0421 100644 --- a/host/ipc@1.0.0.wit +++ b/host/ipc@1.0.0.wit @@ -19,7 +19,7 @@ package astrid:ipc@1.0.0; interface host { - use wasi:io/poll@0.2.0.{pollable}; + use astrid:io/poll@1.0.0.{pollable}; /// Typed error returned from every fallible ipc operation. variant error-code { @@ -117,7 +117,7 @@ interface host { /// Pollable that fires when messages are queued. Compose with /// other pollables (net, future http-stream, etc.) via - /// `wasi:io/poll.poll` for multiplexed I/O in bridge and + /// `astrid:io/poll.poll` for multiplexed I/O in bridge and /// fan-out capsules. subscribe-readiness: func() -> pollable; } diff --git a/host/net@1.0.0.wit b/host/net@1.0.0.wit index a12b55a..f72292f 100644 --- a/host/net@1.0.0.wit +++ b/host/net@1.0.0.wit @@ -16,7 +16,7 @@ package astrid:net@1.0.0; interface host { - use wasi:io/poll@0.2.0.{pollable}; + use astrid:io/poll@1.0.0.{pollable}; // ----------------------------------------------------------------- // Error type @@ -116,7 +116,7 @@ interface host { /// Pollable that fires when a connection is ready to be /// accepted. Compose with other pollables via - /// `wasi:io/poll.poll` for multiplexed I/O. + /// `astrid:io/poll.poll` for multiplexed I/O. subscribe-readiness: func() -> pollable; } diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit index c86d448..63ab9dc 100644 --- a/host/process@1.0.0.wit +++ b/host/process@1.0.0.wit @@ -11,7 +11,7 @@ package astrid:process@1.0.0; interface host { - use wasi:io/poll@0.2.0.{pollable}; + use astrid:io/poll@1.0.0.{pollable}; /// Typed error returned from process operations. variant error-code { diff --git a/host/sys@1.0.0.wit b/host/sys@1.0.0.wit index 28ee1ce..af39e06 100644 --- a/host/sys@1.0.0.wit +++ b/host/sys@1.0.0.wit @@ -1,12 +1,19 @@ /// System-level runtime functions: logging, config, time, caller context, /// entropy, sleep, capability introspection. /// -/// Astrid does not expose `wasi:random`, `wasi:clocks`, or other generic -/// WASI primitives to capsules (only `wasi:io/poll` + `wasi:io/streams` -/// are retained as Component Model foundation types). Every other host -/// call is gated, principal-scoped, and audited — primitives capsules -/// need (random bytes, monotonic clock, sleep) live here so they go -/// through the same audit/principal layer. +/// Astrid does not expose any `wasi:*` interfaces to capsules. The host +/// ABI is fully Astrid-owned: every call is gated, principal-scoped, +/// audited, and dispatched through the kernel's capability layer. +/// Readiness multiplexing — the one place capsules historically reached +/// for `wasi:io/poll` — is provided by Astrid's own `astrid:io/poll@1.0.0` +/// interface. Primitives capsules need (random bytes, monotonic clock, +/// sleep) live in this `sys` package for the same reason: a single +/// audit/principal layer covering every host call. +/// +/// This namespace ownership matters for the unikernel target as well — +/// when Astrid ships on hermit-rs, the WIT contract is unchanged and the +/// kernel-side impls dispatch to unikernel syscalls instead of +/// wasmtime-wasi-backed futures. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. From cfe24c0137b2875aaca5224189d7690e55fa848a Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 03:27:49 +0400 Subject: [PATCH 2/5] chore(deps): drop vendored wasi-io MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the wasi:io/poll dependency replaced by astrid:io/poll@1.0.0, no Astrid host package references wasi:* anymore. The vendored deps/wasi-io/ tree is dead weight — remove it. - deps/wasi-io/{error,poll,streams,world}.wit: removed. - scripts/validate-wit.sh: header + comments updated; deps/ is now reserved for any future vendored external packages but intentionally empty today. The staging loop already gracefully handles an absent deps/ directory, so no logic change. - .github/workflows/lint.yml: comment updated to reflect the self-contained host ABI. Validation: `scripts/validate-wit.sh` still passes on all 13 host files (approval, elicit, fs, guest, http, identity, io, ipc, kv, net, process, sys, uplink). --- .github/workflows/lint.yml | 6 +- deps/wasi-io/error.wit | 34 ----- deps/wasi-io/poll.wit | 41 ------ deps/wasi-io/streams.wit | 262 ------------------------------------- deps/wasi-io/world.wit | 6 - scripts/validate-wit.sh | 12 +- 6 files changed, 11 insertions(+), 350 deletions(-) delete mode 100644 deps/wasi-io/error.wit delete mode 100644 deps/wasi-io/poll.wit delete mode 100644 deps/wasi-io/streams.wit delete mode 100644 deps/wasi-io/world.wit diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c7b4d33..13ef813 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,8 +37,10 @@ jobs: run: cargo install --locked wasm-tools - name: Parse every host/*.wit file - # Stages each host/*.wit alongside vendored deps/ (wasi-io) + - # sibling host/ files so cross-package `use` clauses resolve. + # Stages each host/*.wit alongside its sibling host/ files so + # cross-package `use` clauses resolve. There are no vendored + # external WIT packages — the Astrid host ABI is self-contained + # (`astrid:*` only, no `wasi:*` dependency). # interfaces/*.wit files are not validated here — they cross- # reference each other and wasm-tools 1.x can't topo-sort deps/ # in a single pass; downstream SDK builds (cargo-component / wkg) diff --git a/deps/wasi-io/error.wit b/deps/wasi-io/error.wit deleted file mode 100644 index 22e5b64..0000000 --- a/deps/wasi-io/error.wit +++ /dev/null @@ -1,34 +0,0 @@ -package wasi:io@0.2.0; - - -interface error { - /// A resource which represents some error information. - /// - /// The only method provided by this resource is `to-debug-string`, - /// which provides some human-readable information about the error. - /// - /// In the `wasi:io` package, this resource is returned through the - /// `wasi:io/streams/stream-error` type. - /// - /// To provide more specific error information, other interfaces may - /// provide functions to further "downcast" this error into more specific - /// error information. For example, `error`s returned in streams derived - /// from filesystem types to be described using the filesystem's own - /// error-code type, using the function - /// `wasi:filesystem/types/filesystem-error-code`, which takes a parameter - /// `borrow` and returns - /// `option`. - /// - /// The set of functions which can "downcast" an `error` into a more - /// concrete type is open. - resource error { - /// Returns a string that is suitable to assist humans in debugging - /// this error. - /// - /// WARNING: The returned string should not be consumed mechanically! - /// It may change across platforms, hosts, or other implementation - /// details. Parsing this string is a major platform-compatibility - /// hazard. - to-debug-string: func() -> string; - } -} diff --git a/deps/wasi-io/poll.wit b/deps/wasi-io/poll.wit deleted file mode 100644 index ddc67f8..0000000 --- a/deps/wasi-io/poll.wit +++ /dev/null @@ -1,41 +0,0 @@ -package wasi:io@0.2.0; - -/// A poll API intended to let users wait for I/O events on multiple handles -/// at once. -interface poll { - /// `pollable` represents a single I/O event which may be ready, or not. - resource pollable { - - /// Return the readiness of a pollable. This function never blocks. - /// - /// Returns `true` when the pollable is ready, and `false` otherwise. - ready: func() -> bool; - - /// `block` returns immediately if the pollable is ready, and otherwise - /// blocks until ready. - /// - /// This function is equivalent to calling `poll.poll` on a list - /// containing only this pollable. - block: func(); - } - - /// Poll for completion on a set of pollables. - /// - /// This function takes a list of pollables, which identify I/O sources of - /// interest, and waits until one or more of the events is ready for I/O. - /// - /// The result `list` contains one or more indices of handles in the - /// argument list that is ready for I/O. - /// - /// If the list contains more elements than can be indexed with a `u32` - /// value, this function traps. - /// - /// A timeout can be implemented by adding a pollable from the - /// wasi-clocks API to the list. - /// - /// This function does not return a `result`; polling in itself does not - /// do any I/O so it doesn't fail. If any of the I/O sources identified by - /// the pollables has an error, it is indicated by marking the source as - /// being reaedy for I/O. - poll: func(in: list>) -> list; -} diff --git a/deps/wasi-io/streams.wit b/deps/wasi-io/streams.wit deleted file mode 100644 index 6d2f871..0000000 --- a/deps/wasi-io/streams.wit +++ /dev/null @@ -1,262 +0,0 @@ -package wasi:io@0.2.0; - -/// WASI I/O is an I/O abstraction API which is currently focused on providing -/// stream types. -/// -/// In the future, the component model is expected to add built-in stream types; -/// when it does, they are expected to subsume this API. -interface streams { - use error.{error}; - use poll.{pollable}; - - /// An error for input-stream and output-stream operations. - variant stream-error { - /// The last operation (a write or flush) failed before completion. - /// - /// More information is available in the `error` payload. - last-operation-failed(error), - /// The stream is closed: no more input will be accepted by the - /// stream. A closed output-stream will return this error on all - /// future operations. - closed - } - - /// An input bytestream. - /// - /// `input-stream`s are *non-blocking* to the extent practical on underlying - /// platforms. I/O operations always return promptly; if fewer bytes are - /// promptly available than requested, they return the number of bytes promptly - /// available, which could even be zero. To wait for data to be available, - /// use the `subscribe` function to obtain a `pollable` which can be polled - /// for using `wasi:io/poll`. - resource input-stream { - /// Perform a non-blocking read from the stream. - /// - /// When the source of a `read` is binary data, the bytes from the source - /// are returned verbatim. When the source of a `read` is known to the - /// implementation to be text, bytes containing the UTF-8 encoding of the - /// text are returned. - /// - /// This function returns a list of bytes containing the read data, - /// when successful. The returned list will contain up to `len` bytes; - /// it may return fewer than requested, but not more. The list is - /// empty when no bytes are available for reading at this time. The - /// pollable given by `subscribe` will be ready when more bytes are - /// available. - /// - /// This function fails with a `stream-error` when the operation - /// encounters an error, giving `last-operation-failed`, or when the - /// stream is closed, giving `closed`. - /// - /// When the caller gives a `len` of 0, it represents a request to - /// read 0 bytes. If the stream is still open, this call should - /// succeed and return an empty list, or otherwise fail with `closed`. - /// - /// The `len` parameter is a `u64`, which could represent a list of u8 which - /// is not possible to allocate in wasm32, or not desirable to allocate as - /// as a return value by the callee. The callee may return a list of bytes - /// less than `len` in size while more bytes are available for reading. - read: func( - /// The maximum number of bytes to read - len: u64 - ) -> result, stream-error>; - - /// Read bytes from a stream, after blocking until at least one byte can - /// be read. Except for blocking, behavior is identical to `read`. - blocking-read: func( - /// The maximum number of bytes to read - len: u64 - ) -> result, stream-error>; - - /// Skip bytes from a stream. Returns number of bytes skipped. - /// - /// Behaves identical to `read`, except instead of returning a list - /// of bytes, returns the number of bytes consumed from the stream. - skip: func( - /// The maximum number of bytes to skip. - len: u64, - ) -> result; - - /// Skip bytes from a stream, after blocking until at least one byte - /// can be skipped. Except for blocking behavior, identical to `skip`. - blocking-skip: func( - /// The maximum number of bytes to skip. - len: u64, - ) -> result; - - /// Create a `pollable` which will resolve once either the specified stream - /// has bytes available to read or the other end of the stream has been - /// closed. - /// The created `pollable` is a child resource of the `input-stream`. - /// Implementations may trap if the `input-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - subscribe: func() -> pollable; - } - - - /// An output bytestream. - /// - /// `output-stream`s are *non-blocking* to the extent practical on - /// underlying platforms. Except where specified otherwise, I/O operations also - /// always return promptly, after the number of bytes that can be written - /// promptly, which could even be zero. To wait for the stream to be ready to - /// accept data, the `subscribe` function to obtain a `pollable` which can be - /// polled for using `wasi:io/poll`. - resource output-stream { - /// Check readiness for writing. This function never blocks. - /// - /// Returns the number of bytes permitted for the next call to `write`, - /// or an error. Calling `write` with more bytes than this function has - /// permitted will trap. - /// - /// When this function returns 0 bytes, the `subscribe` pollable will - /// become ready when this function will report at least 1 byte, or an - /// error. - check-write: func() -> result; - - /// Perform a write. This function never blocks. - /// - /// When the destination of a `write` is binary data, the bytes from - /// `contents` are written verbatim. When the destination of a `write` is - /// known to the implementation to be text, the bytes of `contents` are - /// transcoded from UTF-8 into the encoding of the destination and then - /// written. - /// - /// Precondition: check-write gave permit of Ok(n) and contents has a - /// length of less than or equal to n. Otherwise, this function will trap. - /// - /// returns Err(closed) without writing if the stream has closed since - /// the last call to check-write provided a permit. - write: func( - contents: list - ) -> result<_, stream-error>; - - /// Perform a write of up to 4096 bytes, and then flush the stream. Block - /// until all of these operations are complete, or an error occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write`, and `flush`, and is implemented with the - /// following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while !contents.is_empty() { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, contents.len()); - /// let (chunk, rest) = contents.split_at(len); - /// this.write(chunk ); // eliding error handling - /// contents = rest; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - blocking-write-and-flush: func( - contents: list - ) -> result<_, stream-error>; - - /// Request to flush buffered output. This function never blocks. - /// - /// This tells the output-stream that the caller intends any buffered - /// output to be flushed. the output which is expected to be flushed - /// is all that has been passed to `write` prior to this call. - /// - /// Upon calling this function, the `output-stream` will not accept any - /// writes (`check-write` will return `ok(0)`) until the flush has - /// completed. The `subscribe` pollable will become ready when the - /// flush has completed and the stream can accept more writes. - flush: func() -> result<_, stream-error>; - - /// Request to flush buffered output, and block until flush completes - /// and stream is ready for writing again. - blocking-flush: func() -> result<_, stream-error>; - - /// Create a `pollable` which will resolve once the output-stream - /// is ready for more writing, or an error has occured. When this - /// pollable is ready, `check-write` will return `ok(n)` with n>0, or an - /// error. - /// - /// If the stream is closed, this pollable is always ready immediately. - /// - /// The created `pollable` is a child resource of the `output-stream`. - /// Implementations may trap if the `output-stream` is dropped before - /// all derived `pollable`s created with this function are dropped. - subscribe: func() -> pollable; - - /// Write zeroes to a stream. - /// - /// This should be used precisely like `write` with the exact same - /// preconditions (must use check-write first), but instead of - /// passing a list of bytes, you simply pass the number of zero-bytes - /// that should be written. - write-zeroes: func( - /// The number of zero-bytes to write - len: u64 - ) -> result<_, stream-error>; - - /// Perform a write of up to 4096 zeroes, and then flush the stream. - /// Block until all of these operations are complete, or an error - /// occurs. - /// - /// This is a convenience wrapper around the use of `check-write`, - /// `subscribe`, `write-zeroes`, and `flush`, and is implemented with - /// the following pseudo-code: - /// - /// ```text - /// let pollable = this.subscribe(); - /// while num_zeroes != 0 { - /// // Wait for the stream to become writable - /// pollable.block(); - /// let Ok(n) = this.check-write(); // eliding error handling - /// let len = min(n, num_zeroes); - /// this.write-zeroes(len); // eliding error handling - /// num_zeroes -= len; - /// } - /// this.flush(); - /// // Wait for completion of `flush` - /// pollable.block(); - /// // Check for any errors that arose during `flush` - /// let _ = this.check-write(); // eliding error handling - /// ``` - blocking-write-zeroes-and-flush: func( - /// The number of zero-bytes to write - len: u64 - ) -> result<_, stream-error>; - - /// Read from one stream and write to another. - /// - /// The behavior of splice is equivelant to: - /// 1. calling `check-write` on the `output-stream` - /// 2. calling `read` on the `input-stream` with the smaller of the - /// `check-write` permitted length and the `len` provided to `splice` - /// 3. calling `write` on the `output-stream` with that read data. - /// - /// Any error reported by the call to `check-write`, `read`, or - /// `write` ends the splice and reports that error. - /// - /// This function returns the number of bytes transferred; it may be less - /// than `len`. - splice: func( - /// The stream to read from - src: borrow, - /// The number of bytes to splice - len: u64, - ) -> result; - - /// Read from one stream and write to another, with blocking. - /// - /// This is similar to `splice`, except that it blocks until the - /// `output-stream` is ready for writing, and the `input-stream` - /// is ready for reading, before performing the `splice`. - blocking-splice: func( - /// The stream to read from - src: borrow, - /// The number of bytes to splice - len: u64, - ) -> result; - } -} diff --git a/deps/wasi-io/world.wit b/deps/wasi-io/world.wit deleted file mode 100644 index 5f0b43f..0000000 --- a/deps/wasi-io/world.wit +++ /dev/null @@ -1,6 +0,0 @@ -package wasi:io@0.2.0; - -world imports { - import streams; - import poll; -} diff --git a/scripts/validate-wit.sh b/scripts/validate-wit.sh index 789748c..72a3323 100755 --- a/scripts/validate-wit.sh +++ b/scripts/validate-wit.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash # # validate-wit.sh — parse every host/*.wit and interfaces/*.wit by -# staging each file alongside the repo's vendored deps/ tree so cross- -# package `use` clauses (e.g. `use wasi:io/poll@0.2.0.{pollable};`) +# staging each file alongside the repo's other WIT files so cross- +# package `use` clauses (e.g. `use astrid:io/poll@1.0.0.{pollable};`) # resolve. # # wasm-tools resolves WIT dependencies from a sibling `deps/` directory. # Each WIT file declares its own package, so we stage one tempdir per -# file: `/main.wit` + `/deps/...`. The shared deps live in -# `/deps/` and are vendored at fixed versions. +# file: `/main.wit` + `/deps/...`. The `deps/` directory in +# this repo is reserved for any future vendored external WIT packages; +# it is intentionally empty today — the Astrid host ABI does not +# depend on `wasi:*`, so there is nothing to vendor. # # Usage: scripts/validate-wit.sh @@ -22,9 +24,9 @@ shopt -s nullglob # For each main file we want to parse, stage: # main.wit <- the file under test -# deps/wasi-io/*.wit <- vendored WASI # deps/astrid-/*.wit <- every OTHER astrid WIT file in the repo, # so cross-package `use` clauses resolve. +# deps//*.wit <- (future) any vendored external packages. # # Each WIT file declares its own package, so deps/ ends up with one # subdirectory per other package in the workspace. From 0b66ee2ce629c967d2cd4d8c97b64c031b5b4ae8 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 03:40:55 +0400 Subject: [PATCH 3/5] feat(io)!: add astrid:io/error + astrid:io/streams; wire stream halves into net/http/process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors wasi:io@0.2.0's three-interface foundation (error / poll / streams) but Astrid-owned: every read / write / skip / splice is audited per-principal, every blocking variant races against the calling capsule's cancellation token, and stream-handle counts are bounded by the per-principal quota profile. No wasi:* carve-outs. This is the second amendment to the @1.0.0 freeze in the same pre-adoption window. Nothing has been built against the @1.0.0 contracts yet; after this PR merges the @1.0.0 files are locked for real and shape changes ship as @1.1.0 alongside. host/io@1.0.0.wit: - New interface 'error' with a downcastable error resource (single to-debug-string method). Carried by stream-error::last-operation-failed. - Existing interface 'poll' kept verbatim. - New interface 'streams' with input-stream and output-stream resources. Surface matches wasi:io/streams: read / blocking-read / skip / blocking-skip / subscribe on input; check-write / write / blocking-write-and-flush / flush / blocking-flush / write-zeroes / blocking-write-zeroes-and-flush / splice / blocking-splice / subscribe on output. Error variant is stream-error { closed, last-operation-failed(error) }. Stream accessors wired where they belong: - host/net@1.0.0.wit: tcp-stream gains read-stream / write-stream so proxy / forwarder capsules can pipe bytes host-side via output-stream.splice without crossing the WASM boundary per byte. The primary throughput primitive for capsule-hosted TCP servers. - host/http@1.0.0.wit: http-stream gains body-stream for streaming response bodies through splice (e.g. forwarding upstream HTTP into a capsule-served TCP connection). - host/process@1.0.0.wit: process-handle gains stdin / stdout / stderr returning output-stream / input-stream / input-stream. Use these instead of read-logs / write-stdin for high-throughput child stdio (e.g. wrapping ffmpeg, piping shell pipelines). Each amended @1.0.0 file gains a 'use astrid:io/streams@1.0.0.{...}' declaration to bring the resource types into scope. The byte-oriented methods (read-bytes / write-bytes / read-chunk / read-logs / write-stdin) stay alongside the new stream halves; capsules pick the access pattern that fits the use case. UDP intentionally stays datagram-only — no stream halves. Mapping datagrams to a byte stream loses message boundaries. Validation: `scripts/validate-wit.sh` passes on all 13 host packages. --- README.md | 2 +- host/http@1.0.0.wit | 11 ++ host/io@1.0.0.wit | 254 ++++++++++++++++++++++++++++++++++------- host/net@1.0.0.wit | 26 +++++ host/process@1.0.0.wit | 25 ++++ 5 files changed, 277 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index b410503..f915dfe 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Each domain is its own package, frozen at a per-file version. A capsule imports | File | Package | Description | |------|---------|-------------| | `host/fs@1.0.0.wit` | `astrid:fs@1.0.0` | Filesystem operations within the workspace boundary — whole-file IO, file handles with positional read/write, metadata, canonicalize, read-link, hard-link. | -| `host/io@1.0.0.wit` | `astrid:io@1.0.0` | Readiness multiplexing — Astrid-owned `pollable` resource returned by `subscribe-*` methods on other host resources, plus `poll(list)` for waiting on N readiness signals at once. Owned (not `wasi:io`) so every readiness operation is audited, principal-scoped, and cancellable. | +| `host/io@1.0.0.wit` | `astrid:io@1.0.0` | Foundation I/O — three interfaces. `error`: downcastable error resource carried by stream errors. `poll`: Astrid-owned `pollable` resource for readiness multiplexing across heterogeneous host resources. `streams`: `input-stream` / `output-stream` resources with read / write / skip / flush / `splice` for high-throughput host-side byte movement. Shape mirrors `wasi:io@0.2.0` but Astrid-owned — every operation is audited, principal-scoped, cancellable, and quota-bounded. | | `host/ipc@1.0.0.wit` | `astrid:ipc@1.0.0` | Publish/subscribe IPC event bus. | | `host/uplink@1.0.0.wit` | `astrid:uplink@1.0.0` | Inbound message ingestion from external platforms. | | `host/kv@1.0.0.wit` | `astrid:kv@1.0.0` | Per-capsule, per-principal key-value storage with atomic compare-and-swap and paginated key listing. | diff --git a/host/http@1.0.0.wit b/host/http@1.0.0.wit index e3d410b..b62b282 100644 --- a/host/http@1.0.0.wit +++ b/host/http@1.0.0.wit @@ -12,6 +12,7 @@ package astrid:http@1.0.0; interface host { use astrid:io/poll@1.0.0.{pollable}; + use astrid:io/streams@1.0.0.{input-stream}; /// Typed error returned from every fallible http operation. variant error-code { @@ -115,6 +116,16 @@ interface host { /// multiplexed I/O — e.g. a capsule streaming an HTTP /// response while also handling IPC requests. subscribe-readable: func() -> pollable; + + /// The response body as an `input-stream`. Use this instead + /// of `read-chunk` when forwarding the body to another sink + /// (e.g. a TCP stream via `output-stream.splice`) — the splice + /// path moves bytes host-side without crossing the WASM + /// boundary per chunk. + /// + /// `body-stream` and `read-chunk` share the underlying + /// response cursor; the kernel serializes access. Pick one. + body-stream: func() -> input-stream; } /// Perform a buffered HTTP request (full response in memory). diff --git a/host/io@1.0.0.wit b/host/io@1.0.0.wit index ea545e6..525c6ba 100644 --- a/host/io@1.0.0.wit +++ b/host/io@1.0.0.wit @@ -1,34 +1,30 @@ -/// Readiness multiplexing — Astrid-owned `pollable` resource. +/// Foundation I/O primitives — Astrid-owned readiness multiplexing, +/// downcastable error resource, and byte streams. /// -/// Capsules use `pollable` handles returned by other host resources -/// (e.g. `astrid:net/host.tcp-stream.subscribe-readable`, -/// `astrid:ipc/host.subscription.subscribe-readiness`, -/// `astrid:http/host.http-stream.subscribe-readable`, -/// `astrid:process/host.process-handle.subscribe-exit`) to wait on -/// heterogeneous readiness signals via a single `poll` call. This is -/// the only multiplexing primitive in the host ABI; capsules that -/// need to await one of N resources compose them through this. +/// The shape mirrors `wasi:io@0.2.0` (error / poll / streams) because the +/// Component Model conventions for these primitives are mature and well- +/// understood. What differs is ownership: Astrid implements all three +/// interfaces itself rather than re-exporting `wasi:io`, so every +/// operation is gated, principal-scoped, audited, cancellable, and +/// quota-bounded by the kernel's capability layer. No wasi:* carve-outs. /// -/// Astrid does NOT expose `wasi:io/poll` — the kernel implements its -/// own `pollable`/`poll` so every readiness operation is gated, -/// principal-scoped, audited, and cancellable. Concretely: +/// Why Astrid-owned and not wasi:io: /// -/// - `pollable.block()` races against the calling capsule's -/// cancellation token. When the capsule is unloaded mid-block, the -/// call returns immediately rather than stranding a host task on a -/// future that may never complete. -/// - `poll(...)` and `pollable.block()` are audit-logged with the -/// calling principal and the wait duration. -/// - Per-capsule `pollable` resource handles are bounded by the -/// per-principal quota profile; exceeding it returns `quota` from -/// the host fn that would have allocated the pollable. -/// - Cross-capsule leakage is prevented by the wasmtime resource- -/// table boundary: a pollable created in capsule A's store cannot -/// be passed to capsule B. +/// - `pollable.block()` and `poll.poll(...)` race against the calling +/// capsule's cancellation token. On capsule unload, blocking calls +/// return `cancelled` immediately rather than stranding host tasks on +/// futures that may never complete. +/// - Every read/write/skip/splice on a stream is audited (per-principal, +/// with bytes transferred and elapsed time). +/// - Pollable and stream resource handles are bounded by the per-principal +/// quota profile; exceeding it returns `quota` from the host fn that +/// would have allocated them. +/// - Pollables created in capsule A's store cannot be passed to capsule +/// B (wasmtime resource-table boundary). /// -/// Forward-looking: when Astrid ships as a hermit-rs unikernel, this -/// interface dispatches to hermit's wait primitives rather than a -/// wasmtime-wasi-backed future. The WIT contract is stable across +/// Forward-looking: when Astrid ships as a hermit-rs unikernel, the +/// kernel-side impls dispatch to unikernel wait/io primitives rather +/// than wasmtime-wasi-backed futures. The WIT contract is stable across /// host implementations. /// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes @@ -36,6 +32,31 @@ package astrid:io@1.0.0; +/// A downcastable error resource carried by `stream-error::last-operation-failed`. +/// +/// Mirrors `wasi:io/error`. Other host interfaces (`astrid:net`, +/// `astrid:http`, `astrid:process`, …) may provide downcast functions +/// that take `borrow` and return a typed error-code from their +/// own domain — e.g. converting a stream's last-operation-failed into +/// a `net.error-code::connection-reset`. +interface error { + /// An opaque error value tagged with a host-side identifier. + resource error { + /// Human-readable diagnostic. + /// + /// WARNING: do not parse this string. Its content is best-effort + /// and changes across platforms / kernel revisions. For typed + /// classification use a domain-specific downcast function on a + /// `borrow`. + to-debug-string: func() -> string; + } +} + +/// Readiness multiplexing. +/// +/// Pollables are returned by `subscribe-*` methods on other Astrid host +/// resources and let capsules wait on heterogeneous readiness signals +/// via a single `poll` call. interface poll { /// Typed error returned from fallible poll operations. variant error-code { @@ -55,25 +76,20 @@ interface poll { } /// An opaque handle to a future readiness signal. - /// - /// Pollables are *only* obtained from `subscribe-*` methods on - /// other Astrid host resources. The capsule never constructs one - /// directly — that would let untrusted guest code synthesise a - /// readiness signal the kernel can't reason about. resource pollable { /// Non-blocking readiness check. /// /// Returns `true` if a subsequent `block` would return - /// immediately, `false` otherwise. Idempotent and side-effect - /// free; not audit-recorded per call (high-volume). + /// immediately, `false` otherwise. Side-effect free; not + /// audit-recorded per call (high-volume). ready: func() -> bool; /// Block the calling guest task until the pollable is ready. /// /// Returns when the underlying signal fires OR when the - /// capsule's cancellation token is triggered (capsule unload). - /// Returns `cancelled` in the latter case; capsules should - /// treat that as graceful shutdown and exit their loops. + /// capsule's cancellation token is triggered. Returns + /// `cancelled` in the latter case; capsules should treat + /// that as graceful shutdown. /// /// Audit: recorded with the calling principal and wait /// duration in milliseconds. @@ -83,9 +99,8 @@ interface poll { /// Wait until at least one of the given pollables is ready. /// /// Returns the indices (into the input list) of every pollable - /// that was ready when at least one became so. The host filters - /// out duplicates; the returned list is sorted ascending and - /// contains at least one entry on success. + /// that was ready when at least one became so. The returned list + /// is sorted ascending and contains at least one entry on success. /// /// Per-call cap: 64 pollables. Larger lists return `too-large`. /// Returns `cancelled` if the capsule unloads mid-poll. @@ -94,3 +109,162 @@ interface poll { /// count and wait duration). poll: func(pollables: list>) -> result, error-code>; } + +/// Byte streams. +/// +/// `input-stream` is the read end of a byte source; `output-stream` is +/// the write end of a byte sink. Both are non-blocking by default; +/// blocking variants are provided for ergonomic use. `splice` moves +/// bytes from an input to an output in the host without crossing the +/// WASM boundary per byte — the primary throughput primitive for +/// proxying / forwarding capsules (e.g. capsule-hosted TCP servers). +/// +/// Streams are not constructed directly by capsules. They are obtained +/// from `subscribe-*` / `*-stream` methods on other host resources: +/// +/// - `astrid:net/host.tcp-stream.{read-stream, write-stream}` — TCP byte halves +/// - `astrid:http/host.http-stream.body-stream` — HTTP response body +/// - `astrid:process/host.process-handle.{stdin, stdout, stderr}` — child stdio +/// +/// Each per-call read / write / splice is audited (per-principal, with +/// bytes transferred). Blocking variants race against the calling +/// capsule's cancellation token. Per-capsule stream-handle quotas are +/// bounded by the principal's profile. +interface streams { + use error.{error}; + use poll.{pollable}; + + /// Error variant for stream operations. + /// + /// Matches the `wasi:io/streams.stream-error` shape so capsule SDKs + /// can reason uniformly about stream failures. After a stream + /// returns `last-operation-failed`, the stream is closed; all + /// subsequent calls return `closed`. + variant stream-error { + /// The last read / write / splice / flush failed before + /// completion. The `error` payload is downcastable to a + /// domain-specific error-code via interfaces that source the + /// stream (e.g. `astrid:net` for TCP streams, `astrid:http` + /// for HTTP body streams). + last-operation-failed(error), + /// Stream end: no more bytes will be produced (input) or + /// accepted (output). Returned by every operation on a closed + /// stream. + closed, + } + + /// Read end of a byte stream. + /// + /// `read` is non-blocking; returns up to `len` bytes (possibly zero) + /// if any are promptly available. To wait, take the `subscribe` + /// pollable and `block` on it, or use `blocking-read`. + resource input-stream { + /// Non-blocking read up to `len` bytes. Empty list = no data + /// available right now (not EOF). Use `subscribe` to wait. + /// EOF or peer close surfaces as `closed` on the next call. + /// + /// Per-call cap: 1 MiB. Larger requests trap (consistent with + /// wasi:io behaviour on `len` larger than wasm32 can allocate). + /// + /// Audit: recorded (per-principal, with bytes read). + read: func(len: u64) -> result, stream-error>; + + /// Block until at least one byte is available, then read up to + /// `len`. Identical to `read` except for the wait. Races + /// against the cancellation token; `last-operation-failed` + /// surfaces if the capsule is unloading. + blocking-read: func(len: u64) -> result, stream-error>; + + /// Skip up to `len` bytes without buffering them in the guest. + /// Equivalent to `read` followed by discarding, but skips the + /// data copy. Returns the number of bytes actually skipped. + skip: func(len: u64) -> result; + + /// Blocking variant of `skip`. + blocking-skip: func(len: u64) -> result; + + /// Pollable that fires when bytes are available to read OR the + /// stream has closed. Once ready, `read` is guaranteed to + /// return at least one byte OR a `closed` error. + /// + /// The pollable is a child resource: dropping the input-stream + /// before all derived pollables traps. + subscribe: func() -> pollable; + } + + /// Write end of a byte stream. + /// + /// `write` is non-blocking; `check-write` reports how many bytes + /// may be written before the next `write` would block. `splice` is + /// the primary throughput primitive — bytes move host-side without + /// crossing the WASM boundary per byte. + resource output-stream { + /// How many bytes may be written in the next `write` call. + /// Returns 0 if the stream is not currently writable; + /// `subscribe` will fire when that changes. + /// + /// Calling `write` with more bytes than `check-write` permits + /// traps. This mirrors `wasi:io/streams` exactly. + check-write: func() -> result; + + /// Write bytes. `contents.len()` must be <= the last + /// `check-write` permit. Returns `closed` if the stream closed + /// since the permit was issued. + /// + /// Audit: recorded (per-principal, with bytes written). + write: func(contents: list) -> result<_, stream-error>; + + /// Convenience: write `contents` and flush, blocking until all + /// bytes are accepted and the flush completes. Internally + /// drives `check-write` / `subscribe` / `write` / `flush` in a + /// loop. Per-call payload capped at 4 KiB for parity with + /// wasi:io. + /// + /// Audit: recorded (per-principal, with total bytes written). + blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; + + /// Request flush of all bytes passed to `write` prior to this + /// call. Non-blocking. While the flush is in progress, + /// `check-write` returns 0; `subscribe` fires when it + /// completes. + flush: func() -> result<_, stream-error>; + + /// Block until flush completes and the stream is ready to + /// accept more writes. Races against cancellation. + blocking-flush: func() -> result<_, stream-error>; + + /// Pollable that fires when the stream is ready to accept + /// writes (i.e. `check-write` will return > 0) OR the stream + /// closed. The pollable is a child resource: dropping the + /// output-stream before all derived pollables traps. + subscribe: func() -> pollable; + + /// Write `len` zero bytes. Same preconditions as `write`: + /// `len` must be <= last `check-write` permit. Useful for + /// sparse-file extension and protocol padding. + write-zeroes: func(len: u64) -> result<_, stream-error>; + + /// Convenience: write `len` zeroes and flush, blocking until + /// complete. Per-call cap: 4 KiB. + blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; + + /// Move up to `len` bytes from `src` to this stream in the + /// host. The kernel handles the read-then-write loop without + /// crossing the WASM boundary per byte — the primary + /// throughput primitive for proxy / forwarder capsules. + /// + /// Behaviour is equivalent to: + /// 1. `check-write` on this stream + /// 2. `read` on `src` with the smaller of the permit and `len` + /// 3. `write` on this stream with the bytes read + /// Any error in those steps ends the splice and is reported. + /// + /// Audit: recorded (per-principal, with bytes spliced). + splice: func(src: borrow, len: u64) -> result; + + /// Blocking variant of `splice`. Blocks until `src` has data + /// AND this stream is writable, then splices. Races against + /// cancellation. + blocking-splice: func(src: borrow, len: u64) -> result; + } +} diff --git a/host/net@1.0.0.wit b/host/net@1.0.0.wit index f72292f..b4b3b40 100644 --- a/host/net@1.0.0.wit +++ b/host/net@1.0.0.wit @@ -17,6 +17,7 @@ package astrid:net@1.0.0; interface host { use astrid:io/poll@1.0.0.{pollable}; + use astrid:io/streams@1.0.0.{input-stream, output-stream}; // ----------------------------------------------------------------- // Error type @@ -237,6 +238,31 @@ interface host { /// Pollable that fires when the stream is ready to read. /// Compose with other pollables for multiplexed I/O. subscribe-readable: func() -> pollable; + + // ---- Stream halves (high-throughput byte movement) ---- + + /// The read half as an `input-stream`. Use the standard stream + /// methods (`read` / `blocking-read` / `skip` / `subscribe`) + /// for low-level byte access, or pass the stream into + /// `output-stream.splice` to move bytes from this TCP + /// connection into another stream without crossing the WASM + /// boundary per byte — the throughput primitive for proxy / + /// forwarder capsules (e.g. capsule-hosted TCP servers). + /// + /// `read-stream` and `write-stream` share the underlying + /// socket with the per-frame `read` / `write` and per-byte + /// `read-bytes` / `write-bytes` methods above; the kernel + /// serializes access. Pick one access pattern per use case to + /// avoid interleaving surprises. + read-stream: func() -> input-stream; + + /// The write half as an `output-stream`. Use `write` / + /// `blocking-write-and-flush` for byte writes, or + /// `output-stream.splice(input-stream, len)` to forward bytes + /// from another stream into this connection. Pollable + /// composability via `subscribe` enables back-pressure-aware + /// pipelines. + write-stream: func() -> output-stream; } // ----------------------------------------------------------------- diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit index 63ab9dc..ea49a31 100644 --- a/host/process@1.0.0.wit +++ b/host/process@1.0.0.wit @@ -12,6 +12,7 @@ package astrid:process@1.0.0; interface host { use astrid:io/poll@1.0.0.{pollable}; + use astrid:io/streams@1.0.0.{input-stream, output-stream}; /// Typed error returned from process operations. variant error-code { @@ -181,6 +182,30 @@ interface host { /// Pollable that fires when stdout / stderr has buffered /// data ready to be drained via `read-logs`. subscribe-logs: func() -> pollable; + + // ---- Stream halves (high-throughput stdio) ---- + // + // The byte-oriented `read-logs` / `write-stdin` methods above + // are the simple-case API: small REPL-style I/O, drained on + // demand. The stream halves below are the throughput API: pipe + // child stdio to/from a network connection, a file, or another + // process via `output-stream.splice` without crossing the WASM + // boundary per byte. Pick one access pattern per stream — the + // kernel serializes them but interleaving is rarely what you + // want. + + /// Child's stdin as an `output-stream`. Bytes written here go + /// to the child process. `close-stdin` (or dropping this + /// stream) signals EOF. + stdin: func() -> result; + + /// Child's stdout as an `input-stream`. Reads bytes the child + /// has written. Surfaces `closed` on child exit + buffer drain. + stdout: func() -> result; + + /// Child's stderr as an `input-stream`. Same semantics as + /// `stdout`. + stderr: func() -> result; } /// Spawn a synchronous (blocking) process. Blocks the WASM From 13daba97066f042cf5e38ded68e7a863aadfb8b9 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 03:56:28 +0400 Subject: [PATCH 4/5] fix(process)!: drop stream halves on process-handle; mark package desktop-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconsidered the stdin/stdout/stderr stream accessors I added in the prior commit. They do not pull weight: - The IPC bus already provides high-throughput capsule-to-capsule byte movement. If capsule A spawns a child and capsule B wants its output, A drains via read-logs and republishes on the bus; the bus is the throughput primitive, not process stdio. - write-stdin + read-logs already cover all realistic child stdio patterns: REPL-style MCP stdio JSON-RPC, structured tool capture, periodic log drain. - The unikernel target (hermit-rs) has no POSIX fork/exec model, so the entire process package is desktop-kernel only — forward-compat for unikernel is moot here. Net effect: process-handle becomes simpler (three fewer methods) and the splice-into-child media-gateway scenario, if it ever materialises, ships as process-handle@1.1.0 alongside the @1.0.0 file. What changes: - host/process@1.0.0.wit: removed stdin / stdout / stderr methods on process-handle; removed the now-unused 'use astrid:io/streams@1.0.0.{input-stream, output-stream}' clause. - Header doc updated to call out the desktop-only scope explicitly so capsule authors targeting the unikernel know not to import this package. tcp-stream and http-stream stream halves stay — splicing TCP-to-TCP (capsule-hosted proxies) and HTTP-body-to-sink remain real, recurring patterns that work on the unikernel target too. --- host/process@1.0.0.wit | 39 ++++++++++++++------------------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/host/process@1.0.0.wit b/host/process@1.0.0.wit index ea49a31..a819553 100644 --- a/host/process@1.0.0.wit +++ b/host/process@1.0.0.wit @@ -5,6 +5,20 @@ /// directory. All processes are tracked for cancellation. /// Security-gated: requires `host_process` capability. /// +/// **Desktop-kernel only.** This package depends on a POSIX-style +/// fork/exec model. Unikernel targets (hermit-rs, etc.) do not implement +/// it — capsules importing `astrid:process` will fail to load on those +/// kernels. Capsule-to-capsule patterns over the IPC bus replace most +/// child-process workflows on the unikernel target; remaining +/// workloads stay desktop-only by design. +/// +/// Child stdio is byte-oriented (`write-stdin` / `read-logs`) rather +/// than stream-based. The bus already handles high-throughput +/// capsule-to-capsule traffic; stream halves on `process-handle` would +/// pull weight only for niche "splice TCP into child into TCP" media- +/// gateway scenarios that haven't materialised yet. Add as +/// `process-handle@1.1.0` if/when concrete need arises. +/// /// Frozen per the ABI evolution discipline (RFC: host_abi). Shape changes /// ship as a new file at a new version path; never edit this file. @@ -12,7 +26,6 @@ package astrid:process@1.0.0; interface host { use astrid:io/poll@1.0.0.{pollable}; - use astrid:io/streams@1.0.0.{input-stream, output-stream}; /// Typed error returned from process operations. variant error-code { @@ -182,30 +195,6 @@ interface host { /// Pollable that fires when stdout / stderr has buffered /// data ready to be drained via `read-logs`. subscribe-logs: func() -> pollable; - - // ---- Stream halves (high-throughput stdio) ---- - // - // The byte-oriented `read-logs` / `write-stdin` methods above - // are the simple-case API: small REPL-style I/O, drained on - // demand. The stream halves below are the throughput API: pipe - // child stdio to/from a network connection, a file, or another - // process via `output-stream.splice` without crossing the WASM - // boundary per byte. Pick one access pattern per stream — the - // kernel serializes them but interleaving is rarely what you - // want. - - /// Child's stdin as an `output-stream`. Bytes written here go - /// to the child process. `close-stdin` (or dropping this - /// stream) signals EOF. - stdin: func() -> result; - - /// Child's stdout as an `input-stream`. Reads bytes the child - /// has written. Surfaces `closed` on child exit + buffer drain. - stdout: func() -> result; - - /// Child's stderr as an `input-stream`. Same semantics as - /// `stdout`. - stderr: func() -> result; } /// Spawn a synchronous (blocking) process. Blocks the WASM From 8a0f29d68e239dc60e04b113dc0fe9da4ad6c7b5 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Fri, 22 May 2026 04:11:08 +0400 Subject: [PATCH 5/5] fixup(io): apply Gemini review + drop immutability CI gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the four points raised on PR #8 and retires the frozen-file CI check while the @1.0.0 contracts are still in pre-adoption (no SDK or capsule has bound to them yet). WIT shape changes (host/io@1.0.0.wit): - poll(): per-call cap raised 64 -> 256. The 64 number was inconsistent with the per-capsule subscription quotas elsewhere (ipc allows 128 subscriptions alone); a capsule at full IPC quota plus TCP / UDP / HTTP / process pollables couldn't wait on them all in a single call. 256 covers the realistic worst case with headroom; per-principal profiles may still cap it lower. - input-stream.read(): no longer claims to trap on > 1 MiB. wasi:io semantics only trap on lengths exceeding wasm32's addressable range. The 1 MiB ceiling is a host-internal buffer ceiling that truncates the return — callers loop. Truncation is not an error and the doc now says so. - output-stream.blocking-write-and-flush(): drops the 4 KiB cap. wasi:io's 4096 is documentation pseudocode, not a contract. Real impls loop internally for arbitrary sizes; capping forces the guest into an outer loop that defeats the convenience method. Host now segments internally and yields between chunks. - output-stream.blocking-write-zeroes-and-flush(): same — drops the 4 KiB cap. This one is especially silly because the input is just a u64 length, no guest-side buffer to bound. CI / discipline: - scripts/lint-wit-immutability.sh removed. - .github/workflows/lint.yml: 'WIT frozen-file immutability' job deleted; 'WIT files parse' job kept. - README: 'Evolution discipline' section updated. The frozen-file rule remains documented; the automated check is retired during pre-adoption (no consumer means no real breakage to prevent). Re-enable from git history once a downstream consumer ships against a versioned file. scripts/validate-wit.sh passes on all 13 packages. --- .github/workflows/lint.yml | 20 ----------- README.md | 2 +- host/io@1.0.0.wit | 37 +++++++++++++++----- scripts/lint-wit-immutability.sh | 60 -------------------------------- 4 files changed, 29 insertions(+), 90 deletions(-) delete mode 100755 scripts/lint-wit-immutability.sh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 13ef813..bac43bb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,26 +7,6 @@ on: branches: [main] jobs: - wit-immutability: - name: WIT frozen-file immutability - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Determine base ref - id: base - run: | - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT" - else - echo "ref=HEAD~1" >> "$GITHUB_OUTPUT" - fi - - - name: Check frozen-file immutability - run: scripts/lint-wit-immutability.sh ${{ steps.base.outputs.ref }} - wit-parses: name: WIT files parse runs-on: ubuntu-latest diff --git a/README.md b/README.md index f915dfe..6616fbf 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ To evolve a package: 4. Leave the existing frozen file untouched. 5. The kernel registers both versions in its linker (`bindings::ipc_v1_0::add_to_linker` and `bindings::ipc_v1_1::add_to_linker`) so old and new capsules both load. -CI enforces this via `scripts/lint-wit-immutability.sh` — any PR that modifies or deletes a published `*@X.Y.Z.wit` file fails the build. +The rule is currently a documented convention rather than a CI gate. The automated frozen-file check was retired during pre-adoption iteration (no SDK or capsule is bound to `@1.0.0` yet, so in-place amendments don't break anyone). Once a real downstream consumer ships against a versioned file, re-enable the check (the original script lives in git history) so accidental edits surface in review. See [RFC: Host ABI](https://github.com/unicity-astrid/rfcs/pull/22) for the full design (per-domain packages, multi-version kernel registration, frozen-file rule) and [issue #750](https://github.com/unicity-astrid/astrid/issues/750) for the motivating bug. diff --git a/host/io@1.0.0.wit b/host/io@1.0.0.wit index 525c6ba..ffc6ca8 100644 --- a/host/io@1.0.0.wit +++ b/host/io@1.0.0.wit @@ -65,9 +65,11 @@ interface poll { invalid-input, /// Pollable handle was dropped (or never valid in this store). closed, - /// Caller exceeded a per-capsule cap (e.g. > 64 pollables in - /// a single `poll` call). Subdivide the wait set or use - /// resource-specific blocking calls. + /// Caller exceeded the hard per-call cap (256 pollables) on + /// `poll`. The cap is sized so a capsule at its full IPC + /// subscription quota (128) plus its TCP / UDP / HTTP / process + /// stream pollables can wait on them all in one call. + /// Subdivide the wait set or use resource-specific blocking. too-large, /// Block was cancelled because the capsule is unloading. cancelled, @@ -102,7 +104,13 @@ interface poll { /// that was ready when at least one became so. The returned list /// is sorted ascending and contains at least one entry on success. /// - /// Per-call cap: 64 pollables. Larger lists return `too-large`. + /// Per-call cap: 256 pollables. Larger lists return `too-large`. + /// The cap is sized so a capsule at full IPC subscription quota + /// (128) plus its TCP / UDP / HTTP / process pollables can wait + /// on them all in a single call. Per-principal quota profiles + /// may lower the effective cap further but never raise it above + /// 256. + /// /// Returns `cancelled` if the capsule unloads mid-poll. /// /// Audit: every `poll` call recorded (per-principal, with handle @@ -163,8 +171,13 @@ interface streams { /// available right now (not EOF). Use `subscribe` to wait. /// EOF or peer close surfaces as `closed` on the next call. /// - /// Per-call cap: 1 MiB. Larger requests trap (consistent with - /// wasi:io behaviour on `len` larger than wasm32 can allocate). + /// The host may return fewer than `len` bytes — `len` is the + /// caller's *upper bound*, and the host applies its own + /// internal buffer ceiling (currently 1 MiB) to bound a single + /// call's transfer. Callers loop on `read` to drain larger + /// volumes; truncation is not a stream error. Per `wasi:io` + /// semantics, a trap only occurs when `len` exceeds what + /// wasm32 can address (~4 GiB). /// /// Audit: recorded (per-principal, with bytes read). read: func(len: u64) -> result, stream-error>; @@ -217,8 +230,11 @@ interface streams { /// Convenience: write `contents` and flush, blocking until all /// bytes are accepted and the flush completes. Internally /// drives `check-write` / `subscribe` / `write` / `flush` in a - /// loop. Per-call payload capped at 4 KiB for parity with - /// wasi:io. + /// loop, so callers don't need an outer loop of their own. + /// No fixed cap on payload size — the host segments large + /// transfers internally and yields between chunks to keep + /// other capsules responsive. (The `wasi:io` documentation + /// mentions 4096 in pseudocode but does not actually cap.) /// /// Audit: recorded (per-principal, with total bytes written). blocking-write-and-flush: func(contents: list) -> result<_, stream-error>; @@ -245,7 +261,10 @@ interface streams { write-zeroes: func(len: u64) -> result<_, stream-error>; /// Convenience: write `len` zeroes and flush, blocking until - /// complete. Per-call cap: 4 KiB. + /// complete. No fixed cap on `len` — no guest-side buffer is + /// involved, so the host can stream arbitrary zero-fill sizes + /// efficiently. The host yields between chunks to keep other + /// capsules responsive. blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>; /// Move up to `len` bytes from `src` to this stream in the diff --git a/scripts/lint-wit-immutability.sh b/scripts/lint-wit-immutability.sh deleted file mode 100755 index 281decc..0000000 --- a/scripts/lint-wit-immutability.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# -# lint-wit-immutability.sh — enforces the frozen-WIT-file rule. -# -# Once a WIT file is named with an embedded version (`@X.Y.Z.wit`), -# that file is published — its shape is committed to forever. Shape changes -# ship as a new file at a new version path. -# -# This script fails if any *@X.Y.Z.wit file present on the base ref was -# modified or deleted between the base ref and HEAD. New files matching the -# pattern are allowed (that is the legitimate evolution path). -# -# Usage: -# scripts/lint-wit-immutability.sh [BASE_REF] -# -# BASE_REF defaults to origin/main. - -set -euo pipefail - -BASE_REF="${1:-origin/main}" - -if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then - echo "error: base ref '$BASE_REF' not resolvable. Run 'git fetch' or pass a valid ref." >&2 - exit 2 -fi - -PATTERN='@[0-9]+\.[0-9]+\.[0-9]+\.wit$' - -violations=$(git diff --name-status "$BASE_REF"...HEAD \ - | awk -F'\t' -v pat="$PATTERN" ' - ($1 == "M" || $1 == "D") && $2 ~ pat { print $1 "\t" $2 } - $1 ~ /^R/ && $2 ~ pat { print "R\t" $2 " -> " $3 } - ') - -if [[ -z "$violations" ]]; then - echo "wit immutability lint: ok" - exit 0 -fi - -cat >&2 <