Adopt alkcall 0.5.0's channel-open establishment phase (ADR-049 — review 006 E-01 + N-1) and migrate the channels-path semantic failures per its §5 sequencing (alktty ADR-010). - `register_openable` registers `channels/tty/sub` with an establisher (`register_openable_with_establisher`): full `NegotiateRequest` parse of schema-valid `input`, `carriage == "raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership check run before the open reply; rejections are `channel:open_failed` with `details.reason` (`unknown_resource` / `handler_error` / `timeout`) — no phantom channel (the SSH contract holds consumer-visibly) - `backend.allocate` deliberately stays in the pump handler: `Establishment` is payloadless so the `TtyHandle` cannot cross the establisher→handler boundary, and re-allocating would violate ADR-005's kill-on-Drop contract — `allocate_failed` remains the one in-band failure class on the channels path (pinned by test) - `TtySessionError::ChannelsOpen` carries alkcall's typed `ChannelOpenError` (`#[from]`) instead of a flattened `String` — the N-1 fix at alktty's layer (breaking) - channels-path semantic failures change shape from `NegotiationRejected` in-band frames to `channel:open_failed` call errors (breaking); the direct-ALPN path is unchanged - `tty_open_spec()` gains a `description` (review 006 E-02) and an ErrorDefinition for `channel:open_failed` (ADR-016 — disclosed via services/schema) - alkcall = "0.5.0"; version 0.2.0; ADR-010 + ADR-009 amendment + tty-adapter.md + CHANGELOG Verification: cargo test (112 lib + integration), cargo test --all-features (136), clippy --all-targets -D warnings (host + wasm), fmt --check, cargo doc --no-deps clean; wasm32-unknown-unknown check confirms the default crate stays wasm-clean.
11 KiB
11 KiB
Changelog
All notable changes to this crate are documented here. The format is based on Keep a Changelog, and this crate adheres to Semantic Versioning.
[0.2.0] - 2026-09-06
The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the
channels-path establishment phase. Breaking on the channels path's
error surface; the wire format, the direct-ALPN path, and the
TtyBackend trait are unchanged.
Added
- Channels establishment phase (ADR-010; alkcall 0.5.0 ADR-049 /
review 006 E-01).
register_openablenow registerschannels/tty/subwith an establisher (ChannelCore::register_openable_with_establisher): the semantic validation that used to be post-open in-band error frames — the fullNegotiateRequestparse of schema-validinput,carriage == "raw", non-emptycmd, backend lookup, and the ADR-050 ownership check — runs before the open reply. Rejections resolve the open op withchannel:open_failedcarryingdetails: { reason, message }(unknown_resourcefor an unknown backend,handler_errorfor malformed-negotiation/ownership-denial,timeouton the establishment deadline) and no channel ever exists opener-side — the SSH contract. The phantom-channel workaround (allocate → succeed → in-band-fail) is retired. channel:open_failedErrorDefinition ontty_open_spec()(ADR-016). The establishment-failure contract (the reachabledetails.reasonenum) is disclosed viaservices/schema; the spec also gains adescription(review 006 E-02) disclosed viaservices/list.- New tests: the establisher unit gates (parse/carriage/cmd/backend/
ownership mappings), the end-to-end
channel:open_failedsurfaces (the review-006 verification gate), and a pinned test guarding the one failure class that stays in-band (below).
Changed
- BREAKING —
TtySessionError::ChannelsOpencarries alkcall's typedChannelOpenError(#[from]) instead of a flattenedString(alkcall 0.5.0 ADR-049 §4 / review 006 N-1, applied at alktty's layer). Consumers branch onChannelOpenError::establishment_reason()instead of parsing a debug string. - BREAKING — channels-path semantic failures change shape. An
unknown backend (and schema-valid-but-unparseable params,
ownership denial) previously surfaced as
TtySessionError::NegotiationRejected(an in-band0x00-prefixed error frame on a channel the open op had already reported as succeeding); they now fail the open op itself asTtySessionError::ChannelsOpen(CallFailed{ channel:open_failed })(ADR-010). The one failure class that still arrives in-band isallocate_failed(stillNegotiationRejected): the establisher cannot carry the allocatedTtyHandleacross to the pump handler (Establishmentis payloadless), and re-allocating would violate ADR-005's kill-on-Dropcontract — revisit when alkcall givesEstablishmenta payload. The direct-ALPN path's failure surface is unchanged (two transports, two contracts). alkcall = "0.5.0".
Fixed
- No more phantom channels on the channels path. Ledger, policy count, and manager state balance on every establishment rejection (the wrapper's teardown runs before the reply), instead of allocating a channel that immediately EOFs with an error frame.
0.1.0 - 2026-09-05
Initial crates.io release: the alk/tty terminal-session protocol —
producer/consumer protocol crate on top of alkcall channels, ported and
consolidated from the alknet-tty + alknet-tty-local crates of the
alknet mono-repo.
Added
- Wire format (ADR-001). Two-carriage protocol: a 4-byte
big-endian length-prefixed JSON negotiation frame (
NegotiateRequest—carriage/backend/tty/cmd/cwd/envplus opaqueserde(flatten)backend params), then raw chunks ([stream_type: u8][length: u32 be][payload]) with five stream types (STREAM_STDIN=0,STREAM_STDOUT=1,STREAM_STDERR=2,STREAM_CTRL_IN=3,STREAM_CTRL_OUT=4). Zero-length data chunks are sentinels (zero-length stdin = client EOF; zero-length stdout = server drained); control chunks are never zero-length. Payloads are capped at 16 MiB (MAX_CHUNK_LEN) — this keeps the length prefix's high byte0x00, which is the disambiguation invariant between an error frame and a raw chunk (ADR-001 §5). The framing is self-contained (ADR-006): not reused from alkcall'sEventEnvelopeframing. The control channel is split intoSTREAM_CTRL_IN/STREAM_CTRL_OUTso it is genuinely bidirectional on the wire (the Phase 7 amendment inside ADR-001). TTY always uses its own 5-byte chunk format inside channels — the channels layer carries it transparently in the channel payload (ADR-008, reversing ADR-007). The machine-readable contract is the BAST document (docs/architecture/tty-bast.md); a unit test guards the BAST'sStreamTypeenum against drift from the wire constants. TtyBackendtrait (ADR-002). The inversion point between the wire-format adapter and backend crates.allocate(&TtyParams) -> TtyHandle(stdinAsyncWrite, stdout/stderrStream<Item = Bytes>,exit_code: BoxFuture,TtyControlHandlefor resize/signal);resource_id(&TtyParams) -> Option<(kind, id)>feeds the ADR-050 ownership check.TtyErroris#[non_exhaustive]. The trait shape is a one-way door once backends exist.- Local backend behind the
localfeature (ADR-003). Thealknet-tty-localcrate folded in as a feature-gated module: PTY mode viaportable_pty(real terminal semantics — resize, process-group signal forwarding, merged stdout/stderr) and pipe mode viatokio::process::Command(the runner case — separate stdout/stderr, no-op resize, pid-only signal), selected byTtyParams::terminal. The blocking→async bridge uses three dedicated std threads feeding tokio mpsc/oneshot channels. Non-wasm by design; the default crate (no features) stayswasm32-unknown-unknown-clean. TtyAdapterproducer (directalk/ttyALPN). AProtocolHandlerholding aHashMap<String, Arc<dyn TtyBackend>>keyed by the negotiation frame'sbackendstring. Accepts a connection, loopsaccept_bi, and dispatches each bidi stream to a session. Per-stream flow (ADR-001/002/005): parse + validate the negotiation frame (carriage == "raw", non-emptycmd), scope-gate viatty:open, optionalOwnershipProviderresource check,backend.allocate(), then three concurrent pumps (stdin→backend, stdout/stderr→client, exit→exit chunk) with the exit-chunk-is-last invariant —{"type":"exit","code":N}(-1on wait failure, ADR-004; negative = signal-terminated) is enqueued only after both stream pumps complete andexit_coderesolves. A concurrent drainer task owns the client write half, so a burst of >64 chunks from the backend cannot deadlock the session (the drainer starts before the pumps join; the single FIFO preserves exit-chunk-last). On session cancel theTtyHandledrops without drivingexit_codeto completion, which triggers the backend's kill-on-Dropguard — the adapter has no separate kill path (the session-cancel contract, ADR-005: dropping the future kills the session target).channelsintegration (ADR-008, ADR-009).register_openable+tty_open_specregister thechannels/tty/subopen op on a per-connectionOperationRegistry(per ADR-047's per-connection amendment): the registry'sAccessControlcarries thetty:openscope gate, the op's input schema validates the sharedNegotiateRequestfields (carriage/backend/cmdrequired; alkcall 0.4 enforces at dispatch), and theOpenHandlerspawns the session driver pre-negotiated — the open op's params ARE the negotiation (ADR-009), no second frame on the channel stream. Access control is enforced by the registry before the handler runs; the handler validates the backend exists and drives the session.TtySessionconsumer. The typed client handle with two constructors —connect_direct(directalk/ttyconnection: writes the negotiation frame, then typed methods) andopen_via_channels(openschannels/tty/subon aChannelClient, adopts the channel, starts in raw-chunk mode). Typed methods:send_stdin/close_stdin(stdin chunks / EOF sentinel),recv_stdout/recv_stderr(Stream<Item = Bytes>),resize,signal, andwait(awaits theExitcontrol chunk; watch-based, no lost wakeup).recv_stdoutends ON the zero-length drained sentinel (it is not yielded as an item). Both constructors disambiguate the first response frame (0x00prefix = negotiation error frame →NegotiationRejectedwith the server's error code; raw chunk = session proceeds); a refused session open surfaces asTtySessionError::Opencarrying the upstream error type.TtySessionErroris#[non_exhaustive]. Dropping the session aborts the read pump and closes the write half.- Control messages.
ControlMessage(Resize/Signal/Eofonctrl_in;Exitonctrl_out) — JSON, tagged by"type", unknown types ignored by policy.signal_from_namemaps the common signal set (HUP/INT/QUIT/TERM/KILL/USR1/USR2/TSTP/CONT) to libc numbers (unix). - Crate-root re-exports. The primary types (
TtyAdapter,TtySession,TtyBackend,TtyHandle,TtyParams,Chunk,NegotiateRequest,ControlMessage, the wire constants, andLocalTtyBackendunderlocal) are re-exported at the crate root; the module paths remain the full surface. - Wire write-path validation. All
ChunkWritermethods validate before touching the transport — astream_type> 4 or a payload overMAX_CHUNK_LENfails locally instead of writing a header the peer cannot frame (the length is validated before theu32cast, so an oversized payload cannot truncate past the check and corrupt the peer's framing). Empty-payload writes are one shape:length == 0with no payload bytes. - Peek-safe
ChunkReader. The reader tracks peeked state:read_chunk()afterpeek_stream_type()completes the peeked chunk instead of consuming a second header byte (which silently desynchronized framing), and a second peek returns the peeked byte without reading.read_chunk_after_peekremains the explicit form. - Backpressure regression test. >64-chunk sessions are driven
through the real adapter with a per-read timeout so a deadlock
regression fails the test instead of hanging it, plus exact-boundary
(
MAX_CHUNK_LEN) round-trip and server-sends-client-direction- stream-types discard tests. - PTY bridge robustness (
localfeature). The stdin sink's EOF (poll_shutdown) parks an in-flight send on a full channel so the waker is registered — a stdin blast followed by EOF always delivers the EOF to the child. Lock-poisoning no longer cascades (the five lock sites adoptinto_inner()), and thread-spawn failure maps toTtyError::AllocFailedinstead of panicking. - Producer hardening. The
tty:openscope gate runs before the backend lookup (an unscoped identity getsforbiddenregardless of the request body — no backend-name enumeration differential), and the client→backend pump is aborted at session end instead of lingering until the client disconnects.