Files
alktty/docs/reviews/003-prepublish-review.md
T

744 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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::<Chunk>(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(&params)`… 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
(001009, 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(&params)` (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.