- Rewrote em dash prose across the whole tree outside docs/reviews/ (the historical reviews stay verbatim, matching the alktunnels and alktty precedent): src doc comments and inline comments (587), docs/architecture (319), docs/research + sdd_process (484), tests (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77), AGENTS.md (85), and the .opencode/agents prompts (109) - Fenced content follows the precedent: comments, ASCII-diagram label separators, and template placeholders rewritten; wire data, JSON/BAST documents, byte diagrams, and transcripts survive verbatim; assert, expect, panic, error-display, and format message literals rewritten as prose; no code, fixture, or test data change - src/rfc1928/LICENSE: the two dashes in alksocks' own provenance header rewritten; the MIT legal text untouched - AGENTS.md adds Project Convention 18: no em dashes in prose; varied restructure, no spaced-hyphen substitutes, fenced prose rule, en dash numeric ranges exempt, docs/reviews/ exempt from the greps - tasks/*.md frontmatter: the name values rewritten by the sweep are now quoted (a raw colon inside an unquoted YAML scalar parsed as null); taskgraph validate reports 37 tasks, no issues Verification: cargo test; cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; fuzz corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/ and the bast.md BAST JSON data fence
11 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level |
|---|---|---|---|---|---|---|---|
| implementation/fix-bind-reply2-reentry | Fix: BIND reply#2 re-entry guard + consumer session hygiene (review-002 C-1, D-1, N-6, S-2) | completed | narrow | medium | component | implementation |
Description
Close review 002's one reachable silent-corruption path plus the
consumer-side residues that ride the same file
(docs/reviews/002-protocol-core-implementation-review.md):
-
C-1 (major): the reply#2 phase is re-entrant with no spent-marker.
BindSession::await_inbound(src/consumer.rs:757-763) takes&mut self, leavesinnerpopulated, and returns aBindDataPlane<'_>borrowing the same stream, and nothing marks the phase consumed. A second call runsread_reply2against the live data plane (post-reply#2 bytes are the accepted peer's raw application data): a bafflingUnsupportedSocksVersionat best, silent 10+-byte data corruption at worst (any peer protocol whose first byte is 0x05). The same shape replays throughreply1()twice →BindReply1::await_inbound. The sibling sessions are all spent-state-guarded; this is the gap.Fix per the review's sketch: make the reply#2 phase consumed in the session's established posture:
inner.take()(a second call hits theNonearm and returns the existingOk(None)spent shape, which the method's doc already claims: "Noneafter a taken plane") or an equivalent spent-marker riding the session. Note the take-shape's ownership consequence:BindDataPlanecomes to own the stream (theTakenInnerpairing postureSocks5ChannelSession::into_inneralready pins) while the session stays the teardown owner for the channel entry. Either mechanism is acceptable; the spent shape must match the session family's posture (Ok(None)or the typedSpentSessionError; pick one and document it). -
D-1 (minor):
BindReply1::await_inboundis dead surface (0% covered; every test drives the session's method). The choice interacts with C-1, so pick one normative reply#2 reader:- (a) the session's
await_inbound(the review's lean: documentreply1()as a view, demoteBindReply1::await_inboundto a private helper or cut it), or - (b)
BindReply1::await_inbound(the task spec's intent,tasks/implementation/core-consumer-bind.md:74-75; the session's method delegates to it and the handle is tested through). The alternate path must be impossible to double-read (private, cut, or spent-guarded).
- (a) the session's
-
N-6 (observation): the three session constructors share a near-identical prologue (open →
BiStream::from_joined→ negotiated → teardown-on-err;src/consumer.rs:121-149,290-324,702-733). Collapse into one privateopen_and_negotiatehelper, holding the teardown-at-birth invariant in one place instead of three (a batch-2 constructor then cannot hand-copy the reap path). Zero behavior change. -
S-2 (observation): one doc line on the three sessions'
reap(src/consumer.rs:197-202,490-495,787-792): the droppedteardown_channelJoinHandleis deliberate: the producer-side pump task finishes via EOF propagation (the graceful shape);clear_allaborts instead (the substrate's abrupt path). No code change; prevents a future reader "fixing" the handle into an abort.
Tests: double await_inbound returns the spent shape AND the
data-plane bytes survive untouched (the pinning test); the
reply1()-twice path closed the same way; the existing BIND suite
stays green unchanged (the round-trip, gate-refusal, factory/
accept-failure mappings unaffected).
Acceptance Criteria
- A second
BindSession::await_inboundcannot re-read the data plane: it returns the spent shape, pinned by test, with the data-plane bytes asserted untouched - The
reply1()-twice re-entry path is closed (the D-1 decision: one normative reader; the alternate private/cut/ spent-guarded), with the choice recorded in the task Notes - The session types' docs match the landed spent posture (the
"
Noneafter a taken plane" claim is true of the code) - The three constructors share one
open_and_negotiatehelper; zero behavior change (the full consumer suite green unchanged) - The three
reapsites carry the EOF-propagation doc line cargo testgreen; clippy/fmt clean; both wasm checks clean
References
- docs/reviews/002-protocol-core-implementation-review.md §C-1, §D-1, §N-6, §S-2
- docs/architecture/consumer.md (the session postures; the spent-session shape)
- tasks/implementation/core-consumer-bind.md (the landed BIND
session; the
BindReply1handle's spec'd intent) - tasks/implementation/review-protocol-core.md (R-2's
SpentSessionErrorshape, the consistency target)
Notes
Filled by the implementation agent (2026-09-24):
- C-1 landed as the take-shape (the review's first sketch):
BindSession ::await_inboundtakes the session's inner stream before reading reply#2 (inner.take()); the returnedBindDataPlaneowns theSocks5Stream<BiStream>while the session stays the teardown owner; that is theTakenInnerpairing postureSocks5ChannelSession::into_innerpinned, now carried byBindDataPlaneitself (no new pairing type: the plane is the pair's inner half; the session is the pair's teardown owner). A second call hits theNonearm → the family's spent shapeOk(None)(the method's doc claim "Noneafter a taken plane" is now true). The spent shape chosen isOk(None)(not a typedSpentSessionError): the method's existing return typeResult<Option<...>>already carried the documented spent arm, and the sibling read-shaped surfaces (recv_datagram's spent stream) return the EOF-shapeOk(None), the session family's read-side posture;SpentSessionErrorstays the taking-methods' (into_inner) shape. - A failed reply#2 read restores the inner (the phase is not
consumed on failure): the taken inner is returned to the session on
error, so the channel stays owned (reply#1 succeeded, the listener
existed, reap via
close/Drop, the error-surface contract the existing tests pin), and the session'ssplit()/close()keep the pipe. The corruption path is the post-success re-entry, and that is exactly what the take-guard closes: after success the plane is taken, the spent shape holds. A post-failure re-call re-reads reply#2 against the (already-ended) session: the producer ends the session on every post-reply#1 failure exit, so the read EOFs the same way (the quiet-wait garbage test pins this shape, green unchanged). - D-1 decision: option (a): the session's
await_inboundis the one normative reply#2 reader.BindReply1::await_inboundis cut (not private: the take-shape makes the handle's borrowed-plane reader impossible, since the plane now owns the stream);reply1()is documented as a view (bind_addr+raw_mut, the front-door bridge seam), which is exactly how the four existing call sites use it.BindReply1stays as the typed view (itsSendprobe pinned). BindDataPlaneis un-lifetimed (BindDataPlane, notBindDataPlane<'s>): it owns the stream, so the'staticlifetime probe became a plainassert_send::<BindDataPlane>();into_innerreturns the ownedSocks5Stream<BiStream>(the test call site gained amut+ drop-before-reap, the CONNECTTakenInnertests' shape); theget_socket_mutaccessor is unchanged (borrowed socket access through the owned inner). Thesplit()doc notes the take-guard's consequence (post-reply#2 halves ride the plane, not the session).- N-6 landed as the private
open_and_negotiate(client, negotiate)helper: open →BiStream::from_joined→ the forked typestate'suse_stream→ the negotiation closure → teardown-on-err, one place for the teardown-at-birth invariant. The three constructors are one-liners over it (CONNECT returns the inner; ASSOCIATE/BIND return(addr, inner)tuples). Zero behavior change: the full consumer + integration suites stayed green unchanged (the only touched call sites were the two test-side ownership consequences above). - S-2 landed: the same doc line on all three
reapsites (CONNECT, ASSOCIATE, BIND), saying the droppedteardown_channelJoinHandleis deliberate (the producer-side pump finishes via EOF propagation, the graceful shape;clear_allaborts instead, the substrate's abrupt path). - Test round: the pinning integration test
(
double_await_inbound_returns_the_spent_shape_and_bytes_survive, where the second call returnsOk(None)and the first plane's exchange completed uncorrupted, a fresh bind round-trips), thereply1()-twice view test (reply1_twice_is_a_view_and_the_reader_yields_one_plane; repeated views carry the same address, the reader yields exactly one plane, the second reader call is the spent shape), the spent-BIND unit test (spent_bind_await_inbound_returns_the_spent_shape, theNone-inner arm +reply1/splitspent probes), and theSendprobe updated (BindSession+ ownedBindDataPlane). The existing BIND suite stayed green unchanged apart from the oneinto_innerownership call site (the round-trip, gate-refusal, factory/accept-failure mappings all untouched).
Summary
Closed review 002's C-1 (the one reachable silent-corruption path): the
BIND reply#2 phase is take-guarded: BindSession::await_inbound takes
the session's stream, the returned BindDataPlane owns it (the
TakenInner pairing posture; the session stays the teardown owner), and
a second call returns the family's spent shape Ok(None) with no re-read
of the live data plane possible. D-1 resolved as option (a): the
session's await_inbound is the one normative reply#2 reader;
BindReply1::await_inbound is cut and reply1() is documented as a view
(bind_addr + raw_mut), matching its actual usage. N-6 collapsed the
three session constructors' prologues into one private
open_and_negotiate helper (teardown-at-birth in one place). S-2 added
the EOF-propagation doc line to all three reap sites. Docs synced
(module docs + the session docs + consumer.md §The BIND client; the
frontmatter last_updated records the reconciliation). Tests: the
double-read pinning test (spent shape + bytes untouched), the
reply1()-twice view test, the spent-BIND unit test, and the existing
suite green (352 total). Verification: cargo test + --all-features
green, clippy -D warnings clean, fmt clean, cargo doc clean, both
wasm checks clean.