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.
23 KiB
status, last_updated, reviewed_artifacts, tool, reviewer, base_commit
| status | last_updated | reviewed_artifacts | tool | reviewer | base_commit | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| open | 2026-09-05 |
|
two subagent reviews (code review + packaging/docs vs alkcall 0.4.1) + manual verification of every finding against source + empirical deadlock reproduction | prepublish review for v0.1.0 (subagent-assisted) | 918af40 (review |
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 (alsocargo 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 insrc/ortests/. - 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"inexcludeis 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.mdships, including its/workspace/@alkdev/...absolute paths. alkcall ships onlydocs/architecture/*. Consider excludingdocs/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:310says alkcall "is v0.1.x … Pinalkcall = "0.1.1"" while Cargo.toml pinsalkcall = "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 in37ae07a).AGENTS.md:253-254anddocs/architecture/README.md:140-142say "Phase 5 … is not yet done" — Phase 5 landed 2026-08-17 perdocs/plans/project-setup.md:5, and the integration tests exist and pass (19 tests).docs/architecture/README.md:28-37ADR table stops at 008 and the preamble says "renumbered into alktty's ADR range (001..008)" — the directory contains 009, whichoverview.mdandtty-adapter.mdalready 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'slocalthe reference implementation" (missing "module is").Cargo.toml:50—tokio-streamis redundant as a dev-dependency (the main dep is genuinely used atadapter.rs:73; dev-deps inherit it).src/adapter.rs:362—NegotiationError::Io(_)arm returns silently while the catch-all arm logsdebug!; add a log for parity.src/session.rs:191-193—connect_directflattens alkcall'sStreamErrorintoio::Error::new(ConnectionReset, string), losing the type. Consider aTtySessionErrorvariant 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_valuecan consume viaDeserialize::deserialize(¶ms)… or restructure to avoid the clone. Trivial.src/adapter.rs:133—set_identityresult discarded withlet _; 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 infieldswould overwrite the error code inerror_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-matterlast_updateddates inoverview.md/tty-adapter.mdpredate 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/NegotiationErrorlack#[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_openableuntyped 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 documentedlibc::killsites insrc/local/(pty.rs:157,162,pipe.rs:167,176). Convention 14 holds.local-feature isolation — zeroportable_pty/tokio::process/std::thread/libccode outsidesrc/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!),ConnectionClosedvsIodistinction, the0x00framing-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;Dropaborts 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-ttymentions 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:
- P1 + P13 — the deadlock and its regression test are one unit (the test proves the fix). Highest value; do first.
- 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.
- P8 + P9 + P10 — the
localrobustness batch, all inpty.rs. - P5 + P11 — producer-hygiene batch (scope reorder + sentinel contract decision).
- P2 + P3 + P15 — packaging/docs closeout; then final
cargo publish --dry-runand 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(mockTtyBackendemitting N chunks through realdrive_sessionovertokio::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 fromcargo 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.rsfor P14. - ADR-001 (wire format) and ADR-005 (exit-code chunk) — the invariants P1's fix must preserve.