phase 4: architecture docs + BAST schema + renumbered ADRs
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
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
# ADR-002: TtyBackend Trait and TtyHandle — the Backend Inversion Point
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (ported from alknet ADR-053 2026-08-17; cross-references
|
||||
renumbered to alktty's ADR range — ADR-052→001, ADR-053→002,
|
||||
ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007,
|
||||
ADR-093→008. Alknet ADRs referenced by alknet number (003, 007, 017,
|
||||
050) are not ported into alktty's ADR range because they are not
|
||||
tty-specific; the alknet originals at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain
|
||||
authoritative.)
|
||||
|
||||
## Context
|
||||
|
||||
alktty's wire format (ADR-001) 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 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: alktty defines the
|
||||
trait and the wire-format adapter; the backend crates (alknet-docker,
|
||||
alknet-ssh, alktty's `local` feature module) implement the trait. This
|
||||
preserves alknet ADR-003's no-handler-depends-on-another-handler rule:
|
||||
alktty depends on alkcall; backend crates depend on alktty for the
|
||||
trait; alktty does not depend on any backend (and, per ADR-006, does
|
||||
not depend on alkcall's internal wire types either — the negotiation
|
||||
framing is self-contained).
|
||||
|
||||
### What the local-PTY POC discovered about the trait shape
|
||||
|
||||
The Phase 0 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 alknet 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 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 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
|
||||
`TtyControlHandle` newtype — a concrete `#[derive(Clone)]` struct
|
||||
wrapping `Arc<dyn TtyControl + Send + Sync>` (the trait is NOT `Clone`;
|
||||
`Clone` is not object-safe — see OQ-43). The newtype carries the
|
||||
`Clone`-ability; the trait stays object-safe.
|
||||
|
||||
## 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-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`
|
||||
/// (most backends create their own resource).
|
||||
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { None }
|
||||
}
|
||||
```
|
||||
|
||||
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-003). `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,
|
||||
/// 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 (ADR-003). `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).
|
||||
|
||||
### 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:
|
||||
|
||||
```rust
|
||||
// in alknet-docker
|
||||
#[derive(Deserialize)]
|
||||
struct DockerBackendParams { container: String }
|
||||
|
||||
// in alknet-ssh
|
||||
#[derive(Deserialize)]
|
||||
struct SshBackendParams { /* host selector if multi-host; else empty */ }
|
||||
|
||||
// in alktty's local feature module
|
||||
// no backend-specific params — backend_params is empty
|
||||
```
|
||||
|
||||
And deserializes from `params.backend_params` inside `allocate()`:
|
||||
|
||||
```rust
|
||||
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 ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**Why not a typed enum.** The earlier draft of this ADR defined
|
||||
`BackendParams` as a `#[non_exhaustive]` enum with `Local`, `Docker {
|
||||
container }`, and `Ssh { channel: SshChannelRef }` variants. Three
|
||||
problems:
|
||||
|
||||
1. **Rust enums are closed.** `#[non_exhaustive]` prevents *consumers*
|
||||
from matching exhaustively, but only the *defining crate* (alktty)
|
||||
can add variants. A backend crate cannot add a variant; every new
|
||||
backend requires modifying alktty. The inversion is only partial.
|
||||
2. **`SshChannelRef` was an output, not an input.** The SSH channel is
|
||||
what `allocate()` *opens* (`session.channel_open_session()` →
|
||||
`pty_request` → `shell_request`). It doesn't exist until the backend
|
||||
creates it; the client doesn't send one.
|
||||
3. **Dependency contradiction.** `SshChannelRef` "wraps a russh
|
||||
`ChannelId` and session reference." If it lives in alknet-ssh,
|
||||
alktty depends on alknet-ssh (violates the inversion). If it lives in
|
||||
alktty, alktty pulls in russh types (same violation). Opaque params
|
||||
dissolve the contradiction — there is no `SshChannelRef` type in
|
||||
alktty at all.
|
||||
|
||||
The earlier draft rejected `serde_json::Value` because "it loses type
|
||||
safety and forces the adapter to parse backend-specific JSON it
|
||||
shouldn't interpret." The first concern doesn't apply (each backend has
|
||||
its own strongly-typed struct via serde; type safety moves from alktty
|
||||
to the backend where it belongs). The second was already inconsistent
|
||||
with the adapter, which hardcoded extraction of docker's `container`
|
||||
field for the ownership check — the adapter *was* parsing
|
||||
backend-specific JSON. The opaque approach removes that: the adapter
|
||||
delegates the resource-id extraction to the backend via `resource_id()`
|
||||
below.
|
||||
|
||||
### 3. `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 (process exited,
|
||||
/// container output stream ended, SSH channel closed).
|
||||
/// `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 (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 `TtyControlHandle`
|
||||
/// newtype rationale.
|
||||
pub control: Option<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 (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);
|
||||
}
|
||||
|
||||
/// 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>);
|
||||
```
|
||||
|
||||
### 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-003) 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-001 wire format +
|
||||
mock backend first, real backends last) follows directly.
|
||||
- alktty stays dependency-light: no bollard, no russh, no `portable_pty`
|
||||
in the default crate. The heavy deps live in the backend crates /
|
||||
the `local` feature. This is the same inversion as
|
||||
`OperationAdapter` (alknet 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-004).
|
||||
|
||||
**Negative:**
|
||||
|
||||
- **Backend params are opaque (`serde_json::Map`), not a typed enum.**
|
||||
Each backend deserializes its own params struct; the adapter passes
|
||||
the JSON through verbatim. The cost is one serde deserialize per
|
||||
`allocate()` call (negligible — allocation is once per session, not on
|
||||
the hot path). The benefit is a complete inversion: alktty has zero
|
||||
knowledge of any backend's params shape, and a new backend crate
|
||||
requires zero changes to alktty (no enum variant, no forward-reference
|
||||
type). See §"Backend params are opaque" for the full rationale and
|
||||
why the typed-enum alternative was rejected (Rust enums are closed;
|
||||
the earlier `SshChannelRef` variant was an output modeled as an input
|
||||
and created a dependency contradiction).
|
||||
- **`TtyControl` is not `Clone`; the `TtyControlHandle` newtype is.**
|
||||
`Clone` is not object-safe (`fn clone(&self) -> Self` returns `Self`,
|
||||
which forbids `dyn` dispatch), so `Box<dyn TtyControl + Clone>` does
|
||||
not compile. The design splits the concerns: the `TtyControl` trait
|
||||
stays object-safe (`Send + Sync`, no `Clone`); the `TtyControlHandle`
|
||||
newtype (a concrete struct holding `Arc<dyn TtyControl + Send +
|
||||
Sync>`) implements `Clone` by cloning the `Arc`. 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 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 supply an empty
|
||||
`cmd` and synthesize one internally. 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 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` as an
|
||||
opaque `serde_json::Map` is part of the one-way `TtyParams` shape — the
|
||||
*carrier* is fixed (opaque JSON), but the *contents* are
|
||||
backend-defined and require no alktty change for new backends. The
|
||||
`resource_id()` default method is additive (a new method with a default
|
||||
impl doesn't break existing implementors); its return type
|
||||
`Option<(&'static str, String)>` is one-way.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **The `TtyControl` trait is kept object-safe by NOT putting `Clone`
|
||||
on it; the `TtyControlHandle` newtype holds the trait object behind
|
||||
an `Arc` and implements `Clone` by cloning the `Arc`.** The POC used
|
||||
a concrete `PtyControl` struct (inherently `Clone` — it held
|
||||
`Arc<Mutex<...>>` fields). The 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. `Clone` cannot live on the trait itself
|
||||
(it is not object-safe); the newtype is the seam. OQ-43 confirms.
|
||||
|
||||
2. **Backends produce a single session per `allocate()` call.** The
|
||||
adapter calls `allocate()` once per accepted bidi stream (one session
|
||||
per stream — ADR-001). 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-004). 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
|
||||
|
||||
- alknet ADR-003 + Amendments 1 & 2 — no-handler-depends-on-another-
|
||||
handler; alktty depends on alkcall (no alkcall-internal-wire-types per
|
||||
Am. 2 / ADR-006); backends depend on alktty for the trait
|
||||
- [ADR-006](006-negotiation-framing-self-contained.md) — alktty does not
|
||||
depend on alkcall's internal wire types (self-contained negotiation
|
||||
framing)
|
||||
- alknet ADR-007 — `Connection`, `SendStream`, `RecvStream` (the adapter
|
||||
receives a `Connection`, accepts bidi streams, pumps per-session)
|
||||
- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format this
|
||||
trait's backends feed
|
||||
- [ADR-003](003-local-backend-placement.md) — the local backend's
|
||||
module placement (folded into alktty behind a `local` feature)
|
||||
- [ADR-004](004-exit-code-on-control-chunk.md) — the exit chunk ordering
|
||||
this trait's `exit_code` field feeds into
|
||||
- [ADR-005](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)
|
||||
- alknet ADR-017 — the adapter-location-map pattern (trait where types
|
||||
live, implementation where deps live) this ADR follows
|
||||
- alknet ADR-050 — the ownership model the `resource_id()` default
|
||||
declares against
|
||||
- OQ-43 — `TtyControl` as `Clone` trait object (resolved: confirmed)
|
||||
- OQ-44 — terminal modes (deferred(scope): not needed for current scope)
|
||||
- Spec: [tty-backend.md](../tty-backend.md)
|
||||
- Port origin: alknet ADR-053 at
|
||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md`
|
||||
Reference in New Issue
Block a user