--- status: closed last_updated: 2026-09-05 resolved: 1-16 (P14 closed; P13's accept-loop harness deferred by disposition) published: v0.1.0 (crates.io, 2026-09-05) — review closed before publish reviewed_artifacts: - src/lib.rs - src/wire.rs - src/control.rs - src/negotiation.rs - src/backend.rs - src/adapter.rs - src/session.rs - src/channels.rs - src/local/mod.rs - src/local/backend.rs - src/local/pty.rs - src/local/pipe.rs - tests/common/mod.rs - tests/negotiation.rs - tests/pipe.rs - tests/pty.rs - Cargo.toml - AGENTS.md - docs/architecture/README.md - docs/plans/project-setup.md tool: two subagent reviews (code review + packaging/docs vs alkcall 0.4.1) + manual verification of every finding against source + empirical deadlock reproduction reviewer: prepublish review for v0.1.0 (subagent-assisted) base_commit: 918af40 (review #002 resolved) resolutions: - "P1 + P13 resolved 2026-09-05 (87c52e5) — see the Session-1 Resolution section" - "P12 + P6 + P7 + P4 + P16 resolved 2026-09-05 — see the Session-2 Resolution section" - "P8 + P9 + P10 + P5 + P11 + P2 + P3 + P15 resolved 2026-09-05 — see the Session-3 Resolution section" --- # Code Review #003 — Prepublish Review (v0.1.0) ## Purpose Prepublish sanity check for the first crates.io release. Everything before this point was reviewed while the crate was unpublished, so every API-shape and packaging mistake was still free. This review is the last pass under that property: it looks for (a) correctness bugs, (b) semver-visible surface decisions that are cheap now and expensive per-consumer later, and (c) packaging/docs parity with alkcall 0.4.1 (the org's published-crate reference). Two subagent reviews were run against this tree — a code review (conventions, wire format, concurrency, API surface, test gaps) and a packaging/docs review (Cargo.toml parity with alkcall, package-list inspection, docs consistency, MSRV). Every M and L finding below was then manually re-verified against source by the publisher before being recorded here; the P1 deadlock was additionally reproduced empirically (twice, independently). ## Verification Baseline All verification run on the reviewed tree (`918af40`, before this review's own commit): - `cargo test`: **95 lib tests pass** (default, wasm-clean build). - `cargo test --all-features`: **138 tests pass** (119 lib + 5 negotiation + 6 pipe + 8 pty). Zero failures. - `cargo clippy --all-targets -- -D warnings`: **clean**. - `cargo clippy --target wasm32-unknown-unknown -- -D warnings`: **clean** (also `cargo check --target wasm32-unknown-unknown`). - `cargo fmt --check`: **clean**. - `cargo doc --no-deps`: **0 warnings**. - `cargo publish --dry-run --allow-dirty`: **OK** — packages 42 files, 671.0KiB (180.7KiB compressed), no warnings. (One packaged file is wrong — see P2.) - alkcall pinned at 0.4.1 (Cargo.lock); `alkcall = "0.4.0"` in Cargo.toml; MSRV 1.85 backed by lockfile pins (`idna_adapter 1.2.0`, icu 2.0.x/1.5.x) — no post-1.85 syntax found in `src/` or `tests/`. - CJK scan (`git ls-files | xargs grep -lP "[\p{Han}]"`): **zero matches** in tracked files. --- ## Findings ### P1. [M — blocks publish] `pump_session` deadlocks when the backend produces ≥63 stdout chunks before exit **Files**: `src/adapter.rs:428-490` (`pump_session`) **Problem**: the stdout/stderr pumps and the `exit_code` future are joined (`tokio::join!`, adapter.rs:465-475) *before* the client-facing drainer loop starts (adapter.rs:480-486). All three producers share one bounded channel (`mpsc::channel::(64)`, adapter.rs:437). Once 64 chunks are queued, `pump_stdout`'s `writer_tx.send(...).await` (adapter.rs:516) parks — and because the drainer hasn't started, the channel never drains. `pump_stdout` can't finish, so the sentinel send (adapter.rs:520) can't happen, `exit_code` may still resolve but `join!` never completes (it waits for the stdout pump handle), the exit chunk is never enqueued, and the drainer never starts. Circular wait; the session hangs with the client actively reading. **Reproduced empirically** (mock backend streaming N chunks through the real `drive_session` over a duplex transport, client reading with a 10 s timeout): - N=62 → OK: 62 data chunks + stdout sentinel + exit chunk = 64, exactly fills the channel before the join completes. - N=63 → **deadlock**: client times out having received 0 chunks. Any real session (`cat` a file, `ls -R`) exceeds 64 chunks instantly — the first published consumer would hit this on day one. **Fix direction** (preserves the exit-chunk-is-last invariant, ADR-005): start the drainer *before* the join — spawn a task owning `writer_rx` and `client_write` that loops `recv()` → `ChunkWriter::write_chunk` (breaking on write error, as today); run the pumps/exit join exactly as now; `send_exit_chunk` after the join (same ordering — single mpsc FIFO, so the exit chunk is still last); then `drop(writer_tx)` so the drainer sees channel close and finishes. The `W: AsyncWrite + Send + Unpin + 'static` bound already permits moving the writer into a task. Add a >64-chunk regression test with the fix (see P13). --- ### P2. [M] `AGENTS.md` ships in the published crate; exclude list has two dead entries **Files**: `Cargo.toml:11` **Problem**: alkcall's `exclude` contains `"AGENTS.md"` (alkcall/Cargo.toml:12); alktty's does not. Verified in the package list: `alktty-0.1.0/AGENTS.md` ships. Also: - `"Cargo.lock"` in `exclude` is a dead entry — modern cargo force-includes the lockfile; it ships in both crates regardless (alkcall doesn't exclude it). Remove the entry or keep as documentation. - `"docs/research/"` targets a directory that doesn't exist (harmless, but dead). - `docs/plans/project-setup.md` ships, including its `/workspace/@alkdev/...` absolute paths. alkcall ships only `docs/architecture/*`. Consider excluding `docs/plans/`. **Fix**: add `"AGENTS.md"` (and decide on `docs/plans/`) to `exclude`; drop or keep the dead entries deliberately. --- ### P3. [M] Stale agent- and reader-facing docs contradict the tree **Files**: `AGENTS.md:253-257,307-310`, `docs/architecture/README.md:21-22,28-37,138-142` **Problem**: three stale spots, one of them actively harmful: - `AGENTS.md:310` says alkcall "is v0.1.x … Pin `alkcall = "0.1.1"`" while Cargo.toml pins `alkcall = "0.4.0"` (lock: 0.4.1). An agent following AGENTS.md would *downgrade* the dependency. Same doc's ADR mapping stops at 007→008 and omits ADR-009 (landed in `37ae07a`). - `AGENTS.md:253-254` and `docs/architecture/README.md:140-142` say "Phase 5 … is not yet done" — Phase 5 landed 2026-08-17 per `docs/plans/project-setup.md:5`, and the integration tests exist and pass (19 tests). - `docs/architecture/README.md:28-37` ADR table stops at 008 and the preamble says "renumbered into alktty's ADR range (001..008)" — the directory contains 009, which `overview.md` and `tty-adapter.md` already reference. **Fix**: update AGENTS.md (alkcall guidance, phase status, ADR range 001..009) and the architecture README (ADR-009 row, phase status). Related N-level staleness in the plan doc is folded into P16. --- ### P4. [M] No README (and no `readme` field) **Files**: `Cargo.toml` (`[package]`), repo root **Problem**: no README.md and no `readme = "README.md"` field; alkcall has both. `cargo publish --dry-run` does not warn, so nothing catches it at publish time — the crates.io page will be bare and the missing field is a (trivially additive, but avoidable) post-publish change. **Fix**: write the README mirroring alkcall's structure — tagline + positioning paragraph, `## Quick start` with per-role compilable examples (producer: backends map + `TtyAdapter` as `ProtocolHandler`, or `channels::register_openable`; consumer: `TtySession::connect_direct` / `open_via_channels`), `## Architecture` (producer/consumer framing per AGENTS.md convention 7; wasm target + `local` feature note), `## Documentation` (architecture docs, docs.rs), `## License`. Add the `readme` field. Do this *after* P5/P6 so the examples show the final surface. --- ### P5. [L] Backend lookup runs before the scope gate **Files**: `src/adapter.rs:300-312` (`validate_and_allocate`) **Problem**: `backends.get(&req.backend)` (adapter.rs:300) is checked before `has_scope(identity, TTY_OPEN_SCOPE)` (adapter.rs:309). An unscoped caller gets `unknown_backend` for registered names and `forbidden` otherwise — a differential that enumerates registered backend names ("local", and every future `alknet-docker`/`alknet-ssh` registration) to any authenticated-but-unscoped identity. **Fix**: move the scope check above the lookup (arguably above the carriage/cmd checks too). Trivial; check whether any test asserts `unknown_backend` with an unscoped identity and update accordingly. --- ### P6. [L] Wire write paths accept invalid chunks **Files**: `src/wire.rs:255-306` (`write_chunk`, `write_stdin`, `write_ctrl_in_json`, `write_ctrl_out_json`) **Problem**: the read path validates (`stream_type > 4` → error, `length > MAX_CHUNK_LEN` → error, wire.rs:200-212), but the write paths validate nothing: they accept `stream_type > 4`, payloads larger than `MAX_CHUNK_LEN`, and compute `chunk.bytes.len() as u32` — silently truncating a ≥2³² payload and corrupting the peer's framing. A bad `Chunk` from a caller (or a future backend bug) corrupts the peer's stream instead of failing locally. **Fix**: return `RawError` (e.g. `InvalidStreamType`/`ChunkTooLarge` reused) when `stream_type > 4` or `len > MAX_CHUNK_LEN`. Additive pre-consumers; aligns write-side with read-side invariants. Also decide the empty-payload inconsistency while touching this: `write_chunk`/ `write_stdin` skip the payload when empty but `write_ctrl_*` always writes it (wire.rs:261 vs 290/303) — harmless on the wire (length 0 ≡ skip) but pick one shape. --- ### P7. [L] `ChunkReader`: calling `read_chunk()` after a manual `peek_stream_type()` silently corrupts framing **Files**: `src/wire.rs:166-185` **Problem**: `read_chunk()` internally calls `peek_stream_type()` then `read_chunk_after_peek()` (wire.rs:167-168). A caller who has already peeked and then calls `read_chunk()` (instead of `read_chunk_after_peek()`) consumes a *second* header byte — the peeked byte is gone — and the stream desynchronizes with no error. The docs steer callers correctly, but the misuse is one wrong call away and silent. **Fix**: track peeked state in the reader (a `bool`; `read_chunk` asserts/uses it) or `debug_assert!(!self.peeked)` in `read_chunk`. Small, pre-consumers, and it makes the misuse loud instead of corrupt. --- ### P8. [L] `StdinSink::poll_shutdown` returns `Pending` without registering a waker **Files**: `src/local/pty.rs:339-357` **Problem**: on a full stdin command channel, `try_send(StdinCmd::Eof)` → `Full` → `Poll::Pending` — but `_cx` is ignored, so no waker is registered. The input pump task may never be woken: a stdin blast that fills the channel immediately followed by EOF never delivers the EOF to the child, whose `exit_code` then never resolves. Same hang shape as P1, lower trigger probability. **Fix**: mirror `poll_write`'s inflight-future pattern in the same file (send an async send and poll it), so backpressure wakes correctly. --- ### P9. [L] `pty.rs` uses `.lock().expect("… poisoned")` — violates the poisoned-lock convention **Files**: `src/local/pty.rs:134,175,184,433,474` **Problem**: five non-test sites use `.expect()` on lock acquisition. AGENTS.md convention 2 mandates `unwrap_or_else(|e| e.into_inner())` so a panic in one operation does not cascade. (All other `.expect()` sites in `src/local/` are under `#[cfg(test)]` — verified: `local/backend.rs:83`, `pty.rs:546`, `pipe.rs:302` are the test-module boundaries.) **Fix**: mechanical replacement at the five sites. --- ### P10. [L] `pty.rs` panics on thread-spawn failure in library code **Files**: `src/local/pty.rs:465,504,523` **Problem**: `thread::Builder::spawn(...).expect("spawn pty-reader/-writer/-waiter")` panics on thread-spawn failure (fd/thread exhaustion) — a panic in library code, and the no-panics convention's spirit. `allocate_pty` already returns `Result`, so the error path exists. **Fix**: map the three sites to `TtyError` (e.g. `Backend` / `AllocFailed` with the io error message). --- ### P11. [L] `recv_stdout` doc says the stream ends at the sentinel; the implementation delivers it as an empty item **Files**: `src/session.rs:436-453` (doc vs `unfold`) **Problem**: the doc states the stream ends when the server's stdout reaches EOF (the zero-length sentinel). The `unfold` ends only when the read pump terminates — the sentinel arrives as an empty `Bytes` item the consumer must filter. The crate's own tests do exactly that (session.rs:918, 1160), i.e. the implementation is the de-facto contract and the doc is wrong — or the doc is the intended contract and the `unfold` should terminate on the empty stdout item. **Fix**: pick one before consumers codify the accident. Terminating the stream on the empty-stdout item matches the documented contract (and the sentinel's meaning: "drained") and is the better shape; the doc fix is the fallback. --- ### P12. [L] No crate-root re-exports of the primary types **Files**: `src/lib.rs:49-58` **Problem**: the crate root exports modules only; every consumer writes `alktty::adapter::TtyAdapter`, `alktty::session::TtySession`, `alktty::backend::{TtyBackend, TtyError, TtyHandle}`. Ergonomics surface decided implicitly by omission. alkcall re-exports its core types (`alkcall::core`) — the org pattern favors a curated root. **Fix**: add root re-exports (`pub use` of the primary types — and decide whether `wire`/`control`/`negotiation` constants like `STREAM_STDOUT` get root aliases). Purely additive; do it before the README (P4) so examples use the short paths. --- ### P13. [L] Test gaps on the riskiest paths **Files**: `src/wire.rs` (tests), `tests/` **Problem**: no test drives >64 chunks through `pump_session` (the P1 regression — the single most important missing test in the crate); no `MAX_CHUNK_LEN` exact-boundary test (the suite covers over-limit only); no test for the server sending stream_type 0/3 toward the consumer's read pump (the pump's discard arm); and `TtyAdapter::handle`'s `accept_bi` loop is untested (needs a transport harness). **Fix**: the P1 fix lands with its backpressure regression test; add the boundary/pump-discard cases to `wire.rs`/`session.rs` unit tests. The accept-loop harness can wait for the first transport-level consumer. --- ### P14. [N] `register_openable` returns `Result<(), String>` **Files**: `src/channels.rs:108` **Problem**: an untyped error locks `String` into the public shape. However, alkcall's own `register_openable`/`register_on` have the identical `Result<(), String>` shape (alkcall `src/channels/operations.rs:65,405`) — alktty is consistent with its upstream, and a typed error here ahead of alkcall's would be a divergence. **Disposition**: closed as consistent-with-upstream. Revisit only if alkcall types its return first; match whatever alkcall does. --- ### P15. [N] Minor polish batch Verified, low-stakes items; batch into one commit when convenient: - `src/backend.rs:253-254` — doc typo: "alktty's `local` the reference implementation" (missing "module is"). - `Cargo.toml:50` — `tokio-stream` is redundant as a dev-dependency (the main dep is genuinely used at `adapter.rs:73`; dev-deps inherit it). - `src/adapter.rs:362` — `NegotiationError::Io(_)` arm returns silently while the catch-all arm logs `debug!`; add a log for parity. - `src/session.rs:191-193` — `connect_direct` flattens alkcall's `StreamError` into `io::Error::new(ConnectionReset, string)`, losing the type. Consider a `TtySessionError` variant pre-consumers (additive now, breaking later — same logic as R5 in review #002). - `src/session.rs:228` — `params.clone()` exists only for the fail-fast parse; `serde_json::from_value` can consume via `Deserialize::deserialize(¶ms)`… or restructure to avoid the clone. Trivial. - `src/adapter.rs:133` — `set_identity` result discarded with `let _`; log on failure or assert in debug. - `src/adapter.rs:478` — `drop(input_pump)` detaches the client→backend pump; it lingers until the client disconnects after session end. Aborting at session end is tighter. - `src/negotiation.rs:263-271` — an `"error"` key in `fields` would overwrite the error code in `error_response_bytes`; no caller passes one today. One-line guard or doc note. - `src/adapter.rs:146-150`, `src/session.rs:451` — two trivial inline comments (split-idiom explanation; "Already taken") — the carve-out is for non-obvious correctness constraints; promote or remove. - `docs/plans/project-setup.md:530-531` — test counts stale (says 80+19; actual 119 lib + 19 integration = 138 under `--all-features`); front-matter `last_updated` dates in `overview.md`/`tty-adapter.md` predate the ADR-009 edits. --- ### P16. [N] No CHANGELOG.md **Files**: repo root **Problem**: alkcall ships a Keep-a-Changelog-format `CHANGELOG.md` (`# Changelog` preamble, `## [X.Y.Z] - YYYY-MM-DD` sections with `### Added/Changed/Fixed`, link definitions mapping versions to git tags). alktty has none, so v0.1.0's entry will be written retroactively from the log. **Fix**: start `CHANGELOG.md` with the full `[0.1.0]` history (the crate's commits are the source), including the link definition for the `v0.1.0` tag — and note alkcall's own 0.4.x headings currently lack theirs; don't replicate that nit. --- ## Examined and closed - **`RawError`/`NegotiationError` lack `#[non_exhaustive]`** — matches the recorded policy from review #002's R5 resolution: those enums mirror fixed wire semantics and stay *exhaustive* deliberately (in-crate matchers keep exhaustiveness checking); only the extension-point enums (`TtyError`, `TtySessionError`) are `#[non_exhaustive]`. Both are. No change. - **`register_openable` untyped error** — see P14; consistent with alkcall's shape. Closed. - **"Phase 7" references** — all are the legitimate alknet-Phase-7 provenance note inside ADR-001 (control-channel split amendment), matching AGENTS.md's instruction. Not stale. - **MSRV 1.85** — no let-chains, no edition-2024 syntax, no post-1.85 std APIs in `src/`/`tests/`; lockfile pins back the claim. Clean. - **`unsafe`** — exactly the four documented `libc::kill` sites in `src/local/` (`pty.rs:157,162`, `pipe.rs:167,176`). Convention 14 holds. - **`local`-feature isolation** — zero `portable_pty`/`tokio::process`/ `std::thread`/`libc` code outside `src/local/` (doc mentions only); wasm check + clippy pass. Convention 9 holds. --- ## What's Good - **The verification baseline is genuinely clean** — 138 tests, both clippy targets, fmt, doc, publish dry-run, wasm build all pass on the reviewed tree. - **The reader side of the wire format is correct.** Header bounds checks, bounded allocation (≤16 MiB before `vec!`), `ConnectionClosed` vs `Io` distinction, the `0x00` framing-disambiguation invariant, and the BAST-drift guard on enum ordering all match ADR-001/006. - **The cancellation/kill contract holds.** Both kill guards (`LocalExitFuture`, `PipeExitFuture`) disarm on resolve and kill on cancel (ADR-006), verified by the cancel-cleanup integration tests; `TtySession::wait`'s watch pattern has no lost wakeup; `Drop` aborts the read pump. - **The packaging is one file away from alkcall parity.** Every other `[package]` field matches (edition, rust-version, license + both files, keywords at the 5 limit, categories, `[lib]` name), and the publish dry-run is otherwise warning-free. - **The docs structure is sound.** The ADR set is complete on disk (001–009, including the reversal pair 007/008), the "Phase 7" provenance is handled per convention, and the port-provenance `alknet-tty` mentions all correctly describe history. ## Remediation Plan | ID | Finding | Fix | Effort | Risk | Status | |----|---------|-----|--------|------|--------| | P1 | pump_session deadlock ≥63 chunks | concurrent drainer + regression test | small | medium (core pump) | ✅ resolved (`87c52e5`) | | P2 | AGENTS.md ships; dead exclude entries | fix `exclude` | trivial | none | ✅ resolved (Session 3) | | P3 | stale AGENTS.md + architecture README | refresh phase/ADR/alkcall text | trivial | none | ✅ resolved (Session 3) | | P4 | no README + `readme` field | write README (after P5/P6/P12) | medium | none | ✅ resolved (Session 2) | | P5 | scope gate after backend lookup | reorder checks | trivial | low (test updates) | ✅ resolved (Session 3) | | P6 | write paths accept invalid chunks | validate on write; unify empty-payload shape | small | low | ✅ resolved (Session 2) | | P7 | read-after-peek framing corruption | peeked-state guard | small | none | ✅ resolved (Session 2) | | P8 | poll_shutdown missing waker | inflight-future pattern | small | low | ✅ resolved (Session 3) | | P9 | poisoned-lock `.expect()` × 5 | `into_inner()` | trivial | none | ✅ resolved (Session 3) | | P10 | thread-spawn `.expect()` × 3 | map to `TtyError` | trivial | none | ✅ resolved (Session 3) | | P11 | recv_stdout sentinel contract | terminate stream on sentinel (or fix doc) | small | low (semver-visible) | ✅ resolved (Session 3) | | P12 | no crate-root re-exports | `pub use` primary types | trivial | none | ✅ resolved (Session 2) | | P13 | test gaps | backpressure (>64 chunks) + boundary tests | small | none | ✅ resolved (`87c52e5`; accept-loop harness deferred) | | P14 | `Result<(), String>` on register_openable | closed — alkcall parity | none | none | ✅ closed | | P15 | minor polish batch | one batched commit | small | none | ✅ resolved (Session 3) | | P16 | no CHANGELOG.md | start changelog with [0.1.0] | small | none | ✅ resolved (Session 2) | ## Suggested Session Breakdown Natural break points, cheapest-dependency order first: 1. **P1 + P13** — the deadlock and its regression test are one unit (the test proves the fix). Highest value; do first. 2. **P12 + P6 + P7** — the API-shape batch (root re-exports, write validation, peek guard). Then **P4** (README uses the final surface) and **P16** (changelog) in the same or next session. 3. **P8 + P9 + P10** — the `local` robustness batch, all in `pty.rs`. 4. **P5 + P11** — producer-hygiene batch (scope reorder + sentinel contract decision). 5. **P2 + P3 + P15** — packaging/docs closeout; then final `cargo publish --dry-run` and tag. ## Session-1 Resolution (P1 + P13, 2026-09-05) P1 and P13 were resolved together (the regression test is part of the fix). Commit: `87c52e5`. - **P1 fix**: `pump_session` now spawns the drainer (`drain_chunks`, a new function owning `writer_rx` + `client_write`) *before* the pumps+exit join; after the join, `send_exit_chunk` enqueues as before, then `drop(writer_tx)` lets the drainer observe channel close and finish. The single FIFO preserves exit-chunk-is-last (ADR-005); `W`'s existing `'static + Send + Unpin` bounds permitted moving the writer into the task. The doc comment on `pump_session` was updated to describe the concurrent-drainer shape and why it is load-bearing. - **P1 regression test** (`adapter::tests::backpressure_more_chunks_than_channel_capacity`): drives 80 chunks (> the 64-slot channel) through a real `drive_session` via the `TestBackend` harness, asserts in-order delivery of all 80 + sentinel + exit, with a 10 s per-read timeout so a regression fails the test instead of hanging it (the test body was validated against the bug: with the old order reinstated, it fails with "chunk read must not stall (P1 regression: deadlock): Elapsed" — the producer is spawned so the test's own sends cannot park first). - **P13 boundary test** (`wire::tests::chunk_at_exact_max_len_boundary`): a payload of exactly `MAX_CHUNK_LEN` round-trips (the limit is inclusive); the existing `chunk_too_large` covers over-limit. - **P13 discard test** (`session::tests::read_pump_ignores_client_to_server_stream_types_from_server`): stream_type 0 and 3 chunks arriving from the server (mid-stream — a leading `0x00` is the negotiation-error marker, ADR-052 §5, so stream_type 0 cannot be first) are ignored without desynchronizing framing, losing stdout data, or blocking the exit. The remaining P13 item (the `TtyAdapter::handle` accept-loop harness) stays deferred to the first transport-level consumer, per the review. Verification on the resolution tree: `cargo test` 98 lib (was 95) / `cargo test --all-features` 141 (was 138); clippy (all-targets + wasm32), fmt, wasm check, doc — all clean. ## Session-2 Resolution (P12 + P6 + P7 + P4 + P16, 2026-09-05) The API-shape batch (P12/P6/P7) plus the docs that use the final surface (P4/P16), per the review's suggested session breakdown item 2. Commit: `c8e4610`. - **P12 — crate-root re-exports** (`src/lib.rs`): `pub use` of the primary types — `TtyAdapter`, `drive_session`, `drive_session_pre_negotiated`, `TTY_OPEN_SCOPE` (adapter); `TtyBackend`, `TtyHandle`, `TtyParams`, `TerminalParams`, `TtyControl`, `TtyControlHandle`, `TtyError`, `BoxFuture` (backend); `TtySession`, `TtySessionError` (session); `Chunk`, `ChunkReader`, `ChunkWriter`, `RawError` and the five `STREAM_*` constants + `CHUNK_HEADER_LEN` + `MAX_CHUNK_LEN` (wire); `ControlMessage` and `signal_from_name` — the latter `#[cfg(unix)]`-gated to match its definition (caught by the wasm check); `NegotiateRequest`, `TerminalParamsWire`, `NegotiationError`, `NegotiationReader`, `NegotiationWriter`, `error_response_bytes` (negotiation); `register_openable`, `tty_open_spec`, `OP_TTY_OPEN`, `TTY_ALPN` (channels); `LocalTtyBackend` under the `local` feature. Module paths remain the full surface; the lib.rs doc comment gained a "Crate-root surface" section explaining the split. - **P6 — write-path validation** (`src/wire.rs`): all four `ChunkWriter` methods validate via a shared `validate_header` (stream_type ≤ 4, length ≤ `MAX_CHUNK_LEN`) before touching the transport, reusing `RawError::InvalidStreamType`/`ChunkTooLarge`. The length is validated as `u64` *before* the `u32` cast, so an oversized payload fails instead of wrapping past the check — validating the truncated value would have reintroduced the exact bug the validation exists to catch. Empty-payload shape unified: one `write_validated` helper emits `length == 0` + no payload bytes for all four paths (the old `write_ctrl_*` always-write shape is gone; wire-equivalent). Tests: reject-invalid-stream-type (nothing written), reject-oversized (writer + all three helpers), and exact-boundary acceptance. - **P7 — peek-state guard** (`src/wire.rs`, `src/session.rs`): `ChunkReader` tracks peeked state. `read_chunk()` after `peek_stream_type()` completes the peeked chunk instead of consuming a second header byte; a second peek is idempotent; `read_chunk_after_peek` debug-asserts a peek happened. This let the session's read pump drop its manual `first_byte_peeked` threading — `read_chunk()` handles both orders. Tests: peek-then-`read_chunk` delivers two well-framed chunks (the old shape silently read the peeked byte's length field from the payload), and peek idempotence. - **P4 — README + `readme` field**: `README.md` mirrors alkcall's structure — tagline + positioning, `## Quick start` with per-role examples (producer: backends map + `TtyAdapter` as `ProtocolHandler`; producer-via-channels: `register_openable`; consumer: `TtySession::connect_direct` with the full typed-method flow; consumer-via-channels: `open_via_channels`), `## Architecture` (producer/consumer table per convention 7, wire-format summary, wasm + `local` note), `## Documentation`, `## License`. Examples use the P12 root paths. `readme = "README.md"` added to `[package]`; the dry-run package list confirms it ships. - **P16 — CHANGELOG.md**: Keep-a-Changelog format with a full `[0.1.0]` entry written from the commit log (wire format, backend trait, local backend, adapter, channels integration, session consumer, control messages, plus the Session-1/2 fixes folded into their features), with the `[0.1.0]` release-tag link definition (alkcall's missing-link nit not replicated). ADR references use the actual on-disk numbering (exit code = ADR-004, cancel-cleanup = ADR-005, framing = ADR-006, channels = ADR-008/009). Verification on the resolution tree: `cargo test` 104 lib (was 98) / `cargo test --all-features` 128 lib + 19 integration = 147 (was 141); clippy (all-targets + wasm32) `-D warnings`, fmt, wasm check, doc (0 warnings), `cargo publish --dry-run --allow-dirty` (44 files, 702.8KiB) — all clean. ## Session-3 Resolution (P8 + P9 + P10 + P5 + P11 + P2 + P3 + P15, 2026-09-05) The `local` robustness batch, the producer-hygiene batch, and the packaging/docs closeout — the review's suggested session breakdown items 3, 4, and 5. This closes review #003. - **P8 — `poll_shutdown` waker** (`src/local/pty.rs`): `StdinSink` gained an `inflight_close` slot mirroring `poll_write`'s inflight pattern — on a full stdin channel, `poll_shutdown` parks a `reserve + send(StdinCmd::Eof)` future and polls it on re-poll, so the poller's waker is registered and a stdin blast followed by EOF delivers the EOF instead of stranding it. Distinct from the byte-write slot so the two sends never share a future. - **P9 — poisoned-lock `.expect()` ×5** (`src/local/pty.rs`): the five non-test lock sites (`PtyControl::resize`, both `PtyControl::signal` arms, the reader-thread and writer-thread master-lock acquisitions) use `unwrap_or_else(|e| e.into_inner())` per convention 2. - **P10 — thread-spawn `.expect()` ×3** (`src/local/pty.rs`): the reader/writer/waiter `thread::Builder::spawn` results map to `TtyError::AllocFailed` (the error path existed — `allocate_pty` returns `Result`); fd/thread exhaustion surfaces as an allocation error, not a panic. - **P5 — scope gate first** (`src/adapter.rs`): `validate_and_allocate` now runs the `tty:open` scope check before the carriage/cmd checks and the backend lookup. An authenticated-but-unscoped identity gets `forbidden` regardless of the request body — the `unknown_backend`-vs-`forbidden` name- enumeration differential is gone. The existing `scope_gate_forbidden_without_tty_open` test asserted the scoped behavior and still passes (it uses a *registered* backend name, so the reorder doesn't change its outcome); no test asserted `unknown_backend` with an unscoped identity. - **P11 — sentinel contract** (`src/session.rs`): `recv_stdout` now terminates the stream ON the zero-length stdout sentinel ("drained") — the sentinel is not yielded as an item. This matches the documented contract and the sentinel's meaning; consumers no longer filter. `recv_stderr`'s doc now states stderr has no sentinel (the adapter's stderr pump emits none; that stream ends when the read pump terminates). The three session tests that filtered the sentinel manually now assert the stream ends on it (no filter). The README example's manual `is_empty` break was removed. - **P2 — packaging** (`Cargo.toml`): `exclude` is now `[".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/sdd_process.md"]` — AGENTS.md no longer ships (alkcall parity), `docs/plans/` (absolute `/workspace/...` paths) no longer ships, and both dead entries (`Cargo.lock` — force-included anyway; `docs/research/` — nonexistent) are dropped. Package list verified: 42 files, 659.2KiB (was 44 files, 702.8KiB). - **P3 — stale docs**: AGENTS.md now says all five phases are landed, the ADR range is 001..009 with the alktty-native ADR-009 noted in the mapping list, and the alkcall guidance is corrected to "v0.4.x — Pin `alkcall = "0.4.0"` (lockfile resolves 0.4.1)" (was "v0.1.x — pin 0.1.1", which would have downgraded the dependency). `docs/architecture/README.md` gained the ADR-009 table row, the 001..009 preamble, and the landed-phase status. - **P15 — polish batch** (one commit, as planned): - `src/backend.rs` doc typo fixed ("`local` feature module) is the reference implementation"). - `tokio-stream` dev-dependency removed (inherited from the main dep). - `src/adapter.rs` — the `NegotiationError::Io(_)` arm now logs (`debug!`) for parity with the catch-all arm; `set_identity` failure is logged (`AlreadySet` is benign-by-construction, noted in a comment); `drop(input_pump)` → `input_pump.abort()` at session end (no lingering client→backend task holding the backend stdin half after the exit chunk). - `src/session.rs` — `connect_direct`/`from_bidi_stream_via` map the `accept_bi` `StreamError` into a new `TtySessionError::Open` variant (`#[from]`) instead of flattening it into an anonymous `io::Error(ConnectionReset)` (additive pre-consumers, same logic as review #002 R5); the fail-fast params parse uses `NegotiateRequest::deserialize(¶ms)` (borrowing) instead of `from_value(params.clone())` — no clone. - `src/negotiation.rs` — `error_response_bytes` skips an `"error"` key in `fields` so it cannot overwrite the error code. - Two trivial inline comments promoted or removed (the split-idiom comment in `adapter.rs` removed; the "Already taken" comment in `session.rs` now points at the documented behavior). - `docs/plans/project-setup.md` test counts refreshed (119 lib + 19 integration = 138 under `--all-features` at the review baseline, with a note that the suites have grown); `overview.md` and `tty-adapter.md` front-matter `last_updated` dates and the ADR range updated for the ADR-009 edits. - The `input_pump.abort()` change surfaced a latent test race: the `send_stdin_round_trips_to_backend` and `resize_and_signal_dont_error` tests used `MockBackend` (exit resolves on allocate) and were writing while the session data plane was already closing — previously masked by the lingering input pump the abort now reclaims. Both use a new `GatedBackend` (exit held until the test releases it), making the writes deterministic and asserting the exit code. Verification on the resolution tree: `cargo test` 104 lib (unchanged) / `cargo test --all-features` 128 lib + 19 integration = 147 (unchanged; the two reworked tests kept their names); clippy (all-targets + wasm32) `-D warnings`, fmt, wasm check, doc (0 warnings), `cargo publish --dry-run --allow-dirty` (42 files, 659.2KiB) — all clean. **Review #003 is closed.** Published: `cargo publish` uploaded alktty v0.1.0 to crates.io (2026-09-05, dry-run gate clean without `--allow-dirty`), and the `v0.1.0` tag — the CHANGELOG link definition's target — is pushed. Post-publish iteration is expected at first-consumer time (the alkcall trajectory: unpublished mistakes were free, per-consumer ones are semver cost); the wire format (ADR-001) and the `TtyBackend` trait shape (ADR-002) are the one-way doors this review scrutinized hardest. ## Notes - Line numbers refer to the tree at `918af40` (review base). - The P1 reproduction probe was a scratch crate at `/tmp/opencode/alktty-deadlock-probe` (mock `TtyBackend` emitting N chunks through real `drive_session` over `tokio::io::duplex`; N=62 passes, N=63 hangs) — ephemeral by design; the P13 regression test makes it permanent. - The two subagent reviews overlapped on findings P1/P5/P6/P7/P9/P10/ P12 and the `#[non_exhaustive]` question; the overlapping findings agreed, and every one was re-verified against source before being recorded. The packaging agent's file list came from `cargo package --list --allow-dirty`, not inference. - This review deliberately does not re-open decisions recorded in review #002 (R3's per-connection-registry constraint, R5's `#[non_exhaustive]` policy) — they are cited where relevant. ## Cross-references - Review #001 (`docs/reviews/001-code-review.md`) — first full review; its M/L/N ID scheme is why this review uses P1…P16 (prepublish). - Review #002 (`docs/reviews/002-post-session-review.md`) — R5's resolution records the `#[non_exhaustive]` policy this review applied in "Examined and closed". - alkcall 0.4.1 (`/workspace/@alkdev/alkcall`) — packaging/README/ CHANGELOG reference for P2/P4/P16; `src/channels/operations.rs` for P14. - ADR-001 (wire format) and ADR-005 (exit-code chunk) — the invariants P1's fix must preserve.