Files
alknet/tasks/tty/backend-trait.md
T

239 lines
11 KiB
Markdown

---
id: tty/backend-trait
name: Implement TtyBackend trait, TtyHandle, TtyControl, TtyParams, TtyError (ADR-053)
status: completed
depends_on: [tty/crate-init]
scope: moderate
risk: high
impact: phase
level: implementation
---
## Description
Implement the `TtyBackend` trait and its associated types in `src/backend.rs`.
This is the **inversion point** (ADR-053) between the wire-format adapter and
the backends. alknet-tty defines the trait; the backend crates
(`alknet-tty-local`, future `alknet-docker`, `alknet-ssh`) implement it. The
trait shape is a **one-way door** (ADR-053) — changing it after backends exist
is a rewrite across crates. Get it right.
### 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 pre-existing resource this session targets, for ownership
/// checks (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. A backend is the *thing that allocates a session*; the
wire-format pump is backend-agnostic.
### TtyError
`#[non_exhaustive]` so new variants are additive (two-way-door extension
within the one-way trait shape — ADR-053).
```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` — PTY couldn't be allocated, docker exec failed, SSH channel
rejected. Returned by `allocate()`; adapter sends `allocate_failed` error.
- `WaitFailed` — backend couldn't reap the child / determine exit code.
Returned by the `exit_code` future; adapter sends `{"type":"exit","code":-1}`.
- `Io` — I/O error from a backend's stream/handle.
- `Backend` — backend-specific error not covered by the above.
### 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). 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.
/// alknet-tty has zero knowledge of any backend's params shape.
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
}
```
Provide a conversion `From<NegotiateRequest> for TtyParams` (or a constructor)
that the adapter uses — mapping `NegotiateRequest.tty: Option<TerminalParamsWire>`
to `TtyParams.terminal: Option<TerminalParams>`, and passing `backend_params`
through verbatim. This conversion lives here (in `backend.rs` or
`negotiation.rs`) so the adapter doesn't hand-roll it.
### 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 `futures::io`).
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.
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-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. 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); the `Clone`-ability
/// lives on this concrete newtype, which holds the trait object behind an
/// `Arc`. 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`. A backend produces
its own control type via `TtyControlHandle::new(Arc::new(MyControl))` without
the adapter knowing the concrete shape (OQ-43).
### REQ-TTY-01: backends are not required to be natively async
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 via
std threads + tokio mpsc/oneshot (the pattern `portable_pty` requires). This is
a documented, supported implementation strategy, not a workaround. The local
backend (task `tty-local/pty-mode`) is the reference implementation.
### ADR-056: kill-on-Drop contract
Dropping the `exit_code` future MUST kill the session target. 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.
Document this contract in the `TtyHandle.exit_code` doc comment.
### Tests
This task defines types and traits; the concrete implementations are in the
local backend tasks. Tests here are structural:
- A mock backend (in-memory pipes) implementing `TtyBackend` for the adapter's
tests is built in task `tty/adapter`. Here, write a minimal compile-time
check: a `MockBackend` struct that implements `TtyBackend` returning a
`TtyHandle` with tokio mpsc channels, to verify the trait is implementable
and the types compose. This mock is reused by the adapter task.
- `TtyControlHandle::new` + `Clone` + `resize`/`signal` delegation test with a
mock `TtyControl`.
## Acceptance Criteria
- [ ] `TtyBackend` trait with `allocate()` (async) and `resource_id()` (default `None`)
- [ ] `TtyError` enum, `#[non_exhaustive]`, with `AllocFailed`, `WaitFailed`, `Io`, `Backend`
- [ ] `TtyParams` struct with `terminal`, `cmd`, `cwd`, `env`, `backend_params`
- [ ] `TerminalParams` struct with `term`, `cols`, `rows`, `pixel_width`, `pixel_height`, `modes`
- [ ] `TtyHandle` struct with `stdin` (Box<dyn AsyncWrite + Send + Unpin>), `stdout` (Pin<Box<dyn Stream<Item=Bytes> + Send>>), `stderr` (Option), `exit_code` (BoxFuture<Result<i32, TtyError>>), `control` (Option<TtyControlHandle>)
- [ ] `TtyControl` trait with `resize` and `signal` (object-safe, NOT `Clone`)
- [ ] `TtyControlHandle` newtype, `#[derive(Clone)]`, wraps `Arc<dyn TtyControl + Send + Sync>`, with `new`/`resize`/`signal`
- [ ] `From<NegotiateRequest> for TtyParams` (or constructor) mapping wire types to params
- [ ] `TtyHandle.exit_code` doc comment documents the ADR-056 kill-on-Drop contract
- [ ] `TtyBackend` trait doc comment documents REQ-TTY-01 (backends need not be natively async)
- [ ] A `MockBackend` (in-memory pipes) implements `TtyBackend` and compiles
- [ ] Unit test: `TtyControlHandle::new` + clone + resize/signal delegation
- [ ] `cargo test -p alknet-tty` succeeds
- [ ] `cargo clippy -p alknet-tty` succeeds with no warnings
## References
- docs/architecture/crates/tty/tty-backend.md — full trait spec (the authoritative source)
- docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md — ADR-053 (the trait decision)
- docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md — ADR-056 (kill-on-Drop contract)
- docs/architecture/decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md — ADR-050 (`resource_id`)
- /workspace/alknet-tty-poc/src/local_pty.rs — `LocalPty` (the reference shape a backend produces)
## Notes
> This is the one-way-door task (ADR-053). The trait shape, `TtyHandle` field
> set, and `TtyControl` trait are the API surface every backend crate
> implements and the adapter consumes. Review carefully before the local
> backend begins. The `MockBackend` built here is reused by the adapter task
> for the three-pump driver tests. The `BoxFuture` type comes from
> `futures::future::BoxFuture` (or `std::pin::Pin<Box<dyn Future + Send>>`);
> the `Stream` type from `futures_core::Stream`. Both are re-exported by
> `tokio_stream` for extension methods.
## Summary
> To be filled on completion