Files
alktty/docs/architecture/tty-local.md
T
glm-5.2 b3f50d1836 phase 4: architecture docs + BAST schema + renumbered ADRs
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
2026-08-17 10:52:26 +00:00

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 via portable_pty::native_pty_system().openpty(), spawn the command into the slave side, return a TtyHandle with merged stdout (stderr is None — kernel PTY property) and a real TtyControl (resize via MasterPty::resize, signal via libc::kill(-pgid, sig)).
  • terminal: None — pipe mode, the runner case. Spawn the command with Stdio::piped() for stdin/stdout/stderr, return a TtyHandle with separate stdout and stderr (stderr is Some) and a TtyControl whose resize is a no-op (no PTY) and signal calls libc::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)
}
  1. Reader thread — blocking reads from MasterPty::try_clone_reader()mpsc::Sender<Bytes>. The reader loop reads into an 8 KiB buffer, copies each chunk to Bytes, and blocking_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-length Bytes sentinel (the "drained" signal) and exits. The async-facing TtyHandle.stdout is the mpsc::Receiver<Bytes>, wrapped as Pin<Box<dyn Stream<Item = Bytes> + Send>>.
  2. Writer thread — drains an mpsc::Receiver<StdinCmd> → blocking writes to MasterPty::take_writer(). StdinCmd::Bytes(bytes) writes and flushes; StdinCmd::Eof drops the writer (sends EOF to the slave's stdin) and exits. The async-facing TtyHandle.stdin is the mpsc::Sender<StdinCmd>, wrapped as Box<dyn AsyncWrite + Send + Unpin> (an AsyncWrite impl that wraps each write as a StdinCmd::Bytes and flush as a no-op; the mpsc::Sender is the sink).
  3. Waiter thread — blocking Child::wait()oneshot::Sender<i32> with the exit code. The async-facing TtyHandle.exit_code is a Future wrapping this oneshot::Receiver<i32> PLUS a kill guard holding the portable_pty::ChildKiller (see "Cancel-Cleanup (ADR-005)" below). This is the Future the adapter awaits (ADR-002 REQ-TTY-01; ADR-004); its Drop-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:

  1. 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 (the portable_pty default).
  2. 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.
  3. The TtyControl::signal contract 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's ChildKiller::kill sends SIGHUP); known names map to libc signal 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 (Unix openpty / 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.
  • LocalTtyBackend takes no constructor dependencies. Unlike DockerTtyBackend (wraps a bollard::Docker client) or SshTtyBackend (wraps an SSH session), the local backend is dependency-free at construction — the portable_pty system is process-global. The assembly layer constructs one LocalTtyBackend and registers it as "local".
  • The exit_code future's Drop-on-cancel kills the child (ADR-005). The local backend MUST NOT return a bare oneshot::Receiver<i32> as TtyHandle.exit_code — it must wrap it in a Future whose Drop calls ChildKiller::kill(SIGHUP) (PTY) or Child::start_kill() (pipe) when dropped without resolving. An implementer who copies the POC's bare oneshot::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_code future's Drop-on-cancel kills the child via ChildKiller / 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