Files
alknet/docs/research/alknet-tty/phase-0-findings.md
glm-5.2 d0e3711b46 docs(research): record alknet-tty local-PTY POC findings — REQ-TTY-01 (blocking-backend trait accommodation) and REQ-TTY-02 (process-group signal forwarding)
Built /workspace/alknet-tty-poc against portable_pty 0.9 to validate the
local-PTY path (Step 2 of the build order) before Phase 1 specs. The POC
surfaced two constraints that were not knowable from reading the
portable_pty docs alone and that the architect must carry into the
tty-backend.md and tty-local.md specs:

- REQ-TTY-01: portable_pty is a blocking std::io API; the TtyBackend
  trait must accommodate blocking backends that bridge to async via std
  threads + tokio mpsc. exit_code resolves to a Future the adapter
  awaits (resolves the load-bearing half of OQ-TTY-01).
- REQ-TTY-02: signal forwarding must target the process group
  (kill(-pgid, sig)), which depends on the child being a session leader
  (portable_pty's controlling_tty=true default).

The POC also validated the control channel (stream_type 3), JSON control
messages (DP-3), and exit-code-on-control-chunk (DP-5). OQ-TTY-01 is
marked resolved with the control-as-Clone-trait-object sub-question left
open with a POC-informed recommendation. The POC itself lives in the dev
workspace, not the repo; this doc is the durable record.
2026-07-05 14:43:28 +00:00

781 lines
41 KiB
Markdown

---
status: draft
last_updated: 2026-07-05
---
# alknet-tty — Phase 0 Research Findings
This document captures Phase 0 (Exploration) findings for the `alknet-tty`
crate. The objective of Phase 0 per `docs/sdd_process.md` is: *"Capture vision
and guiding principles; research options; validate approaches; converge on a
recommended approach."* It is the input to Phase 1 (Architecture), where the
Architect will produce `docs/architecture/crates/tty/*.md` specs, ADRs, and
open questions.
This document was drafted 2026-07-03, immediately after the `alknet-docker`
POC (`docs/research/alknet-docker/poc-summary.md`) validated that bollard's
container attach maps cleanly onto a framed bidi stream with a 1-byte
stream-type multiplexer. The POC's raw chunk format is the seed of
`alknet-tty`'s wire format.
## Vision Recap
`alknet-tty` is a 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 guiding insight, surfaced during the alknet-docker POC and recognized in
the conversation that followed:
> **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 proved that the hard part of interactive attach —
bidirectional byte pumping over a framed stream with a multiplexing header —
is the same problem regardless of whether the backend is `bollard::attach_container()`
or russh's `pty_request` + session channel. The POC's raw chunk format
(`[stream_type: u8][length: u32 be][payload bytes]`, with stream_type
0=stdin, 1=stdout, 2=stderr) is a deliberately impoverished version of SSH's
channel multiplexer: fixed set of channel types, no negotiation, no
open/close handshake, no windowing (QUIC provides flow control on the bidi
stream). That impoverishment is the feature — a terminal session needs
exactly those channels and no more.
`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 (`docs/research/alknet-ssh/phase-0-findings.md`
DP-5: "shell_request and pty_request default-reject; interactive shell is
an explicit opt-in") — PTY is not an SSH feature, it's a tty feature that
SSH happens to be able to provide.
Beyond terminals, the same wire format and backend trait support a general
"runner" pattern: a process (local `std::process::Command`, docker
container, SSH exec) whose stdin/stdout/stderr/exit-code are streamed over
a framed bidi connection. The dispatch project
(`/workspace/@alkdev/dispatch/`) is a reverse runner that currently requires
an SSH server on the remote end; with `alknet-tty` and a local-process
backend, the same runner pattern works without SSH at all — the endpoint
runs the process directly and streams its I/O back. This is the same shape
as GitHub/Gitea Actions runners, just over alknet's transport instead of
HTTP polling.
## Sources Investigated
| Source | Path | Note |
|--------|------|------|
| alknet-docker POC | `/workspace/alknet-docker-poc/` | Validated raw chunk format, two-carriage model, bidirectional pumping against live docker. The POC's `src/raw.rs` is the seed of alknet-tty's wire format. |
| alknet-docker POC summary | `docs/research/alknet-docker/poc-summary.md` | Documents the two-carriage model (JSON negotiation → raw bytes), the three validated targets, the open unknowns. |
| alknet-ssh phase-0 findings | `docs/research/alknet-ssh/phase-0-findings.md` | DP-5 hedges PTY as an SSH concern; the channel decomposition (Layers 1-7) treats PTY as part of Layer 4 (Session/exec). This document dissolves that hedge. |
| alknet-core types | `crates/alknet-core/src/types.rs` | `ProtocolHandler`, `Connection`, `SendStream`, `RecvStream` — the handler interface alknet-tty implements. |
| alknet-call wire format | `crates/alknet-call/src/protocol/wire.rs` | `EventEnvelope`, `FrameFramedReader/Writer` — the JSON carriage layer alknet-tty uses for the initial `call.requested` negotiation frame. |
| alknet-call dispatch | `crates/alknet-call/src/protocol/dispatch.rs` | `handle_stream` (:295), `pump_stream` (:340) — the streaming pump pattern. alknet-tty's raw-carriage path is a sibling to this, not a consumer of it. |
| bollard source | `/workspace/bollard/src/` | `container.rs` (`attach_container` :540, `LogOutput` :96, `AttachContainerResults` :80), `read.rs` (`NewlineLogOutputDecoder` :32 — the 8-byte header format our chunk format mirrors), `exec.rs` (`StartExecResults` enum :99) |
| bollard examples | `/workspace/bollard/examples/attach_container.rs` | Reliable attach + TTY passthrough. |
| dispatch project | `/workspace/@alkdev/dispatch/` | The "reverse runner" — axum + russh SSH client for exec/forwarding/sync over Docker/vast.ai. `src/handlers.rs` (`start_job`, `job_status`, `job_logs`) is the runner pattern alknet-tty generalizes. Currently requires SSH on the remote; alknet-tty with a local-process backend removes that requirement. |
| russh source | `/workspace/russh/` | `server::Handler``pty_request` (allocates PTY), `window_change` (resize), `signal` (signal forwarding), `shell_request`/`exec_request`. These are the SSH-side operations a `SshTtyBackend` wraps. |
| alknet-runtime research | `docs/research/alknet-runtime/summary.md` | The "operation host" pattern — a node that exposes ops on a registry. alknet-tty is the same pattern for process execution: a node that can run a process and stream its I/O. |
| Rust std::process | stdlib | `Command`, `Stdio` (piped stdin/stdout/stderr), `Child::wait` (exit code). The local-process backend. The threading/deadlock caveat (must read stdout/stderr concurrently with writing stdin to avoid pipe-buffer deadlock) is handled by the bidirectional pump, same as docker attach. |
| **alknet-tty POC** | `/workspace/alknet-tty-poc/` | **Phase 0 local-PTY validation POC** (built 2026-07-05). Implements the chunk codec with `stream_type: 3` (control), the control message schema (resize/signal/eof/exit as JSON), and a `portable_pty`-backed `LocalPty` bridged to async via std threads + tokio mpsc. Two integration tests (`tests/integration.rs`, `tests/signal.rs`) validate the full round-trip: negotiate → PTY alloc → bidirectional echo via `cat` → mid-session resize → EOF → exit code; and SIGINT forwarding to `sleep` → non-zero exit. Source of the two new REQ-TTY-01 / REQ-TTY-02 requirements below. |
| portable_pty source | `~/.cargo/registry/src/.../portable-pty-0.9.0/src/lib.rs` | `PtySystem::openpty`, `MasterPty` (`try_clone_reader``Box<dyn Read + Send>`, `take_writer``Box<dyn Write + Send>`, `resize(&self)`), `SlavePty::spawn_command`, `Child` (`wait` blocks, `clone_killer``Send+Sync`, `process_id`). **Blocking std::io API, not async** — the load-bearing constraint that drives REQ-TTY-01. |
## The Wire Format: From POC to Spec
### What the alknet-docker POC validated
The POC's `src/raw.rs` defines a chunk format for raw carriage on a bidi
stream:
```text
[stream_type: u8][length: u32 be][payload bytes]
```
- `stream_type` mirrors bollard's `NewlineLogOutputDecoder` header byte
(`/workspace/bollard/src/read.rs:46`): 0=stdin, 1=stdout, 2=stderr.
- `length` is the payload length in bytes (u32 big-endian, max 16 MiB).
- A zero-length chunk is a sentinel (used for completion notification).
The POC proved this format works for:
- **server→client stdout/stderr**: each `LogOutput` from bollard's attach
stream becomes a chunk with the matching stream_type.
- **client→server stdin**: `ChunkWriter::write_stdin(bytes)` writes a
type-0 chunk; the server reads it and writes the bytes to bollard's
`container_input` (`AsyncWrite`).
- **completion**: when bollard's output stream ends (container exited),
the server sends a zero-length type-1 chunk as a "drained" sentinel.
### What alknet-tty adds
A terminal session needs two things the docker attach POC didn't:
1. **Control messages during the raw phase.** Window resize (SIGWINCH) and
signal forwarding (Ctrl-C → SIGINT) must ride *during* the byte stream,
not as a new request. The chunk format handles this by reserving a 4th
stream_type:
| stream_type | channel | direction | payload |
|---|---|---|---|
| 0 | data-in (stdin) | client→server | raw bytes |
| 1 | data-out (stdout) | server→client | raw bytes |
| 2 | data-err (stderr) | server→client | raw bytes |
| 3 | control | bidirectional | JSON control message |
Control chunks carry a small JSON payload:
- `{"type":"resize","cols":80,"rows":24,"pixel_width":0,"pixel_height":0}`
window resize (maps to SSH `window-change`, docker exec resize, or
`ioctl(TIOCSWINSZ)` on a local PTY).
- `{"type":"signal","name":"INT"}` — signal forwarding (maps to SSH
`signal`, docker exec signal, or `kill(pid, sig)` on a local process).
- `{"type":"eof"}` — client signals no more stdin (maps to SSH channel
EOF, docker stdin close, or `ChildStdin::drop`).
- `{"type":"exit","code":0}` — server signals process exit (terminal,
no more data chunks follow; the stream then closes).
2. **Terminal parameters at negotiation time.** The initial `call.requested`
frame (JSON carriage, same as the POC) carries the terminal attributes
that the backend needs to allocate the PTY:
```json
{
"operationId": "/tty/open",
"carriage": "raw",
"backend": "docker",
"container": "abc123",
"tty": {
"term": "xterm-256color",
"cols": 80,
"rows": 24,
"pixel_width": 0,
"pixel_height": 0,
"modes": {}
},
"cmd": ["/bin/bash"]
}
```
The `tty` block maps directly to SSH's `pty_request` parameters
(term, cols, rows, pixel_width, pixel_height, modes) and to docker's
`CreateExecOptions { tty: true }`. A local-process backend passes them
to `portable_pty::PtySystem::openpty` (or equivalent).
### Why fixed channel set, not extensible
SSH's channels are `ChannelId(u32)` with string-named types negotiated per
channel. alknet-tty's channels are a fixed `u8` set with no negotiation.
This is a one-way door (adding a 5th channel type is a wire-format change),
and it's the right one-way door:
- **The use cases are bounded.** A terminal session has stdin, stdout,
stderr, and control. If something genuinely new appears (say, a
sideband file-transfer channel alongside the terminal), that's a
different ALPN, not a 5th tty channel type. The ALPN model handles
extensibility at the protocol level — a new ALPN is cheap, a wire-format
change is not.
- **1 byte vs length-prefixed string + negotiation round-trip.** The fixed
set is faster, simpler, and the demuxing is a `match` instead of a hash
lookup. For a terminal session where every chunk is hot, this matters.
- **The comparison to SSH channels is the justification, not the
constraint.** SSH needs dynamic channels because it multiplexes
*arbitrary* services (forwarding, SFTP, agent, X11) over one connection.
alknet-tty multiplexes *one* service (a terminal session) with a fixed
channel structure. The impoverishment is the feature.
## The Backend Trait
The `TtyBackend` trait is the inversion point that keeps alknet-tty
decoupled from its backends:
```rust
#[async_trait]
pub trait TtyBackend: Send + Sync {
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
}
pub struct TtyParams {
pub backend_params: BackendParams, // backend-specific (container id, ssh host, command)
pub terminal: TerminalParams, // term, cols, rows, modes
pub cmd: Vec<String>,
}
pub enum BackendParams {
Docker { container: String },
Ssh { channel: SshChannelRef },
Local { cwd: Option<PathBuf>, env: HashMap<String, String> },
}
pub struct TtyHandle {
pub stdin: Box<dyn AsyncWrite + Send + Unpin>,
pub stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>>,
pub stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>>, // None if PTY (merged into stdout)
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
pub control: Box<dyn TtyControl + Send + Unpin>, // resize, signal
}
```
The `TtyAdapter` (the `ProtocolHandler` for `alknet/tty`) receives the
`Connection`, reads the `call.requested` frame, selects the backend by the
`backend` field, calls `allocate()`, and pumps bytes bidirectionally using
the chunk format. Control chunks are dispatched to `TtyHandle::control`.
When `exit_code` resolves, the server sends a `{"type":"exit","code":N}`
control chunk and closes the stream.
Three implementations, each in its own crate (the no-handler-depends-on-
another-handler rule from ADR-003 is preserved — backends depend on
alknet-tty for the trait, alknet-tty doesn't depend on them):
- **`DockerTtyBackend`** (in alknet-docker, or a thin adapter): wraps
`bollard::attach_container()` → `AttachContainerResults { output, input }`
for interactive attach, or `bollard::exec::start_exec` with `tty: true`
for exec-with-PTY. The POC's `drive_attach_raw` *is* this backend,
inlined; with the trait, it becomes `impl TtyBackend for DockerTtyBackend`.
`control.resize()` calls `bollard::exec::resize_exec` or
`bollard::container::resize_container`.
- **`SshTtyBackend`** (in alknet-ssh): wraps russh's `pty_request` +
`shell_request` (or `exec_request` with 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 a `window_change` channel request;
`control.signal()` sends a `signal` channel request. stdout and stderr
are merged (PTY property), so `TtyHandle.stderr` is `None`.
- **`LocalTtyBackend`** (in alknet-tty or a sibling crate): wraps
`std::process::Command` with `Stdio::piped()` for stdin/stdout/stderr,
OR `portable_pty` for a real PTY (needed for terminal escape sequences,
signal delivery, window resize). Without a PTY, it's a "runner" (piped
process); with a PTY, it's a terminal. `control.resize()` calls
`ioctl(TIOCSWINSZ)` on the PTY master; `control.signal()` calls
`kill(child.pid, sig)`. The threading/deadlock caveat (must read
stdout/stderr concurrently with writing stdin to avoid pipe-buffer
deadlock) is handled by the bidirectional pump — the same pattern as
docker attach, where `tokio::spawn` runs the two directions concurrently.
### The runner generalization
The `LocalTtyBackend` without a PTY is the "runner" pattern: a process
whose stdin/stdout/stderr/exit-code are streamed over a framed bidi
connection. This is functionally identical to GitHub/Gitea Actions runners,
just over alknet's transport instead of HTTP polling:
- A coordinator sends `{"backend":"local","cmd":["cargo","test"],"tty":null}`
— no terminal, just a command.
- The endpoint runs `cargo test` with piped stdio, streams stdout/stderr
chunks back, sends `{"type":"exit","code":N}` when it finishes.
- The coordinator gets reliable completion notification (the exit control
chunk + stream close) — the same stopgap property as the docker logs
subscription.
The dispatch project (`/workspace/@alkdev/dispatch/`) is a reverse runner
that currently requires an SSH server on the remote end (it uses russh to
exec commands and stream output). 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. This is "discuss afterwards" territory per the conversation,
but the trait shape preserves the option.
## What This Dissolves in alknet-ssh
### DP-5's PTY hedge
The alknet-ssh research (`phase-0-findings.md` DP-5) says:
> `shell_request` and `pty_request` default-reject; `exec_request`
> permitted (gated by ACL). This keeps alknet-ssh a focused forwarding/exec
> appliance rather than a general-purpose interactive login server.
> Interactive shell is an explicit opt-in (two-way door).
With alknet-tty, PTY is not an SSH feature — it's a tty feature. alknet-ssh
implements `TtyBackend` for SSH session channels; alknet-tty owns the
terminal session lifecycle. alknet-ssh's session channel (Layer 4) still
does `exec` (structured, JSON carriage, exit code on completion) but
*delegates* PTY to alknet-tty. The "default-reject" stance stays for the
SSH channel policy (alknet-ssh still rejects `pty_request` on its own
session channels — it doesn't serve terminals directly), but the PTY
capability is provided by a separate crate via a separate ALPN, not hedged
inside alknet-ssh.
### Layer 4 simplifies
The alknet-ssh build order was "1-4 first (SSH+exec), then 5 (forwarding),
then 6/7 (SOCKS5/SFTP)." PTY was a deferred wart on Layer 4. With
alknet-tty, Layer 4 is just `exec` (one-shot command, JSON carriage, exit
code on completion) — clean and complete. PTY is a *different ALPN*
(`alknet/tty`) that happens to use SSH as its backend.
### The browser case gets a terminal for free
The alknet-ssh research notes the browser runs a WASM SSH client over
WebTransport (ADR-040). But a browser terminal (xterm.js) doesn't want SSH
— it wants a terminal. With `alknet/tty` as an ALPN, xterm.js connects via
WebTransport to `/alknet/tty`, negotiates a session (docker container, SSH
PTY, or local process), and gets raw bytes. The browser doesn't need to
implement SSH at all for the terminal use case — it only needs SSH if it
wants SSH-specific features (port forwarding, SFTP). This is a cleaner
browser story than "run a WASM SSH client."
## Straightforward Parts
These are settled by the POC, existing ADRs, and the wire format above.
Phase 1 should document them as spec rather than re-litigate.
### 1. alknet-tty is a `ProtocolHandler` on `alknet/tty`
Same pattern as every other handler: `TtyAdapter` implements
`ProtocolHandler::handle(&self, connection: Connection, auth: &AuthContext)`
with `alpn() = b"alknet/tty"`. The handler owns the entire `Connection`
lifecycle (ADR-006) and accepts one bidi stream per terminal session.
### 2. The two-carriage model is inherited from the POC
The initial `call.requested` frame is JSON (length-prefixed `EventEnvelope`,
identical to alknet-call's `FrameFramedReader/Writer`). After the request,
the stream switches to raw chunks. The `carriage` field in the request
payload is `"raw"` for terminal sessions. This is the same mechanism the
POC validated; no new wire-format invention.
### 3. Raw chunk format is POC-validated
The `[stream_type: u8][length: u32 be][payload]` format, the `ChunkReader`/
`ChunkWriter` types, and the bidirectional pump pattern are all directly
from the POC's `src/raw.rs`. The only addition is `stream_type: 3` for
control messages, which is a 1-byte extension to a validated format.
### 4. Backend trait is the inversion point
alknet-tty defines `TtyBackend`; the backend crates (alknet-docker,
alknet-ssh, local) implement it. The `TtyAdapter` is backend-agnostic.
This preserves ADR-003's no-handler-depends-on-another-handler rule:
alknet-tty depends on alknet-core; the backend crates depend on alknet-tty
(for the trait); alknet-tty doesn't depend on any backend.
### 5. Completion notification is free
The exit control chunk (`{"type":"exit","code":N}`) + stream close gives
the coordinator deterministic completion notification — the same stopgap
property the docker POC validated for logs subscriptions. No plugin state,
no polling. The container/process exiting is the signal.
## Less Straightforward Parts (Decision Points)
### DP-1: Local-process backend in alknet-tty or a sibling crate?
*(Recommended: two-way door — start in alknet-tty, extract if warranted)*
The `LocalTtyBackend` (std::process::Command / portable_pty) is the
simplest backend and the one that enables the runner pattern. It has no
heavy dependencies (no bollard, no russh — just std + optionally
`portable_pty`). Two options:
- **(a) In alknet-tty**: the crate ships with the local backend built-in.
Pro: zero-config runner, one crate gets you a terminal/process-streaming
endpoint. Con: alknet-tty pulls in `portable_pty` even for deployments
that only use docker/ssh backends.
- **(b) In a sibling crate (`alknet-tty-local`)**: alknet-tty defines the
trait; the local backend is a separate crate. Pro: alknet-tty stays
dependency-light; consumers opt into the local backend explicitly. Con:
one extra crate for the common case.
**Recommendation**: **(b) sibling crate**, behind a feature flag on
alknet-tty for the common case (`features = ["local"]` → re-export from
`alknet-tty-local`). This keeps alknet-tty's default dependency surface
minimal while making the local backend a one-feature opt-in. The local
backend is where the `portable_pty` dependency lives; alknet-tty itself
depends only on alknet-core and the frame/raw codec. Extraction is cheap
because the trait is the seam.
### DP-2: PTY vs pipe for the local backend
*(Recommended: two-way door — support both, PTY is opt-in)*
`std::process::Command` with `Stdio::piped()` gives pipes (no terminal
semantics — no signal delivery, no window resize, no escape-sequence
handling). `portable_pty` gives a real PTY (terminal semantics, resize,
signals, escape sequences). The `TtyParams.terminal` field distinguishes:
if `terminal` is `Some(TerminalParams { ... })`, the backend allocates a
PTY; if `None`, it uses pipes (the runner case).
**Recommendation**: support both. The `TtyHandle.stderr` field is `None`
for PTY (stdout/stderr merged) and `Some` for pipes (separate streams).
The `control` field is a no-op impl for pipes (resize/signal don't apply
without a PTY — though `kill(pid, sig)` still works for signal forwarding).
The decision is per-session, not per-deployment.
### DP-3: Control message format — JSON vs binary
*(Recommended: two-way door — JSON first, binary if hot)*
Control chunks (stream_type 3) carry a JSON payload (`{"type":"resize",
"cols":80,"rows":24}`). This is consistent with the call protocol's
JSON-everything stance and easy to extend. A binary format
(`[control_type: u8][params...]`) would be faster but harder to extend and
inconsistent with the negotiation layer.
**Recommendation**: JSON first. Control messages are rare (resize happens
on window drag, signal on Ctrl-C) — the serialization cost is negligible
compared to the data chunks. If a hot control path appears (unlikely for
terminals), a binary format can be added as a `control_type` extension
without breaking the chunk format.
### DP-4: The threading/deadlock caveat for piped processes
*(Recommended: acknowledged constraint — the bidirectional pump handles it)*
`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.
**Recommendation**: Phase 1 records this as a known constraint with a
known solution (concurrent pumping). No design decision needed — the POC
already proved the pattern. The spec notes that `LocalTtyBackend` must use
the concurrent-pump pattern, not sequential read-then-write.
### DP-5: Exit code propagation — control chunk vs final data chunk
*(Recommended: one-way door — control chunk)*
The alknet-docker POC validated exit-code-on-final-`call.responded` for
the JSON carriage path (exec with exit code). The raw carriage path needs
a different mechanism because there's no `call.responded` after the raw
phase begins. Two options:
- **(a) Control chunk**: `{"type":"exit","code":N}` as the last chunk
before stream close. Clean, explicit, carries the code as structured
data.
- **(b) Final data chunk with exit code**: a special stdout chunk with an
exit-code payload. Hacky — overloads the data channel for metadata.
**Recommendation**: **(a) control chunk**. The exit code is control
metadata, not data. The control channel (stream_type 3) exists for exactly
this. The chunk is the last thing before stream close; the client reads it
and knows the process exited with code N. This is a one-way door because
clients will depend on the "exit chunk is last" invariant.
### DP-6: Multiple sessions per connection
*(Recommended: two-way door — one session per stream, multiple streams per connection)*
A `Connection` (ADR-007) can open/accept multiple bidi streams. Should one
`alknet/tty` connection host multiple terminal sessions (one per stream),
or one session per connection?
**Recommendation**: **one session per bidi stream, multiple streams per
connection**. This matches the call protocol's model (one operation per
stream, multiple operations per connection) and is the natural fit for
QUIC's stream multiplexing. A coordinator opens one connection to an
endpoint and launches multiple sessions (one stream each) for parallel
tasks. The `TtyAdapter::handle` accepts the connection and loops
`accept_bi`, dispatching each stream to a session — same pattern as
alknet-call's `Dispatcher::run_loop` (`protocol/dispatch.rs:369`).
## Recommended Approach
### Crate
`alknet-tty`, depends on `alknet-core` (for `ProtocolHandler`, `Connection`).
Defines the `TtyBackend` trait, the wire format (chunk codec + control
messages), and the `TtyAdapter` (`ProtocolHandler` for `alknet/tty`). Does
not depend on bollard, russh, or portable_pty — those are in the backend
crates.
### Build order
**Step 1: Wire format + TtyAdapter + mock backend.**
- Extract `raw.rs` from the POC into alknet-tty's wire format module.
- Add `stream_type: 3` (control) and the control message types
(resize, signal, eof, exit).
- Implement `TtyAdapter` with a mock backend (in-memory pipes) to validate
the full protocol: negotiate → pump → control → exit → close.
- **Result**: a working `alknet/tty` handler with no real backends, but
the wire format and session lifecycle are proven.
**Step 2: LocalTtyBackend (runner).**
- `alknet-tty-local` crate (or feature): `impl TtyBackend for LocalTtyBackend`
using `std::process::Command` with piped stdio.
- Validate the runner pattern: `cargo test` as the command, stream
stdout/stderr/exit over `alknet/tty`.
- Add `portable_pty` for the PTY case (terminal semantics, resize, signals).
- **Result**: a working runner/terminal endpoint with no docker or SSH
dependency.
- **Status (2026-07-05)**: the PTY case is validated by the
`/workspace/alknet-tty-poc` POC — control channel, resize, signal
forwarding, and exit-code propagation all proven against a real
`portable_pty` PTY. The piped-runner case (no PTY) remains unproven by
POC but is lower-risk (the docker POC already validated piped pumping).
**Step 3: DockerTtyBackend.**
- In alknet-docker: `impl TtyBackend for DockerTtyBackend` wrapping
`bollard::attach_container` / `exec with tty:true`.
- The POC's `drive_attach_raw` becomes this backend; the `TtyAdapter` calls
it via the trait.
- **Result**: docker containers as terminal sessions via `alknet/tty`.
**Step 4: SshTtyBackend.**
- In alknet-ssh: `impl TtyBackend for SshTtyBackend` wrapping russh's
`pty_request` + `shell_request`/`exec_request` on a session channel.
- `control.resize()` → `window_change` channel request;
`control.signal()` → `signal` channel request.
- **Result**: SSH PTYs as terminal sessions via `alknet/tty`. alknet-ssh's
DP-5 hedge dissolves — PTY is delegated to alknet-tty.
### De-risk POC (extending the alknet-docker POC)
The alknet-docker POC already validated targets 1 (attach round-trip), 2
(logs completion), and 3 (exec exit code). Two extensions validate the
alknet-tty additions:
1. **Control message during raw phase** — add `stream_type: 3` to the POC's
chunk format, send a `resize` control chunk mid-session, prove the
backend receives it. For docker this requires `tty: true` on the exec
and `bollard::exec::resize_exec`. Small POC, validates the control
channel mechanism.
**Status (2026-07-05)**: the control channel mechanism itself is now
validated by `/workspace/alknet-tty-poc` (resize + signal + eof + exit
all round-trip against a real PTY). The docker-specific variant
(`bollard::exec::resize_exec`) is still unproven but is a thin wrapper
over the same control-chunk path.
2. **PTY allocation via docker exec with TTY** — `CreateExecOptions { tty:
true }` allocates a real PTY. Validate that stdout/stderr merge
(stream_type always 1) and that resize works. Proves the docker-as-PTY-
backend path.
Both are extensions to the existing POC, not new POCs. The wire format and
bidirectional pump are already proven; these just confirm the control
channel and PTY-specific paths.
### De-risk POC: local PTY (built 2026-07-05)
A separate POC, `/workspace/alknet-tty-poc`, validates the local-PTY path
(Step 2 above) against `portable_pty` 0.9. It is not an extension of the
docker POC; it exists because the local backend has a constraint the
docker path doesn't — `portable_pty` is a blocking std::io API, and the
POC exists primarily to discover how that constraint shapes the
`TtyBackend` trait.
**What the POC validated:**
- **The `stream_type: 3` control channel works mid-session.** A resize
control chunk sent while `cat` is running reaches `MasterPty::resize`
without disturbing the data stream. SIGINT forwarding reaches the child
process group and kills it.
- **Exit code on a control chunk (DP-5) is the right call.** The
`{"type":"exit","code":N}` chunk fires after the child is reaped and is
the last control chunk before stream close. The one-way door holds.
- **JSON control messages (DP-3) are fine.** No measurable cost; control
chunks are rare (resize on window drag, one signal per Ctrl-C).
- **Signal forwarding must target the process group, not just the child.**
`libc::kill(-pgid, sig)` reaches the shell's children; `kill(pid, sig)`
alone leaves orphaned children. This works because `portable_pty` sets
the child as session leader when `controlling_tty` is true (the
default). See REQ-TTY-02 below.
**What the POC discovered (new requirements):**
See the "Requirements from the local-PTY POC" section below for
REQ-TTY-01 and REQ-TTY-02 — two constraints that fell out of building the
PTY bridge and that the Phase 1 spec must record. These were
open questions (OQ-TTY-01) or undocumented assumptions (signal delivery)
before the POC; doing the POC first turned them into grounded
requirements.
## Requirements from the local-PTY POC
Two requirements surfaced from building `/workspace/alknet-tty-poc` that
were not knowable from reading the portable_pty docs alone. They constrain
the `TtyBackend` trait shape (Phase 1's `tty-backend.md` spec) and the
local backend's signal-delivery contract (`tty-local.md`). Recording them
here so the Architect doesn't re-derive them.
### REQ-TTY-01: the `TtyBackend` trait must accommodate blocking backends
`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 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
(the primary portable_pty consumer) uses.
The Phase 1 `TtyHandle` sketch in §"The Backend Trait" above has
`stdin: Box<dyn AsyncWrite + Send + Unpin>`,
`stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>>`, and
`exit_code: BoxFuture<...>`. That shape *can* be satisfied by the local
backend via the channel bridge (tokio mpsc implements AsyncRead/AsyncWrite
via `tokio-util` codecs, and a `oneshot::Receiver` is a Future), but the
spec must state explicitly that:
1. **Backends are not required to be natively async.** A backend may
expose blocking handles internally and bridge them; the trait's
async-facing types are the *adapter-side* contract, not a constraint on
the backend's implementation.
2. **The bridging pattern (blocking → tokio mpsc/oneshot via std threads
or `spawn_blocking`) is a documented, supported implementation
strategy**, not a workaround. The local backend will use it. Other
blocking-API backends (if any) may use it too.
3. **`exit_code` should be a `Future` the adapter awaits, not a method on
`TtyHandle`.** This resolves the first half of OQ-TTY-01: 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.
This is the inversion of the usual "design the trait, then implement"
flow: building the POC *first* showed that the trait sketch was
*almost* right, but the assumption that backends would be natively async
was hidden, and would have surfaced as a re-spec in Phase 1 had we not
built it.
### 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), 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 Phase 1 `tty-local.md` spec must record:
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)`); the spec should
document that 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 and are sent to the group.
This resolves the signal-delivery half of OQ-TTY-01 and pre-empts a
class of "Ctrl-C doesn't kill my `cargo build`" bugs that would
otherwise surface in Phase 2/3.
## Open Questions to Carry into Phase 1
- **OQ-TTY-01 (backend trait shape)**: ~~the exact `TtyHandle` field set —
is `control` a separate trait object or are resize/signal methods on
`TtyHandle` directly? Does `exit_code` belong on the handle or is it a
separate `Future` the adapter awaits?~~ **Resolved 2026-07-05 by the
local-PTY POC** — see REQ-TTY-01: `exit_code` is a `Future` the adapter
awaits; backends may be blocking-API and bridge to async via std
threads + mpsc. The remaining open shape question is `control`: a
separate `Box<dyn TtyControl + Send + Unpin>` trait object (as the
sketch shows) vs methods on `TtyHandle`. The POC used a separate
cloneable `PtyControl` struct (resize + signal), which worked cleanly
because the control-chunk dispatcher needs to be `Clone` to hand off
to the spawned pump task. Phase 1 should confirm `control` as a
separate `Clone` trait object.
- **OQ-TTY-02 (terminal modes)**: SSH's `pty_request` carries TTY modes
(echo, raw, canonical, etc.) as a packed bitmask. Does alknet-tty
support these, or use the backend's defaults? The common case is
"default terminal modes" — the `modes` field in `TerminalParams` is
reserved for when a concrete use case requires mode control. Not
needed for the current scope.
- **OQ-TTY-03 (flow control)**: the chunk format has no windowing (QUIC
provides flow control on the bidi stream). Is this sufficient for
high-throughput stdout (e.g., `cargo build` output)? QUIC's per-stream
flow control should handle it, but a POC with real high-volume output
would confirm. Low risk — the docker POC's logs subscription handled
multi-line output without issue.
- **OQ-TTY-04 (local backend crate placement)**: confirm `alknet-tty-local`
as a sibling crate vs a feature flag on alknet-tty. DP-1 recommends
sibling + feature re-export; Phase 1 confirms.
- **OQ-TTY-05 (runner API surface)**: the "runner" generalization
(local-process backend without PTY) is noted as "discuss afterwards" in
the conversation. Phase 1 should at minimum preserve the option
(`TtyParams.terminal = None` → pipe mode) even if the runner-specific
API surface (job management, log persistence, task graph integration) is
deferred to a later crate.
## Next Steps (Phase 0 → Phase 1)
1. **POC extension (docker side)**: extend `/workspace/alknet-docker-poc`
with `stream_type: 3` (control) and `tty: true` exec to validate the
docker-specific control channel and PTY allocation. Timeboxed; the wire
format is already proven, these are extensions.
**Note (2026-07-05)**: the control channel mechanism itself and the
local-PTY path are now validated by `/workspace/alknet-tty-poc` (see
"Requirements from the local-PTY POC" above). What remains is the
docker-specific resize path (`bollard::exec::resize_exec`).
2. **You decide** on the DP recommendations (or amend them). DP-1 (local
backend placement) and DP-5 (exit code on control chunk, now
POC-validated) are the load-bearing choices. DP-2, DP-3 (now
POC-validated), DP-4, DP-6 are defaults recommended as-is.
3. **Phase 1 (Architect)**: produce `docs/architecture/crates/tty/README.md`
+ component specs (`tty-wire.md` for the chunk format + control
messages, `tty-backend.md` for the `TtyBackend` trait + `TtyHandle`,
`tty-adapter.md` for the `ProtocolHandler` + session lifecycle,
`tty-local.md` for the local backend / runner), ADRs for the accepted
DPs (wire format + fixed channel set, backend trait as inversion point,
local backend placement, exit code on control chunk), and the OQs above
in `open-questions.md`. Update `docs/architecture/README.md` index and
ADR table.
**Carry REQ-TTY-01 and REQ-TTY-02 into `tty-backend.md` and
`tty-local.md` respectively** — they are requirements, not open
questions, and the spec must state them as such. The POC at
`/workspace/alknet-tty-poc` is the reference implementation for both;
the Architect should read `src/local_pty.rs` (the blocking→async
bridge) and `src/session.rs` (the pump that consumes the bridged
handles) before drafting the trait spec.
## References
- `docs/research/alknet-docker/poc-summary.md` — the POC that seeded this
crate. Raw chunk format, two-carriage model, three validated targets.
- `/workspace/alknet-docker-poc/src/raw.rs` — the chunk codec
(`ChunkReader`, `ChunkWriter`, stream_type 0/1/2) that alknet-tty
extends with stream_type 3.
- `/workspace/alknet-docker-poc/src/ops.rs` — `drive_attach_raw` (the
bidirectional pump pattern, the session lifecycle) that the
`TtyAdapter` generalizes.
- `docs/research/alknet-ssh/phase-0-findings.md` — DP-5 (PTY hedge, dissolved
by this crate), the channel decomposition (Layers 1-7, PTY moves out of
Layer 4), the browser case (xterm.js over WebTransport to `/alknet/tty`).
- `docs/architecture/decisions/001-alpn-protocol-dispatch.md` — ALPN dispatch
- `docs/architecture/decisions/002-protocol-handler-trait.md` — ProtocolHandler
- `docs/architecture/decisions/007-bistream-type-definition.md` — Connection,
SendStream, RecvStream
- `docs/architecture/decisions/003-crate-decomposition.md` — no-handler-depends-
on-another-handler (alknet-tty depends on alknet-core; backends depend on
alknet-tty for the trait)
- `docs/architecture/decisions/040-webtransport-alpn-stream-proxy.md` —
WebTransport stream → `Connection` (the browser terminal path)
- `/workspace/bollard/src/read.rs` — `NewlineLogOutputDecoder` (the 8-byte
header format our chunk format mirrors)
- `/workspace/russh/` — `server::Handler` (`pty_request`, `window_change`,
`signal`) — the SSH operations a `SshTtyBackend` wraps
- `/workspace/@alkdev/dispatch/` — the reverse runner that currently requires
SSH; `LocalTtyBackend` removes that requirement
- `docs/research/alknet-runtime/summary.md` — the "operation host" pattern
(alknet-tty is the same pattern for process execution)
- **`/workspace/alknet-tty-poc/`** — **Phase 0 local-PTY validation POC
(built 2026-07-05)**. `src/raw.rs` (chunk codec + stream_type 3),
`src/control.rs` (JSON control schema), `src/local_pty.rs` (the
blocking→async bridge that drives REQ-TTY-01),
`src/session.rs` (the bidirectional pump / session lifecycle),
`tests/integration.rs` (`cat` echo + resize + EOF + exit),
`tests/signal.rs` (SIGINT forwarding to `sleep`).
- **`portable-pty` 0.9 source** (in `~/.cargo/registry/src/.../portable-pty-0.9.0/`)
— `src/lib.rs` (`PtySystem`, `MasterPty`, `SlavePty`, `Child`,
`ChildKiller` traits; blocking `std::io` API), `src/cmdbuilder.rs`
(`CommandBuilder`, `set_controlling_tty` — the spawn semantics
REQ-TTY-02 depends on). Read before drafting `tty-backend.md` /
`tty-local.md`.