diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c7b4d33..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 @@ -37,8 +17,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/README.md b/README.md index f674136..6616fbf 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` | 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. | @@ -73,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/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/host/http@1.0.0.wit b/host/http@1.0.0.wit index 231a14f..b62b282 100644 --- a/host/http@1.0.0.wit +++ b/host/http@1.0.0.wit @@ -11,7 +11,8 @@ package astrid:http@1.0.0; interface host { - use wasi:io/poll@0.2.0.{pollable}; + 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 new file mode 100644 index 0000000..ffc6ca8 --- /dev/null +++ b/host/io@1.0.0.wit @@ -0,0 +1,289 @@ +/// Foundation I/O primitives — Astrid-owned readiness multiplexing, +/// downcastable error resource, and byte streams. +/// +/// 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. +/// +/// Why Astrid-owned and not wasi:io: +/// +/// - `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, 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 +/// ship as a new file at a new version path; never edit this file. + +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 { + /// `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 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, + /// Unspecific host error; detail is best-effort. + unknown(string), + } + + /// An opaque handle to a future readiness signal. + resource pollable { + /// Non-blocking readiness check. + /// + /// Returns `true` if a subsequent `block` would return + /// 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. Returns + /// `cancelled` in the latter case; capsules should treat + /// that as graceful shutdown. + /// + /// 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 returned list + /// is sorted ascending and contains at least one entry on success. + /// + /// 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 + /// 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. + /// + /// 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>; + + /// 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, 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>; + + /// 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. 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 + /// 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/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..b4b3b40 100644 --- a/host/net@1.0.0.wit +++ b/host/net@1.0.0.wit @@ -16,7 +16,8 @@ package astrid:net@1.0.0; interface host { - use wasi:io/poll@0.2.0.{pollable}; + use astrid:io/poll@1.0.0.{pollable}; + use astrid:io/streams@1.0.0.{input-stream, output-stream}; // ----------------------------------------------------------------- // Error type @@ -116,7 +117,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; } @@ -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 c86d448..a819553 100644 --- a/host/process@1.0.0.wit +++ b/host/process@1.0.0.wit @@ -5,13 +5,27 @@ /// 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. 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. 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 </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.