The single STREAM_CONTROL = 3 was documented as bidirectional but the
adapter had to ignore Exit from the client because the two directions
were indistinguishable on the same stream_type — half-duplex in
disguise. Phase 7 splits it into two halves so the bidirectionality is
literal on the wire.
Changes:
- wire.rs: STREAM_CTRL_IN = 3 (client→server), STREAM_CTRL_OUT = 4
(server→client); InvalidStreamType bound > 3 → > 4; Chunk::control
→ Chunk::ctrl_in/ctrl_out; ChunkWriter::write_control_json →
write_ctrl_in_json/write_ctrl_out_json; tests split accordingly.
- control.rs: ControlMessage doc updated with the stream_type column;
JSON shape unchanged.
- adapter.rs: pump_client_to_backend dispatches on STREAM_CTRL_IN
(Resize/Signal/Eof; Exit on ctrl_in is a protocol violation,
ignored); send_exit_chunk emits on STREAM_CTRL_OUT; STREAM_CTRL_OUT
from the client is a protocol violation, ignored. 3 new tests for
the direction enforcement; existing tests updated to the new
stream_types.
- negotiation.rs: framing-disambiguation doc updated (server-sent
stream_type set is {1, 2, 4}).
- alknet-tty-local/tests: common/mod.rs, pty.rs, pipe.rs updated to
the new constants.
Specs:
- ADR-052 amended (§4a 'Control channel split (Phase 7 amendment)').
- tty-wire.md + tty-adapter.md updated (last_updated 2026-07-18).
Verification:
- cargo test -p alknet-tty: 65 passed (was 61; +4 new tests).
- cargo test -p alknet-tty-local: 19 passed.
- cargo test --workspace --all-features: 1017 passed, 0 failed.
- cargo clippy --workspace --all-features: clean.
- cargo fmt --all: clean.
Implement ADR-092 across the workspace: accept_bi/open_bi return BiStream
(a concrete AsyncRead + AsyncWrite + Send + Unpin newtype), not the split
(SendStream, RecvStream) pair. The join moves into core's BidiStreamSource
impls (quinn/iroh via tokio::io::join, single-stream via boxed AsyncReadWrite);
handlers receive the joined BiStream and never see the pair.
Core (alknet-core/src/types.rs):
- Add concrete BiStream struct boxing Box<dyn AsyncReadWrite + Unpin>,
with AsyncRead + AsyncWrite impls. from_joined (pub, for downstream
crates that produce split halves naturally — channels reassembly, tests)
and from_bidi (pub(crate), for Connection::from_bidi) constructors.
- Change BidiStreamSource::accept_bi/open_bi return types from
(SendStream, RecvStream) to BiStream. Update QuinnBidiStreamSource,
IrohBidiStreamSource, StreamBidiStreamSource impls to do the join once.
- Collapse SendStream/RecvStream to thin newtypes over
Box<dyn Async* + Send + Unpin>. Remove SendStreamKind/RecvStreamKind
enums and the quinn/iroh per-call dispatch (the join happens once in the
BidiStreamSource impl now). Keep SendStream::from_stream /
RecvStream::from_stream per-half boxing for into_sub_streams() (ADR-074)
and the future channels reassembly path.
- Remove Connection::from_stream (split-pair constructor). Promote
Connection::from_bidi to the only public stream constructor (the rule:
the split never crosses a crate boundary as part of a constructor).
- Update Connection::accept_bi/open_bi to return BiStream. Update
from_source_tests and tests modules to use from_bidi and BiStream;
add a SinkEmpty test helper (AsyncRead EOF + AsyncWrite discard) for
Connection-level-only test connections.
alknet-http (server/adapter.rs):
- Drop the 44-line QuicStream wrapper — accept_bi returns BiStream which
is already AsyncRead + AsyncWrite. HttpAdapter::handle becomes 4 lines.
- Drop the 38-line QuicStreamDuplex test helper — tests use a single
tokio::io::duplex whose ends are each AsyncRead + AsyncWrite natively.
- Remove unused std::io / std::pin::Pin imports.
alknet-tty (adapter.rs):
- TtyAdapter::handle splits the BiStream from accept_bi via
tokio::io::split for drive_session's separate AsyncWrite/AsyncRead args
(the stdlib idiom for TcpStream-style duplex streams).
alknet-call (protocol/*, client/*):
- Dispatcher::run_loop accept_bi site: take BiStream, pass to handle_stream.
- Dispatcher::handle_stream signature: take BiStream, split internally via
tokio::io::split (was: take SendStream + RecvStream separately).
- CallConnection::call_with_payload / subscribe_with_payload / write_envelope:
split the BiStream from open_bi via tokio::io::split at the call site.
- write_request / read_stream_until_closed: generic over AsyncWrite/AsyncRead
(were: concrete SendStream/RecvStream) — accepts the ReadHalf/WriteHalf
from tokio::io::split directly.
- Add protocol/test_support.rs with sink_empty_connection() (replaces the
5 duplicated stub_connection() fns that used Connection::from_stream).
- Update all test stubs (call_client.rs, protocol/connection.rs,
protocol/dispatch.rs, protocol/adapter.rs, client/from_call.rs) to use
Connection::from_bidi + the shared sink_empty_connection() helper.
- Test handle_stream call sites: build BiStream::from_joined(recv, send)
from the existing BufReader<Cursor> + duplex pair.
Workspace test status: all 9 crates pass (116 + 307 + 18 + 3 + 17 + 301 +
34 + 61 + 23 + 5 + 6 + 8 + 82 + 4 + 3 + 6 + 12 + 1 = 1007 tests pass). One
pre-existing failure remains in alknet-http
(adapters::to_mcp::tests::search_returns_access_control_filtered_ops_excluding_subscriptions
— handler kind mismatch, unrelated to Phase 6, fails on develop baseline).
ADR-054 prescribes with a re-export
module, but cargo rejects the cyclic dependency (alknet-tty →
alknet-tty-local → alknet-tty) even with an optional dep. The re-export
is deferred to the assembly layer: consumers depend on alknet-tty-local
directly and register LocalTtyBackend in the TtyAdapter backend map.
The feature gate stays declared (empty) for forward compatibility.
Assembly pattern documented in the crate root doc comment.
Implement TtyAdapter (ProtocolHandler on alknet/tty) and the
drive_session three-pump bidirectional driver in adapter.rs.
TtyAdapter holds a HashMap<String, Arc<dyn TtyBackend>> keyed by the
negotiation frame's backend string and an optional OwnershipProvider.
handle() loops accept_bi and spawns drive_session per stream.
drive_session proceeds in three phases:
1. Negotiation — read length-prefixed JSON, parse NegotiateRequest,
validate (carriage == raw, cmd non-empty), look up backend, scope-gate
(tty:open), resource ownership check via backend.resource_id() +
OwnershipProvider::owns(), construct TtyParams. Errors sent as JSON
in negotiation framing, stream closed.
2. Allocation — backend.allocate(¶ms). On failure send
allocate_failed error, close.
3. Raw carriage — three concurrent pumps:
A. stdout/stderr → client (stream_type 1/2), zero-length sentinel on EOF
B. client → backend: stdin chunks → TtyHandle.stdin, control chunks →
Resize/Signal/Eof dispatch (Exit ignored, unknown types ignored)
C. exit → exit chunk: await exit_code; on resolve enqueue exit chunk
Exit-chunk-is-last invariant (ADR-055): adapter waits for BOTH
stdout/stderr pumps to complete AND exit_code to resolve before
enqueueing the exit chunk (tokio::join!). On TtyError → code -1.
Cancel cleanup (ADR-056): on connection drop/stream reset, pump tasks
drop, TtyHandle drops, exit_code future drops without resolve →
backend kill-on-Drop fires. Client write-half close is NOT a cancel —
session runs to completion.
Access control (ADR-050): scope gate at negotiation (tty:open scope,
configurable constant), resource ownership via backend.resource_id() +
OwnershipProvider::owns() when wired.
18 integration tests with a TestBackend fixture over tokio::io::duplex:
happy path, exit-chunk-is-last, stdin EOF, resize/signal control,
unknown control ignored, exit control ignored, unknown_backend,
malformed_negotiation (bad JSON, carriage != raw, empty cmd),
allocate_failed, exit error (-1), cancel cleanup, scope gate forbidden,
ownership check deny/allow, stderr pump, eof control closes stdin.
Also fix two clippy unused_mut warnings in negotiation.rs tests.
Replace placeholder negotiation types with the full Phase 1 wire shape:
- NegotiateRequest/TerminalParamsWire with serde(flatten) capturing
backend-specific fields into backend_params
- NegotiationReader/Writer: self-contained 4-byte BE length-prefixed
framing on tokio AsyncRead/AsyncWrite, bounds-checked against MAX_CHUNK_LEN
- NegotiationError (Io, ConnectionClosed, FrameTooLarge, Json) via thiserror
- error_response_bytes helper for server-side JSON error frames
- into_inner reclaims the stream for raw-chunk use
- Error frames stay under 16 MiB so the high byte of the length prefix is
0x00, making the framing-disambiguation trick sound (ADR-052 §5)
Unit tests cover round-trip, serde(flatten) capture, FrameTooLarge,
ConnectionClosed on truncated header/body, error-response shape, and the
0x00-first-byte framing-disambiguation invariant. backend.rs's
From<NegotiateRequest> for TtyParams still compiles (same field shapes).
backend.rs defines its own BoxFuture type alias
(Pin<Box<dyn Future + Send>>) using std::future::Future + futures_core,
so the futures crate is unused. Removes the dead dependency.
Port the POC's control channel schema (alknet-tty-poc/src/control.rs)
into crates/alknet-tty/src/control.rs: the ControlMessage tagged enum
(Resize/Signal/Eof/Exit) with snake_case type tag, to_json/from_slice
helpers, and the Unix-only signal_from_name mapping the 9 supported
signal names to libc numbers. Unknown type tags return a serde_json
error; the adapter (later task) ignores that error per the wire spec's
extensibility policy. Adds libc as a target.'cfg(unix)' dependency.
Refs: docs/architecture/crates/tty/tty-wire.md §"Control Channel"
Refs: docs/architecture/decisions/055-exit-code-on-control-chunk.md
Create crates/alknet-tty with Cargo.toml (alknet-core path dep, tokio,
bytes, futures-core, tokio-stream, serde, serde_json, async-trait,
tracing, thiserror — no portable_pty/bollard/russh/alknet-call per
ADR-057), the `local` feature gate (dep wiring deferred to
tty/local-feature-reexport), src/lib.rs with the crate doc comment and
module declarations, and skeleton modules (wire, control, negotiation,
backend, adapter) with doc comments and TODO markers. Add the crate to
the workspace members list.
cargo check, clippy -D warnings, and fmt --check all pass.