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:
1 parent
ea66398c88
commit
f305f8c0a5
23 files changed
+3752
-203
No files matched your search
Generated
+1
@@ -31,6 +31,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"alktype",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user