fix(daemon): the accepted-gesture latch must follow the dispatch

Both inbound panel-pointer 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 the latch, so a later authority loss
counted a cancellation for a gesture that never began, and once replay
attaches effects it would deliver a release to 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 armed and that child holds
the button down for good. A rejected Down on top of a live gesture was
worse again: arm_accepted_gesture ends what it overwrites, so it also
counted a spurious cancellation.

Both arms now gate on the return. The dispatcher is #[must_use], so the
class cannot recur silently --- clippy runs with -D warnings, making a
future discarded answer a build failure rather than a review finding.

Four 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 the control
a row would pass just as well if an unrelated rung had dropped the
event. 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.

Also removes a committed diff3 ancestor marker this file carried since
8c9afde --- the only one in the branch's 32 commits, and invisible to a
clean-worktree `git diff --check`.

Also withdraws the claim that the local CRDT sweep could not go green.
The full 16-stage gate is green, sweep-crdt included. The two m4_24
failures came from running that sweep outside scripts/gate, where it
inherited TMPDIR=/tmp; /tmp/.git exists on this machine and project
detection walks upward, so both base-resolution rows resolved against
the wrong root. That is the exact hazard #240's isolation exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-20 17:41:17 +02:00
parent e06fb83850
commit 026bb4955f
No known key found for this signature in database
3 changed files with 371 additions and 46 deletions

View File

@ -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 `<gate-root>/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

View File

@ -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<FrontendId, crate::semantic_render::SemanticRenderState>,
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<FrontendId, crate::semantic_render::SemanticRenderState>,
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<FrontendId, crate::semantic_render::SemanticRenderState>,
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]

View File

@ -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,