Port the alknet-tty architecture docs into alktty and add the BAST document for the alk/tty wire format. Docs-only; no Rust source changes. Spec docs (docs/architecture/, flat layout — single-crate repo): - overview.md — crate purpose, two-carriage model, deps, ALPN, backend location map, feature gates - tty-wire.md — 5-byte chunk codec, control channel split (STREAM_CTRL_IN=3 / STREAM_CTRL_OUT=4), sentinels - tty-backend.md — TtyBackend trait, TtyHandle, TtyControl, REQ-TTY-01 (backends need not be natively async) - tty-adapter.md — TtyAdapter, three-pump driver, exit-chunk ordering (ADR-004), cancel cleanup (ADR-005), access control - tty-local.md — LocalTtyBackend (local feature module), PTY + pipe modes, REQ-TTY-02 (signal forwarding to process group) - README.md — architecture index ADRs (docs/architecture/decisions/, renumbered 001..008 from alknet 052,053,054,055,056,057,077,093 in order): - 001 wire format + two-carriage model (incl. Phase 7 control- channel split amendment) - 002 TtyBackend trait + TtyHandle - 003 local backend placement (records both the alknet sibling- crate decision and the alktty single-crate consolidation behind a local feature) - 004 exit code on a control chunk - 005 backend cleanup on session cancel - 006 self-contained negotiation framing - 007 tty inside channels (reversed by 008; kept for historical context with reversal notice) - 008 channels pure channel multiplexing (reverses 007; TTY always uses its 5-byte format) BAST document (docs/architecture/tty-bast.md): - Normative JSON spec for the alk/tty wire format, conforming to the BAST meta-schema at https://alk.dev/bast/v1/schema - 5-byte chunk header (struct, big-endian: stream_type uint8, length uint32) + StreamType enum (Stdin=0..CtrlOut=4) - ControlMessage union (field-name discriminator on type: resize/signal/eof/exit) with documented deviation that on-wire control payloads are UTF-8 JSON, not BAST's binary union encoding - NegotiationFrame (4-byte BE length + UTF-8 JSON body) + NegotiateRequest / TerminalParams JSON shapes - StreamType enum deviation noted: on-wire uint8, not BAST's standard u32 enum index (chunk header is 5 bytes, not 8) - alktty does not depend on alktype; the hand-rolled wire.rs is the runtime codec, the BAST is the human-readable contract AGENTS.md: fixed the ADR mapping table to match the plan's 8-to-8 mapping (the previous table substituted ADR-050 for 054, relabeled 056 as control-message split, dropped 077, and added a new control-split ADR at 006 — inconsistent with both the plan and the prose). ADR-050 (dynamic resource ownership) is an alkcall/alknet- core ADR, not tty-specific, and is not ported; the Phase 7 control split stays as an amendment inside ADR-001, mirroring alknet. Verification (all pass, no Rust source changed): - cargo test (80 passed) - cargo test --all-features (103 passed) - cargo clippy --all-targets -- -D warnings (clean) - cargo fmt --check (clean) - cargo check --target wasm32-unknown-unknown (clean) - cargo clippy --target wasm32-unknown-unknown -- -D warnings (clean) - cargo doc --no-deps: 9 pre-existing intra-doc-link warnings in src/session.rs and src/channels.rs (untouched by this commit; not introduced here) - BAST JSON parses; StreamType indices match wire.rs constants (0=Stdin..4=CtrlOut) - all markdown cross-reference links resolve
21 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft (ported from alknet 2026-08-17; alknet-tty → alktty, alknet/tty → alk/tty, alknet-core → alkcall::core, alknet-call → alkcall, ADRs renumbered 052..093 → 001..008) | 2026-08-17 |
alktty — TtyBackend Trait and TtyHandle
The TtyBackend trait is the inversion point that keeps alktty
decoupled from its backends. alktty defines the trait, the TtyParams
allocation request, the TtyHandle a backend produces, and the
TtyControl trait; the backend crates (alktty's own local feature
module, future alknet-docker, alknet-ssh) implement TtyBackend.
This document specifies what an implementer builds against. The trait
shape is decided in ADR-002.
What
The TtyBackend trait is what the TtyAdapter calls to allocate a
terminal/process session. The adapter holds a
HashMap<String, Arc<dyn TtyBackend>> keyed by the negotiation frame's
backend string ("local", "docker", "ssh"). On a new session, the
adapter reads the negotiation frame, selects the backend by the
backend field, calls allocate(), and pumps the resulting
TtyHandle's fields bidirectionally using the chunk format
(ADR-001). The backend does not write to the wire — it produces handles;
the adapter pumps.
Three implementations are contemplated, each in its own crate (the no-handler-depends-on-another-handler rule from alknet ADR-003 is preserved — backends depend on alktty for the trait, alktty doesn't depend on them):
LocalTtyBackend(in alktty'slocalfeature module, ADR-003) — wrapsportable_ptyfor the PTY case andstd::process::CommandwithStdio::piped()for the pipe/runner case. See tty-local.md.DockerTtyBackend(inalknet-dockeror a sibling adapter crate — future, out of scope here) — wrapsbollard::attach_container()for interactive attach orbollard::exec::start_execwithtty: truefor exec-with-PTY.control.resize()callsbollard::exec::resize_execorbollard::container::resize_container. stdout/stderr are merged whentty: true(bollard'sLogOutputon a TTY exec returnsStdOutonly), soTtyHandle.stderrisNonefor the PTY case.SshTtyBackend(inalknet-ssh— future, out of scope here) — wraps russh'spty_request+shell_request(orexec_requestwith a PTY) on a session channel.channel.into_stream()gives(AsyncRead, AsyncWrite)— the stream is the PTY; russh handles kernel PTY allocation on the server side.control.resize()sends awindow_changechannel request;control.signal()sends asignalchannel request. stdout and stderr are merged (PTY property), soTtyHandle.stderrisNone.
The docker and SSH backend crates are future work; this spec set commits the trait shape they will implement, so they can be built against it without re-spec'ing the seam.
Why
The guiding insight: 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 TtyBackend trait is what makes
that insight load-bearing — alktty owns the wire format and session
lifecycle; the backends own PTY allocation. The full rationale (the
inversion point, why the trait is the seam) is in
ADR-002 §Context.
The Phase 0 local-PTY POC was built before this spec specifically to discover constraints the trait sketch would have missed by reading docs alone. Two requirements fell out, recorded as REQ-TTY-01 and REQ-TTY-02 in the alknet research findings; this spec carries REQ-TTY-01 here (backends need not be natively async) and tty-local.md carries REQ-TTY-02 (signal forwarding to the process group).
Architecture
TtyBackend trait
#[async_trait]
pub trait TtyBackend: Send + Sync {
/// Allocate a terminal/process session and return the handles the
/// adapter pumps. The `backend` field of the negotiation frame
/// (ADR-001) selects which registered backend's `allocate` is called.
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
/// The pre-existing resource this session targets, for ownership
/// checks (alknet ADR-050). `None` = no pre-existing resource (the session
/// creates its own — local process, SSH channel). `Some((kind, id))`
/// = the session targets an existing resource the caller must own
/// (e.g., DockerTtyBackend returns `Some(("container", id))`). The
/// adapter calls this at negotiation to gate access; the backend
/// extracts the id from its own `backend_params`. Default `None`.
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { None }
}
The adapter holds HashMap<String, Arc<dyn TtyBackend>> populated at
construction. The assembly layer (the CLI binary) constructs backends
with their dependencies and registers them. A backend is the thing
that allocates a session; the wire-format pump is backend-agnostic.
TtyError
The error type for allocate() and exit_code. #[non_exhaustive] so
new variants are additive (two-way-door extension within the one-way
trait shape — ADR-002).
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum TtyError {
#[error("allocate failed: {message}")]
AllocFailed { message: String },
#[error("wait failed: {message}")]
WaitFailed { message: String },
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("backend-specific: {message}")]
Backend { message: String },
}
AllocFailed— the PTY couldn't be allocated, the docker exec failed to start, the SSH channel request was rejected. Returned byallocate(); the adapter sends{"error":"allocate_failed",...}and closes (tty-adapter.md §"Negotiation errors").WaitFailed— the backend couldn't reap the child / determine the exit code. Returned by theexit_codefuture; the adapter sends{"type":"exit","code":-1}(ADR-004 §4).Io— an I/O error from a backend's stream/handle.Backend— backend-specific error not covered by the above (e.g., a bollard API error, a russh protocol error).
TtyParams — the allocation request
pub struct TtyParams {
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case,
/// ADR-003). `Some` = allocate a PTY with these dimensions.
pub terminal: Option<TerminalParams>,
/// Command vector (argv[0] + args). Non-empty.
pub cmd: Vec<String>,
/// Working directory (None = inherit/default).
pub cwd: Option<PathBuf>,
/// Environment variables (empty = inherit).
pub env: HashMap<String, String>,
/// Backend-specific selector fields from the negotiation frame,
/// unparsed. The adapter passes the JSON object through verbatim; the
/// backend deserializes its own strongly-typed params struct from it.
/// alktty has zero knowledge of any backend's params shape.
/// See "Backend params are opaque" below.
pub backend_params: serde_json::Map<String, serde_json::Value>,
}
pub struct TerminalParams {
pub term: Option<String>, // e.g., "xterm-256color"; None = backend default
pub cols: u16,
pub rows: u16,
pub pixel_width: u16,
pub pixel_height: u16,
pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1
}
terminal: None is the pipe/runner case — no PTY, separate
stdout/stderr. terminal: Some is the PTY case — stdout/stderr merged
into the single stdout stream (TtyHandle.stderr is None), real
terminal semantics (resize, signal delivery to process group). The
per-session choice is the backend's branch in allocate(), not a
per-deployment choice — see ADR-003.
Backend params are opaque
backend_params is a serde_json::Map<String, serde_json::Value>, not
a typed enum. The adapter passes the negotiation frame's
backend-specific fields through verbatim; the backend deserializes its
own strongly-typed params struct. alktty has zero knowledge of any
backend's params shape — not docker's container, not an SSH host
selector, not anything. Each backend defines its own params struct and
deserializes from params.backend_params inside allocate():
// in alknet-docker
#[derive(Deserialize)]
struct DockerBackendParams { container: String }
impl TtyBackend for DockerTtyBackend {
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError> {
let p: DockerBackendParams = serde_json::from_value(
serde_json::Value::Object(params.backend_params.clone())
).map_err(|e| TtyError::Backend { message: e.to_string() })?;
// use p.container ...
}
fn resource_id(&self, params: &TtyParams) -> Option<(&'static str, String)> {
// extract for the ownership check — backend-driven, not adapter-hardcoded
let p: DockerBackendParams = serde_json::from_value(
serde_json::Value::Object(params.backend_params.clone())
).ok()?;
Some(("container", p.container))
}
}
This is a complete inversion: the trait is inverted (backends
implement, alktty doesn't depend on them) and the params are
inverted (backends define their own typed shape, alktty doesn't carry
it). A new backend crate requires zero changes to alktty — no
enum variant to add, no forward-reference type to place, no dependency
edge. See ADR-002 §"Backend params are opaque" for the full rationale
and why the typed-enum alternative (with SshChannelRef) was rejected.
TtyHandle — what a backend produces
pub struct TtyHandle {
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
/// one — they are incompatible traits; the tokio stack is the
/// adapter's runtime).
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
/// Ends when the backend's stdout reaches EOF.
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
/// `tokio_stream::StreamExt` for extension methods).
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>,
/// Stderr stream — `None` for PTY backends (stdout/stderr merged
/// into `stdout`). `Some` for pipe backends (separate streams).
pub stderr: Option<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + Send>>>,
/// Exit code — a `Future` the adapter awaits. Resolves when the
/// process/container/SSH exec exits. The adapter sends the result
/// as the `{"type":"exit","code":N}` control chunk (ADR-004) and
/// closes the stream. This is `BoxFuture`, not a method on
/// `TtyHandle`, so the adapter can `select` between exit and
/// stream-close without coupling to the other fields. (REQ-TTY-01.)
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
/// Control handle (resize, signal) — `Clone` so the adapter can
/// hand it to the spawned control-chunk dispatcher. `None` only
/// when the backend genuinely has no control path. See OQ-43.
pub control: Option<TtyControlHandle>,
}
TtyControl trait and TtyControlHandle
pub trait TtyControl: Send + Sync {
/// Resize the terminal. Maps to SSH `window-change`, docker exec
/// resize, or `ioctl(TIOCSWINSZ)` on a local PTY. No-op for pipe
/// backends without a PTY.
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16);
/// Forward a signal by name. Best-effort delivery to the foreground
/// process group (see tty-local.md REQ-TTY-02). Unknown names fall
/// back to the backend's default kill.
fn signal(&self, name: &str);
}
/// The `Clone`-able handle to a backend's control path. The `TtyControl`
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) ->
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
/// lives on this concrete newtype, which holds the trait object behind an
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
/// control-chunk dispatcher. See OQ-43.
#[derive(Clone)]
pub struct TtyControlHandle(Arc<dyn TtyControl + Send + Sync>);
impl TtyControlHandle {
pub fn new(control: Arc<dyn TtyControl + Send + Sync>) -> Self { Self(control) }
pub fn resize(&self, c: u16, r: u16, pw: u16, ph: u16) { self.0.resize(c, r, pw, ph) }
pub fn signal(&self, name: &str) { self.0.signal(name) }
}
The trait is kept object-safe by NOT putting Clone on it; the Clone
newtype (TtyControlHandle) holds the trait object behind an Arc. The
POC used a concrete PtyControl struct (inherently Clone — it held
Arc<Mutex<...>> fields); this newtype generalizes the POC's shape so a
backend produces its own control type via TtyControlHandle::new(Arc::new(MyControl))
without the adapter knowing the concrete shape. See OQ-43 for the
confirmation and the rationale for why Clone cannot live on the trait
itself.
REQ-TTY-01: backends are not required to be natively async
portable_pty's API is blocking std::io::{Read, Write} and a blocking
Child::wait() — there is no async variant. The local-PTY POC bridges
this with three dedicated std threads (reader, writer, waiter) feeding
tokio mpsc/oneshot channels; the async-facing LocalPty then exposes
mpsc::Receiver<Bytes> for stdout, mpsc::Sender<StdinCmd> for stdin,
and oneshot::Receiver<i32> for exit. This is the same pattern wezterm
(portable_pty's primary consumer) uses.
The trait's adapter-facing types (AsyncWrite, Stream<Item = Bytes>,
BoxFuture, TtyControl) are the adapter's contract. A backend may
expose blocking handles internally and bridge them to these async-facing
types. The bridging pattern — blocking std::io on dedicated std threads
or tokio::task::spawn_blocking, feeding tokio mpsc/oneshot channels —
is a documented, supported implementation strategy, not a workaround.
This resolves the first half of OQ-TTY-01 (the research's open question
on the trait shape): exit_code is a Future the adapter awaits; a
oneshot::Receiver<i32> (or any BoxFuture<'static, i32>) lets the
adapter select between exit and stream-close without coupling to the
handle's other fields. The local backend's waiter thread produces exactly
this shape for free. See tty-local.md for the bridge
details.
Backend registration and the assembly layer
let mut backends = HashMap::new();
backends.insert("local".into(),
Arc::new(LocalTtyBackend::new()) as Arc<dyn TtyBackend>);
backends.insert("docker".into(),
Arc::new(DockerTtyBackend::new(docker_client, "alk".into())) as Arc<dyn TtyBackend>);
backends.insert("ssh".into(),
Arc::new(SshTtyBackend::new(ssh_session)) as Arc<dyn TtyBackend>);
let tty_adapter = TtyAdapter::new(Arc::new(backends));
A deployment that doesn't want docker registers only local. A browser
terminal endpoint that proxies to remote docker/ssh registers docker
and/or ssh backends. The adapter is backend-agnostic; the assembly
layer chooses what's available.
Backend implementations (where they live)
| Backend | Crate | Status | Notes |
|---|---|---|---|
LocalTtyBackend |
alktty's local feature module (ADR-003) |
in scope (tty-local.md) | portable_pty (PTY) + std::process (pipe); the runner pattern |
DockerTtyBackend |
alknet-docker (behind tty feature) |
future, out of scope here | wraps bollard::attach_container / exec with tty: true; attach vs exec mode |
SshTtyBackend |
alknet-ssh |
future, out of scope here | wraps russh pty_request + shell_request/exec_request; dissolves alknet-ssh DP-5 PTY hedge |
The SSH backend crate is future work; this spec commits the
trait shape it implements so it can be built against it without
re-spec'ing the seam. The DockerTtyBackend is future work in
alknet-docker — the natural extension of the alknet-docker POC's
drive_attach_raw — with the trait, it becomes impl TtyBackend for DockerTtyBackend. The SshTtyBackend dissolves the alknet-ssh
research's PTY hedge (DP-5): alknet-ssh's session channel still does
exec (structured, JSON carriage, exit code on completion) but
delegates PTY to alktty via the SshTtyBackend. alknet-ssh's
"default-reject" stance stays for the SSH channel policy (it rejects
pty_request on its own session channels), but the PTY capability is
provided by a separate crate via a separate ALPN (alk/tty), not hedged
inside alknet-ssh.
Constraints
- The trait shape is one-way (ADR-002). The
TtyBackendtrait methodallocate(), theTtyHandlefield set, and theTtyControltrait are the API surface every backend crate implements and the adapter consumes. Changing them after backends exist is a rewrite across crates. - Backend params are opaque (
serde_json::Map), not a typed enum. The carrier type is one-way (part ofTtyParams), but the contents are backend-defined: each backend deserializes its own strongly-typed params struct, and a new backend crate requires zero changes to alktty. See "Backend params are opaque" above and ADR-002 §"Backend params are opaque." - The adapter, not the backend, owns the wire format. Backends
produce handles; the adapter pumps. A backend that wrote to the wire
directly would break the wire-format invariants (the exit-chunk
ordering, ADR-004). The backend's
exit_codefuture resolves and the adapter sends the exit chunk — the backend does not serializeControlMessage::Exit. - PTY backends merge stdout/stderr.
TtyHandle.stderrisNonefor the PTY case (kernel PTY property — one output stream from the slave). The adapter pumps only stdout chunks (stream_type 1). Pipe backends setstderr: Someand the adapter pumps both stdout (stream_type 1) and stderr (stream_type 2) chunks. TtyControl::signalis best-effort. The contract is "best-effort delivery to the foreground process group," not "the child pid receives the signal." See tty-local.md REQ-TTY-02 for the process-group targeting and the fallback to the backend's default kill.- Dropping the
exit_codefuture MUST kill the session target (ADR-005). Theexit_codefield is aBoxFuture<'static, Result<i32, TtyError>>whoseDrop-on-cancel (i.e., dropped without being driven to completion) MUST kill the child/container/SSH process. This is a behavioral contract on theTtyBackendtrait — the adapter triggers it by dropping theTtyHandleon session cancel (connection drop, stream reset); the backend wires the kill into theexit_codefuture'sDrop. A backend that returns a bareoneshot::Receiver<i32>(or any future without a kill-on-Dropguard) asexit_codeviolates the contract and will orphan processes on cancel. See ADR-005 and tty-local.md §"Cancel-Cleanup (ADR-005)" for the local backend's mechanism.
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
TtyBackend trait and TtyHandle |
ADR-002 | The backend inversion point; exit_code as Future; backends need not be natively async (REQ-TTY-01) |
| Local backend placement | ADR-003 | alktty folds the local backend in behind a local feature (resolves the alknet cyclic-dep workaround) |
| Wire format | ADR-001 | The chunk codec + control channel the adapter pumps to/from these handles |
| Exit code on a control chunk | ADR-004 | The adapter awaits exit_code, sends the exit chunk, closes |
| Backend cleanup on session cancel | ADR-005 | Dropping exit_code future (cancel) MUST kill the session target; contract on the TtyBackend trait |
Open Questions
- OQ-43 (resolved):
TtyControlas aClonetrait object. - OQ-44 (deferred(scope)): Terminal modes.
References
- ADR-002 — the trait shape decision (this spec is its elaboration)
- ADR-001 — the wire format the adapter pumps to/from these handles
- ADR-004 — the
exit-chunk ordering the
exit_codefield feeds into - ADR-005 — the
cancel-cleanup contract on this trait (
exit_codefuture'sDrop-on-cancel kills the session target) - ADR-003 — the local
backend's placement (folded into alktty behind a
localfeature) src/backend.rs— the Rust source this spec documents- tty-local.md — the
LocalTtyBackendspec (carries REQ-TTY-02: signal forwarding to the process group) - tty-adapter.md — the session driver that consumes these handles