Files
alknet/docs/architecture/decisions/054-local-tty-backend-sibling-crate.md
T
glm-5.2 dbac972aaa docs(arch): ADR-057 — alknet-tty does not depend on alknet-call (self-contained negotiation framing)
The earlier specs claimed alknet-tty reuses alknet-call's
FrameFramedReader/FrameFramedWriter 'framing utility' (ADR-052 §6,
ADR-003 Am. 1). A pre-implementation sanity check found this was
unsound: FrameFramedReader::read_frame() is hardcoded to deserialize
EventEnvelope — the length-prefix read and the type-specific deserialize
are one entangled call, not a separable utility. alknet-tty's
negotiation payload is a NegotiateRequest, not an EventEnvelope, so the
claimed reuse did not exist in a usable form.

ADR-057: alknet-tty implements its own ~30-line length-prefixed
framing directly on tokio AsyncRead/AsyncWrite. The format coincides
with alknet-call's framing by convention (both are length-prefixed
JSON); the implementations are independent. alknet-tty depends on
alknet-core only — no alknet-call dependency. The ~30 lines of framing
is an idiom, not a domain abstraction worth a cross-crate dependency.

Updates:
- ADR-003 Amendment 2: clarifies alknet-tty does not depend on
  alknet-call; the Am. 1 protocol-foundation exception remains for
  alknet-http/agent/napi (actual type reuse), not alknet-tty.
- ADR-052 §6: framing is self-contained in alknet-tty; format coincides
  by convention, not by code reuse.
- overview.md: dependency block drops alknet-call; new 'Why no
  alknet-call dependency' subsection explains the unsound-reuse finding.
- tty-wire.md, tty-backend.md, tty README, crates/tty/README.md,
  top-level README: ADR tables, Design Decisions tables, and dependency
  edges updated to reflect alknet-core-only dependency.
- ADR-053, ADR-054: References updated to ADR-003 Am. 2 + ADR-057.
2026-07-07 11:15:52 +00:00

9.6 KiB

ADR-054: Local TTY Backend as a Sibling Crate (alknet-tty-local)

Status

Accepted

Context

The alknet-tty research (docs/research/alknet-tty/phase-0-findings.md DP-1) posed the placement question for the local-process backend (std::process::Command with piped stdio, or portable_pty for a real PTY):

  • (a) In alknet-tty: the crate ships with the local backend built-in. Pro: zero-config runner, one crate gets a terminal/process-streaming endpoint. Con: alknet-tty pulls in portable_pty even for deployments that only use docker/ssh backends.
  • (b) In a sibling crate (alknet-tty-local): alknet-tty defines the trait (ADR-053); the local backend is a separate crate. Pro: alknet-tty stays dependency-light; consumers opt into the local backend explicitly. Con: one extra crate for the common case.

The local backend is the simplest backend and the one that enables the runner pattern (a process whose stdin/stdout/stderr/exit-code stream over a framed bidi connection — the same shape as GitHub/Gitea Actions runners, just over alknet's transport instead of HTTP polling). It has no heavy dependencies in the pipe case — just std — but the PTY case pulls in portable_pty (a non-trivial native dependency that builds on Unix openpty/ioctl and Windows ConPTY).

The relevant constraint from ADR-053: alknet-tty itself depends only on alknet-core and the wire-format codec (ADR-052). The portable_pty dependency does not belong in the core tty crate — a docker-only deployment or an SSH-PTY-only deployment should not pull in PTY allocation code. This is the same inversion as OperationAdapter (ADR-017): the trait lives where the types live; the implementations live where their transport dependencies live.

The feature-re-export compromise

The research recommended (b) sibling crate behind a feature flag on alknet-tty for the common case (features = ["local"] → re-export from alknet-tty-local). This keeps alknet-tty's default dependency surface minimal while making the local backend a one-feature opt-in. The portable_pty dependency lives in alknet-tty-local; alknet-tty itself never depends on portable_pty.

Decision

1. The local backend is a sibling crate, alknet-tty-local

alknet-tty-local implements TtyBackend for LocalTtyBackend (ADR-053), backed by portable_pty for the PTY case and std::process::Command with Stdio::piped() for the pipe/runner case. It depends on alknet-tty (for the TtyBackend/TtyHandle/TtyControl traits) and on portable_pty for the PTY case.

alknet-tty-local
├── alknet-tty      (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alknet-core      (Connection — via alknet-tty's re-export; not direct)
├── portable_pty     (PTY allocation — the heavy dep, here not in alknet-tty)
└── libc             (signal forwarding — REQ-TTY-02, Unix only)

2. alknet-tty re-exports the local backend behind a local feature

# alknet-tty Cargo.toml
[features]
default = []
local = ["dep:alknet-tty-local"]   # re-export LocalTtyBackend

[dependencies]
alknet-tty-local = { path = "../alknet-tty-local", optional = true }

A consumer that wants the local backend enables features = ["local"] on alknet-tty and gets alknet_tty::local::LocalTtyBackend re-exported from alknet-tty-local. A consumer that only wants docker/ssh backends uses the default features and depends on alknet-tty-docker / alknet-tty-ssh (or their own backend crate) directly — no portable_pty in the dependency tree.

This is the same feature-re-export pattern the Rust ecosystem uses for optional heavy dependencies (e.g., tokio's full feature pulling in tokio-util, h2, etc.). The seam is the TtyBackend trait; extraction is cheap because the trait is the contract.

3. PTY vs pipe is a per-session choice, not a per-deployment choice

TtyParams.terminal: Option<TerminalParams> (ADR-053) selects the mode:

  • terminal: Some(TerminalParams { ... }) — allocate a real PTY via portable_pty. Terminal semantics: resize (via ioctl(TIOCSWINSZ)), signal delivery to the foreground process group (via libc::kill(-pgid, sig), REQ-TTY-02), escape-sequence handling (the kernel PTY's line discipline). stdout and stderr are merged (kernel PTY property — one output stream from the slave), so TtyHandle.stderr is None.
  • terminal: None — pipe mode, no PTY. std::process::Command with Stdio::piped() for stdin/stdout/stderr. No resize, no escape-sequence handling, but kill(pid, sig) still works for signal forwarding. stdout and stderr are separate streams, so TtyHandle.stderr is Some. This is the runner case — a command-streaming endpoint with no terminal semantics.

The same LocalTtyBackend serves both; the allocate() call branches on params.terminal. A deployment that only does terminals always sends Some; a deployment that only does runners always sends None; a deployment that does both (a hub that runs agents in PTYs and runs cargo test as a runner) sends the appropriate one per session.

4. The runner pattern is preserved, not specialized

The pipe mode (terminal: None) is the "runner" generalization the research identified: a process whose stdin/stdout/stderr/exit-code stream over a framed bidi connection. This is functionally identical to GitHub/Gitea Actions runners, just over alknet's transport instead of HTTP polling:

  • A coordinator sends a negotiation frame with { "backend": "local", "tty": null, "cmd": ["cargo", "test"] }.
  • The endpoint runs cargo test with piped stdio, streams stdout/stderr chunks back, sends {"type":"exit","code":N} when it finishes (ADR-055).
  • The coordinator gets reliable completion notification (the exit control chunk + stream close) — no polling.

The runner-specific API surface (job management, log persistence, task graph integration) is out of scope for alknet-tty. alknet-tty provides the mechanism (a framed byte stream for a process); the runner policy is a downstream crate's job. This ADR commits to preserving the option (terminal: None → pipe mode) and not building runner policy into alknet-tty. See OQ-46.

Consequences

Positive:

  • alknet-tty's default dependency surface is minimal (alknet-core + the wire-format codec). A docker-only or ssh-only deployment never pulls in portable_pty.
  • The local backend is a one-feature opt-in (features = ["local"]) for the common case — a consumer that wants a terminal/runner endpoint with no docker or SSH gets it with one feature flag, not a separate dependency.
  • PTY vs pipe is per-session, so one LocalTtyBackend serves terminals and runners. A hub that does both doesn't need two backends.
  • The runner pattern is preserved without baking runner policy into alknet-tty. The mechanism is the framed byte stream; the policy is downstream.
  • The sibling-crate placement composes with ADR-003's no-handler-depends- on-another-handler rule: alknet-tty-local depends on alknet-tty (for the trait), alknet-tty does not depend on alknet-tty-local (the feature re-export is optional).

Negative:

  • A consumer that wants the local backend must enable a feature flag (features = ["local"]). Forgetting the flag results in the alknet_tty::local module not existing — a compile error, not a silent miss. This is the standard Rust feature-flag trade and is self-documenting.
  • One extra crate in the workspace (alknet-tty-local alongside alknet-tty). The workspace Cargo.toml gains a member; version coordination is per-crate. This is the established pattern (alknet-http is one crate with colocated server + adapters; alknet-call is separate from alknet-core).
  • The runner-specific API surface (job management, log persistence) is not in alknet-tty. A downstream crate that wants a full runner builds on the pipe mode + the wire format. This is the right layering (mechanism vs policy) but means a "runner crate" is a separate future deliverable, not part of alknet-tty. See OQ-46.

Door type

Two-way. The sibling-crate placement is reversible: if the local backend turned out to be the only backend anyone used, merging alknet-tty-local back into alknet-tty (behind the same local feature, just in the same crate) is mechanical — the trait is the seam, and the feature gate already exists. The cost of reversal is low (re-exports become local modules), and no downstream consumer breaks (the alknet_tty::local::LocalTtyBackend path stays valid).

This is a two-way door that is decided (sibling crate + feature re-export), not deferred. The decision is made now; the reversal is cheap if a future consolidation warrants it. See ADR-009 §"What this framework is NOT" — door type classifies reversal cost, not urgency.

References

  • docs/research/alknet-tty/phase-0-findings.md DP-1 — the placement question this ADR resolves
  • ADR-003 + Amendments 1 & 2 — crate decomposition rule (the sibling-crate placement preserves it)
  • ADR-009 — door-type-as-deferral anti-pattern (this ADR's two-way-door classification is reversal cost, not a deferral)
  • ADR-017 — the adapter-location-map pattern (trait where types live, implementation where deps live) this ADR follows
  • ADR-053 — the TtyBackend trait this crate implements
  • OQ-46 — runner API surface (deferred(scope): mechanism in alknet-tty, policy is a downstream crate)
  • Spec: crates/tty/tty-local.md