Files
alktty/docs/reviews/003-prepublish-review.md
T
glm-5.3-flash da94d5e23f docs: add review #003 (prepublish review for v0.1.0)
Two subagent reviews (code + packaging/docs vs alkcall 0.4.1) with every
finding manually re-verified against source at 918af40. 13 open findings:
P1 pump_session deadlock at >=63 stdout chunks (empirically reproduced,
N=62 ok / N=63 hangs), P2 AGENTS.md ships in the crate, P3 stale
AGENTS.md/architecture-README text, P4 no README, P5-P13 API-shape and
robustness items, P15-P16 polish/changelog. P14 closed as alkcall parity.

Includes suggested session breakdown for distributing the remediation.

Verification baseline on the reviewed tree: cargo test 95 lib (default) /
138 --all-features, clippy (all-targets + wasm32) clean, fmt clean, doc
clean, publish --dry-run OK, CJK scan clean.
2026-09-05 09:30:32 +00:00

525 lines
23 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: open
last_updated: 2026-09-05
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)
---
# 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) | open |
| P2 | AGENTS.md ships; dead exclude entries | fix `exclude` | trivial | none | open |
| P3 | stale AGENTS.md + architecture README | refresh phase/ADR/alkcall text | trivial | none | open |
| P4 | no README + `readme` field | write README (after P5/P6/P12) | medium | none | open |
| P5 | scope gate after backend lookup | reorder checks | trivial | low (test updates) | open |
| P6 | write paths accept invalid chunks | validate on write; unify empty-payload shape | small | low | open |
| P7 | read-after-peek framing corruption | peeked-state guard | small | none | open |
| P8 | poll_shutdown missing waker | inflight-future pattern | small | low | open |
| P9 | poisoned-lock `.expect()` × 5 | `into_inner()` | trivial | none | open |
| P10 | thread-spawn `.expect()` × 3 | map to `TtyError` | trivial | none | open |
| P11 | recv_stdout sentinel contract | terminate stream on sentinel (or fix doc) | small | low (semver-visible) | open |
| P12 | no crate-root re-exports | `pub use` primary types | trivial | none | open |
| P13 | test gaps | backpressure (>64 chunks) + boundary tests | small | none | open |
| P14 | `Result<(), String>` on register_openable | closed — alkcall parity | none | none | ✅ closed |
| P15 | minor polish batch | one batched commit | small | none | open |
| P16 | no CHANGELOG.md | start changelog with [0.1.0] | small | none | open |
## 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.
## 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.