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
19 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft (ported from alknet 2026-08-17; alknet-tty → alktty, alknet-tty-local → alktty's `local` feature module, alknet/tty → alk/tty, alknet-core → alkcall::core, alknet-call → alkcall, ADRs renumbered 052..093 → 001..008) | 2026-08-17 |
alktty — Local TTY Backend (local feature module)
The local backend: a TtyBackend implementation that wraps
portable_pty for the PTY case (terminal semantics — resize, signal
delivery, escape-sequence handling) and tokio::process::Command with
Stdio::piped() for the pipe/runner case (process-streaming without
terminal semantics). This document specifies the LocalTtyBackend, the
blocking→async bridge pattern (REQ-TTY-01's reference implementation),
and the signal-delivery contract (REQ-TTY-02). The module placement is
decided in ADR-003; the trait
it implements is in tty-backend.md.
What
LocalTtyBackend lives in alktty's local feature module
(src/local/, gated by the local cargo feature) and implements
TtyBackend. The backend's allocate() branches on TtyParams.terminal:
terminal: Some(TerminalParams { ... })— allocate a real PTY viaportable_pty::native_pty_system().openpty(), spawn the command into the slave side, return aTtyHandlewith merged stdout (stderr isNone— kernel PTY property) and a realTtyControl(resize viaMasterPty::resize, signal vialibc::kill(-pgid, sig)).terminal: None— pipe mode, the runner case. Spawn the command withStdio::piped()for stdin/stdout/stderr, return aTtyHandlewith separate stdout and stderr (stderr isSome) and aTtyControlwhoseresizeis a no-op (no PTY) andsignalcallslibc::kill(pid, sig)(still works for signal forwarding without a PTY).
The backend is the reference implementation of REQ-TTY-01 (backends need not be natively async) and carries REQ-TTY-02 (signal forwarding to the process group).
Why
The local backend is the simplest backend and the one that enables 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 alk's transport instead of HTTP polling. With
LocalTtyBackend, the dispatch project (a reverse runner that currently
requires SSH on the remote end) works without SSH — the endpoint runs
the process directly and streams its I/O back. SSH becomes one transport
option (for reaching hosts that don't run alk), not a requirement.
The PTY case is what makes a terminal a terminal: real resize (via
ioctl(TIOCSWINSZ)), signal delivery to the foreground process group
(via libc::kill(-pgid, sig), REQ-TTY-02), and escape-sequence handling
(the kernel PTY's line discipline). Without a PTY, it's a runner (piped
process); with a PTY, it's a terminal. The per-session choice
(TtyParams.terminal) lets one LocalTtyBackend serve both — see
ADR-003.
The wrinkle that drove the Phase 0 POC: portable_pty is a blocking
std::io API, not async. MasterPty::try_clone_reader() returns
Box<dyn std::io::Read + Send>; take_writer() returns
Box<dyn std::io::Write + Send>; Child::wait() blocks. The POC was
built to discover how that constraint shapes the TtyBackend trait
(REQ-TTY-01) and the signal-delivery contract (REQ-TTY-02). This spec
records both as requirements, not open questions — the POC turned them
into grounded requirements.
Architecture
PTY Mode (terminal: Some)
allocate() calls portable_pty::native_pty_system().openpty(PtySize)
with the terminal dimensions, spawns the command into the slave side
via SlavePty::spawn_command(CommandBuilder), drops the slave (so the
child sees EOF on its stdin when the master writer closes), and returns
a TtyHandle.
The blocking→async bridge (REQ-TTY-01's reference implementation):
three dedicated std threads feed tokio mpsc/oneshot channels. The
writer thread consumes an mpsc of StdinCmd:
pub enum StdinCmd {
Bytes(Vec<u8>), // write these bytes to the master writer
Eof, // close the master writer (EOF to the slave's stdin)
}
- Reader thread — blocking reads from
MasterPty::try_clone_reader()→mpsc::Sender<Bytes>. The reader loop reads into an 8 KiB buffer, copies each chunk toBytes, andblocking_sends to the mpsc. On EOF (the master reader returns EOF when the slave closes — the child has exited and the OS has drained the PTY buffer), the thread sends a zero-lengthBytessentinel (the "drained" signal) and exits. The async-facingTtyHandle.stdoutis thempsc::Receiver<Bytes>, wrapped asPin<Box<dyn Stream<Item = Bytes> + Send>>. - Writer thread — drains an
mpsc::Receiver<StdinCmd>→ blocking writes toMasterPty::take_writer().StdinCmd::Bytes(bytes)writes and flushes;StdinCmd::Eofdrops the writer (sends EOF to the slave's stdin) and exits. The async-facingTtyHandle.stdinis thempsc::Sender<StdinCmd>, wrapped asBox<dyn AsyncWrite + Send + Unpin>(anAsyncWriteimpl that wraps eachwriteas aStdinCmd::Bytesandflushas a no-op; thempsc::Senderis the sink). - Waiter thread — blocking
Child::wait()→oneshot::Sender<i32>with the exit code. The async-facingTtyHandle.exit_codeis aFuturewrapping thisoneshot::Receiver<i32>PLUS a kill guard holding theportable_pty::ChildKiller(see "Cancel-Cleanup (ADR-005)" below). This is theFuturethe adapter awaits (ADR-002 REQ-TTY-01; ADR-004); itsDrop-on-cancel kills the child (ADR-005).
TtyHandle.stderr is None (PTY backends merge stdout/stderr — kernel
PTY property, one output stream from the slave).
TtyHandle.control is a PtyControl struct (the POC's concrete type;
the trait-object form per ADR-002 is the Arc-backed Clone newtype,
OQ-43):
#[derive(Clone)]
pub struct PtyControl {
master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
killer: Arc<Mutex<Box<dyn portable_pty::ChildKiller + Send + Sync>>>,
pid: Option<u32>,
}
resize() locks the master and calls MasterPty::resize(PtySize) —
non-blocking (it issues an ioctl). signal() — see REQ-TTY-02 below.
REQ-TTY-02: Signal Forwarding Must Target the Process Group
libc::kill(pid, sig) on the spawned child's pid alone is insufficient
for terminal semantics: a shell running under a PTY will have spawned
children (a find | grep pipeline, a make with sub-makes), and those
children will not receive the signal. A real terminal forwards Ctrl-C to
the foreground process group, which (under job-control shells) is the
process group the shell most recently spawned for the foreground job.
portable_pty makes the child a session leader (when
controlling_tty = true, the default — CommandBuilder::set_controlling_tty(true)),
so the child's pid is its process-group id, and libc::kill(-pid, sig)
(the negative pid) reaches the whole group. The POC's PtyControl::signal
uses exactly this — kill(-pgid, sig) with a fallback to kill(pid, sig)
if the group signal fails (e.g., the child already exited).
The spec records:
- The local backend MUST forward signals to the child's process
group, not just the child pid. Using
kill(-pgid, sig)when the child is a session leader (theportable_ptydefault). - The local backend MUST spawn the child as a session leader with a
controlling tty. This is
portable_pty's default (CommandBuilder::set_controlling_tty(true)); disabling it (e.g., for container-boundary workarounds) breaks signal forwarding and is therefore not supported for the terminal use case. - The
TtyControl::signalcontract is "best-effort delivery to the foreground process group," not "the child pid receives the signal." Unknown signal names fall back to the backend's default kill (portable_pty'sChildKiller::killsends SIGHUP); known names map tolibcsignal numbers (HUP,INT,QUIT,TERM,KILL,USR1,USR2,TSTP,CONT) and are sent to the group.
This pre-empts a class of "Ctrl-C doesn't kill my cargo build" bugs
that would otherwise surface in Phase 2/3.
Cancel-Cleanup (ADR-005)
The TtyBackend cleanup contract (ADR-005): dropping the exit_code
future kills the session target. The local backend implements this for
both PTY and pipe modes.
PTY mode. allocate() obtains a portable_pty::Child (with
wait()) and a portable_pty::ChildKiller (with kill()) — the two
handles portable_pty exposes alongside each other. The Child moves
into the waiter thread (which blocks on wait()). The ChildKiller
moves into the exit_code future's Drop guard, alongside the
oneshot::Receiver<i32> from the waiter thread. The future's poll
delegates to the oneshot receiver (resolves on natural exit); the
future's Drop (runs on cancel only — on resolve, the guard is
disarmed) calls ChildKiller::kill(SIGHUP):
struct LocalExitFuture {
rx: oneshot::Receiver<i32>,
killer: Option<portable_pty::ChildKiller>, // None after resolve (disarmed)
}
impl Future for LocalExitFuture { /* poll delegates to rx; on Ready, take killer */ }
impl Drop for LocalExitFuture {
fn drop(&mut self) {
if let Some(killer) = self.killer.take() {
let _ = killer.kill(SIGHUP); // best-effort; child may already be exiting
}
}
}
On cancel: the Drop kills the child (SIGHUP); the child exits; the
waiter thread's wait() reaps it and exits (its oneshot::send fails
silently — the receiver was dropped with the future, which is expected);
the reader/writer threads exit on channel close. The child is reaped
(no zombie) by the waiter thread's wait() returning after the kill.
Pipe mode. The same pattern with tokio::process::Child instead of
portable_pty::Child. The exit_code future's Drop guard holds the
Child handle (or a Child-kill wrapper) and calls
Child::start_kill() on cancel. The waiter task (Child::wait())
reaps the killed child.
The happy path is unaffected. When the adapter drives exit_code
to completion (the child exits naturally), the future resolves, the
guard is disarmed (the Option::take() in poll's Ready branch),
and the subsequent Drop is a no-op. The contract is "kill on cancel;
no-op on resolve."
This closes the orphaned-process gap the local-PTY POC surfaced: a
child that ignores stdin EOF (a daemon, a long-lived process with no
stdin reader) is killed when the session is cancelled, not left
running. The POC's LocalPty::exit_code was a bare
oneshot::Receiver<i32> with no kill guard — an implementer who
copies the POC's shape without the guard violates the contract. See
ADR-005 for the contract and the trait-level rationale.
Pipe Mode (terminal: None)
allocate() spawns the command with tokio::process::Command and
Stdio::piped() for stdin, stdout, and stderr. The async bridge is
simpler than the PTY case — tokio's Child provides AsyncRead for
stdout/stderr and AsyncWrite for stdin directly (no std-thread
bridge needed). TtyHandle.stderr is Some (separate streams). The
exit_code future is Child::wait() (async on tokio's Child).
TtyHandle.control is a PipeControl whose resize() is a no-op
(no PTY — resize doesn't apply) and signal() calls libc::kill(pid, sig)
on the child's pid. Signal forwarding to the process group is not
applicable in pipe mode (there's no session leader / controlling tty);
kill(pid, sig) reaches the direct child only. If the child has
spawned its own children, they won't receive the signal — this is a
known limitation of the runner case (a runner that needs
process-group signal delivery uses the PTY case, not the pipe case).
The Threading/Deadlock Caveat (DP-4, Acknowledged Constraint)
std::process::Command with piped stdio can deadlock if stdin writes
block while stdout/stderr buffers fill — the classic pipe-buffer deadlock.
The fix is concurrent reads on stdout/stderr alongside stdin writes,
which is exactly what the bidirectional pump does (the POC's
drive_attach_raw runs the two directions as concurrent
tokio::spawn tasks). The same pattern works for LocalTtyBackend:
spawn one task pumping stdin→process, one task pumping process→stdout-chunks,
one for stderr if piped. This is a known constraint with a known solution
(POC-validated); no design decision needed.
Module Placement (ADR-003)
The local backend is folded into alktty behind a local cargo feature
(the single-crate consolidation ADR-003 records):
# alktty Cargo.toml
[features]
default = []
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
A consumer that wants the local backend enables features = ["local"]
and gets alktty::local::LocalTtyBackend. A consumer that only wants
docker/ssh uses the default features and depends on the backend crate
directly — no portable_pty in the dependency tree. See ADR-003.
The single-crate consolidation resolves the alknet cyclic-dep
workaround that motivated the original sibling-crate decision
(alknet ADR-054): alknet-tty-local depended on alknet-tty for the
trait, and ADR-054 wanted alknet-tty to re-export LocalTtyBackend
behind a local feature — which cargo rejects (a crate cannot
re-export from a sibling crate it depends on via an optional dep AND
have that sibling depend back on it). The workaround in the alknet
mono-repo was the assembly-layer pattern (consumer depends on both
crates directly). In alktty, the local backend is in the same crate as
the trait, so the cyclic-dep workaround doesn't apply. ADR-003 records
both the original alknet decision and the alktty consolidation.
Dependencies
alktty (local feature)
├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alkcall::core (via alktty's re-export; not direct)
├── portable_pty (PTY allocation — the heavy dep, Unix openpty + Windows ConPTY)
├── libc (signal forwarding — REQ-TTY-02, Unix only)
└── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite)
The local feature is inherently non-wasm (portable-pty +
tokio::process need a real OS); enabling local on
wasm32-unknown-unknown is a build error by design. The default crate
(no features) stays wasm-clean — see the crate root's # WASM target
doc comment.
The Runner Pattern
The pipe mode (terminal: None) is the "runner" generalization the
research identified. A coordinator sends a negotiation frame with
{ "backend": "local", "tty": null, "cmd": ["cargo", "test"] }; the
endpoint runs cargo test with piped stdio, streams stdout/stderr chunks
back, sends {"type":"exit","code":N} when it finishes (ADR-004). The
coordinator gets reliable completion notification (the exit control
chunk + stream close) — no polling, no plugin state.
This is functionally identical to GitHub/Gitea Actions runners, just over
alk's transport instead of HTTP polling. The dispatch project is a
reverse runner that currently requires SSH on the remote end; with
LocalTtyBackend, the same pattern works without SSH — the endpoint
runs the process directly. SSH becomes one transport option (for
reaching hosts that don't run alk), not a requirement.
The runner-specific API surface (job management, log persistence, task
graph integration) is out of scope for alktty (OQ-46). alktty
provides the mechanism (a framed byte stream for a process + exit
code); the runner policy is a downstream crate's job. This spec
commits to preserving the option (terminal: None → pipe mode) and not
building runner policy into alktty.
Constraints
- PTY mode requires
portable_pty's native PTY (Unixopenpty/ Windows ConPTY). The blocking→async bridge (three std threads) is the documented pattern for any blocking-API backend (REQ-TTY-01). PTY mode is#[cfg(unix)]-only in the source; pipe mode is cross-platform. - Signal forwarding in PTY mode targets the process group (REQ-TTY-02).
kill(-pgid, sig)when the child is a session leader (controlling_tty = true, the default). Disabling the controlling tty breaks signal forwarding and is not supported for the terminal use case. - Pipe mode does not forward signals to the process group.
kill(pid, sig)reaches the direct child only; grandchildren don't receive it. A runner that needs process-group signal delivery uses the PTY case. - The pipe-buffer deadlock is handled by the concurrent pump. The
adapter's three-pump driver (
tty-adapter.md) reads stdout/stderr concurrently with writing stdin — the POC-validated pattern. No design decision needed; the spec notes it as a known constraint with a known solution. LocalTtyBackendtakes no constructor dependencies. UnlikeDockerTtyBackend(wraps abollard::Dockerclient) orSshTtyBackend(wraps an SSH session), the local backend is dependency-free at construction — theportable_ptysystem is process-global. The assembly layer constructs oneLocalTtyBackendand registers it as"local".- The
exit_codefuture'sDrop-on-cancel kills the child (ADR-005). The local backend MUST NOT return a bareoneshot::Receiver<i32>asTtyHandle.exit_code— it must wrap it in aFuturewhoseDropcallsChildKiller::kill(SIGHUP)(PTY) orChild::start_kill()(pipe) when dropped without resolving. An implementer who copies the POC's bareoneshot::Receiver<i32>shape without the kill guard violates the contract and will orphan processes on session cancel. See ADR-005.
Design Decisions
| Decision | ADR | Summary |
|---|---|---|
| Local backend placement | ADR-003 | alktty folds the local backend in behind a local feature (resolves the alknet cyclic-dep workaround); PTY vs pipe per-session |
TtyBackend trait and TtyHandle |
ADR-002 | The trait this backend implements; REQ-TTY-01 (backends need not be natively async) |
| Wire format | ADR-001 | The chunk codec + control channel the adapter pumps to/from this backend |
| Exit code on a control chunk | ADR-004 | The waiter thread's oneshot::Receiver<i32> feeds the exit chunk |
| Backend cleanup on session cancel | ADR-005 | The exit_code future's Drop-on-cancel kills the child via ChildKiller (PTY) / start_kill (pipe); the waiter thread reaps |
Open Questions
- OQ-46 (deferred(scope)): Runner API surface.
References
- ADR-003 — the module placement decision (single-crate consolidation)
- ADR-002 — the trait this backend implements; REQ-TTY-01 (the blocking-backend accommodation)
- ADR-004 — the
waiter thread's
oneshot::Receiver<i32>feeds the exit chunk - ADR-005 —
the cancel-cleanup contract this backend implements (the
exit_codefuture'sDrop-on-cancel kills the child viaChildKiller/start_kill) src/local/— the Rust source this spec documents (backend.rs,pty.rs,pipe.rs)- tty-backend.md — the trait this backend implements
- tty-adapter.md — the session driver that consumes this backend's handles