diff --git a/docs/active-work.md b/docs/active-work.md index 498820f..ea03521 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -270,35 +270,103 @@ hazard in a shape that looks committed. **A documented error message that never appears is worse than no documentation**, because the reader waits for a signal that is not coming. -## Panel cell-mapping generation (v25) — REBASED, GATE GREEN except two pre-existing rows +## Panel cell-mapping generation (v25) — REBASED, FULL GATE GREEN, review round 4 fixed -- **REBASED onto `f13506c`** (post-#241 main) at `3e68b76`, 31 ahead, 0 - behind, signed, clean. One conflict, in this file: main's merged-#241 - block and this lane's own first-commit block landed at the same - position; both kept, active lane above merged. The other 30 commits - applied cleanly, and no code file overlapped — #241 touched - `scripts/`, `tests/common/` and two test suites; this lane touches - the protocol, daemon and GPU sources. +- **REBASED onto `f13506c`** (post-#241 main); the rebase itself landed + at `3e68b76`, and everything after it is review-round work. One + conflict, in this file: main's merged-#241 block and this lane's own + first-commit block landed at the same position; both kept, active + lane above merged. No code file overlapped — #241 touched `scripts/`, + `tests/common/` and two test suites; this lane touches the protocol, + daemon and GPU sources. +- **NO AHEAD-COUNT OR TIP SHA IS RECORDED HERE, deliberately.** Both + were wrong within one commit the last two times they were written, + because the commit that updates this file is itself the commit that + invalidates them. Read them instead: + `git log --oneline githubsucks/main..HEAD | wc -l` and + `git rev-parse HEAD`. Immutable EVENT shas — the rebase, a fix, a + merge — are recorded; the moving tip is not. +- **REVIEW ROUND 4 (2026-08-20) — three blockers, all fixed.** + - **FUNCTIONAL: rejected gestures mutated the accepted-gesture + latch.** Both inbound arms discarded the bool from + `dispatch_semantic_panel_pointer` and called + `update_accepted_gesture` unconditionally. The ladder authenticates + the SENDER; only the dispatcher re-derives the TARGET, so an event + can clear every rung and still be refused for an out-of-grid + coordinate, an absent side window, or a buffer that is no longer + the one in the side window. **A rejected `Down` therefore ARMED** — + so a later authority loss counted a cancellation for a gesture that + never began, and once replay attaches effects it would release a + child that was never pressed. **A rejected `Up` CONSUMED a real + armed gesture** — so the authority loss that should have ended it + found nothing, and that child holds the button down for good. A + rejected `Down` on top of a live gesture was worse still: + `arm_accepted_gesture` ends what it overwrites, so it also counted + a spurious cancellation. + - **Fixed** by gating both arms on the return, and by marking the + dispatcher `#[must_use]` so the class cannot recur silently — + clippy runs with `-D warnings`, so a future discarded answer is a + build failure rather than a review finding. + - **Four new rows**, `g5_substrate_a_refused_{press_never_arms, + release_never_consumes}_on_the_{legacy,mapped}_arm`. Each drives + the refusal from a coordinate ONE PAST the last grid row and **ends + in a positive control differing only in that coordinate** — + without it a row would pass just as well if an unrelated rung had + dropped the event, which is how a negative test of this shape rots. + Mapped rows read the generation through the validator's own + accessor, so a mapping-rung refusal cannot masquerade as a + dispatcher refusal. **Three mutations, each biting its named rows:** + ungating the legacy arm fails exactly the two legacy rows, ungating + the mapped arm exactly the two mapped rows, and relaxing the + dispatcher's `>=` to `>` fails all four. + - **DOCS: committed conflict residue** — an orphaned diff3 + `|||||||` ancestor line survived an earlier resolution in this + file, with the other three markers removed and both sides' content + kept. Deleted; the section below it was the only copy. + - **THE GATE CANNOT SEE THAT CLASS, and this is not fixed here.** + `git diff --check` compares the WORKTREE against the index, so a + marker that is already committed passes a clean-worktree gate + forever. `git diff --check githubsucks/main...HEAD` exits 2 on it. + Widening the gate's check to the branch range is a **candidate + lane** — a gate change owes a framing doc, and it should not ride + in on a §5b review round. - **The blocker is gone.** #241 fixed the `sweep-crdt` defect this lane was held behind, and the SIGINT guard now refuses a gate run started with `SIGINT` ignored — which is what made stage 15 unreachable. -- **GATE, run in two labelled pieces** because 16 stages exceed this - session's 10-minute command cap and the guard rightly forbids the - backgrounding that would evade it: - - **14 stages green** — plain gate plus all six `--acceptance` - suites, log `20260820T143050Z-3909596`, started at load 5.28. - - **`build-crdt` green**; **`sweep-crdt` red on two rows only**: - `m4_24_bare_string_glob_stays_relative` and - `m4_24_d3_fallback_base_is_the_smallest_attachment_dir`. -- **Those two rows are PRE-EXISTING and local-only.** They fail - identically at **current main `f13506c`**, and they **passed in CI** - on #241's `Test (crdt)` job. Not this lane's, and not a code defect — - local-environment-specific. +- **FULL 16-STAGE GATE GREEN**, log `20260820T153017Z-147411`, one + unsplit `./scripts/gate --protocol` run with all six touched + acceptance suites. `sweep-crdt` reports **zero** failures. +- **THE TWO `m4_24_*` FAILURES WERE MY OWN MEASUREMENT ERROR, and the + "CI is the arbiter" framing built on them is WITHDRAWN.** I had run + the CRDT sweep OUTSIDE `scripts/gate`, so it inherited + `TMPDIR=/tmp` instead of the gate's isolated one, and I then reported + the local sweep as unable to go green for pre-existing reasons. It + goes green. CI never needed to substitute for it. + - **MECHANISM, and it is the exact hazard #240 fixed:** `/tmp/.git` + exists on this machine (an empty directory, 2026-08-15). Project + detection **walks upward**, so every `tempfile::tempdir()` under + `/tmp` inherits `/tmp` as a project root. Both failing rows are + base-resolution rows — `m4_24_bare_string_glob_stays_relative` and + `m4_24_d3_fallback_base_is_the_smallest_attachment_dir` — so both + resolve against the wrong base and fail. CI's `/tmp` carries no + such marker, which is why they passed there. + - **The gate's `TMPDIR` is disk-backed under `/tmp/` on + btrfs, in a directory the gate owns**, with no stray ancestor. + Running the sweep by hand defeats exactly the isolation #240 built + for this signature. + - **`/tmp/.git` IS STILL THERE and is not mine to delete** — it is + outside the repo and I did not create it. Any hand-run test that + resolves a project root from a temp dir will keep failing on this + machine until it is removed. - **A prior gate attempt is NOT evidence**: I wrapped it in - `timeout 580` to fit the command cap, which killed the sweep mid-run - (`Terminated` in its log) and reported it as a stage failure. Its - two perf-budget failures also ran at load 36.9 with a foreign - workload present. Recorded so the log is not mistaken for a result. + `timeout 580` to fit the session's command cap, which killed the + sweep mid-run (`Terminated` in its log) and reported it as a stage + failure. Its two perf-budget failures also ran at load 36.9 with a + foreign workload present. Recorded so the log is not mistaken for a + result. The supported way past the cap is the tool-level background + launch, which measures `safe` under + `scripts/check-sigint-deliverable`; `docs/agent-handoff.md` §3 now + says so. **Written with the branch's FIRST commit**, per the standing correction from #171 and #215. @@ -1189,7 +1257,6 @@ from #171 and #215. bite, direct-test diagnosis, unaffected foreground success, mutation, an otherwise unchanged gate, a distinct `error` outcome, and a non-Linux-unix statement. -||||||| parent of 8a4711e (docs(framing): the panel cell-mapping generation (v25) --- SS5b revision 14) ## `scripts/gate` TMPDIR isolation — PR #240 OPEN diff --git a/src/daemon.rs b/src/daemon.rs index d338222..2345821 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2581,18 +2581,36 @@ fn handle_dispatcher_event( panel_epoch, ) { - editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); - update_accepted_gesture( - semantic_states, - source, - kind, - coord, - buffer_id, - // Whether the press reached a child is - // replay's to know; until replay exists no - // press does. - false, - ); + // THE LATCH FOLLOWS THE DISPATCH. The ladder + // above authenticates the SENDER; the dispatcher + // re-derives the TARGET and refuses an + // out-of-grid coordinate, an absent side window, + // or a buffer that is no longer the one shown + // there. Those refusals are not hypothetical -- + // a stale coordinate outlives the frame it was + // hit-tested against. + // + // Arming on a refusal lets a rejected press + // manufacture a cancellation, and -- once replay + // attaches effects -- a release for a child that + // was never pressed. Consuming on a refusal is + // worse: a rejected release swallows a REAL + // armed gesture, so the authority loss that + // should have ended it finds nothing, and the + // child holds the button down forever. + if editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind) { + update_accepted_gesture( + semantic_states, + source, + kind, + coord, + buffer_id, + // Whether the press reached a child is + // replay's to know; until replay exists + // no press does. + false, + ); + } } } FrontendEvent::PanelPointerMapped { @@ -2637,15 +2655,22 @@ fn handle_dispatcher_event( mapping_generation, ) { - editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); - update_accepted_gesture( - semantic_states, - source, - kind, - coord, - buffer_id, - false, - ); + // The latch follows the dispatch, for the + // reason spelled out on the legacy arm above. + // The mapping rung narrows WHICH coordinates + // survive the ladder; it does not make a + // surviving one land, so this arm needs the same + // gate. + if editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind) { + update_accepted_gesture( + semantic_states, + source, + kind, + coord, + buffer_id, + false, + ); + } } } FrontendEvent::Pointer { @@ -7317,6 +7342,234 @@ mod tests { ); } + // ----------------------------------------------------------------- + // §5b review round 4 — THE LATCH FOLLOWS THE DISPATCH. + // + // Both inbound arms discarded the dispatcher's answer and updated + // the accepted-gesture latch unconditionally. The ladder + // authenticates the SENDER; only the dispatcher re-derives the + // TARGET, so an event that clears every rung can still be refused — + // for an out-of-grid coordinate, an absent side window, or a buffer + // that is no longer the one shown there. + // + // Each row drives the refusal from a coordinate ONE PAST the last + // row of the live grid, which also pins the dispatcher's `>=` + // against a `>`. Each ends in a POSITIVE CONTROL differing from the + // refused event ONLY in that coordinate: without one, a row would + // pass just as well if some unrelated rung had dropped the event, + // which is how a negative test of this shape usually rots. + // ----------------------------------------------------------------- + + /// Which inbound arm a latch row exercises. + #[derive(Clone, Copy)] + enum PanelArm { + Legacy, + Mapped, + } + + impl PanelArm { + fn version(self) -> u32 { + match self { + PanelArm::Legacy => LEGACY_PANEL_VERSION, + PanelArm::Mapped => PROTOCOL_VERSION, + } + } + } + + /// The mapping key the validator will compare against, read through + /// the SAME accessor it reads through. + /// + /// Peeking the last STAMPED value instead would let a mapped row be + /// refused at the mapping rung whenever the preceding dispatch moved + /// the fingerprint — a ladder refusal wearing a dispatcher refusal's + /// clothes. Legacy events carry no generation and never read this. + fn live_generation( + arm: PanelArm, + editor: &crate::editor::EditorState, + semantic_states: &mut HashMap, + fid: FrontendId, + ) -> u64 { + match arm { + PanelArm::Legacy => 0, + PanelArm::Mapped => { + let snapshot = editor.panel_mapping_snapshot(fid); + semantic_states + .get_mut(&fid) + .expect("semantic projection") + .panel_mapping_generation(snapshot) + .expect("a stamped mapping generation") + } + } + } + + /// One pointer event on whichever arm the row is testing. + fn arm_pointer( + arm: PanelArm, + fid: FrontendId, + epochs: (u64, u64), + buffer_id: crate::buffer::BufferId, + mapping_generation: u64, + coord: pmacs_protocol::CellCoord, + kind: pmacs_protocol::MouseKind, + ) -> FrontendEvent { + match arm { + PanelArm::Legacy => FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: epochs.0, + panel_epoch: epochs.1, + buffer_id, + coord, + kind, + mods: pmacs_protocol::Modifiers::default(), + }, + PanelArm::Mapped => FrontendEvent::PanelPointerMapped { + frontend_id: fid, + geometry_epoch: epochs.0, + panel_epoch: epochs.1, + buffer_id, + coord, + kind, + mods: pmacs_protocol::Modifiers::default(), + mapping_generation, + }, + } + } + + /// The panel's buffer, and a coordinate one row past its grid. + fn panel_buffer_and_outside_coord( + editor: &crate::editor::EditorState, + fid: FrontendId, + panel: crate::window::WindowId, + ) -> (crate::buffer::BufferId, pmacs_protocol::CellCoord) { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("a live panel grid"); + ( + core.windows[&panel].buffer_id, + pmacs_protocol::CellCoord::new(grid.rows, 0), + ) + } + + /// A press the dispatcher refuses must leave the latch exactly as it + /// found it — neither armed, nor displaced. + fn a_refused_press_never_arms(arm: PanelArm, fid: FrontendId) { + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(arm.version(), fid); + let (buffer_id, outside) = panel_buffer_and_outside_coord(&editor, fid, panel); + let inside = pmacs_protocol::CellCoord::new(0, 0); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let mut send = + |editor: &mut crate::editor::EditorState, + states: &mut HashMap, + coord| { + let generation = live_generation(arm, editor, states, fid); + let event = arm_pointer(arm, fid, epochs, buffer_id, generation, coord, press); + dispatch_panel_event(editor, fid, arm.version(), states, &mut render, event); + }; + + send(&mut editor, &mut states, outside); + assert!( + !states[&fid].has_accepted_gesture(), + "a refused press must not arm: an authority loss would then \ + count a cancellation for a gesture that never began, and \ + replay would release a child that was never pressed" + ); + assert_eq!( + states[&fid].panel_gesture_cancellations(), + 0, + "and it must not count one on the way in either" + ); + + send(&mut editor, &mut states, inside); + assert!( + states[&fid].has_accepted_gesture(), + "control: the same event one row up clears the ladder, so the \ + refusal above came from the DISPATCHER and not from a rung" + ); + + // `arm_accepted_gesture` ends whatever it overwrites, so an + // unconditional update also destroys a live gesture. + send(&mut editor, &mut states, outside); + assert!( + states[&fid].has_accepted_gesture(), + "the live gesture survives a refused press" + ); + assert_eq!( + states[&fid].panel_gesture_cancellations(), + 0, + "and the refused press ends nothing" + ); + } + + /// A release the dispatcher refuses must not consume a live gesture. + fn a_refused_release_never_consumes(arm: PanelArm, fid: FrontendId) { + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(arm.version(), fid); + let (buffer_id, outside) = panel_buffer_and_outside_coord(&editor, fid, panel); + let inside = pmacs_protocol::CellCoord::new(0, 0); + let mut send = + |editor: &mut crate::editor::EditorState, + states: &mut HashMap, + coord, + kind| { + let generation = live_generation(arm, editor, states, fid); + let event = arm_pointer(arm, fid, epochs, buffer_id, generation, coord, kind); + dispatch_panel_event(editor, fid, arm.version(), states, &mut render, event); + }; + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + + send(&mut editor, &mut states, inside, press); + assert!( + states[&fid].has_accepted_gesture(), + "fixture: a real gesture is live" + ); + + send(&mut editor, &mut states, outside, release); + assert!( + states[&fid].has_accepted_gesture(), + "a refused release must not consume the live gesture: the \ + authority loss that should have ended it would find nothing \ + armed, and the child would hold the button down for good" + ); + assert_eq!( + states[&fid].panel_gesture_cancellations(), + 0, + "and a refused release ends nothing" + ); + + send(&mut editor, &mut states, inside, release); + assert!( + !states[&fid].has_accepted_gesture(), + "control: the same release one row up DOES consume it, so the \ + refusal above came from the DISPATCHER and not from a rung" + ); + assert_eq!( + states[&fid].panel_gesture_cancellations(), + 0, + "an ordinary release is a consume, never a cancellation" + ); + } + + #[test] + fn g5_substrate_a_refused_press_never_arms_on_the_legacy_arm() { + a_refused_press_never_arms(PanelArm::Legacy, FrontendId(778)); + } + + #[test] + fn g5_substrate_a_refused_press_never_arms_on_the_mapped_arm() { + a_refused_press_never_arms(PanelArm::Mapped, FrontendId(779)); + } + + #[test] + fn g5_substrate_a_refused_release_never_consumes_on_the_legacy_arm() { + a_refused_release_never_consumes(PanelArm::Legacy, FrontendId(780)); + } + + #[test] + fn g5_substrate_a_refused_release_never_consumes_on_the_mapped_arm() { + a_refused_release_never_consumes(PanelArm::Mapped, FrontendId(781)); + } + /// Criterion 50: a gesture from a source whose latest declaration is /// not a visible `Present` is dropped. #[test] diff --git a/src/editor.rs b/src/editor.rs index 139bf84..d908259 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2801,6 +2801,11 @@ impl EditorState { /// which needs the GPU band and lands in Stage 2B-3. /// /// Returns whether the gesture was accepted. + /// + /// `#[must_use]` because the accepted-gesture latch is driven off + /// this answer, and discarding it silently arms on rejected presses + /// and consumes on rejected releases. + #[must_use] pub fn dispatch_semantic_panel_pointer( &self, frontend_id: FrontendId,