fix: Unit 2 — channel 0 single-stream call mode (C-01, C-25 #1)

Channel 0 was dead in both directions (C-01): the call protocol's
stream-per-request model (open_bi per call) is incompatible with
channel 0's single yield-once BiStream — every call_open_op failed
with StreamClosed on the connect side, and channel 0's Connection was
a black hole on the accept side.

The fix is single-stream call mode (ADR-036 amendment): all
EventEnvelope frames are multiplexed on channel 0's one BiStream.

Changes:
- CallConnection gains single_stream_writer: Option<Arc<SharedFrameWriter>>
  and new_single_stream() constructor. call_with_payload,
  subscribe_with_payload, publish_with_payload, and abort branch on
  is_single_stream() — in single-stream mode they write frames through
  the shared writer (mutex-serialized) instead of opening a fresh
  open_bi per call.
- Dispatcher::run_loop_single_stream reads frames off channel 0's read
  half, dispatches call.requested, writes responses through the shared
  writer, and routes in-flight call.published/call.completed/
  call.aborted to the matching Pub sink's chunk_tx by request_id.
- ChannelsAdapter::handle's InstallChannelZero hook now receives
  channel 0's Connection (built by the adapter) and runs the
  single-stream dispatch loop on it — closing the accept-side black
  hole. Mux runner is spawned BEFORE install_channel_zero so
  mux.register(0) can complete.
- ChannelClient::from_connection uses CallConnection::new_single_stream
  and spawns a read pump (read_single_stream_until_closed) that routes
  channel-0 response frames into the PendingRequestMap via
  dispatch_envelope — closing the connect-side StreamClosed path.
- ADR-036 amendment records the single-stream-mode decision (two-way
  door: implementation detail, wire format unchanged).

Acceptance gate (C-25 #1): three end-to-end tests wire
ChannelClient ↔ ChannelsAdapter over a real tokio::io::duplex pair
carrying the channels 8-byte chunk header wire format:
- channel_0_end_to_end_call_round_trip: a Query op round-trip
- channel_0_end_to_end_unknown_op_returns_not_found: NOT_FOUND
- channel_0_end_to_end_publish_delivers_chunks: a Pub op with 3 chunks

Verification: 437 tests pass (was 434; +3 e2e), clippy clean, fmt
clean, doc warnings unchanged (4, pre-existing C-09).
This commit is contained in:
2026-08-12 21:55:04 +00:00
parent 502488cc72
commit 495e04ed43
5 changed files with 925 additions and 68 deletions

View File

@@ -5,7 +5,9 @@
Accepted (amended 2026-07-18 by ADR-035 — channel 0's `stream_types`
field is removed; the channels layer has no `stream_type` concept; the
call protocol's `EventEnvelope` framing is the channels payload, carried
transparently — see "Amendment (ADR-035, 2026-07-18)" below)
transparently — see "Amendment (ADR-035, 2026-07-18)" below; further
amended 2026-08-12 — channel 0 runs in single-stream call mode — see
"Amendment (single-stream call mode, 2026-08-12)" below)
## Amendment (ADR-035, 2026-07-18)
@@ -24,6 +26,89 @@ The body below describes the **original** (with `stream_types`) shape;
the amendment above is the operative decision. See ADR-035 for the
resolution rationale and the cross-ADR impacts.
## Amendment (single-stream call mode, 2026-08-12)
Channel 0 runs in **single-stream call mode**: all `EventEnvelope`
frames (requests, responses, published chunks, aborts) are multiplexed
on channel 0's one `BiStream`. The call protocol's stream-per-request
model (`CallConnection` opens a fresh `open_bi` per call;
`Dispatcher::run_loop` accepts fresh streams in a loop) does not apply
to channel 0 — channel 0's `ChannelBidiStreamSource` is yield-once
(ADR-038), so `open_bi` returns `StreamClosed` and `accept_bi` yields
the one `BiStream` once. Without single-stream mode, every
`call_open_op` on a `ChannelClient` fails with `StreamClosed` on the
connect side, and channel 0's `Connection` is a black hole on the
accept side (the dispatch loop has no stream to accept).
### What this means concretely
1. **`CallConnection` gains a single-stream mode.**
`CallConnection::new_single_stream(connection, shared_writer)`
constructs a `CallConnection` that holds a `SharedFrameWriter` (a
`tokio::sync::Mutex<FrameFramedWriter<W>>`) instead of opening a
fresh `open_bi` per call. `call_with_payload`,
`subscribe_with_payload`, `publish_with_payload`, and `abort`
branch on `is_single_stream()`: in single-stream mode they write
frames through the shared writer; in stream-per-request mode they
open a fresh stream as before. The shared writer's mutex serializes
frames so concurrent calls do not interleave.
2. **`Dispatcher` gains `run_loop_single_stream`.** The accept-side
dispatch loop reads `EventEnvelope` frames off channel 0's read
half (a `FrameFramedReader` backed by the reassembled `MpscRecvStream`),
dispatches `call.requested` events, and writes responses through the
same `SharedFrameWriter`. In-flight Pub (Sink) operations are tracked
by `request_id` in a local `HashMap<String, mpsc::Sender>`:
`call.published` / `call.completed` / `call.aborted` frames arriving
after the `call.requested` are routed to the matching sink's
`chunk_tx` while new `call.requested` frames for other requests
continue to be dispatched. Query/Mutation and Sub responses are
written through the shared writer immediately after dispatch.
3. **`ChannelClient::from_connection` drives the client-side read
pump.** The client spawns a read pump
(`read_single_stream_until_closed`) that reads `EventEnvelope`
frames off channel 0's reassembled read half (fed by the demux) and
routes them into the `PendingRequestMap` via `dispatch_envelope`,
resolving pending calls. This is the single-stream analogue of
`read_stream_until_closed` in stream-per-request mode.
4. **`ChannelsAdapter::handle`'s `InstallChannelZero` hook now
receives channel 0's `Connection`.** The adapter constructs
channel 0's `Connection` (from the reassembled read half + the mux
write half) and passes it to the hook; the hook runs
`Dispatcher::run_loop_single_stream` on it. This closes C-01's
accept-side black hole: channel 0's `Connection` is actually driven
by a call dispatch loop.
5. **Top-level `alknet/call` connections are unchanged.**
`CallConnection::new` and `Dispatcher::run_loop` keep the
stream-per-request model. Single-stream mode is only for channel 0
(and any future single-stream substrate that multiplexes call
frames on one `BiStream`).
### Door type
**Two-way (implementation detail).** Single-stream call mode is an
implementation strategy for channel 0's yield-once `BiStream`, not a
wire-format change. The wire format (length-prefixed `EventEnvelope`
frames carried in channel 0's 8-byte chunk header payloads) is
unchanged. A future QUIC-native channels substrate (ADR-039) that
yields multiple bidi streams could use stream-per-request mode on
channel 0 instead; the `CallConnection` and `Dispatcher` branching
supports both. The `SharedFrameWriter` / `run_loop_single_stream`
types are pub(crate) — not part of the public API surface.
### References
- C-01 in `docs/reviews/001-pub-and-channels-integration-review.md`
(the channel-0-is-dead-in-both-directions finding this amendment
resolves)
- ADR-038: `ChannelBidiStreamSource` (yield-once `accept_bi` — the
constraint that forces single-stream mode)
- ADR-035: channels pure channel multiplexing (no `stream_type` — the
amendment this builds on)
## Context
A channels connection carries N logical channels. One of them must carry the