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).
Fixes the Pub operation's client→responder path so a publish() actually
works on any transport. Previously the wire was broken in three
compounding ways and the unit tests couldn't see it.
P-01 [critical] — publish chunks were written to a *fresh* open_bi
stream (stream B) while the responder read chunks from the stream that
carried call.requested (stream A). Stream B's frames were silently
discarded by the dispatch loop's "ignoring non-requested event" branch.
On single-stream transports (TCP+TLS, SSH) the second open_bi returns
StreamClosed outright, so every publish failed. Fix: pump call.requested
+ call.published + call.completed on the *same* write half via the new
pump_publish_to_wire free function; the read half is pumped concurrently
for the single call.responded.
P-02 [major] — publish() / publish_with_payload() now take
Pin<Box<dyn Stream<Item = Value> + Send>> per ADR-046 §8, not Vec<Value>.
The Vec shape buffered the entire publish in memory and made the ADR's
flagship use cases (telemetry ingest, live upload, drag-drop file
streaming) impossible. The wire format is unchanged; this corrects API
drift (no deployments exist).
P-05 [major] — publish now registers with timeout: None (like
subscribe), not DEFAULT_CALL_TIMEOUT (30s). A publish whose stream took
>30s wall-clock got a spurious client-side TIMEOUT while the responder
kept consuming. PendingEntry::Call.timeout is now Option<Instant> so the
sweeper never evicts unbounded calls; all register_call callers updated
(Some(...) for call(), None for publish()).
P-08 [major] — the dispatch Pub branch re-implemented invoke_sink's
not-found / visibility / ACL / handler-kind checks inline; invoke_sink
was only ever called from its own tests. Any future fix in invoke_sink
(e.g. the missing publish_schema validation, P-03) wouldn't reach the
wire path. Fix: extract OperationRegistry::resolve_sink_handler
(pub(crate)) as the single source of truth for the sink dispatch checks;
both invoke_sink and Dispatcher::dispatch (Pub branch) call it. The two
paths can no longer diverge.
P-09 [major, side-effect] — make_sink_forwarding_handler in from_call.rs
was store-and-forward (collect into Vec) and silently discarded Err
items (filter_map(|item| item.ok())), contradicting the SinkHandler
contract that an Err terminates the stream. Now that
publish_with_payload takes a Stream, the forwarding handler passes the
stream through directly (streamed, not buffered) and uses
take_while(Ok) + filter_map to terminate on Err. P-09 was listed in
Unit 6 but depends on P-01/P-02, so it falls out naturally here.
P-12 #1 [acceptance gate] — adds the end-to-end publish test that the
review identifies as the gate for this unit:
publish_end_to_end_delivers_chunks_and_returns_response wires
CallConnection::publish ↔ Dispatcher::run_loop over a real
tokio::io::duplex pair (new duplex_connection_pair test helper +
SingleStreamSource BidiStreamSource impl), publishes 3 chunks, and
asserts the responder saw all 3 and returned the right result. A second
test covers the unknown-op → NOT_FOUND path. These tests would have
caught P-01 immediately.
Verification:
- cargo test --lib → 434 passed, 0 failed (was 432; +2 e2e tests)
- cargo clippy --all-targets -- -D warnings → clean
- cargo fmt --check → clean
- cargo doc --no-deps → 4 warnings (pre-existing C-09, unchanged)
Consolidates three code-review passes (one main summary + sub-review A
on ADR-046 + sub-review B on channels ADRs 034–043/047) into a single
verified document under docs/reviews/. Every finding was re-verified
directly against the source at f305f8c with exact file:line refs.
Findings (all verified): 10 critical, 19 major, 7 minor.
- Pub end-to-end cannot work (chunks written to a different bi-stream
than the request); publish() takes Vec<Value> not a Stream; 30s
client timeout; abort-doesn't-cancel-Pub; dispatch re-implements
invoke_sink inline; from_call sink forwarding swallows errors.
- Channel 0 dead in both directions; register_openable absent;
resolve_channel_manager stub; backpressure drops chunks; buffer cap
in messages not bytes; ledger decrement on 1/4 teardown paths; demux
desyncs on oversized chunk; no channel-adoption/collision scheme.
- channel/control + resources/subscribe stubs; busy-wait spin; lost
EOF sentinel; TOCTOU on max_channels; ~50 lines of abandoned
deliberation in poll_write; cargo doc 4 warnings; spec docs
inconsistent post-047.
Includes a 6-unit remediation plan sequenced by dependency, with
acceptance gates. Units 1/2/4 need no spec decisions; Units 3 and 5
each need one written ADR decision first (ADR-047 §4 env-resolution;
§5 channel-id adoption). The overarching acceptance gate is the
end-to-end ChannelClient ↔ ChannelsAdapter test whose absence let
both commits land green.
Two corrections to the original reviews noted in the verification log:
- C-17 (lost EOF sentinel): Sub Review B overstated "the pump does not
write an EOF chunk" — the pump does write EOF when it receives the
sentinel; the real bug is narrower (sentinel lost on full buffer →
pump exits on recv→None without writing EOF).
- Warning count: main review said 5 cargo doc warnings; this pass sees
4 (2× register_openable, 1× default_policy, 1× env module/macro).
Verification:
- cargo test → 432 passed (no source changed; docs-only commit)
- cargo clippy --all-targets -- -D warnings → clean
- cargo fmt --check → clean
- cargo doc --no-deps → 4 warnings (documented as C-09; not addressed
here — fixing them requires the register_openable method to exist,
which is Unit 3 of the remediation plan)
The call protocol had Subscription (server→client streaming) but lacked
the directional complement: client→server streaming, where the initiator
produces a stream and the responder's handler consumes it. This gap was
inherited from the @alkdev/pubsub EventEnvelope prior art, which has
subscribe but no wire-level publish.
ADR-046 adds the Pub primitive:
- OperationType::Pub (client→server streaming)
- OperationType::Subscription renamed to Sub (wire: "subscription" → "sub")
- SinkHandler type + HandlerKind::Sink variant
- PublishStream type alias (Stream<Item = Result<Value, CallError>>)
- OperationRegistry::invoke_sink() dispatch path
- call.published wire event (sixth event type, additive)
- OperationSpec.publish_schema (Option<Value>, validates per-chunk input)
- DispatchResult::Sink + SinkDispatch (handler future + chunk channel)
- Dispatcher::pump_sink (feeds call.published chunks from wire to handler)
- CallConnection::publish() / publish_with_payload() client methods
- from_call sink forwarding handler (make_sink_forwarding_handler)
- make_sink_handler() helper
Fan-out/broker (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. The Pub primitive is the
load-bearing piece the broker will compose on.
- 23 new tests (366 total, up from 343)
- clippy clean, fmt clean
Verification:
cargo test — 366 passed
cargo clippy --all-targets -- -D warnings — clean
cargo fmt --check — clean
Update doc comments referencing the old alknet-* crate names and
spec paths to reflect the alkcall home. ALPN strings (alknet/call,
alknet/test) are wire-stable and unchanged. Sibling crate refs
(alknet-http, alknet-core historical context) kept as-is — those are
accurate references to crates that still exist or will be reworked
later.
Also fixes the pre-existing broken intra-doc link [`CallAdapter`] in
dispatch.rs (now [`super::adapter::CallAdapter`]) — the only doc
warning carried over from the extraction.
Verification:
- cargo test: 343 passed (0 failed)
- cargo clippy --all-targets -- -D warnings: clean
- cargo fmt --check: clean
- cargo doc --no-deps: clean (0 warnings, was 1)