A chunk routed by the demux inside the adopt_channel install→drain window was delivered ahead of earlier-parked early arrivals: the mpsc send order was inverted relative to wire-arrival order, misordering the channel's byte stream (C-01, critical — downstream alkgit observed two desync shapes under multi-process load). A related loss variant is also closed: a chunk whose map miss parked it after the drain had removed the queue sat parked forever. The window closes by serialization, not by widening locks across awaits — the resolution is fully synchronous: - route_payload holds the channels-map lock across the miss → park decision (park_early_arrival runs nested; lock order channels → early_arrivals): a park can never straddle the install, and the installer that caused the miss always swaps the queue out. - install_locked (new, shared by open_channel / adopt_channel / install_channel_zero) swaps the parked queue, inserts the state, and try_sends the parked queue into the fresh, empty receiver before releasing the channels lock — a direct route can only begin after release, so parked chunks always precede it in send order. No await runs inside the section: the effective park cap is now min(EARLY_ARRIVAL_CAP, buffer_cap), bounding the parked queue by the receiver's capacity so the drain never blocks. drain_early_arrivals remains as the defensive full-buffer fallback and is now called by all three install paths. Regression tests: adopt_window_does_not_reorder_chunks (200 iterations on a 2-worker runtime — a racer routing C the moment the install is visible, receiver byte stream asserted byte-identical to the producer's; catches the old window under genuine preemption, the alkgit repro shape), adopt_drains_parked_and_raced_chunks_in_fifo, and park_cap_bounded_by_receiver_buffer. rt-multi-thread added as a dev-dependency for the runtime test. Semver: patch (0.10.1) — private fix surface; no API, wire-format, or documented-cap behavior change; behavior only strengthens. Verification: cargo test 700/724 (--all-features) passed; clippy --all-targets -D warnings clean; fmt --check clean; cargo doc --no-deps generated; fuzz corpus replay passed; wasm32-unknown-unknown check passed.
12 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-10-13 (alkcall reframing) |
channel-client.md — ChannelClient
The client side of a channels connection. ADR-043 is the decision; this doc specifies the API.
What
ChannelClient is the symmetric counterpart to ChannelsAdapter (ADR-039).
The server side is a ProtocolHandler (ChannelsAdapter::handle); the
client side takes over an established transport Connection, runs the
demux/mux, and exposes open_channel(alpn, params) -> Channel to the
application.
This is the channels analogue of CallClient (server: CallAdapter;
client: CallClient) in the call protocol.
API
pub struct ChannelClient {
manager: ChannelManager,
// The transport-side demux/mux, running in a background task.
...
}
impl ChannelClient {
/// Construct a `ChannelClient` over a pre-established transport
/// `Connection` on ALPN `alk/channels`. This is the
/// transport-agnostic primary constructor: the caller (or a
/// transport-specific dial helper) produces the `Connection` —
/// via `Connection::from_bidi` (TCP+TLS, WebTransport, SSH
/// `direct-tcpip`), a quinn connection, or any other `AsyncRead +
/// AsyncWrite` source — and this method takes over: installs
/// channel 0 (`alk/call`), spawns the demux/mux, and returns
/// the client. Mirrors the server side's transport-agnostic
/// `ChannelsAdapter::handle(Connection)` and
/// `CallClient::spawn_dispatch(Connection)`.
///
/// This is the one-way-door API surface (ADR-043). It must not be
/// coupled to a transport — the channels protocol is
/// transport-agnostic (ADR-034, as amended by ADR-035; ADR-007,
/// ADR-009), and the client side is half of that protocol.
pub async fn from_connection(connection: Connection)
-> Result<Self, ChannelError>;
/// 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).
/// In single-stream call mode (ADR-036 amendment), this writes
/// `call.requested` through channel 0's shared frame writer and
/// awaits the response via the `PendingRequestMap`.
pub async fn call_open_op(&self, operation_id: &str, input: Value)
-> ResponseEnvelope;
/// Open a data channel by calling the per-ALPN open op on channel 0
/// and adopting the resulting `channel_id` (ADR-047 §5 odd/even
/// split). The connect side calls the open op; the accept side
/// allocates the `channel_id` (even). The connect side then adopts
/// the `channel_id` via `ChannelManager::adopt_channel` to install
/// local routing state.
///
/// Returns the `channel_id`, the `MpscSendStream` (write half), and
/// the `MpscRecvStream` (read half). The caller can build a
/// `Connection` from these via `channel_source` and
/// `Connection::from_source`.
///
/// The error is typed (ADR-049 §4 — review 006 N-1):
/// `ChannelOpenError::CallFailed` carries the wire `CallError`
/// verbatim (branch on `establishment_reason()` for
/// `channel:open_failed`'s `details.reason`); `MissingChannelId`
/// covers a malformed success reply; `AdoptFailed` covers local
/// adoption failure.
pub async fn open_channel(
&self,
operation_id: &str,
input: Value,
alpn: &str,
) -> Result<(u32, MpscSendStream, MpscRecvStream), ChannelOpenError>;
/// 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>;
}
Post-047: per-ALPN open ops, not a generic channel/open
ADR-047 dissolved the generic channel/open operation into per-ALPN
open ops (channels/<alpn>/sub, channels/<alpn>/pub). Each ALPN
crate registers its own open op via ChannelCore::register_openable
(ADR-047 §3, as amended 2026-08-13 — per-connection registration),
optionally with an establisher via
register_openable_with_establisher (ADR-049 — the awaited,
bounded establishment phase; establishment failure resolves
Err on the client with channel:open_failed + details.reason).
The ChannelClient calls these ops by name on channel 0 via
call_open_op; open_channel wraps call_open_op + adopt_channel.
The pre-047 ChannelDirection, Channel { channel_id, source } struct,
and subscribe_resources method are deferred (OQ-40, OQ-41). The
ResourceEntry.access preview was dropped by ADR-047 §6 — it is
available via services/schema on the op spec.
The early-arrival park bound (push-first producers)
The open-op response carrying channel_id races the producer's first
data-plane writes: the producer's handler can start pumping before the
consumer's adopt_channel runs. The demux parks those first chunks
per-channel (FIFO) instead of dropping them, and adopt_channel drains
the parked chunks into the new receiver — a push-first producer (a TTY
backend's banner, a sub protocol's greeting) must not lose its first
chunks.
The park is bounded: up to 64 chunks per channel are parked
(EARLY_ARRIVAL_CAP, ADR-040's memory bounds — bounded further by the
receiver's buffer capacity when the connection is constructed with a
smaller buffer_cap); chunks arriving past the cap are dropped
silently — at the consumer this presents as a truncated stream with
clean framing everywhere else, not as an error. Consumer-facing
consequences (review 006 E-04, noted for tunnel-style consumers):
- Size any pre-adopt buffering math (e.g. a UDP POC's MTU-vs-buffer sizing) against 64 parked chunks as the observable bound, and adopt promptly — the open reply resolves before the first data arrives by design, so the adopt is the consumer's next step, not a slow path.
- The producer side observes both halves of the race via
ChannelManager::early_arrival_count()(chunks parked) andChannelManager::dropped_unknown_chunks()(chunks lost to the cap) — a non-zero dropped counter under a push-first producer means the adopter was too slow for the producer's burst, and the affected streams were truncated at the park boundary.
Adoption-window ordering (review 013): the install and its drain of
parked chunks are one atomic section inside adopt_channel, and
route_payload parks only while that section's lock is free. A parked
chunk is therefore always pushed into the new receiver before any
direct route can begin, and a park can never straddle the install —
the reassembled byte stream preserves wire-arrival order through the
adoption window, whatever scheduling interleaves the demux and the
adopter.
Transport-agnostic by construction
ChannelClient is the client side of the channels protocol. The channels
protocol is transport-agnostic (ADR-034 substrate modes, as amended by
ADR-035; Connection::from_bidi/from_source from ADR-007/070/092 take
any AsyncRead + AsyncWrite). The client side must not be welded to a
transport — that would repeat the server-side welding ADR-007 explicitly
unwound.
from_connection(connection: Connection) is the primary constructor and
the one-way-door API surface. It takes a pre-established Connection and
takes over channels establishment. The transport is the caller's concern:
Connection::from_bidi(tls_stream, ...) for TCP+TLS, a quinn Connection,
a WebTransport BiStream, an SSH direct-tcpip channel wrapped via
from_bidi, a WebSocket carrying alk/channels (the browser path per
alknet ADR-044 — websocket-native session, provenance) — all produce a Connection that from_connection accepts
unchanged. This mirrors the server side's ChannelsAdapter::handle(Connection), which is substrate-agnostic by the same mechanism.
The dial (QUIC, TCP+TLS, iroh) lives in the composition layer's dial
helper, AlknetClient (today in the alknet glue; future owner OQ-43 —
ADR-045), not on ChannelClient. Callers compose
AlknetClient::dial_quic(...).await? + ChannelClient::from_connection(conn).await?
— two lines, the dial then the take-over. Keeping the dial off
ChannelClient avoids alkcall depending on a dial crate;
the protocol crates are parallel to the dial, not downstream of it.
The credential/verifier-selection rule (ADR-054, re-owned from the
source repo's alknet ADR-034) lives in the dial
(AlknetClient), not in from_connection — from_connection receives
an already-established, already-authenticated Connection, exactly as
ChannelsAdapter::handle does on the server side.
Bidirectionality preserved
The channels protocol is bidirectional — either side can open a channel
(ADR-037 §direction semantics). The connection owner (the side that holds
the ChannelManager) allocates channel_ids (ADR-047 §5). The connect
side calls per-ALPN open ops on channel 0; the accept side allocates the
channel_id and responds. Both sides can initiate — the connect side
calls the open op, the accept side could also call open ops on the
connect side's channel 0 (if the connect side registers any).
ChannelClient is one endpoint of a bidirectional channels connection. The
name follows the CallClient convention (the side that dialed), not a
request/response role.
Relationship to the composition layer's dial helper
ChannelClient's API is transport-agnostic — from_connection takes a
pre-established Connection. The shared dial+TLS seam is the
composition layer's dial helper, AlknetClient (ADR-045; today in the
alknet glue, future owner OQ-43), which
provides three dial methods (dial_quic /
dial_tcp_tls / dial_iroh), each producing a Connection that
from_connection consumes. The dial is transport-specific (QUIC,
TCP+TLS, iroh); the take-over (from_connection) is
transport-agnostic. The two concerns are separated.
AlknetClient::dial_quic is the dial that feeds from_connection. A
caller that needs transport selection (QUIC with TCP+TLS fallback) uses
the dial helper directly; the fallback policy is a caller concern. See
ADR-045 for the
full decision.
Design Decisions
All design decisions are documented as ADRs in decisions/.
| ADR | Decision | Summary |
|---|---|---|
| 043 | ChannelClient | Client side; transport-agnostic from_connection primary; dial lives in the composition layer's dial helper (ADR-045) |
| 035 | channels Pure Channel Multiplexing | No stream_types on open_channel/Channel; handler owns sub-stream multiplexing |
Open Questions
- Dial seam ownership (ADR-045): the dial helper (
AlknetClient) lives in the composition layer today; its post-alknet owner and shape is OQ-43.ChannelClient's API is transport-agnostic (from_connection); the dial is the shared seam.
References
- ADR-043: ChannelClient (the decision)
- ADR-035: channels pure channel multiplexing (no
stream_types) - ADR-037: channel lifecycle operations (
open_channelsends per-ALPN open ops) - ADR-038: ChannelBidiStreamSource (what
Channel.sourcewraps, as amended by ADR-035 —accept_biyields aBiStream) - ADR-039: ChannelManager (the shared state
ChannelClientholds) - ADR-047: openable ALPNs are operations (per-ALPN open ops dissolve
the generic
channel/open) - ADR-045: the dial seam (dial out of the protocol crate)
- client-and-adapters.md —
CallClient(the shapeChannelClientmirrors)