The biggest single-row surface of the spine: pty/shell/pipe-exec serve
through the published alktty seam (drive_session_pre_negotiated) over a
translation bridge, re-landing POC-3 stage D as contract:
- src/tty_map.rs (session feature): the bridge (SSH data → Chunk::stdin
through a bounded mpsc + framing task; SSH EOF → the zero-length
stdin sentinel — verified against alktty's pump semantics; stdout →
data; stderr → extended_data(1); exit chunk → the derivation
contract), the locally-built negotiation (OQ-08: shell argv verbatim,
wrapper ++ [cmd], cwd/env session-side, ttymodes → the reserved {}
modes JSON), and the spawned serving path.
- serve_tty_row: driven chunks→SSH inline so the exit report is the
path's final act (status before close, ADR-006 decision 4); two
cancel signals (per-channel watch fired by channel_close, connection
teardown fired by SessionHandler::Drop) abort the alktty session
task — the ADR-056 kill-on-drop contract is the cancelled session's
cleanup.
- Derivation contract rules 1–6 in derive_exit + the crate-local
signum→name table: −N → exit-signal(name), −1 → exit-status(255),
core_dumped never, unmapped negatives → 255, the PTY-path rule-5
fidelity limit documented; wire rows pin the shapes.
- Negotiation-error frames (alktty's 0x00-length-prefixed error
framing) → negotiation-failed serving decline + exit-status(255) +
close — the one could-not-start claim this door makes (nothing ran).
- Control rows: window_change/signal → the per-channel BridgeSurface
(bounded mpsc, try_send, never awaited on the packet loop); signal
names verbatim (no decline shape — fire-and-forget); pre-serve
resizes update the stored pty params (final size wins).
- Unset-declines (OQ-08) keep the git row's serving-path shape
(failure then close, spawned): shell-unconfigured /
wrapper-unconfigured / seam-unconfigured; pipe-less shell SERVES
pipe-mode (the recorded vanilla-sshd posture); env_request decline
unchanged (OQ-09).
- tests: the hand-rolled TtyBackend shim + ownership witness doubles;
serving rows through the harness, the unset-field decline matrix,
derivation rows on the wire, negotiation-failure rows, the identity
ride asserted live at the seam, the kill witness; the real-tool
smoke — OpenSSH -tt against a real PTY via dev-dep alktty local
(test graph only), skip-with-reason posture. Wave-2 stub rows
migrated to the new decline reasons (dispatch_core, observe).
Verification: cargo test (113) / --features session (149) / git (142) /
tunnel (115) / --all-features (161) — all green; cargo clippy
--all-targets -- -D warnings ×5 feature configs; cargo fmt --check;
cargo doc --no-deps ×3 builds clean; taskgraph validate.
15 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| w3-tty-session | Wave 3 — session → alktty (translation bridge + exit derivation) | completed |
|
moderate | medium | phase | implementation |
Description
Land the session branch — the biggest single-row surface of the spine
(mapping.md's session rows): pty/shell/pipe-exec become a real
serving path through the published alktty seam, as a translation
bridge, not a byte pump (POC-3 §D.1). Everything POC-3 stage D
proved, re-landed as contract:
- The per-channel state + the store-and-take reuse — the channel
store
w3-git-mappinglands (channels.remove(&id)→into_stream()in the spawned serving task) gains the session rows' per-channel state: the PTY params apty_requestdelivered (consumed by the latershell_request/exec_requeston the same channel —ssh -t host "cmd"sends pty-then-exec and must serve PTY-mode; ashell_requestwithout a preceding pty serves pipe-modetty = None— decide whether such a shell serves or declines and record). - Negotiation, built locally, never wire-derived (OQ-08 — the
serve-config is the whole truth):
pty_requestargs →TerminalParamsWire{term, cols, rows, pixel_w/h, modes}(SSH ttymodes → the reserved modes JSON — alktty OQ-44's reservation, deferred there);shell_request→NegotiateRequest{carriage: "raw", cmd: shell_argv (the serve-config shell, verbatim), cwd: session_cwd, env: session_env, backend: the seam selector}→drive_session_pre_negotiated(writer, reader, req, backends, ownership, identity)spawned;exec_request(pipe row) →cmd = wrapper ++ [cmd](the OQ-08 argv append — vanilla quoting semantics; never naively split),tty = Nonepipe mode. Unset-declines (OQ-08):shell_argvunset →shell_requestdeclines;exec_wrapper_argvunset → the pipe row declines — never a fallback. Decide the wire shape per row (capability decline — channel stays usable — vs the serving-path failure+close shape) and record. - The translation bridge — two small adapters around a duplex the
serving task drives (the POC-3 §D.1 shape): SSH
data/EOF →Chunk::stdin(decide the EOF translation — the zero-length stdin-chunk sentinel vs alktty'sControlMessage::EofCTRL_IN — verify what alktty's ChunkReader/backend actually consumes and record); stdout chunks → SSHdata; stderr chunks →extended_data(1)(PTY mode merges upstream — the backend's stderr stream isNonethere, so the translation is universal, no mode-switch); the exit chunk (STREAM_CTRL_OUT) → the derivation contract. No unbounded buffering in either adapter (convention 9). - Control rows —
window_change_request→ControlMessage:: ResizeCTRL_IN;signal→ControlMessage::Signal(alktty's uppercase-name vocabulary; name accepted verbatim — decide the decline shape for names alktty's backend semantics reject and record). Mechanics: the requests arrive on the packet loop after the serving task owns the wire — reach them into the spawned bridge through a per-channel control surface (bounded mpsc — decide + record; a silent unbounded proxy is a bounded-resources bug). - The exit derivation contract (mapping.md rules 1–6,
normative): the alktty exit chunk (
{"type":"exit","code":i32}, exit-chunk-is-last) is the sole fidelity input, never widened:code ≥ 0→exit-status(N); negative−N, N ≥ 2 →exit-signal(name)via the server-side Unix signum→name table (crate-local; not derived from alktty'ssignal_from_nameinverse unless that is public and sound — decide the table's form and record;core_dumpednever asserted, the bool stays false);−1→exit-status(255), never a signal claim (rule 3 — the SIGHUP collision accepted symmetrically, per the mapping.md rationale); non-Unix adds no other negatives (rule 4); the PTY-path rule-5 fidelity limit documented in the module docs (signal- terminated PTY sessions arriveexit-status(1)— portable-pty's exit surface, upstream); unmapped negatives → 255 (rule 6). Sent before close, from the spawned task. env_requeststays declined (OQ-09 — unchanged from wave 2);session_cwd/session_envrideNegotiateRequestonly.- Contract rows through the harness, per build — the wave-2 stub
rows (
pty_row_stub/shell_row_stub/pipe_row_stub) replaced; the decline matrix's unset-field rows — feature on + handle unset → the rows decline; featured+set serve through the alkssh-side TtyBackend shim double (alktty'sMockBackend/testing.rsarepub(crate)+cfg(test)— never importable; alkssh hand-rolls the shim: a backend that recordsTtyParams, emits scripted stdout/stderr/exit chunks and consumes stdin/resizes/signals — per theTtyHandlefield shapes). The identity-landing item (the parked wave-2 note) asserted through the row that observes the identity atdrive_session_pre_negotiated. - The real-tool smoke — one OpenSSH pty session through the
harness against the shim (or against a real PTY via a dev-dep
alkttylocalcomposition — decide which posture and record; the real bash/stty sizefidelity was POC stage D's, wave 5 re-lands it as e2e): the smoke owns the wire rows a plain rustsh-client row can't pin (echo paths,stty size-grade fidelity are wave-5's matrix; here the pty lifecycle rows only).
Acceptance Criteria
- pty/shell/pipe-exec rows serve through
drive_session_pre_negotiated(spawned, handler fast); the store-and-take + per-channel PTY state reused from the git task's handoff; pipe-less shell decided + recorded pty_request→TerminalParamsWire(ttymodes → the reserved JSON),shell_request→ shell argv, exec →wrapper ++ [cmd], cwd/env session-side; unset-declines on both OQ-08 rows with recorded wire shapes- The bridge translated per row (stdin chunks + EOF shape
verified against alktty; stdout→data; stderr→
extended_data(1); resize/signal CTRL_IN reach the serving path through a bounded per-channel surface) - The derivation contract rules 1–6 in code with the signum→name
table;
−1→255;core_dumpednever; PTY-path rule-5 documented; exit-status before close from the spawned task — pinned by rows - The decline matrix per build green through the harness (unset-
field rows included); identity ride asserted; the
env_requestdecline unchanged; skeleton stub rows migrated - The real-tool pty smoke row (posture decided + recorded)
cargo test(+ per-feature +--all-featuresrows), clippy ×2 (-D warnings), fmt,cargo doc --no-depspass
References
- docs/architecture/mapping.md (the
sessionchannel-type row, request-level rows, §Exit chunk → SSH exit reporting — rules 1–6) - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (decision 4 — spawn-and-return: status before close from the spawned task), docs/architecture/decisions/003-feature-graph.md
- docs/architecture/auth.md §Server direction step 4 (the identity ride), §Dispositions (OQ-03)
- docs/research/poc-3-mapping-bakeoff.md §Stage D (the bridge shape, control rows, the signal/exit fidelity findings, §D.3 mechanics)
- docs/research/reverse-runner.md (pipe-mode pid-only signal caveat — the fidelity note)
- alktty 0.8.1:
adapter.rs:234-241(drive_session_pre_negotiated),negotiation.rs(NegotiateRequest/TerminalParamsWire),wire.rs(chunks, readers/writers),control.rs(ControlMessage),backend.rs(TtyHandle/TtyParams/TtyBackendshapes)
Notes
Decisions recorded (implementation, 2026-10-28)
-
The module (
src/tty_map.rs, featuresession): the session rows live in one module following thegit_mappattern — the bridge, the locally-built negotiation, the derivation contract, and the serving path.serde_json(the OQ-44 modes JSON + the exit chunk/error-frame reads) andbytes(the chunk plane) joined as session-gated optional deps at alktty's own pins. -
The negotiation (locally built, never wire-derived — OQ-08):
pty_requestargs →TerminalParamsWirewith SSH ttymodes → the reserved{}modes JSON (alktty OQ-44's reservation, deferred there; the map is honest — nothing is carried). Shell argv rides the serve-config verbatim; exec rideswrapper ++ [cmd](never naively split); cwd/env come fromsession_cwd/session_envonly.backend_paramsstays empty (no backend-specific selector fields on this door; the seam selector is thebackendstring). Pipe-less shell decided: ashell_requestwithout a preceding pty SERVES pipe-mode (tty = None) — vanilla sshd serves a shell without a terminal too (ssh -Tis legitimate vanilla). -
Unset-declines (OQ-08) — wire shape decided + recorded: the serving-path shape (
channel_failurethen close, from a spawned task) forshell_argv-unset (shell-unconfigured),exec_wrapper_argv-unset (wrapper-unconfigured), and thesession_seam-unset rows (seam-unconfigured— the git row's recorded shape); the pty row checks the seam first (a doomed allocation is declined before its params are stored — decline first, alloc never). Field checks precede the seam check. The unvanilla guards keep the git row's shapes:channel-not-held→ inline request-level failure;no-identity→ the spawned serving-path decline. A routed request answerschannel_successinline (the POC-3 §A.5 shape). -
The translation bridge (the POC-3 §D.1 shape, two adapters around a duplex pair): SSH
data→Chunk::stdinthrough a bounded mpsc (bound 64) feeding a framing task; SSH channel EOF → the zero-length stdin-chunk sentinel — decided and verified against alktty 0.8.1'spump_client_to_backend: a zero-length stdin chunk shuts the backend's stdin, identical in effect toControlMessage::Eof, and rides the data path the SSH EOF already chose (no extra control frame). stdout chunks → SSHdata; stderr chunks →extended_data(1); the exit chunk → the derivation contract. All buffers bounded (convention 9): mpsc 64, duplexes 64 KiB, frames bounds-checked against alktty'sMAX_CHUNK_LEN. -
The negotiation-error frame is a serving-path decline class (decided + recorded): alktty re-runs validation/allocation/ownership inline even on the pre-negotiated path and answers failures with a length-prefixed error frame (first byte
0x00— alktty's ADR-052 §5 disambiguation invariant). The bridge reads it via a 4-byte first-window disambiguation and maps it tonegotiation-failed+exit-status(255)+ close — the one place this door claims 255: the session never started, nothing ran, so 255 is the honest could-not-start semantic (contrast the git row, whose service runs and honestly reports1;255is never fabricated for a real outcome). A bridge read ending without an exit chunk reports nothing (sole-fidelity-input posture: absence is neither 0 nor 255) — plain close. -
The control rows:
window_change_request→ControlMessage::Resize,signal→ControlMessage::Signal, forwarded through the per-channelBridgeSurface(handler state while the row serves): a bounded mpsc whosetry_sendis never awaited on the packet loop — a full/closed surface drops the control event (fire-and-forget rows carry no SSH reply to decline with; bounded resources, convention 9). Signal names are accepted verbatim — no decline shape: the backend owns the rejection semantics, nothing on the wire can answer. A pre-serve resize updates the stored pty params instead (the final size wins — vanilla sshd's ordering). -
The derivation contract (mapping.md rules 1–6, in code + unit rows + wire rows):
derive_exit—code ≥ 0→exit-status(N); negative−N, N ≥ 2 →exit-signal(name)via the crate-local [SIGNAL_NAMES] signum→name table (1–31, server-side work, deliberately not alktty'ssignal_from_nameinverse);−1→exit-status(255)(rule 3, the SIGHUP collision accepted symmetrically);core_dumpednever asserted (the bool stays false on every exit-signal row); unmapped negatives (andi32::MIN's checked-abs failure) → 255 (rule 6); the PTY-path rule-5 fidelity limit documented in the module docs. Report sent before close from the spawned task; names ride russh'sSig(known variants verbatim,Sig::Customfor the rest — RFC 4254 §6.10). -
Cancellation (bounded resources + alktty's kill-on-drop): the serving path watches two signals — the per-channel cancel (fired by
channel_closevia the surface's watch) and the connection teardown (fired by the handler'sDrop; the russh session ended however it ended). On either: the serving task aborts the alktty session task, dropping its not-yet-resolvedexit_codefuture — alktty's ADR-056 kill-on-drop contract is the cancelled session's cleanup — then closes the channel and ends. Witnessed live by the shim's kill log row. -
The contract tests (
tests/tty_mapping.rs, session-gated;tests/common/tty_doubles.rs): the shim double (hand-rolled per theTtyHandlefield shapes — alktty'sMockBackendispub(crate)and never importable) records TtyParams, consumes stdin/EOF/resizes/signals, emits scripted chunks and exit codes, and carries the kill-on-drop witness; the ownership witness double observes the identity at the seam. The wave-2 stub rows migrated:dispatch_core.rs's session-build rows now pin the unset-decline reasons (seam-unconfigured/wrapper-unconfigured, same serving-path wire shape);observe.rs's two session rows updated (and a one-line pre-existing clippy lint in its git-build cap row fixed).env_requestdecline unchanged (OQ-09). -
The identity ride (the parked wave-2 item, discharged): asserted through alktty's ADR-050 ownership check — the shim's
resource_idguarantees the check evaluates, and the ownership double records the resolved identity; the deny shape also pins the negotiation-error wire shape. -
Real-tool smoke posture (recorded): one OpenSSH
-ttpty session (ssh -tt … smoke-rider@127.0.0.1with piped stdin) through a TCP-mounted adapter against a real PTY via a dev-dep alkttylocalcomposition — the decided posture: a shim row cannot show what vanilla OpenSSH really sends, and POC-3 stage D proved exactly this composition. The dev-depfeatures = ["local"]unification applies to the test graph only (the published lib never enables it;src/never names a local shape). Requires ssh + sh; absent tooling is skipped with a printed reason — never silently green. The smoke pins the pty lifecycle (serve, stdin ride, clean exit → exit-status(0) before close); thestty size-grade fidelity matrix is wave 5's. -
Verification:
cargo test(default),cargo test --features session(incl. the smoke + shim rows),cargo test --features git,cargo test --features tunnel,cargo test --all-features;cargo clippy --all-targets -- -D warnings×5 (default / session / git / tunnel / all-features);cargo fmt --check;cargo doc --no-deps(default + session + all-features) — all green.