docs(arch): alknet-tty Phase 1 specs — wire format, backend trait, local backend, exit-chunk ordering

Grounded in the alknet-docker POC (seed codec) and the alknet-tty POC
(2026-07-05, validated the control channel, the local-PTY blocking→async
bridge, and the signal-delivery contract).

Four ADRs:
- ADR-052: wire format — alknet/tty ALPN, two-carriage (JSON negotiation
  then raw chunks), fixed channel set 0-3, control as JSON, negotiation-
  error framing disambiguation
- ADR-053: TtyBackend trait + TtyHandle — the backend inversion point;
  exit_code as a Future; REQ-TTY-01 (backends need not be natively async)
- ADR-054: local backend as alknet-tty-local sibling crate behind a
  feature re-export; PTY vs pipe per-session; runner pattern preserved
- ADR-055: exit code on a stream_type 3 control chunk; "exit chunk is
  last" invariant; adapter owns the ordering

Five component specs (crates/tty/): README, overview, tty-wire, tty-
backend, tty-adapter, tty-local. The docker/SSH backends are future
crates (out of scope); the trait shape is committed so they can be
built against it.

Five OQs (OQ-43…47): two resolved (TtyControl Clone, stdin closure),
two deferred(scope) (terminal modes, runner API surface), one open
low-risk (flow control).

Zero critical issues from a general-subagent architecture review;
warnings addressed (inline rationale trimmed to ADR refs, TtyError and
StdinCmd defined, framing disambiguation documented, missing ADRs added
to index tables).
This commit is contained in:
glm-5.2 committed 2026-07-06 15:05:36 +00:00
1 parent 14c8340380
commit e4e6ccfb14
12 files changed
+2892 -1

No files matched your search

+314
View File
@@ -0,0 +1,314 @@
---
status: draft
last_updated: 2026-07-06
---
# alknet-tty — Local TTY Backend (`alknet-tty-local`)
The local backend: a `TtyBackend` implementation that wraps
`portable_pty` for the PTY case (terminal semantics — resize, signal
delivery, escape-sequence handling) and `std::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 crate placement is decided
in [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md);
the trait it implements is in [tty-backend.md](tty-backend.md).
## What
`alknet-tty-local` is a sibling crate (ADR-054) that implements
`TtyBackend` for `LocalTtyBackend`. 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 alknet's transport instead of HTTP polling. With
`LocalTtyBackend`, the dispatch project (`/workspace/@alkdev/dispatch/`, 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
alknet), 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-054.
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`:
```rust
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_send`s 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 the
`oneshot::Receiver<i32>`, wrapped as `BoxFuture<'static, Result<i32,
TtyError>>`. This is the `Future` the adapter awaits (ADR-053
REQ-TTY-01; ADR-055).
`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-053 is the `Arc`-backed `Clone` newtype,
OQ-43):
```rust
#[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.
### Pipe Mode (`terminal: None`)
`allocate()` spawns the command with `std::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.
### Crate Placement (ADR-054)
`alknet-tty-local` is a sibling crate. `alknet-tty` re-exports
`LocalTtyBackend` behind a `local` feature:
```toml
# alknet-tty Cargo.toml
[features]
default = []
local = ["dep:alknet-tty-local"]
```
A consumer that wants the local backend enables `features = ["local"]`
and gets `alknet_tty::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-054.
### Dependencies
```
alknet-tty-local
├── alknet-tty (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alknet-core (via alknet-tty'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 (mpsc, oneshot, AsyncRead/AsyncWrite for the pipe case)
```
## 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-055). 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
alknet's transport instead of HTTP polling. The dispatch project
(`/workspace/@alkdev/dispatch/`) 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 alknet), not a
requirement.
The runner-specific API surface (job management, log persistence, task
graph integration) is **out of scope for alknet-tty** (OQ-46). alknet-tty
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 alknet-tty.
## 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).
- **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"`.
## Design Decisions
| Decision | ADR | Summary |
|----------|-----|---------|
| Local backend as a sibling crate | [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md) | `alknet-tty-local` behind a `local` feature re-export; PTY vs pipe per-session |
| `TtyBackend` trait and `TtyHandle` | [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) | The trait this backend implements; REQ-TTY-01 (backends need not be natively async) |
| Wire format | [ADR-052](../../decisions/052-alknet-tty-wire-format-and-two-carriage.md) | The chunk codec + control channel the adapter pumps to/from this backend |
| Exit code on a control chunk | [ADR-055](../../decisions/055-exit-code-on-control-chunk.md) | The waiter thread's `oneshot::Receiver<i32>` feeds the exit chunk |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-46** (deferred(scope)): Runner API surface.
## References
- [ADR-054](../../decisions/054-local-tty-backend-sibling-crate.md) —
the crate placement decision
- [ADR-053](../../decisions/053-ttybackend-trait-and-ttyhandle.md) — the
trait this backend implements; REQ-TTY-01 (the blocking-backend
accommodation)
- `docs/research/alknet-tty/phase-0-findings.md` — §"Requirements from
the local-PTY POC" (REQ-TTY-01 and REQ-TTY-02, the load-bearing
constraints this spec records)
- `/workspace/alknet-tty-poc/src/local_pty.rs` — the reference
implementation of the PTY backend (the three-thread bridge, the
`PtyControl` struct, the `kill(-pgid, sig)` signal forwarding)
- `/workspace/alknet-tty-poc/src/session.rs` — the session driver that
consumes this backend's handles (the reference for the adapter's
three-pump driver)
- `/workspace/alknet-tty-poc/tests/signal.rs` — the SIGINT-forwarding
integration test (validates REQ-TTY-02)
- `/workspace/@alkdev/dispatch/` — the reverse-runner prior art
(currently requires SSH; this backend removes that requirement)
- `portable-pty` 0.9 source — the blocking-API constraint and the
`CommandBuilder::set_controlling_tty` default REQ-TTY-02 depends on
- [tty-backend.md](tty-backend.md) — the trait this backend implements
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes
this backend's handles