- version 0.8.1 -> 0.8.2 (patch: behavior-only bug fix in the local
feature's PTY waiter from db0b1fc; ADR-004 decision 1's negative-code
convention now holds on the PTY path; no API, wire-format, or
dependency change)
- CHANGELOG entry for 0.8.2 (review #004 resolution: the waitpid reap
mechanism, the signal round-trip notes, the tests, the docs)
- finish the em-dash sweep in prose 58da42a missed: doc comments in
tests/ (negotiation, pipe, pty, common) and fuzz/shared
(control_json, negotiation_frame, session_opseq); prose rewrites
only, no code, JSON literals, or wire shapes touched
- README checked: no version pins, nothing to update
Verification: cargo test; cargo test --all-features; cargo clippy
--all-targets -D warnings (default and --all-features); cargo fmt
--check; cargo doc --no-deps; fuzz/shared corpus replay (9 seeds pass);
cargo check + clippy for wasm32-unknown-unknown -D warnings; cargo
publish --dry-run --allow-dirty (43 files, 194.4 KiB); detached
campaigns over the four targets (chunk_frame 632s, negotiation_frame
602s, control_json 610s, session_opseq 608s) all exiting 0 with
0 crash/oom/timeout findings.
30 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.8.2 - 2026-11-02
The PTY-path exit-signal fidelity fix (review #004, consumer input from
alkssh 0.1.0): a behavior-only bug fix in the local feature's PTY
waiter. The pipe path already conformed; the wire format, the
TtyBackend trait, the adapter, the session, and alktty's public API
surface are unchanged. Patch release per the bug-fix rule: the fix
brings the PTY path into conformance with the convention ADR-004
decision 1 already documents (consumers that read the exit chunk,
notably alkssh, already consume negative codes verbatim, with no
contract change needed).
The finding: portable-pty 0.9.0's ExitStatus flattens a
signal-terminated child to code = 1 with the signal surviving only as
a strsignal name string, so the waiter's status.exit_code() as i32
reported a SIGTERM death as {"type":"exit","code":1},
indistinguishable from a real exit 1, and ADR-004's own -N
encoding (-15 for SIGTERM) was unrecoverable on the PTY path. The
crate's own signal features made this internally inconsistent: REQ-TTY-02's
signal("TERM") (the libc::kill(-pgid, sig) forward) and the ADR-056
cancel cleanup's ChildKiller::kill() (SIGHUP) both ended in
signal-terminated reaps the waiter reported as plain 1.
Fixed
- PTY-path exit-status encoding now honors ADR-004 decision 1: on
Unix a signal-terminated child reports the negative signal number
(e.g.
-15for SIGTERM), Python'sPopen.returncodeparity. The waiter (wait_exit_codeinsrc/local/pty.rs) skips portable-pty's status conversion entirely on Unix and reaps the direct child vialibc::waitpid(pid, ..), decodingWIFEXITED/WEXITSTATUS(normal exit) andWIFSIGNALED/WTERMSIG(signal death →-N). Fully numeric, locale-independent, and exhaustive: no name→signum table, nostrsignalparsing, no upstream ask, no fork. This relies on portable-pty's unix backend returning astd::process::ChildwhoseDropneither kills nor reaps, so the two reap paths never double-reap (Child::wait()remains as the waitpid-failure fallback and the non-Unix primary). The pipe path's encodings are unchanged. - Signal features now round-trip through the exit report.
signal("TERM")resolves as-15on the exit chunk; the ADR-056 cancel cleanup (SIGHUP) resolves as-1, which is the documented ADR-004 sentinel collision (that collision is now common on the cancel path, not just the wait-failure path; the-1→ "could not determine / exit-status(255)" consumer reading stands). - Tests strengthened: the PTY-path pinning now covers
SIGINT→-2,SIGTERM→-15, and the SIGHUP fallback →-1collision. - Docs aligned: ADR-004 decision 1 now references both backends'
encodings, and
tty-local.md, ADR-005, andtty-adapter.mdcarry the mechanism wording (review #004 closed as resolved).
0.8.1 - 2026-10-22
The alkcall 0.10.1 adoption: a dependency bump only; no source change
in alktty. alkcall 0.10.1 is the adoption-window ordering fix (review
013, routed from alkgit's wave-2 gate): a chunk routed by the demux
inside the adopt_channel install→drain window could be delivered
ahead of earlier-parked early arrivals (inverting the channel's
byte-stream order) and a related chunk could sit parked forever past
the drain. alktty adopts channels only as a consumer-side client
(TtySession::open_via_channels, which opens and then adopts the
session channel), so the fix applies to every channels-path session
under multi-process load: the reassembled chunk stream now preserves
wire-arrival order through the adoption window. The surface of the fix
is private to alkcall (ChannelState and the install_locked path):
no API, wire-format, or documented-cap change; the behavior only
strengthens. The direct-ALPN path does not touch the adopt machinery
at all.
Changed
alkcallrequirement updated to0.10.1; lockfile bumped from 0.10.0 to 0.10.1. Version-requirement-only (0.10.1 is inside the0.10caret family; alkcall 0.10.1 changed no type alktty touches, and the fix surface is private). alktty's public signatures reference alkcall types, so downstream crates composing alktty with the fixed alkcall pick this patch bump up on their nextcargo update.
0.8.0 - 2026-10-19
The alkcall 0.10.0 adoption: a dependency bump only; no source change
in alktty. alkcall 0.10.0 is the gateway ownership seam release
(GatewayDispatch::with_ownership_provider, the declared
static-fallback opt-in, and the loud unwired-spine default). All of it
touches surfacing alktty never uses: alktty consults the
OwnershipProvider seam directly on its own paths (the direct-ALPN
scope gate and the channels establisher), not through the gateway
dispatch spine, and the wire surface and OperationContext shape are
unchanged. The wire format, the direct-ALPN path, the TtyBackend
trait, and alktty's public API surface are unchanged.
Changed
- BREAKING (ecosystem-coordination):
alkcallbumped to 0.10.0. alkcall 0.10.0 changed no type that alktty touches: the seam alktty exercises (ChannelCore/register_openable_with_establisher/OpenEstablisher/OpenHandler/Establishment,ChannelClient,OwnershipProvider/OwnershipStore/InMemoryOwnershipStore) is shape-identical to 0.9.0; the loud unwired-spine default applies only toresource_type-gated ops served throughGatewayDispatch, which alktty never registers against. alktty's public signatures reference alkcall types, so downstream crates must now unify on alkcall 0.10.0. Minor bump per the 0.x ecosystem-coordination rule (raising the minimum requirement is breaking, hence 0.8.0, not a patch release). alkcallrequirement updated to0.10.0; lockfile bumped from 0.9.0 to 0.10.0. Verification counts are unchanged from the 0.7.0 baseline (113 default / 137 all-features); the adoption required no source change in alktty.
0.7.0 - 2026-10-14
The alkcall 0.9.0 adoption: a dependency bump only, with no source change
in alktty. alkcall 0.9.0 is the stub-sweep release (the marked-pub open
contract, channel/control, channel/resources/subscribe, and
op/register forward-through are real implementations). All of
alkcall's semver-relevant changes (ManagerError::InvalidAdopt, the
RelayError::PubTypedOpen removal, the register_imported
Result return, Establishment losing Debug, the new
register_openable* refusal of Pub-typed specs) touch surfacing
alktty never uses: alktty's open op is Sub-typed and it only
constructs Establishment (whose new is unchanged). The wire format,
the direct-ALPN path, the TtyBackend trait, and alktty's public API
surface are unchanged.
Changed
- BREAKING (ecosystem-coordination):
alkcallbumped to 0.9.0. alkcall 0.9.0 changed no type that alktty touches: the open-op machinery alktty registers through (ChannelCore/register_openable_with_establisher/OpenEstablisher/OpenHandler/Establishment/ChannelPlan,ChannelClient::open_channel,ChannelOpenError::CallFailed) is shape-identical to 0.8.1: the four stub implementations are net-new surfaces on ops alktty does not exercise, the established-wrapper refusal ofPub-typed specs does not apply to alktty'sSub-typed open op, and the removedRelayError::PubTypedOpenwas never referenced. alktty's public signatures reference alkcall types, so downstream crates must now unify on alkcall 0.9.0. Minor bump per the 0.x ecosystem-coordination rule (raising the minimum requirement is breaking, hence 0.7.0, not a patch release). alkcallrequirement updated to0.9.0; lockfile bumped from 0.8.1 to 0.9.0.
0.6.0 - 2026-09-28
The fuzzing adoption: a cargo-fuzz workspace over the four wire-facing
parse surfaces, with zero findings in the crate; every crash the
campaigns produced was a harness-model gap, and every fix landed in the
harness, not the crate. The alkcall requirement also updates to
0.8.1. No API, wire-format, or behavior change; this release ships no
source change in src/ at all: the deltas are the fuzz workspace,
the dependency bump, and this changelog. Minor bump per the 0.x
ecosystem-coordination rule (alktty's public signatures reference
alkcall types, so downstream crates must unify on alkcall 0.8.1).
Added
- cargo-fuzz workspace under
fuzz/(perdocs/plans/fuzzing.md, adopted from the alkcall fuzzing playbook): four targets over the wire-facing surfaces, with the invariant logic in a stable-toolchain shared crate (fuzz/shared/) so the corpus replays on stable; the nightly pin is scoped to the subtree viafuzz/rust-toolchain.toml:chunk_frame: the 5-byte chunk codec (ChunkReader/ChunkWriter), including the peek/disambiguation seam (peek_stream_typevsread_chunk_after_peekin both call orders must yield exactly one chunk).negotiation_frame: the 4-byte BE length-prefixed negotiation framing +NegotiateRequestJSON parse +error_response_bytes, and the cross-codec invariant that an error-response frame's length-prefix high byte is0x00so aChunkReaderpeek distinguishes it from a chunk (ADR-001 §5).control_json:ControlMessageJSON parse +signal_from_name.session_opseq, the stateful target: a#[derive(Arbitrary)]12-op sequence drives the real producer session pump (drive_session_pre_negotiated) over a duplex pair against a fuzz-local mock backend, asserting the wire-contract invariants (kill-on-Dropper ADR-005, exit-chunk-is-last per ADR-004, lossless per-stream FIFO + single stdout sentinel, stdin prefix-losslessness, control dispatch bounds, take-once allocation, teardown termination).
- Committed corpus: 340 hand-made seeds across the four targets
(deterministic generator
fuzz/gen_fuzz_seeds.py; thesession_opseqseeds are hand-encoded against thearbitrary1.4.x derive layout with the encoding pinned by a decode test). Grown corpus entries are gitignored and excised after campaigns. - The standing fuzz gate:
cargo test --manifest-path fuzz/shared/Cargo.tomlreplays every committed seed through the same invariant functions the targets run, on stable. Added to the verification checklist. - Detached-runner rule: campaigns run via
fuzz/run-detached.sh(setsid/nohup, fork mode, bounded RSS), never in the foreground of an agent session.
Changed
- BREAKING (ecosystem-coordination):
alkcallbumped to 0.8.1. alkcall 0.8.1 is a patch release fixing the duplicate adopt/open channel-state destruction found by alkcall's own fuzz campaign; it changed no type alktty touches. Downstream crates must unify on alkcall 0.8.x per the caret requirement. Minor bump per the 0.x ecosystem-coordination rule. - Root
Cargo.tomlgains a[workspace]table (members = ["."],exclude = ["fuzz"]) so the fuzz subtree's nightly dev-deps never join the root workspace (an MSRV footgun), andfuzz/joins the publish exclude. Verified: the packaged file list is unchanged except for the exclusion (43 files; the fuzz workspace, seeds, plans, reviews, and.opencode/all stay out of the crate).
Verification
- 0 findings across all four targets: detached campaigns (10 min each
on the three wire targets; 10 min on
session_opseqat 42.3k execs, cov 3934 → 4075 and still growing at budget end) plus a 60s smoke run on target 4: no crash/OOM/timeout/leak. - Corpus replay 9 tests green on stable (340 seeds); crate tests 113 default / 156 all-features; clippy stable + wasm; fmt; doc; wasm check: all pass.
- The three crashes the harness-model smoke runs surfaced were harness gaps, each fixed in the fuzz harness (the alkcall §7.7 pattern): a failed client write can mean the session completed and dropped the server duplex half mid-write; the harness drains and requires the exit chunk on any write/shutdown error (a premature close is a finding). No adapter code changed.
0.5.0 - 2026-09-18
The alkcall 0.8.0 adoption: a dependency bump only; no source change
in alktty. alkcall 0.8.0 landed review 008's remediation (the
establisher reply projection, flavor-form open-op ids in discovery, the
in-tree ChannelRelay + HubLegImports/HubLegTemplate) plus a
post-landing hardening sweep; all new surfaces are additive on the
call-plane JSON and the registry seams, and the channels data plane is
untouched. The wire format, the direct-ALPN path, the TtyBackend
trait, and alktty's public API surface are unchanged.
Changed
- BREAKING (ecosystem-coordination):
alkcallbumped to 0.8.0. alkcall 0.8.0 changed no type that alktty touches: the open-op machinery alktty registers through (ChannelCore/register_openable_with_establisher/OpenEstablisher/OpenHandler/Establishment/ChannelPlan,ChannelClient::open_channel,ChannelOpenError::CallFailed) is shape-identical to 0.7.1: the newopen_channel_with_reply/ reply-field / relay / hub-leg surfaces are additive and alktty exercises none of them. alktty's own open op is the standard-shape namechannels/tty/sub, so the new flavor-formchannel_open_alpndiscovery path stays on the byte-stable standard derivation, and with no establisher reply-fields configured the open reply is byte-identical to pre-0.8.0. alktty's public signatures reference alkcall types, so downstream crates must now unify on alkcall 0.8.0. Minor bump per the 0.x ecosystem-coordination rule. alkcallrequirement updated to0.8.0; lockfile bumped from 0.7.1 to 0.8.0. Verification counts are unchanged from the 0.7.1 baseline (113 default / 156 all-features); the adoption required no source change in alktty.
0.4.1 - 2026-09-10
The MSRV floor becomes honest: rust-version raises from 1.85 to
1.88. The 1.85 claim was already false at the dependency level: the
lockfile pins alkcall 0.7.x, and alkcall 0.7.1 raised its MSRV floor
to 1.88 (the ecosystem MSRV audit: noq's 1.88 matching the QUIC path;
iroh sits above at 1.91), so no 1.85 toolchain could build 0.4.0
regardless of what Cargo.toml declares. The raise breaks no downstream
that could build the crate before. No API or wire-format change;
downstreams on caret requirements pick this up on their next cargo update.
Changed
rust-version1.85 → 1.88. Verified on a real 1.88 toolchain (full test suite, clippy). alktty's own code is 1.85-clean (cargo checkunder a 1.85 toolchain passes with the pre-bump lockfile), so the raise is dependency-driven only and needed no clippy fixes. Behavior-identical.alkcallrequirement updated to0.7.1; lockfile bumped from 0.7.0 to 0.7.1. Version-requirement-only (0.7.x is already the resolved family); alkcall 0.7.1 changed no type alktty touches.
0.4.0 - 2026-09-07
The alkcall 0.7.0 adoption (the connect-side caller-identity seam,
CF-005/006/007): a dependency bump plus one behavior-alignment change
on the channels path. The wire format, the direct-ALPN path, the
TtyBackend trait, and the public API surface are unchanged.
Changed
- BREAKING (ecosystem-coordination):
alkcallbumped to 0.7.0. alkcall 0.7.0 changed no type that alktty touches (OpenEstablisher/OpenHandler/Establishment/ChannelPlanare identical to 0.6.0; alktty never constructsServingConfig), but alktty's public signatures reference alkcall types, so downstream crates must now unify on alkcall 0.7.0. Minor bump per the 0.x ecosystem-coordination rule. - The channels establisher and pump handler take their identity
from the per-call
auth(CF-006 alignment).register_openableno longer captures the install-time identity and overrides the establisher/handlerauth.identitywith it: post-alkcall-0.7, the per-call context already carries the dispatch-resolved opener (the same identity view the registry's ACL gate checked; the end client, not the hub, on hub-forwarded opens). Behavior-identical in the per-connection-registry deployment (install-time identity was the caller's); the ownership check and the ACL gate now see the same subject in every deployment. Two factory parameters dropped (make_tty_establisher/make_tty_open_handlerare private; no public-surface change).
0.3.0 - 2026-09-07
The alkcall 0.6.0 adoption (review 007 R-01, the Establishment
plan payload): backend allocation moves into the channels establisher.
Breaking on the channels path's allocation-failure shape; the wire
format, the direct-ALPN path, and the TtyBackend trait are
unchanged.
Changed
- BREAKING:
alkcallbumped to 0.6.0; channels-path allocation failure changes shape (ADR-010 §2A). alkcall 0.6.0 filled the reservedEstablishmentfield (plan: Option<ChannelPlan>, review 007 R-01), sobackend.allocatemoved from the pump handler into the establisher; the allocatedTtyHandlecrosses to the handler via the plan payload (a private per-open one-shot slot; the handle is notSync). Allocation failure now fails the open op itself:TtySessionError::ChannelsOpen(CallFailed{ channel:open_failed, reason: "dial_failed" })instead of the post-open in-bandNegotiationRejected{ "allocate_failed" }. The SSH contract now covers every failure class: no channel ever exists opener-side, nochannel_id, no0x00-prefixed in-band frame. The direct-ALPN path's in-band vocabulary (includingallocate_failed) is unchanged. tty_open_spec()'schannel:open_failedErrorDefinitiondeclaresdial_failedin thedetails.reasonenum (four reachable reasons:dial_failed/unknown_resource/handler_error/timeout).- Handler closures in
make_tty_open_handlergain theOption<ChannelPlan>parameter (alkcall 0.6OpenHandlershape); aNoneplan (no-establisher registration) falls back to the pre-0.3.0 inline validate-and-allocate, defense-in-depth, keeping the in-band error-frame vocabulary for that registration shape. - New tests: establisher unit gate for allocate-in-establisher (plan
slot carries the live handle; failure maps to
dial_failed), the end-to-enddial_failedopen-failure surface (flipped from the pinned in-band test), and the handler'splan: Nonefallback arm.
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, a
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 (
NegotiateRequestwithcarriage/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), with 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", with 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, and 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, with no backend-name enumeration differential), and the client→backend pump is aborted at session end instead of lingering until the client disconnects.