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
426 lines
21 KiB
Markdown
426 lines
21 KiB
Markdown
---
|
|
status: draft (ported from alknet 2026-08-17; alknet-tty → alktty,
|
|
alknet/tty → alk/tty, alknet-core → alkcall::core, alknet-call →
|
|
alkcall, ADRs renumbered 052..093 → 001..008)
|
|
last_updated: 2026-08-17
|
|
---
|
|
|
|
# alktty — TtyBackend Trait and TtyHandle
|
|
|
|
The `TtyBackend` trait is the inversion point that keeps alktty
|
|
decoupled from its backends. alktty defines the trait, the `TtyParams`
|
|
allocation request, the `TtyHandle` a backend produces, and the
|
|
`TtyControl` trait; the backend crates (alktty's own `local` feature
|
|
module, future `alknet-docker`, `alknet-ssh`) implement `TtyBackend`.
|
|
This document specifies what an implementer builds against. The trait
|
|
shape is decided in [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md).
|
|
|
|
## What
|
|
|
|
The `TtyBackend` trait is what the `TtyAdapter` calls to allocate a
|
|
terminal/process session. The adapter holds a
|
|
`HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation frame's
|
|
`backend` string (`"local"`, `"docker"`, `"ssh"`). On a new session, the
|
|
adapter reads the negotiation frame, selects the backend by the
|
|
`backend` field, calls `allocate()`, and pumps the resulting
|
|
`TtyHandle`'s fields bidirectionally using the chunk format
|
|
(ADR-001). The backend does not write to the wire — it produces handles;
|
|
the adapter pumps.
|
|
|
|
Three implementations are contemplated, each in its own crate (the
|
|
no-handler-depends-on-another-handler rule from alknet ADR-003 is
|
|
preserved — backends depend on alktty for the trait, alktty doesn't
|
|
depend on them):
|
|
|
|
- **`LocalTtyBackend`** (in alktty's `local` feature module,
|
|
[ADR-003](decisions/003-local-backend-placement.md)) — wraps
|
|
`portable_pty` for the PTY case and `std::process::Command` with
|
|
`Stdio::piped()` for the pipe/runner case. See
|
|
[tty-local.md](tty-local.md).
|
|
- **`DockerTtyBackend`** (in `alknet-docker` or a sibling adapter crate
|
|
— future, out of scope here) — wraps `bollard::attach_container()` for
|
|
interactive attach or `bollard::exec::start_exec` with `tty: true` for
|
|
exec-with-PTY. `control.resize()` calls `bollard::exec::resize_exec`
|
|
or `bollard::container::resize_container`. stdout/stderr are merged
|
|
when `tty: true` (bollard's `LogOutput` on a TTY exec returns
|
|
`StdOut` only), so `TtyHandle.stderr` is `None` for the PTY case.
|
|
- **`SshTtyBackend`** (in `alknet-ssh` — future, out of scope here) —
|
|
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`.
|
|
|
|
The docker and SSH backend crates are future work; this spec set commits
|
|
the trait shape they will implement, so they can be built against it
|
|
without re-spec'ing the seam.
|
|
|
|
## Why
|
|
|
|
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 `TtyBackend` trait is what makes
|
|
that insight load-bearing — alktty owns the wire format and session
|
|
lifecycle; the backends own PTY allocation. The full rationale (the
|
|
inversion point, why the trait is the seam) is in
|
|
[ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) §Context.
|
|
|
|
The Phase 0 local-PTY POC was built *before* this spec specifically to
|
|
discover constraints the trait sketch would have missed by reading docs
|
|
alone. Two requirements fell out, recorded as REQ-TTY-01 and REQ-TTY-02
|
|
in the alknet research findings; this spec carries REQ-TTY-01 here
|
|
(backends need not be natively async) and [tty-local.md](tty-local.md)
|
|
carries REQ-TTY-02 (signal forwarding to the process group).
|
|
|
|
## Architecture
|
|
|
|
### `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-001) selects which registered backend's `allocate` is called.
|
|
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError>;
|
|
|
|
/// The pre-existing resource this session targets, for ownership
|
|
/// checks (alknet ADR-050). `None` = no pre-existing resource (the session
|
|
/// creates its own — local process, SSH channel). `Some((kind, id))`
|
|
/// = the session targets an existing resource the caller must own
|
|
/// (e.g., DockerTtyBackend returns `Some(("container", id))`). The
|
|
/// adapter calls this at negotiation to gate access; the backend
|
|
/// extracts the id from its own `backend_params`. Default `None`.
|
|
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { None }
|
|
}
|
|
```
|
|
|
|
The adapter holds `HashMap<String, Arc<dyn TtyBackend>>` populated at
|
|
construction. The assembly layer (the CLI binary) constructs backends
|
|
with their dependencies and registers them. A backend is the *thing
|
|
that allocates a session*; the wire-format pump is backend-agnostic.
|
|
|
|
### `TtyError`
|
|
|
|
The error type for `allocate()` and `exit_code`. `#[non_exhaustive]` so
|
|
new variants are additive (two-way-door extension within the one-way
|
|
trait shape — ADR-002).
|
|
|
|
```rust
|
|
#[non_exhaustive]
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum TtyError {
|
|
#[error("allocate failed: {message}")]
|
|
AllocFailed { message: String },
|
|
#[error("wait failed: {message}")]
|
|
WaitFailed { message: String },
|
|
#[error("io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("backend-specific: {message}")]
|
|
Backend { message: String },
|
|
}
|
|
```
|
|
|
|
- `AllocFailed` — the PTY couldn't be allocated, the docker exec failed
|
|
to start, the SSH channel request was rejected. Returned by
|
|
`allocate()`; the adapter sends `{"error":"allocate_failed",...}` and
|
|
closes (tty-adapter.md §"Negotiation errors").
|
|
- `WaitFailed` — the backend couldn't reap the child / determine the
|
|
exit code. Returned by the `exit_code` future; the adapter sends
|
|
`{"type":"exit","code":-1}` (ADR-004 §4).
|
|
- `Io` — an I/O error from a backend's stream/handle.
|
|
- `Backend` — backend-specific error not covered by the above (e.g., a
|
|
bollard API error, a russh protocol error).
|
|
|
|
### `TtyParams` — the allocation request
|
|
|
|
```rust
|
|
pub struct TtyParams {
|
|
/// Terminal parameters. `None` = pipe mode (no PTY — the runner case,
|
|
/// ADR-003). `Some` = allocate a PTY with these dimensions.
|
|
pub terminal: Option<TerminalParams>,
|
|
/// Command vector (argv[0] + args). Non-empty.
|
|
pub cmd: Vec<String>,
|
|
/// Working directory (None = inherit/default).
|
|
pub cwd: Option<PathBuf>,
|
|
/// Environment variables (empty = inherit).
|
|
pub env: HashMap<String, String>,
|
|
/// Backend-specific selector fields from the negotiation frame,
|
|
/// unparsed. The adapter passes the JSON object through verbatim; the
|
|
/// backend deserializes its own strongly-typed params struct from it.
|
|
/// alktty has zero knowledge of any backend's params shape.
|
|
/// See "Backend params are opaque" below.
|
|
pub backend_params: serde_json::Map<String, serde_json::Value>,
|
|
}
|
|
|
|
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 — OQ-44; backends MUST ignore content in v1
|
|
}
|
|
```
|
|
|
|
`terminal: None` is the pipe/runner case — no PTY, separate
|
|
stdout/stderr. `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). The
|
|
per-session choice is the backend's branch in `allocate()`, not a
|
|
per-deployment choice — see ADR-003.
|
|
|
|
### Backend params are opaque
|
|
|
|
`backend_params` is a `serde_json::Map<String, serde_json::Value>`, not
|
|
a typed enum. The adapter passes the negotiation frame's
|
|
backend-specific fields through verbatim; the backend deserializes its
|
|
own strongly-typed params struct. alktty has zero knowledge of any
|
|
backend's params shape — not docker's `container`, not an SSH host
|
|
selector, not anything. Each backend defines its own params struct and
|
|
deserializes from `params.backend_params` inside `allocate()`:
|
|
|
|
```rust
|
|
// in alknet-docker
|
|
#[derive(Deserialize)]
|
|
struct DockerBackendParams { container: String }
|
|
|
|
impl TtyBackend for DockerTtyBackend {
|
|
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError> {
|
|
let p: DockerBackendParams = serde_json::from_value(
|
|
serde_json::Value::Object(params.backend_params.clone())
|
|
).map_err(|e| TtyError::Backend { message: e.to_string() })?;
|
|
// use p.container ...
|
|
}
|
|
fn resource_id(&self, params: &TtyParams) -> Option<(&'static str, String)> {
|
|
// extract for the ownership check — backend-driven, not adapter-hardcoded
|
|
let p: DockerBackendParams = serde_json::from_value(
|
|
serde_json::Value::Object(params.backend_params.clone())
|
|
).ok()?;
|
|
Some(("container", p.container))
|
|
}
|
|
}
|
|
```
|
|
|
|
This is a complete inversion: the *trait* is inverted (backends
|
|
implement, alktty doesn't depend on them) and the *params* are
|
|
inverted (backends define their own typed shape, alktty doesn't carry
|
|
it). A new backend crate requires zero changes to alktty — no
|
|
enum variant to add, no forward-reference type to place, no dependency
|
|
edge. See ADR-002 §"Backend params are opaque" for the full rationale
|
|
and why the typed-enum alternative (with `SshChannelRef`) was rejected.
|
|
|
|
### `TtyHandle` — what a backend produces
|
|
|
|
```rust
|
|
pub struct TtyHandle {
|
|
/// Stdin writer — bytes the adapter pumps from client stdin chunks.
|
|
/// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io`
|
|
/// one — they are incompatible traits; the tokio stack is the
|
|
/// adapter's runtime).
|
|
pub stdin: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
|
|
/// Stdout stream — bytes the adapter pumps to client stdout chunks.
|
|
/// Ends when the backend's stdout reaches EOF.
|
|
/// `futures_core::Stream<Item = bytes::Bytes>` (re-exported by
|
|
/// `tokio_stream::StreamExt` for extension methods).
|
|
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::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 futures_core::Stream<Item = bytes::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-004) 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. See OQ-43.
|
|
pub control: Option<TtyControlHandle>,
|
|
}
|
|
```
|
|
|
|
### `TtyControl` trait and `TtyControlHandle`
|
|
|
|
```rust
|
|
pub trait TtyControl: Send + Sync {
|
|
/// 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.
|
|
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);
|
|
}
|
|
|
|
/// The `Clone`-able handle to a backend's control path. The `TtyControl`
|
|
/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) ->
|
|
/// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability
|
|
/// lives on this concrete newtype, which holds the trait object behind an
|
|
/// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned
|
|
/// control-chunk dispatcher. See OQ-43.
|
|
#[derive(Clone)]
|
|
pub struct TtyControlHandle(Arc<dyn TtyControl + Send + Sync>);
|
|
|
|
impl TtyControlHandle {
|
|
pub fn new(control: Arc<dyn TtyControl + Send + Sync>) -> Self { Self(control) }
|
|
pub fn resize(&self, c: u16, r: u16, pw: u16, ph: u16) { self.0.resize(c, r, pw, ph) }
|
|
pub fn signal(&self, name: &str) { self.0.signal(name) }
|
|
}
|
|
```
|
|
|
|
The trait is kept object-safe by NOT putting `Clone` on it; the `Clone`
|
|
newtype (`TtyControlHandle`) holds the trait object behind an `Arc`. The
|
|
POC used a concrete `PtyControl` struct (inherently `Clone` — it held
|
|
`Arc<Mutex<...>>` fields); this newtype generalizes the POC's shape so a
|
|
backend produces its own control type via `TtyControlHandle::new(Arc::new(MyControl))`
|
|
without the adapter knowing the concrete shape. See OQ-43 for the
|
|
confirmation and the rationale for why `Clone` cannot live on the trait
|
|
itself.
|
|
|
|
### REQ-TTY-01: backends are not required to be natively async
|
|
|
|
`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/oneshot 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
|
|
(portable_pty's primary consumer) uses.
|
|
|
|
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
|
|
`BoxFuture`, `TtyControl`) 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.
|
|
|
|
This resolves the first half of OQ-TTY-01 (the research's open question
|
|
on the trait shape): `exit_code` is a `Future` the adapter awaits; 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. See [tty-local.md](tty-local.md) for the bridge
|
|
details.
|
|
|
|
### Backend registration and the assembly layer
|
|
|
|
```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, "alk".into())) as Arc<dyn TtyBackend>);
|
|
backends.insert("ssh".into(),
|
|
Arc::new(SshTtyBackend::new(ssh_session)) as Arc<dyn TtyBackend>);
|
|
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.
|
|
|
|
### Backend implementations (where they live)
|
|
|
|
| Backend | Crate | Status | Notes |
|
|
|---------|-------|--------|-------|
|
|
| `LocalTtyBackend` | alktty's `local` feature module ([ADR-003](decisions/003-local-backend-placement.md)) | in scope ([tty-local.md](tty-local.md)) | `portable_pty` (PTY) + `std::process` (pipe); the runner pattern |
|
|
| `DockerTtyBackend` | `alknet-docker` (behind `tty` feature) | future, out of scope here | wraps `bollard::attach_container` / `exec` with `tty: true`; attach vs exec mode |
|
|
| `SshTtyBackend` | `alknet-ssh` | future, out of scope here | wraps russh `pty_request` + `shell_request`/`exec_request`; dissolves alknet-ssh DP-5 PTY hedge |
|
|
|
|
The SSH backend crate is future work; this spec commits the
|
|
trait shape it implements so it can be built against it without
|
|
re-spec'ing the seam. The `DockerTtyBackend` is future work in
|
|
`alknet-docker` — the natural extension of the alknet-docker POC's
|
|
`drive_attach_raw` — with the trait, it becomes `impl TtyBackend for
|
|
DockerTtyBackend`. The `SshTtyBackend` dissolves the alknet-ssh
|
|
research's PTY hedge (DP-5): alknet-ssh's session channel still does
|
|
`exec` (structured, JSON carriage, exit code on completion) but
|
|
*delegates* PTY to alktty via the `SshTtyBackend`. alknet-ssh's
|
|
"default-reject" stance stays for the SSH channel policy (it rejects
|
|
`pty_request` on its own session channels), but the PTY capability is
|
|
provided by a separate crate via a separate ALPN (`alk/tty`), not hedged
|
|
inside alknet-ssh.
|
|
|
|
## Constraints
|
|
|
|
- **The trait shape is one-way (ADR-002).** The `TtyBackend` trait
|
|
method `allocate()`, the `TtyHandle` field set, and the `TtyControl`
|
|
trait are the API surface every backend crate implements and the
|
|
adapter consumes. Changing them after backends exist is a rewrite
|
|
across crates.
|
|
- **Backend params are opaque (`serde_json::Map`), not a typed enum.**
|
|
The carrier type is one-way (part of `TtyParams`), but the *contents*
|
|
are backend-defined: each backend deserializes its own
|
|
strongly-typed params struct, and a new backend crate requires zero
|
|
changes to alktty. See "Backend params are opaque" above and
|
|
ADR-002 §"Backend params are opaque."
|
|
- **The adapter, not the backend, owns the wire format.** Backends
|
|
produce handles; the adapter pumps. A backend that wrote to the wire
|
|
directly would break the wire-format invariants (the exit-chunk
|
|
ordering, ADR-004). The backend's `exit_code` future resolves and the
|
|
adapter sends the exit chunk — the backend does not serialize
|
|
`ControlMessage::Exit`.
|
|
- **PTY backends merge stdout/stderr.** `TtyHandle.stderr` is `None` for
|
|
the PTY case (kernel PTY property — one output stream from the slave).
|
|
The adapter pumps only stdout chunks (stream_type 1). Pipe backends
|
|
set `stderr: Some` and the adapter pumps both stdout (stream_type 1)
|
|
and stderr (stream_type 2) chunks.
|
|
- **`TtyControl::signal` is best-effort.** The contract is "best-effort
|
|
delivery to the foreground process group," not "the child pid receives
|
|
the signal." See [tty-local.md](tty-local.md) REQ-TTY-02 for the
|
|
process-group targeting and the fallback to the backend's default kill.
|
|
- **Dropping the `exit_code` future MUST kill the session target
|
|
([ADR-005](decisions/005-backend-cleanup-on-session-cancel.md)).** The
|
|
`exit_code` field is a `BoxFuture<'static, Result<i32, TtyError>>`
|
|
whose `Drop`-on-cancel (i.e., dropped without being driven to
|
|
completion) MUST kill the child/container/SSH process. This is a
|
|
behavioral contract on the `TtyBackend` trait — the adapter triggers
|
|
it by dropping the `TtyHandle` on session cancel (connection drop,
|
|
stream reset); the backend wires the kill into the `exit_code`
|
|
future's `Drop`. A backend that returns a bare `oneshot::Receiver<i32>`
|
|
(or any future without a kill-on-`Drop` guard) as `exit_code`
|
|
violates the contract and will orphan processes on cancel. See
|
|
[ADR-005](decisions/005-backend-cleanup-on-session-cancel.md)
|
|
and [tty-local.md](tty-local.md) §"Cancel-Cleanup (ADR-005)" for the
|
|
local backend's mechanism.
|
|
|
|
## Design Decisions
|
|
|
|
| Decision | ADR | Summary |
|
|
|----------|-----|---------|
|
|
| `TtyBackend` trait and `TtyHandle` | [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) | The backend inversion point; `exit_code` as `Future`; backends need not be natively async (REQ-TTY-01) |
|
|
| Local backend placement | [ADR-003](decisions/003-local-backend-placement.md) | alktty folds the local backend in behind a `local` feature (resolves the alknet cyclic-dep workaround) |
|
|
| Wire format | [ADR-001](decisions/001-wire-format-and-two-carriage.md) | The chunk codec + control channel the adapter pumps to/from these handles |
|
|
| Exit code on a control chunk | [ADR-004](decisions/004-exit-code-on-control-chunk.md) | The adapter awaits `exit_code`, sends the exit chunk, closes |
|
|
| Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future (cancel) MUST kill the session target; contract on the `TtyBackend` trait |
|
|
|
|
## Open Questions
|
|
|
|
- **OQ-43** (resolved): `TtyControl` as a `Clone` trait object.
|
|
- **OQ-44** (deferred(scope)): Terminal modes.
|
|
|
|
## References
|
|
|
|
- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the
|
|
trait shape decision (this spec is its elaboration)
|
|
- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire
|
|
format the adapter pumps to/from these handles
|
|
- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the
|
|
exit-chunk ordering the `exit_code` field feeds into
|
|
- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) — the
|
|
cancel-cleanup contract on this trait (`exit_code` future's
|
|
`Drop`-on-cancel kills the session target)
|
|
- [ADR-003](decisions/003-local-backend-placement.md) — the local
|
|
backend's placement (folded into alktty behind a `local` feature)
|
|
- `src/backend.rs` — the Rust source this spec documents
|
|
- [tty-local.md](tty-local.md) — the `LocalTtyBackend` spec (carries
|
|
REQ-TTY-02: signal forwarding to the process group)
|
|
- [tty-adapter.md](tty-adapter.md) — the session driver that consumes
|
|
these handles |