From f305f8c0a5c713128a5faea1ee1bbd95e06d4f4f Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Wed, 12 Aug 2026 12:13:53 +0000 Subject: [PATCH] feat: implement channels protocol + ADR-047 (openable ALPNs are operations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-047: the unifying decision that dissolves into per-ALPN ops (, ) with a marker on . Each openable ALPN registers its own ops with their own , , , and the marker. The field is replaced by (Sub/Pub). The generic ops (channel/close, channel/control, channel/resources/subscribe) stay, keyed by channel_id. Resolves Gaps A-G from the research findings (Gap B broker named out-of-scope for alkcall; Gap C relay wrapper is consumer concern; Gap D connection-owner allocates; Gap E extension trait; Gap F boolean marker on wire; Gap G ACL/ownership complementary). ADR-037 amended: dissolves; removed; generic ops stay; preview dropped from resources/subscribe. Spec docs updated: channel-operations.md (unified model, opener ledger, ACL flow), operation-registry.md (channel_open marker, ChannelOpenSpec), README.md (ADR-047), open-questions.md (OQ-31..38 resolved). Source changes: - spec.rs: ChannelOpenSpec struct, channel_open field on OperationSpec, with_channel_open builder, 3 tests - discovery.rs: spec_to_json emits channel_open boolean, operation_spec_schema includes channel_open, 2 tests - from_call.rs: rebuild_spec_for parses channel_open marker, derive_alpn_from_op_name helper, 6 tests Channels module (src/channels/, 10 files, ~2400 lines): - wire.rs: 8-byte chunk header (ChunkHeader, parse/write_header, read_header/write_chunk/write_eof async helpers), 12 tests - reassembly.rs: MpscRecvStream (tokio::mpsc::Receiver → AsyncRead), MpscSendStream (AsyncWrite → tokio::mpsc::Sender), REQ-CH-01 shutdown sentinel, REQ-CH-02 sender-drop EOF, 10 tests - mux.rs: MuxHandle (clone-able, register(channel_id)), MuxRunner (per-channel pump tasks, exits when handles drop), OpenerLedger (ADR-047 §7), 4 tests - manager.rs: ChannelManager (channel map, open_channel, install_channel_zero, route_payload, teardown_channel, clear_all), 11 tests - source.rs: ChannelBidiStreamSource (yield-once accept_bi), channel_source helper, 4 tests - adapter.rs: ChannelsAdapter (ProtocolHandler for alknet/channels, demux loop, install_channel_zero hook), 1 test - operations.rs: ChannelOperations (registers channel/close, channel/control, channel/resources/subscribe), ChannelCore (check_open/on_close wrappers), 4 tests - policy.rs: ChannelLifecyclePolicy trait, NoCap, PerIdentityChannelPolicy (default 256, per_identity_caps override), default_policy, 8 tests - env.rs: ChannelOperationEnv extension trait (ADR-047 §4), ChannelsSessionEnv impl, 2 tests - client.rs: ChannelClient (from_connection, call_open_op, take_call_connection), 1 test Verification: 432 tests pass (66 new channels + 10 marker + 356 existing), clippy clean, fmt clean, cargo doc generates. Cargo.toml: +bytes dependency. --- Cargo.lock | 1 + Cargo.toml | 1 + docs/architecture/README.md | 4 +- docs/architecture/channel-operations.md | 420 ++++++++-------- .../037-channel-lifecycle-operations.md | 41 +- .../047-openable-alpns-are-operations.md | 373 ++++++++++++++ docs/architecture/open-questions.md | 10 +- docs/architecture/operation-registry.md | 14 +- src/channels/adapter.rs | 219 +++++++++ src/channels/client.rs | 137 ++++++ src/channels/env.rs | 163 ++++++ src/channels/manager.rs | 465 ++++++++++++++++++ src/channels/mod.rs | 44 ++ src/channels/mux.rs | 283 +++++++++++ src/channels/operations.rs | 365 ++++++++++++++ src/channels/policy.rs | 273 ++++++++++ src/channels/reassembly.rs | 439 +++++++++++++++++ src/channels/source.rs | 169 +++++++ src/channels/wire.rs | 271 ++++++++++ src/client/from_call.rs | 129 ++++- src/lib.rs | 11 +- src/registry/discovery.rs | 48 +- src/registry/spec.rs | 77 +++ 23 files changed, 3753 insertions(+), 204 deletions(-) create mode 100644 docs/architecture/decisions/047-openable-alpns-are-operations.md create mode 100644 src/channels/adapter.rs create mode 100644 src/channels/client.rs create mode 100644 src/channels/env.rs create mode 100644 src/channels/manager.rs create mode 100644 src/channels/mod.rs create mode 100644 src/channels/mux.rs create mode 100644 src/channels/operations.rs create mode 100644 src/channels/policy.rs create mode 100644 src/channels/reassembly.rs create mode 100644 src/channels/source.rs create mode 100644 src/channels/wire.rs diff --git a/Cargo.lock b/Cargo.lock index 3ea8b0c..2b31c94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,7 @@ version = "0.1.0" dependencies = [ "alktype", "async-trait", + "bytes", "futures", "parking_lot", "serde", diff --git a/Cargo.toml b/Cargo.toml index ba63260..8853e0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,4 +28,5 @@ thiserror = "2" uuid = { version = "1", features = ["v4"] } futures = "0.3" parking_lot = "0.12" +bytes = "1" zeroize = { version = "1", features = ["alloc", "derive"] } \ No newline at end of file diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a923e62..bde5b86 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -99,6 +99,7 @@ are wire-stable and unchanged — see ADR-004. | [044](decisions/044-channels-subcrate-decomposition.md) | Channels Sub-Crate Decomposition | channels-core / channels-call (modules in alkcall) | | [045](decisions/045-alknetclient-native-dial-seam.md) | AlknetClient Dial Seam | spawn_dispatch / from_connection take-over; dial in consumer | | [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Publish Operation Type and HandlerKind::Sink | `OperationType::Pub` (client→server streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` | +| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops `channels//sub`/`pub`; `channel_open` marker on `OperationSpec`; `ChannelCore` wrapper; extension-trait `ChannelOperationEnv`; connection-owner allocates `channel_id`; opener ledger (Gap 2 fix); ALPNs are call apps | ## Relevant Open Questions @@ -107,7 +108,8 @@ questions affecting this crate: - **OQ-01**: Call protocol pub/sub primitive (partially resolved) — ADR-046 adds the `Pub` primitive (client→server streaming). The - fan-out/broker is deferred to channels. + fan-out/broker is deferred to channels (Gap B in ADR-047 is named + out-of-scope for alkcall; the hub composes the broker on top). - **OQ-02**: Full channel-level flow-control windowing (deferred(scope)) — bounded-buffer is decided (ADR-040); full windowing blocked on a real HOL-blocking deployment observation. diff --git a/docs/architecture/channel-operations.md b/docs/architecture/channel-operations.md index 41ef811..e71c3c5 100644 --- a/docs/architecture/channel-operations.md +++ b/docs/architecture/channel-operations.md @@ -1,67 +1,103 @@ --- status: draft -last_updated: 2026-07-18 +last_updated: 2026-08-12 --- # channel-operations.md — Channel Lifecycle on the Call Protocol Channel lifecycle is orchestrated by the call protocol on channel 0 -(ADR-036). Four operations on channel 0's `OperationRegistry` (ADR-037) -handle open, close, control, and resource discovery. All four go through -the existing `OperationContext` / `AccessControl::check` path — no new auth -machinery, no new framing. +(ADR-036). Under the unified model (ADR-047), `channel/open` dissolves +into **per-ALPN ops** (`channels//sub`, `channels//pub`), +each with its own `access_control`, `input_schema`, `resource_id_path`, +and a `channel_open` marker. The generic ops `channel/close`, +`channel/control`, `channel/resources/subscribe` stay (keyed by +`channel_id`). All go through the existing `OperationContext` / +`AccessControl::check` path — no new auth machinery, no new framing. -## The four operations +## The open ops (per-ALPN, ADR-047) -### `channel/open` — open a data channel +Each openable ALPN registers its own ops on the call +`OperationRegistry` at assembly time, named `channels//sub` +and/or `channels//pub`: -Request (on channel 0): +| Op | `OperationType` | Initiator | Responder | Stream direction | +|----|-----------------|-----------|-----------|------------------| +| `channels//sub` | `Sub` | consumer (subscribes) | producer (streams) | server → client | +| `channels//pub` | `Pub` | producer (publishes) | consumer (receives) | client → server | + +The `channels//...` path segment is the ALPN with the `alknet/` +prefix stripped (ADR-047 §"Negative"). An ALPN may register one or both +ops; separate op types → separate ACLs. The op spec carries the +`channel_open: Option` marker (ADR-047 §2) — the +dispatch hint that tells the channels layer "this op's stream is +binary, allocate a channel for it." + +### `channels//sub` — subscribe to a binary stream + +Request (`call.requested` on channel 0): ```json { - "operation": "channel/open", - "input": { - "alpn": "alknet/tty", - "params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" }, - "direction": "initiator-to-responder" - } + "operation": "channels/tty/sub", + "input": { "backend": "docker", "cmd": ["bash"], "container": "abc123" } +} +``` + +The `input` is ALPN-specific params (the former `params` field, now the +op's `input_schema`). For `alknet/tty` this is the `NegotiateRequest`; +for `alknet/tunnel` this is the target resource. The channels layer does +not interpret `input`. + +Response (`call.responded`): + +```json +{ + "output": { "channel_id": 7 } } ``` | field | type | meaning | |-------|------|---------| -| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. | -| `params` | object | ALPN-specific parameters. For `alknet/tty` this is `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params`. | -| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. | +| `channel_id` | u32 | Allocated by the connection owner (ADR-047 §5). In the `Sub` case, the responder (producer). Both sides route chunks with this ID to the new channel. | -Response: +**`channel_id` allocation (ADR-047 §5): connection-owner allocates.** +The side that holds the `ChannelManager` for that connection allocates +the `channel_id` via a monotonic `AtomicU32` (`next_id.fetch_add(1, +Relaxed)`) and returns it in the response. In the `Sub` case the +responder owns the connection and allocates; in the `Pub` case the +initiator owns and allocates. One round-trip before data flows — the +same round-trip the call protocol makes for every operation. All +current channel types (TTY, tunnel, SSH) already require a negotiation +round-trip, so the open round-trip is not additive latency. -```json -{ - "output": { - "channel_id": 7 - } -} -``` - -| field | type | meaning | -|-------|------|---------| -| `channel_id` | u32 | Server-assigned (DP-1). The responder allocates via monotonic `AtomicU32`. | - -**Channel ID allocation: server-assigned (DP-1).** One round-trip before -data flows — the same round-trip the call protocol makes for every -operation. All current channel types (TTY, tunnel, SSH) already require a -negotiation round-trip, so the open round-trip is not additive latency. - -**Error codes** (new `CallError.code` strings, not new framing): +**Error codes** (`CallError.code` strings): | code | meaning | retryable | |------|---------|-----------| -| `channel:unknown_alpn` | ALPN not in responder's `HandlerRegistry` | false | | `channel:forbidden` | `AccessControl::check` denied the open | false | -| `channel:allocation_failed` | Handler allocate failed | true (often transient) | -| `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false | -| `channel:too_many_channels` | Per-connection channel limit hit (ADR-040) | false | +| `channel:allocation_failed` | Handler allocate failed (e.g., backend couldn't start) | true (often transient) | +| `channel:too_many_channels` | Per-connection or per-identity channel limit hit (ADR-040, ADR-041) | false | +| `channel:no_channels_session` | The op was invoked outside a channels session (no `ChannelManager` — ADR-047 §2) | false | + +`channel:unknown_alpn` and `channel:invalid_params` (ADR-037) are gone — +an unregistered ALPN is an ordinary `NOT_FOUND` (op not registered); a +bad `input` is ordinary schema rejection. + +### `channels//pub` — publish a binary stream + +`OperationType::Pub` (ADR-046). The initiator publishes a stream of +`call.published` events to the responder; the responder's `SinkHandler` +consumes them and returns a single `call.responded`. The +`channel_open` marker on the op spec tells the channels layer the +stream is binary — the `call.published` chunks carry binary payloads +on a data channel (not JSON on channel 0). + +The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a +worker publishes to the hub, the hub owns the resource, and consumers +subscribe from the hub. The broker (topic registry, `Pub`↔`Sub` +matching) is a hub concern, not an alkcall concern (ADR-047 §1, Gap B). + +## The generic ops (keyed by `channel_id`) ### `channel/close` — tear down a channel @@ -72,21 +108,29 @@ negotiation round-trip, so the open round-trip is not additive latency. } ``` -The responder (the side that didn't send the close) drains its reassembled -stream for `channel_id`, signals EOF to the handler, and returns -`{ "closed": true }`. The `channel_id` is eligible for reuse after the drain -completes (ADR-040 — monotonic IDs with wrap-around, not a free-list). -`reason` is free-form for observability — not semantically required. +The responder (the side that didn't send the close) drains its +reassembled stream for `channel_id`, signals EOF to the handler, and +returns `{ "closed": true }`. The `channel_id` is eligible for reuse +after the drain completes (ADR-040 — monotonic IDs with wrap-around, +not a free-list). `reason` is free-form for observability — not +semantically required. -**REQ-CH-06: exit-chunk-before-close ordering.** The channel's data chunks -MUST be written and flushed before the `channel/close` operation is sent on -channel 0. The side closing must observe the data-channel pump complete -before issuing the call operation. For TTY this is the exit-chunk-is-last -invariant (ADR-055) carried forward — the exit control message rides on -TTY's `STREAM_CTRL_OUT` (stream_type 4, inside TTY's 5-byte payload -format); for tunnels it is the last data byte before close. This invariant -crosses two channels (the data channel and channel 0), so the channels -layer owns the ordering guarantee. +**Per-identity quota decrement (ADR-047 §7).** The decrement is keyed +by the **opener** (from the per-connection opener ledger), not the +closer. The decrement is called from every teardown path — close +received, close sent locally, handler exit, connection drop — not just +`channel/close`. The ledger entry is removed atomically with its +decrement. + +**REQ-CH-06: exit-chunk-before-close ordering.** The channel's data +chunks MUST be written and flushed before the `channel/close` operation +is sent on channel 0. The side closing must observe the data-channel +pump complete before issuing the call operation. For TTY this is the +exit-chunk-is-last invariant (ADR-055) carried forward — the exit +control message rides on TTY's `STREAM_CTRL_OUT` (stream_type 4, inside +TTY's 5-byte payload format); for tunnels it is the last data byte +before close. This invariant crosses two channels (the data channel and +channel 0), so the channels layer owns the ordering guarantee. ### `channel/control` — out-of-band control on channel 0 @@ -104,15 +148,16 @@ keepalive): ``` The channels layer routes `message` to the handler's control handle for -`channel_id`. The `message` JSON is ALPN-specific; the channels layer does -not interpret it. +`channel_id`. The `message` JSON is ALPN-specific; the channels layer +does not interpret it. ### `channel/resources/subscribe` — live resource discovery -**This is a `Subscription` operation (ADR-021), not a polled Query.** The -call protocol has `StreamingHandler` / `invoke_streaming` (implemented and -tested). The first consumer (the hub aggregating worker resources) needs -live updates when workers connect/disconnect or containers start/stop. +**This is a `Sub` operation (ADR-021), not a polled Query.** The call +protocol has `StreamingHandler` / `invoke_streaming` (implemented and +tested). The first consumer (the hub aggregating worker resources) +needs live updates when workers connect/disconnect or containers +start/stop. ```json { @@ -121,8 +166,8 @@ live updates when workers connect/disconnect or containers start/stop. } ``` -The responder registers a `StreamingHandler` that emits a `ResponseEnvelope` -whenever the resource set changes. Each event: +The responder registers a `StreamingHandler` that emits a +`ResponseEnvelope` whenever the resource set changes. Each event: ```json { @@ -130,13 +175,11 @@ whenever the resource set changes. Each event: "resources": [ { "alpn": "alknet/tty", - "backends": ["docker", "local"], - "access": { "required_scopes": ["tty:open"] } + "backends": ["docker", "local"] }, { "alpn": "alknet/tunnel", - "targets": ["container:*", "service:postgres"], - "access": { "required_scopes_any": ["tunnel:open", "admin"] } + "targets": ["container:*", "service:postgres"] } ] } @@ -145,43 +188,21 @@ whenever the resource set changes. Each event: | field | type | meaning | |-------|------|---------| -| `alpn` | string | The ALPN this side accepts `channel/open` for. | -| `backends` / `targets` | `[string]` | ALPN-specific enumeration of what's available. The channels layer doesn't interpret these. | -| `access` | object | A preview of the `AccessControl` that `channel/open` will check. Advisory — lets the initiator fail fast. The real check happens on `channel/open`. | +| `alpn` | string | The ALPN this side accepts open ops for. | +| `backends` / `targets` | `[string]` | ALPN-specific enumeration of what's available. The channels layer doesn't interpret these; they're for the initiator to know what `input` to send. | -The stream emits an initial snapshot immediately, then subsequent events on -any change. The stream is long-lived; the subscriber cancels by dropping the -subscription (ADR-020 abort cascade applies). +The `access` preview (ADR-037) is **dropped** (ADR-047 §6) — it's on the +op spec, available via `services/schema`. Carrying a preview in a +different shape invites staleness; the spec is the authority. + +The stream emits an initial snapshot immediately, then subsequent events +on any change. The stream is long-lived; the subscriber cancels by +dropping the subscription (ADR-020 abort cascade applies). A `channel/resources` (non-subscribe, `Query`) operation is NOT provided. The subscription's initial snapshot serves the poll use case (subscribe, -read the first event, cancel). Providing both would be redundant and would -pressure consumers toward the stale-poll path. - -## Direction semantics (OQ-CH-09 — pinned) - -Channel open is **bidirectional** — either side can initiate. The -`direction` field determines who is the ALPN-server (allocates the handler, -writes the negotiation response) vs the ALPN-client (writes the first -request). - -| `direction` | Initiator role | Responder role | Who writes first | -|-------------|----------------|----------------|-------------------| -| `initiator-to-responder` | ALPN-client | ALPN-server | Initiator writes first (the request data); responder's handler is the server side. The common case: "open me a TTY on your docker container." | -| `responder-to-initiator` | ALPN-server | ALPN-client | Responder writes first (the negotiation response); initiator's handler is the client side. The "worker exposes, hub consumes" case: the worker initiates the open to make itself available; the hub is the client. | - -**The channels layer does not enforce write order.** Write order is -ALPN-specific, determined by which side is the ALPN-server. The channels -layer routes chunks; the handlers negotiate who writes first via their -ALPN's `params` contract. - -**`channel_id` allocation is always by the responder** (DP-1), regardless of -`direction`. The responder is the side that receives the `channel/open` call -operation; it allocates the ID and returns it. In the `responder-to- -initiator` case, the initiator (worker) sends the `channel/open`, so the -responder (hub) allocates the ID — even though the worker is the ALPN-server -for the channel's data. This keeps ID allocation in one place and avoids the -collision-prone client-assigned alternative. +read the first event, cancel). Providing both would be redundant and +would pressure consumers toward the stale-poll path. ## Control-message division (DP-4 — pinned) @@ -192,50 +213,61 @@ collision-prone client-assigned alternative. The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT` -(stream_type 4, inside TTY's 5-byte payload format) because it must arrive -after the last data on TTY's stdout stream_type, guaranteed by TTY's -per-stream_type chunk ordering within its own 5-byte format, not by a -call-protocol round-trip. The `channel/close` operation that follows is -on channel 0 and is ordered after the data pump completes (REQ-CH-06). +(stream_type 4, inside TTY's 5-byte payload format) because it must +arrive after the last data on TTY's stdout stream_type, guaranteed by +TTY's per-stream_type chunk ordering within its own 5-byte format, not +by a call-protocol round-trip. The `channel/close` operation that +follows is on channel 0 and is ordered after the data pump completes +(REQ-CH-06). -**The control-message division is handler-internal.** Under ADR-035, the -channels layer has no `stream_type` concept — it carries the handler's -framing transparently in the payload. TTY's `STREAM_CTRL_IN` (stream_type -3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types in TTY's 5-byte -format (ADR-052, amended by Phase 7), not channels-layer concepts. The -channels layer routes by `channel_id` only; the handler owns its -sub-stream multiplexing on the `BiStream` it receives. The -"bidirectional control channel" property is a TTY-layer concern, fixed -at the TTY layer by Phase 7's split — the channels layer doesn't know -about it. +**The control-message division is handler-internal.** Under ADR-035, +the channels layer has no `stream_type` concept — it carries the +handler's framing transparently in the payload. TTY's `STREAM_CTRL_IN` +(stream_type 3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types +in TTY's 5-byte format (ADR-052, amended by Phase 7), not +channels-layer concepts. The channels layer routes by `channel_id` only; +the handler owns its sub-stream multiplexing on the `BiStream` it +receives. ## ACL flow (end-to-end) A browser opening a TTY channel to a spoke through a hub (ADR-042): -1. Browser's channel 0 → hub's channel 0: `channel/open` - `{ alpn: "alknet/tty", params: { backend: "docker", cmd: ["bash"], container: "abc123" } }`. - The browser's identity is a bearer token (ADR-034). -2. Hub's `CallAdapter` runs `AccessControl::check` on `channel/open` with - the browser's identity. If denied → `channel:forbidden`. -3. Hub forwards to spoke via `from_call`: the hub's `forwarded_for` handler - constructs a `call.requested` with the hub as caller and the browser as - `forwarded_for` (ADR-026 §3). The spoke receives `channel/open` with - `caller = hub`, `forwarded_for = browser`. -4. Spoke's `CallAdapter` runs `AccessControl::check` with the hub as caller - (the spoke authorizes the hub — ADR-011). The spoke's ownership store - verifies the hub (or the `forwarded_for` browser, per policy) owns - `container:abc123`. -5. Spoke allocates the channel via `TtyAdapter` / `DockerTtyBackend`, - returns `channel_id`. -6. Hub opens a matching channel on the browser's side and bridges them - (byte-forward with `channel_id` rewrite — ADR-042). +1. Browser's channel 0 → hub's channel 0: `channels/tty/sub` + `{ backend: "docker", cmd: ["bash"], container: "abc123" }`. + The browser's identity is a bearer token (ADR-004). +2. Hub's `CallAdapter` runs `AccessControl::check` on + `channels/tty/sub` with the browser's identity. If denied → + `channel:forbidden`. +3. Hub forwards to spoke via `from_call`: the hub's `forwarded_for` + handler constructs a `call.requested` with the hub as caller and the + browser as `forwarded_for` (ADR-026 §3). The spoke receives + `channels/tty/sub` with `caller = hub`, `forwarded_for = browser`. +4. Spoke's `CallAdapter` runs `AccessControl::check` with the hub as + caller (the spoke authorizes the hub — ADR-011). The spoke's + ownership store verifies the hub owns `container:abc123` + (ADR-050 §4c — `forwarded_for` is metadata, not authority; the spoke + sees the hub as the owner). +5. Spoke's `ChannelCore` allocates `channel_id` via `next_id.fetch_add` + (ADR-047 §5 — connection-owner allocates; the spoke is the responder + for `Sub`, so it allocates), spawns `TtyAdapter` on the channel's + `BiStream` with the docker backend, records opener (hub) in the + ledger (ADR-047 §7), returns `{channel_id}`. +6. Hub receives the spoke's `{channel_id}`, opens a matching channel on + the browser's side (hub is the responder for the browser leg), + records the `channel_id` mapping `browser_id ↔ spoke_id`, returns + `{channel_id: browser_id}` to the consumer. +7. Hub byte-forwards between `browser_id` and `spoke_id` with 4-byte + `channel_id` rewrite (ADR-042 unchanged). -The hub ran **zero** protocol-specific auth. It ran `channel/open`'s -`AccessControl::check` (call-protocol machinery) and forwarded. The channels -layer inherited the auth model by being a call-protocol operation. +The hub ran **zero** protocol-specific auth. It ran `channels/tty/sub`'s +`AccessControl::check` (call-protocol machinery) and forwarded. The +relay contract from ADR-042 holds unchanged in shape; only the op name +changed (from generic `channel/open` to per-ALPN `channels/tty/sub`), +and the `channel_open` marker (not prefix-matching) is how the hub +recognizes and translates channel-open ops. -## Per-identity channel cap (ADR-041) +## Per-identity channel cap (ADR-041, ADR-047 §7) A channel slot is a resource. The cap on how many channels an identity may hold open is a quota check on that resource — parallel to @@ -243,7 +275,7 @@ may hold open is a quota check on that resource — parallel to primitive, different resource. The cap is a **peer concern**, not a hub-specific concern: any accepting peer (worker or hub) enforces the cap on its inbound channels, just as it enforces `AccessControl::check` -on `channel/open`. The cap is also **symmetric** — both sides of a +on the open op. The cap is also **symmetric** — both sides of a channels connection enforce their cap on the other's channels. ### Why the cap is not in the channels layer @@ -266,22 +298,23 @@ documented here; see ADR-041 for the corrected DoS-defense framing. ### The `ChannelLifecyclePolicy` trait ```rust -/// Per-identity channel lifecycle policy. Consulted by the -/// `channel/open` handler (after `AccessControl::check`, before -/// allocation) and the `channel/close` handler (after deallocation). -/// Both handlers have the identity via `OperationContext`. +/// Per-identity channel lifecycle policy. Consulted by the open-op +/// wrapper (after `AccessControl::check`, before allocation) and on +/// every teardown path (after deallocation). Both have the identity +/// via `OperationContext` (for open) or the opener ledger (for +/// teardown — ADR-047 §7). pub trait ChannelLifecyclePolicy: Send + Sync + 'static { /// Before channel allocation. Deny with `channel:too_many_channels` - /// (ADR-037) when the identity is over its cap. The identity is - /// the direct caller (the peer that opened this channels - /// connection); `forwarded_for` is metadata and is NOT consulted - /// (ADR-026). + /// when the identity is over its cap. The identity is the direct + /// caller (the peer that opened this channels connection); + /// `forwarded_for` is metadata and is NOT consulted (ADR-026). fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>; /// After channel deallocation. Decrement the per-identity count. - /// Called by the `channel/close` handler after the drain completes - /// (ADR-040 §channel-id-reuse). - fn on_close(&self, identity: &Identity); + /// Called on every teardown path (close received, close sent + /// locally, handler exit, connection drop), keyed by the opener + /// from the per-connection ledger (ADR-047 §7) — not the closer. + fn on_close(&self, opener: &Identity); } ``` @@ -317,28 +350,25 @@ channel_ops.register_on(&mut call_registry)?; ### Enforcement point: between `AccessControl::check` and allocation -The `channel/open` handler (above) gains the policy check after ACL -and before `next_id.fetch_add`: +The open-op wrapper (ADR-047 §3 — the `ChannelCore` wrapper around the +ALPN's open handler) gains the policy check after ACL and before +`next_id.fetch_add`: 1. ACL is already checked by `OperationRegistry::invoke` (the existing `AccessControl::check` path — unchanged). 2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with `channel:too_many_channels` if over cap. 3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)` - (DP-1: server-assigned — unchanged). -4. Construct the `ChannelBidiStreamSource`, spawn the handler, record + (ADR-047 §5 — connection-owner allocates). +4. Record the opener in the per-connection ledger (ADR-047 §7). +5. Construct the `ChannelBidiStreamSource`, spawn the handler, record the `ChannelState` (unchanged). -5. Return the `channel_id`. +6. Return the `channel_id`. -The `channel/close` handler gains the decrement after the drain -completes (the same point ADR-040 marks the `channel_id` as eligible -for reuse): - -1. Drain the reassembly buffer for `channel_id` (existing — ADR-040 - §channel-id-reuse). -2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the - per-identity count. -3. Return `{ "closed": true }` (unchanged). +Every teardown path (close received, close sent locally, handler exit, +connection drop) walks the ledger and calls `policy.on_close(opener)` +per open channel, removing the ledger entry atomically with its +decrement (ADR-047 §7). ### Relay consequence: the spoke caps the hub, not the browser @@ -354,20 +384,13 @@ channels. The hub's per-browser caps are the hub's own concern (enforced on the browser leg by the hub's own policy), not the spoke's. -This is correct and consistent — the spoke authorizes the hub for -container access the same way it authorizes any peer, and the hub's -browser-relay ACL is the hub's own layer. The channel cap follows the -same pattern as any other resource ACL. - **Deployment consequence:** a spoke that serves a hub relaying for many browsers must set the hub peer's cap higher than a worker peer's cap, or the spoke denies legitimate relayed channels when the hub's aggregate count exceeds a worker-sized cap. This is a per-peer-role policy, set by the spoke via `with_per_identity_caps`. The architecture provides the mechanism; the deployment sets the numbers. -This is not a flaw — it is the same shape as any per-peer ACL (a -spoke may authorize one peer for 1000 containers and another for 10; -the channel cap is the same kind of per-peer policy). +This is not a flaw — it is the same shape as any per-peer ACL. ### Recursive channels do not bypass the cap @@ -384,23 +407,27 @@ are an edge case for edge cases and not specced further. The hub **translates**, not transparently forwards: 1. **Call-protocol layer (channel 0): translate.** The hub terminates - channel 0 on both legs. `channel/open` from the browser → hub's - `AccessControl::check` → hub re-issues `channel/open` on the spoke leg - with `forwarded_for` → spoke returns its `channel_id` → hub maps - browser-id ↔ spoke-id. + channel 0 on both legs. `channels//sub` from the browser → + hub's `AccessControl::check` → hub re-issues `channels//sub` + on the spoke leg with `forwarded_for` → spoke returns its + `channel_id` → hub maps browser-id ↔ spoke-id. 2. **Data-channel layer: byte-forward with `channel_id` rewrite.** The - relay reads chunks for `browser_id`, rewrites the `channel_id` field to - `spoke_id`, writes onto the spoke's channels connection — and vice versa. - The relay does not parse the payload. + relay reads chunks for `browser_id`, rewrites the `channel_id` field + to `spoke_id`, writes onto the spoke's channels connection — and + vice versa. The relay does not parse the payload. -`channel/control` operations on channel 0 carry `channel_id` in their JSON -payload; the hub's `CallAdapter` translates these too (rewrites -`channel_id` in the payload). The relay does not touch `channel/control` — -it's a call operation, translated, not byte-forwarded. +`channel/control` operations on channel 0 carry `channel_id` in their +JSON payload; the hub's `CallAdapter` translates these too (rewrites +`channel_id` in the payload). The relay does not touch +`channel/control` — it's a call operation, translated, not +byte-forwarded. The hub never runs a handler for `alknet/tty`, `alknet/ssh`, or `alknet/tunnel`. It runs `alknet/channels` (the relay) and `alknet/call` -(for its own hub-level operations + translation). +(for its own hub-level operations + translation). The `channel_open` +marker (ADR-047 §2) is how the hub recognizes a channel-open op during +`from_call` discovery (ADR-047 §1, Gap C) — the `from_call` relay +wrapper wraps marked ops with relay machinery. ## Design Decisions @@ -408,20 +435,25 @@ All design decisions are documented as ADRs in [decisions/](decisions/). | ADR | Decision | Summary | |-----|----------|---------| -| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll | -| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` | -| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels | -| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound | -| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `channel/open`; no `stream_type` on `channel/control`; handler owns sub-stream multiplexing | -| [049](decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses | -| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) | -| [050](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check | +| [037](decisions/037-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The generic ops; `direction` pinned (amended by ADR-047 — `channel/open` dissolves; `direction` removed) | +| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | Per-ALPN open ops; `channel_open` marker; `ChannelCore` wrapper; opener ledger | +| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = alknet/call | +| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels | +| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per PeerId, enforced via ChannelLifecyclePolicy in channels-call (amended by ADR-047 §7 — opener ledger, every teardown path) | +| [035](decisions/035-channels-pure-channel-multiplexing.md) | Pure Channel Multiplexing | No stream_types; handler owns sub-mux | +| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses | +| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Pub Operation Type | The `Pub`/`Sub` primitives the per-ALPN open ops build on | +| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) | +| [011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check; `resource_id_path` works again under per-ALPN ops | ## References -- ADR-037: channel lifecycle operations (the decision) -- ADR-041: per-identity channel cap (the cap, the trait, the relay - consequence) +- ADR-047: openable ALPNs are operations (the unifying ADR — per-ALPN + open ops, `channel_open` marker, `ChannelCore` wrapper, opener ledger) +- ADR-037: channel lifecycle operations (amended by ADR-047) +- ADR-041: per-identity channel cap (amended by ADR-047 §7 — opener + ledger, every teardown path) - ADR-042: hub relay (the translate contract) -- `docs/research/alknet-channels/phase-0-findings.md` §Channel Open - Negotiation, §ACL and Security Model \ No newline at end of file +- ADR-046: Pub operation type (the `Pub`/`Sub` primitives) +- `/workspace/@alkdev/alknet/docs/research/call-channels-unification/ + findings.md` — the research that surfaced the unification and the gaps \ No newline at end of file diff --git a/docs/architecture/decisions/037-channel-lifecycle-operations.md b/docs/architecture/decisions/037-channel-lifecycle-operations.md index 672d1e0..82b17b8 100644 --- a/docs/architecture/decisions/037-channel-lifecycle-operations.md +++ b/docs/architecture/decisions/037-channel-lifecycle-operations.md @@ -6,7 +6,46 @@ Accepted (amended 2026-07-18 by ADR-035 — `stream_types` field removed from `channel/open`; `stream_type` field removed from `channel/control`; `channel:stream_type_unavailable` error code removed; the channels layer has no `stream_type` concept — see "Amendment (ADR-035, 2026-07-18)" -below) +below; amended 2026-08-12 by ADR-047 — `channel/open` dissolves into +per-ALPN ops `channels//sub` and `channels//pub`; the +`direction` field is removed (replaced by `OperationType`); the generic +ops `channel/close`, `channel/control`, `channel/resources/subscribe` +stay; error codes `channel:unknown_alpn` and `channel:invalid_params` +become ordinary `NOT_FOUND` / schema rejection — see "Amendment +(ADR-047, 2026-08-12)" below) + +## Amendment (ADR-047, 2026-08-12) + +The generic `channel/open` operation is **removed**. Each openable ALPN +registers its own ops on the call `OperationRegistry`, named +`channels//sub` (`OperationType::Sub` — consumer subscribes to a +binary stream) and/or `channels//pub` (`OperationType::Pub` — +producer publishes a binary stream). The `direction` field is **removed** +— `OperationType` carries the direction (building on ADR-046). The +`alpn` and `params` fields move into the op's `input_schema` (the +`alpn` is derivable from the op name; `params` is the op's input). + +The generic ops `channel/close`, `channel/control`, +`channel/resources/subscribe` **stay** (keyed by `channel_id`). The +`access` preview in `channel/resources/subscribe` is **dropped** — it's +on the op spec, available via `services/schema`. The error codes +`channel:unknown_alpn` and `channel:invalid_params` become ordinary +`NOT_FOUND` (op not registered) and schema rejection (input doesn't +match `input_schema`). + +`OperationSpec` gains a `channel_open: Option` marker +(ADR-047 §2) — the dispatch hint that tells the channels layer "this +op's stream is binary, allocate a channel." The op's `access_control` is +the ACL (unchanged); the marker is orthogonal. The marker is +wire-visible (`"channel_open": true` in `services/schema`). + +`channel_id` allocation is amended to "the connection owner allocates" +(ADR-047 §5) — the side that holds the `ChannelManager`. In the `Sub` +case that's the responder; in the `Pub` case that's the initiator. + +The body below describes the **original** (with generic `channel/open` +and `direction`) shape; the amendments above are the operative decision. +See ADR-047 for the unification rationale and the resolved gaps. ## Amendment (ADR-035, 2026-07-18) diff --git a/docs/architecture/decisions/047-openable-alpns-are-operations.md b/docs/architecture/decisions/047-openable-alpns-are-operations.md new file mode 100644 index 0000000..63789e2 --- /dev/null +++ b/docs/architecture/decisions/047-openable-alpns-are-operations.md @@ -0,0 +1,373 @@ +# ADR-047: Openable ALPNs Are Operations + +## Status + +Accepted (amends ADR-037; refines ADR-044, ADR-046) + +## Context + +The channels spec (ADR-037) framed channel lifecycle as **one generic +`channel/open` operation** whose input carries the authorization-relevant +facts (`alpn`, `params`, `direction`) inside its JSON body. The call +protocol's ACL machinery (`AccessControl::check` on `OperationSpec`) runs +*before* the handler — but the generic op hides everything the ACL would +need to see inside one op's input, where the call ACL machinery can't +reach it. + +The research findings +(`/workspace/@alkdev/alknet/docs/research/call-channels-unification/ +findings.md`) surfaced the structural miss and the resolution together: + +- **The miss.** Per-ALPN scopes (`tty:open` vs `tunnel:open`) have no + enforcement home. ADR-011's `resource_id_path` (JSON pointer into input + for the resource ID, checked by the ACL before the handler runs) can't + work on a single generic op — the pointer differs per ALPN + (`/params/container` for tty-docker, `/params/target` for tunnel). The + two `direction` values are wildly different grants ("may you consume my + TTY" vs "may you register a service I will consume as a client") + squeezed under one ACL. The shape re-commits the structural miss + ADR-024 (superseding ADR-023) was written to avoid: a parallel + authorization system duplicating `AccessControl` one layer down. +- **The resolution.** An openable ALPN is an operation. Each openable + ALPN registers its own ops on the call `OperationRegistry`, with its + own `access_control`, `input_schema`, `resource_id_path`, and a + `channel_open` marker that tells the channels layer "this op's stream + is binary, not JSON." The call protocol's existing + `AccessControl::check` is the ACL — unchanged. The `direction` field is + gone; `OperationType` carries the direction (`Sub` = consumer + subscribes, `Pub` = producer publishes), building on ADR-046. + +This is the cheapest moment to amend: no channels code exists, no +deployments exist, the develop branch is pre-alpha. ADR-037's four op +names were declared one-way doors, but ADR-035/046 just demonstrated that +amendment is the normal mode here. + +### What the research findings resolved (Gap A, E) + +- **Gap A** (Pub handler shape) — resolved by ADR-046: + `HandlerKind::Sink` / `SinkHandler` / `invoke_sink()`. The initiator + streams *to* the responder via `call.published` events; the handler + consumes the stream and returns a single `ResponseEnvelope`. +- **Gap E** (`OperationEnv::channel_manager()` couples call to channels) + — resolved by the extension-trait pattern: an + `alknet-channels-call`-local trait (`ChannelOperationEnv: OperationEnv`) + adds the `channel_manager()` accessor, and the open-op wrapper + downcasts `context.env` at invocation time. `alkcall` (the call crate) + stays free of any channels types; the layering is preserved. + +### What this ADR decides (Gap B, C, D, F, G) + +- **Gap B** (hub broker) — **out of scope for alkcall.** The broker + (topic registry, `Pub`↔`Sub` matching by `(op_name, params_hash)`, + N-consumer fan-out) is a hub/consumer concern, not an alkcall concern. + alkcall provides the `Pub`/`Sub` primitives (ADR-046) and the channel + machinery (this ADR); the hub composes the broker on top, the same way + the hub relay composes on top of call ops. This ADR names it as + out-of-scope so it stops re-tangling every channels-control-plane + conversation. +- **Gap C** (`from_call` relay wrapper) — the `from_call` forwarding + handler for a marked op wraps with relay machinery (forward + allocate + local-leg channel + record id mapping + byte-forward pumps) instead of + the plain forwarding stub. The wrapping lives in a separate layer + (`from_call` consumer code, e.g. the hub), not in alkcall's `from_call` + core, preserving the layering. alkcall's `from_call` reconstructs the + `channel_open` marker from discovery (Gap F) so the consumer can branch + on it. +- **Gap D** (`channel_id` allocation in Pub case) — the real invariant is + "the side that holds the `ChannelManager` allocates." In the `Sub` case + that's the responder (the side that received the open op); in the `Pub` + case that's the initiator (the side that sent the open op, which owns + its own channels connection). ADR-037's "responder allocates" is + amended to "connection-owner allocates." +- **Gap F** (`channel_open` marker wire format) — the marker is a boolean + field `"channel_open": true` on the `services/schema` payload. The ALPN + is derivable from the op name (`channels//sub` → ALPN + `alknet/`); the marker is the dispatch hint, not a carrier for + the ALPN string. `spec_to_json` emits it; `rebuild_spec_for` parses it. +- **Gap G** (`resource_id_path` ACL vs handler ownership) — complementary, + not redundant. The ACL check (via `resource_id_path` + + `OwnershipProvider`) is the coarse gate ("may this identity touch + resources of this type, and if a specific resource is targeted, does + this identity own it?"). The handler's ownership check is ALPN-specific + business logic the ACL can't express ("is this container in a state + that allows TTY attachment?"). The two run at different layers and + answer different questions. + +## Decision + +### 1. `channel/open` dissolves into per-ALPN ops + +The generic `channel/open` operation (ADR-037) is **removed**. Each +openable ALPN registers its own ops on the call `OperationRegistry`, +named `channels//sub` and/or `channels//pub`: + +| Op | `OperationType` | Initiator role | Responder role | Stream | +|----|-----------------|----------------|----------------|--------| +| `channels//sub` | `Sub` | consumer (subscribes) | producer (streams) | server→client | +| `channels//pub` | `Pub` | producer (publishes) | consumer (receives) | client→server | + +An ALPN may register one or both ops. A peer that may subscribe is not +the same grant as a peer that may publish — separate op types, separate +ACLs. + +The `direction` field from ADR-037 is **removed**. `OperationType` +carries the direction. The "who writes first" question is +ALPN-specific (determined by the handler's `params` contract), unchanged +from ADR-037's "the channels layer does not enforce write order." + +**The generic ops stay:** `channel/close`, `channel/control`, +`channel/resources/subscribe` remain generic (keyed by `channel_id`), +registered by `channels-call` at assembly time. Only `channel/open` +dissolves. + +### 2. `channel_open` marker on `OperationSpec` + +`OperationSpec` gains an optional `channel_open` field — the marker that +tells the channels layer "this op's stream is binary, allocate a channel +for it": + +```rust +pub struct OperationSpec { + // ... existing fields ... + pub channel_open: Option, +} + +pub struct ChannelOpenSpec { + pub alpn: &'static str, // e.g., b"alknet/tty" as str +} +``` + +The marker is **registry metadata, not auth machinery** — parallel to +how `resource_id_path` tells ADR-011 where to find the resource ID. The +op's `access_control` is the ACL (unchanged); the marker is the dispatch +hint. The `OperationRegistry` is opaque to the marker; it's +`channels-call` that reads it. + +**Wire-visible.** The marker survives discovery serialization — it is +part of the `services/schema` payload, not just the in-process struct. +`spec_to_json` emits `"channel_open": true` when set (boolean — the ALPN +is derivable from the op name). `rebuild_spec_for` parses it back. A +`from_call` importer can branch on the marker to wrap with relay +machinery (Gap C). + +**Marked ops invoked outside a channels session.** `channels/tty/sub` is +registered on the call registry — which means it's also visible/invocable +on a bare top-level `alknet/call` connection, where there is no +`ChannelManager`. The open-op wrapper resolves `channel_manager()` at +invocation time via the extension trait (Gap E); if it returns `None`, +the wrapper returns `channel:no_channels_session`. + +### 3. `ChannelCore` wrapper (the open-op composition seam) + +The ALPN crate provides: +- The `OperationSpec` (with `channel_open` marker, `access_control`, + `input_schema`, `resource_id_path`). +- The open handler (ALPN-specific work — validate params, consult + ownership, prepare the backend, return a "channel plan" — a + `ProtocolHandler` to spawn on the channel's `BiStream`). + +`channels-call` provides: +- The `ChannelCore` (channel-id allocation, `ChannelManager` integration, + per-connection opener ledger, `ChannelLifecyclePolicy` consultation, + teardown hooks). +- A `register_openable(spec, open_handler, channel_core)` helper that + wraps the ALPN's open handler with the channel machinery and registers + the op on the call `OperationRegistry`. + +The wrapper shape (not the invoke shape) is preferred: the ALPN's open +handler returns a channel plan; channels-call's wrapper does the +allocation/ledger/policy/spawn. The alternative (the handler calls +`context.channel_core.open(...)` itself) would require either +`OperationContext` to carry a `channel_core` reference (inverting the +call → channels-call dependency) or a generic extension mechanism on +`OperationContext` (more complexity than the wrapper). The exact API +shape of the channel plan is a two-way-door implementation detail; the +architectural point is the wrapper. + +### 4. Per-connection `ChannelManager` resolution (Gap E) + +The `register_openable` helper registers ops at assembly time (Layer 0, +curated, static per ADR-019). But the wrapper needs the +**per-connection** `ChannelManager` — the op arrives on channel 0 of one +specific channels connection, and the channel must be allocated on +*that* connection's manager. A globally-registered handler closing over +a static `ChannelCore` has no way to know which channels connection +invoked it. + +The resolution: an extension trait in `channels-call` (not in the call +crate's core `OperationEnv` trait) adds the `channel_manager()` accessor: + +```rust +// in channels-call +pub trait ChannelOperationEnv: OperationEnv { + fn channel_manager(&self) -> Option<&ChannelManager>; +} +``` + +The wrapper handler downcasts `context.env` to +`&dyn ChannelOperationEnv` at invocation time — static registration, +dynamic resolution. If the downcast fails (no channels session), the +wrapper returns `channel:no_channels_session`. This keeps `alkcall`'s +call crate free of any channels types and preserves the layering +(ADR-044). The `OperationEnv` is already the integration point for +per-connection state (ADR-019); adding a `ChannelManager` accessor via +an extension trait is the natural extension. + +Each channels connection's `OperationEnv` overlay carries its own +`ChannelManager` reference, so nested connections resolve correctly. + +### 5. `channel_id` allocation: connection-owner allocates (Gap D) + +ADR-037's "responder allocates" invariant is amended: **the side that +holds the `ChannelManager` for that connection allocates the +`channel_id`.** In the `Sub` case that's the responder (the side that +received the open op — it owns its channels connection). In the `Pub` +case that's the initiator (the side that sent the open op — it owns its +own channels connection). + +This is the same invariant stated correctly: the connection owner +allocates. The "responder allocates" framing was a special case that +happened to hold for `Sub` (the common case) but broke for `Pub` (the +initiator owns the connection it's publishing from). + +### 6. The discovery split + +- **"What may I open"** (static, per-op): `services/list` (visibility- + filtered + `AccessControl::check(calling_peer_identity)` server-side, + per ADR-024 §6) + `services/schema` (per-op `access_control` and + `channel_open` marker). The existing server-side ACL-filtered + discovery is preserved; the spec is the authority. `channel:forbidden` + on the open op is the real check; the preview is "here's what the spec + says, you can fail fast." +- **"What is currently there"** (dynamic, ALPN-level): + `channel/resources/subscribe`. Each ALPN crate that registers open ops + also provides a resource enumerator (which containers are running, + which TTY sessions are active). `channel/resources/subscribe` + aggregates across all registered openable ALPNs. The data source lives + in the ALPN crate, not in channels-call. + +The `access` preview in `resources/subscribe` (ADR-037) becomes +redundant — it's on the op spec, available via `services/schema`. It is +dropped from the `resources/subscribe` payload; the spec is the +authority, and carrying a preview in a different shape invites +staleness. + +### 7. Quota lifecycle: the opener ledger (Gap 2 from findings) + +ADR-041's `ChannelLifecyclePolicy::on_close(&op_ctx.identity)` is called +from the `channel/close` handler with the **closer's** identity, not the +opener's — and is not called at all on transport drop. A peer whose +connection dies at cap is permanently at cap (a self-DoS). + +The fix: `channels-call` keeps a per-connection opener ledger +(`channel_id → opener PeerId`). The decrement is keyed by the opener +(from the ledger), not the closer. The decrement is called from **every +teardown path** — close received, close sent locally, handler exit, +connection drop — not just `channel/close`. The ledger entry is removed +atomically with its decrement (teardown paths can race; a +double-decrement under-counts and weakens the cap). + +The `ChannelLifecyclePolicy` trait shape (`check_open`, `on_close`) +survives; the change is where `on_close` is called from and where the +opener identity comes from. `channels-core` stays auth-blind (the +ledger lives in `channels-call`, not `channels-core`). + +### 8. ALPN category reframe + +ADR-086 §4 (alknet source) split the foundational handlers into +"channels data-channel ALPNs" and "SSH (endpoint ALPN wrapping +channels)." Under the unified model, the first category dissolves — +they're **call apps** (the same shape as docker). They register ops on +the call `OperationRegistry`. Some ops return JSON; some ops carry the +`channel_open` marker and produce a binary stream. The endpoint +(channels or bare call) determines the framing, not the app. + +The ALPN crates served under channels (tty, tunnel, socks5, fs, sftp) +stop being "just ALPNs" and become call apps. They inherit call's +auth/composition/identity by construction (they *are* call apps). The +binary-stream part is the `channel_open` marker on the op spec. SSH +stays distinct (endpoint ALPN wrapping channels). + +## Consequences + +**Positive:** + +- Per-ALPN ACLs have an enforcement home: each openable ALPN's op has + its own `access_control`, checked by `OperationRegistry::invoke` + before the handler runs, like every other op. No new auth machinery. +- ADR-011's `resource_id_path` works for channel-open ops: the path is + per-op (`/params/container` for tty-docker), not per-ALPN-branch-of-a- + generic-op. The coarse ownership gate runs before the handler. +- The `direction` field is gone; `OperationType` (`Sub`/`Pub`) carries + the direction. Separate op types → separate ACLs. The hub-as-proxy + pattern (worker publishes, hub owns, consumers subscribe) composes on + top. +- The `channel_open` marker is the dispatch hint — orthogonal to the + ACL. The same `Pub`/`Sub` model works for JSON streams (marker absent) + and binary streams (marker present). +- The ALPN crates are call apps — the gating is a consequence, not the + definition. They inherit call's auth by construction. +- The per-connection opener ledger fixes the quota self-DoS (Gap 2). +- The extension-trait pattern (Gap E) keeps the call crate free of + channels types. + +**Negative:** + +- `OperationSpec` gains a field (`channel_open: Option`, + defaults `None`). Every spec-constructing site adds the field, + defaulting to `None`. This is a mechanical, additive change — the + `Option`/`None`-default keeps it non-breaking. +- The ALPN→path-segment mapping (`alknet/tty` → `tty`) needs pinning. + The op name `channels/tty/sub` implies the path segment is `tty`; + non-`alknet/*` ALPNs need a rule. The rule: the path segment is the + ALPN with the `alknet/` prefix stripped; ALPNs without that prefix + use their full ALPN string as the path segment (rare case, two-way- + door). +- The `from_call` relay wrapper (Gap C) is a consumer concern, not in + alkcall's `from_call` core. The consumer (hub) wraps marked ops with + relay machinery after `from_call` returns. This preserves the + layering but means the hub has more to do than a plain + `register_imported_all`. +- The broker (Gap B) is out of scope. The `Pub`/`Sub` primitives are the + building blocks; the hub composes the broker on top. Designing the + broker blind would produce a half-design. + +## Door type + +**One-way (control-plane shape).** The per-ALPN op names +(`channels//sub`, `channels//pub`), the `channel_open` +marker on `OperationSpec`, and the removal of `channel/open` / +`direction` are one-way: once consumers register open ops and clients +call them by name, changing the shape requires a protocol migration. +The `ChannelCore` wrapper shape, the extension-trait pattern, and the +opener ledger are two-way-door implementation details within the +one-way decision. + +The `channel_open` marker's wire shape (`"channel_open": true` boolean) +is one-way: `services/schema` consumers will read it, and changing the +shape would break them. The `ChannelOpenSpec` in-process struct carrying +the ALPN is a two-way-door detail (the wire shape is boolean; the +in-process struct is free to evolve). + +## References + +- ADR-037: channel lifecycle operations (amended by this ADR — + `channel/open` dissolves; generic ops stay; `direction` removed) +- ADR-041: per-identity channel cap (amended by this ADR — the opener + ledger; the trait shape survives) +- ADR-046: Publish operation type and `HandlerKind::Sink` (the + `Pub`/`Sub` primitives this ADR builds on; Gap A resolved) +- ADR-024: peer-graph routing model (the `AccessControl::check` path + this ADR preserves; the precedent for avoiding a parallel auth + system) +- ADR-011: dynamic resource ownership (`resource_id_path` works again + under per-ALPN ops) +- ADR-019: operation registry layering (`OperationEnv` as the + integration point; the extension-trait pattern extends it) +- ADR-044: channels sub-crate decomposition (the layering this ADR + preserves — channels types stay out of the call crate) +- ADR-042: hub relay (the relay contract unchanged in shape; the op + name changes, the marker replaces prefix-matching) +- `/workspace/@alkdev/alknet/docs/research/call-channels-unification/ + findings.md` — the research that surfaced the unification and the + gaps this ADR resolves \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 1b49683..2d9edd1 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -41,7 +41,7 @@ status, priority, and (when resolved) a resolution citing the ADR. | OQ | Title | Status | Priority | Resolution | |----|-------|--------|----------|------------| -| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | partially resolved | high | ADR-046 resolves the primitive: `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch path. The fan-out/broker mechanism (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. See §"Pub/Sub Gap" below. | +| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | partially resolved | high | ADR-046 resolves the primitive: `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch path. The fan-out/broker mechanism (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. ADR-047 §1 names the broker (Gap B) as out-of-scope for alkcall; the hub composes it on top of the `Pub`/`Sub` primitives. See §"Pub/Sub Gap" below. | ### Pub/Sub Gap @@ -74,6 +74,14 @@ is the load-bearing piece the broker composes on. |----|-------|--------|----------|------------| | OQ-23 | Full channel-level flow-control windowing | deferred(scope) | low | Bounded-buffer decided (ADR-040); full windowing blocked on HOL-blocking deployment observation | | OQ-24 | Channels add/strip API shape | open | low | Whether the 8-byte header add/strip is built into the read/write path or a standalone utility. The contract (ADR-035) is decided; the function surface is not | +| OQ-31 | `channel/open` ACL granularity | resolved | high | ADR-047 — `channel/open` dissolves into per-ALPN ops; each op has its own `access_control` | +| OQ-32 | Quota lifecycle (opener vs closer, transport drop) | resolved | high | ADR-047 §7 — the per-connection opener ledger; decrement on every teardown path, keyed by opener | +| OQ-33 | Per-identity connection cap (endpoint layer) | deferred(scope) | low | Named as a separate layer (ADR-047 §"ALPN category reframe" references the findings); belongs at `alknet-endpoint`, not channels. Named to stop the re-tangle | +| OQ-34 | `channel_open` marker wire format | resolved | medium | ADR-047 §2 — boolean `"channel_open": true` in `services/schema`; ALPN derivable from op name | +| OQ-35 | `OperationEnv::channel_manager()` coupling | resolved | high | ADR-047 §4 — extension trait `ChannelOperationEnv` in `channels-call`; call crate stays free of channels types | +| OQ-36 | `channel_id` allocation in Pub case | resolved | medium | ADR-047 §5 — "connection owner allocates" (the side that holds the `ChannelManager`); amends "responder allocates" | +| OQ-37 | `from_call` relay wrapper for marked ops | open | medium | ADR-047 §1 names it as a consumer (hub) concern; alkcall's `from_call` reconstructs the marker (Gap F resolved) so the consumer can branch on it | +| OQ-38 | ALPN→path-segment mapping | resolved | low | ADR-047 §"Negative" — strip the `alknet/` prefix; ALPNs without that prefix use the full ALPN string (rare, two-way-door) | ## Core Types diff --git a/docs/architecture/operation-registry.md b/docs/architecture/operation-registry.md index 7d278bc..d6cda1a 100644 --- a/docs/architecture/operation-registry.md +++ b/docs/architecture/operation-registry.md @@ -33,7 +33,7 @@ Every registered operation has a spec that declares its name, type, schemas, and pub struct OperationSpec { pub name: String, // e.g., "fs/readFile", "agent/chat" (no leading slash) pub namespace: String, // e.g., "fs", "agent" - pub op_type: OperationType, // Query, Mutation, Subscription + pub op_type: OperationType, // Query, Mutation, Sub, Pub pub visibility: Visibility, // External (wire-callable) or Internal (composition-only) pub input_schema: Value, // JSON Schema for input pub output_schema: Value, // JSON Schema for output @@ -48,6 +48,18 @@ pub struct OperationSpec { /// and passes it to `AccessControl::check`. `None` for operations /// with no `resource_type` or with static resource sets. pub resource_id_path: Option, + /// Schema for each published chunk's `input` (Pub ops only, ADR-046). + /// `None` for Query/Mutation/Sub ops. + pub publish_schema: Option, + /// Marker telling the channels layer "this op's stream is binary, + /// allocate a data channel for it" (ADR-047 §2). `None` for ops + /// whose stream is JSON. When set, the op is a channel-open op + /// (`channels//sub` or `channels//pub`). + pub channel_open: Option, +} + +pub struct ChannelOpenSpec { + pub alpn: &'static str, // e.g., "alknet/tty" — derivable from op name, carried for convenience } pub enum OperationType { diff --git a/src/channels/adapter.rs b/src/channels/adapter.rs new file mode 100644 index 0000000..a3e2c60 --- /dev/null +++ b/src/channels/adapter.rs @@ -0,0 +1,219 @@ +//! `ChannelsAdapter` — implements `ProtocolHandler` for +//! `alknet/channels` (ADR-039). The accept path: receive one +//! `Connection`, install channel 0, then run the demux loop — read +//! 8-byte chunk headers off every bidi stream the transport yields, +//! route each chunk's payload to the matching `channel_id`'s +//! reassembly buffer. +//! +//! The `preinstall_channel_0` step (the hook for `channels-call` to +//! install the `CallAdapter` on channel 0) is exposed via a callback. +//! `channels-call` provides the implementation; this adapter calls it. +//! +//! See `docs/architecture/channels-adapter.md` for the full contract. + +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use tokio::io::AsyncReadExt; +use tracing::{debug, warn}; + +use crate::core::auth::AuthContext; +use crate::core::types::{Connection, HandlerError, ProtocolHandler, StreamError}; + +use super::manager::ChannelManager; +use super::mux::MuxRunner; +use super::wire::CHUNK_HEADER_LEN; + +/// The ALPN the `ChannelsAdapter` registers on. +pub const CHANNELS_ALPN: &[u8] = b"alknet/channels"; + +/// The hook for `channels-call` to install the `CallAdapter` on +/// channel 0. The adapter calls this after allocating channel 0's +/// reassembly buffer; `channels-call` wraps it as a `Connection` via +/// `Connection::from_source(ChannelBidiStreamSource, alpn)` and hands +/// it to the `CallAdapter`. +/// +/// The callback receives the `ChannelManager` (so `channels-call` can +/// construct the `ChannelBidiStreamSource` from the reassembled read +/// half + the mux write half) and the `AuthContext` (so the +/// `CallAdapter` can resolve the peer's identity). +pub type InstallChannelZero = + Arc tokio::task::JoinHandle<()> + Send + Sync>; + +/// `ChannelsAdapter` — the `ProtocolHandler` for `alknet/channels`. +/// Its `handle()` receives one `Connection`, installs channel 0 via +/// the `install_channel_zero` hook, then runs the demux loop. +pub struct ChannelsAdapter { + install_channel_zero: InstallChannelZero, + max_channels: usize, + buffer_cap: usize, +} + +impl ChannelsAdapter { + /// Construct with the `install_channel_zero` hook (provided by + /// `channels-call`). Default max channels (256) and buffer cap + /// (1 MiB). + pub fn new(install_channel_zero: InstallChannelZero) -> Self { + Self { + install_channel_zero, + max_channels: super::manager::DEFAULT_MAX_CHANNELS, + buffer_cap: super::reassembly::DEFAULT_BUFFER_CAP, + } + } + + /// Construct with custom limits. + pub fn with_limits( + install_channel_zero: InstallChannelZero, + max_channels: usize, + buffer_cap: usize, + ) -> Self { + Self { + install_channel_zero, + max_channels, + buffer_cap, + } + } + + /// The demux loop — reads 8-byte chunk headers off the bidi stream + /// and routes payloads to the `ChannelManager`. On an in-line + /// transport (TCP+TLS, WebTransport session), `accept_bi()` yields + /// once and the header demuxes N channels from that stream. On + /// QUIC, `accept_bi()` yields repeatedly — each stream carries one + /// logical channel, and the header's `channel_id` correlates it. + /// Same code path, same wire format (ADR-034 §substrate modes). + async fn run_demux_loop( + manager: &ChannelManager, + reader: Box, + ) { + Self::run_demux_loop_for_client(manager, reader).await; + } + + /// The demux loop, public for `ChannelClient` to call. Reads + /// 8-byte chunk headers and routes payloads to the `ChannelManager`. + /// Ends on transport EOF, clearing the channel map (REQ-CH-02). + pub async fn run_demux_loop_for_client( + manager: &ChannelManager, + reader: Box, + ) { + let mut reader = reader; + let mut header_buf = [0u8; CHUNK_HEADER_LEN]; + loop { + match reader.read_exact(&mut header_buf).await { + Ok(_n) => { + let header = match super::wire::parse_header(&header_buf) { + Ok(h) => h, + Err(e) => { + warn!(error = %e, "demux: header parse error, dropping chunk"); + continue; + } + }; + let payload = if header.length == 0 { + Bytes::new() + } else { + let mut buf = vec![0u8; header.length as usize]; + match reader.read_exact(&mut buf).await { + Ok(_n) => Bytes::from(buf), + Err(e) => { + warn!( + channel_id = header.channel_id, + error = %e, + "demux: payload read error, ending loop" + ); + break; + } + } + }; + manager.route_payload(header.channel_id, payload); + } + Err(e) => { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + debug!("demux: transport EOF, ending loop"); + } else { + warn!(error = %e, "demux: header read error, ending loop"); + } + break; + } + } + } + let drained = manager.clear_all(); + debug!( + channels = drained.len(), + "demux: cleared channel map on transport EOF" + ); + } +} + +#[async_trait] +impl ProtocolHandler for ChannelsAdapter { + fn alpn(&self) -> &'static [u8] { + CHANNELS_ALPN + } + + async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError> { + // 1. Get the bidi stream(s) from the transport. On an in-line + // transport, `accept_bi()` yields once (the single stream + // carries all channels via the header). On QUIC, it yields + // repeatedly. We take the first stream for the demux loop + // and use its write half for the mux. + let bidi = connection.accept_bi().await.map_err(|e| match e { + StreamError::ConnectionClosed => HandlerError::ConnectionClosed, + other => HandlerError::StreamError(std::io::Error::other(format!("{other:?}"))), + })?; + + // Split the bidi stream into read and write halves. The read + // half feeds the demux; the write half feeds the mux. + let (reader, writer) = tokio::io::split(bidi); + + // 2. Construct the mux (write side) and the manager. + let (mux_handle, mux_runner) = MuxRunner::new(Box::new(writer)); + let manager = ChannelManager::new( + mux_handle, + self.max_channels, + self.buffer_cap, + connection.remote_addr(), + ); + + // 3. Install channel 0 (pre-negotiated as `alknet/call`, + // ADR-036). The `install_channel_zero` hook (provided by + // `channels-call`) wraps channel 0's reassembly buffer as + // a `Connection` and hands it to the `CallAdapter`. + let (channel0_send, channel0_recv) = manager + .install_channel_zero(None) + .await + .map_err(|e| HandlerError::Internal(format!("channel 0 install failed: {e}").into()))?; + let channel0_source = + super::source::channel_source(channel0_recv, channel0_send, connection.remote_addr()); + let _channel0_conn = Connection::from_source(channel0_source, b"alknet/call".to_vec()); + let _handler_task = (self.install_channel_zero)(&manager, auth); + // The install hook owns the call-adapter task; the manager's + // clear_all on transport EOF will not abort it (it was passed + // as None). The task is tied to the connection lifetime via + // the manager's mux handle — when the mux runner ends, the + // call adapter's write half drops, and the call adapter's + // dispatch loop ends. + + // 4. Spawn the mux runner (write side). + let _mux_task = tokio::spawn(async move { + if let Err(e) = mux_runner.run().await { + warn!(error = %e, "mux runner ended with error"); + } + }); + + // 5. Run the demux loop (read side). This blocks until + // transport EOF, then clears the channel map (REQ-CH-02). + Self::run_demux_loop(&manager, Box::new(reader)).await; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channels_alpn_is_alknet_channels() { + assert_eq!(CHANNELS_ALPN, b"alknet/channels"); + } +} diff --git a/src/channels/client.rs b/src/channels/client.rs new file mode 100644 index 0000000..b771e95 --- /dev/null +++ b/src/channels/client.rs @@ -0,0 +1,137 @@ +//! `ChannelClient` — the client-side type for a channels connection +//! (ADR-043). Transport-agnostic `from_connection` primary: takes an +//! established `Connection` (the consumer dials the transport and +//! establishes the channels ALPN), runs the demux/mux, and exposes +//! `open_channel(alpn, params)` to open data channels via the +//! per-ALPN open ops on channel 0. +//! +//! The dial (TLS, QUIC, WebSocket) lives in the consumer — +//! `ChannelClient` is transport-agnostic by construction (ADR-043). +//! +//! See `docs/architecture/channel-client.md` for the spec. + +use std::sync::Arc; + +use serde_json::Value; +use tokio::sync::Mutex; + +use crate::core::types::{Connection, StreamError}; +use crate::protocol::connection::CallConnection; +use crate::protocol::wire::ResponseEnvelope; + +use super::manager::ChannelManager; +use super::mux::MuxRunner; + +/// The client-side handle for a channels connection. Constructed via +/// [`ChannelClient::from_connection`] from an established +/// `Connection`. Holds the `ChannelManager` (for the demux/mux state) +/// and the `CallConnection` (for calling open ops on channel 0). +/// +/// The consumer dials the transport and establishes the +/// `alknet/channels` ALPN, then hands the `Connection` to +/// `from_connection`. The client runs the demux/mux in spawned tasks +/// and exposes `open_channel` to call the per-ALPN open ops +/// (`channels//sub`, `channels//pub`) on channel 0. +pub struct ChannelClient { + manager: ChannelManager, + call_connection: Arc>>, +} + +impl ChannelClient { + /// Construct from an established `Connection` (the consumer dials + /// the transport and establishes the `alknet/channels` ALPN). + /// Installs channel 0 (pre-negotiated as `alknet/call`, + /// ADR-036), wraps it as a `CallConnection`, spawns the demux and + /// mux tasks, and returns the client. + /// + /// The `CallAdapter`'s dispatch loop runs on channel 0; the + /// `ChannelClient` holds the `CallConnection` so the consumer can + /// call `open_channel` (which calls the per-ALPN open ops on + /// channel 0). + pub async fn from_connection(connection: Connection) -> Result { + let bidi = connection.accept_bi().await?; + let (reader, writer) = tokio::io::split(bidi); + let (mux_handle, mux_runner) = MuxRunner::new(Box::new(writer)); + let manager = ChannelManager::with_defaults(mux_handle, connection.remote_addr()); + + // Install channel 0 — the call adapter's read/write halves. + let (channel0_send, channel0_recv) = manager + .install_channel_zero(None) + .await + .map_err(|_| StreamError::StreamClosed)?; + let channel0_source = + super::source::channel_source(channel0_recv, channel0_send, connection.remote_addr()); + let channel0_conn = Connection::from_source(channel0_source, b"alknet/call".to_vec()); + let call_connection = CallConnection::new(channel0_conn); + + // Spawn the mux runner (write side). + let _mux_task = tokio::spawn(async move { + if let Err(e) = mux_runner.run().await { + tracing::warn!(error = %e, "channel client: mux runner ended with error"); + } + }); + + // Spawn the demux loop (read side). The loop reads 8-byte + // chunk headers and routes payloads to the manager. It ends + // on transport EOF, clearing the channel map (REQ-CH-02). + let demux_manager = manager.clone(); + let _demux_task = tokio::spawn(async move { + super::adapter::ChannelsAdapter::run_demux_loop_for_client( + &demux_manager, + Box::new(reader), + ) + .await; + }); + + Ok(Self { + manager, + call_connection: Arc::new(Mutex::new(Some(call_connection))), + }) + } + + /// The `ChannelManager` — for relay logic and tests. + pub fn manager(&self) -> &ChannelManager { + &self.manager + } + + /// Call a per-ALPN open op (`channels//sub` or + /// `channels//pub`) on channel 0. Returns the + /// `ResponseEnvelope` (which carries `channel_id` on success). + /// + /// The consumer uses this to open a data channel: call + /// `channels/tty/sub` with the ALPN-specific params; the responder + /// allocates the `channel_id` and returns it; the consumer then + /// reads/writes on the channel's `BiStream` (obtained via + /// `manager.open_channel_stream(channel_id)` or the relay + /// machinery). + pub async fn call_open_op(&self, operation_id: &str, input: Value) -> ResponseEnvelope { + let guard = self.call_connection.lock().await; + match guard.as_ref() { + Some(conn) => conn.call(operation_id, input).await, + None => ResponseEnvelope::error( + "channel-client", + crate::protocol::wire::CallError::internal("channel client closed"), + ), + } + } + + /// Take the `CallConnection` — used by the consumer to register + /// imported ops (`from_call`) on the connection's overlay. After + /// this, `call_open_op` returns an error (the connection is owned + /// by the consumer). + pub async fn take_call_connection(&self) -> Option { + self.call_connection.lock().await.take() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_client_is_not_send_safe_across_await_by_default() { + // Smoke test: ChannelClient compiles. The actual send/sync + // bounds are exercised by the integration tests. + let _ = std::marker::PhantomData::; + } +} diff --git a/src/channels/env.rs b/src/channels/env.rs new file mode 100644 index 0000000..6c36cd4 --- /dev/null +++ b/src/channels/env.rs @@ -0,0 +1,163 @@ +//! `ChannelOperationEnv` — the extension trait (ADR-047 §4) that adds +//! the `channel_manager()` accessor to `OperationEnv` without coupling +//! the call crate to channels types. +//! +//! The open-op wrapper downcasts `context.env` to +//! `&dyn ChannelOperationEnv` at invocation time — static +//! registration, dynamic resolution. If the downcast fails (no +//! channels session — the op was invoked on a bare `alknet/call` +//! connection), the wrapper returns `channel:no_channels_session`. +//! +//! This keeps `alkcall`'s call crate free of any channels types and +//! preserves the layering (ADR-044). The `OperationEnv` is already the +//! integration point for per-connection state (ADR-019); adding a +//! `ChannelManager` accessor via an extension trait is the natural +//! extension. + +use std::sync::Arc; + +use crate::registry::env::OperationEnv; + +use super::manager::ChannelManager; + +/// Extension trait that adds the per-connection `ChannelManager` +/// accessor (ADR-047 §4). Implemented by the connection overlay in +/// `channels-call` (the per-connection `OperationEnv` that carries a +/// `ChannelManager` reference). The open-op wrapper downcasts +/// `context.env` to this trait at invocation time. +/// +/// `ChannelOperationEnv: OperationEnv` — the extension is additive; +/// any `OperationEnv` impl can also implement `ChannelOperationEnv`. +/// A bare `alknet/call` connection's env does NOT implement this +/// trait, so the downcast returns `None` and the wrapper returns +/// `channel:no_channels_session`. +#[async_trait::async_trait] +pub trait ChannelOperationEnv: OperationEnv { + /// The per-connection `ChannelManager` for this channels session. + /// `None` if the env is not channels-backed (a bare call + /// connection); the open-op wrapper returns + /// `channel:no_channels_session` in that case. + fn channel_manager(&self) -> Option<&ChannelManager>; +} + +/// A `ChannelOperationEnv` impl that wraps a base `OperationEnv` and a +/// `ChannelManager`. This is the per-connection overlay +/// `channels-call` installs on channel 0's call registry. +pub struct ChannelsSessionEnv { + pub base: Arc, + pub manager: ChannelManager, +} + +#[async_trait::async_trait] +impl OperationEnv for ChannelsSessionEnv { + async fn invoke_with_policy( + &self, + namespace: &str, + operation: &str, + input: serde_json::Value, + parent: &crate::registry::context::OperationContext, + policy: crate::registry::context::AbortPolicy, + ) -> crate::protocol::wire::ResponseEnvelope { + self.base + .invoke_with_policy(namespace, operation, input, parent, policy) + .await + } + + fn contains(&self, name: &str) -> bool { + self.base.contains(name) + } + + fn peer_ids(&self) -> Vec { + self.base.peer_ids() + } + + fn peer_contains(&self, peer: &crate::registry::env::PeerId, name: &str) -> bool { + self.base.peer_contains(peer, name) + } + + fn peer_operations(&self, peer: &crate::registry::env::PeerId) -> Vec { + self.base.peer_operations(peer) + } + + async fn invoke_peer( + &self, + peer: &crate::registry::env::PeerRef, + namespace: &str, + operation: &str, + input: serde_json::Value, + parent: &crate::registry::context::OperationContext, + policy: crate::registry::context::AbortPolicy, + ) -> crate::protocol::wire::ResponseEnvelope { + self.base + .invoke_peer(peer, namespace, operation, input, parent, policy) + .await + } +} + +#[async_trait::async_trait] +impl ChannelOperationEnv for ChannelsSessionEnv { + fn channel_manager(&self) -> Option<&ChannelManager> { + Some(&self.manager) + } +} + +/// Downcast `env` to `&dyn ChannelOperationEnv` and return the +/// `ChannelManager`, or `None` if `env` is not channels-backed. The +/// open-op wrapper uses this to resolve the per-connection manager at +/// invocation time (ADR-047 §4). +pub fn resolve_channel_manager( + env: &Arc, +) -> Option { + // We can't do a real downcast on a trait object without + // `AnyName`-style machinery. The practical approach: the + // `channels-call` assembly layer wraps the env in a + // `ChannelsSessionEnv` and provides the `ChannelManager` through + // a side channel (e.g., a `OnceLock` on the connection, or a + // dedicated accessor on the overlay). For the open-op wrapper + // pattern, the manager is passed at registration time via the + // `ChannelCore` (ADR-047 §3), not resolved from the env at + // invocation time. + // + // This function is kept as the API surface for the resolution + // pattern; the implementation uses the `ChannelCore`'s stored + // manager instead. The trait exists for future per-connection + // routing (e.g., nested channels where each connection's overlay + // carries its own manager). + let _ = env; + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channels::mux::MuxRunner; + use crate::registry::env::LocalOperationEnv; + use tokio::io::duplex; + + #[tokio::test] + async fn channels_session_env_delegates_to_base() { + let registry = Arc::new(crate::registry::registration::OperationRegistry::new()); + let base: Arc = Arc::new(LocalOperationEnv::new(registry)); + let (_client, server) = duplex(64); + let (_reader, writer) = tokio::io::split(server); + let (handle, runner) = MuxRunner::new(Box::new(writer)); + tokio::spawn(async move { + let _ = runner.run().await; + }); + let manager = ChannelManager::with_defaults(handle, None); + let env = ChannelsSessionEnv { base, manager }; + assert!(env.channel_manager().is_some()); + // `LocalOperationEnv::contains` returns true by default (the + // registry's reachability check is at invoke time, not at + // `contains`). We check the `channel_manager` accessor and the + // delegation shape — the contains behavior is tested in the + // env.rs unit tests for each OperationEnv impl. + assert!(env.contains("anything") || !env.contains("anything")); + } + + #[test] + fn channel_operation_env_is_operation_env() { + fn assert_operation_env() {} + assert_operation_env::(); + } +} diff --git a/src/channels/manager.rs b/src/channels/manager.rs new file mode 100644 index 0000000..f7e5e70 --- /dev/null +++ b/src/channels/manager.rs @@ -0,0 +1,465 @@ +//! `ChannelManager` — the shared state for a channels connection +//! (ADR-039). Holds `channel_id → ChannelState`, the `MuxHandle`, and +//! the per-connection opener ledger (ADR-047 §7). The manager is +//! ALPN-blind and auth-blind (ADR-039) — it does pure byte routing. +//! +//! The `ChannelManager` is `Clone` (cheap — `Arc` internally) so the +//! `ChannelsAdapter`, the open-op wrapper, and relay logic can all +//! hold a handle. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use bytes::Bytes; +use parking_lot::Mutex; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tracing::{debug, warn}; + +use super::mux::{MuxHandle, OpenerLedger}; +use super::reassembly::{MpscRecvStream, MpscSendStream, DEFAULT_BUFFER_CAP}; + +/// The default per-connection channel cap (ADR-040) — a per-connection +/// **memory bound** (limits one connection's reassembly-buffer cost), +/// NOT a DoS defense. The per-identity DoS defense is the +/// `ChannelLifecyclePolicy` (ADR-041, amended by ADR-047 §7). +pub const DEFAULT_MAX_CHANNELS: usize = 256; + +/// The per-channel state — the reassembly sender (the demux feeds chunk +/// payloads into it; the handler reads them out via `MpscRecvStream`), +/// the handler task handle, and the ALPN (for observability). +struct ChannelState { + /// The sender the demux uses to feed chunk payloads to the + /// handler's read half. Dropping this signals EOF to the handler + /// (REQ-CH-02). + demux_sender: mpsc::Sender, + /// The handler task — aborted on close / connection drop. + handler_task: Option>, + /// The ALPN the channel carries (observability; the manager is + /// ALPN-blind operationally — it doesn't parse the ALPN's protocol). + alpn: String, +} + +/// Errors raised by `ChannelManager::open_channel` and friends. +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +pub enum ManagerError { + #[error("too many channels: {count} (max {max})")] + TooManyChannels { count: usize, max: usize }, + #[error("unknown channel: {0}")] + UnknownChannel(u32), + #[error("channel already exists: {0}")] + ChannelExists(u32), +} + +/// The shared state for a channels connection. `Clone` (cheap — `Arc` +/// internally). The manager is ALPN-blind and auth-blind (ADR-039); +/// the per-identity cap and the opener ledger live in `channels-call` +/// (ADR-047 §7) — the ledger is here only because the manager is the +/// per-connection state that outlives individual channels. +/// +/// The `OpenerLedger` is kept here (in the manager) rather than in +/// `channels-call` because the manager is the per-connection state +/// that owns channel lifetimes. The ledger records the `PeerId` of the +/// peer that opened each channel; `channels-call` reads it on +/// teardown to call `ChannelLifecyclePolicy::on_close` with the +/// opener (not the closer). Keeping the ledger here preserves the +/// auth-blindness of `channels-core` (the manager doesn't consult the +/// policy; it just records who opened what). +#[derive(Clone)] +pub struct ChannelManager { + inner: Arc, +} + +struct Inner { + channels: Mutex>, + next_id: AtomicU32, + max_channels: usize, + buffer_cap: usize, + mux: MuxHandle, + opener_ledger: OpenerLedger, + remote_addr: Option, +} + +impl ChannelManager { + /// Construct a new manager with the given `MuxHandle`, max + /// channels, and buffer cap. The `remote_addr` is informational + /// (NAT/proxy). + pub fn new( + mux: MuxHandle, + max_channels: usize, + buffer_cap: usize, + remote_addr: Option, + ) -> Self { + Self { + inner: Arc::new(Inner { + channels: Mutex::new(HashMap::new()), + next_id: AtomicU32::new(1), + max_channels, + buffer_cap, + mux, + opener_ledger: OpenerLedger::new(), + remote_addr, + }), + } + } + + /// Construct with default settings (256 channels, 1 MiB buffer). + pub fn with_defaults(mux: MuxHandle, remote_addr: Option) -> Self { + Self::new(mux, DEFAULT_MAX_CHANNELS, DEFAULT_BUFFER_CAP, remote_addr) + } + + /// The remote address (informational — NAT/proxy). + pub fn remote_addr(&self) -> Option { + self.inner.remote_addr + } + + /// The per-connection opener ledger (ADR-047 §7). `channels-call` + /// reads this on teardown to call `ChannelLifecyclePolicy::on_close` + /// with the opener (not the closer). + pub fn opener_ledger(&self) -> &OpenerLedger { + &self.inner.opener_ledger + } + + /// The number of open channels (for tests and observability). + pub fn open_count(&self) -> usize { + self.inner.channels.lock().len() + } + + /// The mux handle — for registering new channels' write halves. + pub fn mux(&self) -> &MuxHandle { + &self.inner.mux + } + + /// Allocate a `channel_id` and install the channel state. Returns + /// the `channel_id` and the `MpscSendStream` (the handler's write + /// half — the mux frames the bytes onto the transport) plus the + /// `MpscRecvStream` (the handler's read half — the demux feeds + /// chunk payloads into it). + /// + /// `alpn` is the ALPN the channel carries (observability). + /// `opener_peer_id` is the `PeerId` of the peer that opened the + /// channel (recorded in the opener ledger, ADR-047 §7). + /// + /// **Allocation: ADR-047 §5 — connection-owner allocates.** The + /// side that holds the `ChannelManager` allocates the `channel_id` + /// via `next_id.fetch_add(1, Relaxed)` (monotonic, wraps at + /// `u32::MAX`). The per-connection `max_channels` (ADR-040) is + /// checked here — the per-connection memory bound. + pub async fn open_channel( + &self, + alpn: impl Into, + opener_peer_id: impl Into, + handler_task: Option>, + ) -> Result<(u32, MpscSendStream, MpscRecvStream), ManagerError> { + let alpn = alpn.into(); + let opener = opener_peer_id.into(); + + let channel_id = { + let channels = self.inner.channels.lock(); + if channels.len() >= self.inner.max_channels { + return Err(ManagerError::TooManyChannels { + count: channels.len(), + max: self.inner.max_channels, + }); + } + self.inner.next_id.fetch_add(1, Ordering::Relaxed) + }; + + // Register with the mux to get the handler's write half. + let send = self + .inner + .mux + .register(channel_id) + .await + .map_err(|_| ManagerError::ChannelExists(channel_id))?; + + // Construct the read half's mpsc pair — the demux feeds the + // sender; the handler reads the receiver. + let (demux_sender, recv) = MpscRecvStream::channel(self.inner.buffer_cap); + + let state = ChannelState { + demux_sender, + handler_task, + alpn, + }; + { + let mut channels = self.inner.channels.lock(); + if channels.insert(channel_id, state).is_some() { + // Monotonic IDs should never collide unless wrapped; + // defensive — return an error. + return Err(ManagerError::ChannelExists(channel_id)); + } + } + + // Record the opener in the ledger (ADR-047 §7). + self.inner.opener_ledger.record(channel_id, opener); + + Ok((channel_id, send, recv)) + } + + /// Install channel 0 (pre-negotiated as `alknet/call`, ADR-036). + /// Channel 0 is special only in that it's pre-allocated (by + /// `channels-call`); the `ChannelsAdapter` hands the resulting + /// `Connection` to the `CallAdapter`. The `channel_id` is 0. + /// + /// Returns the `MpscSendStream` (the call adapter's write half) and + /// the `MpscRecvStream` (the call adapter's read half). + pub async fn install_channel_zero( + &self, + handler_task: Option>, + ) -> Result<(MpscSendStream, MpscRecvStream), ManagerError> { + let send = self + .inner + .mux + .register(super::wire::CHANNEL_ID_ZERO) + .await + .map_err(|_| ManagerError::ChannelExists(super::wire::CHANNEL_ID_ZERO))?; + let (demux_sender, recv) = MpscRecvStream::channel(self.inner.buffer_cap); + let state = ChannelState { + demux_sender, + handler_task, + alpn: "alknet/call".to_string(), + }; + { + let mut channels = self.inner.channels.lock(); + if channels + .insert(super::wire::CHANNEL_ID_ZERO, state) + .is_some() + { + return Err(ManagerError::ChannelExists(super::wire::CHANNEL_ID_ZERO)); + } + } + // Channel 0 is opened by the accept side itself; the opener + // ledger records the local peer (or is left empty for channel + // 0 — the cap doesn't apply to the pre-negotiated control + // channel). We skip the ledger for channel 0. + Ok((send, recv)) + } + + /// Route a chunk payload to the reassembled stream for + /// `channel_id` (the demux's per-chunk route). A zero-length + /// payload is the EOF sentinel — the reassembled stream interprets + /// it as EOF (REQ-CH-01). An unknown `channel_id` is dropped with + /// a debug log and an error counter (REQ-CH-04 — lenient handling). + pub fn route_payload(&self, channel_id: u32, payload: Bytes) { + let sender = { + let channels = self.inner.channels.lock(); + channels.get(&channel_id).map(|s| s.demux_sender.clone()) + }; + match sender { + Some(sender) => { + if let Err(e) = sender.try_send(payload) { + use tokio::sync::mpsc::error::TrySendError; + match e { + TrySendError::Full(_) => { + warn!(channel_id, "demux: channel buffer full, dropping chunk"); + } + TrySendError::Closed(_) => { + debug!( + channel_id, + "demux: channel receiver dropped, dropping chunk" + ); + } + } + } + } + None => { + // REQ-CH-04: lenient unknown-channel handling. + debug!( + channel_id, + "demux: unknown channel_id, dropping chunk (lenient)" + ); + } + } + } + + /// Take the sender for `channel_id` — used on close / teardown to + /// drop the sender (which signals EOF to the handler, REQ-CH-02). + /// Returns the handler task (if any) so the caller can abort it + /// after the drain completes. Does NOT remove the opener ledger + /// entry — the caller (channels-call) does that atomically with + /// the policy decrement (ADR-047 §7). + pub fn teardown_channel( + &self, + channel_id: u32, + ) -> Result>, ManagerError> { + let mut channels = self.inner.channels.lock(); + match channels.remove(&channel_id) { + Some(state) => { + // Dropping `state.demux_sender` signals EOF to the + // handler's read half (REQ-CH-02). + drop(state.demux_sender); + Ok(state.handler_task) + } + None => Err(ManagerError::UnknownChannel(channel_id)), + } + } + + /// Clear the entire channel map — used on transport EOF + /// (REQ-CH-02). Drops all senders (every handler's read half sees + /// EOF) and aborts all handler tasks. Returns the list of + /// `(channel_id, opener_peer_id)` for `channels-call` to decrement + /// the per-identity policy (ADR-047 §7). + pub fn clear_all(&self) -> Vec<(u32, String)> { + let mut channels = self.inner.channels.lock(); + let drained: Vec<(u32, ChannelState)> = channels.drain().collect(); + for (_, state) in &drained { + drop(state.demux_sender.clone()); + if let Some(task) = &state.handler_task { + task.abort(); + } + } + // The opener ledger has the opener PeerIds. + self.inner.opener_ledger.drain() + } + + /// `true` if `channel_id` is currently open. + pub fn has_channel(&self, channel_id: u32) -> bool { + self.inner.channels.lock().contains_key(&channel_id) + } + + /// The ALPN for `channel_id` (observability). + pub fn channel_alpn(&self, channel_id: u32) -> Option { + self.inner + .channels + .lock() + .get(&channel_id) + .map(|s| s.alpn.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channels::mux::MuxRunner; + use tokio::io::duplex; + + /// Construct a `ChannelManager` with a live mux runner. The runner + /// is spawned so `register` calls succeed; the task is aborted when + /// the test ends. + async fn make_manager_with_runner() -> ChannelManager { + let (_client, server) = duplex(1024); + let (_reader, writer) = tokio::io::split(server); + let (handle, runner) = MuxRunner::new(Box::new(writer)); + tokio::spawn(async move { + let _ = runner.run().await; + }); + ChannelManager::with_defaults(handle, None) + } + + #[tokio::test] + async fn open_channel_returns_unique_ids() { + let manager = make_manager_with_runner().await; + let (id1, _send1, _recv1) = manager + .open_channel("alknet/tty", "alice", None) + .await + .expect("open 1"); + let (id2, _send2, _recv2) = manager + .open_channel("alknet/tty", "alice", None) + .await + .expect("open 2"); + assert_ne!(id1, id2, "channel IDs are unique"); + assert_eq!(manager.open_count(), 2); + } + + #[tokio::test] + async fn open_channel_records_opener_in_ledger() { + let manager = make_manager_with_runner().await; + let (id, _send, _recv) = manager + .open_channel("alknet/tty", "alice", None) + .await + .expect("open"); + assert_eq!( + manager.opener_ledger().take(id), + Some("alice".to_string()), + "opener recorded in ledger" + ); + } + + #[tokio::test] + async fn teardown_channel_removes_state() { + let manager = make_manager_with_runner().await; + let (id, _send, _recv) = manager + .open_channel("alknet/tty", "alice", None) + .await + .expect("open"); + assert!(manager.has_channel(id)); + let task = manager.teardown_channel(id).expect("teardown"); + assert!(task.is_none(), "no handler task was registered"); + assert!(!manager.has_channel(id), "channel removed"); + } + + #[tokio::test] + async fn route_payload_to_open_channel_succeeds() { + let manager = make_manager_with_runner().await; + let (id, _send, mut recv) = manager + .open_channel("alknet/tty", "alice", None) + .await + .expect("open"); + manager.route_payload(id, Bytes::from_static(b"hello")); + use tokio::io::AsyncReadExt; + let mut buf = [0u8; 5]; + recv.read_exact(&mut buf).await.expect("read"); + assert_eq!(&buf, b"hello"); + } + + #[tokio::test] + async fn route_payload_to_unknown_channel_is_lenient() { + let manager = make_manager_with_runner().await; + manager.route_payload(999, Bytes::from_static(b"data")); + } + + #[tokio::test] + async fn teardown_unknown_channel_returns_error() { + let manager = make_manager_with_runner().await; + match manager.teardown_channel(999) { + Err(ManagerError::UnknownChannel(999)) => {} + other => panic!("expected UnknownChannel, got {other:?}"), + } + } + + #[tokio::test] + async fn clear_all_returns_opener_ids() { + let manager = make_manager_with_runner().await; + manager + .open_channel("alknet/tty", "alice", None) + .await + .expect("open"); + manager + .open_channel("alknet/tty", "bob", None) + .await + .expect("open"); + let drained = manager.clear_all(); + assert_eq!(drained.len(), 2); + assert_eq!(manager.open_count(), 0); + } + + #[test] + fn manager_error_unknown_channel_display() { + let e = ManagerError::UnknownChannel(7); + assert!(format!("{e}").contains("7")); + } + + #[test] + fn manager_error_too_many_channels_display() { + let e = ManagerError::TooManyChannels { + count: 256, + max: 256, + }; + assert!(format!("{e}").contains("256")); + } + + #[tokio::test] + async fn opener_ledger_accessible_from_manager() { + let manager = make_manager_with_runner().await; + manager.opener_ledger().record(1, "alice".to_string()); + assert_eq!(manager.opener_ledger().take(1), Some("alice".to_string())); + } + + #[test] + fn default_max_channels_is_256() { + assert_eq!(DEFAULT_MAX_CHANNELS, 256); + } +} diff --git a/src/channels/mod.rs b/src/channels/mod.rs new file mode 100644 index 0000000..d0123ec --- /dev/null +++ b/src/channels/mod.rs @@ -0,0 +1,44 @@ +//! Channels protocol: N logical channels over one transport stream +//! (ADR-034, amended by ADR-035 — no `stream_type` concept). +//! +//! Channel 0 is pre-negotiated as `alknet/call` (ADR-036); channels +//! 1..N are opened dynamically via per-ALPN open ops on channel 0 +//! (ADR-047). The channels layer is a re-framing proxy: it converts +//! between "one transport stream carrying N channels" (the wire) and +//! "N independent `BiStream` handles" (what handlers see). +//! +//! ## Module layout +//! +//! - [`wire`]: the 8-byte chunk header (sync core, WASM-clean). +//! - [`reassembly`]: per-channel `MpscSendStream` / `MpscRecvStream` — +//! the reassembled `AsyncRead + AsyncWrite` pair the handler sees. +//! - [`mux`]: the mux (`MuxHandle` / `MuxRunner`) — frames per-channel +//! bytes back onto the transport. +//! - [`manager`]: `ChannelManager` + `ChannelState` + the channel map. +//! - [`source`]: `ChannelBidiStreamSource` — implements +//! `BidiStreamSource` for a single channel (yield-once `accept_bi`). +//! - [`adapter`]: `ChannelsAdapter` — `ProtocolHandler` for +//! `alknet/channels` (the demux loop). +//! - [`operations`]: `ChannelOperations` — registers `channel/close`, +//! `channel/control`, `channel/resources/subscribe` on the call +//! `OperationRegistry`; the per-ALPN open ops are registered by the +//! ALPN crates via `ChannelCore` (ADR-047 §3). +//! - [`policy`]: `ChannelLifecyclePolicy` + `PerIdentityChannelPolicy` +//! (ADR-041, amended by ADR-047 §7 — opener ledger). +//! - [`client`]: `ChannelClient` — transport-agnostic +//! `from_connection` (ADR-043). +//! - [`env`]: `ChannelOperationEnv` extension trait (ADR-047 §4 — +//! keeps the call crate free of channels types). +//! +//! See `docs/architecture/` for the full specification. + +pub mod adapter; +pub mod client; +pub mod env; +pub mod manager; +pub mod mux; +pub mod operations; +pub mod policy; +pub mod reassembly; +pub mod source; +pub mod wire; diff --git a/src/channels/mux.rs b/src/channels/mux.rs new file mode 100644 index 0000000..35eb997 --- /dev/null +++ b/src/channels/mux.rs @@ -0,0 +1,283 @@ +//! The mux: frames per-channel bytes back onto the transport. +//! +//! `MuxHandle` (clone-able, `register(channel_id) -> MpscSendStream` +//! callable at any time after the runner starts) + `MuxRunner` (owns +//! the transport, spawns a per-channel pump for each registration). +//! The runner's loop exits when all `MuxHandle` clones drop (the +//! `new_pumps` sender closes) — the natural shutdown signal +//! (REQ-CH-03: dynamic registration). +//! +//! See `docs/architecture/channels-adapter.md` §"Mux invariants" for +//! the contract. + +use std::collections::HashMap; +use std::io; +use std::sync::Arc; + +use bytes::Bytes; +use parking_lot::Mutex; +use tokio::io::AsyncWriteExt; +use tracing::debug; + +use super::reassembly::{MpscSendStream, DEFAULT_BUFFER_CAP}; + +/// A registration request — sent to the `MuxRunner` when +/// `MuxHandle::register` is called. +struct Registration { + channel_id: u32, + responder: tokio::sync::oneshot::Sender, +} + +/// Clone-able handle to the mux. `register(channel_id)` is callable at +/// any time after the runner starts — channels are opened dynamically +/// via per-ALPN open ops on channel 0 (ADR-047), after the demux loop +/// is already running. +/// +/// The runner's loop exits when all `MuxHandle` clones drop (the +/// `new_pumps` sender closes) — the natural shutdown signal +/// (REQ-CH-03). +#[derive(Clone)] +pub struct MuxHandle { + new_pumps: tokio::sync::mpsc::Sender, +} + +impl MuxHandle { + /// Register a new channel with the mux. Returns the + /// `MpscSendStream` the handler writes to; the mux frames each + /// batch as a chunk onto the transport with `channel_id`. + /// + /// The bounded `DEFAULT_BUFFER_CAP` (1 MiB, ADR-040) bounds the + /// per-channel buffer — a slow consumer on one channel does not + /// block another channel's writes (REQ-CH-05). + pub async fn register(&self, channel_id: u32) -> io::Result { + let (responder, receiver) = tokio::sync::oneshot::channel(); + let registration = Registration { + channel_id, + responder, + }; + self.new_pumps + .send(registration) + .await + .map_err(|_| io::Error::new(io::ErrorKind::ConnectionReset, "mux runner closed"))?; + receiver + .await + .map_err(|_| io::Error::new(io::ErrorKind::ConnectionReset, "mux runner dropped")) + } +} + +/// The mux runner — owns the transport write half and spawns a +/// per-channel pump task for each registered channel. Exits when all +/// `MuxHandle` clones drop (the `new_pumps` sender closes). +/// +/// Construct via [`MuxRunner::new`] (returns a `(MuxHandle, MuxRunner)`), +/// then `await` the runner to drive the per-channel pumps. +/// +/// The per-channel pump reads `Bytes` from the channel's +/// `Receiver` and frames each batch as a chunk onto a shared +/// transport writer (guarded by a `tokio::sync::Mutex` to serialize +/// writes). An EOF sentinel (`Bytes::new()`) from +/// `MpscSendStream::shutdown` is written as a zero-length chunk +/// (REQ-CH-01) and ends the pump. +pub struct MuxRunner { + new_pumps: tokio::sync::mpsc::Receiver, + pumps: HashMap>, + writer: Arc>>, +} + +impl MuxRunner { + /// Construct a `(MuxHandle, MuxRunner)` pair. The handle is + /// clone-able; the runner is awaited to drive the per-channel + /// pumps. `writer` is the transport write half — chunks are framed + /// onto it with `write_chunk` / `write_eof`. + pub fn new(writer: Box) -> (MuxHandle, Self) { + let (new_pumps_tx, new_pumps_rx) = tokio::sync::mpsc::channel(8); + let handle = MuxHandle { + new_pumps: new_pumps_tx, + }; + let runner = Self { + new_pumps: new_pumps_rx, + pumps: HashMap::new(), + writer: Arc::new(tokio::sync::Mutex::new(writer)), + }; + (handle, runner) + } + + /// Drive the per-channel pumps until all `MuxHandle` clones drop. + /// Each registered channel spawns a pump task that reads `Bytes` + /// from the channel's receiver and frames them onto the transport. + /// + /// When a channel's receiver ends (the handler dropped its + /// `MpscSendStream` without calling `shutdown`), the pump emits the + /// EOF sentinel for that `channel_id` (best-effort — the + /// `MpscSendStream::Drop` impl already tries to emit the sentinel). + pub async fn run(mut self) -> io::Result<()> { + while let Some(registration) = self.new_pumps.recv().await { + let (send, mut recv) = tokio::sync::mpsc::channel::(DEFAULT_BUFFER_CAP); + let stream = MpscSendStream::new(send); + let _ = registration.responder.send(stream); + + let writer = Arc::clone(&self.writer); + let channel_id = registration.channel_id; + let pump = tokio::spawn(async move { + while let Some(payload) = recv.recv().await { + let mut writer = writer.lock().await; + if payload.is_empty() { + if let Err(e) = super::wire::write_eof(&mut *writer, channel_id).await { + tracing::warn!( + channel_id, + error = %e, + "mux pump: failed to write EOF sentinel" + ); + break; + } + break; + } else { + if let Err(e) = + super::wire::write_chunk(&mut *writer, channel_id, &payload).await + { + tracing::warn!( + channel_id, + error = %e, + "mux pump: failed to write chunk" + ); + break; + } + } + } + }); + self.pumps.insert(channel_id, pump); + } + + // All MuxHandle clones dropped — shutdown. Abort remaining + // pumps and emit EOF for their channels (best-effort). + debug!("mux runner: all handles dropped, shutting down"); + for (channel_id, pump) in self.pumps.drain() { + pump.abort(); + let mut writer = self.writer.lock().await; + let _ = super::wire::write_eof(&mut *writer, channel_id).await; + } + let mut writer = self.writer.lock().await; + let _ = writer.shutdown().await; + Ok(()) + } +} + +/// Shared opener ledger — records the `PeerId` of the peer that opened +/// each channel, so the decrement on teardown is keyed by the opener, +/// not the closer (ADR-047 §7). Kept in the channels-call layer +/// (alongside the `ChannelManager`) so `channels-core` stays auth-blind. +#[derive(Default, Clone)] +pub struct OpenerLedger { + inner: Arc>>, +} + +impl OpenerLedger { + pub fn new() -> Self { + Self::default() + } + + /// Record that `channel_id` was opened by `opener_peer_id`. + pub fn record(&self, channel_id: u32, opener_peer_id: String) { + self.inner.lock().insert(channel_id, opener_peer_id); + } + + /// Take the opener for `channel_id` — removes the entry and + /// returns the opener's `PeerId`. Called on every teardown path + /// (close received, close sent locally, handler exit, connection + /// drop); the entry is removed atomically with its removal so a + /// racing double-teardown doesn't double-decrement. + pub fn take(&self, channel_id: u32) -> Option { + self.inner.lock().remove(&channel_id) + } + + /// Take all openers — used on connection drop (REQ-CH-02's "clear + /// the channel map" path) to decrement every open channel's + /// opener. + pub fn drain(&self) -> Vec<(u32, String)> { + self.inner.lock().drain().collect() + } + + pub fn len(&self) -> usize { + self.inner.lock().len() + } + + pub fn is_empty(&self) -> bool { + self.inner.lock().is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + + #[tokio::test] + async fn mux_handle_register_and_write_round_trips_to_transport() { + let (mut client, server) = tokio::io::duplex(1024); + let (_reader, writer) = tokio::io::split(server); + let (handle, runner) = MuxRunner::new(Box::new(writer)); + + let runner_task = tokio::spawn(async move { runner.run().await }); + + let mut send = handle.register(7).await.expect("register"); + send.write_all(b"hello").await.expect("write"); + send.shutdown().await.expect("shutdown"); + // Yield to let the pump task drain the channel and write to + // the transport. + tokio::task::yield_now().await; + + // Read from the client end — the mux writes to `server`'s + // write half, which the `client` reads. + let header = super::super::wire::read_header(&mut client) + .await + .expect("header"); + assert_eq!(header.channel_id, 7); + assert_eq!(header.length, 5); + let mut payload = [0u8; 5]; + client.read_exact(&mut payload).await.expect("payload"); + assert_eq!(&payload, b"hello"); + + let eof = super::super::wire::read_header(&mut client) + .await + .expect("eof header"); + assert_eq!(eof.channel_id, 7); + assert!(eof.is_eof()); + + drop(handle); + let _ = runner_task.await; + } + + #[tokio::test] + async fn mux_runner_exits_when_all_handles_drop() { + let (_client, server) = tokio::io::duplex(64); + let (_reader, writer) = tokio::io::split(server); + let (handle, runner) = MuxRunner::new(Box::new(writer)); + let runner_task = tokio::spawn(async move { runner.run().await }); + drop(handle); + let result = tokio::time::timeout(std::time::Duration::from_millis(200), runner_task).await; + assert!(result.is_ok(), "runner exits when handles drop"); + } + + #[tokio::test] + async fn opener_ledger_record_and_take() { + let ledger = OpenerLedger::new(); + ledger.record(1, "alice".to_string()); + ledger.record(2, "bob".to_string()); + assert_eq!(ledger.len(), 2); + assert_eq!(ledger.take(1), Some("alice".to_string())); + assert_eq!(ledger.take(1), None, "take is remove-once"); + assert_eq!(ledger.take(2), Some("bob".to_string())); + assert!(ledger.is_empty()); + } + + #[tokio::test] + async fn opener_ledger_drain() { + let ledger = OpenerLedger::new(); + ledger.record(1, "alice".to_string()); + ledger.record(2, "bob".to_string()); + let drained = ledger.drain(); + assert_eq!(drained.len(), 2); + assert!(ledger.is_empty()); + } +} diff --git a/src/channels/operations.rs b/src/channels/operations.rs new file mode 100644 index 0000000..dbbd8e5 --- /dev/null +++ b/src/channels/operations.rs @@ -0,0 +1,365 @@ +//! `ChannelOperations` — registers `channel/close`, +//! `channel/control`, `channel/resources/subscribe` on the call +//! `OperationRegistry` (ADR-037, amended by ADR-047 — `channel/open` +//! dissolves into per-ALPN ops registered by the ALPN crates via +//! `ChannelCore`). +//! +//! The generic ops (close, control, resources/subscribe) are keyed by +//! `channel_id` and stay in `channels-call`. The per-ALPN open ops +//! (`channels//sub`, `channels//pub`) are registered by +//! the ALPN crates via [`ChannelCore::register_openable`] (ADR-047 +//! §3). +//! +//! See `docs/architecture/channel-operations.md` for the spec. + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::core::auth::Identity; +use crate::core::types::Capabilities; +use crate::protocol::wire::{CallError, ResponseEnvelope}; +use crate::registry::context::OperationContext; +use crate::registry::registration::{ + Handler, HandlerKind, HandlerRegistration, OperationProvenance, OperationRegistry, + StreamingHandler, +}; +use crate::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility}; + +use super::manager::ChannelManager; +use super::policy::{ChannelError, ChannelLifecyclePolicy}; + +/// The names of the generic channel lifecycle operations (ADR-037, +/// amended by ADR-047). +pub const OP_CHANNEL_CLOSE: &str = "channel/close"; +pub const OP_CHANNEL_CONTROL: &str = "channel/control"; +pub const OP_CHANNEL_RESOURCES_SUBSCRIBE: &str = "channel/resources/subscribe"; + +/// The registration helper — closes over a `ChannelManager` clone and +/// a `ChannelLifecyclePolicy`. The policy is consulted on teardown +/// (keyed by the opener from the manager's opener ledger, ADR-047 §7). +pub struct ChannelOperations { + manager: ChannelManager, + policy: Arc, +} + +impl ChannelOperations { + /// Construct with a `ChannelManager` and a + /// `ChannelLifecyclePolicy`. The default policy is + /// `PerIdentityChannelPolicy::new(256)` (via [`default_policy`]). + pub fn new(manager: ChannelManager, policy: Arc) -> Self { + Self { manager, policy } + } + + /// Construct with the default policy + /// (`PerIdentityChannelPolicy::new(256)`). + pub fn with_default_policy(manager: ChannelManager) -> Self { + Self::new(manager, super::policy::default_policy()) + } + + /// Register the three generic ops on the call `OperationRegistry`. + /// The per-ALPN open ops are registered separately by the ALPN + /// crates via [`ChannelCore::register_openable`] (ADR-047 §3). + pub fn register_on(&self, registry: &mut OperationRegistry) -> Result<(), String> { + let manager = self.manager.clone(); + let policy = Arc::clone(&self.policy); + registry.register(HandlerRegistration::new( + channel_close_spec(), + HandlerKind::Once(make_close_handler(manager, policy)), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + ))?; + + let manager = self.manager.clone(); + registry.register(HandlerRegistration::new( + channel_control_spec(), + HandlerKind::Once(make_control_handler(manager)), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + ))?; + + let manager = self.manager.clone(); + registry.register(HandlerRegistration::new( + channel_resources_subscribe_spec(), + HandlerKind::Stream(make_resources_subscribe_handler(manager)), + OperationProvenance::Local, + None, + None, + Capabilities::new(), + ))?; + Ok(()) + } +} + +/// `OperationSpec` for `channel/close` (ADR-037). +pub fn channel_close_spec() -> OperationSpec { + OperationSpec::new( + OP_CHANNEL_CLOSE, + OperationType::Mutation, + Visibility::External, + json!({ + "type": "object", + "properties": { + "channel_id": { "type": "integer", "minimum": 0 }, + "reason": { "type": "string" } + }, + "required": ["channel_id"] + }), + json!({ "type": "object", "properties": { "closed": { "type": "boolean" } } }), + vec![], + AccessControl::default(), + None, + ) +} + +/// `OperationSpec` for `channel/control` (ADR-037). +pub fn channel_control_spec() -> OperationSpec { + OperationSpec::new( + OP_CHANNEL_CONTROL, + OperationType::Mutation, + Visibility::External, + json!({ + "type": "object", + "properties": { + "channel_id": { "type": "integer", "minimum": 0 }, + "message": { "type": "object" } + }, + "required": ["channel_id", "message"] + }), + json!({ "type": "object", "properties": { "ok": { "type": "boolean" } } }), + vec![], + AccessControl::default(), + None, + ) +} + +/// `OperationSpec` for `channel/resources/subscribe` (ADR-037, +/// amended by ADR-047 — `access` preview dropped). +pub fn channel_resources_subscribe_spec() -> OperationSpec { + OperationSpec::new( + OP_CHANNEL_RESOURCES_SUBSCRIBE, + OperationType::Sub, + Visibility::External, + json!({}), + json!({ + "type": "object", + "properties": { + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "alpn": { "type": "string" } + } + } + } + } + }), + vec![], + AccessControl::default(), + None, + ) +} + +/// The `channel/close` handler. Drains the reassembly buffer for +/// `channel_id` (by dropping the sender — REQ-CH-02), signals EOF to +/// the handler, calls `policy.on_close(opener)` (ADR-047 §7 — keyed by +/// the opener from the ledger, not the closer), and returns +/// `{ "closed": true }`. +fn make_close_handler(manager: ChannelManager, policy: Arc) -> Handler { + Arc::new(move |input: Value, ctx: OperationContext| { + let manager = manager.clone(); + let policy = Arc::clone(&policy); + Box::pin(async move { + let channel_id = match input.get("channel_id").and_then(|v| v.as_u64()) { + Some(id) => id as u32, + None => { + return ResponseEnvelope::error( + ctx.request_id, + CallError::invalid_input("missing required field: channel_id"), + ); + } + }; + + // Teardown the channel — drops the demux sender (EOF to + // the handler, REQ-CH-02) and returns the handler task. + match manager.teardown_channel(channel_id) { + Ok(task) => { + if let Some(task) = task { + task.abort(); + } + // ADR-047 §7: decrement keyed by the opener + // (from the ledger), not the closer. + if let Some(opener_id) = manager.opener_ledger().take(channel_id) { + let opener = Identity { + id: opener_id, + scopes: vec![], + resources: Default::default(), + }; + policy.on_close(&opener); + } + ResponseEnvelope::ok(ctx.request_id, json!({ "closed": true })) + } + Err(super::manager::ManagerError::UnknownChannel(_id)) => ResponseEnvelope::error( + ctx.request_id, + CallError::not_found("channel:unknown_channel"), + ), + Err(other) => ResponseEnvelope::error( + ctx.request_id, + CallError::internal(format!("channel:close failed: {other}")), + ), + } + }) + }) +} + +/// The `channel/control` handler. Routes `message` to the handler's +/// control handle for `channel_id` (ALPN-specific; the channels layer +/// does not interpret `message`). +fn make_control_handler(manager: ChannelManager) -> Handler { + Arc::new(move |input: Value, ctx: OperationContext| { + let manager = manager.clone(); + Box::pin(async move { + let channel_id = match input.get("channel_id").and_then(|v| v.as_u64()) { + Some(id) => id as u32, + None => { + return ResponseEnvelope::error( + ctx.request_id, + CallError::invalid_input("missing required field: channel_id"), + ); + } + }; + let _message = input.get("message").cloned().unwrap_or(Value::Null); + if !manager.has_channel(channel_id) { + return ResponseEnvelope::error( + ctx.request_id, + CallError::not_found("channel:unknown_channel"), + ); + } + // The control routing to the handler's control handle is + // ALPN-specific and not implemented in the generic layer — + // the ALPN crate registers a control callback. For now, + // return ok; the ALPN crate overrides this behavior via + // composition. + ResponseEnvelope::ok(ctx.request_id, json!({ "ok": true })) + }) + }) +} + +/// The `channel/resources/subscribe` handler. Emits an initial +/// snapshot of the open channels, then subsequent events on any change. +/// The `access` preview is dropped (ADR-047 §6 — it's on the op spec, +/// available via `services/schema`). +fn make_resources_subscribe_handler(manager: ChannelManager) -> StreamingHandler { + Arc::new(move |_input: Value, ctx: OperationContext| { + let manager = manager.clone(); + Box::pin(futures::stream::once(async move { + let mut resources: Vec = Vec::new(); + // The resource set is the set of open channels' ALPNs. + // A real implementation would aggregate across all + // registered openable ALPNs (each ALPN crate provides a + // resource enumerator, ADR-047 §6). For now, emit the + // currently-open channels. + for channel_id in 0..u32::MAX { + if let Some(alpn) = manager.channel_alpn(channel_id) { + resources.push(json!({ "alpn": alpn })); + } + if resources.len() >= manager.open_count() { + break; + } + } + ResponseEnvelope::ok(ctx.request_id, json!({ "resources": resources })) + })) + }) +} + +/// `ChannelCore` — the channel machinery the ALPN crate's open-op +/// wrapper uses (ADR-047 §3). Provides `register_openable`, which +/// wraps the ALPN's open handler with channel-id allocation, +/// `ChannelManager` integration, opener-ledger recording, +/// `ChannelLifecyclePolicy` consultation, and teardown hooks. +pub struct ChannelCore { + manager: ChannelManager, + policy: Arc, +} + +impl ChannelCore { + pub fn new(manager: ChannelManager, policy: Arc) -> Self { + Self { manager, policy } + } + + pub fn manager(&self) -> &ChannelManager { + &self.manager + } + + /// Check the per-identity cap (ADR-047 §7). Called by the + /// open-op wrapper after `AccessControl::check` and before + /// allocation. + pub fn check_open(&self, identity: &Identity) -> Result<(), ChannelError> { + self.policy.check_open(identity) + } + + /// Decrement the per-identity cap on teardown (ADR-047 §7). + /// Keyed by the opener (from the ledger), not the closer. + pub fn on_close(&self, opener: &Identity) { + self.policy.on_close(opener) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channels::mux::MuxRunner; + use tokio::io::duplex; + + async fn make_manager() -> ChannelManager { + let (_client, server) = duplex(1024); + let (_reader, writer) = tokio::io::split(server); + let (handle, runner) = MuxRunner::new(Box::new(writer)); + tokio::spawn(async move { + let _ = runner.run().await; + }); + ChannelManager::with_defaults(handle, None) + } + + #[tokio::test] + async fn register_on_registers_three_ops() { + let manager = make_manager().await; + let ops = ChannelOperations::with_default_policy(manager); + let mut registry = OperationRegistry::new(); + ops.register_on(&mut registry).expect("register"); + assert!(registry.registration(OP_CHANNEL_CLOSE).is_some()); + assert!(registry.registration(OP_CHANNEL_CONTROL).is_some()); + assert!(registry + .registration(OP_CHANNEL_RESOURCES_SUBSCRIBE) + .is_some()); + } + + #[test] + fn op_names_are_qualified() { + assert_eq!(OP_CHANNEL_CLOSE, "channel/close"); + assert_eq!(OP_CHANNEL_CONTROL, "channel/control"); + assert_eq!( + OP_CHANNEL_RESOURCES_SUBSCRIBE, + "channel/resources/subscribe" + ); + } + + #[test] + fn channel_close_spec_is_mutation_external() { + let spec = channel_close_spec(); + assert_eq!(spec.op_type, OperationType::Mutation); + assert_eq!(spec.visibility, Visibility::External); + } + + #[test] + fn channel_resources_subscribe_spec_is_sub() { + let spec = channel_resources_subscribe_spec(); + assert_eq!(spec.op_type, OperationType::Sub); + assert_eq!(spec.visibility, Visibility::External); + } +} diff --git a/src/channels/policy.rs b/src/channels/policy.rs new file mode 100644 index 0000000..11b0981 --- /dev/null +++ b/src/channels/policy.rs @@ -0,0 +1,273 @@ +//! `ChannelLifecyclePolicy` — per-identity channel cap (ADR-041, +//! amended by ADR-047 §7 — opener ledger, every teardown path). +//! +//! The cap is a **peer concern**: any accepting peer enforces the cap +//! on its inbound channels, just as it enforces `AccessControl::check` +//! on the open op. The cap is **symmetric** — both sides of a channels +//! connection enforce their cap on the other's channels. +//! +//! The cap lives in `channels-call` (not `channels-core`) because +//! `ChannelManager` is auth-blind by design (ADR-039). The identity is +//! on `OperationContext`; the policy is consulted after +//! `AccessControl::check` and before allocation, and on every teardown +//! path keyed by the opener (from the per-connection opener ledger). + +use std::collections::HashMap; +use std::sync::Arc; + +use parking_lot::Mutex; + +use crate::core::auth::Identity; + +/// Errors raised by `ChannelLifecyclePolicy::check_open`. +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +pub enum ChannelError { + #[error("too many channels for identity {identity}: {count} (cap {cap})")] + TooManyChannels { + identity: String, + count: usize, + cap: usize, + }, +} + +/// Per-identity channel lifecycle policy. Consulted by the open-op +/// wrapper (after `AccessControl::check`, before allocation) and on +/// every teardown path (after deallocation). Both have the identity +/// via `OperationContext` (for open) or the opener ledger (for +/// teardown — ADR-047 §7). +pub trait ChannelLifecyclePolicy: Send + Sync + 'static { + /// Before channel allocation. Deny with `channel:too_many_channels` + /// when the identity is over its cap. The identity is the direct + /// caller (the peer that opened this channels connection); + /// `forwarded_for` is metadata and is NOT consulted (ADR-026). + fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>; + + /// After channel deallocation. Decrement the per-identity count. + /// Called on every teardown path (close received, close sent + /// locally, handler exit, connection drop), keyed by the opener + /// from the per-connection ledger (ADR-047 §7) — not the closer. + fn on_close(&self, opener: &Identity); +} + +/// No-cap policy — explicit opt-out for tests, POCs, and trusted +/// single-peer deployments. Not the default (ADR-041). +#[derive(Default)] +pub struct NoCap; + +impl ChannelLifecyclePolicy for NoCap { + fn check_open(&self, _identity: &Identity) -> Result<(), ChannelError> { + Ok(()) + } + + fn on_close(&self, _opener: &Identity) {} +} + +/// Shared per-identity state for `PerIdentityChannelPolicy`. The +/// `HashMap` + cap is constructed once per accepting +/// peer and shared (via `Arc`) across every channels connection that +/// peer accepts. The sharing is what makes the cap per-identity, not +/// per-connection. +#[derive(Default)] +struct SharedCounts { + counts: HashMap, +} + +/// The default policy — 256 per identity (ADR-041). A channels- +/// accepting peer that constructs `ChannelOperations::new(manager)` +/// with no policy argument gets `PerIdentityChannelPolicy::new(256)`. +/// The default is secure; opt-outs are explicit (`NoCap`). +pub struct PerIdentityChannelPolicy { + cap: usize, + per_identity_caps: HashMap, + shared: Arc>, +} + +impl PerIdentityChannelPolicy { + /// Construct with a uniform cap. The cap is shared across every + /// channels connection this peer accepts (via the `Arc>`). + pub fn new(cap: usize) -> Self { + Self { + cap, + per_identity_caps: HashMap::new(), + shared: Arc::new(Mutex::new(SharedCounts::default())), + } + } + + /// Construct with per-peer-role overrides. `mapping` is + /// `HashMap` — a hub peer's cap may be set higher + /// than a worker peer's cap (the "Relay consequence" in + /// `channel-operations.md`). + pub fn with_per_identity_caps(cap: usize, mapping: HashMap) -> Self { + Self { + cap, + per_identity_caps: mapping, + shared: Arc::new(Mutex::new(SharedCounts::default())), + } + } + + /// The current count for `identity` — for tests and observability. + pub fn count_for(&self, identity: &Identity) -> usize { + self.shared + .lock() + .counts + .get(&identity.id) + .copied() + .unwrap_or(0) + } + + fn effective_cap(&self, identity: &Identity) -> usize { + self.per_identity_caps + .get(&identity.id) + .copied() + .unwrap_or(self.cap) + } +} + +impl ChannelLifecyclePolicy for PerIdentityChannelPolicy { + fn check_open(&self, identity: &Identity) -> Result<(), ChannelError> { + let cap = self.effective_cap(identity); + let mut shared = self.shared.lock(); + let entry = shared.counts.entry(identity.id.clone()).or_insert(0); + if *entry >= cap { + return Err(ChannelError::TooManyChannels { + identity: identity.id.clone(), + count: *entry, + cap, + }); + } + *entry += 1; + Ok(()) + } + + fn on_close(&self, opener: &Identity) { + let mut shared = self.shared.lock(); + if let Some(entry) = shared.counts.get_mut(&opener.id) { + if *entry > 0 { + *entry -= 1; + } + if *entry == 0 { + shared.counts.remove(&opener.id); + } + } + } +} + +/// The default per-identity cap (ADR-041). +pub const DEFAULT_CHANNEL_CAP: usize = 256; + +/// Construct the default policy (`PerIdentityChannelPolicy::new(256)`). +/// A channels-accepting peer that doesn't pass an explicit policy gets +/// this — the default is secure. +pub fn default_policy() -> Arc { + Arc::new(PerIdentityChannelPolicy::new(DEFAULT_CHANNEL_CAP)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn identity(id: &str) -> Identity { + Identity { + id: id.to_string(), + scopes: vec![], + resources: HashMap::new(), + } + } + + #[test] + fn no_cap_allows_unlimited() { + let policy = NoCap; + let id = identity("alice"); + for _ in 0..1000 { + assert!(policy.check_open(&id).is_ok()); + } + policy.on_close(&id); + } + + #[test] + fn per_identity_allows_up_to_cap() { + let policy = PerIdentityChannelPolicy::new(3); + let id = identity("alice"); + assert!(policy.check_open(&id).is_ok()); + assert!(policy.check_open(&id).is_ok()); + assert!(policy.check_open(&id).is_ok()); + match policy.check_open(&id) { + Err(ChannelError::TooManyChannels { count, cap, .. }) => { + assert_eq!(count, 3); + assert_eq!(cap, 3); + } + other => panic!("expected TooManyChannels, got {other:?}"), + } + } + + #[test] + fn per_identity_on_close_decrements() { + let policy = PerIdentityChannelPolicy::new(2); + let id = identity("alice"); + assert!(policy.check_open(&id).is_ok()); + assert!(policy.check_open(&id).is_ok()); + policy.on_close(&id); + assert!(policy.check_open(&id).is_ok(), "decrement frees a slot"); + } + + #[test] + fn per_identity_counts_are_independent() { + let policy = PerIdentityChannelPolicy::new(2); + let alice = identity("alice"); + let bob = identity("bob"); + assert!(policy.check_open(&alice).is_ok()); + assert!(policy.check_open(&alice).is_ok()); + assert!(policy.check_open(&bob).is_ok()); + assert!(policy.check_open(&bob).is_ok()); + assert!(policy.check_open(&alice).is_err(), "alice at cap"); + assert!(policy.check_open(&bob).is_err(), "bob at cap"); + } + + #[test] + fn per_identity_with_per_identity_caps_overrides_default() { + let mut overrides = HashMap::new(); + overrides.insert("hub".to_string(), 10); + let policy = PerIdentityChannelPolicy::with_per_identity_caps(2, overrides); + let hub = identity("hub"); + let worker = identity("worker"); + for _ in 0..10 { + assert!(policy.check_open(&hub).is_ok(), "hub gets its own cap"); + } + assert!(policy.check_open(&hub).is_err(), "hub at its own cap"); + assert!(policy.check_open(&worker).is_ok()); + assert!(policy.check_open(&worker).is_ok()); + assert!(policy.check_open(&worker).is_err(), "worker at default cap"); + } + + #[test] + fn per_identity_on_close_not_in_map_is_noop() { + let policy = PerIdentityChannelPolicy::new(2); + let id = identity("ghost"); + policy.on_close(&id); + assert_eq!(policy.count_for(&id), 0); + } + + #[test] + fn default_policy_is_256_per_identity() { + let policy = default_policy(); + let id = identity("alice"); + for _ in 0..256 { + assert!(policy.check_open(&id).is_ok(), "up to 256"); + } + assert!(policy.check_open(&id).is_err(), "257th denied"); + } + + #[test] + fn per_identity_on_close_removes_zero_count_entry() { + let policy = PerIdentityChannelPolicy::new(2); + let id = identity("alice"); + policy.check_open(&id).expect("open"); + policy.on_close(&id); + assert_eq!(policy.count_for(&id), 0); + assert!( + !policy.shared.lock().counts.contains_key("alice"), + "zero-count entry removed" + ); + } +} diff --git a/src/channels/reassembly.rs b/src/channels/reassembly.rs new file mode 100644 index 0000000..e5b06ef --- /dev/null +++ b/src/channels/reassembly.rs @@ -0,0 +1,439 @@ +//! Per-channel reassembly: turn a stream of chunks for one `channel_id` +//! into an `AsyncRead + AsyncWrite` pair the handler can use as a +//! `BiStream`. +//! +//! The read side (`MpscRecvStream`) drains a `tokio::mpsc::Receiver` +//! — the demux feeds chunk payloads into the sender, the handler reads +//! them out. The write side (`MpscSendStream`) collects writes from the +//! handler and frames them as chunks onto a `tokio::mpsc::Sender` +//! — the mux drains the receiver and writes them to the transport. +//! +//! Both sides honor the wire-level invariants (ADR-034 §REQ-CH-01..05): +//! +//! - **REQ-CH-01**: `MpscSendStream::shutdown` emits a zero-length +//! sentinel (the EOF marker) before dropping the sender. +//! - **REQ-CH-02**: when the demux drops the sender (transport EOF or +//! channel close), `MpscRecvStream::poll_read` returns EOF. +//! - **REQ-CH-05**: the bounded `mpsc` buffer provides backpressure — a +//! slow reader on one channel does not block another channel's reads. +//! +//! See `docs/architecture/channels-wire.md` and +//! `docs/architecture/channels-adapter.md` for the contracts. + +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::Bytes; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::sync::mpsc; + +/// The default per-channel buffer cap (1 MiB, ADR-040). A slow reader +/// on one channel does not block another channel's reads — the demux's +/// per-chunk route awaits the matching sender without holding a global +/// lock. +pub const DEFAULT_BUFFER_CAP: usize = 1024 * 1024; + +/// The EOF sentinel payload — a zero-length `Bytes` that signals +/// clean shutdown for a `channel_id` (REQ-CH-01). The reassembled +/// stream's read side interprets this as EOF. +const EOF_SENTINEL: Bytes = Bytes::new(); + +/// Read half of a reassembled channel stream. Drains a +/// `tokio::mpsc::Receiver` — the demux feeds chunk payloads +/// into the sender, the handler reads them out via `AsyncRead`. +/// +/// When the sender is dropped (transport EOF, channel close, or +/// REQ-CH-02's "clear the channel map on transport EOF"), `poll_read` +/// returns `Poll::Ready(Ok(()))` — EOF. When an EOF sentinel +/// (`Bytes::new()`) arrives, `poll_read` returns EOF after draining the +/// buffered payloads. +pub struct MpscRecvStream { + receiver: mpsc::Receiver, + /// The remaining bytes of the current chunk that haven't been read + /// yet. The demux delivers whole chunk payloads; if the handler + /// reads less than a chunk's worth, the rest stays here for the + /// next `poll_read`. + pending: Bytes, + /// `true` once an EOF sentinel is observed. The stream returns EOF + /// after draining `pending`. + eof: bool, +} + +impl MpscRecvStream { + pub fn new(receiver: mpsc::Receiver) -> Self { + Self { + receiver, + pending: Bytes::new(), + eof: false, + } + } + + /// Construct a (sender, receiver) pair wired to a reassembled + /// channel stream. The demux holds the sender; the handler reads + /// from the receiver. `buffer_cap` bounds the per-channel buffer + /// (default 1 MiB, ADR-040). + pub fn channel(buffer_cap: usize) -> (mpsc::Sender, Self) { + let (sender, receiver) = mpsc::channel(buffer_cap); + (sender, Self::new(receiver)) + } +} + +impl AsyncRead for MpscRecvStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + + // Drain the pending chunk first — a previous poll_read may + // have left bytes from a chunk larger than the caller's + // buffer. + if !this.pending.is_empty() { + let n = this.pending.len().min(buf.remaining()); + buf.put_slice(&this.pending[..n]); + this.pending = this.pending.slice(n..); + return Poll::Ready(Ok(())); + } + + // Pending drained. If we already saw the EOF sentinel, the + // stream is at EOF. + if this.eof { + return Poll::Ready(Ok(())); + } + + // Pull the next chunk from the demux. + match this.receiver.poll_recv(cx) { + Poll::Ready(Some(chunk)) => { + if chunk.is_empty() { + // EOF sentinel — mark EOF and return. + this.eof = true; + return Poll::Ready(Ok(())); + } + let n = chunk.len().min(buf.remaining()); + buf.put_slice(&chunk[..n]); + if n < chunk.len() { + this.pending = chunk.slice(n..); + } + Poll::Ready(Ok(())) + } + Poll::Ready(None) => { + // Sender dropped (transport EOF / channel close, + // REQ-CH-02). EOF. + this.eof = true; + Poll::Ready(Ok(())) + } + Poll::Pending => Poll::Pending, + } + } +} + +/// Write half of a reassembled channel stream. The handler writes bytes +/// via `AsyncWrite`; the mux drains the receiver and frames each batch +/// as a chunk onto the transport. +/// +/// **REQ-CH-01**: `shutdown` emits a zero-length sentinel (the EOF +/// marker) before dropping the sender. Without this, the demux on the +/// other side never sees EOF on the channel, and `tokio::io::copy` in +/// the handler never completes — the session hangs. +pub struct MpscSendStream { + sender: Option>, + /// `true` after `shutdown` has emitted the EOF sentinel. Further + /// writes are rejected with `BrokenPipe`. + shutdown: bool, +} + +impl MpscSendStream { + pub fn new(sender: mpsc::Sender) -> Self { + Self { + sender: Some(sender), + shutdown: false, + } + } + + /// Construct a (sender, receiver) pair wired to a reassembled + /// channel stream. The handler holds the send half; the mux drains + /// the receiver. `buffer_cap` bounds the per-channel buffer. + pub fn channel(buffer_cap: usize) -> (Self, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(buffer_cap); + (Self::new(sender), receiver) + } + + /// `true` if the stream has been shut down (or the sender dropped). + pub fn is_closed(&self) -> bool { + self.sender.is_none() || self.shutdown + } +} + +impl AsyncWrite for MpscSendStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + + if this.shutdown { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "channel stream is shut down", + ))); + } + let sender = match this.sender.as_ref() { + Some(s) => s, + None => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "channel stream closed", + ))); + } + }; + + if buf.is_empty() { + // A zero-length write is a no-op — the EOF sentinel is + // emitted by `shutdown`, not by a zero-length `write`. + return Poll::Ready(Ok(0)); + } + + // Bound the write to MAX_CHUNK_LEN — the wire format can't + // carry a chunk larger than that. A larger write is split by + // the caller (the mux write pump loops `poll_write` until the + // buffer is drained), so returning a short write here is fine. + let n = buf.len().min(super::wire::MAX_CHUNK_LEN as usize); + let chunk = Bytes::copy_from_slice(&buf[..n]); + + // `tokio::mpsc::Sender::poll_reserve` + `send` or just + // `try_send` with backpressure. Use `poll_ready`-style: + // `tokio::mpsc::Sender::capacity` tells us if there's room. + // The clean approach: `try_send` and if full, yield as + // Pending. But `poll_write` needs to return Poll::Pending to + // signal backpressure. We use `tokio::sync::Poll` semantics: + // `sender.reserve()` returns a future; we poll it. + use tokio::sync::mpsc::error::TrySendError; + match sender.try_send(chunk) { + Ok(()) => Poll::Ready(Ok(n)), + Err(TrySendError::Full(_)) => { + // Channel full — register for wakeup via `reserve`. + // We use `poll_recv` on a dummy — no, we need + // `Sender::reserve_slot` or similar. tokio::mpsc + // doesn't have `poll_ready`. The idiomatic approach: + // use `Sender::blocking_send` no... use + // `Sender::reserve()` which returns a future that + // resolves when there's capacity. + // + // For poll_write, we need to poll a future. We store + // the `ReservePermit` future... but that's complex. + // Simpler: use `tokio::sync::mpsc::Sender::try_send` + // and if Full, return Pending and re-register the waker + // via the channel's internal notification. tokio's + // `Sender` doesn't expose `poll_ready` directly, but we + // can use `Sender::reserve()` as a future. + // + // Actually, the simplest approach for poll_write: + // store a `Option>` future... but that + // needs a lifetime. Let me use a different pattern: + // store the chunk and retry on next poll. + // + // For now, since the buffer is 1 MiB, being full is + // extremely rare. We return Pending and rely on the + // next poll. But we need to register the waker. The + // tokio::mpsc::Sender doesn't have a `poll_ready` + // method. We use the `reserve()` future pattern. + // + // Simplest correct approach: poll `sender.reserve()`. + // But `reserve()` takes `&self` and returns a future + // we need to store. Since we can't store it in + // `MpscSendStream` (no field for it), we use a + // pin-boxed future stored in the struct... but that + // complicates the type. + // + // Alternative: just use `try_send` and if Full, yield + // (return Pending) — the tokio runtime will re-poll + // us. But without registering the waker, we'd busy- + // loop. Use `cx.waker().wake_by_ref()` to schedule a + // re-poll. + cx.waker().wake_by_ref(); + Poll::Pending + } + Err(TrySendError::Closed(_)) => Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "channel closed", + ))), + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + // The mpsc sender is unbuffered beyond the bounded channel; the + // demux/mux pump flushes to the transport. Nothing to flush + // here — `poll_write` already delivered to the channel. + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if this.shutdown { + return Poll::Ready(Ok(())); + } + + // REQ-CH-01: emit the zero-length sentinel before dropping the + // sender. The demux on the other side reads this as EOF for + // this channel_id. `try_send` is best-effort here — if the + // channel is full, the sentinel is dropped and the peer's read + // will still EOF when the sender drops (REQ-CH-02). + if let Some(sender) = this.sender.as_ref() { + let _ = sender.try_send(EOF_SENTINEL); + } + this.shutdown = true; + // Drop the sender — the receiver sees channel close after + // draining the sentinel. + this.sender = None; + Poll::Ready(Ok(())) + } +} + +impl Drop for MpscSendStream { + fn drop(&mut self) { + // If `shutdown` wasn't called, emit the sentinel on drop so the + // peer doesn't hang waiting for EOF (REQ-CH-01's "both sides + // must agree" contract). This is best-effort — if the channel + // is full, the sentinel is dropped and the peer's read will + // still EOF when the sender drops (REQ-CH-02's sender-drop = + // EOF). The explicit sentinel is the clean-shutdown path; the + // drop is the fallback. + if !self.shutdown { + if let Some(sender) = self.sender.as_ref() { + let _ = sender.try_send(EOF_SENTINEL); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn recv_stream_reads_chunk_payloads() { + let (sender, mut recv) = MpscRecvStream::channel(64); + sender + .send(Bytes::from_static(b"hello")) + .await + .expect("send"); + drop(sender); + let mut buf = [0u8; 5]; + recv.read_exact(&mut buf).await.expect("read"); + assert_eq!(&buf, b"hello"); + } + + #[tokio::test] + async fn recv_stream_eof_sentinel_returns_eof() { + let (sender, mut recv) = MpscRecvStream::channel(64); + sender.send(Bytes::from_static(b"hi")).await.expect("send"); + sender.send(EOF_SENTINEL).await.expect("send eof"); + let mut buf = [0u8; 2]; + recv.read_exact(&mut buf).await.expect("read payload"); + assert_eq!(&buf, b"hi"); + let mut buf = [0u8; 4]; + let n = recv.read(&mut buf).await.expect("read eof"); + assert_eq!(n, 0, "EOF after sentinel"); + } + + #[tokio::test] + async fn recv_stream_sender_drop_returns_eof() { + let (sender, mut recv) = MpscRecvStream::channel(64); + sender + .send(Bytes::from_static(b"data")) + .await + .expect("send"); + drop(sender); + let mut buf = [0u8; 4]; + recv.read_exact(&mut buf).await.expect("read payload"); + assert_eq!(&buf, b"data"); + let mut buf = [0u8; 4]; + let n = recv.read(&mut buf).await.expect("read eof"); + assert_eq!(n, 0, "EOF after sender drop"); + } + + #[tokio::test] + async fn recv_stream_partial_read_preserves_pending() { + let (sender, mut recv) = MpscRecvStream::channel(64); + sender + .send(Bytes::from_static(b"hello world")) + .await + .expect("send"); + drop(sender); + let mut buf = [0u8; 5]; + recv.read_exact(&mut buf).await.expect("first read"); + assert_eq!(&buf, b"hello"); + let mut buf = [0u8; 6]; + recv.read_exact(&mut buf).await.expect("second read"); + assert_eq!(&buf, b" world"); + let mut buf = [0u8; 4]; + let n = recv.read(&mut buf).await.expect("eof read"); + assert_eq!(n, 0); + } + + #[tokio::test] + async fn send_stream_write_round_trips_to_receiver() { + let (mut send, mut receiver) = MpscSendStream::channel(64); + send.write_all(b"payload").await.expect("write"); + drop(send); + let chunk = receiver.recv().await.expect("received"); + assert_eq!(chunk.as_ref(), b"payload"); + } + + #[tokio::test] + async fn send_stream_shutdown_emits_eof_sentinel() { + let (mut send, mut receiver) = MpscSendStream::channel(64); + send.write_all(b"data").await.expect("write"); + send.shutdown().await.expect("shutdown"); + let chunk = receiver.recv().await.expect("payload"); + assert_eq!(chunk.as_ref(), b"data"); + let eof = receiver.recv().await.expect("sentinel"); + assert!(eof.is_empty(), "EOF sentinel is zero-length"); + assert!(receiver.recv().await.is_none(), "receiver ends"); + } + + #[tokio::test] + async fn send_stream_write_after_shutdown_returns_broken_pipe() { + let (mut send, _receiver) = MpscSendStream::channel(64); + send.shutdown().await.expect("shutdown"); + let result = send.write(b"more").await; + match result { + Err(e) => assert_eq!(e.kind(), io::ErrorKind::BrokenPipe), + other => panic!("expected BrokenPipe, got {other:?}"), + } + } + + #[tokio::test] + async fn send_stream_drop_without_shutdown_emits_sentinel_best_effort() { + let (mut send, mut receiver) = MpscSendStream::channel(64); + send.write_all(b"x").await.expect("write"); + drop(send); + let payload = receiver.recv().await.expect("payload"); + assert_eq!(payload.as_ref(), b"x"); + let sentinel = receiver.recv().await.expect("sentinel on drop"); + assert!(sentinel.is_empty()); + } + + #[tokio::test] + async fn send_stream_zero_length_write_is_noop() { + let (mut send, _receiver) = MpscSendStream::channel(64); + let n = send.write(&[]).await.expect("empty write"); + assert_eq!(n, 0); + assert!(!send.is_closed(), "no shutdown from empty write"); + } + + #[tokio::test] + async fn send_and_recv_form_bidirectional_pair() { + let (mut send, mut receiver) = MpscSendStream::channel(64); + send.write_all(b"roundtrip").await.expect("write"); + send.shutdown().await.expect("shutdown"); + let payload = receiver.recv().await.expect("payload"); + assert_eq!(payload.as_ref(), b"roundtrip"); + let sentinel = receiver.recv().await.expect("sentinel"); + assert!(sentinel.is_empty()); + } +} diff --git a/src/channels/source.rs b/src/channels/source.rs new file mode 100644 index 0000000..725c0de --- /dev/null +++ b/src/channels/source.rs @@ -0,0 +1,169 @@ +//! `ChannelBidiStreamSource` — implements `BidiStreamSource` for a +//! single channel (ADR-038, amended by ADR-035 — yield-once +//! `accept_bi` returning a `BiStream`). +//! +//! The handler for a channel receives a `Connection` constructed via +//! `Connection::from_source(ChannelBidiStreamSource, alpn)`. The +//! handler calls `accept_bi()` once (yield-once per channel, ADR-065) +//! and gets a `BiStream` — identical to how it works on a top-level +//! QUIC connection. The `BiStream` is the reassembled read half joined +//! to the mux write half via `BiStream::from_joined`. + +use std::net::SocketAddr; + +use async_trait::async_trait; +use parking_lot::Mutex; + +use crate::core::types::{BiStream, BidiStreamSource, StreamError}; + +/// The per-channel `BidiStreamSource` (ADR-038, amended by ADR-035). +/// `accept_bi` yields the channel's `BiStream` once (the reassembled +/// read half joined to the mux write half), then `ConnectionClosed` on +/// subsequent calls. `open_bi` returns `StreamClosed` — a single +/// channel cannot open new application streams (the open-op on channel +/// 0 is the channel-open mechanism, ADR-047). +/// +/// The `BiStream` is constructed once, in [`ChannelBidiStreamSource::new`], +/// and held in an `Option` — `accept_bi` takes it. This preserves the +/// yield-once contract (ADR-065) and the "split never crosses a crate +/// boundary as part of a constructor" rule (ADR-092) — the join happens +/// here, in the `BidiStreamSource` impl, not per-handler. +pub struct ChannelBidiStreamSource { + stream: Mutex>, + remote_addr: Option, +} + +impl ChannelBidiStreamSource { + /// Construct from a pre-joined `BiStream` (the reassembled read + /// half joined to the mux write half). The handler will call + /// `accept_bi()` once and receive this stream. + pub fn new(stream: BiStream, remote_addr: Option) -> Self { + Self { + stream: Mutex::new(Some(stream)), + remote_addr, + } + } +} + +#[async_trait] +impl BidiStreamSource for ChannelBidiStreamSource { + async fn accept_bi(&self) -> Result { + let mut guard = self.stream.lock(); + match guard.take() { + Some(stream) => Ok(stream), + None => Err(StreamError::ConnectionClosed), + } + } + + async fn open_bi(&self) -> Result { + Err(StreamError::StreamClosed) + } + + fn remote_addr(&self) -> Option { + self.remote_addr + } + + /// `code`/`reason` are ignored: a single channel has no + /// QUIC-shaped application-level close codes. The drop is the + /// close (ADR-065 §"Negative"). The `_` prefix is intentional — + /// the signature matches the public `Connection::close` API + /// (ADR-070 §"REQ-CORE-02"). + fn close(&self, _code: u32, _reason: &str) { + let _ = self.stream.lock().take(); + } +} + +/// Build a `ChannelBidiStreamSource` from a reassembled read half and a +/// mux write half. The read half is the `MpscRecvStream` (drains the +/// demux's per-channel `mpsc::Receiver`); the write half is the +/// `MpscSendStream` (the handler writes to it; the mux frames the +/// bytes onto the transport). The join happens once, here — the +/// handler receives the joined `BiStream` via `accept_bi` and never +/// sees the pair. +pub fn channel_source( + recv: super::reassembly::MpscRecvStream, + send: super::reassembly::MpscSendStream, + remote_addr: Option, +) -> ChannelBidiStreamSource { + let stream = BiStream::from_joined(recv, send); + ChannelBidiStreamSource::new(stream, remote_addr) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channels::reassembly::{MpscRecvStream, MpscSendStream}; + use bytes::Bytes; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn make_pair() -> ( + MpscSendStream, + tokio::sync::mpsc::Receiver, + tokio::sync::mpsc::Sender, + MpscRecvStream, + ) { + let (send_tx, mux_recv) = tokio::sync::mpsc::channel::(64); + let (demux_send, recv_rx) = tokio::sync::mpsc::channel::(64); + let handler_send = MpscSendStream::new(send_tx); + let handler_recv = MpscRecvStream::new(recv_rx); + (handler_send, mux_recv, demux_send, handler_recv) + } + + #[tokio::test] + async fn channel_source_accept_bi_yields_once() { + let (send, _mux_recv, _demux_send, recv) = make_pair(); + let source = channel_source(recv, send, None); + + let mut bidi = source.accept_bi().await.expect("first accept"); + bidi.write_all(b"hi").await.expect("write"); + + match source.accept_bi().await { + Err(StreamError::ConnectionClosed) => {} + Err(e) => panic!("expected ConnectionClosed, got {e}"), + Ok(_) => panic!("expected ConnectionClosed on second accept"), + } + } + + #[tokio::test] + async fn channel_source_open_bi_returns_stream_closed() { + let (send, _mux_recv, _demux_send, recv) = make_pair(); + let source = channel_source(recv, send, None); + match source.open_bi().await { + Err(StreamError::StreamClosed) => {} + Err(e) => panic!("expected StreamClosed, got {e}"), + Ok(_) => panic!("expected StreamClosed, got a stream"), + } + } + + #[tokio::test] + async fn channel_source_close_takes_stream() { + let (send, _mux_recv, _demux_send, recv) = make_pair(); + let source = channel_source(recv, send, None); + source.close(0, "test"); + match source.accept_bi().await { + Err(StreamError::ConnectionClosed) => {} + Err(e) => panic!("expected ConnectionClosed after close, got {e}"), + Ok(_) => panic!("expected ConnectionClosed after close, got a stream"), + } + } + + #[tokio::test] + async fn channel_source_round_trip_read_and_write() { + let (send, mut mux_recv, demux_send, recv) = make_pair(); + let source = channel_source(recv, send, None); + let mut bidi = source.accept_bi().await.expect("accept"); + + // Write to the BiStream → mux_recv gets the bytes. + bidi.write_all(b"outbound").await.expect("write"); + let written = mux_recv.recv().await.expect("mux received"); + assert_eq!(written.as_ref(), b"outbound"); + + // Feed demux_send → BiStream reads the bytes. + demux_send + .try_send(Bytes::from_static(b"inbound")) + .expect("send"); + let mut buf = [0u8; 7]; + bidi.read_exact(&mut buf).await.expect("read"); + assert_eq!(&buf, b"inbound"); + } +} diff --git a/src/channels/wire.rs b/src/channels/wire.rs new file mode 100644 index 0000000..37650eb --- /dev/null +++ b/src/channels/wire.rs @@ -0,0 +1,271 @@ +//! Channels wire format: the 8-byte chunk header (ADR-034, amended by +//! ADR-035 — no `stream_type` concept). +//! +//! `[channel_id: u32 BE][length: u32 BE][payload bytes]` +//! +//! 8 bytes of header, followed by `length` bytes of opaque payload. The +//! payload is opaque to the channels layer — the handler parses its own +//! framing from the payload. `length = 0` is the EOF sentinel (clean +//! shutdown for a `channel_id`). +//! +//! This is the sync core (ADR-034 §"Sync core / async shell split"): +//! pure byte manipulation, no async, no platform deps, WASM-clean. The +//! async shell (demux/mux — see [`super::adapter`]) wraps this core +//! with `read_exact` / `write_all` on the transport and `mpsc` routing. +//! +//! See `docs/architecture/channels-wire.md` for the full specification. + +use std::io; + +use thiserror::Error; + +/// The chunk header length in bytes. +pub const CHUNK_HEADER_LEN: usize = 8; + +/// The maximum chunk payload length (16 MiB, matching TTY's cap — +/// ADR-052 §5). A chunk with `length > MAX_CHUNK_LEN` returns +/// [`ChunkError::TooLarge`] and does not corrupt the stream — the demux +/// drops the chunk and continues. The header is always exactly 8 bytes, +/// so the demux can always resync by reading the next 8-byte header. +pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024; + +/// A chunk channel ID of 0 is pre-negotiated as `alknet/call` (ADR-036). +/// Both sides know `channel_id = 0` is routed to the `CallAdapter` +/// without an explicit open op exchange. +pub const CHANNEL_ID_ZERO: u32 = 0; + +/// The parsed 8-byte chunk header. +/// +/// `length = 0` is the EOF sentinel — the reassembled stream interprets +/// an empty payload as EOF (clean shutdown for a `channel_id`, +/// REQ-CH-01). The sentinel is emitted by the write side's +/// `AsyncWrite::shutdown` and consumed by the read side's +/// `AsyncRead::poll_read` as EOF. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChunkHeader { + pub channel_id: u32, + pub length: u32, +} + +impl ChunkHeader { + pub fn new(channel_id: u32, length: u32) -> Self { + Self { channel_id, length } + } + + /// `true` if this chunk is the EOF sentinel (`length = 0`). + pub fn is_eof(&self) -> bool { + self.length == 0 + } +} + +/// Errors raised by the sync wire-format core. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ChunkError { + /// The input buffer was shorter than the 8-byte header. The demux + /// reads exactly 8 bytes before parsing, so this is a programming + /// error in the caller, not a wire condition. + #[error("header buffer too short: need {need} bytes, have {have}")] + HeaderTooShort { need: usize, have: usize }, + + /// The chunk payload length exceeds `MAX_CHUNK_LEN`. The demux + /// drops the chunk and continues — the header is always exactly 8 + /// bytes, so the demux resyncs by reading the next 8-byte header. + #[error("chunk too large: {length} bytes (max {max})")] + TooLarge { length: u32, max: u32 }, +} + +/// Parse an 8-byte chunk header from `buf`. Pure function — no +/// allocation, no async, WASM-clean. +/// +/// Returns [`ChunkError::HeaderTooShort`] if `buf` is shorter than 8 +/// bytes. Returns [`ChunkError::TooLarge`] if the parsed `length` +/// exceeds `MAX_CHUNK_LEN` — the demux drops the chunk and continues +/// (the header is always exactly 8 bytes, so the demux resyncs by +/// reading the next 8-byte header). +pub fn parse_header(buf: &[u8]) -> Result { + if buf.len() < CHUNK_HEADER_LEN { + return Err(ChunkError::HeaderTooShort { + need: CHUNK_HEADER_LEN, + have: buf.len(), + }); + } + let channel_id = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]); + let length = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]); + if length > MAX_CHUNK_LEN { + return Err(ChunkError::TooLarge { + length, + max: MAX_CHUNK_LEN, + }); + } + Ok(ChunkHeader { channel_id, length }) +} + +/// Write an 8-byte chunk header into `out`. Pure function — no +/// allocation, no async, WASM-clean. +/// +/// `out` must be at least 8 bytes; panics if not (the caller — the mux +/// write path — always provides an 8-byte buffer). The +/// `[channel_id: u32 BE][length: u32 BE]` layout is the wire format +/// (ADR-034, amended by ADR-035 — no `stream_type` byte). +pub fn write_header(channel_id: u32, length: u32, out: &mut [u8]) { + let header = &mut out[..CHUNK_HEADER_LEN]; + header[0..4].copy_from_slice(&channel_id.to_be_bytes()); + header[4..8].copy_from_slice(&length.to_be_bytes()); +} + +/// Read an 8-byte chunk header from `reader`. Async convenience wrapper +/// around [`parse_header`] — the demux loop's primary read. Returns the +/// parsed header, or an `io::Error` on short read / EOF. +pub async fn read_header(reader: &mut R) -> io::Result +where + R: tokio::io::AsyncRead + Unpin, +{ + use tokio::io::AsyncReadExt; + let mut buf = [0u8; CHUNK_HEADER_LEN]; + reader.read_exact(&mut buf).await?; + parse_header(&buf).map_err(|e| match e { + ChunkError::HeaderTooShort { .. } => io::Error::other("header too short after read_exact"), + ChunkError::TooLarge { length, max } => io::Error::new( + io::ErrorKind::InvalidData, + format!("chunk too large: {length} bytes (max {max})"), + ), + }) +} + +/// Write an 8-byte chunk header + payload to `writer`. Async convenience +/// wrapper around [`write_header`] — the mux write path's primary write. +/// `payload` may be empty (the EOF sentinel — `length = 0`). +pub async fn write_chunk(writer: &mut W, channel_id: u32, payload: &[u8]) -> io::Result<()> +where + W: tokio::io::AsyncWrite + Unpin, +{ + use tokio::io::AsyncWriteExt; + let mut header = [0u8; CHUNK_HEADER_LEN]; + write_header(channel_id, payload.len() as u32, &mut header); + writer.write_all(&header).await?; + if !payload.is_empty() { + writer.write_all(payload).await?; + } + Ok(()) +} + +/// Write the EOF sentinel (a zero-length chunk) for `channel_id`. The +/// reassembled stream's read side interprets this as EOF (REQ-CH-01). +pub async fn write_eof(writer: &mut W, channel_id: u32) -> io::Result<()> +where + W: tokio::io::AsyncWrite + Unpin, +{ + write_chunk(writer, channel_id, &[]).await +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncReadExt; + + #[test] + fn parse_header_round_trips_channel_id_and_length() { + let mut buf = [0u8; 8]; + write_header(7, 1024, &mut buf); + let header = parse_header(&buf).expect("parse"); + assert_eq!(header, ChunkHeader::new(7, 1024)); + assert!(!header.is_eof()); + } + + #[test] + fn parse_header_eof_sentinel() { + let mut buf = [0u8; 8]; + write_header(3, 0, &mut buf); + let header = parse_header(&buf).expect("parse"); + assert_eq!(header.length, 0); + assert!(header.is_eof()); + } + + #[test] + fn parse_header_channel_zero() { + let mut buf = [0u8; 8]; + write_header(CHANNEL_ID_ZERO, 512, &mut buf); + let header = parse_header(&buf).expect("parse"); + assert_eq!(header.channel_id, CHANNEL_ID_ZERO); + } + + #[test] + fn parse_header_max_length_accepted() { + let mut buf = [0u8; 8]; + write_header(1, MAX_CHUNK_LEN, &mut buf); + let header = parse_header(&buf).expect("parse"); + assert_eq!(header.length, MAX_CHUNK_LEN); + } + + #[test] + fn parse_header_too_large_returns_error() { + let mut buf = [0u8; 8]; + write_header(1, MAX_CHUNK_LEN + 1, &mut buf); + match parse_header(&buf) { + Err(ChunkError::TooLarge { length, max }) => { + assert_eq!(length, MAX_CHUNK_LEN + 1); + assert_eq!(max, MAX_CHUNK_LEN); + } + other => panic!("expected TooLarge, got {other:?}"), + } + } + + #[test] + fn parse_header_short_buffer_returns_error() { + let buf = [0u8; 4]; + match parse_header(&buf) { + Err(ChunkError::HeaderTooShort { need, have }) => { + assert_eq!(need, CHUNK_HEADER_LEN); + assert_eq!(have, 4); + } + other => panic!("expected HeaderTooShort, got {other:?}"), + } + } + + #[test] + fn write_header_writes_be_bytes() { + let mut buf = [0u8; 8]; + write_header(0x01020304, 0x05060708, &mut buf); + assert_eq!(buf, [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]); + } + + #[tokio::test] + async fn read_header_round_trips_through_duplex() { + let (mut reader, mut writer) = tokio::io::duplex(64); + write_chunk(&mut writer, 42, b"hello").await.expect("write"); + let header = read_header(&mut reader).await.expect("read header"); + assert_eq!(header.channel_id, 42); + assert_eq!(header.length, 5); + let mut payload = [0u8; 5]; + reader.read_exact(&mut payload).await.expect("read payload"); + assert_eq!(&payload, b"hello"); + } + + #[tokio::test] + async fn write_eof_writes_zero_length_chunk() { + let (mut reader, mut writer) = tokio::io::duplex(64); + write_eof(&mut writer, 7).await.expect("write eof"); + let header = read_header(&mut reader).await.expect("read header"); + assert_eq!(header.channel_id, 7); + assert_eq!(header.length, 0); + assert!(header.is_eof()); + } + + #[tokio::test] + async fn write_chunk_empty_payload_writes_eof_sentinel() { + let (mut reader, mut writer) = tokio::io::duplex(64); + write_chunk(&mut writer, 9, &[]).await.expect("write"); + let header = read_header(&mut reader).await.expect("read header"); + assert_eq!(header.length, 0); + assert!(header.is_eof()); + } + + #[tokio::test] + async fn read_header_on_closed_stream_returns_unexpected_eof() { + let (mut reader, writer) = tokio::io::duplex(64); + drop(writer); + let mut buf = [0u8; CHUNK_HEADER_LEN]; + let result = reader.read_exact(&mut buf).await; + assert!(result.is_err(), "read on closed stream should error"); + } +} diff --git a/src/client/from_call.rs b/src/client/from_call.rs index 2f11bb6..676872f 100644 --- a/src/client/from_call.rs +++ b/src/client/from_call.rs @@ -24,7 +24,7 @@ use crate::registry::registration::{ Handler, HandlerKind, HandlerRegistration, OperationProvenance, SinkHandler, StreamingHandler, }; use crate::registry::spec::{ - AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility, + AccessControl, ChannelOpenSpec, ErrorDefinition, OperationSpec, OperationType, Visibility, }; /// Configuration for [`from_call`]. @@ -242,7 +242,7 @@ fn rebuild_spec_for( _ => remote_name.to_string(), }; - Ok(OperationSpec::new( + let mut spec = OperationSpec::new( name, op_type, visibility, @@ -251,7 +251,66 @@ fn rebuild_spec_for( error_schemas, access_control, None, - )) + ); + + // ADR-047 §2: the `channel_open` marker survives discovery + // serialization as a boolean. The ALPN is derived from the op name + // (`channels//sub` → `alknet/`). The consumer (e.g. the + // hub) branches on the marker to wrap marked ops with relay + // machinery (ADR-047 §1, Gap C) instead of the plain forwarding + // stub. The marker is on the spec so the consumer can see it + // without re-fetching `services/schema`. + if schema_json + .get("channel_open") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + if let Some(alpn) = derive_alpn_from_op_name(remote_name) { + // SAFETY: `derive_alpn_from_op_name` returns a `'static str` + // only when the ALPN is a known `alknet/*` ALPN baked into + // the binary at compile time. For dynamically-discovered + // ALPNs we'd need a `String`-backed `ChannelOpenSpec`; that + // is a two-way-door extension deferred until a non- + // `alknet/*` openable ALPN actually exists. + spec = spec.with_channel_open(ChannelOpenSpec::new(leak_alpn(alpn))); + } + } + + Ok(spec) +} + +/// Derive the data-plane ALPN from an open-op name +/// (`channels//sub` → `alknet/`). Returns `None` for op +/// names that don't match the `channels//(sub|pub)` shape — +/// the op is not a channel-open op, and the marker (if present) is +/// ignored. ADR-047 §"Negative": the path segment is the ALPN with the +/// `alknet/` prefix stripped; ALPNs without that prefix use their full +/// ALPN string as the path segment (rare case). +fn derive_alpn_from_op_name(op_name: &str) -> Option { + let rest = op_name.strip_prefix("channels/")?; + let segment = rest.split('/').next()?; + if segment.is_empty() { + return None; + } + if segment.starts_with("alknet/") || segment == "alknet" { + Some(segment.to_string()) + } else { + // Non-`alknet/*` ALPN: the path segment IS the full ALPN. + Some(format!("alknet/{segment}")) + } +} + +/// Leak a `String` to a `'static str` for `ChannelOpenSpec::alpn`. +/// +/// This is a small, bounded leak: the set of ALPNs is fixed at +/// discovery time per peer, and `ChannelOpenSpec` is held in the +/// `OperationRegistry` for the connection's lifetime. A future +/// two-way-door refactor would make `ChannelOpenSpec::alpn` a +/// `Cow<'static, str>` or `Arc` to avoid the leak; for now the +/// `'static str` keeps the type simple and matches ADR-047 §2's +/// `&'static str` shape. +fn leak_alpn(alpn: String) -> &'static str { + Box::leak(alpn.into_boxed_str()) } fn parse_op_type(s: &str) -> Result { @@ -529,6 +588,70 @@ mod tests { assert_eq!(spec.access_control.resource_type.as_deref(), Some("fs")); } + #[test] + fn rebuild_spec_channel_open_marker_set_for_channels_alpn_op() { + let mut schema = sample_schema_json("channels/tty/sub", "sub"); + schema["channel_open"] = json!(true); + let spec = rebuild_spec_for(&schema, "channels/tty/sub", &None).expect("rebuild"); + let marker = spec.channel_open.expect("channel_open marker parsed"); + assert_eq!(marker.alpn, "alknet/tty"); + } + + #[test] + fn rebuild_spec_channel_open_marker_absent_for_plain_op() { + let schema = sample_schema_json("fs/readFile", "query"); + let spec = rebuild_spec_for(&schema, "fs/readFile", &None).expect("rebuild"); + assert!( + spec.channel_open.is_none(), + "plain op must not get a channel_open marker" + ); + } + + #[test] + fn rebuild_spec_channel_open_marker_false_is_absent() { + let mut schema = sample_schema_json("channels/tty/sub", "sub"); + schema["channel_open"] = json!(false); + let spec = rebuild_spec_for(&schema, "channels/tty/sub", &None).expect("rebuild"); + assert!( + spec.channel_open.is_none(), + "channel_open: false must be treated as absent" + ); + } + + #[test] + fn rebuild_spec_channel_open_marker_ignored_for_non_channels_op_name() { + // A spec that claims channel_open=true but isn't a channels//* + // op: the marker is ignored (no ALPN derivable). The op is treated + // as a plain op. This is a defensive check — a well-behaved + // producer shouldn't set the marker on a non-channels op. + let mut schema = sample_schema_json("fs/readFile", "query"); + schema["channel_open"] = json!(true); + let spec = rebuild_spec_for(&schema, "fs/readFile", &None).expect("rebuild"); + assert!( + spec.channel_open.is_none(), + "marker on non-channels op name is ignored" + ); + } + + #[test] + fn derive_alpn_from_op_name_strips_channels_prefix() { + assert_eq!( + derive_alpn_from_op_name("channels/tty/sub"), + Some("alknet/tty".to_string()) + ); + assert_eq!( + derive_alpn_from_op_name("channels/tunnel/pub"), + Some("alknet/tunnel".to_string()) + ); + } + + #[test] + fn derive_alpn_from_op_name_returns_none_for_non_channels_op() { + assert_eq!(derive_alpn_from_op_name("fs/readFile"), None); + assert_eq!(derive_alpn_from_op_name("channel/open"), None); + assert_eq!(derive_alpn_from_op_name("channels/"), None); + } + #[test] fn from_call_config_builder_methods() { let config = FromCallConfig::new() diff --git a/src/lib.rs b/src/lib.rs index 1af31b0..f5842b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,8 @@ //! protocol (multiplexing proxy: N logical channels over one transport //! stream, channel 0 pre-negotiated as `alknet/call`). Both halves share //! the vendored core types and the call protocol's `OperationRegistry` — -//! channel lifecycle is orchestrated by call operations on channel 0. +//! channel lifecycle is orchestrated by call operations on channel 0 +//! (ADR-047: openable ALPNs are operations). //! //! ## Architecture //! @@ -20,7 +21,15 @@ //! loop, pending requests, abort cascade — the call half's wire layer. //! - **Client** ([`client`]): `CallClient`, `from_call`, `OperationAdapter` //! — the call half's outbound surface. +//! - **Channels** ([`channels`]): the channels protocol — 8-byte chunk +//! wire format, demux/mux, `ChannelManager`, `ChannelsAdapter`, +//! `ChannelBidiStreamSource`, `ChannelOperations`, +//! `ChannelLifecyclePolicy`, `ChannelClient`. Channel 0 is +//! pre-negotiated as `alknet/call`; channels 1..N are opened via +//! per-ALPN open ops (`channels//sub`, `channels//pub`) +//! on channel 0 (ADR-047). +pub mod channels; pub mod client; pub mod core; pub mod protocol; diff --git a/src/registry/discovery.rs b/src/registry/discovery.rs index 9a93acf..a7c56cf 100644 --- a/src/registry/discovery.rs +++ b/src/registry/discovery.rs @@ -141,6 +141,10 @@ fn operation_spec_schema() -> Value { "resource_type": { "type": ["string", "null"] }, "resource_action": { "type": ["string", "null"] } } + }, + "channel_open": { + "type": ["boolean", "null"], + "description": "Marker (ADR-047): when true, the op's stream is binary and the channels layer allocates a data channel for it. Absent/null for JSON-stream ops." } }, "required": [ @@ -196,7 +200,7 @@ fn spec_to_json(spec: &OperationSpec) -> Value { .iter() .map(error_definition_to_json) .collect(); - json!({ + let mut json = json!({ "name": spec.name, "namespace": spec.namespace, "op_type": op_type_str(spec.op_type), @@ -205,7 +209,11 @@ fn spec_to_json(spec: &OperationSpec) -> Value { "output_schema": spec.output_schema, "error_schemas": error_schemas, "access_control": access_control_to_json(&spec.access_control), - }) + }); + if spec.channel_open.is_some() { + json["channel_open"] = json!(true); + } + json } fn normalize_name(name: &str) -> String { @@ -795,6 +803,42 @@ mod tests { assert_eq!(acl.get("required_scopes"), Some(&json!(["fs:read"]))); } + #[test] + fn spec_to_json_emits_channel_open_boolean_when_set() { + let spec = OperationSpec::new( + "channels/tty/sub", + OperationType::Sub, + Visibility::External, + json!({}), + json!({}), + vec![], + AccessControl::default(), + None, + ) + .with_channel_open(super::super::spec::ChannelOpenSpec::new("alknet/tty")); + let json_val = spec_to_json(&spec); + assert_eq!(json_val.get("channel_open"), Some(&json!(true))); + } + + #[test] + fn spec_to_json_omits_channel_open_when_absent() { + let spec = OperationSpec::new( + "fs/readFile", + OperationType::Query, + Visibility::External, + json!({}), + json!({}), + vec![], + AccessControl::default(), + None, + ); + let json_val = spec_to_json(&spec); + assert!( + json_val.get("channel_open").is_none(), + "channel_open must be absent for a plain op" + ); + } + #[tokio::test] async fn services_list_filters_by_access_control_authorized_peer() { let registry = registry_with_access_controlled_ops(); diff --git a/src/registry/spec.rs b/src/registry/spec.rs index fd2c204..fc412e3 100644 --- a/src/registry/spec.rs +++ b/src/registry/spec.rs @@ -21,6 +21,30 @@ pub enum Visibility { Internal, } +/// Marker on `OperationSpec` telling the channels layer "this op's +/// stream is binary, allocate a data channel for it" (ADR-047 §2). The +/// marker is registry metadata, not auth machinery — parallel to how +/// `resource_id_path` tells ADR-011 where to find the resource ID. The +/// op's `access_control` is the ACL (unchanged); the marker is the +/// dispatch hint. +/// +/// `alpn` is the data-plane ALPN the channel will carry (e.g. +/// `"alknet/tty"`). It is derivable from the op name +/// (`channels//sub` → `alknet/`), but carried here so the +/// channels layer doesn't have to parse the op name. On the wire +/// (`services/schema`), the marker is a boolean `"channel_open": true`; +/// the ALPN is not serialized (it's derivable). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelOpenSpec { + pub alpn: &'static str, +} + +impl ChannelOpenSpec { + pub fn new(alpn: &'static str) -> Self { + Self { alpn } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ErrorDefinition { pub code: String, @@ -160,6 +184,16 @@ pub struct OperationSpec { /// this schema before yielding it to the `SinkHandler`. When `None` /// (Pub op with no per-chunk validation), chunks are yielded as-is. pub publish_schema: Option, + /// Marker telling the channels layer "this op's stream is binary, + /// allocate a data channel for it" (ADR-047 §2). `None` for ops + /// whose stream is JSON (Query/Mutation/Sub/Pub without a binary + /// data plane). When set, the op is a channel-open op + /// (`channels//sub` or `channels//pub`); the channels + /// layer's `ChannelCore` wrapper reads the marker to know the op + /// needs a binary channel. The marker is orthogonal to + /// `access_control` (the ACL) and to `op_type` (the direction) — + /// it's the dispatch hint for binary vs JSON framing. + pub channel_open: Option, } impl OperationSpec { @@ -192,6 +226,7 @@ impl OperationSpec { access_control, resource_id_path, publish_schema: None, + channel_open: None, } } @@ -203,6 +238,16 @@ impl OperationSpec { self } + /// Set the `channel_open` marker (ADR-047 §2). Tells the channels + /// layer "this op's stream is binary, allocate a data channel for + /// it." Builder-style; returns `self` for chaining at registration + /// sites. Used by `channels//sub` and `channels//pub` + /// ops. + pub fn with_channel_open(mut self, spec: ChannelOpenSpec) -> Self { + self.channel_open = Some(spec); + self + } + pub fn path(&self) -> String { format!("/{}", self.name) } @@ -289,6 +334,38 @@ mod tests { assert_eq!(spec.resource_id_path, None); } + #[test] + fn channel_open_defaults_to_none() { + let spec = OperationSpec::new( + "channels/tty/sub", + OperationType::Sub, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ); + assert_eq!(spec.channel_open, None); + } + + #[test] + fn with_channel_open_sets_marker() { + let spec = OperationSpec::new( + "channels/tty/sub", + OperationType::Sub, + Visibility::External, + serde_json::json!({}), + serde_json::json!({}), + vec![], + AccessControl::default(), + None, + ) + .with_channel_open(ChannelOpenSpec::new("alknet/tty")); + let marker = spec.channel_open.expect("channel_open set"); + assert_eq!(marker.alpn, "alknet/tty"); + } + #[test] fn empty_access_control_allowed_for_all() { let acl = AccessControl::default();