fix(panel): D2 was broken on the real sequence, and Q#BP-R3 is ruled
Two of review's findings: the framing blocker, and a live defect in the commit before this one. The remaining six are queued. **I REPORTED M-D2 AS BITING AND IT DID NOT.** `presented()` filters on `frame.geometry_epoch == self.panel.geometry_epoch`, and a geometry change advances that field FIRST --- so by the time the matching frame arrives, `presented()` already answers `None` and my `is_some_and` predicate skipped the reset entirely. The shipped D2 did nothing on the production sequence. The witness could not see it because it invented a higher-epoch frame without driving `next_geometry_declaration`, leaving `self.panel.geometry_epoch` untouched so `presented()` still matched. A test that skips the step which breaks the code cannot fail on it. The predicate now compares against the RETAINED frame (`self.panel.frame`), which survives the epoch advance, and the witness drives `GeometryTrigger::Metrics` for real --- asserting along the way that `presented()` IS `None` in that window, so the trap is pinned rather than merely avoided. Restoring the `presented()` predicate now fails the row. **M-D3 WAS ALSO UNCONSTRAINED**, for a smaller reason: arming clears `last_pointer_cell`, and the leg only armed, so the field was already `None` before the replacement and deleting its reset changed nothing. The arm helper now seeds the baseline with one accepted motion --- what a real gesture would have produced --- and after replacement the row requires `panel_motion_is_new` at that same cell to return true. Deleting only that line now fails. **Q#BP-R3 IS RULED: current-state hit semantics, narrowly, with the token named as follow-up.** `PanelPointer` carries epochs and a cell but nothing identifying the frame CONTENT the user saw, and `panel_epoch` is stable across ordinary frames by design. So a document wheel moves `view_top` daemon-side, and a click sent before the new frame lands is inverted through the NEW `view_top` --- selecting a row the user never saw, with every validation passing. Closing it properly needs a per-frame token on `PanelFrame` echoed by `PanelPointer`: a WIRE CHANGE, and this lane is non-protocol-bearing with 1b blocked behind it. A daemon-only mitigation was considered and does not work --- inverting against the last EMITTED frame still cannot tell which frame the user SAW, and the failing window is identical. So the lane accepts current-state semantics and says so: the window is narrow and self-inflicted (the same frontend must move the view and then click within one round trip), the magnitude is bounded by `SCROLL_LINES`, and the TUI is structurally unaffected. The token is recorded as a named follow-up for the next protocol-bearing slice, so it is inherited rather than rediscovered. Overrule stated explicitly: the trade is a narrow same-frontend mis-hit now, against serializing this lane and 1b behind a v25 wire change. Gates: all nine green under `env -u TMPDIR`, log 20260814T154611Z. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
4bd3a76618
commit
3dff79a0ec
|
|
@ -2031,6 +2031,68 @@ follows acceptance 48's wording — it names row *selection* — and keeps
|
||||||
document navigation from becoming an incidental consequence of wiring
|
document navigation from becoming an incidental consequence of wiring
|
||||||
replay.
|
replay.
|
||||||
|
|
||||||
|
#### Q#BP-R3 — a panel cell has no frame-content provenance **RULED: current-state hit semantics, narrowly, with the token named as follow-up**
|
||||||
|
|
||||||
|
**The hole.** `PanelPointer` carries `geometry_epoch`, `panel_epoch`,
|
||||||
|
`buffer_id` and a `coord` — **and nothing identifying the frame CONTENT
|
||||||
|
the user was looking at** (`pmacs-protocol/src/message.rs:500`).
|
||||||
|
`panel_epoch` is deliberately *"stable across ordinary frames of one
|
||||||
|
continuously present window/buffer"* (`panel.rs:61`), so an ordinary
|
||||||
|
repaint changes no epoch at all.
|
||||||
|
|
||||||
|
**The race that follows is real.** A document-panel wheel moves
|
||||||
|
`view_top` **daemon-side**. The daemon repaints and emits a new frame.
|
||||||
|
Before that frame reaches the GPU, the user clicks. Every validation
|
||||||
|
passes — same buffer, same epochs — and the daemon inverts the cell
|
||||||
|
through its **current** `view_top`, selecting a row the user never saw.
|
||||||
|
Off by up to `SCROLL_LINES` (3). **No existing gate can reject it**,
|
||||||
|
because nothing about the event is stale by any test the ladder
|
||||||
|
applies.
|
||||||
|
|
||||||
|
**Why the epochs cannot be stretched to cover it.** Moving
|
||||||
|
`panel_epoch` on content change would invalidate a gesture on every
|
||||||
|
repaint, which breaks drags outright — the field is stable *by design*,
|
||||||
|
and that design is what makes selection possible.
|
||||||
|
|
||||||
|
**Why a fix is not free.** Closing it properly needs a **per-frame
|
||||||
|
token** on `PanelFrame`, echoed by `PanelPointer` — **a wire change**,
|
||||||
|
which makes it a protocol-bearing slice. This lane is explicitly
|
||||||
|
non-protocol-bearing, and GUI arc 1b is blocked behind it.
|
||||||
|
|
||||||
|
**A daemon-only mitigation was considered and does not work.** Having
|
||||||
|
the daemon invert against the `view_top` it used for its **last emitted
|
||||||
|
frame** sounds like it removes the wire dependency, but it does not:
|
||||||
|
the daemon still cannot know **which** emitted frame the user saw, and
|
||||||
|
the failing window — frames emitted after the one on screen — is
|
||||||
|
exactly the same one. Without a token echoed back, the information does
|
||||||
|
not exist on the receiving side.
|
||||||
|
|
||||||
|
**Ruling: this lane accepts CURRENT-STATE hit semantics**, and says so
|
||||||
|
rather than leaving it undiscovered:
|
||||||
|
|
||||||
|
- A panel cell is resolved against the daemon's state **at the moment
|
||||||
|
the event is processed**, not against the frame the frontend painted.
|
||||||
|
- **The window is narrow and self-inflicted**: it requires the same
|
||||||
|
frontend to change the panel's view and then click inside one
|
||||||
|
round-trip. It cannot arise from another frontend's activity, because
|
||||||
|
a foreign edit that moves `view_top` is not a thing panels do.
|
||||||
|
- **The magnitude is bounded** by whatever moved the view — one wheel
|
||||||
|
step, `SCROLL_LINES` rows.
|
||||||
|
- **The TUI is unaffected.** It has no round trip; the hazard is
|
||||||
|
structural to a remote frontend inverting cells against mutable
|
||||||
|
daemon state.
|
||||||
|
|
||||||
|
**Named follow-up, not a shrug: `PanelFrame` gains a content token and
|
||||||
|
`PanelPointer` echoes it, in the next protocol-bearing slice.** The
|
||||||
|
daemon then drops a gesture whose token no longer matches — the same
|
||||||
|
shape as the epoch ladder, one level finer. It is recorded here so the
|
||||||
|
next wire slice inherits it rather than rediscovering the race.
|
||||||
|
|
||||||
|
**Overrule this if you would rather block the lane on a protocol
|
||||||
|
slice.** The trade is explicit: a narrow, bounded, same-frontend
|
||||||
|
mis-hit now, against serializing this lane and 1b behind a v25 wire
|
||||||
|
change.
|
||||||
|
|
||||||
### The four replay edges — none of which "activation ordering" covers
|
### The four replay edges — none of which "activation ordering" covers
|
||||||
|
|
||||||
§5a's first draft concluded that the activation rule made the replay
|
§5a's first draft concluded that the activation rule made the replay
|
||||||
|
|
|
||||||
|
|
@ -7085,7 +7085,14 @@ impl State {
|
||||||
// Only on a CHANGE of identity. A panel repaints constantly
|
// Only on a CHANGE of identity. A panel repaints constantly
|
||||||
// during a drag, and resetting on every frame would make
|
// during a drag, and resetting on every frame would make
|
||||||
// selection impossible.
|
// selection impossible.
|
||||||
let identity_changed = self.panel.presented().is_some_and(|current| {
|
//
|
||||||
|
// Compared against the RETAINED frame, never `presented()`:
|
||||||
|
// that accessor filters on `geometry_epoch == self.panel
|
||||||
|
// .geometry_epoch`, and a geometry change advances the field
|
||||||
|
// FIRST, so by the time the matching frame arrives
|
||||||
|
// `presented()` is already `None` and an `is_some_and`
|
||||||
|
// predicate skips the reset — exactly the case D2 covers.
|
||||||
|
let identity_changed = self.panel.frame.as_ref().is_some_and(|current| {
|
||||||
current.panel_epoch != frame.panel_epoch
|
current.panel_epoch != frame.panel_epoch
|
||||||
|| current.geometry_epoch != frame.geometry_epoch
|
|| current.geometry_epoch != frame.geometry_epoch
|
||||||
});
|
});
|
||||||
|
|
@ -20105,9 +20112,20 @@ mod tests {
|
||||||
};
|
};
|
||||||
let frame = present_panel(&mut state, 4);
|
let frame = present_panel(&mut state, 4);
|
||||||
|
|
||||||
|
let live = pmacs_protocol::CellCoord::new(1, 1);
|
||||||
let arm = |state: &mut State| {
|
let arm = |state: &mut State| {
|
||||||
state.set_panel_pointer_held(true);
|
state.set_panel_pointer_held(true);
|
||||||
state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 1));
|
state.panel.gesture_last_content_cell = Some(live);
|
||||||
|
// SEED THE DEDUPE BASELINE TOO. Arming clears it, so a leg that
|
||||||
|
// only arms leaves `last_pointer_cell` already `None` and its
|
||||||
|
// reset is unconstrained — deleting that line stays green. One
|
||||||
|
// accepted motion is what a real gesture would have produced by
|
||||||
|
// the time a replacement arrives.
|
||||||
|
assert!(
|
||||||
|
state.panel_motion_is_new(live),
|
||||||
|
"the first motion after a press is never a duplicate"
|
||||||
|
);
|
||||||
|
assert_eq!(state.panel.last_pointer_cell, Some(live));
|
||||||
};
|
};
|
||||||
|
|
||||||
// D4, the NEGATIVE leg, first: a CHANGED frame at the SAME identity
|
// D4, the NEGATIVE leg, first: a CHANGED frame at the SAME identity
|
||||||
|
|
@ -20137,23 +20155,43 @@ mod tests {
|
||||||
"a replacement panel never saw the press"
|
"a replacement panel never saw the press"
|
||||||
);
|
);
|
||||||
assert_eq!(state.panel.gesture_last_content_cell, None);
|
assert_eq!(state.panel.gesture_last_content_cell, None);
|
||||||
assert_eq!(
|
assert_eq!(state.panel.last_pointer_cell, None);
|
||||||
state.panel.last_pointer_cell, None,
|
assert!(
|
||||||
"and the dedupe baseline goes too, or the successor's first \
|
state.panel_motion_is_new(live),
|
||||||
same-cell motion is silently suppressed as a duplicate"
|
"the dedupe baseline goes too: the successor's first motion at \
|
||||||
|
the predecessor's cell must reach the daemon, not be suppressed \
|
||||||
|
as a duplicate of a gesture that belonged to another panel"
|
||||||
);
|
);
|
||||||
|
|
||||||
// D2 — geometry identity, with panel identity UNCHANGED. This is the
|
// D2 — geometry identity, with panel identity UNCHANGED. This is the
|
||||||
// font/scale case: the gesture would otherwise resume under a new
|
// font/scale case: the gesture would otherwise resume under a new
|
||||||
// grid carrying epochs that are current and perfectly valid.
|
// grid carrying epochs that are current and perfectly valid.
|
||||||
let base = state
|
//
|
||||||
.panel
|
// **Driven through the real declaration path.** Inventing a
|
||||||
.presented()
|
// higher-epoch frame is not the production sequence: a font or
|
||||||
.cloned()
|
// scale change advances `self.panel.geometry_epoch` FIRST, and only
|
||||||
.expect("a frame is present");
|
// then does the matching frame arrive. That ordering is what broke
|
||||||
|
// the first implementation — `presented()` filters on the epoch, so
|
||||||
|
// it answers `None` in exactly this window — and a witness that
|
||||||
|
// skips the declaration cannot see it.
|
||||||
|
let base = state.panel.frame.clone().expect("a frame is retained");
|
||||||
arm(&mut state);
|
arm(&mut state);
|
||||||
|
let (next_epoch, total) = state
|
||||||
|
.next_geometry_declaration(GeometryTrigger::Metrics)
|
||||||
|
.expect("a metrics change re-declares geometry");
|
||||||
|
assert_ne!(next_epoch, base.geometry_epoch, "the declaration advanced");
|
||||||
|
assert!(
|
||||||
|
state.panel.presented().is_none(),
|
||||||
|
"and the retained frame no longer answers `presented()` — the \
|
||||||
|
window in which a `presented()`-based reset silently skips"
|
||||||
|
);
|
||||||
let mut regeometried = base.clone();
|
let mut regeometried = base.clone();
|
||||||
regeometried.geometry_epoch = base.geometry_epoch + 1;
|
regeometried.geometry_epoch = next_epoch;
|
||||||
|
regeometried.size = CellSize::new(base.size.rows, total.cols.max(1));
|
||||||
|
regeometried.cells = vec![
|
||||||
|
pmacs_protocol::Cell::default();
|
||||||
|
(regeometried.size.rows * regeometried.size.cols) as usize
|
||||||
|
];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
regeometried.panel_epoch, base.panel_epoch,
|
regeometried.panel_epoch, base.panel_epoch,
|
||||||
"the point of this leg is that PANEL identity holds"
|
"the point of this leg is that PANEL identity holds"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue