Files
alknet/docs/architecture/crates/tty/overview.md
T
glm-5.2 dbac972aaa docs(arch): ADR-057 — alknet-tty does not depend on alknet-call (self-contained negotiation framing)
The earlier specs claimed alknet-tty reuses alknet-call's
FrameFramedReader/FrameFramedWriter 'framing utility' (ADR-052 §6,
ADR-003 Am. 1). A pre-implementation sanity check found this was
unsound: FrameFramedReader::read_frame() is hardcoded to deserialize
EventEnvelope — the length-prefix read and the type-specific deserialize
are one entangled call, not a separable utility. alknet-tty's
negotiation payload is a NegotiateRequest, not an EventEnvelope, so the
claimed reuse did not exist in a usable form.

ADR-057: alknet-tty implements its own ~30-line length-prefixed
framing directly on tokio AsyncRead/AsyncWrite. The format coincides
with alknet-call's framing by convention (both are length-prefixed
JSON); the implementations are independent. alknet-tty depends on
alknet-core only — no alknet-call dependency. The ~30 lines of framing
is an idiom, not a domain abstraction worth a cross-crate dependency.

Updates:
- ADR-003 Amendment 2: clarifies alknet-tty does not depend on
  alknet-call; the Am. 1 protocol-foundation exception remains for
  alknet-http/agent/napi (actual type reuse), not alknet-tty.
- ADR-052 §6: framing is self-contained in alknet-tty; format coincides
  by convention, not by code reuse.
- overview.md: dependency block drops alknet-call; new 'Why no
  alknet-call dependency' subsection explains the unsound-reuse finding.
- tty-wire.md, tty-backend.md, tty README, crates/tty/README.md,
  top-level README: ADR tables, Design Decisions tables, and dependency
  edges updated to reflect alknet-core-only dependency.
- ADR-053, ADR-054: References updated to ADR-003 Am. 2 + ADR-057.
2026-07-07 11:15:52 +00:00

17 KiB

status, last_updated
status last_updated
draft 2026-07-07

alknet-tty — Overview

The terminal session protocol handler: a ProtocolHandler on alknet/tty that pumps a bidirectional byte stream (stdin/stdout/stderr) with a JSON control channel (resize, signal, eof, exit) over a framed bidi stream, decoupled from the backend that allocates the PTY via a TtyBackend trait. This document covers the crate's purpose, the two-carriage model in brief, its dependency edges, the ALPN, and the backend location map. Component details are in the sibling documents.

What

alknet-tty is the terminal session protocol handler for the ALPN-as-service architecture (ADR-001). It registers the alknet/tty ALPN on the shared AlknetEndpoint and implements the ProtocolHandler trait (ADR-002, ADR-007). The TtyAdapter receives a Connection, accepts one bidi stream per terminal session, reads a single JSON negotiation frame, switches to a raw chunk format, and pumps bytes bidirectionally for the life of the session — backend-agnostic.

The guiding insight that shapes the crate:

A terminal session is not an SSH concern, or a Docker concern — it is a terminal concern. SSH and Docker are just two backends that can allocate a PTY.

The alknet-docker POC (docs/research/alknet-docker/poc-summary.md) proved that the hard part of interactive attach — bidirectional byte pumping over a framed stream with a 1-byte stream-type multiplexer — is the same problem regardless of whether the backend is bollard::attach_container() or russh's pty_request. The POC's raw chunk format is the seed of alknet-tty's wire format. alknet-tty extracts that pattern into its own crate and ALPN; the backends (Docker, SSH, local process) implement a TtyBackend trait; the alknet/tty handler is backend-agnostic. This dissolves the PTY hedge in the alknet-ssh research (DP-5): PTY is not an SSH feature delegated to a separate crate, it's a tty feature that SSH happens to be able to provide.

Why

The crate's purpose is to be the terminal session library for downstream consumers. A hub that runs agent workspaces in containers wires DockerTtyBackend into the TtyAdapter and gets interactive terminal sessions over alknet/tty. A coordinator that runs cargo test remotely wires LocalTtyBackend (pipe mode) and gets the runner pattern (a process whose stdin/stdout/stderr/exit-code stream over a framed bidi connection) — the same shape as GitHub/Gitea Actions runners, just over alknet's transport instead of HTTP polling. A browser terminal (xterm.js over WebTransport, when WebTransport revives) connects to alknet/tty directly and gets raw bytes without implementing SSH or the call protocol.

The key architectural insight: the wire format and the backends invert at the TtyBackend trait. alknet-tty owns the wire format, the negotiation frame, the chunk codec, the control channel, and the session lifecycle; the backends own the PTY allocation (docker exec with tty: true, russh pty_request + shell_request, portable_pty::openpty). The adapter is backend-agnostic and testable with a mock backend (in-memory pipes). See ADR-053.

The Two-Carriage Model in Brief

A alknet/tty bidi stream has two phases (full detail in tty-wire.md, decided in ADR-052):

  1. Negotiation (JSON carriage). The client writes a single length-prefixed JSON frame carrying the terminal parameters, backend selector, command, and environment. The framing is a 4-byte big-endian length prefix + UTF-8 JSON body, self-contained in alknet-tty (the format coincides with alknet-call's framing by convention; alknet-tty does not depend on alknet-call — see Dependencies below).

  2. Raw carriage. After the negotiation frame, the stream switches to the chunk format ([stream_type: u8][length: u32 be][payload]) for the life of the session. Four stream types: 0=stdin (client→server), 1=stdout (server→client), 2=stderr (server→client), 3=control (bidirectional, JSON control messages: resize, signal, eof, exit). There is no call.responded/call.completed — this is not the call protocol; the raw-carriage byte pump is its own wire format after the single JSON negotiation frame.

This is the pattern the docker POC validated and the SSH research independently arrived at: JSON for the structured request, raw bytes for the body, which is the part that is actually bytes. The full rationale (why not JSON for everything; the two-carriage decision) is in ADR-052 §Context.

Dependencies

alknet-tty
├── alknet-core   (ProtocolHandler, Connection, AuthContext, Identity, AccessControl,
│                  OwnershipProvider — ADR-050 for terminal sessions as resources)
└── (no backend deps — portable_pty, bollard, russh are in the backend crates)

alknet-tty is dependency-light: alknet-core (the handler interface and auth) only. The negotiation framing is a self-contained ~30-line module in alknet-tty (4-byte BE length prefix + UTF-8 JSON body on tokio's AsyncRead/AsyncWrite). The heavy backend dependencies (portable_pty, bollard, russh) live in the backend crates, not here. alknet-tty does not depend on alknet-call — see ADR-057.

Why no alknet-call dependency

An earlier draft had alknet-tty depending on alknet-call for the FrameFramedReader/FrameFramedWriter "framing utility." A pre-implementation check found this was unsound: FrameFramedReader's read_frame() is hardcoded to deserialize EventEnvelope — the length-prefix read and the type-specific deserialize are one entangled call, not a separable utility. alknet-tty's negotiation payload is a NegotiateRequest, not an EventEnvelope, so the claimed reuse did not exist in a usable form. alknet-tty implements its own framing (the format coincides with alknet-call's by convention; the implementations are independent). The ~30 lines of length-prefix framing is an idiom, not a domain abstraction worth a cross-crate dependency. See ADR-057 for the full decision and ADR-003 Amendment 2 for the dependency-edge clarification.

alknet-tty stays lean — it has no portable_pty, no bollard, no alknet-call, no backend deps. The TtyBackend implementations are opaque Arc<dyn TtyBackend> from the adapter's perspective: constructed by the assembly layer at startup, stored in the adapter's backend map, dispatched by the backend field of the negotiation frame.

ALPN

ALPN Handler Transport Browser?
alknet/tty TtyAdapter QUIC bidi stream Yes (when WebTransport revives — ADR-040 parked)

alknet/tty is a custom ALPN per the ADR-006 alknet/<name> convention. The TtyAdapter registers for it; the endpoint's HandlerRegistry maps alknet/tty to the adapter instance. One ALPN per connection (ADR-006); within a connection, multiple bidi streams carry independent sessions (one session per stream — see tty-adapter.md).

The browser terminal case: a browser (xterm.js) connects via WebTransport to alknet/tty and gets raw bytes. The browser doesn't need to implement SSH or the call protocol for the terminal use case — only if it wants SSH-specific features (port forwarding, SFTP). This is a cleaner browser story than "run a WASM SSH client." WebTransport is deferred per ADR-044; when it revives, the alknet/tty ALPN is reachable over WebTransport's ALPN-stream-proxy (ADR-040, parked). See OQ-38 for the WebTransport relay scope question (unrelated to tty's own scope).

Backend Location Map

The decomposition principle (the same as alknet-http's adapter location map): the trait lives where the types live (alknet-tty); the implementations live where their transport dependencies live.

alknet-tty (lean — no portable_pty, no bollard, no russh)
├── TtyBackend trait             (the contract — ADR-053)
├── TtyHandle, TtyControl        (the handle shape backends produce)
├── TtyParams, TerminalParams    (the allocation request)
├── TtyAdapter                   (ProtocolHandler on alknet/tty — session lifecycle)
├── wire format                  (ChunkReader/ChunkWriter, ControlMessage — ADR-052)
└── negotiation framing          (self-contained ~30-line module; format coincides
                                  with alknet-call's by convention — ADR-057)

alknet-tty-local (sibling crate — ADR-054; behind alknet-tty's `local` feature re-export)
├── LocalTtyBackend              (impl TtyBackend — portable_pty for PTY, std::process for pipe)
├── portable_pty dependency      (PTY allocation — the heavy dep, here not in alknet-tty)
└── libc                         (signal forwarding — REQ-TTY-02, Unix only)

alknet-docker (or alknet-tty-docker adapter — future crate, out of scope here)
└── DockerTtyBackend             (impl TtyBackend — wraps bollard::attach_container / exec with tty:true)

alknet-ssh (future crate — out of scope here)
└── SshTtyBackend                (impl TtyBackend — wraps russh pty_request + shell_request/exec_request)

alknet-tty never sees portable_pty, bollard, or russh. The backend implementations are opaque Arc<dyn TtyBackend> from the adapter's perspective. alknet-tty stays lean; the backend crates own their transport dependencies. The local backend's crate placement (sibling crate behind a feature re-export) is decided in ADR-054; the docker and SSH backends are future crates (out of scope for this spec set — see tty-backend.md §"Backend implementations" for where they live).

Feature Gates

# alknet-tty Cargo.toml
[features]
default = []
local = ["dep:alknet-tty-local"]   # re-export LocalTtyBackend from alknet-tty-local
  • default — the wire format, TtyAdapter, and the TtyBackend trait. No backend implementations; the assembly layer registers backends from their own crates. A docker-only or ssh-only deployment uses the default features and depends on alknet-docker / alknet-ssh (or their own backend crate) directly.
  • local — re-export alknet_tty_local::LocalTtyBackend as alknet_tty::local::LocalTtyBackend. Pulls in alknet-tty-local (which pulls in portable_pty). A consumer that wants the local backend (terminal or runner) enables this feature.

The local backend's portable_pty dependency is the heavy dep that motivates the feature gate — a docker-only deployment should not pull in PTY allocation code. See ADR-054.

Architecture (component pointers)

  • tty-wire.md — the wire format: the negotiation frame (JSON carriage, self-contained length-prefixed framing), the raw chunk codec ([stream_type: u8][length: u32 be][payload]), the four stream types, the control channel (stream_type 3, JSON control messages), sentinels, and the fixed-channel-set rationale.
  • tty-backend.md — the TtyBackend trait, TtyParams, TtyHandle, TtyControl. The inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async; the bridging pattern is a documented strategy). Notes where the docker/SSH backend crates live (future, out of scope here).
  • tty-adapter.md — the TtyAdapter (ProtocolHandler on alknet/tty): the session lifecycle, the three-pump bidirectional driver (stdout→client, client→backend, exit→exit-chunk), negotiation errors, the exit-chunk ordering (ADR-055), access control (terminal sessions as runtime-spawned resources per ADR-050).
  • tty-local.md — the alknet-tty-local sibling crate: LocalTtyBackend via portable_pty (PTY mode) and std::process::Command (pipe/runner mode). Carries REQ-TTY-02 (signal forwarding to the foreground process group). The blocking→async bridge pattern (the three std threads feeding tokio mpsc/oneshot) is the reference for any future blocking-API backend.

Design Decisions

Decision ADR Summary
Wire format and two-carriage model ADR-052 alknet/tty ALPN; JSON negotiation frame then raw chunks; fixed channel set 0-3; control as JSON
TtyBackend trait and TtyHandle ADR-053 The backend inversion point; exit_code as Future; backends need not be natively async (REQ-TTY-01)
Local backend as a sibling crate ADR-054 alknet-tty-local behind a local feature re-export; PTY vs pipe per-session
Exit code on a control chunk ADR-055 {"type":"exit","code":N} on stream_type 3; "exit chunk is last" invariant; adapter owns the ordering
Backend cleanup on session cancel ADR-056 Dropping exit_code future (cancel) kills the session target; the adapter triggers it by dropping the TtyHandle
ALPN-based protocol dispatch ADR-001 TtyAdapter registers on alknet/tty
ProtocolHandler trait ADR-002 TtyAdapter implements ProtocolHandler
Crate decomposition ADR-003 Am. 2 alknet-tty depends on alknet-core only (no alknet-call); backends depend on alknet-tty for the trait
No alknet-call dependency (self-contained framing) ADR-057 alknet-tty implements its own length-prefixed framing; format coincides with alknet-call's by convention, not by code reuse
ALPN string convention ADR-006 alknet/tty is the custom ALPN; new ALPN for incompatible versions
BiStream type definition ADR-007 TtyAdapter receives a Connection, accepts bidi streams
Call protocol stream model (not used for body) ADR-012 The raw carriage is not the call protocol's EventEnvelope streaming — by design
Forwarded-for identity ADR-032 forwarded_for for proxied terminal sessions (hub→worker)
WebTransport ALPN-stream-proxy (parked) ADR-040 Parked per ADR-044; alknet/tty reachable over WebTransport's stream proxy when WebTransport revives
Defer h3/WebTransport ADR-044 WebTransport deferred; the browser terminal case revives with WebTransport
Streaming handler (not used for body) ADR-049 The StreamingHandler path tty explicitly does not use for the byte body
Dynamic resource ownership ADR-050 Terminal sessions are runtime-spawned resources; AccessControl shape declares against this model

Open Questions

See open-questions.md for full details.

  • OQ-43 (resolved): TtyControl as a Clone trait object.
  • OQ-44 (deferred(scope)): Terminal modes (TTY modes).
  • OQ-45 (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism.
  • OQ-46 (deferred(scope)): Runner API surface.
  • OQ-47 (resolved): Stdin closure canonical signal.

References

  • docs/research/alknet-tty/phase-0-findings.md — Phase 0 research
  • /workspace/alknet-tty-poc/ — Phase 0 local-PTY validation POC (the reference implementation for REQ-TTY-01 and REQ-TTY-02)
  • /workspace/alknet-docker-poc/src/raw.rs — the seed codec (stream_type 0/1/2) the tty POC extended with stream_type 3
  • docs/research/alknet-docker/poc-summary.md — the POC that seeded this crate
  • docs/research/alknet-ssh/phase-0-findings.md DP-5 — the PTY hedge this crate dissolves
  • /workspace/@alkdev/dispatch/ — the reverse-runner prior art (currently requires SSH; LocalTtyBackend removes that requirement)
  • portable-pty 0.9 source — the blocking-API constraint that drives REQ-TTY-01 and the signal-delivery contract (REQ-TTY-02)