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:
1 parent
14c8340380
commit
e4e6ccfb14
12 files changed
+2892
-1
No files matched your search
@@ -0,0 +1,319 @@
|
||||
# ADR-053: TtyBackend Trait and TtyHandle — the Backend Inversion Point
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
alknet-tty's wire format (ADR-052) is backend-agnostic — the chunk codec
|
||||
pumps bytes and JSON control messages without knowing whether the backend
|
||||
is a docker container, an SSH session channel, or a local process. The
|
||||
question this ADR answers: **what is the seam between the wire-format
|
||||
adapter and the backends?**
|
||||
|
||||
The alknet-tty research (`docs/research/alknet-tty/phase-0-findings.md`)
|
||||
identified the `TtyBackend` trait as the inversion point. The guiding
|
||||
insight:
|
||||
|
||||
> A terminal session is not an SSH concern, or a Docker concern — it is a
|
||||
> terminal concern. SSH and Docker are just two backends that can allocate
|
||||
> a PTY.
|
||||
|
||||
The trait is what makes that insight load-bearing: alknet-tty defines the
|
||||
trait and the wire-format adapter; the backend crates (alknet-docker,
|
||||
alknet-ssh, alknet-tty-local) implement the trait. This preserves ADR-003's
|
||||
no-handler-depends-on-another-handler rule (amended by ADR-003 Amendment 1
|
||||
for protocol-foundation crates): alknet-tty depends on alknet-core;
|
||||
backend crates depend on alknet-tty for the trait; alknet-tty does not
|
||||
depend on any backend.
|
||||
|
||||
### What the local-PTY POC discovered about the trait shape
|
||||
|
||||
The Phase 0 POC (`/workspace/alknet-tty-poc`) was built *before* this ADR
|
||||
specifically to discover constraints the trait sketch would have missed by
|
||||
reading docs alone. Two requirements fell out of it (recorded as
|
||||
REQ-TTY-01 and REQ-TTY-02 in the findings doc):
|
||||
|
||||
- **REQ-TTY-01: backends are not required to be natively async.**
|
||||
`portable_pty` is a blocking `std::io::{Read, Write}` API with a blocking
|
||||
`Child::wait()`. The POC bridges it to async via three dedicated std
|
||||
threads (reader, writer, waiter) feeding tokio mpsc/oneshot channels —
|
||||
the same pattern wezterm (portable_pty's primary consumer) uses. The
|
||||
trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
|
||||
`BoxFuture`) are the *adapter's* contract; a backend may expose blocking
|
||||
handles internally and bridge them. The bridging pattern is a
|
||||
documented, supported implementation strategy, not a workaround.
|
||||
|
||||
- **`exit_code` is a `Future` the adapter awaits, not a method on
|
||||
`TtyHandle`.** 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.
|
||||
|
||||
The POC's `LocalPty` struct (`src/local_pty.rs`) is the reference
|
||||
implementation of what a backend produces: `stdout: mpsc::Receiver<Bytes>`,
|
||||
`stdin: mpsc::Sender<StdinCmd>`, `control: PtyControl` (Clone), `exit_code:
|
||||
oneshot::Receiver<i32>`. The trait shape below generalizes this.
|
||||
|
||||
### What the local-PTY POC did not resolve
|
||||
|
||||
The POC used a separate cloneable `PtyControl` struct for resize/signal,
|
||||
not a `Box<dyn TtyControl + Send + Unpin>` trait object. The research
|
||||
noted this worked cleanly because the control-chunk dispatcher needs to be
|
||||
`Clone` to hand off to the spawned pump task. Phase 1 confirms the `control`
|
||||
field as a separate `Clone` trait object — see OQ-43 for the confirmation
|
||||
and the `Clone` constraint rationale.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. `TtyBackend` trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait TtyBackend: Send + Sync {
|
||||
/// Allocate a terminal/process session and return the handles the
|
||||
/// adapter pumps. The `backend` field of the negotiation frame
|
||||
/// (ADR-052) selects which registered backend's `allocate` is called.
|
||||
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
|
||||
}
|
||||
```
|
||||
|
||||
The adapter holds a `HashMap<String, Arc<dyn TtyBackend>>` keyed by the
|
||||
negotiation frame's `backend` string (`"local"`, `"docker"`, `"ssh"`).
|
||||
The assembly layer registers backends at startup; the adapter dispatches
|
||||
by the `backend` field. A backend is the *thing that allocates a session*;
|
||||
the wire-format pump is backend-agnostic.
|
||||
|
||||
### 2. `TtyParams` — the allocation request
|
||||
|
||||
```rust
|
||||
pub struct TtyParams {
|
||||
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case,
|
||||
/// ADR-054). `Some` = allocate a PTY with these dimensions.
|
||||
pub terminal: Option<TerminalParams>,
|
||||
/// Command vector (argv[0] + args).
|
||||
pub cmd: Vec<String>,
|
||||
/// Working directory (backend-specific; None = inherit/default).
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Environment variables (backend-specific; empty = inherit).
|
||||
pub env: HashMap<String, String>,
|
||||
/// Backend-specific selector fields from the negotiation frame
|
||||
/// (e.g., `container` for docker, `SshChannelRef` for ssh). The
|
||||
/// adapter parses the negotiation frame's backend-specific fields
|
||||
/// and passes them here. The exact shape is backend-defined; the
|
||||
/// adapter does not interpret it.
|
||||
pub backend_params: BackendParams,
|
||||
}
|
||||
|
||||
pub struct TerminalParams {
|
||||
pub term: Option<String>, // e.g., "xterm-256color"; None = backend default
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
pub pixel_width: u16,
|
||||
pub pixel_height: u16,
|
||||
pub modes: serde_json::Value, // reserved — see OQ-44
|
||||
}
|
||||
|
||||
pub enum BackendParams {
|
||||
Local,
|
||||
Docker { container: String },
|
||||
Ssh { channel: SshChannelRef },
|
||||
// Extensible: a backend crate may add variants. (See Consequences.)
|
||||
}
|
||||
```
|
||||
|
||||
`terminal: None` is the pipe/runner case — no PTY, separate stdout/stderr
|
||||
(ADR-054). `terminal: Some` is the PTY case — stdout/stderr merged into
|
||||
the single stdout stream (`TtyHandle.stderr` is `None`), real terminal
|
||||
semantics (resize, signal delivery to process group).
|
||||
|
||||
### 3. `TtyHandle` — what a backend produces
|
||||
|
||||
```rust
|
||||
pub struct TtyHandle {
|
||||
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
|
||||
pub stdin: Box<dyn AsyncWrite + Send + Unpin>,
|
||||
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
|
||||
/// Ends when the backend's stdout reaches EOF (process exited,
|
||||
/// container output stream ended, SSH channel closed).
|
||||
pub stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>>,
|
||||
/// Stderr stream — `None` for PTY backends (stdout/stderr merged
|
||||
/// into `stdout`). `Some` for pipe backends (separate streams).
|
||||
pub stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>>,
|
||||
/// Exit code — a `Future` the adapter awaits. Resolves when the
|
||||
/// process/container/SSH exec exits. The adapter sends the result
|
||||
/// as the `{"type":"exit","code":N}` control chunk (ADR-055) and
|
||||
/// closes the stream. This is `BoxFuture`, not a method on
|
||||
/// `TtyHandle`, so the adapter can `select` between exit and
|
||||
/// stream-close without coupling to the other fields. (REQ-TTY-01.)
|
||||
pub exit_code: BoxFuture<'static, Result<i32, TtyError>>,
|
||||
/// Control handle (resize, signal) — `Clone` so the adapter can
|
||||
/// hand it to the spawned control-chunk dispatcher. `None` only
|
||||
/// when the backend genuinely has no control path (e.g., a pipe
|
||||
/// backend with no PTY — signal still works via `kill(pid, sig)`,
|
||||
/// but resize is a no-op). See OQ-43 for the trait-object-vs-methods
|
||||
/// confirmation.
|
||||
pub control: Option<Box<dyn TtyControl + Send + Unpin + Clone>>,
|
||||
}
|
||||
|
||||
pub trait TtyControl: Send {
|
||||
/// Resize the terminal. Maps to SSH `window-change`, docker exec
|
||||
/// resize, or `ioctl(TIOCSWINSZ)` on a local PTY. No-op for pipe
|
||||
/// backends without a PTY (the adapter still calls it; the backend
|
||||
/// ignores).
|
||||
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16);
|
||||
|
||||
/// Forward a signal by name. Best-effort delivery to the foreground
|
||||
/// process group (see tty-local.md REQ-TTY-02). Unknown names fall
|
||||
/// back to the backend's default kill.
|
||||
fn signal(&self, name: &str);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Backends are not required to be natively async (REQ-TTY-01)
|
||||
|
||||
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
|
||||
`BoxFuture`, the `TtyControl` trait object) are the **adapter's contract**.
|
||||
A backend may expose blocking handles internally and bridge them to these
|
||||
async-facing types. The bridging pattern — blocking `std::io` on dedicated
|
||||
std threads or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot
|
||||
channels — is a **documented, supported implementation strategy**, not a
|
||||
workaround.
|
||||
|
||||
The local backend (ADR-054) uses this pattern: `portable_pty` is a blocking
|
||||
API, and the backend's `allocate()` spawns reader/writer/waiter threads
|
||||
that feed `mpsc::Receiver<Bytes>` (stdout), `mpsc::Sender<StdinCmd>` (stdin
|
||||
wrapped as `AsyncWrite`), and `oneshot::Receiver<i32>` (exit). The
|
||||
adapter consumes the bridged async-facing types and is unaware of the
|
||||
threading. See `tty-local.md` for the bridge details.
|
||||
|
||||
### 5. Backend registration and the assembly layer
|
||||
|
||||
The `TtyAdapter` does not know the set of backends at compile time — it
|
||||
holds a `HashMap<String, Arc<dyn TtyBackend>>` populated at construction.
|
||||
The assembly layer (the CLI binary) constructs backends with their
|
||||
dependencies (a `DockerTtyBackend` wraps a `bollard::Docker` client; an
|
||||
`SshTtyBackend` wraps an SSH session; a `LocalTtyBackend` takes no
|
||||
deps) and registers them:
|
||||
|
||||
```rust
|
||||
let mut backends = HashMap::new();
|
||||
backends.insert("local".into(), Arc::new(LocalTtyBackend::new()) as Arc<dyn TtyBackend>);
|
||||
backends.insert("docker".into(), Arc::new(DockerTtyBackend::new(docker_client)) as _);
|
||||
backends.insert("ssh".into(), Arc::new(SshTtyBackend::new(ssh_session)) as _);
|
||||
let tty_adapter = TtyAdapter::new(Arc::new(backends));
|
||||
```
|
||||
|
||||
A deployment that doesn't want docker registers only `local`. A browser
|
||||
terminal endpoint that proxies to remote docker/ssh registers `docker`
|
||||
and/or `ssh` backends. The adapter is backend-agnostic; the assembly
|
||||
layer chooses what's available.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The wire-format adapter is backend-agnostic and testable with a mock
|
||||
backend (in-memory pipes). The build order (ADR-052 wire format + mock
|
||||
backend first, real backends last) follows directly.
|
||||
- alknet-tty stays dependency-light: no bollard, no russh, no
|
||||
`portable_pty` in the core crate. The heavy deps live in the backend
|
||||
crates. This is the same inversion as `OperationAdapter` (ADR-017):
|
||||
the trait lives where the types live; the implementations live where
|
||||
their transport dependencies live.
|
||||
- Blocking-API backends (portable_pty) are first-class — the trait
|
||||
accommodates them by making the adapter-facing types the contract and
|
||||
the bridging pattern a documented strategy. No re-spec required when a
|
||||
future backend is also blocking.
|
||||
- `exit_code` as a `Future` (not a method on the handle) lets the adapter
|
||||
`select` between exit and stream-close — the load-bearing composition
|
||||
the session lifecycle needs (the exit chunk is sent after the child is
|
||||
reaped, then the stream closes — ADR-055).
|
||||
|
||||
**Negative:**
|
||||
|
||||
- `BackendParams` is an enum, which means a new backend crate adding a
|
||||
variant modifies the enum. This is a two-way door (the enum is
|
||||
`#[non_exhaustive]`; a new variant is additive, and the adapter's
|
||||
`match` has a `_ => unsupported backend` arm), but it means
|
||||
`BackendParams` lives in alknet-tty and backend crates extend it rather
|
||||
than defining their own. An alternative — backend-specific params as
|
||||
`serde_json::Value` — was rejected: it loses type safety and forces the
|
||||
adapter to parse backend-specific JSON it shouldn't interpret. The
|
||||
enum-with-`#[non_exhaustive]` trade is the right one: the adapter
|
||||
dispatches by string key, the params enum carries the typed shape, and
|
||||
new backends add variants additively.
|
||||
- `Box<dyn TtyControl + Send + Unpin + Clone>` is an unusual trait-object
|
||||
bound (`Clone` on a trait object requires a workaround — typically a
|
||||
custom clone-via-`Arc` or a `Box<dyn TtyControl>` wrapped in a small
|
||||
`Clone` newtype). This is the cost of the POC-discovered constraint
|
||||
that the control-chunk dispatcher needs to be `Clone` to hand off to
|
||||
the spawned pump task. See OQ-43 for the confirmation and the concrete
|
||||
`Clone` newtype approach.
|
||||
- A backend that produces neither a PTY nor a process (a hypothetical
|
||||
"recorded session replay" backend) would have a no-op `TtyControl` and
|
||||
a synthetic `exit_code`. The trait accommodates it but the `TtyParams`
|
||||
shape (`cmd` is `Vec<String>`, `terminal` is `Option`) assumes
|
||||
command-spawning. A non-command backend would need a different
|
||||
`BackendParams` variant and a backend-internal `cmd` synthesis. Not a
|
||||
current use case; the trait shape doesn't preclude it but doesn't
|
||||
optimize for it.
|
||||
|
||||
## Door type
|
||||
|
||||
**One-way.** The `TtyBackend` trait, `TtyHandle` field set, and
|
||||
`TtyControl` trait are the API surface every backend crate implements and
|
||||
the adapter consumes. Changing the trait shape after backends exist is a
|
||||
rewrite across crates. The `BackendParams` enum is `#[non_exhaustive]`
|
||||
(additive — two-way for new variants), but the trait and handle shapes are
|
||||
one-way.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **The `TtyControl` trait object can be made `Clone` via a small
|
||||
newtype.** The POC used a concrete `PtyControl` struct (inherently
|
||||
`Clone`). The trait-object form needs a `Clone` newtype wrapping the
|
||||
`Box` (e.g., a struct holding `Arc<dyn TtyControl>` so `Clone` is an
|
||||
`Arc` clone). This is a known Rust pattern; OQ-43 confirms the
|
||||
approach.
|
||||
|
||||
2. **Backends produce a single session per `allocate()` call.** The
|
||||
adapter calls `allocate()` once per accepted bidi stream (one session
|
||||
per stream — ADR-052). A backend that multiplexed multiple sessions
|
||||
over one `allocate()` would not fit the trait; no such backend is
|
||||
contemplated.
|
||||
|
||||
3. **The adapter, not the backend, owns the exit-chunk ordering.** The
|
||||
backend resolves `exit_code`; the adapter awaits it, sends the exit
|
||||
control chunk, and closes the stream (ADR-055). The backend does not
|
||||
write to the wire — it produces handles; the adapter pumps. This keeps
|
||||
the wire-format logic in one place (the adapter) and the backend
|
||||
focused on its allocation target (docker, ssh, local process).
|
||||
|
||||
## References
|
||||
|
||||
- `docs/research/alknet-tty/phase-0-findings.md` — Phase 0 research; §"The
|
||||
Backend Trait" is the seed of this ADR; §"Requirements from the
|
||||
local-PTY POC" (REQ-TTY-01) is the load-bearing constraint
|
||||
- `/workspace/alknet-tty-poc/src/local_pty.rs` — the reference
|
||||
implementation of what a backend produces (`LocalPty`: stdout mpsc,
|
||||
stdin mpsc, control `PtyControl` (Clone), exit oneshot)
|
||||
- `/workspace/alknet-tty-poc/src/session.rs` — the adapter-side pump that
|
||||
consumes `TtyHandle`-shaped fields (the reference for how the adapter
|
||||
uses the trait)
|
||||
- [ADR-003](003-crate-decomposition.md) + Amendment 1 — no-handler-depends-
|
||||
on-another-handler; alknet-tty depends on alknet-core; backends depend
|
||||
on alknet-tty for the trait
|
||||
- [ADR-007](007-bistream-type-definition.md) — `Connection`, `SendStream`,
|
||||
`RecvStream` (the adapter receives a `Connection`, accepts bidi streams,
|
||||
pumps per-session)
|
||||
- [ADR-052](052-alknet-tty-wire-format-and-two-carriage.md) — the wire
|
||||
format this trait's backends feed
|
||||
- [ADR-054](054-local-tty-backend-sibling-crate.md) — the local backend's
|
||||
crate placement (sibling crate, behind a feature re-export)
|
||||
- [ADR-055](055-exit-code-on-control-chunk.md) — the exit chunk ordering
|
||||
this trait's `exit_code` field feeds into
|
||||
- OQ-43 — `TtyControl` as `Clone` trait object (resolved: confirmed)
|
||||
- OQ-44 — terminal modes (deferred(scope): not needed for current scope)
|
||||
- Spec: [crates/tty/tty-backend.md](../crates/tty/tty-backend.md)
|
||||
Reference in new issue
Block a user