Files
alktty/docs/reviews/001-code-review.md
T
deepseek-v4-pro e5330e957b docs: mark review #001 findings resolved (M1, M2, L2, L4, L5, N1-N3, N5)
Update the remediation plan with a resolution section and per-finding
status. Nine findings resolved in commit 9944153; L1, L3, L6, N4, N6
remain open.
2026-08-17 12:13:35 +00:00

26 KiB
Raw Blame History

status, last_updated, reviewed_artifacts, tool, reviewer
status last_updated reviewed_artifacts tool reviewer
partially-resolved (M1, M2, L2, L4, L5, N1, N2, N3, N5) 2026-08-17
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
manual source read + cargo test/clippy/fmt/doc + cargo-llvm-cov post-Phase-5 code review (coverage + correctness + code smell)

Code Review #001 — Post-Phase-5 Review

Purpose

First full review of the alktty crate after Phase 5 (tests) landed. The publisher asked for three things: a coverage pass (cargo-llvm-cov) with an eye toward important gaps rather than raw numbers, a correctness pass, and a code-smell pass. This review covers all three.

The crate is a port of alknet-tty + alknet-tty-local from the alknet mono-repo into a single feature-gated crate, plus new channels integration (src/channels.rs) and a new consumer half (src/session.rs). The ported code (wire, control, negotiation, backend, adapter, local) is battle-tested; the new code (channels + session) is where the correctness findings concentrate, which is expected for first-pass code.

Methodology

  • Full read of all 12 src/*.rs files (production + test modules) and all 4 integration test files.
  • cargo test (default) and cargo test --all-features.
  • cargo clippy --all-targets -- -D warnings and cargo clippy --target wasm32-unknown-unknown -- -D warnings.
  • cargo check --target wasm32-unknown-unknown (the wasm-clean guard).
  • cargo fmt --check and cargo doc --no-deps.
  • cargo llvm-cov --all-features (summary + per-file + uncovered-lines) to attribute coverage gaps to specific code paths.
  • cargo publish --dry-run --allow-dirty to confirm the package is publishable.
  • Cross-reference every error path against its caller to confirm errors propagate (not swallowed) and carry useful attribution.

Verification Baseline

All verification run on the reviewed tree (commit 18c4924):

  • cargo test: 81 lib tests pass (default, wasm-clean build).
  • cargo test --all-features: 104 lib tests + 19 integration tests pass (123 total). Zero failures.
  • cargo clippy --all-targets -- -D warnings: clean.
  • cargo clippy --target wasm32-unknown-unknown -- -D warnings: clean.
  • cargo check --target wasm32-unknown-unknown: clean (the default crate stays wasm-clean).
  • cargo fmt --check: FAILS — 2 diffs in src/wire.rs (see L5).
  • cargo doc --no-deps: 9 rustdoc warnings (see N1).
  • cargo publish --dry-run --allow-dirty: packages cleanly (40 files, 608.2 KiB).
  • cargo llvm-cov --all-features: 90.74% line coverage (3019/3327), 92.24% function coverage (392/425). Per-file breakdown below.

Coverage breakdown

Module Lines Functions
adapter.rs 92.35% 94.31%
backend.rs 89.94% 88.00%
channels.rs 93.43% 93.10%
control.rs 99.18% 100.00%
local/backend.rs 96.84% 94.12%
local/pipe.rs 90.84% 92.50%
local/pty.rs 80.85% 87.88%
negotiation.rs 99.26% 100.00%
session.rs 79.71% 79.25%
wire.rs 96.73% 96.23%
TOTAL 90.74% 92.24%

The two low modules are not test-helper noise — they are exactly where the correctness findings below live. session.rs (the consumer half) is the newest code and its lowest-covered paths are the channels constructor and the read-pump stdout/stderr routing (see L2, L3). local/pty.rs's uncovered lines are the blocking→async bridge's error paths (see L6).

Summary Statistics

Severity Count
Critical 0
Medium 2 (M1, M2)
Low 6 (L1, L2, L3, L4, L5, L6)
Nit 6 (N1, N2, N3, N4, N5, N6)

No critical findings. The two Medium findings are in the consumer half (TtySession): the negotiation-rejection error frame is never handled, and wait() swallows the malformed-exit-chunk error. Both are correctness gaps in the newest code, not regressions in the ported code. The Low findings are a mix of coverage gaps, a public-API leak, and a process failure (cargo fmt). The Nits are hygiene.


Findings

M1. TtySession never handles the negotiation-rejection error frame

Files: src/session.rs:90 (NegotiationRejected), src/session.rs:225-254 (from_halves), src/session.rs:405-474 (read_pump)

Problem: TtySessionError::NegotiationRejected is dead — it is declared and documented but never constructed. from_halves writes the negotiation frame, then immediately starts read_pump with a ChunkReader. If the server rejects negotiation (unknown backend, forbidden, allocate_failed), it sends a 4-byte-length-prefixed JSON error frame whose first byte is 0x00. The ChunkReader will misparse that as a stream_type = 0 (stdin) chunk with a garbage length, and the session will either error with a confusing RawError or hang.

The entire ADR-052 §5 framing-disambiguation design exists precisely so the client can detect this case: an error frame's length prefix starts with 0x00, while a raw chunk's first byte is a stream_type in {1, 2, 4} (the server never sends 0 or 3). The producer half implements the disambiguation (it writes the error frame); the consumer half does not implement the read side. The error type documents the intent; the code does not do it.

Fix: after writing the negotiation frame, read the first byte of the response. If it is 0x00, read the remaining 3 length bytes, read the body, parse the {"error": "...", ...} JSON, and return TtySessionError::NegotiationRejected { error, fields }. Otherwise, the first byte is a stream_type and the session should hand the byte back to the ChunkReader (the reader needs a "push back one byte" path, or the session reads the full 5-byte header itself and constructs the first Chunk). This is the one place the consumer half is genuinely incomplete.

Lift: closes a real protocol gap — a rejected session currently surfaces as a confusing wire error instead of the intended NegotiationRejected. Medium effort (the disambiguation logic is ~30 lines, plus a small ChunkReader change to accept a pre-read first byte).


M2. wait() swallows MalformedExitChunk

Files: src/session.rs:359-381 (wait), src/session.rs:441 (read_pump)

Problem: the read pump does construct TtySessionError::MalformedExitChunk when a STREAM_CTRL_OUT chunk fails to parse as a ControlMessage (session.rs:441), but wait() collapses every Err(_) in the watch channel to TtySessionError::NoExitChunk:

if let Some(Err(_)) = borrow.as_ref() {
    return Err(TtySessionError::NoExitChunk);
}

A malformed exit chunk is therefore indistinguishable from a clean close. The MalformedExitChunk variant is effectively dead — the only place it is constructed is immediately flattened away by the only consumer of the watch channel. A client that receives a corrupt exit chunk gets "session ended without exit chunk" instead of "malformed exit chunk", which is misleading and hides a real protocol error.

Fix: wait() should match on the specific error variant and propagate it:

match borrow.as_ref() {
    Some(Ok(code)) => Ok(*code),
    Some(Err(e)) => Err(match e {
        TtySessionError::MalformedExitChunk(_) => /* clone/rebuild */,
        _ => TtySessionError::NoExitChunk,
    }),
    None => Err(TtySessionError::NoExitChunk),
}

TtySessionError is not Clone, so either derive Clone on the error type (it is all String/serde_json::Error/io::Error payloads — io::Error is Clone, serde_json::Error is Clone) or restructure the watch channel to carry a Result<i32, String> and map at the boundary. Small effort.

Lift: closes a swallowed-error path; makes the malformed-exit-chunk case observable. Small effort.


L1. Channels path ignores the registry-validated input

Files: src/channels.rs:180-205 (make_tty_open_handler)

Problem: make_tty_open_handler receives the open op's input (the NegotiateRequest params, validated by the registry's schema against carriage/backend/cmd required fields) and does let _ = input; — it reads the negotiation from the channel's wire frame instead. The registry validates input and enforces the scope gate, but the actual backend selection comes from the unvalidated wire frame. A client can pass schema-valid input and write a divergent frame.

This is not a vulnerability today (the only ACL is identity-scoped, and drive_session re-validates the frame), but it is a latent inconsistency: the channels wrapper's AccessControl and the drive_session negotiation reader are two independent gates that can disagree. The doc comment on make_tty_open_handler acknowledges the frame is read from the wire "same as the direct-ALPN path", but the input parameter is then dead weight.

Fix: decide with the publisher. Either (a) drop the input parameter from the OpenHandler signature if the wire frame is the source of truth, or (b) pass input through to drive_session so the registry-validated params are authoritative and the wire frame is ignored. Option (b) is the more consistent design but requires a drive_session variant that takes pre-parsed params. Defer until the channels path gets a real consumer.

Lift: removes a latent inconsistency. Small effort either way.


L2. Consumer read-pump stdout/stderr routing is untested

Files: src/session.rs:419-429 (read_pump), src/session.rs:588-608 (recv_stdout_yields_backend_stdout)

Problem: the consumer's core data path — actually receiving stdout and stderr chunks and routing them to the right channel — is never tested with real data. The one test that touches it (recv_stdout_yields_backend_stdout) explicitly admits the gap:

MockBackend doesn't pump stdout (it resolves exit immediately), so the stdout stream should be empty.

MockBackend drops its stdout/stderr senders on allocation, so the read pump's STREAM_STDOUT/STREAM_STDERR arms (session.rs:419-429) are never exercised with a non-empty payload. The producer half's stdout/stderr pumping is well-tested (adapter.rs), but the consumer half's receiving of those chunks is not. A regression in the routing (e.g. stdout chunks landing on the stderr channel) would not be caught.

Fix: add a test backend (or extend MockBackend) that emits a stdout chunk and a stderr chunk before resolving exit, then assert recv_stdout() yields the stdout bytes and recv_stderr() yields the stderr bytes. Small effort, high value — this is the consumer half's happy path.


L3. open_via_channels and from_bidi_stream_via are 0% covered

Files: src/session.rs:175-197 (open_via_channels), src/session.rs:213-221 (from_bidi_stream_via)

Problem: the entire consumer channels path is untested. Only connect_direct is exercised (via the duplex harness in session.rs::tests). open_via_channels — the constructor that opens a channel via ChannelClient, adopts the channel, builds a Connection from channel_source, and negotiates — has zero coverage. The producer side of channels (register_openable, TtyOpenHandler) is tested end-to-end in channels.rs::tests, but the consumer side is not.

Fix: add an end-to-end test that wires a ChannelClient to a server-side ChannelCore (the wire_client_and_server harness in channels.rs::tests already does the hard part) and calls TtySession::open_via_channels(client, params), asserting the session negotiates and wait() resolves. Medium effort (the harness exists but is in a different module's test scope; it may need to be shared or duplicated).


L4. Test mocks leak into the public API

Files: src/backend.rs:286 (MockControl), src/backend.rs:308 (MockBackend)

Problem: MockBackend and MockControl are pub in backend.rs, and backend is pub mod (lib.rs:50). They are only used by #[cfg(test)] modules (adapter.rs, channels.rs, session.rs tests), so they should not be part of the public API surface. Downstream consumers see alktty::backend::MockBackend and alktty::backend::MockControl, which is noise and a maintenance liability (they are now part of the semver surface).

Fix: change pub struct MockBackend / pub struct MockControl to pub(crate) struct, or gate them behind a test-util feature. The pub(crate) route is simplest and sufficient — all uses are in-crate test modules. Small effort.


L5. cargo fmt --check fails — unformatted code was committed

Files: src/wire.rs:447-460 (bast_stream_type_enum_matches_wire_constants)

Problem: cargo fmt --check reports 2 diffs, both in the BAST drift-detection test added in commit 18c4924. The agent's commit message claimed "My change introduced no new warnings," but it introduced a cargo fmt failure — the cargo fmt --check step in AGENTS.md's verification commands was skipped. This is the most concrete, actionable item in the review: the tree does not pass its own verification gate.

Fix: run cargo fmt and commit the result. Trivial effort.


L6. local/pty.rs blocking→async bridge error paths are untested

Files: src/local/pty.rs:409-411 (try_clone_reader failure), src/local/pty.rs:428-431 (reader read error), src/local/pty.rs:450-452 (take_writer failure), src/local/pty.rs:460-466 (writer write/flush failure), src/local/pty.rs:486-489 (waiter wait() failure)

Problem: local/pty.rs is the lowest-covered module (80.85% lines), and the uncovered lines are exactly the error paths of the three-thread bridge. These are the paths that matter most for a blocking→async bridge: what happens when the master reader can't be cloned, when the writer thread's write_all fails, when the waiter's wait() fails. The happy path (echo, cat, resize, signal) is well-tested; the failure paths are not.

Some of these are structurally hard to reach (e.g. try_clone_reader failing requires the master to be in a bad state), but the writer write/flush failure and the waiter wait() failure are reachable by killing the child mid-write or racing the waiter. The wait() failure path in particular is the one that produces the -1 exit code sentinel (ADR-055 §4), which is a documented wire-format behavior with no test.

Fix: add targeted tests for the reachable paths (waiter wait() failure → -1 exit code; writer write failure → clean thread exit). The try_clone_reader/take_writer failure paths can be left as documented-unreachable if the publisher agrees. Medium effort.


Files: src/lib.rs:23,30, src/backend.rs:96, src/negotiation.rs:75, src/channels.rs:12,53,70, src/session.rs:16,259

Problem: cargo doc --no-deps emits 9 unresolved-link warnings. These are the "gross" warnings the Phase-5 agent noted as pre-existing. Each is a distinct fix:

  • lib.rs:23 [local] and lib.rs:30 [local::LocalTtyBackend] — the local module is feature-gated, so the link target doesn't exist in the default (no-features) doc build. Fix: use a plain-code link (`local`) or gate the doc line with #[cfg(feature = "local")].
  • backend.rs:96 argv[0] and negotiation.rs:75 argv[0] — parsed as intra-doc links. Fix: escape as `argv[0]` (backticks) or argv\[0\].
  • channels.rs:12,70 [TtyOpenHandler]TtyOpenHandler is a private fn, not linkable. Fix: plain-code `TtyOpenHandler`.
  • channels.rs:53 [crate::adapter::TtyAdapter::alpn]alpn is a method, not a field/associated item. Fix: link the type [crate::adapter::TtyAdapter] and mention alpn() in prose.
  • session.rs:16 [alkcall::channels::ChannelClient] — wrong path; the type is alkcall::channels::client::ChannelClient. Fix the path.
  • session.rs:259 [close_stdin] — needs [Self::close_stdin].

Fix: one pass over the 9 sites. Trivial effort, but it makes cargo doc clean and unblocks treating doc warnings as errors in CI.


N2. Stale doc paths that don't exist in this repo

Files: src/wire.rs:32, src/adapter.rs:51

Problem: two doc comments reference paths that don't exist in the alktty tree:

  • wire.rs:32docs/architecture/crates/tty/tty-wire.md — the crates/tty/ subpath is an alknet-mono-repo path; in alktty the file is docs/architecture/tty-wire.md.
  • adapter.rs:51docs/research/alknet-crate-extraction/findings.md — there is no docs/research/ directory in alktty.

Fix: update both to the correct alktty paths. Trivial effort.


N3. unsafe blocks contradict AGENTS.md §14

Files: src/local/pty.rs:130,135, src/local/pipe.rs:167,176,420

Problem: AGENTS.md §14 states "the crate has zero unsafe blocks." The code has five unsafe { libc::kill(...) } blocks (all in the local feature module, all the safe-libc-call pattern the convention describes). The convention's own wording is violated — either the code or the convention needs reconciling. The libc::kill calls are the documented signal-forwarding path and are not a safety concern, but the convention should be accurate.

Fix: amend AGENTS.md §14 to say "zero unsafe blocks outside the local feature module's libc::kill signal-forwarding calls (safe libc crate APIs, not unsafe blocks in the crate's own logic)." Trivial effort.


N4. Sleep-based timing in tests

Files: src/local/pty.rs (150-300ms sleeps), src/local/pipe.rs, tests/pipe.rs, tests/pty.rs

Problem: the signal and cancel-cleanup tests use fixed tokio::time::sleep delays (150-300ms) to let the child process reach a state before signaling or dropping. This is a flakiness risk on slow CI — a loaded machine can exceed the grace period and the test fails spuriously. The cancel-cleanup tests are better (they poll for a pid file), but the signal tests (signal_int_kills_child, signal_reaches_process_group_child, pipe_signal_sigterm_kills_child) sleep a fixed duration before signaling.

Fix: replace the fixed sleeps with a readiness signal where possible (e.g. have the child write a marker to a temp file before exec, as the cancel-cleanup tests already do), or widen the grace period and add a retry. Low priority — the tests pass reliably on the current machine — but worth hardening before CI.


N5. rand_seed/nanos_seed duplicated four times

Files: src/local/pipe.rs:462, src/local/pty.rs:705, tests/pipe.rs:218, tests/pty.rs:262

Problem: four identical nanos-timestamp helpers (used to uniquify temp-file names in the cancel-cleanup tests) are copy-pasted across the crate and the integration tests. The integration-test copies can't share with the crate (separate compilation units), but the two src/local/ copies could be a single pub(crate) helper, and the two tests/ copies could live in tests/common/mod.rs.

Fix: consolidate. Trivial effort.


N6. rust-version = "1.85" is unverified

Files: Cargo.toml:5

Problem: the crate declares rust-version = "1.85" but there is no MSRV CI job, and the review toolchain is 1.94. The declared MSRV is aspirational until it is actually checked. A dependency bump or a new language feature could silently raise the real MSRV above 1.85.

Fix: add an MSRV check to CI (e.g. cargo +1.85 check), or bump the declared rust-version to a value that is actually verified. Low priority.


What's Good

The crate is in strong shape for a first-pass port + new-code review. Highlights:

  • The ported code is clean. wire, control, negotiation, backend, and adapter are well-tested (92-99% line coverage) and the error paths propagate correctly. The ChunkReader/ChunkWriter and NegotiationReader/NegotiationWriter are defensive (bounds-checked lengths, ConnectionClosed vs Io distinction, no oversized allocations).
  • The exit-chunk-is-last invariant (ADR-055) is genuinely enforced and tested. pump_session joins both stdout/stderr pumps and the exit future before enqueueing the exit chunk, and the integration tests assert no chunk follows the exit chunk.
  • The kill-on-Drop contract (ADR-056) is real and tested. Both LocalExitFuture (PTY) and PipeExitFuture (pipe) implement the disarm-on-resolve / kill-on-cancel pattern correctly, and the cancel-cleanup tests probe the child's pid after drop to confirm no orphan.
  • The BAST drift-detection test is a nice touch. The bast_stream_type_enum_matches_wire_constants test (wire.rs:445) parses the BAST document and asserts the StreamType enum matches the STREAM_* constants — exactly the cheap drift guard the plan's "Risk: BAST schema drift" mitigation called for.
  • The control-channel split (Phase 7) is well-tested. The adapter tests cover Exit-on-STREAM_CTRL_IN and STREAM_CTRL_OUT-from- client as protocol violations, and the exit-chunk-arrives-on-ctrl-out test pins the direction.
  • No unwrap/expect/panic! in production code. All are confined to #[cfg(test)] modules and the test harness. The poisoned-mutex pattern (unwrap_or_else(|e| e.into_inner())) is used where it matters.
  • The wasm-clean invariant holds. cargo check --target wasm32-unknown-unknown and the wasm clippy pass are both clean, and the local feature is properly isolated.

Remediation Plan

ID Finding Fix Effort Risk Status
L5 cargo fmt fails run cargo fmt, commit trivial none resolved
N1 9 rustdoc warnings fix 9 link sites trivial none resolved
N2 stale doc paths fix 2 paths trivial none resolved
N3 unsafe vs AGENTS.md §14 amend convention wording trivial none resolved
L4 mocks leak into public API pub(crate) on MockBackend/MockControl trivial none resolved
N5 rand_seed/nanos_seed ×4 consolidate trivial none resolved
M2 wait() swallows MalformedExitChunk propagate the variant small low resolved
L2 consumer stdout/stderr routing untested add emitting test backend small low resolved
M1 negotiation-rejection frame unhandled implement disambiguation read medium medium (wire-facing) resolved
L1 channels input ignored decide drop-vs-pass-through small low open
L3 open_via_channels 0% covered end-to-end channels consumer test medium low open
L6 pty bridge error paths untested targeted error-path tests medium low open
N4 sleep-based timing readiness signals small low open
N6 MSRV unverified CI MSRV job or bump small none open

Resolution (2026-08-17, commit 9944153)

Nine findings were resolved in a single commit:

  • M1TtySession::from_halves now peeks the first response byte and returns NegotiationRejected on a 0x00-prefixed error frame (ADR-052 §5 disambiguation). ChunkReader gained peek_stream_type/read_chunk_after_peek to support the peek. Test: connect_direct_returns_negotiation_rejected.
  • M2wait() now surfaces MalformedExitChunk instead of collapsing it to NoExitChunk. The exit watch channel carries a cloneable ExitOutcome enum (the TtySessionError payloads are not Clone); MalformedExitChunk now carries a String. Test: wait_returns_malformed_exit_chunk.
  • L2 — added EmittingBackend and recv_stdout_and_stderr_route_backend_data, covering the consumer read-pump stdout/stderr routing with real data.
  • L4MockBackend/MockControl/MockStdinSink are now #[cfg(test)] pub(crate), removed from the public API.
  • L5cargo fmt (the BAST drift test was unformatted).
  • N1 — all 9 rustdoc intra-doc links fixed; cargo doc is clean.
  • N2 — stale doc paths fixed (crates/tty/ and docs/research/).
  • N3 — AGENTS.md §14 amended to accurately describe the local module's libc::kill unsafe blocks.
  • N5nanos_seed consolidated into tests/common/mod.rs.

Post-fix coverage: session.rs 79.71% → 87.43% lines, total 90.74% → 91.47% lines. All verification gates pass (cargo test, cargo test --all-features, clippy native + wasm, fmt, doc, wasm check).

Remaining (open)

  • L1 — channels input ignored; needs a publisher decision (drop the parameter vs pass-through to drive_session).
  • L3open_via_channels still 0% covered; needs the channels harness shared across modules.
  • L6 — pty bridge error paths untested.
  • N4 — sleep-based timing in signal/cancel tests.
  • N6 — MSRV unverified.
  1. L1 + L3 — the channels consumer path; do together since L3's test will exercise L1's code.
  2. L6 — pty bridge error paths; medium effort, lower priority than the consumer-half work.
  3. N4 + N6 — test hardening and MSRV; defer until CI exists.

Notes

  • All line numbers refer to the tree at commit 18c4924 (the last commit on main at review time). The resolution section above reflects the tree at commit 9944153.
  • The coverage numbers are from cargo llvm-cov --all-features on the same tree. The --show-missing-lines output was used to attribute gaps; the full report is at target/llvm-cov/html.
  • This review does not cover documentation quality (README, inline docs, docs.rs rendering) beyond the stale-path nit (N2) and the rustdoc-link nit (N1). Per the publisher's workflow, that is a separate sweep.
  • Findings M1 and M2 are in the consumer half (TtySession), which is the newest code in the crate. The ported producer half (adapter, wire, negotiation, backend, local) is in good shape; the findings there are coverage gaps (L6) and hygiene (N2, N3), not correctness bugs.