feat: implement channels protocol + ADR-047 (openable ALPNs are operations)

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<Bytes> →
  AsyncRead), MpscSendStream (AsyncWrite → tokio::mpsc::Sender<Bytes>),
  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.
This commit is contained in:
glm-5.2 committed 2026-08-12 12:13:53 +00:00
1 parent ea66398c88
commit f305f8c0a5
23 files changed
+3752 -203

No files matched your search

Generated
+1
View File
@@ -31,6 +31,7 @@ version = "0.1.0"
dependencies = [
"alktype",
"async-trait",
"bytes",
"futures",
"parking_lot",
"serde",
+1
View File
@@ -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"] }
+3 -1
View File
@@ -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/<alpn>/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.
+225 -193
View File
@@ -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/<alpn>/sub`, `channels/<alpn>/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/<alpn>/sub`
and/or `channels/<alpn>/pub`:
Request (on channel 0):
| Op | `OperationType` | Initiator | Responder | Stream direction |
|----|-----------------|-----------|-----------|------------------|
| `channels/<alpn>/sub` | `Sub` | consumer (subscribes) | producer (streams) | server → client |
| `channels/<alpn>/pub` | `Pub` | producer (publishes) | consumer (receives) | client → server |
The `channels/<alpn>/...` 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<ChannelOpenSpec>` marker (ADR-047 §2) — the
dispatch hint that tells the channels layer "this op's stream is
binary, allocate a channel for it."
### `channels/<alpn>/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/<alpn>/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/<alpn>/sub` from the browser →
hub's `AccessControl::check` → hub re-issues `channels/<alpn>/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
- 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
@@ -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/<alpn>/sub` and `channels/<alpn>/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/<alpn>/sub` (`OperationType::Sub` — consumer subscribes to a
binary stream) and/or `channels/<alpn>/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<ChannelOpenSpec>` 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)
@@ -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/<alpn>/sub` → ALPN
`alknet/<alpn>`); 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/<alpn>/sub` and/or `channels/<alpn>/pub`:
| Op | `OperationType` | Initiator role | Responder role | Stream |
|----|-----------------|----------------|----------------|--------|
| `channels/<alpn>/sub` | `Sub` | consumer (subscribes) | producer (streams) | server→client |
| `channels/<alpn>/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<ChannelOpenSpec>,
}
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<ChannelOpenSpec>`,
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/<alpn>/sub`, `channels/<alpn>/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
+9 -1
View File
@@ -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
+13 -1
View File
@@ -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<String>,
/// Schema for each published chunk's `input` (Pub ops only, ADR-046).
/// `None` for Query/Mutation/Sub ops.
pub publish_schema: Option<Value>,
/// 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/<alpn>/sub` or `channels/<alpn>/pub`).
pub channel_open: Option<ChannelOpenSpec>,
}
pub struct ChannelOpenSpec {
pub alpn: &'static str, // e.g., "alknet/tty" — derivable from op name, carried for convenience
}
pub enum OperationType {
+219
View File
@@ -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<dyn Fn(&ChannelManager, &AuthContext) -> 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<dyn tokio::io::AsyncRead + Send + Unpin>,
) {
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<dyn tokio::io::AsyncRead + Send + Unpin>,
) {
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");
}
}
+137
View File
@@ -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/<alpn>/sub`, `channels/<alpn>/pub`) on channel 0.
pub struct ChannelClient {
manager: ChannelManager,
call_connection: Arc<Mutex<Option<CallConnection>>>,
}
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<Self, StreamError> {
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/<alpn>/sub` or
/// `channels/<alpn>/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<CallConnection> {
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::<ChannelClient>;
}
}
+163
View File
@@ -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<dyn OperationEnv + Send + Sync>,
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<crate::registry::env::PeerId> {
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<String> {
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<dyn OperationEnv + Send + Sync>,
) -> Option<ChannelManager> {
// 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<dyn OperationEnv + Send + Sync> = 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<T: OperationEnv>() {}
assert_operation_env::<ChannelsSessionEnv>();
}
}
+465
View File
@@ -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<Bytes>,
/// The handler task — aborted on close / connection drop.
handler_task: Option<JoinHandle<()>>,
/// 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<Inner>,
}
struct Inner {
channels: Mutex<HashMap<u32, ChannelState>>,
next_id: AtomicU32,
max_channels: usize,
buffer_cap: usize,
mux: MuxHandle,
opener_ledger: OpenerLedger,
remote_addr: Option<SocketAddr>,
}
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<SocketAddr>,
) -> 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<SocketAddr>) -> Self {
Self::new(mux, DEFAULT_MAX_CHANNELS, DEFAULT_BUFFER_CAP, remote_addr)
}
/// The remote address (informational — NAT/proxy).
pub fn remote_addr(&self) -> Option<SocketAddr> {
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<String>,
opener_peer_id: impl Into<String>,
handler_task: Option<JoinHandle<()>>,
) -> 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<JoinHandle<()>>,
) -> 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<Option<JoinHandle<()>>, 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<String> {
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);
}
}
+44
View File
@@ -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;
+283
View File
@@ -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<MpscSendStream>,
}
/// 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<Registration>,
}
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<MpscSendStream> {
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<Bytes>` 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<Registration>,
pumps: HashMap<u32, tokio::task::JoinHandle<()>>,
writer: Arc<tokio::sync::Mutex<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>,
}
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<dyn tokio::io::AsyncWrite + Send + Unpin>) -> (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::<Bytes>(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<Mutex<HashMap<u32, String>>>,
}
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<String> {
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());
}
}
+365
View File
@@ -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/<alpn>/sub`, `channels/<alpn>/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<dyn ChannelLifecyclePolicy>,
}
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<dyn ChannelLifecyclePolicy>) -> 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<dyn ChannelLifecyclePolicy>) -> 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<Value> = 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<dyn ChannelLifecyclePolicy>,
}
impl ChannelCore {
pub fn new(manager: ChannelManager, policy: Arc<dyn ChannelLifecyclePolicy>) -> 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);
}
}
+273
View File
@@ -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<PeerId, usize>` + 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<String, usize>,
}
/// 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<String, usize>,
shared: Arc<Mutex<SharedCounts>>,
}
impl PerIdentityChannelPolicy {
/// Construct with a uniform cap. The cap is shared across every
/// channels connection this peer accepts (via the `Arc<Mutex<_>>`).
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<PeerId, cap>` — 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<String, usize>) -> 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<dyn ChannelLifecyclePolicy> {
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"
);
}
}
+439
View File
@@ -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<Bytes>`
//! — 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<Bytes>`
//! — 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<Bytes>` — 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<Bytes>,
/// 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<Bytes>) -> 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<Bytes>, 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<io::Result<()>> {
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<mpsc::Sender<Bytes>>,
/// `true` after `shutdown` has emitted the EOF sentinel. Further
/// writes are rejected with `BrokenPipe`.
shutdown: bool,
}
impl MpscSendStream {
pub fn new(sender: mpsc::Sender<Bytes>) -> 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<Bytes>) {
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<io::Result<usize>> {
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<Reserve<'_>>` 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<io::Result<()>> {
// 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<io::Result<()>> {
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());
}
}
+169
View File
@@ -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<Option<BiStream>>,
remote_addr: Option<SocketAddr>,
}
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<SocketAddr>) -> Self {
Self {
stream: Mutex::new(Some(stream)),
remote_addr,
}
}
}
#[async_trait]
impl BidiStreamSource for ChannelBidiStreamSource {
async fn accept_bi(&self) -> Result<BiStream, StreamError> {
let mut guard = self.stream.lock();
match guard.take() {
Some(stream) => Ok(stream),
None => Err(StreamError::ConnectionClosed),
}
}
async fn open_bi(&self) -> Result<BiStream, StreamError> {
Err(StreamError::StreamClosed)
}
fn remote_addr(&self) -> Option<SocketAddr> {
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<Bytes>`); 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<SocketAddr>,
) -> 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<Bytes>,
tokio::sync::mpsc::Sender<Bytes>,
MpscRecvStream,
) {
let (send_tx, mux_recv) = tokio::sync::mpsc::channel::<Bytes>(64);
let (demux_send, recv_rx) = tokio::sync::mpsc::channel::<Bytes>(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");
}
}
+271
View File
@@ -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<ChunkHeader, ChunkError> {
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<R>(reader: &mut R) -> io::Result<ChunkHeader>
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<W>(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<W>(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");
}
}
+126 -3
View File
@@ -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/<alpn>/sub` → `alknet/<alpn>`). 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/<alpn>/sub` → `alknet/<alpn>`). Returns `None` for op
/// names that don't match the `channels/<segment>/(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<String> {
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<str>` 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<OperationType, AdapterError> {
@@ -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/<alpn>/*
// 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()
+10 -1
View File
@@ -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/<alpn>/sub`, `channels/<alpn>/pub`)
//! on channel 0 (ADR-047).
pub mod channels;
pub mod client;
pub mod core;
pub mod protocol;
+46 -2
View File
@@ -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();
+77
View File
@@ -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/<alpn>/sub` → `alknet/<alpn>`), 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<Value>,
/// 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/<alpn>/sub` or `channels/<alpn>/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<ChannelOpenSpec>,
}
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/<alpn>/sub` and `channels/<alpn>/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();