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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user