- signal_after_child_exit_takes_both_kill_fallbacks: a late signal (after the child exited) takes kill(-pgid) fail -> kill(pid) fail -> warn + return, with no panic — the reachable part of the REQ-TTY-02 fallback chain. - the remaining bridge error arms are documented-unreachable through the public path (per the review's disposition), with per-arm reasoning in the module doc: try_clone_reader (dup failure), reader read error (EIO -> EOF mapped), take_writer (second-take only), writer write/flush (externally-closed fd), waiter wait() (already-reaped child). The ADR-055 §4 -1 sentinel is covered at the adapter level (exit_error_sends_minus_one) — it also arises when the oneshot drops on kill-on-cancel. - local/pty.rs line coverage 80.85% -> 86.01%; total 94.48%.
684 lines
30 KiB
Markdown
684 lines
30 KiB
Markdown
---
|
||
status: partially-resolved (L6, N4, N6)
|
||
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
|
||
tool: manual source read + cargo test/clippy/fmt/doc + cargo-llvm-cov
|
||
reviewer: 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`:
|
||
|
||
```rust
|
||
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:
|
||
|
||
```rust
|
||
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.
|
||
|
||
---
|
||
|
||
### N1. Nine rustdoc warnings (unresolved intra-doc links)
|
||
|
||
**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:32` → `docs/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:51` → `docs/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 | ✅ resolved (2026-09-05) |
|
||
| L3 | `open_via_channels` 0% covered | end-to-end channels consumer test | medium | low | ✅ resolved (2026-09-05) |
|
||
| L6 | pty bridge error paths untested | targeted error-path tests | medium | low | ✅ resolved (2026-09-05) |
|
||
| 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:
|
||
|
||
- **M1** — `TtySession::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`.
|
||
- **M2** — `wait()` 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.
|
||
- **L4** — `MockBackend`/`MockControl`/`MockStdinSink` are now
|
||
`#[cfg(test)] pub(crate)`, removed from the public API.
|
||
- **L5** — `cargo 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.
|
||
- **N5** — `nanos_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).
|
||
|
||
### Resolution (2026-09-05, L1 + L3 — the channels consumer path)
|
||
|
||
**L1 — resolved via the publisher decision: the channels path carries no
|
||
second negotiation frame (ADR-009 in `docs/architecture/decisions/`).**
|
||
|
||
Two upstream alkcall changes were prerequisites (the review's premise
|
||
that the registry "validates `input`" was wrong — alkcall never
|
||
enforced `input_schema` on any dispatch path):
|
||
|
||
- alkcall 0.4.0 — `OperationSpec.input_schema` is now enforced at call
|
||
time by all three registry dispatch entry points (`invoke`,
|
||
`invoke_streaming`, `invoke_sink`), compiled once at registration
|
||
(fail-closed, same rule as `publish_schema`/CF-003). Violations
|
||
return `INVALID_INPUT`. The `channels/tty/sub` spec's input schema
|
||
now declares the shared `NegotiateRequest` fields (`carriage`,
|
||
`backend`, `cmd` required); `tty`/`cwd`/`env`/backend-params stay
|
||
free-form (raw JSON Schema is permissive on unknown keys, so the
|
||
opaque ADR-053 params pass through).
|
||
- alkcall 0.4.1 — early-arrival chunks for a not-yet-adopted channel
|
||
are parked (bounded per-channel buffer) and drained on
|
||
`adopt_channel`, instead of dropped. The open-op-response /
|
||
producer's-first-write race silently lost the first chunks of any
|
||
push-first producer — found by L3's test (the session never resolved
|
||
because the stdout sentinel + exit chunk of an
|
||
immediately-resolving backend arrived before the adopt).
|
||
|
||
Design: the open op's `input` IS the negotiation (design 2 of the
|
||
publisher decision). `make_tty_open_handler` parses the
|
||
registry-validated `input` into a `NegotiateRequest` and drives
|
||
`drive_session_pre_negotiated` (new public API in `adapter.rs`) — the
|
||
same three-pump session driver as the direct path, minus the
|
||
wire-frame negotiation phase. Validation still runs
|
||
(`carriage`/`cmd`/backend lookup + ADR-050 ownership); failures go to
|
||
the client as a `0x00`-prefixed negotiation error frame on the channel
|
||
stream, so the M1 disambiguation read applies unchanged. The
|
||
`tty:open` scope gate is enforced by the registry's `AccessControl`
|
||
(not re-checked in the handler). `tty_open_spec()`'s schema is now the
|
||
partial `NegotiateRequest` shape. `TtySession::open_via_channels`
|
||
parses `params` locally (fail-fast before a channel is allocated),
|
||
opens the channel, and starts raw-chunk mode directly
|
||
(`from_halves_raw` — no negotiation write, peek retained for the
|
||
error-frame path).
|
||
|
||
Tests: `open_via_channels_end_to_end_negotiates_and_waits`,
|
||
`open_via_channels_surfaces_negotiation_rejected`,
|
||
`open_via_channels_fails_fast_on_schema_invalid_params`,
|
||
`open_via_channels_fails_fast_on_unparseable_params`,
|
||
`open_via_channels_routes_backend_stdout_and_stderr`,
|
||
`pre_negotiated_happy_path_over_plain_duplex` (adapter), plus the
|
||
shared harness tests in `src/testing.rs` (harness-level handler→client
|
||
data flow). The channels harness (`wire_client_and_server`) moved to
|
||
`crate::testing` so session tests share it with channels tests.
|
||
|
||
**L3 — resolved** by the same work: `open_via_channels` (and
|
||
`from_bidi_stream_via`) are covered end-to-end against the real
|
||
producer path (`register_openable` + `drive_session_pre_negotiated`
|
||
through alkcall's channels stack).
|
||
|
||
### Resolution (2026-09-05, L6 — pty bridge error paths)
|
||
|
||
Per the review's own disposition ("the `try_clone_reader`/`take_writer`
|
||
failure paths can be left as documented-unreachable if the publisher
|
||
agrees"), the bridge's error arms are now **documented-unreachable**
|
||
(module doc in `local/pty.rs` §"Bridge error paths"), with the
|
||
reasoning per arm: `try_clone_reader` needs a dup failure (exhausted
|
||
fd table — not deterministically forceable); a reader read error has
|
||
no trigger (EIO → EOF is mapped); `take_writer` only fails on a second
|
||
take (the bridge takes it once); a writer write/flush error requires
|
||
an externally-closed fd; the waiter `wait()` failure needs an
|
||
already-reaped child (the bridge never calls `try_wait`). The
|
||
ADR-055 §4 `-1` sentinel the review wanted tested is covered at the
|
||
adapter level (`exit_error_sends_minus_one` — the sentinel also arises
|
||
when the oneshot drops on kill-on-cancel, exercised by the
|
||
cancel-cleanup tests).
|
||
|
||
The reachable fallback chain *is* now tested:
|
||
`signal_after_child_exit_takes_both_kill_fallbacks` — a late signal
|
||
(after the child exited) takes `kill(-pgid)` fail → `kill(pid)` fail →
|
||
warn + return, with no panic. `local/pty.rs` line coverage
|
||
80.85% → 86.01%; the remaining uncovered lines are exactly the
|
||
documented-unreachable arms plus `StdinSink`'s in-flight-parking path
|
||
(a single write cannot fill the 64-slot channel).
|
||
|
||
### Remaining (open)
|
||
|
||
- **N4** — sleep-based timing in signal/cancel tests.
|
||
- **N6** — MSRV unverified.
|
||
|
||
### Recommended Order (remaining)
|
||
|
||
1. **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` and the 2026-09-05 L1+L3
|
||
resolution.
|
||
- 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.
|