11 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| tty/backend-trait | Implement TtyBackend trait, TtyHandle, TtyControl, TtyParams, TtyError (ADR-053) | completed |
|
moderate | high | phase | 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
#[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).
#[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 byallocate(); adapter sendsallocate_failederror.WaitFailed— backend couldn't reap the child / determine exit code. Returned by theexit_codefuture; 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
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
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
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
TtyBackendfor the adapter's tests is built in tasktty/adapter. Here, write a minimal compile-time check: aMockBackendstruct that implementsTtyBackendreturning aTtyHandlewith 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/signaldelegation test with a mockTtyControl.
Acceptance Criteria
TtyBackendtrait withallocate()(async) andresource_id()(defaultNone)TtyErrorenum,#[non_exhaustive], withAllocFailed,WaitFailed,Io,BackendTtyParamsstruct withterminal,cmd,cwd,env,backend_paramsTerminalParamsstruct withterm,cols,rows,pixel_width,pixel_height,modesTtyHandlestruct withstdin(Box<dyn AsyncWrite + Send + Unpin>),stdout(Pin<Box<dyn Stream<Item=Bytes> + Send>>),stderr(Option),exit_code(BoxFuture<Result<i32, TtyError>>),control(Option)TtyControltrait withresizeandsignal(object-safe, NOTClone)TtyControlHandlenewtype,#[derive(Clone)], wrapsArc<dyn TtyControl + Send + Sync>, withnew/resize/signalFrom<NegotiateRequest> for TtyParams(or constructor) mapping wire types to paramsTtyHandle.exit_codedoc comment documents the ADR-056 kill-on-Drop contractTtyBackendtrait doc comment documents REQ-TTY-01 (backends need not be natively async)- A
MockBackend(in-memory pipes) implementsTtyBackendand compiles - Unit test:
TtyControlHandle::new+ clone + resize/signal delegation cargo test -p alknet-ttysucceedscargo clippy -p alknet-ttysucceeds 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,
TtyHandlefield set, andTtyControltrait are the API surface every backend crate implements and the adapter consumes. Review carefully before the local backend begins. TheMockBackendbuilt here is reused by the adapter task for the three-pump driver tests. TheBoxFuturetype comes fromfutures::future::BoxFuture(orstd::pin::Pin<Box<dyn Future + Send>>); theStreamtype fromfutures_core::Stream. Both are re-exported bytokio_streamfor extension methods.
Summary
To be filled on completion