Files
alkssh/tasks/implementation/w4-git-exit-close.md
T
glm-5.3-flash 9cf5bfbaf3 fix(git): status-before-close by construction — the split-halves seam duplex (w4-git-exit-close)
The wave-3 gate's booked finding 1: the git row's exit-report order
was honored by scheduling, not by construction — into_stream()'s
ChannelCloseOnDrop spawned a CHANNEL_CLOSE send racing the
exit_status_request send for a queue position on russh's bounded
message queue (event_buffer_size, default 10); a full buffer at the
serve tail could silently no-op the report.

- the seam now rides a split-halves duplex built from russh 0.65's
  public API (its ChannelRx/ChannelTx are private): the reader is
  git_map's ChannelHalveReader (owned read half, the private rx's
  observation semantics repinned, no Drop path ever messages the
  wire) and the writer the write half's ChannelTx via
  make_writer_ext (poll_shutdown still sends EOF; the Rust 2024
  capture phantom erased through a trait object)
- the report's send is now the sole close-contracting sender,
  ordered ahead of the explicit close by sequential awaited sends on
  the one handle — saturated buffer or not
- new wire row backlogged_seam_tail_still_reports_exit_status_before_close:
  a volume flood freezes the armed client loop (the new harness
  DataGate + the client data hook russh awaits inline) so the server's
  flush pends and the bounded message queue saturates at the serve
  tail; the gate's release drives the tail through the unwinding
  contention — the row pins exit-status(0) before close under the
  saturation and falsifies the old shape deterministically (15/15 red
  under a temporary into_stream() revert, 10/10 green with the fix)
- doubles gain a race-safe per-repo serve gate (install_gate, the
  double-check pattern over list_refs); harness gains
  drive_with_data_gate (the drive* bodies factored into
  mount_and_connect, existing signatures unchanged)
- all existing git wire rows unchanged and green (wire order
  identical when the race does not fire); spawn-and-return intact;
  tty rows untouched; event_buffer_size stays russh's default
  (pinned in the row)

Verification: cargo test (default), --all-features, and six mixed
per-feature builds green; clippy x2 -D warnings; fmt -check;
cargo doc --no-deps 0 warnings; taskgraph validate (35 tasks).
2026-10-29 07:01:33 +00:00

9.3 KiB
Raw Blame History

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
w4-git-exit-close Wave 4 — git row hardening (status-before-close by construction — wave-3 gate booked item 1) completed
w3-git-mapping
narrow medium component implementation

Description

Close the wave-3 gate's only structural booked finding (review-wave-3 §Booked findings item 1): the git row's status-before-close is honored by scheduling, not by construction. Today serve_duplex_resolved splits the channel stream internally and its session halves drop when the serve returns — russh's ChannelCloseOnDrop rx-half emits a server→client CHANNEL_CLOSE inside the seam call, before serve_git_row's explicit exit_status_request. The ordering currently holds (the task's continuation queues the report in the same poll, ahead of the drop's send) and every wire row passes, but a full Sender<Msg> buffer at the serve tail could order the implicit close first and silently drop the report (russh no-ops exit_status_request for an already-closed channel) — a silent exit-report loss on a published row.

The fix wants a seam/shape change — decide + record which:

  • split the channel halves in serve_git_row and pass them into the seam (the report then rides a half the seam never closes), or
  • queue the exit report while the stream is alive (the report's send ordered before any close-contracting drop by construction), or
  • another shape that makes the ordering structural rather than scheduler-luck — the gate's reasoning and the two named options are the starting points, not the bounds.

Constraints: every existing git wire row stays green (no behavior change on the happy path — this is ordering hardening, not semantics change); the spawn-and-return constraint (ADR-006 decision 4) holds — the reporting still rides the spawned task; the tty rows are NOT in this class (their report precedes any close-contracting drop in one task — the gate verified; don't touch them). New wire row(s) pinning the construction: a saturated Sender<Msg> buffer at the serve tail (the failure scenario made real) still delivers the exit report — the by-construction property proven on the wire, not by reasoning.

Acceptance Criteria

  • The shape decided + recorded (module docs carry the reasoning); the git row's status-before-close holds by construction, not scheduling
  • Every existing git wire row green (no happy-path behavior change); the spawn-and-return constraint intact
  • New wire row(s): the saturated-buffer scenario delivers the exit report (the by-construction property pinned)
  • cargo test (+ per-feature + --all-features), clippy ×2 (-D warnings), fmt, cargo doc --no-deps pass

References

  • tasks/implementation/review-wave-3.md §Booked findings item 1 (the full finding + the two named fix shapes)
  • docs/architecture/mapping.md (the git exec row — exit-status both directions), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (decision 4 — spawn-and-return)
  • src/git_map.rs, src/serve.rs (report_exit_status_then_close — the wave-2 reporting mechanics this hardening composes with), alkgit 0.1.0 serve_duplex_resolved (the seam whose half-ownership is the question)

Notes

The shape decided (2026-10-29)

Went with the first named option — split the channel halves in serve_git_row and pass them into the seam — refined by one russh 0.65 reality: russh::channels::io::{ChannelRx, ChannelTx} are private at this pin, so the split-halves duplex is rebuilt from the public API in src/git_map.rs:

  • The reader is git_map's own [ChannelHalveReader]: the owned ChannelReadHalf (channel.split()), a poll_read state machine whose observation semantics repin russh's private ChannelRx without the ChannelCloseOnDrop wrapper — Data fills (partial reads buffered), ExtendedData/other non-data messages swallowed and re-read (the _-arm parity), Eof ends the stream, the peer's Close leaves the reader to the channel's sender-death EOF (wait() → None). The wait() access is future-owned: each read round-trips the half through a boxed future that parks it inside ChannelReadHalf::wait and hands it back with the observed message — fully owned ('static-usable, the seam's T bound) across every park/buffer/drop state, no extra task, and no Drop path ever messages the wire.
  • The writer is the write half's ChannelTx via make_writer_ext(None). Its value is 'static-free of the half (clones only) but the Rust 2024 capture rules give the opaque a phantom of the half's borrow; a trait-object erasure (Box<dyn AsyncWrite + Unpin + Send>) drops the phantom, leaving the seam's 'static bound satisfiable without parking the half. poll_shutdown still sends EOF (exactly into_stream's).

The result: nothing the seam drops ever messages the wire — the report's exit_status_request send is the sole close-contracting sender, and its queue position ahead of the explicit close holds by construction (sequential awaited sends on the one Handle), saturated buffer or not. The hazard's premise (russh's ChannelCloseOnDrop Drop spawning a CHANNEL_CLOSE send that can win a queue position on the bounded Sender<Msg> — event_buffer_size, default 10 — before the report's) is removed, not raced.

The wire row (the by-construction property pinned)

tests/git_mapping.rs::backlogged_seam_tail_still_reports_exit_status_before_close — the finding's failure scenario made real on the wire: the serve's answer volume (300 one-pkt-line ref answers) exceeds the entire outbound pipeline via the new client-direction data gate (tests/common/harness.rs::DataGate + the harness handler's data hook, which russh's client loop awaits inline): the armed gate freezes the client's loop → the transport duplex fills (DUPLEX_CAPACITY) → the server's flush pends → russh's bounded message queue fills → the serve's remaining answer writes pend (the serve tail blocked). The gate's release then drives the serve tail through the just-unwinding contention, and the row pins: the whole backlogged answer drains through the same queue the report rides, then exit-status(0), then close — order asserted, exit code asserted.

The row falsifies the old shape. With a temporary one-line revert of serve_git_row to channel.into_stream() the row FAILED 15/15 runs — the implicit close won the frozen-drain queue race ("expected exit-status(0), got Some(Close)"), the silent report loss the gate booked. With the split-halves shape: 10/10 green. So the row is a two-sided pin: deterministic red under the hazard, green by construction without it.

Test-vehicle additions (test surface, freely versioned): the doubles' per-repo serve gate (tests/common/git_doubles.rs: install_gate — race-safe double-check MemGate, gating GitRefs::list_refs, so the row schedules the serve's stages against prepared wire state) and the harness's DataGate + drive_with_data_gate (the drive* bodies factored into the shared mount_and_connect core; existing wrappers' signatures unchanged).

Constraints check

  • Every existing git wire row green — the full git suite (tests/git_mapping.rs, 12 rows) and every other loopback suite: no behavior change on the happy path (the wire order the rows pin — data, exit-status, close — is identical under both shapes when the race doesn't fire).
  • Spawn-and-return intact (ADR-006 decision 4): the reporting still rides the spawned serving task through the cloned Session::handle(); serve.rs's mechanics untouched.
  • The tty rows untouched (not in this class — the gate verified).
  • No API/limits change: event_buffer_size stays russh's default posture (the adapter docs' recorded default posture preserved); the row pins the default's value with a note that a russh default change re-reads the row's freeze-settle yields, not its assertions.

Summary

Completed. The git row's status-before-close now holds by construction: serve_git_row no longer hands russh's close-on-drop ChannelStream (into_stream()) to the seam — the seam rides a split-halves duplex (git_channel_duplex → ChannelHalveReader over the owned read half + the write half's ChannelTx via the capture-erased trait object) where no drop path ever messages the wire, making the report's send the sole close-contracting sender and the report-before-close order unconditional (russh 0.65's private ChannelRx/ChannelTx noted; the semantics repinned from the public API). New wire row pins the by-construction property on a genuinely saturated tail (volume flood → frozen client loop → duplex → bounded message queue; gate release → the tail through the unwinding contention) — and falsifies the old shape deterministically (15/15 red under a temporary into_stream() revert, 10/10 green with the fix). Doubles gained a race-safe per-repo serve gate; the harness gained the client-side data gate + drive_with_data_gate (the drive* cores factored, signatures unchanged). Verification: cargo test (default), --all-features, and 6 mixed per-feature builds all green; clippy ×2 -D warnings clean; fmt clean; cargo doc --no-deps warning-free; taskgraph validate clean (35 tasks).