From ca816a1917eabe3017b6d8762be58864d499a146 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 18:37:14 +0200 Subject: [PATCH 01/35] docs(framing): the panel cell-mapping generation (v25) --- SS5b revision 14 Own branch, own slice, protocol-bearing, runs alone. Framing only; no implementation. Blocks `panel-pointer-replay`, which blocks GUI arc 1b. Answers review of revision 13. Every item below reverses or completes something 13 got wrong. **GATING IS REFUSAL, NOT FALLBACK.** Revision 13 said a bare `PanelPointer` from a new peer would be "handled under the old semantics". That is a BYPASS: it leaves the exact hole this slice exists to close, reachable by omitting a field. A >= v25 session sending the legacy event is REFUSED before mutation, and a >= v25 frontend REJECTS a legacy `Present` rather than painting a band it cannot safely hit-test. Only negotiated <= v24 keeps legacy semantics; `Absent` stays common to both families. **ONE AUTHORITATIVE PER-FRONTEND KEY**, used by projection AND inbound validation, advanced after any mapping mutation and BEFORE the next inbound pointer is handled --- whether or not anything has rendered. Comparing against the last EMITTED frame recreates the hole, because a mutation not yet painted has still changed the inverse mapping. **STALE TAILS TERMINATE; THEY DO NOT VANISH.** A blanket drop breaks liveness: a refused `Up` leaves an empty document selection armed with a stale anchor, and leaves a reporting terminal child HOLDING A BUTTON FOREVER. Cancellation is now a ruled outcome --- producer latch reset, daemon selection and click-chain cleanup, and the child's release delivered at the last coordinate known good. A cancelled gesture is explicitly not a replayed one: the release is for liveness, and no selection or scroll effect is applied from the stale event. Stale BEGINNINGS may still simply drop. **THE DOMAIN WAS INCOMPLETE.** `view_left` is added, because 1b makes horizontal scrolling real. "Cursor movement is stable" is now CONDITIONAL: a cursor move that triggers vertical or horizontal follow changes `view_top` or `view_left` and therefore does change the mapping. Terminal panels are ruled explicitly --- their coordinates are decided by the SCREEN, so output and scrollback movement change the generation while their buffer revision does not. **SS5b HAD NO ACCEPTANCE MATRIX.** G1-G11 now cover the foreign edit before render, every changing and stable domain entry ROW BY ROW, a selection repaint that must preserve the generation and let a drag continue, mid-gesture cancellation, v24 and v25 positive controls with both wrong-family refusals, identical cells across a generation change still emitting, atomic retention of frame and generation on an invalid frame, and fail-closed exhaustion. The per-entry enumeration is deliberate: one aggregate row cannot show WHICH input moved the key, and a key ignoring `view_left` passes every vertical-only row. **MAPPED MOTION KEEPS ITS COALESCING TAGS.** A new variant falling through to the lossless default would put pixel-rate `Move`/`Drag` on a bounded queue. **PINS ACCUMULATE.** Revision 13 said the pin "moves", which would delete coverage of the shape it protects. `PanelPointer` is retained; exact `TextInput` bytes are added as the previous-final `FrontendEvent`; the complete nested `PanelFrame(Absent)` bytes are added as the previous-final `PanelFramePayload`. Recorded honestly: I could find NO exact-bytes pin for `PanelPointer` anywhere in the tree, though `pmacs-protocol/src/message.rs:524` says one is in the tests. Either my search missed it or the doc overclaims; this slice resolves it either way, since it must add exact pins regardless. **AND THIS SLICE OWNS THE VERSION CORRECTION.** `docs/gui-stage1-input-framing.md` now says 1e's `OpenTarget` is **v26**, with the reason stated at the top. An expected rebase conflict on `gui-stage1b-pointer-scroll` is not grounds for leaving the canonical document false --- which is what I argued last round, and it was wrong. `ADVERTISED_PROTOCOL_VERSION` stays pinned at 20. Gates: all ELEVEN green under `env -u TMPDIR`, with `--protocol` (`build-crdt`, `sweep-crdt`), log 20260814T162843Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 60 ++++++++++ docs/bottom-panel-framing.md | 190 +++++++++++++++++++++++++++++++ docs/gui-stage1-input-framing.md | 11 +- 3 files changed, 259 insertions(+), 2 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 6460507..3434850 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -270,6 +270,65 @@ 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) — ACTIVE, framing only + +**Written with the branch's FIRST commit**, per the standing correction +from #171 and #215. + +- **Branch `panel-mapping-generation`**, base `githubsucks/main` @ + **`72da24a`**, worktree + `/home/jeans/Repos/personal/pmacs-mapping-gen`. + **`githubsucks/panel-mapping-generation` is the authoritative tip.** + Recover with `git fetch githubsucks && git checkout + panel-mapping-generation`. +- **No PR yet. Checkpoint: framing revision 14 (§5b) AWAITING + APPROVAL; NO IMPLEMENTATION.** +- **PROTOCOL-BEARING — v25, and it runs alone.** + `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**. +- **Why it exists.** A `PanelPointer` names a cell and nothing on the + wire says which inverse mapping the frontend saw, so the daemon + inverts against whatever is current. `buffer_id` catches + replacement, `panel_epoch` close/reopen, `geometry_epoch` a + declaration race — **nothing catches "the text under that cell + changed"**. Q#BP-R3 first accepted this as narrow; that was + **overruled**, because a **foreign** edit moves the mapping with + `view_top` untouched, the error is unbounded, and the window lasts + until the frontend **presents** the new frame. +- **A generation, not a per-frame token.** A token moving with the + frame would cancel a drag on the next repaint — the mistake + `panel_epoch` is stable to avoid. The key moves with the inverse + mapping (`view_top`, **`view_left`**, grid size, folds, wrap/gutter + geometry, buffer content, terminal output/scrollback) and holds + across focus, styling, selection-only repaints and cursor motion + **that the follow rules absorb**. +- **One authoritative per-frontend key**, used by projection AND + inbound validation, advanced after any mapping mutation and + **before** the next inbound pointer — comparing against the last + emitted frame recreates the hole. +- **Gating is REFUSAL, not fallback.** A ≥ v25 session sending bare + `PanelPointer` is refused; a ≥ v25 frontend rejects legacy + `Present`. Only ≤ v24 sessions keep legacy semantics; `Absent` is + common. +- **Stale tails TERMINATE, they do not vanish.** A dropped `Up` leaves + an empty selection armed and a reporting child holding a button + forever, so cancellation is a ruled outcome: producer latch reset, + daemon selection/click-chain cleanup, and the child's release + delivered. +- **Pins ACCUMULATE**: keep `PanelPointer`, add exact `TextInput` + bytes (previous-final `FrontendEvent`) and the complete nested + `PanelFrame(Absent)` bytes (previous-final `PanelFramePayload`). + **No exact-bytes pin for `PanelPointer` could be found in the tree** + despite `pmacs-protocol/src/message.rs:524` claiming one; this slice + resolves that either way. +- **Owns the version correction.** `docs/gui-stage1-input-framing.md` + moves 1e's `OpenTarget` to **v26** here — an expected rebase + conflict on `gui-stage1b-pointer-scroll` is not grounds for leaving + the canonical document false. +- **Chain: this slice → `panel-pointer-replay` (rebases onto it) → + GUI arc 1b.** +- **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite, + `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. + ## GPU launcher / probe SIGINT teardown — MERGED as #241 (`f8033bc`) - **MERGED 2026-08-20** at approved head `5089715`, merge commit @@ -722,6 +781,7 @@ 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/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 35abd22..2e98924 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -1770,6 +1770,196 @@ cannot preserve the old panel-focused attach leak. preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR then runs the full gate suite. +## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3) + +**Status: revision 14 — AWAITING APPROVAL. Nothing implemented.** This +slice **blocks** panel-pointer replay (`panel-pointer-replay`), which +blocks GUI arc 1b. It is protocol-bearing and runs alone. + +### What it fixes + +A `PanelPointer` names a **cell**; the daemon must invert that to a +**byte**. Nothing on the wire says which inverse mapping the frontend +was looking at, so the daemon inverts against whatever is current — and +the mapping can move for reasons the clicking frontend neither caused +nor can observe. + +This is the one hole in the ladder: `buffer_id` catches replacement, +`panel_epoch` catches close/reopen, `geometry_epoch` catches a +declaration race, and **nothing catches "the text under that cell +changed"**. + +**Revision 12 accepted this as narrow. It is not.** A **foreign** edit +moves the mapping with `view_top` untouched; ticks, paging, folds, +edits and reloads accumulate without bound; and the window lasts until +the frontend **presents** the replacement frame, which a backed-up +frontend widens arbitrarily. + +### The generation, and why not a token + +**A per-frame token is the wrong object.** Panels repaint constantly, +and a token that moved with the frame would invalidate a live drag on +the next repaint — the mistake `panel_epoch` is stable to avoid. + +**`mapping_generation` identifies the INVERSE MAPPING.** + +| changes it | leaves it alone | +|---|---| +| `view_top` | focus gained or lost | +| **`view_left`** — 1b makes horizontal scrolling real | styling, theme, face changes | +| panel grid size | selection-only changes | +| fold state | a re-emitted identical frame | +| wrap mode, gutter geometry | **cursor motion that moves nothing else** | +| **buffer content — any edit, from any source** | | +| **terminal output or scrollback movement** (terminal panels) | **terminal buffer revision** (see below) | + +**"Cursor movement is stable" is CONDITIONAL, and revision 13 stated it +flatly.** A cursor move that triggers vertical or horizontal follow +changes `view_top` or `view_left`, and therefore **does** change the +generation. The stable case is a cursor move the follow rules absorb. + +**Terminal panels are ruled explicitly.** What a coordinate denotes +there is decided by the terminal's **screen**, so output and scrollback +movement change the generation. Their **buffer revision** does not — a +terminal buffer's revision counter tracks something else, and keying on +it would both miss real changes and fire on non-changes. + +**The stability half is load-bearing, not an optimisation.** A drag +provokes selection repaints on every motion; a generation that moved +with them would cancel the drag after one step. + +### Ownership and refresh timing + +**One authoritative per-frontend mapping key**, owned by the daemon, +**used by both projection and inbound validation**. Not two derivations +that agree by inspection. + +- It **advances after any mapping mutation and BEFORE the next inbound + pointer is handled**, whether or not a frame has been rendered or + emitted since. +- **Comparing against the last EMITTED frame recreates the hole.** A + mutation that has not yet been painted still changes the inverse + mapping, and a gesture arriving in that gap must be refused. +- Projection stamps the frame with the same key it validates against, + so "what the frontend was shown" and "what the daemon checks" cannot + drift. + +### Wire shape — appended variants, never widened + +Postcard encodes enums **positionally**, so a shipped variant's field +list is frozen. Both messages gain **new variants**: + +- `PanelFramePayload::PresentMapped { .. }` beside `Present`. +- `FrontendEvent::PanelPointerMapped { .. }` beside `PanelPointer`. + +`Absent` is unchanged and **common to both families** — hiding a band +carries no mapping. + +### Bilateral gating — REFUSAL, not fallback + +Revision 13 said an unmapped event from a new peer is "handled under +the old semantics". **That is a bypass**: it leaves the exact hole the +slice exists to close, reachable by omitting a field. + +| negotiated | daemon sends | daemon accepts | frontend sends | frontend accepts | +|---|---|---|---|---| +| **≤ v24** | `Present` | `PanelPointer` | `PanelPointer` | `Present` | +| **≥ v25** | `PresentMapped` | `PanelPointerMapped` **only** | `PanelPointerMapped` | `PresentMapped` **only** | + +- A **≥ v25 session sending bare `PanelPointer` is REFUSED**, dropped + before any mutation, exactly as an out-of-epoch event is. +- A **≥ v25 frontend receiving legacy `Present` REJECTS it** rather + than painting a band it cannot safely hit-test. +- Only a negotiated **≤ v24** session retains legacy semantics. +- `Absent` is accepted from either family. + +### Enforcement, and the liveness it must not break + +On `PanelPointerMapped`, the daemon compares the echoed generation with +the authoritative key and refuses the gesture before any mutation when +they differ. + +**But a blanket drop breaks liveness, and revision 13's did.** A +refused **tail** is not the same as a refused **beginning**: + +- **Stale BEGINNINGS may simply drop.** A `Down` that never took effect + leaves nothing behind. +- **Stale TAILS must TERMINATE the gesture, not vanish.** A dropped + `Up` leaves an empty document selection armed with a stale anchor, + and leaves a **reporting terminal child holding a button forever**. + +**Cancellation is therefore ruled as a first-class outcome:** + +1. **Producer:** the frontend resets its gesture latch — + `pointer_held`, `last_pointer_cell`, `gesture_last_content_cell` — + on the same signal, so no further `Drag` is manufactured. +2. **Daemon, document:** the panel's selection is cleared if empty, the + click chain is cleared, and no cursor move is applied. +3. **Daemon, terminal:** the child receives its **release** — a + reporting child must not be left holding a button because the + mapping moved — and the controller claim is settled. + +**A cancelled gesture is not a replayed one**: the release is delivered +for liveness, at the last coordinate known to be valid, and no +selection or scroll effect is applied from the stale event. + +### Motion must stay coalesced + +`PanelPointerMapped` carrying `Move` or `Drag` **takes the same +tail-coalescing tags as `PanelPointer`** (`pmacs-gpu/src/attach.rs:374` +onward). A new variant that fell through to the lossless default would +put pixel-rate motion on a bounded queue — the failure the coalescing +tags exist to prevent. `Down`/`Up`/wheel/context stay lossless and +ordered, as today. + +### Pins ACCUMULATE + +**They are not moved.** Revision 13 said the pin "moves to the previous +final variant", which would delete coverage of the shape it was +protecting. + +| pin | covers | +|---|---| +| existing `FrontendEvent::PanelPointer` | retained, unchanged | +| **new**: exact `FrontendEvent::TextInput` bytes | the previous-final `FrontendEvent`, appended by 1a and never pinned | +| **new**: complete nested bytes of `InstanceMessage::PanelFrame(PanelFramePayload::Absent)` | the previous-final `PanelFramePayload` variant | + +**A note on what exists today.** The panel protocol suite round-trips +these shapes and asserts `Absent` differs from an empty `Present` +(`tests/bottom_panel_stage2b_protocol_acceptance.rs:196`), but I could +find **no exact-bytes pin** for `PanelPointer`, despite +`pmacs-protocol/src/message.rs:524` stating one is in the tests. Either +it is somewhere my search missed, or the doc overclaims — **this slice +resolves it either way**, since it must add exact-byte pins regardless. + +### Acceptance and mutation matrix + +| # | row | mutation | +|---|---|---| +| G1 | a **foreign** edit before the next render → the old generation is refused | never advance on content change → G1 passes a stale hit | +| G2 | every **changing** entry of the domain table moves the generation, one row each | omit that entry from the key | +| G3 | every **stable** entry leaves it unchanged, one row each | include that entry → drags cancel on repaint | +| G4 | a **selection repaint** preserves the generation and an in-flight drag **continues** | as G3 | +| G5 | a mid-gesture mapping change **cancels**: latch reset, empty selection cleared, click chain cleared, **child receives its release** | drop the stale tail silently → the child holds the button and the selection stays armed | +| G6 | **v24 positive control** — a legacy session drives a panel gesture end to end | gate ≤ v24 off → old peers lose the panel | +| G7 | **v25 positive control** — a mapped session drives one end to end | — | +| G8 | **wrong-family refusals, both directions** — bare `PanelPointer` from a v25 session is refused; legacy `Present` at a v25 frontend is rejected | accept either → the bypass returns | +| G9 | **identical cells, changed generation, still emitted** — motion dedupe is per cell, and a mapping change makes the same cell a different byte | dedupe across a generation change → the second gesture is suppressed | +| G10 | an **invalid** mapped frame retains the previous frame **and** its generation, atomically | update one without the other → a valid generation names a frame never shown | +| G11 | **generation exhaustion fails CLOSED** — refuse gestures rather than accept unchecked ones | fail open → the hole returns at the boundary | + +G2 and G3 are enumerated per entry deliberately: one row asserting "the +generation changed" cannot show **which** input moved it, and a key +that ignores `view_left` passes every row that only scrolls +vertically. + +### Consequence for the GUI arc + +This slice takes **v25**, so GUI arc **1e's `OpenTarget` moves to +v26** — corrected in `docs/gui-stage1-input-framing.md` **by this +slice**, because a canonical document that says v25 is false the moment +this lands. `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**. + ## 6. Deferred (named) Left / right / top side windows; multiple slots per side; **rehoming a leaf diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index fd1a647..5bed92f 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -11,6 +11,13 @@ review overturned; revision 11 retracts it and P2 is implemented as written** (§6). **Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** **1-pre is IMPLEMENTED**; 1a onward may begin from this document. +**v26, not v25 — corrected by the panel mapping-generation slice.** +That slice (`docs/bottom-panel-framing.md` §5b) takes **v25** for +`PanelFramePayload::PresentMapped` / `FrontendEvent::PanelPointerMapped`, +and it lands ahead of 1e because panel-pointer replay blocks 1b. +Protocol slices stay serialized; one was inserted in front. +`ADVERTISED_PROTOCOL_VERSION` remains pinned at **20**. + **Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12), the tip after 1-pre; it was originally taken at `a994f37`. Sections other than §2 were written against `a994f37` and their *rulings* are @@ -350,7 +357,7 @@ says nothing about glyphs or hit tests staying at scale 1. | D5 | Overlay clears on **empty `Preedit`, `Ime::Disabled`, and focus loss** — all three | no overlay | clear on focus loss only → `Disabled` row leaves stale text | | D6 | Dead-key state owned here; 1a buffers nothing | dead keys dropped | buffer in 1a → D6 by construction | -### 1e — `OpenTarget` (v25) +### 1e — `OpenTarget` (v26) | # | Contract | Witness (fails today because) | Mutation | |---|---|---|---| @@ -560,7 +567,7 @@ and flushed to the socket**. **Bound: 250 ms.** | | **1a — `TextInput`** | **1e — `OpenTarget` + `OpenTargetResult`** | |---|---|---| -| **floor** | **v24** | **v25**, after v24, serialized | +| **floor** | **v24** | **v26**, after v25's mapping generation, serialized | | **encoding** | **appended variant**; never widen a field in place — postcard is positional | appended variants | | **byte pin** | frozen-byte fixture on the **previous final variant** | same | | **gate** | daemon accepts from `>= 24`; producer withholds below | `>= 25`; producer withholds below | From fa980ef1baba09b50dfe9bf354b2ae7df176cc39 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 21:06:08 +0200 Subject: [PATCH 02/35] docs(framing): SS5b revision 15 --- appended means LAST, and cancellation had a race Answers review of 14. Framing only. Four of the six reverse something 14 asserted, and a green protocol gate would not have caught any of them. **"BESIDE `Present`/`PanelPointer`" WAS POSITIONALLY DANGEROUS.** "Beside" reads as adjacent, and adjacent insertion shifts every discriminant below it --- the exact hazard the appended-only rule exists for. Appended means LAST: `PresentMapped` after `Absent`, `PanelPointerMapped` after `TextInput`, with a diagram so the next reader cannot re-derive it wrongly. Field order is stated exactly, `mapping_generation` is a `u64`, ZERO IS INVALID --- it is what a default-constructed or half-initialised sender produces, so accepting it would let a peer opt out of the check by sending nothing --- and the gate reads `PANEL_MAPPING_MIN_VERSION = 25` rather than a literal. **BLANKET REFUSAL STOPPED THE WHEEL AFTER ONE TICK.** The first effective document wheel changes `view_top`, which advances the key, so the next already-queued tick carries the old generation and is refused: the panel scrolls once and goes dead until the frontend observes the new frame. Local terminal scrollback has the same shape. The discriminator is whether the gesture USES its coordinate. Coordinate-free gestures --- the document wheel, non-reporting terminal scrollback --- cannot be mis-aimed by a stale mapping and are EXEMPT. A child-reported wheel is the opposite case: SGR carries row and column, so a stale one aims an application action at a cell the user never pointed at, and it keeps the check. Two-tick witnesses added, because without them a blanket-refusal implementation passes every single-event row in the matrix. **CANCELLATION WAS REACTIVE AND LOSES A RACE.** If the replacement mapped frame reaches the frontend before the physical `Up`, the producer resets `pointer_held` and SUPPRESSES THE VERY EVENT that would have cancelled --- so the daemon is never told, the selection stays armed, and the child keeps holding its button. It is now PROACTIVE, triggered by the authoritative key advancing while a gesture is accepted, and the producer must emit a cancellation tail or retain the latch rather than clearing first. That needs state 14 assumed and never specified: an ACCEPTED-GESTURE LATCH recording whether the `Down` was accepted, whether it reached the child, and the coordinate, button and encoding a release must match. Two rules fall out and are ruled here --- a stale `Up` with no accepted `Down` is INERT, and cancellation NEVER reclaims a controller another frontend has since taken, because a stale gesture must not steal a live one's terminal. **THE EXISTING SCREEN GENERATION CANNOT BE THE TERMINAL KEY.** `Screen::changed()` bumps from 39 call sites including `SetStyle`, `Bell`, the tab-stop operations, cursor-only motion and `SetTitle`. None of those change what a coordinate denotes, so keying on it would cancel a drag every time the child recoloured a character. A dedicated terminal mapping revision is defined over projected cell identity, retained-row identity and the per-view scroll anchor --- with those five events as explicit STABLE CONTROLS, so a reader who later reaches for the convenient counter fails a test instead of shipping a cancelled drag. **G5'S EFFECTS ARE NOT PROVABLE ON THIS BRANCH**, and 14 claimed them. `gesture_last_content_cell` and the document/terminal replay exist only on `panel-pointer-replay` (`pmacs-gpu/src/main.rs:2143` there); the same struct here is at `:2124` with no such field. The obligations are split in a table. G5a --- that the key advancing RAISES cancellation --- stays here on purpose: the trigger is this slice's rule, and moving the whole row out would leave the proactive ruling with no witness in the slice that introduces it. P2s: mutation legs split (wrap vs gutter, terminal content vs scrollback, G5a-c, G8a/b, G9a/b); G7 given a positive-path mutation; G11 expanded --- exhaustion must publish `Absent`, clear input authority, cancel any accepted gesture and LATCH, or a stale panel stays painted and permanently inert; the v26 correction finished at the gate and old-peer cells (`:573`); and the SS20 impact statement added --- hardens an existing panel island, no journey grade changes, no config, no background work. **And the pin correction is mine to make: it EXISTS**, at `src/protocol.rs:1975`, in the ROOT crate's test module rather than under `pmacs-protocol/` or `tests/` --- which is exactly where I searched. `message.rs:524` was right and the doubt was wrong; the contrary claim is removed from both records. Gates: all eleven green under `env -u TMPDIR` with `--protocol`, log 20260814T180105Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 26 ++- docs/bottom-panel-framing.md | 283 +++++++++++++++++++++++++++---- docs/gui-stage1-input-framing.md | 4 +- 3 files changed, 272 insertions(+), 41 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 3434850..b4a90f6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,7 +281,7 @@ from #171 and #215. **`githubsucks/panel-mapping-generation` is the authoritative tip.** Recover with `git fetch githubsucks && git checkout panel-mapping-generation`. -- **No PR yet. Checkpoint: framing revision 14 (§5b) AWAITING +- **No PR yet. Checkpoint: framing revision 15 (§5b) AWAITING APPROVAL; NO IMPLEMENTATION.** - **PROTOCOL-BEARING — v25, and it runs alone.** `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**. @@ -317,9 +317,27 @@ from #171 and #215. - **Pins ACCUMULATE**: keep `PanelPointer`, add exact `TextInput` bytes (previous-final `FrontendEvent`) and the complete nested `PanelFrame(Absent)` bytes (previous-final `PanelFramePayload`). - **No exact-bytes pin for `PanelPointer` could be found in the tree** - despite `pmacs-protocol/src/message.rs:524` claiming one; this slice - resolves that either way. + **The `PanelPointer` pin DOES exist** at `src/protocol.rs:1975` — + revision 14 recorded it as missing, which was wrong: it lives in the + root crate's test module, not under `pmacs-protocol/` or `tests/`. +- **Appended means LAST**: `PresentMapped` after `Absent`, + `PanelPointerMapped` after `TextInput`. `mapping_generation` is a + `u64`, **zero invalid**, gated by `PANEL_MAPPING_MIN_VERSION = 25`. +- **Coordinate-free wheels are EXEMPT** from the freshness check — + otherwise the first tick advances the key and the next queued tick is + refused, so the panel scrolls once and dies. Child-reported terminal + wheels keep the check, because SGR carries row and column. +- **Cancellation is PROACTIVE**, triggered by the key advancing, with a + daemon-side accepted-gesture latch. Reactive cancellation loses a + race: a replacement frame landing before the physical `Up` makes the + producer suppress the very event that would cancel. +- **The terminal key is NOT `Screen`'s generation** — that advances + from 39 sites including style, bell, tab-stops and cursor-only + motion, none of which change what a coordinate denotes. +- **G5's EFFECTS are owed by the rebased replay lane**, not provable + here: `gesture_last_content_cell` and replay exist only on + `panel-pointer-replay`. G5a — that the key advancing raises + cancellation — stays in this slice. - **Owns the version correction.** `docs/gui-stage1-input-framing.md` moves 1e's `OpenTarget` to **v26** here — an expected rebase conflict on `gui-stage1b-pointer-scroll` is not grounds for leaving diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 2e98924..6faaa04 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -1772,7 +1772,38 @@ cannot preserve the old panel-focused attach leak. ## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3) -**Status: revision 14 — AWAITING APPROVAL. Nothing implemented.** This +**Status: revision 15 — AWAITING APPROVAL. Nothing implemented.** +Revision 15 answers review of 14, and four of its items reverse +something 14 asserted: + +- **"Beside `Present`/`PanelPointer`" was positionally dangerous.** + Appended means **last**: `PresentMapped` after **`Absent`**, + `PanelPointerMapped` after **`TextInput`**. Exact field order, `u64`, + **zero invalid**, and `PANEL_MAPPING_MIN_VERSION` are now stated. +- **Blanket refusal stopped the wheel after one tick.** The first + effective wheel advances the key and the next queued tick carries the + old one. **Coordinate-free** gestures — document wheel, local + scrollback — are **exempt**; **child-reported** wheels keep the + check, because SGR carries row and column. Two-tick witnesses added. +- **Cancellation is now PROACTIVE and has a latch.** Reactive + cancellation loses a race: a replacement frame arriving before the + physical `Up` makes the producer suppress the very event that would + have cancelled. It triggers on the key advancing, and the daemon + keeps an accepted-gesture latch — was the `Down` accepted, did it + reach the child, with which coordinate, button and encoding. A stale + `Up` with no accepted `Down` is inert, and cancellation never + reclaims a controller another frontend took. +- **The existing screen generation cannot be the terminal key** — it + advances from 39 sites including style, bell, tab-stops and + cursor-only motion. A **dedicated terminal mapping revision** is + defined, with those events as **stable controls**. +- **G5's effects moved to the rebased replay lane**, which is the only + branch where they exist; **G5a stays here**, so the proactive rule + keeps a witness in the slice that introduces it. +- **The `PanelPointer` pin exists** (`src/protocol.rs:1975`) — revision + 14's claim that it could not be found was wrong. + +**Previously, revision 14 — SUPERSEDED.** This slice **blocks** panel-pointer replay (`panel-pointer-replay`), which blocks GUI arc 1b. It is protocol-bearing and runs alone. @@ -1818,11 +1849,34 @@ flatly.** A cursor move that triggers vertical or horizontal follow changes `view_top` or `view_left`, and therefore **does** change the generation. The stable case is a cursor move the follow rules absorb. -**Terminal panels are ruled explicitly.** What a coordinate denotes -there is decided by the terminal's **screen**, so output and scrollback -movement change the generation. Their **buffer revision** does not — a -terminal buffer's revision counter tracks something else, and keying on -it would both miss real changes and fire on non-changes. +**Terminal panels need their OWN mapping revision, and the existing +screen generation cannot supply it.** What a coordinate denotes there +is decided by the terminal's screen — so output and scrollback movement +change the mapping, while the buffer's revision counter tracks +something else entirely. + +But `Screen::changed()` is **not** that signal. +(`src/terminal/screen.rs:1467`) bumps a single generation from **39 +call sites**, including `SetStyle` (`:270`), `Bell` (`:287`), the +tab-stop operations (`:309`–`:319`), cursor-only motion (`:324`) and +`SetTitle` (`:449`). **None of those changes what a coordinate +denotes**, and keying on it would cancel a drag every time the child +recoloured a character or rang the bell. + +**Define a dedicated terminal mapping revision** over the things that +actually decide the inverse: + +- **projected cell identity** — the grid the panel paints, including + size; +- **retained-row identity** — scrollback rows entering or leaving the + projection; +- **the per-view scroll anchor**. + +**Stable controls are required, not optional**: style, title, bell, +tab-stop and cursor-only operations each get a row asserting the +revision **does not move**. Those are precisely the events the +convenient existing counter would have caught, so a reader who later +reaches for it fails a test instead of shipping a cancelled drag. **The stability half is load-bearing, not an optimisation.** A drag provokes selection repaints on every motion; a generation that moved @@ -1844,13 +1898,58 @@ that agree by inspection. so "what the frontend was shown" and "what the daemon checks" cannot drift. -### Wire shape — appended variants, never widened +### Wire shape — appended at the END, with the field order stated Postcard encodes enums **positionally**, so a shipped variant's field -list is frozen. Both messages gain **new variants**: +list is frozen **and so is every discriminant**. Revision 14 said +"beside `Present`" and "beside `PanelPointer`", which is **positionally +dangerous** — "beside" reads as *adjacent*, and inserting adjacent to +an existing variant shifts every discriminant below it. **Appended +means LAST.** -- `PanelFramePayload::PresentMapped { .. }` beside `Present`. -- `FrontendEvent::PanelPointerMapped { .. }` beside `PanelPointer`. +```text +PanelFramePayload FrontendEvent + 0 Present(PanelFrame) … + 1 Absent n-1 PanelPointer (v21) + 2 PresentMapped <- NEW n TextInput (v24) + n+1 PanelPointerMapped <- NEW +``` + +`PresentMapped` goes **after `Absent`**, not after `Present`; +`PanelPointerMapped` goes **after `TextInput`**, not after +`PanelPointer`. + +**Exact shapes**, field order frozen on landing: + +```rust +PanelFramePayload::PresentMapped { + frame: PanelFrame, // unchanged, reused whole + mapping_generation: u64, +} + +FrontendEvent::PanelPointerMapped { + frontend_id: FrontendId, // untrusted, as every inbound variant + geometry_epoch: u64, + panel_epoch: u64, + buffer_id: BufferId, + coord: CellCoord, + kind: MouseKind, + mods: Modifiers, + mapping_generation: u64, // appended last within the variant +} +``` + +`PanelPointerMapped` deliberately mirrors `PanelPointer`'s field order +with the generation **appended**, so the two are diffable by eye and a +future reader can see that nothing was reordered. + +- **`mapping_generation` is `u64`.** +- **Zero is INVALID** and is refused like a mismatch: it is the value a + default-constructed or partially-initialised sender produces, and + accepting it would let a peer opt out of the check by sending + nothing. A live key starts at 1. +- **`PANEL_MAPPING_MIN_VERSION = 25`**, beside the existing family + constants, and the gate reads that constant rather than a literal. `Absent` is unchanged and **common to both families** — hiding a band carries no mapping. @@ -1879,6 +1978,38 @@ On `PanelPointerMapped`, the daemon compares the echoed generation with the authoritative key and refuses the gesture before any mutation when they differ. +#### The wheel exception — or scrolling stops after one tick + +**A blanket refusal breaks the wheel, and revision 14's was blanket.** +The first effective document wheel changes `view_top`, which *is* a +mapping change, so the key advances. A second wheel event already +queued behind it carries the **old** generation and would be refused — +**the panel scrolls exactly one tick and then goes dead** until the +frontend observes the new frame. Local terminal scrollback has the same +shape, moving its view anchor. + +The discriminator is **whether the gesture uses its coordinate**: + +| gesture | uses `coord`? | generation check | +|---|---|---| +| document wheel | **no** — `scroll_window` is window-level | **EXEMPT** | +| terminal wheel, **local scrollback** (not reporting) | **no** — moves the view anchor | **EXEMPT** | +| terminal wheel, **child-reported** | **yes** — SGR carries row and column | **REQUIRED** | +| every press, drag, release, context gesture | yes | **REQUIRED** | + +**Coordinate-free gestures cannot be mis-aimed by a stale mapping**, +because they never invert a cell. Refusing them buys nothing and costs +the feature. A **child-reported** wheel is the opposite case: it puts a +row and column into the child's input stream, so a stale one aims an +application action at a cell the user never pointed at — the same +hazard as a stale click, and it keeps the check. + +**Witnesses: two ticks under ONE generation.** A document wheel and a +non-reporting terminal wheel must each scroll **twice** when the second +event carries the generation the first invalidated. Without a +two-tick row, a blanket-refusal implementation passes every +single-event row in the matrix. + **But a blanket drop breaks liveness, and revision 13's did.** A refused **tail** is not the same as a refused **beginning**: @@ -1888,20 +2019,53 @@ refused **tail** is not the same as a refused **beginning**: `Up` leaves an empty document selection armed with a stale anchor, and leaves a **reporting terminal child holding a button forever**. -**Cancellation is therefore ruled as a first-class outcome:** +**Cancellation is therefore ruled as a first-class outcome — and it is +PROACTIVE, driven by the key advancing, not reactive to a refused +event.** -1. **Producer:** the frontend resets its gesture latch — - `pointer_held`, `last_pointer_cell`, `gesture_last_content_cell` — - on the same signal, so no further `Drag` is manufactured. -2. **Daemon, document:** the panel's selection is cleared if empty, the - click chain is cleared, and no cursor move is applied. -3. **Daemon, terminal:** the child receives its **release** — a - reporting child must not be left holding a button because the - mapping moved — and the controller claim is settled. +Revision 14 made it reactive, and that has a race it cannot win: if the +replacement mapped frame reaches the frontend **before** the physical +button comes up, the producer resets `pointer_held`, **suppresses its +own `Up`**, and the daemon is never told anything — so the selection +stays armed and the child keeps holding its button. **The cancelling +event never arrives.** + +So the trigger is the authoritative key advancing while a gesture is +accepted, and the producer may not simply forget: + +1. **Daemon, on the key advancing with an accepted gesture live** — + this runs whether or not any further event arrives: + - **document:** clear an empty selection, clear the click chain, + apply no cursor move; + - **terminal:** deliver the child's **release**, at the last + coordinate known valid, with the button and encoding the accepted + `Down` used. +2. **Producer:** either **emit a cancellation tail before clearing**, + or **retain the latch** until the release has been sent. It may not + clear first and drop the `Up`, which is what revision 14 permitted. + +**The daemon needs an ACCEPTED-GESTURE LATCH** to do any of this, and +revision 14 assumed state that does not exist. Per frontend it records: + +- **whether a `Down` was ACCEPTED**, and for a terminal whether it + actually reached the child; +- **the last valid coordinate**, the **button**, and the **encoding** + the child was told — a release must match the press it terminates; +- enough identity to know the gesture is still the one that began. + +Two consequences fall out of the latch and are ruled here: + +- **A stale `Up` with no accepted `Down` is INERT.** It terminates + nothing, because nothing began; it must not synthesise a release or + clear another gesture's state. +- **Cancellation never reclaims a controller another frontend has since + taken.** The release settles *this* gesture; if ownership moved on, + the claim is not taken back. Reclaiming would make a stale gesture + steal a live one's terminal. **A cancelled gesture is not a replayed one**: the release is delivered -for liveness, at the last coordinate known to be valid, and no -selection or scroll effect is applied from the stale event. +for liveness, and no selection or scroll effect is applied from the +stale event. ### Motion must stay coalesced @@ -1924,35 +2088,84 @@ protecting. | **new**: exact `FrontendEvent::TextInput` bytes | the previous-final `FrontendEvent`, appended by 1a and never pinned | | **new**: complete nested bytes of `InstanceMessage::PanelFrame(PanelFramePayload::Absent)` | the previous-final `PanelFramePayload` variant | -**A note on what exists today.** The panel protocol suite round-trips -these shapes and asserts `Absent` differs from an empty `Present` -(`tests/bottom_panel_stage2b_protocol_acceptance.rs:196`), but I could -find **no exact-bytes pin** for `PanelPointer`, despite -`pmacs-protocol/src/message.rs:524` stating one is in the tests. Either -it is somewhere my search missed, or the doc overclaims — **this slice -resolves it either way**, since it must add exact-byte pins regardless. +**Correction: the `PanelPointer` pin DOES exist**, at +`src/protocol.rs:1975` +(`panel_pointer_encoding_is_unchanged_by_the_v24_build`), and revision +14 said it could not be found. It is in the **root crate's** test +module rather than under `pmacs-protocol/` or `tests/`, which is +exactly where the search did not look. `message.rs:524` was right and +the doubt was mine. The pin is **retained**, and the two new pins are +added beside it. ### Acceptance and mutation matrix | # | row | mutation | |---|---|---| | G1 | a **foreign** edit before the next render → the old generation is refused | never advance on content change → G1 passes a stale hit | -| G2 | every **changing** entry of the domain table moves the generation, one row each | omit that entry from the key | -| G3 | every **stable** entry leaves it unchanged, one row each | include that entry → drags cancel on repaint | +| G2 | every **changing** entry moves the key, one row each — and the composites are **split**: `view_top`; `view_left`; grid size; folds; **wrap** and **gutter geometry** separately; buffer content; terminal **projected content** and **scrollback movement** separately | omit that one entry from the key | +| G3 | every **stable** entry leaves it unchanged, one row each — focus; styling; selection-only; absorbed cursor motion; and the terminal controls **style, title, bell, tab-stop, cursor-only** | include that one entry → drags cancel on a repaint | | G4 | a **selection repaint** preserves the generation and an in-flight drag **continues** | as G3 | -| G5 | a mid-gesture mapping change **cancels**: latch reset, empty selection cleared, click chain cleared, **child receives its release** | drop the stale tail silently → the child holds the button and the selection stays armed | +| G5a | the key advancing with an accepted gesture live **raises cancellation** — the daemon acts without waiting for another event | make cancellation reactive to a refused event → nothing happens when the frame lands before the physical `Up` | +| G5b | a stale `Up` with **no accepted `Down`** is **inert** | synthesise a release anyway → an unpressed button is released | +| G5c | cancellation **does not reclaim** a controller another frontend has since taken | reclaim it → a stale gesture steals a live one's terminal | | G6 | **v24 positive control** — a legacy session drives a panel gesture end to end | gate ≤ v24 off → old peers lose the panel | -| G7 | **v25 positive control** — a mapped session drives one end to end | — | -| G8 | **wrong-family refusals, both directions** — bare `PanelPointer` from a v25 session is refused; legacy `Present` at a v25 frontend is rejected | accept either → the bypass returns | -| G9 | **identical cells, changed generation, still emitted** — motion dedupe is per cell, and a mapping change makes the same cell a different byte | dedupe across a generation change → the second gesture is suppressed | +| G7 | **v25 positive control** — a mapped session drives one end to end | withhold `PresentMapped` from a v25 peer → the mapped path never runs and every refusal row still passes | +| G8a | a **bare `PanelPointer` from a ≥ v25 session is REFUSED** | accept it → the bypass returns inbound | +| G8b | a **legacy `Present` at a ≥ v25 frontend is REJECTED** | paint it → the frontend hit-tests a band it cannot map | +| G9a | **daemon emission**: identical cells across a generation change are still **emitted** | dedupe daemon-side across the change → the second gesture is suppressed | +| G9b | **frontend motion dedupe** is unchanged within one generation, and re-arms across one | fold the generation into the motion dedupe → pixel-rate traffic returns, or the first post-change motion is eaten | | G10 | an **invalid** mapped frame retains the previous frame **and** its generation, atomically | update one without the other → a valid generation names a frame never shown | -| G11 | **generation exhaustion fails CLOSED** — refuse gestures rather than accept unchecked ones | fail open → the hole returns at the boundary | +| G11 | **generation exhaustion fails CLOSED, and does not leave a zombie band**: the daemon publishes **`Absent`**, clears input authority, **cancels any accepted gesture**, and **latches** exhaustion for the session | fail open → the hole returns at the boundary; refuse input only → a stale panel stays painted and permanently inert, with no signal to the user; omit the latch → the next frame resurrects it | G2 and G3 are enumerated per entry deliberately: one row asserting "the generation changed" cannot show **which** input moved it, and a key that ignores `view_left` passes every row that only scrolls vertically. +### What this slice can witness, and what the replay lane owes + +**G5's EFFECTS cannot be proven on this branch, and revision 14 claimed +them anyway.** Cancellation's observable outcomes — the producer latch, +the panel's selection, the child's release — are all reached through +**panel replay, which does not exist at this base**. +`gesture_last_content_cell` and the document/terminal replay live on +`panel-pointer-replay` (`pmacs-gpu/src/main.rs:2143` there); the same +struct here (`:2124`) has no such field, and +`dispatch_semantic_panel_pointer` still validates and returns. + +So the obligations split, and the split is stated rather than left to +whoever runs the tests: + +| owned by **this slice** | owed by the **rebased replay lane** | +|---|---| +| the wire shapes, positions and field order | G5's document effects — empty selection cleared, click chain cleared | +| the family gate, both directions (G6–G8) | G5's terminal effect — the child's release, with matching button and encoding | +| the authoritative key: what moves it, what does not (G1–G4) | G5b's inert stale `Up` | +| the wheel exemptions and their two-tick rows | G5c's controller non-reclaim | +| **G5a** — that the key advancing RAISES cancellation, observed as the daemon-side signal | the end-to-end effect witnesses for that signal | +| G9–G11 | — | + +**G5a stays here on purpose.** The trigger is this slice's rule; only +its consequences need replay. Moving the whole row out would leave the +proactive-cancellation ruling — the one that fixes revision 14's race — +with no witness in the slice that introduces it. + +### Coherence impact (`COHERENCE.md` §20) + +- **Journey steps: none change grade.** This hardens a step that + already works rather than opening one. +- **Interaction islands: none added.** It **hardens an existing panel + island** — the same gestures, refused when their mapping is stale. +- **Config registry: no entry.** Nothing here is tunable; a generation + is an identity, not a threshold. +- **Background work: none started**, and no existing work changes + attribution. The key advances synchronously with the mutations that + move it. + +Recorded because §20 asks for it per slice, and because "hardens an +existing island" is the kind of impact that gets omitted precisely +*because* the census does not move. + ### Consequence for the GUI arc This slice takes **v25**, so GUI arc **1e's `OpenTarget` moves to diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index 5bed92f..b76f0ff 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -570,8 +570,8 @@ and flushed to the socket**. **Bound: 250 ms.** | **floor** | **v24** | **v26**, after v25's mapping generation, serialized | | **encoding** | **appended variant**; never widen a field in place — postcard is positional | appended variants | | **byte pin** | frozen-byte fixture on the **previous final variant** | same | -| **gate** | daemon accepts from `>= 24`; producer withholds below | `>= 25`; producer withholds below | -| **old peer** | a `< 24` frontend **retains its existing `Key` behaviour and its existing limitations** — it truncates multi-scalar input today and ignores IME, and continues to. **The guarantee is NO REGRESSION, not retroactive correctness** | a `< 25` frontend cannot drop-open; nothing it already had degrades | +| **gate** | daemon accepts from `>= 24`; producer withholds below | **`>= 26`**; producer withholds below | +| **old peer** | a `< 24` frontend **retains its existing `Key` behaviour and its existing limitations** — it truncates multi-scalar input today and ignores IME, and continues to. **The guarantee is NO REGRESSION, not retroactive correctness** | a **`< 26`** frontend cannot drop-open; nothing it already had degrades | | **bounds** | **64 KiB** UTF-8; oversize **rejected** | **32 KiB** per raw path; non-empty path; absolute non-empty cwd; **embedded NUL rejected**; `Failed.message` capped at the **existing 4 KiB** error cap | | **pins** | frozen bytes on `FrontendEvent`'s previous final variant | **two independent pins** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for `OpenTargetResult` | From 137c7fc7368d3ac989c8cc12944373d7c400fed6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 09:44:01 +0200 Subject: [PATCH 03/35] docs(framing): SS5b revision 16 --- the producer rule was self-defeating, and R7 recurs on a docs-only diff Answers review of 15. Framing only. Three items reverse a rule 15 introduced, and one retracts a mutation that was not a defect. **THE PRODUCER RULE CONTRADICTED PROACTIVE CANCELLATION.** The daemon cancels BEFORE emitting the replacement frame, so the frontend needs only to clear its local latch when that frame arrives, and send nothing. Revision 15 asked it to emit a cancellation tail or retain the latch: the tail is redundant --- the daemon would receive a release for a gesture it has already settled, which is the duplicate release the latch exists to prevent --- and RETAINING IS ACTIVELY HARMFUL, because it manufactures a `Drag` under the NEW generation with no accepted `Down`. That is the exact orphan the section exists to prevent, produced by the rule meant to prevent it. Ordering is what makes the simple rule safe: cancel, then emit. The frame's arrival IS the cancellation signal; no second channel is needed. Witnessed as `Down` -> key advances -> replacement frame -> motion and physical `Up` produce no new drag and no duplicate release. **THE LATCH HAD ONE TRIGGER AND NEEDED FIVE.** Cancellation runs on every loss of gesture authority: generation advance, `Absent`, panel or buffer identity change, geometry-epoch change EVEN AT AN UNCHANGED CELL TOTAL, and detach. And an ordinary accepted `Up` must clear the latch, or a later invalidation finds a gesture it believes live and synthesises a duplicate release for a button already up --- the replay lane's D1/D2 orphan race, arriving from the daemon's side. **G9b's MUTATION WAS A VALID IMPLEMENTATION, NOT A DEFECT.** Keying the dedupe by `(mapping_generation, coord)` preserves same-generation suppression and naturally admits the first motion under a new generation. Requiring it to fail would have forbidden a correct design. Replaced with two real defects: compare only the cell and never key or reset by generation (the first post-change motion is eaten), and reset on every same-generation repaint (pixel-rate traffic returns). **"PROJECTED CELL IDENTITY" CONTRADICTED THE STYLING CONTROL** in the same section. The wire `Cell` derives `PartialEq` over `glyph`, STYLE and `attachment` (`pmacs-protocol/src/cell.rs:153`), so an identity keyed on cell equality moves on a pure recolour --- while the stable controls rule style out. Terminal identity is now glyph and row TOPOLOGY plus the view anchor, excluding face, style and cursor, with a same-glyph/different-style control: the row that catches an implementation reaching for `Cell` equality because it is right there. P2s: zero-generation rows added in BOTH directions as independent legs (a valid `PresentMapped` with generation zero must be rejected atomically; a zero-generation `PanelPointerMapped` must be refused); G7 split into outbound mapped-frame and inbound mapped-pointer legs, since its old mutation only withheld the frame; G2's grid rows/columns and fold-map-content/`fold_projection`-policy composites split; and SS20 now names journey steps 5 and 8 while stating neither grade changes --- an auditor scanning for grade movement alone would otherwise conclude this slice touches no journey. **AND R7 RECURRED, ON A DIFF THAT IS ENTIRELY DOCUMENTATION.** The first `--protocol` run of this tree failed the `gpu` step on `managed_retry_survives_transients_and_uses_the_successful_stream`, with all three required fragments verified from the durable log (`20260815T072601Z`). Recorded as R7's FIFTH occurrence. It carries the strongest tree exclusion the row has had: occurrences 1 and 4 argued "unrelated lane", while this branch cannot be related at all --- no Rust, no wire surface, no `pmacs-gpu` file. The line moved to `attach.rs:1728` from `:1680`, which the row already treats as occurrence-specific rather than a fragment. Isolated rerun green, and the full gate green on the re-run (271/271 in the `gpu` step) --- which per this file's rerun rule establishes INTERMITTENCE ONLY, though here there is no tree change to exonerate. What five occurrences across three flavors and five unrelated lanes now support is that the failure is NOT LANE-CORRELATED. That is evidence about where the cause is not. The retirement condition is unchanged. Gates: all eleven green under `env -u TMPDIR` with `--protocol`, log 20260815T073556Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 21 ++++-- docs/bottom-panel-framing.md | 122 +++++++++++++++++++++++++++++++---- docs/ci-red-signatures.md | 29 ++++++++- 3 files changed, 153 insertions(+), 19 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index b4a90f6..288c709 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,7 +281,7 @@ from #171 and #215. **`githubsucks/panel-mapping-generation` is the authoritative tip.** Recover with `git fetch githubsucks && git checkout panel-mapping-generation`. -- **No PR yet. Checkpoint: framing revision 15 (§5b) AWAITING +- **No PR yet. Checkpoint: framing revision 16 (§5b) AWAITING APPROVAL; NO IMPLEMENTATION.** - **PROTOCOL-BEARING — v25, and it runs alone.** `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**. @@ -327,10 +327,21 @@ from #171 and #215. otherwise the first tick advances the key and the next queued tick is refused, so the panel scrolls once and dies. Child-reported terminal wheels keep the check, because SGR carries row and column. -- **Cancellation is PROACTIVE**, triggered by the key advancing, with a - daemon-side accepted-gesture latch. Reactive cancellation loses a - race: a replacement frame landing before the physical `Up` makes the - producer suppress the very event that would cancel. +- **Cancellation is PROACTIVE** — the daemon cancels, THEN emits the + replacement frame, and the frontend **clears its latch on receiving + that frame and sends nothing**. Revision 15 asked the producer to + emit a tail or retain the latch; retaining it manufactures a `Drag` + under the new generation with no accepted `Down`. +- **The latch dies on EVERY loss of gesture authority**: generation + advance, `Absent`, panel/buffer identity change, geometry-epoch + change **even at an unchanged cell total**, and detach — **and on an + ordinary accepted `Up`**, or a later invalidation synthesises a + duplicate release. +- **Terminal identity EXCLUDES style.** Wire `Cell` equality covers + `glyph`, `style` and `attachment` + (`pmacs-protocol/src/cell.rs:153`), so keying on it would move the + revision on a recolour and contradict the stable control. It is + glyph/row topology plus the view anchor. - **The terminal key is NOT `Screen`'s generation** — that advances from 39 sites including style, bell, tab-stops and cursor-only motion, none of which change what a coordinate denotes. diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 6faaa04..1deee77 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -1772,7 +1772,35 @@ cannot preserve the old panel-focused attach leak. ## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3) -**Status: revision 15 — AWAITING APPROVAL. Nothing implemented.** +**Status: revision 16 — AWAITING APPROVAL. Nothing implemented.** +Revision 16 answers review of 15; three of its items reverse a rule 15 +introduced: + +- **The producer rule contradicted proactive cancellation.** Since the + daemon cancels *before* emitting the replacement frame, the frontend + simply **clears its latch on receiving that frame and sends + nothing**. 15's "emit a tail or retain the latch" was redundant in + the first case and **actively harmful** in the second — retaining it + manufactures a `Drag` under the new generation with no accepted + `Down`, the exact orphan the section exists to prevent. +- **The latch had one trigger and needed five.** Cancellation runs on + every loss of gesture authority: generation advance, `Absent`, panel + or buffer identity change, geometry-epoch change **even at an + unchanged cell total**, and detach. **An ordinary accepted `Up` must + clear the latch**, or a later invalidation synthesises a duplicate + release. +- **G9b's mutation was a valid implementation.** Keying dedupe by + `(mapping_generation, coord)` is a correct design, so requiring it to + fail would have forbidden a good one. Replaced with two real defects. +- **"Projected cell identity" contradicted the styling control** — wire + `Cell` equality includes `style`. Terminal identity is now glyph and + row topology plus the view anchor, **excluding face, style and + cursor**, with a same-glyph/different-style control. +- Zero-generation rows added **in both directions**; G7 split into + outbound and inbound legs; G2's grid and fold composites split; + journey steps **5** and **8** named in §20. + +**Previously, revision 15 — SUPERSEDED.** Revision 15 answers review of 14, and four of its items reverse something 14 asserted: @@ -1866,12 +1894,25 @@ recoloured a character or rang the bell. **Define a dedicated terminal mapping revision** over the things that actually decide the inverse: -- **projected cell identity** — the grid the panel paints, including - size; +- **glyph and row TOPOLOGY** — which glyphs occupy which cells, and + which rows the projection holds; - **retained-row identity** — scrollback rows entering or leaving the projection; - **the per-view scroll anchor**. +**Explicitly EXCLUDING face, style and cursor**, and revision 15's +"projected cell identity" got this wrong. The wire `Cell` derives +`PartialEq` over `glyph`, **`style`** and `attachment` +(`pmacs-protocol/src/cell.rs:153`), so an identity keyed on cell +equality **moves on a pure recolour** — which contradicts the very +stable control that rules style out, in the same section. The two +statements could not both hold. + +**Control row: same glyphs, different style** — the child repaints the +projection in a new colour and the revision **does not move**, so a +drag survives it. That is the row that catches an implementation +reaching for `Cell` equality because it is right there. + **Stable controls are required, not optional**: style, title, bell, tab-stop and cursor-only operations each get a row asserting the revision **does not move**. Those are precisely the events the @@ -2031,18 +2072,56 @@ stays armed and the child keeps holding its button. **The cancelling event never arrives.** So the trigger is the authoritative key advancing while a gesture is -accepted, and the producer may not simply forget: +accepted: -1. **Daemon, on the key advancing with an accepted gesture live** — +1. **Daemon, on losing gesture authority with a gesture accepted** — this runs whether or not any further event arrives: - **document:** clear an empty selection, clear the click chain, apply no cursor move; - **terminal:** deliver the child's **release**, at the last coordinate known valid, with the button and encoding the accepted `Down` used. -2. **Producer:** either **emit a cancellation tail before clearing**, - or **retain the latch** until the release has been sent. It may not - clear first and drop the `Up`, which is what revision 14 permitted. +2. **Producer: clear the local latch on receiving the replacement + frame, and send nothing.** The daemon has already cancelled, ahead + of emitting that frame, so the gesture is over before the frontend + hears about it. + +**Revision 15 asked the producer to emit a cancellation tail or retain +the latch. Both are wrong, and the second is actively harmful.** A tail +is redundant — the daemon cancelled first and would receive a release +for a gesture it has already settled, which is the duplicate release +the latch exists to prevent. **Retaining the latch is worse: it +manufactures a `Drag` under the NEW generation with no accepted +`Down`** — precisely the orphan this whole section is about, created by +the rule meant to avoid it. + +**Ordering is what makes the simple rule safe.** Cancel, then emit. The +frontend's arrival of the replacement frame *is* the cancellation +signal, so no second channel is needed. + +**Witness:** `Down` → the key advances → the replacement frame lands → +subsequent motion and the physical `Up` produce **no new drag and no +duplicate release**. + +##### The latch's full lifecycle + +**Cancellation runs on EVERY loss of gesture authority, not only a +mapping-generation advance.** Revision 15 named one trigger and left +the rest to be inferred: + +| trigger | why it ends the gesture | +|---|---| +| **mapping generation advances** | the cells mean different bytes | +| **`Absent`** | the band the gesture belongs to is gone | +| **panel or buffer identity change** | the successor never saw the press | +| **geometry-epoch change** | a new declaration, **even when the cell total is unchanged** — the frontend re-declared, so the grid it hit-tested against is not the one in force | +| **detach** | there is no frontend left to finish the gesture | + +**And an ordinary accepted `Up` MUST clear the latch.** Otherwise a +later invalidation finds a gesture it believes is still live and +**synthesises a duplicate release** for a button already up. That is +the same orphan race the replay lane's D1/D2 producer resets expose, +arriving from the daemon's side instead of the frontend's. **The daemon needs an ACCEPTED-GESTURE LATCH** to do any of this, and revision 14 assumed state that does not exist. Per frontend it records: @@ -2102,19 +2181,33 @@ added beside it. | # | row | mutation | |---|---|---| | G1 | a **foreign** edit before the next render → the old generation is refused | never advance on content change → G1 passes a stale hit | -| G2 | every **changing** entry moves the key, one row each — and the composites are **split**: `view_top`; `view_left`; grid size; folds; **wrap** and **gutter geometry** separately; buffer content; terminal **projected content** and **scrollback movement** separately | omit that one entry from the key | +| G2 | every **changing** entry moves the key, one row each — and the composites are **split**: `view_top`; `view_left`; grid **rows** and **columns** separately; **fold-map content** and the owning frontend's **`fold_projection` policy** separately; **wrap** and **gutter geometry** separately; buffer content; terminal **projected content** and **scrollback movement** separately | omit that one entry from the key | | G3 | every **stable** entry leaves it unchanged, one row each — focus; styling; selection-only; absorbed cursor motion; and the terminal controls **style, title, bell, tab-stop, cursor-only** | include that one entry → drags cancel on a repaint | | G4 | a **selection repaint** preserves the generation and an in-flight drag **continues** | as G3 | | G5a | the key advancing with an accepted gesture live **raises cancellation** — the daemon acts without waiting for another event | make cancellation reactive to a refused event → nothing happens when the frame lands before the physical `Up` | | G5b | a stale `Up` with **no accepted `Down`** is **inert** | synthesise a release anyway → an unpressed button is released | | G5c | cancellation **does not reclaim** a controller another frontend has since taken | reclaim it → a stale gesture steals a live one's terminal | | G6 | **v24 positive control** — a legacy session drives a panel gesture end to end | gate ≤ v24 off → old peers lose the panel | -| G7 | **v25 positive control** — a mapped session drives one end to end | withhold `PresentMapped` from a v25 peer → the mapped path never runs and every refusal row still passes | +| G7a | **v25 positive control, OUTBOUND** — a v25 peer receives `PresentMapped`, carrying a live generation | withhold `PresentMapped` from a v25 peer → the mapped frame never reaches it | +| G7b | **v25 positive control, INBOUND** — a `PanelPointerMapped` with the current generation is **accepted and takes effect** | ignore the mapped variant inbound → every refusal row still passes while nothing works | | G8a | a **bare `PanelPointer` from a ≥ v25 session is REFUSED** | accept it → the bypass returns inbound | | G8b | a **legacy `Present` at a ≥ v25 frontend is REJECTED** | paint it → the frontend hit-tests a band it cannot map | | G9a | **daemon emission**: identical cells across a generation change are still **emitted** | dedupe daemon-side across the change → the second gesture is suppressed | -| G9b | **frontend motion dedupe** is unchanged within one generation, and re-arms across one | fold the generation into the motion dedupe → pixel-rate traffic returns, or the first post-change motion is eaten | +| G9b | **frontend motion dedupe** suppresses a repeat within one generation and **re-arms across one** | (a) compare **only the cell**, never keying or resetting by generation → the first post-change motion is **eaten**; (b) reset on **every same-generation repaint** → repeated same-cell motion **returns** as pixel-rate traffic | + +**G9b's mutation in revision 15 was a valid implementation, not a +defect.** Keying the dedupe by `(mapping_generation, coord)` preserves +same-generation suppression *and* naturally admits the first motion +under a new generation — it is one correct way to satisfy the row, so +requiring it to fail would have forbidden a good design. The two +mutations above are actual defects, one in each direction. | G10 | an **invalid** mapped frame retains the previous frame **and** its generation, atomically | update one without the other → a valid generation names a frame never shown | +| G10a | a **structurally valid `PresentMapped` carrying generation ZERO is REJECTED**, atomically — previous frame and generation both retained | accept zero → a sender that never initialised the field disables the check for the whole session | +| G10b | a **`PanelPointerMapped` carrying generation ZERO is REFUSED** before mutation | accept zero → the inbound half of the same opt-out | + +G10a and G10b are **independent rows in opposite directions**, not one +row seen twice: zero can arrive from either peer, and G10's atomicity +row exercises neither. | G11 | **generation exhaustion fails CLOSED, and does not leave a zombie band**: the daemon publishes **`Absent`**, clears input authority, **cancels any accepted gesture**, and **latches** exhaustion for the session | fail open → the hole returns at the boundary; refuse input only → a stale panel stays painted and permanently inert, with no signal to the user; omit the latch → the next frame resurrects it | G2 and G3 are enumerated per entry deliberately: one row asserting "the @@ -2152,8 +2245,11 @@ with no witness in the slice that introduces it. ### Coherence impact (`COHERENCE.md` §20) -- **Journey steps: none change grade.** This hardens a step that - already works rather than opening one. +- **Journey steps touched: 5** (document-panel editing and selection) + and **8** (terminal-panel interaction). **Neither changes grade** — + this hardens steps that already work rather than opening one. + Naming them matters even so: a reader auditing §20 by grade movement + alone would conclude this slice touches no journey at all. - **Interaction islands: none added.** It **hardens an existing panel island** — the same gestures, refused when their mapping is stale. - **Config registry: no entry.** Nothing here is tunable; a generation diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index cdc4818..4177b75 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -496,12 +496,39 @@ Stage 4; the lane touches no `pmacs-gpu` code at all. | **selector** | `-p pmacs-gpu attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` | | **job / flavor** | local (Linux), `cargo test --workspace --features crdt --no-fail-fast`, i.e. under full-sweep load | | **required fragments** | `transient sequence must attach` + `Handshake(Io(` + `BrokenPipe` (or `code: 32`) | -| **status** | **FOURTH OCCURRENCE 2026-08-13 — causal status still UNRESOLVED. A NEW candidate mechanism is introduced by the observing lane and is NOT excluded (see below)** | +| **status** | **FIFTH OCCURRENCE 2026-08-15 — causal status still UNRESOLVED.** The fifth carries the strongest tree exclusion this row has had: a **documentation-only diff** | | **what IS established** | **three** occurrences at `pmacs-gpu/src/attach.rs:1680`, the second and third with all three fragments **verified** rather than inferred; the test drives a scripted transient-then-success sequence over a real socket pair. **The added GPU test is not the mechanism** — see the third-occurrence control below | | **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** | | **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. Occurrence 3: 5 isolated selector runs green, 10 full `-p pmacs-gpu` runs green **with** the added test, and **1 failure in 10 with the added test `#[ignore]`d** — the first rerun in this row's history that reproduced anything. Per the rerun rule the green runs establish intermittence only; the red control run is what carries the exclusion | | **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe | +**Fifth occurrence — panel cell-mapping generation (§5b) framing, +2026-08-15, local (Linux).** The `scripts/gate` **`gpu` step** again, +the same flavor as occurrence 2, inside a `--protocol` run +(log `20260815T072601Z-2230169`). + +* **All three fragments verified** from the durable log, not a filtered + stream: `transient sequence must attach: Attach(Handshake(Io(Os { + code: 32, kind: BrokenPipe, message: "Broken pipe" })))`. +* **The line moved and that is not a fragment.** It is + `pmacs-gpu/src/attach.rs:1728` here against `:1680` in the earlier + occurrences — `attach.rs` has changed since, and this row's + convention already treats a `:LINE` suffix as occurrence-specific. +* **The tree exclusion is the strongest available in this row's + history: the branch's entire diff is DOCUMENTATION.** No Rust, no + wire surface, no `pmacs-gpu` file. Occurrences 1 and 4 argued + "unrelated lane"; this one cannot be related at all. +* **Rerun: isolated selector green** (`1 passed`, 0.01 s). Per this + file's rerun rule that establishes **intermittence only** and does + not exonerate the tree — though here there is no tree change to + exonerate. + +**What five occurrences now support, stated carefully:** the failure is +**not lane-correlated**. It has appeared under three flavors across +five unrelated lanes, once on a diff that touches no code whatsoever. +That is evidence about *where the cause is not*, and still says nothing +about what it is. **The retirement condition is unchanged.** + **Not attributed to the observing lane**, and in neither case is the reasoning merely "my diff looks unrelated": long-lines Stage 4 added no wire surface, no protocol version change, and touched no file in From 775a046ce48669f9b74151103bcc7d12c1a27d3a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 11:36:24 +0200 Subject: [PATCH 04/35] docs(framing): fold the mapping-generation closure audit into revision 16 Close the producer, receiver, protocol-family, and gesture-lifecycle cross-products in one pass. Separate route witnesses from replay effects, freeze both old boundaries and new variant fields, and record the bounded review method for the remaining chain. --- docs/active-work.md | 94 ++++++++-- docs/bottom-panel-framing.md | 349 ++++++++++++++++++++++++++++------- 2 files changed, 357 insertions(+), 86 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 288c709..dbd2571 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,8 +281,8 @@ from #171 and #215. **`githubsucks/panel-mapping-generation` is the authoritative tip.** Recover with `git fetch githubsucks && git checkout panel-mapping-generation`. -- **No PR yet. Checkpoint: framing revision 16 (§5b) AWAITING - APPROVAL; NO IMPLEMENTATION.** +- **No PR yet. Checkpoint: framing revision 16 (§5b) APPROVED + 2026-08-15; NO IMPLEMENTATION.** - **PROTOCOL-BEARING — v25, and it runs alone.** `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**. - **Why it exists.** A `PanelPointer` names a cell and nothing on the @@ -294,6 +294,10 @@ from #171 and #215. **overruled**, because a **foreign** edit moves the mapping with `view_top` untouched, the error is unbounded, and the window lasts until the frontend **presents** the new frame. +- **Semantic seam only.** The TUI hit-tests current daemon state + directly, receives no `PanelFrame`, and carries no mapping token. Its + existing panel click/drag/wheel effects are a structural control + against moving the generation gate into shared replay. - **A generation, not a per-frame token.** A token moving with the frame would cancel a drag on the next repaint — the mistake `panel_epoch` is stable to avoid. The key moves with the inverse @@ -308,35 +312,61 @@ from #171 and #215. - **Gating is REFUSAL, not fallback.** A ≥ v25 session sending bare `PanelPointer` is refused; a ≥ v25 frontend rejects legacy `Present`. Only ≤ v24 sessions keep legacy semantics; `Absent` is - common. + common. The reciprocal wrong-family cases are witnessed too: a v24 + daemon refuses `PanelPointerMapped`, and a v24 frontend rejects + `PresentMapped`. Compiling the variant in the same crate never + overrides the negotiated family. - **Stale tails TERMINATE, they do not vanish.** A dropped `Up` leaves an empty selection armed and a reporting child holding a button forever, so cancellation is a ruled outcome: producer latch reset, - daemon selection/click-chain cleanup, and the child's release - delivered. + daemon selection cleanup, and the child's release delivered. + Click-chain invalidation is a separate mapping-identity transition; + it also runs when no gesture is held. - **Pins ACCUMULATE**: keep `PanelPointer`, add exact `TextInput` bytes (previous-final `FrontendEvent`) and the complete nested - `PanelFrame(Absent)` bytes (previous-final `PanelFramePayload`). + `PanelFrame(Absent)` bytes (previous-final `PanelFramePayload`), then + add exact encode/decode pins for both new mapped variants with + distinct same-typed field values. Previous-final pins protect append + position; new-variant pins protect their own frozen field order. **The `PanelPointer` pin DOES exist** at `src/protocol.rs:1975` — revision 14 recorded it as missing, which was wrong: it lives in the root crate's test module, not under `pmacs-protocol/` or `tests/`. - **Appended means LAST**: `PresentMapped` after `Absent`, `PanelPointerMapped` after `TextInput`. `mapping_generation` is a `u64`, **zero invalid**, gated by `PANEL_MAPPING_MIN_VERSION = 25`. +- **Frontend generation is a session high-water mark.** Higher values + may skip, equal-generation style/selection repaints are valid, lower + values are rejected atomically, and `Absent` does not erase the + high-water mark. Detach does. - **Coordinate-free wheels are EXEMPT** from the freshness check — otherwise the first tick advances the key and the next queued tick is refused, so the panel scrolls once and dies. Child-reported terminal wheels keep the check, because SGR carries row and column. -- **Cancellation is PROACTIVE** — the daemon cancels, THEN emits the - replacement frame, and the frontend **clears its latch on receiving - that frame and sends nothing**. Revision 15 asked the producer to - emit a tail or retain the latch; retaining it manufactures a `Drag` - under the new generation with no accepted `Down`. -- **The latch dies on EVERY loss of gesture authority**: generation - advance, `Absent`, panel/buffer identity change, geometry-epoch - change **even at an unchanged cell total**, and detach — **and on an - ordinary accepted `Up`**, or a later invalidation synthesises a - duplicate release. +- **Cancellation is PROACTIVE**, at the transition that revokes + authority: mapped-generation advance before its frame, before + daemon-authored `Absent` or panel/buffer replacement, when a new + geometry declaration is accepted, and at detach. The last four apply + to both v24 legacy and v25 mapped gestures. It is taken exactly once; + no producer cancellation tail is sent. +- **Producer reset has cause-specific signals**, not one generic + replacement frame: accept the valid generation/identity frame; + accept `Absent`; locally advance the geometry declaration; or tear + down the frontend. Invalid frames and same-identity repaints retain + the latch. +- **The accepted-gesture latch is left-button-only and common to both + wire families.** It arms only after accepted `Down(Left)` and dies on + accepted `Up(Left)` or any authority loss. Move, wheel, context, and + right/middle presses do not arm it; otherwise the GPU's press-only + right path manufactures a later release. A terminal Down also fixes + child-reporting versus local-selection domain through Drag/Up, so a + later Shift or reporting-mode change cannot orphan or invent a child + release. +- **Click-chain state is independent and per frontend.** It survives + an ordinary `Up` by design, but every mapping identity change clears + or re-keys it even when no gesture is held. A completed click followed + by a foreign edit and same-cell click is single, not double; an + intervening click from another frontend does not erase the first + frontend's legitimate chain. - **Terminal identity EXCLUDES style.** Wire `Cell` equality covers `glyph`, `style` and `attachment` (`pmacs-protocol/src/cell.rs:153`), so keying on it would move the @@ -345,10 +375,34 @@ from #171 and #215. - **The terminal key is NOT `Screen`'s generation** — that advances from 39 sites including style, bell, tab-stops and cursor-only motion, none of which change what a coordinate denotes. -- **G5's EFFECTS are owed by the rebased replay lane**, not provable - here: `gesture_last_content_cell` and replay exist only on - `panel-pointer-replay`. G5a — that the key advancing raises - cancellation — stays in this slice. +- **G5 is now an explicit transition matrix.** It crosses every common + authority loss with v24 and v25, distinguishes each producer reset + signal from invalid/same-identity controls, pins ordinary-Up + exact-once and left-only arming, and tests click identity without a + live gesture. This slice owns only the new mapping-generation signal + and mapped-frame state; the rebased replay lane owns the common + invalidations and every document, terminal, complete producer-latch + and click-chain effect through real call sites. No dead + classification helper is added on a base without replay. +- **Routing and effects are separate controls.** This slice can prove + v24/v25 events reach the existing focus path, but selection, + terminal reporting, stable-generation drag continuation, two-tick + wheels and exhaustion cancellation remain explicit replay-lane rows; + no classification-only result is reported as end-to-end input. +- **Review-process correction — carry into the next handoff + absorption.** Revision 16 replaces finding-at-a-time review with one + closure pass for this chain and future framings: verify cited ground + truth; enumerate trigger × protocol family × target × lifecycle; + separate producer, route, receiver and effect; prove every witness's + discriminating setup and positive control; check that each proposed + mutation is actually defective; and assign every row to the branch + that can reach its real call site. Protocol work also checks all four + wrong-family quadrants, exact pins and version fallout. Findings are + returned as one batch. If three revisions do not close a framing, + stop serial review and perform/fold this complete audit rather than + opening another one-finding round. The overdue #239/#240 handoff + absorption must promote this rule; this feature branch does not edit + the deliberately lagging canonical handoff. - **Owns the version correction.** `docs/gui-stage1-input-framing.md` moves 1e's `OpenTarget` to **v26** here — an expected rebase conflict on `gui-stage1b-pointer-scroll` is not grounds for leaving diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 1deee77..fd5349d 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -1772,23 +1772,49 @@ cannot preserve the old panel-focused attach leak. ## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3) -**Status: revision 16 — AWAITING APPROVAL. Nothing implemented.** +**Status: revision 16 — APPROVED 2026-08-15. Nothing implemented.** Revision 16 answers review of 15; three of its items reverse a rule 15 introduced: -- **The producer rule contradicted proactive cancellation.** Since the - daemon cancels *before* emitting the replacement frame, the frontend - simply **clears its latch on receiving that frame and sends - nothing**. 15's "emit a tail or retain the latch" was redundant in - the first case and **actively harmful** in the second — retaining it - manufactures a `Drag` under the new generation with no accepted - `Down`, the exact orphan the section exists to prevent. +- **The mapped-generation producer rule contradicted proactive + cancellation.** Since the daemon cancels *before* emitting that + successor frame, the frontend simply **clears its latch on accepting + the frame and sends nothing**. 15's "emit a tail or retain the + latch" was redundant in the first case and **actively harmful** in + the second — retaining it manufactures a `Drag` under the new + generation with no accepted `Down`, the exact orphan the section + exists to prevent. Other authority losses have their own reset + signals below. - **The latch had one trigger and needed five.** Cancellation runs on every loss of gesture authority: generation advance, `Absent`, panel - or buffer identity change, geometry-epoch change **even at an - unchanged cell total**, and detach. **An ordinary accepted `Up` must - clear the latch**, or a later invalidation synthesises a duplicate - release. + epoch or buffer identity change, geometry-epoch change **even at an + unchanged cell total**, and detach. The last four apply to the legacy + and mapped families alike; generation advance exists only in the + mapped family. **An ordinary accepted `Up(Left)` must clear the + latch**, or a later invalidation synthesises a duplicate release. +- **"The replacement frame" was not one producer signal.** A mapped + generation or panel/buffer replacement clears on atomic acceptance of + its valid frame; `Absent` clears when accepted; a local geometry + declaration and detach clear at their own transition, before any + replacement frame can be required. Invalid frame payloads and + same-identity repaints retain the latch. +- **Gesture liveness and click counting are different state.** The + accepted-gesture latch covers an accepted `Down(Left)` only and is + consumed by `Up(Left)` or loss of authority. `MouseClickState` + deliberately survives an ordinary `Up` for multi-click detection, + but its mapping identity must be cleared or re-keyed on every mapping + invalidation even when no gesture is held. +- **Routing controls had been written as effects.** This base can prove + legacy and mapped events reach the already-landed focus path; it + cannot prove selection, terminal reporting, drag continuation, + two-tick scrolling, or exhaustion cleanup before replay exists. + Those controls are split, and the effect halves are obligations of + the rebased replay lane rather than claims made by this slice. +- **Bilateral gating needed all four wrong-family legs.** The v25 + refusal rows did not constrain a v24 session sending or accepting the + mapped variants. Same-crate peers can encode them regardless of what + they negotiated, so both inbound and outbound are now witnessed at + v24 and v25, with exact-family positive controls. - **G9b's mutation was a valid implementation.** Keying dedupe by `(mapping_generation, coord)` is a correct design, so requiring it to fail would have forbidden a good one. Replaced with two real defects. @@ -1799,6 +1825,13 @@ introduced: - Zero-generation rows added **in both directions**; G7 split into outbound and inbound legs; G2's grid and fold composites split; journey steps **5** and **8** named in §20. +- **The folded closure pass made the remaining implicit contracts + executable in one matrix:** authenticated-session family authority; + a frontend generation high-water mark surviving `Absent`; exact pins + for the new variants as well as their previous finals; left-only and + fixed-domain terminal gestures; exact-once coincident invalidations; + independent click identity; the semantic/TUI boundary; and explicit + ownership of every route versus effect row (G0–G15). **Previously, revision 15 — SUPERSEDED.** Revision 15 answers review of 14, and four of its items reverse @@ -1848,6 +1881,15 @@ This is the one hole in the ladder: `buffer_id` catches replacement, declaration race, and **nothing catches "the text under that cell changed"**. +The hole exists only across the **semantic frontend's asynchronous +projection/inbound seam**. The TUI hit-tests and dispatches against its +current daemon state in one process; it neither receives `PanelFrame` +nor echoes a mapping generation. This slice adds no TUI token, gate or +gesture state, and the existing direct TUI panel effects remain a +regression control. Applying the mapped-family check inside the shared +replay body instead of at the semantic-session boundary would break +that control. + **Revision 12 accepted this as narrow. It is not.** A **foreign** edit moves the mapping with `view_top` untouched; ticks, paging, folds, edits and reloads accumulate without bound; and the window lasts until @@ -1989,6 +2031,14 @@ future reader can see that nothing was reordered. default-constructed or partially-initialised sender produces, and accepting it would let a peer opt out of the check by sending nothing. A live key starts at 1. +- It is **monotonic for the authenticated frontend session**. A + frontend accepts a valid generation above its retained high-water + mark (gaps are fine), and accepts an equal-generation repaint because + styling, focus and selection may legitimately change while the + mapping holds. It rejects a lower generation atomically. `Absent` + clears the presentation and pointer state but **retains the + high-water mark**; otherwise a delayed pre-`Absent` frame can roll + authority backward. Detach ends the session and its high-water mark. - **`PANEL_MAPPING_MIN_VERSION = 25`**, beside the existing family constants, and the gate reads that constant rather than a literal. @@ -2006,6 +2056,14 @@ slice exists to close, reachable by omitting a field. | **≤ v24** | `Present` | `PanelPointer` | `PanelPointer` | `Present` | | **≥ v25** | `PresentMapped` | `PanelPointerMapped` **only** | `PanelPointerMapped` | `PresentMapped` **only** | +Both choices use the **authenticated session's negotiated version**. +Inbound code never derives the family gate from the payload's claimed +`frontend_id`; like every inbound variant, that field is untrusted and +is validated separately. Outbound projection uses the destination +session's negotiated version, not the crate's compile-time ability to +encode either discriminant. The family gate runs before focus, +controller, click-chain, gesture-latch or document mutation. + - A **≥ v25 session sending bare `PanelPointer` is REFUSED**, dropped before any mutation, exactly as an out-of-epoch event is. - A **≥ v25 frontend receiving legacy `Present` REJECTS it** rather @@ -2045,6 +2103,13 @@ row and column into the child's input stream, so a stale one aims an application action at a cell the user never pointed at — the same hazard as a stale click, and it keeps the check. +The exemption skips **only generation equality**. It does not bypass +the authenticated family gate, the nonzero-generation requirement, +frontend/panel/geometry/buffer identity, coordinate bounds, or target +resolution. In particular, a zero-generation coordinate-free wheel is +still refused; zero means "no mapping identity was supplied", not "one +was supplied and has since gone stale". + **Witnesses: two ticks under ONE generation.** A document wheel and a non-reporting terminal wheel must each scroll **twice** when the second event carries the generation the first invalidated. Without a @@ -2071,20 +2136,37 @@ own `Up`**, and the daemon is never told anything — so the selection stays armed and the child keeps holding its button. **The cancelling event never arrives.** -So the trigger is the authoritative key advancing while a gesture is -accepted: +So the daemon cancels at the transition that revokes authority, not at +the next pointer event: -1. **Daemon, on losing gesture authority with a gesture accepted** — - this runs whether or not any further event arrives: - - **document:** clear an empty selection, clear the click chain, - apply no cursor move; - - **terminal:** deliver the child's **release**, at the last - coordinate known valid, with the button and encoding the accepted - `Down` used. -2. **Producer: clear the local latch on receiving the replacement - frame, and send nothing.** The daemon has already cancelled, ahead - of emitting that frame, so the gesture is over before the frontend - hears about it. +| authority loss | daemon cancellation point | protocol families | +|---|---|---| +| mapping generation advances | after the mapping mutation and **before** publishing the next `PresentMapped` | mapped, ≥ v25 only | +| `Absent` | **before** publishing `Absent` | legacy and mapped | +| panel epoch or buffer identity changes | before publishing the successor `Present`/`PresentMapped` | legacy and mapped | +| geometry epoch changes | when the new frontend geometry declaration is accepted, even when its rows and columns equal the old declaration | legacy and mapped | +| detach | in that frontend's teardown, before its pointer state is discarded | legacy and mapped | + +If an accepted left-button gesture is live, cancellation is taken +**exactly once**: + +- **document:** clear an empty selection, preserve an already-nonempty + region, and apply no cursor move; +- **terminal:** deliver the child's **release**, at the last coordinate + known valid, with the left button and encoding the accepted press + used. + +The click chain is invalidated at the same authority transitions but is +**not conditional on a live gesture**; its lifetime is specified +separately below. + +Authority losses may coincide. A changed-size geometry declaration, for +example, advances both `geometry_epoch` and the mapping generation; +buffer/panel replacement can also change the projected mapping. All +causes take the **same** accepted-gesture latch, so the first +cancellation settles it and later causes observe no live gesture. +Click-chain invalidation is likewise idempotent. Trigger ordering may +not create two releases or two selection cleanups. **Revision 15 asked the producer to emit a cancellation tail or retain the latch. Both are wrong, and the second is actively harmful.** A tail @@ -2095,13 +2177,31 @@ manufactures a `Drag` under the NEW generation with no accepted `Down`** — precisely the orphan this whole section is about, created by the rule meant to avoid it. -**Ordering is what makes the simple rule safe.** Cancel, then emit. The -frontend's arrival of the replacement frame *is* the cancellation -signal, so no second channel is needed. +**There is no single producer-side "replacement frame" transition.** +The producer clears its local held-gesture state at the first +authoritative signal available for the cause: -**Witness:** `Down` → the key advances → the replacement frame lands → -subsequent motion and the physical `Up` produce **no new drag and no -duplicate release**. +| cause | producer reset point | +|---|---| +| mapped generation changes | atomically accepting the valid successor `PresentMapped` | +| panel epoch or buffer identity changes | atomically accepting the valid successor `Present`/`PresentMapped` | +| `Absent` | accepting `Absent` | +| geometry epoch changes | locally, when `next_geometry_declaration` advances the epoch (`pmacs-gpu/src/main.rs:6847`), **before** sending the declaration | +| detach | frontend teardown | + +An invalid frame and a repaint with the same generation and identities +**do not clear** the producer latch. A local geometry declaration is +different: advancing the local epoch has already revoked the old grid, +so the producer clears before the daemon can accept or refuse the +declaration. For the daemon-authored frame cases, ordering is cancel +then emit; the frame's arrival is the producer's signal, so no second +channel is needed. For geometry and detach, the local transition is +itself the signal and no replacement frame is required. + +After any reset, subsequent motion and the physical `Up` produce **no +new drag and no duplicate release**. The producer never emits a +cancellation tail and never carries the held state into the successor +identity. ##### The latch's full lifecycle @@ -2113,7 +2213,7 @@ the rest to be inferred: |---|---| | **mapping generation advances** | the cells mean different bytes | | **`Absent`** | the band the gesture belongs to is gone | -| **panel or buffer identity change** | the successor never saw the press | +| **panel epoch or buffer identity change** | the successor never saw the press | | **geometry-epoch change** | a new declaration, **even when the cell total is unchanged** — the frontend re-declared, so the grid it hit-tested against is not the one in force | | **detach** | there is no frontend left to finish the gesture | @@ -2124,14 +2224,40 @@ the same orphan race the replay lane's D1/D2 producer resets expose, arriving from the daemon's side instead of the frontend's. **The daemon needs an ACCEPTED-GESTURE LATCH** to do any of this, and -revision 14 assumed state that does not exist. Per frontend it records: +revision 14 assumed state that does not exist. The latch is common to +legacy `PanelPointer` and mapped `PanelPointerMapped`, because four of +the five authority losses are common. Per frontend it records: -- **whether a `Down` was ACCEPTED**, and for a terminal whether it - actually reached the child; -- **the last valid coordinate**, the **button**, and the **encoding** - the child was told — a release must match the press it terminates; +- whether a **`Down(Left)` was ACCEPTED**, and for a terminal whether + it actually reached the child; +- the last valid coordinate and the encoding the child was told — a + release must match the press it terminates; - enough identity to know the gesture is still the one that began. +It arms only **after** `Down(Left)` is accepted, updates its last valid +coordinate on an accepted drag, and is taken by either an ordinary +accepted `Up(Left)` or one authority-loss transition. `Move`, wheel, +context, and right/middle press events never arm it. `MouseKind` has +right and middle `Down` variants (`pmacs-protocol/src/message.rs:220`), +and the GPU panel path currently emits `Down(Right)` from the +press-only `apply_right_press` route +(`pmacs-gpu/src/main.rs:2997`, `:3029`). Treating every `Down` as held +would therefore manufacture a delayed release at the next +invalidation. If right/middle release semantics are added, they need +their own complete producer/replay contract rather than silently +borrowing this left-drag latch. + +For a terminal, the accepted `Down(Left)` also fixes the gesture's +**domain** until it ends: child-reported, or local terminal selection. +Shift precedence and the child's reporting mode decide the domain at +the beginning; later modifier or mode changes do not switch a live +gesture into a path that never saw its Down. Thus a child-reported +gesture sends Drag/Up using the recorded reporting contract and exact +release encoding even if reporting turns off or Shift is pressed, and +a locally-started gesture stays local even if Shift is released or +reporting turns on. This does not freeze wheel precedence: a wheel is +self-contained and never arms the gesture latch. + Two consequences fall out of the latch and are ruled here: - **A stale `Up` with no accepted `Down` is INERT.** It terminates @@ -2142,10 +2268,53 @@ Two consequences fall out of the latch and are ruled here: the claim is not taken back. Reclaiming would make a stale gesture steal a live one's terminal. +Detach has an additional ordering constraint: if the departing +frontend still owns the controller, its child's matching release is +delivered **before** normal detach teardown releases that claim and +discards the gesture record. If another frontend owns it, the release +still settles the recorded gesture without changing that newer claim. +Clearing controller/gesture state first would make the teardown path +look clean while leaving the child button down. + **A cancelled gesture is not a replayed one**: the release is delivered for liveness, and no selection or scroll effect is applied from the stale event. +##### Click-chain identity is independent + +`MouseClickState` (`src/editor.rs:352`) is **not** the +accepted-gesture latch. An ordinary +`Up(Left)` consumes the gesture latch, while click state intentionally +survives long enough to recognize a second click. Consequently, only +clearing click state from the cancellation path is insufficient: after +a completed click there may be no live gesture to cancel. + +The click chain therefore carries, or is cleared against, the complete +mapping identity: frontend, target window and buffer, panel epoch, +geometry epoch, and — for mapped sessions — mapping generation. Every +authority transition in the table above invalidates it independently +of the gesture latch. A completed click followed by a foreign edit and +the successor mapped frame, then a `Down` at the same cell inside the +multi-click interval, is a **single** click under the new mapping, not +a double-click on unrelated text. The same rule holds for the common +legacy identity transitions even though v24 has no mapping generation. + +This extends rather than replaces Q#M5 and the existing TUI event +lifetime: only an **unmodified `Down(Left)`** writes click state; +Shift-Down never enters the double-click chain. Drag, Move, wheel and +right/context gestures clear it, while ordinary `Up(Left)` and a +same-identity repaint preserve it. Mapping invalidation is an +additional clear, not permission to couple click state to every frame +or to the accepted-gesture latch. + +Semantic click chains are **keyed per frontend**, then carry the target +identity above. Merely storing `frontend_id` inside one global slot +prevents a false cross-frontend double-click but lets B's click erase +A's legitimate chain. The adjacent `window_drag` comment already names +that exact defect and uses a `HashMap` because +concurrent frontend gestures are legal (`src/editor.rs:298`). Detach +removes only the departing frontend's entry. + ### Motion must stay coalesced `PanelPointerMapped` carrying `Move` or `Drag` **takes the same @@ -2166,6 +2335,8 @@ protecting. | existing `FrontendEvent::PanelPointer` | retained, unchanged | | **new**: exact `FrontendEvent::TextInput` bytes | the previous-final `FrontendEvent`, appended by 1a and never pinned | | **new**: complete nested bytes of `InstanceMessage::PanelFrame(PanelFramePayload::Absent)` | the previous-final `PanelFramePayload` variant | +| **new**: exact `FrontendEvent::PanelPointerMapped` bytes, encoded **and decoded**, with distinct values in every same-typed field | the new discriminant and its frozen field order; a same-type field swap still round-trips against itself, so round-trip alone is insufficient | +| **new**: complete nested bytes of `InstanceMessage::PanelFrame(PanelFramePayload::PresentMapped { .. })`, encoded **and decoded** | the new payload discriminant, nesting and `frame`-then-generation order | **Correction: the `PanelPointer` pin DOES exist**, at `src/protocol.rs:1975` @@ -2173,27 +2344,66 @@ protecting. 14 said it could not be found. It is in the **root crate's** test module rather than under `pmacs-protocol/` or `tests/`, which is exactly where the search did not look. `message.rs:524` was right and -the doubt was mine. The pin is **retained**, and the two new pins are -added beside it. +the doubt was mine. The pin is **retained**, and the four new pins are +added beside it. The previous-final pins catch an insertion on the +wrong side of the append boundary; the new-variant pins catch +reordering inside the variants themselves. Neither substitutes for the +other. ### Acceptance and mutation matrix | # | row | mutation | |---|---|---| +| G0a | the existing `PanelPointer` pin and the new exact previous-final `TextInput` and nested `PanelFrame(Absent)` pins all remain byte-identical | insert a wedge before each protected previous-final variant → only the pin whose discriminant moved fails | +| G0b | exact encode **and decode** bytes for `PanelPointerMapped` and nested `PresentMapped`, using unequal values for every adjacent same-typed field | reorder two `u64` fields or reverse `frame`/generation → a self-consistent round-trip stays green but the exact pin fails | | G1 | a **foreign** edit before the next render → the old generation is refused | never advance on content change → G1 passes a stale hit | | G2 | every **changing** entry moves the key, one row each — and the composites are **split**: `view_top`; `view_left`; grid **rows** and **columns** separately; **fold-map content** and the owning frontend's **`fold_projection` policy** separately; **wrap** and **gutter geometry** separately; buffer content; terminal **projected content** and **scrollback movement** separately | omit that one entry from the key | | G3 | every **stable** entry leaves it unchanged, one row each — focus; styling; selection-only; absorbed cursor motion; and the terminal controls **style, title, bell, tab-stop, cursor-only** | include that one entry → drags cancel on a repaint | -| G4 | a **selection repaint** preserves the generation and an in-flight drag **continues** | as G3 | -| G5a | the key advancing with an accepted gesture live **raises cancellation** — the daemon acts without waiting for another event | make cancellation reactive to a refused event → nothing happens when the frame lands before the physical `Up` | -| G5b | a stale `Up` with **no accepted `Down`** is **inert** | synthesise a release anyway → an unpressed button is released | -| G5c | cancellation **does not reclaim** a controller another frontend has since taken | reclaim it → a stale gesture steals a live one's terminal | -| G6 | **v24 positive control** — a legacy session drives a panel gesture end to end | gate ≤ v24 off → old peers lose the panel | -| G7a | **v25 positive control, OUTBOUND** — a v25 peer receives `PresentMapped`, carrying a live generation | withhold `PresentMapped` from a v25 peer → the mapped frame never reaches it | -| G7b | **v25 positive control, INBOUND** — a `PanelPointerMapped` with the current generation is **accepted and takes effect** | ignore the mapped variant inbound → every refusal row still passes while nothing works | -| G8a | a **bare `PanelPointer` from a ≥ v25 session is REFUSED** | accept it → the bypass returns inbound | -| G8b | a **legacy `Present` at a ≥ v25 frontend is REJECTED** | paint it → the frontend hit-tests a band it cannot map | +| G4a | a **selection repaint** preserves the generation | as G3 | +| G4b | after that repaint, an in-flight drag reaches real replay and **continues** | route only or clear the latch despite the stable generation → G4a stays green while the gesture dies | +| G5a | **mapped generation**: the key advancing with an accepted left gesture live raises cancellation before the successor frame, without waiting for another pointer event | make cancellation reactive to a refused event → nothing happens when the frame lands before the physical `Up` | +| G5b | **table-driven common authority-loss matrix**: `Absent`, panel-epoch change, buffer replacement, same-size geometry-epoch change, and detach each cancel a live left gesture; every entry has legacy-v24 and mapped-v25 legs, and the rebased effect matrix applies each to a document and a reporting terminal | omit that transition, arm the latch only for the mapped family, or cancel only one target kind → its named leg leaves the gesture live | +| G5c | an ordinary accepted `Down(Left)` → `Up(Left)` consumes the latch; a later authority loss emits **no second** document cleanup or child release, with v24 same-size geometry and v25 generation legs | leave the latch armed after `Up` → the later transition duplicates the release | +| G5d | a stale `Up` with **no accepted `Down`** is **inert** | synthesise a release anyway → an unpressed button is released | +| G5e | cancellation **does not reclaim** a controller another frontend has since taken | reclaim it → a stale gesture steals a live one's terminal | +| G5f | **producer reset matrix**: mapped generation and panel/buffer replacement clear only on atomic acceptance of the valid successor frame; `Absent` clears on acceptance; same-size geometry clears at local declaration; detach clears in teardown; structurally invalid, stale, zero-generation and wrong-family frames plus same-identity repaints retain | funnel every reset through frame arrival → geometry/detach stay armed; clear before validation or on every repaint → the negative controls lose a valid drag | +| G5g | only an accepted **`Down(Left)`** arms cancellation; table-driven Move, wheel, context and right/middle controls followed by an authority loss emit no synthetic release | arm on every `Down` or every accepted pointer event → the press-only right path or another non-gesture event manufactures a delayed release | +| G5h | **click-chain identity is independent**: complete a click, then independently drive `Absent`→Present, panel-epoch change, buffer replacement, same-size geometry change, and detach→reattach with no live gesture; a same-cell click inside the interval is single. A foreign content edit plus successor generation is the additional mapped-v25 leg | clear/re-key `MouseClickState` only while cancelling a held gesture, or omit one identity → unrelated text is treated as the second/third click | +| G5i | reporting-terminal detach delivers the recorded child release before dropping the old gesture/controller state, then leaves no controller claim owned by the detached frontend; a newer owner's claim remains untouched | tear down controller or gesture state before cancellation → no release bytes; reclaim/release unconditionally → the newer owner is disturbed | +| G5j | document cancellation has two legs: Down with no accepted Drag clears the empty selection; Down plus accepted Drag preserves the nonempty region's exact anchor/cursor while ending the gesture | clear every selection → the user's completed range disappears; preserve every selection → the empty armed anchor captures later motion | +| G5k | **terminal gesture-domain matrix**: child-reported Down then (a) reporting off or (b) Shift held before Drag/Up still yields child Drag/release in the recorded encoding and no local selection; local Down via (c) Shift or (d) reporting-off then the condition reverses still finishes locally with no child tail | re-evaluate Shift/reporting independently on every event → the child keeps a button down or receives an Up for a Down it never saw | +| G5l | **click-chain stability control**: complete a click, accept a same-generation style/selection repaint with every identity unchanged, then click the same cell inside the interval → it remains the second click | clear click state on every frame or couple it to the ordinary-Up gesture-latch clear → multi-click never survives a harmless repaint/release | +| G5m | **coincident invalidations cancel once**: changed-size geometry (geometry epoch + mapping generation) and panel/buffer replacement that also changes the mapping each produce exactly one document cleanup/child release | keep per-cause live flags or inspect without taking the shared latch → one transition emits duplicate tails | +| G5n | **click event-lifetime table**: unmodified left Down writes; Shift-Down does not; Drag, Move, wheel and right/context clear; ordinary left Up preserves | write on Shift-Down or omit one clear arm → Q#M5 or the successor click is misclassified; clear on Up → every double-click becomes two singles | +| G5o | **per-frontend click ownership**: A click, B click, then A same-cell click inside A's interval still produces A's double-click, while detaching B removes only B's chain | keep one global `Option` or clear the whole map on detach → B erases A's valid sequence | +| G5p | **per-frontend gesture ownership**: A and B hold gestures on distinct panels; B's panel-epoch change or detach cancels B exactly once while A's next valid Drag still applies | keep one global accepted-gesture latch or clear the whole map on one frontend transition → B's lifecycle cancels or erases A | +| G6a | **v24 positive control, OUTBOUND** — a v24 peer receives exactly legacy `Present`, never `PresentMapped` | send the mapped family or both → an old negotiated session receives a discriminant it did not declare | +| G6b | **v24 routing control, INBOUND** — a current legacy `Down(Left)` reaches the semantic dispatcher and performs the already-landed focus activation | gate v24 off or drop after decoding → the old peer's event is mapped but inert | +| G6c | **v24 effect control after replay rebase** — the same event performs its document/terminal replay effect | keep only the pre-existing focus path → G6b stays green while replay is dead | +| G7a | **v25 positive control, OUTBOUND** — a v25 peer receives exactly `PresentMapped`, carrying a live generation, never legacy `Present` | withhold `PresentMapped`, send legacy, or send both → the mapped frame contract is absent or ambiguous | +| G7b | **v25 routing control, INBOUND** — a current `PanelPointerMapped` carrying `Down(Left)` reaches the semantic dispatcher and performs the already-landed focus activation | ignore the mapped variant inbound → every refusal row still passes while nothing routes | +| G7c | **v25 effect control after replay rebase** — that current mapped event performs its document/terminal replay effect | stop after focus → G7b stays green while replay is dead | +| G8a | a **bare `PanelPointer` from a ≥ v25 session is REFUSED before mutation** | accept it → the bypass returns inbound | +| G8b | a **legacy `Present` at a ≥ v25 frontend is REJECTED**, atomically retaining its mapped frame, generation and pointer state | paint it or clear state first → the frontend hit-tests a band it cannot map or loses a valid gesture | +| G8c | a **`PanelPointerMapped` from a ≤ v24 session is REFUSED before mutation**, even though a peer compiled from this crate can encode it | accept it → inbound negotiation is treated as a sender convention rather than a gate | +| G8d | a **`PresentMapped` at a ≤ v24 frontend is REJECTED**, retaining the previous legacy frame atomically | paint it → outbound negotiation is treated as a sender convention rather than a gate | +| G8e | **authenticated-session authority**: in both inbound wrong-family cases, claiming another frontend whose session negotiated the desired family still refuses | gate by the payload's `frontend_id` lookup → the forged cross-session claim bypasses negotiation | | G9a | **daemon emission**: identical cells across a generation change are still **emitted** | dedupe daemon-side across the change → the second gesture is suppressed | | G9b | **frontend motion dedupe** suppresses a repeat within one generation and **re-arms across one** | (a) compare **only the cell**, never keying or resetting by generation → the first post-change motion is **eaten**; (b) reset on **every same-generation repaint** → repeated same-cell motion **returns** as pixel-rate traffic | +| G9c | **frontend acceptance**: a byte-identical `PanelFrame` with a higher generation still updates the high-water/current generation and resets pointer state atomically; it may report no visual redraw | return early on frame equality before applying generation → G9a passes because the message arrived, but the producer keeps echoing stale authority | +| G10 | an **invalid** mapped frame retains the previous frame **and** its generation, atomically | update one without the other → a valid generation names a frame never shown | +| G10a | a **structurally valid `PresentMapped` carrying generation ZERO is REJECTED**, atomically — previous frame and generation both retained | accept zero → a sender that never initialised the field disables the check for the whole session | +| G10b | a **`PanelPointerMapped` carrying generation ZERO is REFUSED** before mutation, including a coordinate-free document and local-terminal wheel | apply the wheel exemption before the nonzero check → the inbound opt-out returns through the exempt path | +| G10c | mapped-frame generation is nondecreasing: a higher generation may skip values, an equal-generation style/selection repaint is accepted, and a lower generation is rejected atomically; after `PresentMapped(9)` → `Absent`, a valid `PresentMapped(8)` is still rejected | require only nonzero, or clear the high-water mark on `Absent` → a delayed frame rolls the producer's authority backward | +| G11a | **generation exhaustion fails CLOSED, and does not leave a zombie band**: the daemon publishes `Absent`, clears input authority, and latches exhaustion for the session | fail open → the hole returns at the boundary; refuse input only → a stale panel stays painted and permanently inert; omit the latch → the next frame resurrects it | +| G11b | exhaustion with an accepted left gesture also performs the ordinary `Absent` cancellation effects **before** publishing it | publish `Absent` and discard authority without replay cancellation → G11a stays green while the gesture is orphaned | +| G12a | one mapped generation carrying **two document wheel ticks** applies both window-level scrolls even though the first changes the mapping | check every mapped coordinate indiscriminately → only the first tick applies | +| G12b | one mapped generation carrying **two non-reporting terminal wheel ticks** applies both local-scrollback moves even though the first changes the view anchor | remove the terminal coordinate-free exemption → only the first tick applies | +| G13a | mapped `Move` and `Drag` retain their respective tail-coalescing behaviour, including replacement by the latest coordinates/generation without crossing an intervening lossless event | let the new variant fall through to the default → pixel-rate traffic fills the bounded queue | +| G13b | mapped press, release and all wheel kinds remain lossless and ordered | coalesce every mapped pointer → multi-click, release or wheel ticks silently disappear | +| G14a | version tripwire: `PROTOCOL_VERSION == 25`, `SUPPORTED_PROTOCOL_VERSIONS` reaches it, `PANEL_MAPPING_MIN_VERSION == 25`, and `ADVERTISED_PROTOCOL_VERSION == 20` remains pinned | bump only the wire constant or move the advertised baseline → self-handshake fails or the permanent baseline drifts | +| G14b | the supported set accepts every version through `PROTOCOL_VERSION`, rejects `PROTOCOL_VERSION + 1`, and the literal boundary routes v24 legacy versus v25 mapped | end the set at 24 or express the boundary arithmetically against a moving ceiling → the current wire is unsupported or a later bump silently moves this feature | +| G15 | **TUI structural control** — the existing direct panel click/drag/wheel paths keep their effects with no generation token | move the generation requirement into shared replay instead of the authenticated semantic boundary → local TUI input is refused | **G9b's mutation in revision 15 was a valid implementation, not a defect.** Keying the dedupe by `(mapping_generation, coord)` preserves @@ -2201,26 +2411,32 @@ same-generation suppression *and* naturally admits the first motion under a new generation — it is one correct way to satisfy the row, so requiring it to fail would have forbidden a good design. The two mutations above are actual defects, one in each direction. -| G10 | an **invalid** mapped frame retains the previous frame **and** its generation, atomically | update one without the other → a valid generation names a frame never shown | -| G10a | a **structurally valid `PresentMapped` carrying generation ZERO is REJECTED**, atomically — previous frame and generation both retained | accept zero → a sender that never initialised the field disables the check for the whole session | -| G10b | a **`PanelPointerMapped` carrying generation ZERO is REFUSED** before mutation | accept zero → the inbound half of the same opt-out | G10a and G10b are **independent rows in opposite directions**, not one row seen twice: zero can arrive from either peer, and G10's atomicity row exercises neither. -| G11 | **generation exhaustion fails CLOSED, and does not leave a zombie band**: the daemon publishes **`Absent`**, clears input authority, **cancels any accepted gesture**, and **latches** exhaustion for the session | fail open → the hole returns at the boundary; refuse input only → a stale panel stays painted and permanently inert, with no signal to the user; omit the latch → the next frame resurrects it | G2 and G3 are enumerated per entry deliberately: one row asserting "the generation changed" cannot show **which** input moved it, and a key that ignores `view_left` passes every row that only scrolls vertically. +The mutation column states each row's **minimum required bite**, not a +prediction that no legitimate dependent can fail. Alternatives `(a)`, +`(b)`, or distinct omitted table arms are separate mutations; one +mega-mutation cannot establish their individual coverage. Record the +actual bite graph only after each compiling mutant runs. A proposed +mutation that is itself a valid implementation is replaced, as G9b +was, rather than used to reject the implementation. + ### What this slice can witness, and what the replay lane owes -**G5's EFFECTS cannot be proven on this branch, and revision 14 claimed -them anyway.** Cancellation's observable outcomes — the producer latch, -the panel's selection, the child's release — are all reached through -**panel replay, which does not exist at this base**. +**Gesture EFFECTS cannot be proven on this branch, and earlier +revisions claimed some anyway.** Cancellation's observable outcomes — +the complete producer latch, the panel's selection, the click chain and +the child's release — plus drag continuation, replay positive controls +and two-tick scrolling are all reached through **panel replay, which +does not exist at this base**. `gesture_last_content_cell` and the document/terminal replay live on `panel-pointer-replay` (`pmacs-gpu/src/main.rs:2143` there); the same struct here (`:2124`) has no such field, and @@ -2231,17 +2447,18 @@ whoever runs the tests: | owned by **this slice** | owed by the **rebased replay lane** | |---|---| -| the wire shapes, positions and field order | G5's document effects — empty selection cleared, click chain cleared | -| the family gate, both directions (G6–G8) | G5's terminal effect — the child's release, with matching button and encoding | -| the authoritative key: what moves it, what does not (G1–G4) | G5b's inert stale `Up` | -| the wheel exemptions and their two-tick rows | G5c's controller non-reclaim | -| **G5a** — that the key advancing RAISES cancellation, observed as the daemon-side signal | the end-to-end effect witnesses for that signal | -| G9–G11 | — | +| wire shapes, positions, pins and field order (G0), plus the bidirectional family gate (G6a–b, G7a–b, G8) | document cancellation effect — empty selection cleared without moving point | +| authoritative-key changing/stable inputs (G1–G4a) and the **mapping-generation** authority-loss signal before frame emission (G5a) | real stable-generation drag continuation, G5a's effects, and the v24/v25 replay-effect controls (G4b, G6c, G7c) | +| mapped-frame atomicity and generation-aware producer state (the mapped-frame legs of G5f) | complete producer reset lifecycle, including the geometry, `Absent`, identity and detach paths whose fields are introduced by replay (G5f) | +| motion coalescing, dedupe, invalid-frame atomicity, zero rejection, exhaustion state, version fallout and the TUI structural control (G9–G11a, G13–G15) | every common legacy/mapped cancellation transition and real gesture/click lifecycle (G5b–e, G5g, G5i–p), exhaustion cancellation (G11b), and both two-tick wheel effects (G12) | -**G5a stays here on purpose.** The trigger is this slice's rule; only -its consequences need replay. Moving the whole row out would leave the -proactive-cancellation ruling — the one that fixes revision 14's race — -with no witness in the slice that introduces it. +Only the **new mapping-generation** transition stays here on purpose, +and its classification does **not** discharge the effect row. The +common invalidations do not get a dead classification helper on this +base; replay owns them where accepted gesture state exists. The rebased +lane must run the whole G5 matrix through real document and terminal +call sites. This is the same producer-emits/receiver-discards blind +spot that forced the replay prerequisite in the first place. ### Coherence impact (`COHERENCE.md` §20) From 758b985c352cf1ed4751ba741655d245a93c3676 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 12:44:30 +0200 Subject: [PATCH 05/35] feat(protocol): the mapped panel family --- v25 wire shapes and their pins SS5b's first implementation commit: the two appended variants, the version constants, and the pins that hold them in place. No gating, no key, no replay --- those are the next commits, and the variants are REFUSED everywhere until their gate lands. **APPENDED AT THE TRUE END, confirmed by the discriminants.** `PanelPointer` is 15, `TextInput` 16, `PanelPointerMapped` **17**; `Present` 0, `Absent` 1, `PresentMapped` **2**. "Beside `Present`" would have been adjacent insertion, which shifts every discriminant below and silently re-interprets an older peer's bytes. `mapping_generation` is a `u64`, last within each variant, documented invalid at zero --- the value a default-constructed sender produces, so accepting it would let a peer opt out of the check by sending nothing. **THE COMPILER NAMED EVERY SEAM.** Four non-exhaustive matches: `semantic_render`'s declaration accessor now sees through both families, and the three routing sites REFUSE the mapped variant rather than unwrapping it to legacy meaning. Refusal is the correct default at an intermediate commit, not a placeholder --- until the frontend can prove it negotiated v25 it IS a `<= v24` peer for gating purposes, and painting first would ship a window in which the band is hit-tested with no mapping identity at all. **Five mutations, each biting its own rows:** insert `PanelPointerMapped` before `TextInput` -> the TextInput pin and the mapped pin. `PanelPointer`'s v23 pin correctly SURVIVES: its discriminant did not move, which is the "only the pin whose discriminant moved fails" behaviour G0a specifies insert `PresentMapped` before `Absent` -> the Absent pin and the mapped-frame pin swap `geometry_epoch` / `panel_epoch` -> the exact-bytes assertion, while the round-trip stays green. That is the blind spot G0b exists for, and it is why every adjacent same-typed field carries a distinct value bump the wire version without extending the supported set -> both new tripwires and 1a's v6 ladder move `ADVERTISED_PROTOCOL_VERSION` to 25 -> the baseline pin **Version fallout, enumerated rather than discovered one gate at a time.** Four acceptance-suite tripwires (`bottom_panel_stage2b_gpu`, `discovery_stage2` x2, `vterm_stage3`, `statusline_segments`) each say "a wire bump must be a conscious edit here" and each worked. Rather than fix them one run at a time I grepped the tree for version assertions and updated all four in one pass. Review folded five further corrections, two of which fix reasoning of mine that was wrong: - I claimed reversing `frame` and `mapping_generation` "fails to compile" because they are different types. **False for NAMED variant fields** --- the initializer uses names, so reordering the declarations compiles and shifts postcard's positional bytes silently. The pin is the only thing catching that. - Ladder loops now track `PROTOCOL_VERSION` while TRIPWIRES stay literal. I had flattened both to `25`. A tripwire is literal so a bump is a conscious edit; a ladder must move, or the next bump silently stops testing the top rung. G14b is unaffected --- `PANEL_MAPPING_MIN_VERSION` stays literal, because there the arithmetic is exactly the hazard. - `assert!(24 < MIN)` was a compile-time tautology holding for every value above 24. Replaced with the literal equality plus `assert_ne!` against `TEXT_INPUT_MIN_VERSION`: the mapped family must not share v24's gate, or it is admitted on sessions that negotiated only `TextInput`. - Statusline support loop reaches `PROTOCOL_VERSION`; public protocol history records v25. **CI-red observations are in the LANE LEDGER, not the registry**, and that is deliberate: `ci-red-signatures.md` here ends at U9 while the unmerged replay branch already added a U10, so a row from this branch would duplicate an id or invent one blind --- which this file's own history records going wrong, two branches' entries merging "without a conflict, producing duplicate ids across four sites". R7 twice and the composition budget once, fragments verified, owed to the registry by whichever branch merges second. Gates: all eleven green under `env -u TMPDIR` with `--protocol`, log 20260815T103555Z. Four runs were needed; three were lost to those two signatures, not to this diff. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 28 +++ pmacs-gpu/src/main.rs | 14 ++ pmacs-protocol/src/lib.rs | 2 +- pmacs-protocol/src/message.rs | 93 +++++++++- pmacs-protocol/src/panel.rs | 23 +++ src/daemon.rs | 13 ++ src/protocol.rs | 181 ++++++++++++++++++- src/semantic_render.rs | 4 +- tests/bottom_panel_stage2b_gpu_acceptance.rs | 9 +- tests/discovery_stage2_acceptance.rs | 9 +- tests/statusline_segments_acceptance.rs | 4 +- tests/vterm_stage3_acceptance.rs | 9 +- 12 files changed, 361 insertions(+), 28 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index dbd2571..a0e5a9d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -409,6 +409,34 @@ from #171 and #215. the canonical document false. - **Chain: this slice → `panel-pointer-replay` (rebases onto it) → GUI arc 1b.** +- **CI-red observations on this branch, NOT yet in the registry — and + deliberately so.** `docs/ci-red-signatures.md` here ends at **U9**; + the `panel-pointer-replay` branch already added a **U10** that has + not merged. Adding a row from this branch would either duplicate that + id or invent U11 against an unseen neighbour — and this file's own + history records exactly that going wrong once already, when two + branches' entries "merged **without a conflict**, producing duplicate + ids across four sites". **The rows below are owed to the registry by + whichever branch merges second**, numbered after the other's. + - **R7, two occurrences on this branch** (2026-08-15, `gpu` step, + logs `20260815T095532Z` and `20260815T100719Z`). Fragments verified + both times: `transient sequence must attach: Attach(Handshake(Io(Os + { code: 32, kind: BrokenPipe, message: "Broken pipe" })))` at + `pmacs-gpu/src/attach.rs:1728`. One machine, one day, one branch, + with a green full-gate run between them. Isolated reruns green. + - **`composition_overhead_under_ten_percent`, once** (log + `20260815T102527Z`, step **`04-lib-crdt`**). Fragment: + `composition machinery added more than 10% overhead: 1.146 + (single=227350 ns, dispatch=260497 ns)`. **`criterion_1` ran in the + same step and PASSED**, so per U6's own instruction this is one + selector redding without the other and is a different incident. + **1.146× is the smallest margin this budget has ever failed by** + (U6 1.297×, U10 1.343×, U9 1.613×), and the "realistic" figure was + **negative** (−25.8%) in the same run. Isolated rerun green. + - **Cost, stated plainly:** four `--protocol` gate runs on one commit, + three of them lost to these two signatures. U9's synthetic-load + control remains unrun and is the cheapest thing that would either + implicate load or clear it. - **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite, `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index c42fd94..47d4664 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -6895,6 +6895,20 @@ impl State { return false; } match payload { + // §5b — the mapped family is REFUSED until its gate lands. + // + // Refusing is the correct default at every intermediate + // commit, not a placeholder: G8d requires a `<= v24` + // frontend to reject `PresentMapped` outright, and until + // this frontend can prove it negotiated `>= v25` it is, for + // gating purposes, exactly such a peer. Painting it first + // and gating later would mean shipping a window in which the + // band is hit-tested with no mapping identity at all. + // + // The retention is ATOMIC, as G8d and G10 require: nothing + // about the previous frame, its generation or the pointer + // state is touched on the way out. + PanelFramePayload::PresentMapped { .. } => false, PanelFramePayload::Absent => { // Authoritative removal, and always safe. Note this does // NOT clear the geometry declaration: the frontend's frame diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 3a857b7..858359e 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -66,7 +66,7 @@ pub use message::{ MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind, - NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody, + NegotiatedCapabilities, PANEL_MAPPING_MIN_VERSION, PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 75340d4..e7c3ea5 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -550,8 +550,75 @@ pub enum FrontendEvent { /// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`]. text: String, }, + /// Bottom panel §5b (protocol v25): a panel-cell gesture that also + /// names the **inverse mapping** the frontend was looking at. + /// + /// **APPENDED LAST, after [`Self::TextInput`].** "Beside + /// `PanelPointer`" would be adjacent insertion, which shifts every + /// discriminant below it; postcard encodes variants positionally, so + /// only the end of the enum is safe. + /// + /// **Why the mapping and not the epochs.** A cell must be inverted + /// to a byte, and the existing ladder cannot tell whether the text + /// under that cell still means what it meant: `buffer_id` catches an + /// A→B replacement, `panel_epoch` catches close/reopen, + /// `geometry_epoch` catches a declaration race, and **nothing + /// catches a foreign edit** — which moves the mapping while every + /// epoch holds. `mapping_generation` closes exactly that. + /// + /// **Not a per-frame token.** A token moving with each frame would + /// invalidate a gesture on every repaint and break drags outright, + /// which is why [`crate::panel::PanelFrame::panel_epoch`] is stable + /// across ordinary frames. This identifies the *mapping*: it moves + /// with the viewport, folds, wrap and gutter geometry, buffer + /// content and terminal topology, and holds across focus, styling, + /// selection-only repaints and absorbed cursor motion. + /// + /// **Gated bilaterally on [`PANEL_MAPPING_MIN_VERSION`]**, and the + /// gate is a REFUSAL rather than a fallback: a `>= v25` session + /// sending the bare [`Self::PanelPointer`] is refused, and a + /// `<= v24` session sending this variant is refused too. Treating + /// either as "handled under the other family's semantics" would + /// leave the hole reachable by choosing a discriminant. + PanelPointerMapped { + /// Which frontend produced the gesture (untrusted, as above). + frontend_id: FrontendId, + /// Geometry declaration this gesture was hit-tested against. + geometry_epoch: u64, + /// Presentation identity this gesture addresses. + panel_epoch: u64, + /// Buffer the frontend believed the panel was displaying. + buffer_id: crate::BufferId, + /// Cell the pointer is over, within the declared panel grid. + coord: CellCoord, + /// Which gesture step this is. + kind: MouseKind, + /// Modifiers held during the gesture. + mods: Modifiers, + /// The inverse mapping the frontend was displaying. + /// + /// **Zero is invalid** and is refused like a mismatch: it is + /// what a default-constructed or half-initialised sender + /// produces, so accepting it would let a peer opt out of the + /// check by sending nothing. A live key starts at 1. + /// + /// Appended **last within the variant**, so this mirrors + /// [`Self::PanelPointer`]'s field order exactly and the two stay + /// diffable by eye. + mapping_generation: u64, + }, } +/// First protocol version carrying the **mapped panel family** — +/// [`FrontendEvent::PanelPointerMapped`] and +/// [`crate::panel::PanelFramePayload::PresentMapped`]. +/// +/// Read as a constant rather than compared against a literal, and +/// **not** derived from [`PROTOCOL_VERSION`]: expressing the boundary +/// against a moving ceiling would silently drag this feature forward +/// on the next bump. +pub const PANEL_MAPPING_MIN_VERSION: u32 = 25; + /// First protocol version carrying [`FrontendEvent::TextInput`]. /// /// A frontend older than this **retains its existing `Key` behaviour and @@ -617,7 +684,8 @@ impl FrontendEvent { | Self::FrontendCellGeometry { frontend_id, .. } | Self::PanelResizeRows { frontend_id, .. } | Self::PanelPointer { frontend_id, .. } - | Self::TextInput { frontend_id, .. } => *frontend_id, + | Self::TextInput { frontend_id, .. } + | Self::PanelPointerMapped { frontend_id, .. } => *frontend_id, } } } @@ -1920,7 +1988,18 @@ pub enum ResourceBody { /// negotiated version. What is unusual here is only that the extension /// is **inbound-only** — there is no outbound counterpart to withhold, /// so the receiver check is the whole of the daemon's half. -pub const PROTOCOL_VERSION: u32 = 24; +/// +/// Bottom panel §5b: bumped 24 → 25 for the mapped panel family, +/// [`FrontendEvent::PanelPointerMapped`] and +/// [`crate::panel::PanelFramePayload::PresentMapped`]. Both variants are +/// appended after their enum's v24 final variant. The family is gated in +/// both directions from the authenticated session's negotiated version: +/// v6–v24 sessions use only `PanelPointer`/`Present`, while v25 sessions +/// use only the mapped pair; choosing the other discriminant is refused, +/// not reinterpreted. [`crate::panel::PanelFramePayload::Absent`] remains +/// common because hiding a band carries no mapping. The advertised +/// baseline remains 20. +pub const PROTOCOL_VERSION: u32 = 25; /// Protocol version placed in the daemon's server-first [`Hello`]. /// @@ -2108,8 +2187,16 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 { /// cannot rely on the producer withholding. [`ADVERTISED_PROTOCOL_VERSION`] does not move: a v23 /// frontend negotiates v23, never sends the variant, and keeps today's /// first-scalar behaviour. +/// +/// Bottom panel §5b: extended to `[6, ..., 25]` for +/// [`FrontendEvent::PanelPointerMapped`] and +/// [`crate::panel::PanelFramePayload::PresentMapped`]. The authenticated +/// negotiated version selects exactly one legacy/mapped family in each +/// direction; a peer compiled from this crate can encode either variant, +/// so receiver-side refusal is load-bearing. `Absent` is common, and +/// [`ADVERTISED_PROTOCOL_VERSION`] remains 20. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[ - 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, ]; /// T M10.5: predicate for the handshake check. Returns `true` if diff --git a/pmacs-protocol/src/panel.rs b/pmacs-protocol/src/panel.rs index 6e26fe2..f9f344a 100644 --- a/pmacs-protocol/src/panel.rs +++ b/pmacs-protocol/src/panel.rs @@ -103,6 +103,29 @@ pub enum PanelFramePayload { Present(PanelFrame), /// No panel is visible; clear any retained frame. Absent, + /// §5b (protocol v25): a visible panel **plus the identity of the + /// inverse mapping** it was painted from. + /// + /// **APPENDED AFTER `Absent`, not beside `Present`.** postcard + /// encodes variants positionally, so inserting adjacent to + /// `Present` would shift `Absent`'s discriminant and silently + /// re-interpret every older peer's "hide the band" as something + /// else. + /// + /// `Absent` is deliberately **shared by both families** — hiding a + /// band carries no mapping, so it needs no mapped twin. + PresentMapped { + /// The frame, reused whole and unchanged. + frame: PanelFrame, + /// Identity of the inverse mapping this frame was painted from. + /// + /// A `PanelPointerMapped` echoes it, and the daemon refuses a + /// gesture whose echo no longer matches. **Zero is invalid**; + /// a live key starts at 1 and is **nondecreasing**, including + /// across `Absent` — a delayed lower frame must not roll the + /// producer's authority backward. + mapping_generation: u64, + }, } /// Why a [`PanelFrame`] is not structurally valid. diff --git a/src/daemon.rs b/src/daemon.rs index c9d5973..3bba690 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3607,6 +3607,13 @@ fn apply_semantic_input_event( let ct_mouse = mouse_to_crossterm(&pmacs_mouse); editor.dispatch_mouse(source, ct_mouse, term_size); } + // Everything else, including BOTH panel-pointer families. Panel + // gestures are dispatched from their own arm in + // `handle_dispatcher_event`, behind the epoch ladder; reaching + // them from here would route around it. §5b's mapped variant is + // dropped for the same reason, and specifically NOT unwrapped to + // its legacy meaning — that is the bypass the family gate exists + // to close. _ => {} } } @@ -3647,6 +3654,12 @@ fn apply_event( // client-supplied `frontend_id` this function would believe. FrontendEvent::Paste { .. } | FrontendEvent::TextInput { .. } + // §5b: listed here for the same reason as the two above — a + // mapped panel gesture must not reach a payload-trusting path. + // Its own dispatcher arm authenticates the source and checks + // the family gate; arriving here it is dropped, never unwrapped + // to the legacy family. + | FrontendEvent::PanelPointerMapped { .. } | FrontendEvent::FocusGained(_) | FrontendEvent::FocusLost(_) // T M11.1: the semantic-frontend viewport declaration. Its diff --git a/src/protocol.rs b/src/protocol.rs index 474473b..cf26cc6 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_twenty_four_for_text_input() { + fn protocol_version_is_twenty_five_for_the_mapped_panel_family() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1741,10 +1741,11 @@ mod tests { // minibuffer at all. `MinibufferPrompt` is therefore frozen and // pinned by literal bytes below. // - // v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) — an - // APPENDED variant, which is why the freeze above survives it + // v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) and v25 + // is the mapped panel family (bottom-panel §5b) — both APPENDED + // variants, which is why the freeze above survives them // untouched: nothing in `MinibufferPrompt`'s encoding moved. - assert_eq!(PROTOCOL_VERSION, 24); + assert_eq!(PROTOCOL_VERSION, 25); } #[test] @@ -1822,18 +1823,19 @@ mod tests { // v18 (`StatuslineSegments`), v19 (the vterm terminal family), // v20 (semantic initial-target bootstrap), v21 (the bottom // panel band), v22 (`LineWrapFacts`), v23 - // (`MinibufferPromptRows`), and v24 (`TextInput`, GUI arc Stage - // 1a) all interoperate. - for accepted in 6..=24 { + // (`MinibufferPromptRows`), v24 (`TextInput`, GUI arc Stage 1a) + // and v25 (`PanelPointerMapped` + `PresentMapped`, bottom-panel + // §5b) all interoperate. + for accepted in 6..=PROTOCOL_VERSION { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 25, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, PROTOCOL_VERSION + 1, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v24 binary" + "v{rejected} must be rejected by the current binary" ); } } @@ -2008,6 +2010,167 @@ mod tests { ); } + /// §5b G0a — the **previous-final `FrontendEvent`** is now + /// `TextInput`, and it gets its own exact pin. + /// + /// Pins ACCUMULATE rather than move. The `PanelPointer` pin above + /// still protects the v21–v23 population; this one protects v24's, + /// and appending `PanelPointerMapped` is exactly the edit that would + /// shift it if it were inserted rather than appended. + #[test] + fn text_input_encoding_is_unchanged_by_the_v25_build() { + let ev = FrontendEvent::TextInput { + frontend_id: FrontendId(2), + text: "hi".to_owned(), + }; + assert_eq!( + postcard::to_allocvec(&ev).expect("encode"), + [16, 2, 2, 104, 105], + "TextInput's v24 wire bytes changed — a variant was inserted \ + before it; append new FrontendEvent variants at the end" + ); + } + + /// §5b G0a — the **previous-final `PanelFramePayload`** is `Absent`, + /// pinned through its real nesting inside `InstanceMessage`. + /// + /// Nested deliberately: `PanelFramePayload` never travels alone, and + /// a pin on the bare enum would miss a shift in the message + /// discriminant that carries it. + #[test] + fn absent_panel_payload_encoding_is_unchanged_by_the_v25_build() { + let msg = InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent); + assert_eq!( + postcard::to_allocvec(&msg).expect("encode"), + [28, 1], + "Absent's wire bytes changed — a PanelFramePayload variant \ + was inserted before it, or InstanceMessage's discriminant \ + moved; append PresentMapped AFTER Absent" + ); + } + + /// §5b G0b — exact **encode and decode** for the mapped pointer. + /// + /// **Every adjacent same-typed field carries a different value**, so + /// a reordering is visible. `geometry_epoch`, `panel_epoch` and + /// `mapping_generation` are all `u64` and all distinct (3, 7, 11); + /// `coord.row`/`coord.col` likewise (5, 9). A round-trip alone would + /// stay green through any swap of those — it is self-consistent by + /// construction — which is why the exact bytes are asserted in both + /// directions. + #[test] + fn panel_pointer_mapped_encodes_and_decodes_exactly() { + const WIRE: [u8; 11] = [17, 2, 3, 7, 4, 5, 9, 0, 0, 0, 11]; + let ev = FrontendEvent::PanelPointerMapped { + frontend_id: FrontendId(2), + geometry_epoch: 3, + panel_epoch: 7, + buffer_id: pmacs_protocol::BufferId::from_raw(4), + coord: CellCoord { row: 5, col: 9 }, + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: Modifiers::NONE, + mapping_generation: 11, + }; + + assert_eq!( + postcard::to_allocvec(&ev).expect("encode"), + WIRE, + "PanelPointerMapped's v25 wire bytes changed — it must be \ + APPENDED after TextInput, with mapping_generation last" + ); + let decoded: FrontendEvent = postcard::from_bytes(&WIRE).expect("decode"); + assert_eq!( + decoded, ev, + "the frozen historical bytes must decode back to the same \ + value — round-trip alone would only witness the current \ + encoder and decoder agreeing with each other" + ); + } + + /// §5b G0b — exact **encode and decode** for the mapped frame, + /// through its real nesting. + /// + /// Reordering named variant fields still compiles and changes + /// postcard's positional bytes, so the exact pin is load-bearing. + /// The frame's own `panel_epoch` (8) and `geometry_epoch` (12) are + /// adjacent `u64`s and are pinned distinct for the same reason as + /// above. + #[test] + fn present_mapped_encodes_and_decodes_exactly() { + const WIRE: [u8; 33] = [ + 28, 2, 6, 8, 12, 1, 2, 2, 0, 1, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 32, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 13, + ]; + let frame = pmacs_protocol::panel::PanelFrame { + buffer_id: pmacs_protocol::BufferId::from_raw(6), + panel_epoch: 8, + geometry_epoch: 12, + size: CellSize::new(1, 2), + cells: vec![Cell::default(); 2], + cursor: None, + focused: true, + }; + let msg = + InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::PresentMapped { + frame, + mapping_generation: 13, + }); + + assert_eq!( + postcard::to_allocvec(&msg).expect("encode"), + WIRE, + "PresentMapped's v25 wire bytes changed — it must be APPENDED \ + after Absent, with mapping_generation last" + ); + let decoded: InstanceMessage = postcard::from_bytes(&WIRE).expect("decode"); + assert_eq!(decoded, msg, "the frozen bytes must decode back"); + } + + /// §5b G14a — the version constants move together, and the + /// advertised baseline does not move at all. + #[test] + fn the_mapping_slice_takes_v25_and_the_advertised_baseline_stays_pinned() { + assert_eq!(pmacs_protocol::PROTOCOL_VERSION, 25); + assert_eq!(pmacs_protocol::PANEL_MAPPING_MIN_VERSION, 25); + assert!( + pmacs_protocol::SUPPORTED_PROTOCOL_VERSIONS.contains(&pmacs_protocol::PROTOCOL_VERSION), + "a wire version the handshake does not support fails against \ + this daemon's own peers" + ); + assert_eq!( + pmacs_protocol::ADVERTISED_PROTOCOL_VERSION, + 20, + "the advertised baseline is PERMANENT — it is not bumped to \ + chase the wire version" + ); + } + + /// §5b G14b — the supported set's boundary, and the family boundary, + /// are both literal. + #[test] + fn the_supported_set_ends_at_the_current_wire_version() { + assert!(pmacs_protocol::is_supported_protocol_version(25)); + assert!( + !pmacs_protocol::is_supported_protocol_version(26), + "an unreleased version must not negotiate" + ); + // The family boundary is stated against a LITERAL, not against + // `PROTOCOL_VERSION`: expressing it arithmetically would drag + // this feature forward on the next bump, so v24 sessions would + // silently start being offered a family they never negotiated. + // + // Asserted as equality rather than `24 < MIN`, which clippy + // correctly calls a compile-time tautology — it holds for every + // value above 24 and so pins nothing. + assert_eq!(25, pmacs_protocol::PANEL_MAPPING_MIN_VERSION); + assert_ne!( + pmacs_protocol::PANEL_MAPPING_MIN_VERSION, + pmacs_protocol::TEXT_INPUT_MIN_VERSION, + "the mapped family must not share v24's gate — that would \ + admit it on sessions that negotiated only TextInput" + ); + } + #[test] fn terminal_family_round_trips_and_pins_its_discriminants() { let bid = pmacs_protocol::BufferId::from_raw(9); diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 3359504..5e46628 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -592,7 +592,9 @@ impl SemanticRenderState { #[must_use] pub fn panel_declaration(&self) -> Option<&PanelFrame> { match self.last_panel_payload.as_ref()? { - PanelFramePayload::Present(frame) => Some(frame), + PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. } => { + Some(frame) + } PanelFramePayload::Absent => None, } } diff --git a/tests/bottom_panel_stage2b_gpu_acceptance.rs b/tests/bottom_panel_stage2b_gpu_acceptance.rs index 7da08ea..b24629c 100644 --- a/tests/bottom_panel_stage2b_gpu_acceptance.rs +++ b/tests/bottom_panel_stage2b_gpu_acceptance.rs @@ -337,10 +337,11 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() { #[test] fn the_baseline_stays_and_the_counter_offer_activates() { // A deliberate tripwire: bumping the wire must be a conscious edit - // here, not a silent one. v24 is `TextInput` (GUI arc Stage 1a); - // v23 was `MinibufferPromptRows` (Discovery Stage 2); v22 was - // `LineWrapFacts` (long-lines Stage 3). - assert_eq!(PROTOCOL_VERSION, 24); + // here, not a silent one. v25 is the mapped panel family + // (`PanelPointerMapped` + `PresentMapped`, bottom-panel §5b); v24 is + // `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows` + // (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3). + assert_eq!(PROTOCOL_VERSION, 25); assert_eq!( ADVERTISED_PROTOCOL_VERSION, 20, "moving this is the incompatible act the mechanism exists to avoid" diff --git a/tests/discovery_stage2_acceptance.rs b/tests/discovery_stage2_acceptance.rs index db5f8cc..36d50a0 100644 --- a/tests/discovery_stage2_acceptance.rs +++ b/tests/discovery_stage2_acceptance.rs @@ -67,10 +67,11 @@ use common::daemon::{TestDaemon, build_default_caps}; /// server-first, so moving it locks out every already-shipped frontend /// before it can counter-offer. An additive family never needs it. #[test] -fn the_wire_is_v24_and_the_advertised_baseline_is_unmoved() { +fn the_wire_is_v25_and_the_advertised_baseline_is_unmoved() { assert_eq!( - PROTOCOL_VERSION, 24, - "v24 is TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows" + PROTOCOL_VERSION, 25, + "v25 is the mapped panel family (bottom-panel §5b); v24 was \ + TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows" ); assert_eq!( ADVERTISED_PROTOCOL_VERSION, 20, @@ -618,7 +619,7 @@ fn one_daemon_serves_a_v23_rows_session_and_a_frozen_v22_session() { // rather than after the interesting half has already passed. let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22); let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION); - assert_eq!(PROTOCOL_VERSION, 24); + assert_eq!(PROTOCOL_VERSION, 25); // Open the real `M-x` through the real key path, then narrow to the // probe command by typing it — the candidate window is ten rows out diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index c927956..448442f 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -804,8 +804,8 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { // three lines on purpose. The ceiling assertion is the load-bearing // one — it says the supported set ENDS here, which is what makes an // accidentally-widened set a failure rather than a silent pass. - assert_eq!(PROTOCOL_VERSION, 24); - for version in 6..=24 { + assert_eq!(PROTOCOL_VERSION, 25); + for version in 6..=PROTOCOL_VERSION { assert!(is_supported_protocol_version(version)); } assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1)); diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 837d242..2163bfb 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -888,10 +888,11 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { panic!("timed out waiting for {what}"); } - // Tripwire: a wire bump must be a conscious edit here. v24 is - // `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows` - // (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3). - assert_eq!(PROTOCOL_VERSION, 24); + // Tripwire: a wire bump must be a conscious edit here. v25 is the + // mapped panel family (bottom-panel §5b); v24 is `TextInput` (GUI + // arc Stage 1a); v23 was `MinibufferPromptRows` (Discovery Stage 2); + // v22 was `LineWrapFacts` (long-lines Stage 3). + assert_eq!(PROTOCOL_VERSION, 25); let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( &[ ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"), From 063db5255584dcbd6467fefa34f6a95a660df0d0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 13:36:24 +0200 Subject: [PATCH 06/35] feat(panel): the authoritative cell-mapping key (G1-G4) The key itself, its domain, and the witnesses for both. No gating and no replay yet. **DERIVED FROM A FINGERPRINT, NOT BUMPED AT MUTATION SITES.** `panel_mapping_fingerprint` hashes what actually decides which byte a cell means --- buffer identity, grid rows and columns, `view_top`, `view_left`, wrap mode, content columns, fold POLICY and fold CONTENT, and the buffer's content revision --- and the generation advances whenever that changes. This makes the changing/stable split STRUCTURAL: an input that is hashed moves the key by construction, and one that is not cannot. Bumping by hand at each mutation site would have made "advances after any mapping mutation" a promise about someone remembering. Folds are hashed at their SOURCE, the registry's ranges, rather than through the derived `VisibleLineMap`, whose only public summary is `is_identity()` --- too coarse, since a fold edit that leaves the map non-identity still changes which source line a row shows. **ONE SEAM, READ BY BOTH SIDES.** `panel_mapping_generation` advances if the fingerprint changed and returns the current value; projection will stamp with it and inbound validation will compare against it, so "what the frontend was shown" and "what the daemon checks" cannot drift. Computed ON DEMAND, deliberately: a mutation not yet painted has still changed the inverse, and a gesture arriving in that gap must be refused. Deriving from the last emitted frame recreates the hole. **Nondecreasing, and never cleared.** `Absent` yields no key to stamp --- which is not a key of zero --- but the high-water mark survives, so a frame delayed across a hide cannot roll authority backward. First establishment takes 1; zero is the wire's invalid value. **MUTATION TESTING FOUND MY WITNESSES UNDER-SPECIFIED, TWICE.** With only the content-edit row present, dropping `view_left` from the key stayed GREEN, and so did collapsing the grid to `rows * cols`. That is exactly what the closure predicted --- "a key that ignores `view_left` passes every row that only scrolls vertically" --- and it is why G2 is enumerated per input rather than asserted in aggregate. Six legs now, one per input, each touching only its own. Mutations that bite: omit the content revision (3 rows), omit `view_left`, omit wrap, omit fold policy, and include the CURSOR --- a stable input, caught by G3. **One mutation does NOT bite, and the row says so rather than pretending.** Collapsing rows and columns to a product stays green, because `last_content_cols` co-varies with a column change and the row count co-varies with a resize: the key moves by another input either way. Only a transposition (2x6 -> 6x2, identical product) would isolate it, and no production path reaches one --- rows come from the band's height, columns from the frame declaration, and nothing swaps them. The key hashes them separately anyway; hashing a product because no test can currently tell the difference would be choosing the weaker construction for the suite's convenience. G4b --- that an in-flight drag survives a selection repaint through real replay --- stays owed by the rebased replay lane, which is the only branch where replay exists. Verified: focused suite 34/34, `cargo test --lib` green, clippy clean. Per the standing procedure the eleven-stage `--protocol` gate is reserved for the next coherent checkpoint. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/editor.rs | 74 +++++ src/semantic_render.rs | 52 +++ .../bottom_panel_stage2b_daemon_acceptance.rs | 313 ++++++++++++++++++ 3 files changed, 439 insertions(+) diff --git a/src/editor.rs b/src/editor.rs index b08c26a..388ea01 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2452,6 +2452,80 @@ impl EditorState { true } + /// Fingerprint of the panel's **inverse mapping** for one frontend + /// (§5b, Q#BP-R3). + /// + /// This is the whole of "what decides which byte a cell means". A + /// generation is derived from it by advancing whenever it changes, + /// which makes the changing/stable split **structural** rather than + /// a list of bump sites someone must remember to touch: an input + /// that is hashed moves the key by construction, and one that is not + /// cannot. + /// + /// **Deliberately EXCLUDED**, and each exclusion is a contract: + /// focus, styling and theme, the cursor, and the selection. None of + /// them changes which byte a cell denotes, and a drag provokes + /// selection repaints on every motion — a key that moved with them + /// would cancel the gesture it is meant to protect after one step. + /// + /// Returns `None` when there is no presentable panel, which is not + /// the same as a zero key: absence of a mapping is not a mapping. + pub fn panel_mapping_fingerprint(&self, frontend_id: FrontendId) -> Option { + use std::hash::{Hash, Hasher}; + + let core = self.core.borrow(); + let size = core.panel_grid_size(frontend_id)?; + let side = core.side_window_for(frontend_id)?; + let window = core.windows.get(&side)?; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + // Identity first: a different buffer is a different mapping even + // if every geometry input coincides. + window.buffer_id.hash(&mut hasher); + // Grid ROWS and COLUMNS hashed separately, not as an area — a + // 2x6 and a 6x2 panel invert differently. + size.rows.hash(&mut hasher); + size.cols.hash(&mut hasher); + // Viewport, both axes. `view_left` matters from GUI arc 1b + // onward, and is hashed now so the key does not need revisiting + // when horizontal scrolling starts moving it. + window.view_top.hash(&mut hasher); + window.view_left.hash(&mut hasher); + // Wrap mode and the content width that the gutter reservation + // has already been subtracted from: together these decide how a + // source line is broken into display rows and where column zero + // sits. + window.last_wrap.hash(&mut hasher); + window.last_content_cols.hash(&mut hasher); + // Fold POLICY and fold CONTENT are separate inputs. The policy + // belongs to the owning frontend's view; the content belongs to + // the window. Either alone can change which source line a grid + // row shows. + core.views + .get(&frontend_id) + .is_some_and(|view| view.fold_projection) + .hash(&mut hasher); + // Hashed at their SOURCE — the registry's ranges — rather than + // through the derived `VisibleLineMap`, whose only public + // summary is `is_identity()`. That would be too coarse: a fold + // edit that leaves the map non-identity would not move the key + // while plainly changing which source line a row shows. + for range in core.fold_registry.folds(window.buffer_id) { + range.start.hash(&mut hasher); + range.end.hash(&mut hasher); + } + // Content. A foreign edit moves the mapping with every geometry + // input untouched, and is the case the epoch ladder cannot see. + let registry = core.registry.clone(); + let revision = registry + .borrow() + .get(window.buffer_id) + .ok() + .map(crate::buffer::Buffer::revision); + revision.hash(&mut hasher); + Some(hasher.finish()) + } + /// Paint one semantic frontend's side window into a panel-sized grid /// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17). /// diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 5e46628..b2c8d2b 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -377,6 +377,26 @@ pub struct SemanticRenderState { /// retains its last valid frame and silence would leave a stale band /// on screen indefinitely (Q#BP15). last_panel_payload: Option, + /// §5b — the authoritative **cell-mapping key** for this frontend. + /// + /// `(fingerprint, generation)`. The generation advances whenever the + /// fingerprint changes, and **both projection and inbound + /// validation read it through the same accessor**, so "what the + /// frontend was shown" and "what the daemon checks" cannot drift. + /// + /// It is deliberately **not** recomputed from the last emitted + /// frame: a mapping mutation that has not yet been painted has still + /// changed the inverse, and a gesture arriving in that gap must be + /// refused. Advancing on demand at both seams is what makes + /// "advances before the next inbound pointer, whether or not + /// anything rendered" true rather than aspirational. + /// + /// **Nondecreasing, and never cleared** — not even by `Absent`. A + /// delayed lower frame must not roll the producer's authority + /// backward, so this is a high-water mark for the session. + /// `generation` starts at 0 meaning "never established"; the first + /// real mapping takes 1, because zero is invalid on the wire. + panel_mapping: Option<(u64, u64)>, /// Highest presentation epoch allocated for this session; `0` means /// none has been. Advanced only when a frame is actually shipped, so /// a frame that fails validation does not burn an identity the peer @@ -575,6 +595,7 @@ impl SemanticRenderState { // peer already holds. Seeding the baseline keeps the first // frame from shipping a redundant authoritative `Absent`. last_panel_payload: Some(PanelFramePayload::Absent), + panel_mapping: None, panel_epoch_used: 0, panel_presentation: None, panel_error_latched: false, @@ -599,6 +620,37 @@ impl SemanticRenderState { } } + /// Advance-if-changed, then read: the authoritative mapping key. + /// + /// **The single seam §5b requires.** Projection stamps the frame + /// with what this returns, and inbound validation compares against + /// what this returns; there is no second derivation to disagree + /// with. + /// + /// `fingerprint` is `None` when no panel is presentable. That does + /// **not** reset the key — the high-water mark survives `Absent`, + /// so a frame delayed across a hide cannot come back with a lower + /// generation and be believed. + pub fn panel_mapping_generation(&mut self, fingerprint: Option) -> Option { + let fingerprint = fingerprint?; + let next = match self.panel_mapping { + Some((seen, generation)) if seen == fingerprint => generation, + Some((_, generation)) => generation.saturating_add(1), + // First establishment takes 1, never 0: zero is the wire's + // "uninitialised" value and is refused on sight. + None => 1, + }; + self.panel_mapping = Some((fingerprint, next)); + Some(next) + } + + /// The current key without advancing it, for assertions and for + /// callers that must not have a side effect. + #[must_use] + pub fn panel_mapping_generation_peek(&self) -> Option { + self.panel_mapping.map(|(_, generation)| generation) + } + /// Whether the last shipped declaration is a `Present` whose epochs /// both match an inbound panel event **and** which still describes /// the side window that is live now (Q#BP16 steps 2–4). diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index e73109a..a379167 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -1362,3 +1362,316 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() { // write into their real data root. #[path = "common/iso.rs"] mod iso; + +// --------------------------------------------------------------------------- +// §5b G1–G4 — the authoritative cell-mapping key +// +// The key is derived from a FINGERPRINT of the inverse mapping's inputs, +// so the changing/stable split is structural: an input that is hashed +// moves the key by construction, and one that is not cannot. These rows +// pin each input individually, because a single "it changed" row cannot +// show WHICH input moved it — and a key that silently ignored, say, +// `view_left` would pass every row that only scrolls vertically. +// --------------------------------------------------------------------------- + +/// Edit the panel's buffer from OUTSIDE any gesture — the "foreign +/// edit" the ladder cannot see. Done in Rust rather than Lua because it +/// must be a plain content mutation with no view, cursor or command +/// state attached to it. +fn foreign_edit(session: &Session, text: &str) { + let core = session.state.core.borrow(); + let side = core.side_window_for(FID).expect("a side window"); + let buffer_id = core.windows[&side].buffer_id; + let registry = core.registry.clone(); + let mut reg = registry.borrow_mut(); + let buffer = reg.get_mut(buffer_id).expect("the panel's buffer"); + buffer + .set_generated_contents(text.as_bytes()) + .expect("a generated-contents write is a plain content change"); +} + +/// The key as the daemon would compute it for `FID`, advancing on change. +fn mapping_generation(session: &mut Session) -> Option { + let fingerprint = session.state.panel_mapping_fingerprint(FID); + session.render.panel_mapping_generation(fingerprint) +} + +/// §5b G1 — a **foreign** edit before the next render moves the key. +/// +/// This is the case the whole slice exists for: the epoch ladder cannot +/// see it. No buffer is replaced, no panel reopens, no geometry is +/// re-declared — every epoch holds — and yet the byte under a cell has +/// changed. It is also why the key must be derived on demand rather than +/// from the last emitted frame: nothing has rendered here. +#[test] +fn g1_a_foreign_edit_moves_the_mapping_key_before_anything_renders() { + let mut session = Session::new(); + open_panel(&session, "g1", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a presentable panel has a key"); + assert!( + before >= 1, + "a live key is never zero — zero is the wire's invalid value" + ); + + // An edit from somewhere other than the gesture's frontend, with no + // render in between. + foreign_edit(&session, "foreign edit\n"); + + let after = mapping_generation(&mut session).expect("still presentable"); + assert!( + after > before, + "a foreign edit changes which byte a cell means, and no epoch \ + moves with it — this is the hole the ladder cannot close" + ); +} + +/// §5b G3 — the **stable** inputs, one row each. +/// +/// Every entry here is something that repaints a panel without changing +/// which byte a cell denotes. A drag provokes selection repaints on every +/// motion, so a key that moved with them would cancel the gesture it +/// exists to protect after a single step. +#[test] +fn g3_repaints_that_cannot_move_a_byte_leave_the_key_alone() { + let mut session = Session::new(); + open_panel(&session, "g3", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let baseline = mapping_generation(&mut session).expect("a key"); + + // Re-reading with nothing changed at all. + assert_eq!( + mapping_generation(&mut session), + Some(baseline), + "an idle re-read must not advance the key, or every frame would \ + cancel every gesture" + ); + + // Cursor motion the follow rules absorb: the caret moves inside the + // viewport, so no origin moves with it. Set directly, so nothing but + // the cursor changes. + { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("a side window"); + let window = core.windows.get_mut(&side).expect("the side window"); + window.cursor = 0; + } + assert_eq!( + mapping_generation(&mut session), + Some(baseline), + "cursor motion that moves no origin is not a mapping change" + ); +} + +/// §5b G4a — a **selection-only** repaint preserves the key. +/// +/// Split from G3 because it is the one the lifecycle depends on: G4b — +/// that an in-flight drag then continues through real replay — is owed by +/// the rebased replay lane, which is the only branch where replay exists. +#[test] +fn g4a_a_selection_only_repaint_preserves_the_mapping_key() { + let mut session = Session::new(); + open_panel(&session, "g4a", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let baseline = mapping_generation(&mut session).expect("a key"); + foreign_edit(&session, "alpha beta\n"); + let after_edit = mapping_generation(&mut session).expect("a key"); + assert!(after_edit > baseline, "the edit itself is a mapping change"); + + // Now a selection, with no content or viewport change. + { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("a side window"); + let window = core.windows.get_mut(&side).expect("the side window"); + window.selection = Some(pmacs::window::Selection { anchor: 0 }); + window.cursor = 5; + } + assert_eq!( + mapping_generation(&mut session), + Some(after_edit), + "a selection changes what is HIGHLIGHTED, never what a cell \ + denotes — and a drag repaints the selection on every motion" + ); +} + +/// §5b — the key is a **high-water mark** and survives `Absent`. +/// +/// Hiding the band clears input authority, but it must not reset the +/// generation: a frame delayed across the hide would otherwise return +/// with a lower value and be believed. +#[test] +fn the_mapping_key_never_moves_backward_across_a_hidden_panel() { + let mut session = Session::new(); + open_panel(&session, "hw", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a key"); + foreign_edit(&session, "one\n"); + let peak = mapping_generation(&mut session).expect("a key"); + assert!(peak > before); + + // Hide it: no fingerprint, so no advance — and no reset either. + { + let mut core = session.state.core.borrow_mut(); + core.views + .get_mut(&FID) + .expect("the frontend's view") + .panel_hidden = true; + } + assert_eq!( + mapping_generation(&mut session), + None, + "no presentable panel means no key to stamp, which is not the \ + same as a key of zero" + ); + assert_eq!( + session.render.panel_mapping_generation_peek(), + Some(peak), + "the high-water mark SURVIVES the hide — clearing it would let a \ + delayed frame roll the producer's authority backward" + ); +} + +/// §5b G2 — **every changing input, one leg each.** +/// +/// Enumerated rather than asserted in aggregate, and mutation testing is +/// what forced it: with only the content-edit row present, dropping +/// `view_left` from the key and collapsing the grid to `rows * cols` +/// both stayed GREEN. A single "the key moved" row cannot show *which* +/// input moved it, and a key that ignores horizontal scrolling passes +/// every row that only scrolls vertically. +#[test] +fn g2_each_input_of_the_inverse_mapping_moves_the_key_on_its_own() { + // Each leg names one input and touches only that input. + type Leg = (&'static str, fn(&Session)); + + let legs: &[Leg] = &[ + ("view_top", |session| { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("side"); + core.windows.get_mut(&side).expect("win").view_top += 1; + }), + ("view_left — GUI arc 1b makes this real", |session| { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("side"); + core.windows.get_mut(&side).expect("win").view_left += 1; + }), + ("wrap mode", |session| { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("side"); + let window = core.windows.get_mut(&side).expect("win"); + window.last_wrap = match window.last_wrap { + pmacs::view::WrapMode::Wrap => pmacs::view::WrapMode::Truncate, + pmacs::view::WrapMode::Truncate => pmacs::view::WrapMode::Wrap, + }; + }), + ( + "content columns — the gutter is subtracted here", + |session| { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("side"); + core.windows.get_mut(&side).expect("win").last_content_cols += 1; + }, + ), + ("fold PROJECTION POLICY, owned by the view", |session| { + let mut core = session.state.core.borrow_mut(); + let view = core.views.get_mut(&FID).expect("view"); + view.fold_projection = !view.fold_projection; + }), + ("fold CONTENT, owned by the buffer", |session| { + let core = session.state.core.borrow(); + let side = core.side_window_for(FID).expect("side"); + let buffer_id = core.windows[&side].buffer_id; + let registry = core.registry.clone(); + let mut reg = registry.borrow_mut(); + let buffer = reg.get_mut(buffer_id).expect("buffer"); + let store = core.fold_registry.store_or_attach(buffer); + store + .lock() + .expect("fold store mutex") + .insert(pmacs_protocol::ByteRange { start: 0, end: 1 }); + }), + ]; + + for (name, mutate) in legs { + let mut session = Session::new(); + open_panel(&session, "g2", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a key"); + mutate(&session); + let after = mapping_generation(&mut session).expect("a key"); + assert!( + after > before, + "changing {name} changes which byte a cell means, so the key \ + must move; it did not ({before} → {after})" + ); + } +} + +/// §5b G2 — grid **rows** and **columns** each move the key. +/// +/// **Honest limit, recorded because mutation testing found it:** these +/// two legs do NOT discriminate `rows`/`cols` from their product. +/// Collapsing the key to `rows * cols` leaves both GREEN, because +/// `last_content_cols` co-varies with a column change and the panel's +/// row count co-varies with a resize — the key still moves, by another +/// input. Only a **transposition** (2×6 → 6×2, identical product) would +/// isolate it, and no production path reaches one: rows come from the +/// band's height and columns from the frame declaration, and nothing +/// swaps them. +/// +/// The key hashes them separately anyway. That is cheap and correct, +/// and the alternative — hashing a product because no test can currently +/// tell the difference — would be choosing the weaker construction for +/// the convenience of the test suite. What these legs *do* pin is that +/// each dimension moves the key at all, which is what the rest of the +/// slice depends on. +#[test] +fn g2_grid_rows_and_columns_are_independent_inputs() { + // ROWS come from the band's own height, not the frame's total rows — + // declaring a shorter frame leaves a 4-row panel a 4-row panel. The + // resize path is the one that actually changes them. + { + let mut session = Session::new(); + open_panel(&session, "g2rows", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a key"); + assert!( + session.state.apply_panel_resize_rows(FID, 6), + "the resize must be accepted, or this leg proves nothing" + ); + let after = mapping_generation(&mut session).expect("a key"); + assert!( + after > before, + "changing grid ROWS alone must move the key — an area product \ + would miss a transposition" + ); + } + + // COLUMNS come from the declaration. + { + let mut session = Session::new(); + open_panel(&session, "g2cols", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a key"); + session.declare(2, 24, 40); + let after = mapping_generation(&mut session).expect("a key"); + assert!( + after > before, + "changing grid COLUMNS alone must move the key" + ); + } +} From 42efc3618f119bd9aeeee7d128e7d13af7d9a1c5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 14:31:08 +0200 Subject: [PATCH 07/35] fix(panel): the terminal domain, an exact key, and two witnesses that were wrong All four closure gaps in one correction. Three of them are defects in what I committed as G1-G4; the fourth overturns a claim I made about what could not be witnessed. **THE KEY IS NOW EXACT, NOT PROBABILISTIC.** It was a `DefaultHasher` digest, so authoritative equality rested on the absence of collisions --- and a collision silently ACCEPTS a stale gesture, which is precisely the failure the key exists to prevent. It is a `PanelMappingSnapshot` struct compared structurally now. The emitted `mapping_generation` stays a `u64` on the wire; only the daemon's own comparison changed. **THE TERMINAL DOMAIN WAS ABSENT, AND THE BUFFER REVISION WAS WRONG.** The key hashed the panel buffer's content revision for every target kind. For a terminal that is doubly wrong: SS5b says the buffer revision does not decide the mapping, and what does --- the screen --- was not consulted at all. `PanelMappingContent` now splits by kind, and terminals carry the screen's mapping revision plus the view's scroll anchor. That revision had to be built. `Screen::generation` cannot serve: it advances from 39 sites including style, title, bell, tab stops and cursor motion, none of which changes what a coordinate denotes. `Screen` now carries `mapping_revision`, and the classification FAILS SAFE --- `changed()` bumps both by default, and only the eleven explicitly display-only arms call `display_only_changed()`. Anything unclassified is treated as content, because over-cancelling a gesture is a nuisance while under-cancelling one lets a stale coordinate reach a child. **"NO PRODUCTION PATH REACHES A TRANSPOSITION" WAS WRONG.** I recorded the rows/cols product mutation as unwitnessable and kept the separate hashing on principle. Resize plus redeclare reaches it: 4x80 -> 8x40 holds the area at 320 while swapping the dimensions, and `last_content_cols` is not refreshed until the next render, so the two grid fields are isolated. The row exists and the product mutation now fails. **G3 WAS INCOMPLETE.** It covered idle and cursor only. Focus is added at the daemon level --- the tempting error is folding the whole frame, which carries a `focused` flag, into the key. Styling is pinned structurally instead: the snapshot has no style field, so there is nothing a recolour could touch. The terminal controls live in `screen.rs`, at the level the classification lives, with a positive half so a revision that never advanced at all cannot pass them. **AND MUTATION TESTING FOUND ANOTHER UNWITNESSED BRANCH.** Routing terminal panels through the DOCUMENT arm left all thirty-five rows green --- the `is_terminal` branch had no daemon-level witness at all. A row now pins that the snapshot picks its domain by target kind, and that mutation fails. Mutations: display-only events bumping the mapping revision (the screen-level control fails); terminal panels keyed on the buffer revision (the domain row fails); rows*cols as an area (the transposition row fails); plus G1-G4's original five, still biting. Verified: focused suite 36/36, `cargo test --lib` green, clippy clean. Full `--protocol` gate reserved for the checkpoint after bilateral gating, per the standing procedure. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/editor.rs | 176 +++++++++++------- src/semantic_render.rs | 22 ++- src/terminal/screen.rs | 113 +++++++++-- src/terminal/view.rs | 19 ++ .../bottom_panel_stage2b_daemon_acceptance.rs | 155 ++++++++++----- 5 files changed, 358 insertions(+), 127 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 388ea01..ecf6f0b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -375,6 +375,70 @@ const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500); /// column and clobbers no information. const DIVIDER_HANDLE_GLYPH: char = '⇕'; +/// The panel's **inverse mapping**, captured exactly (§5b, +/// Q#BP-R3). +/// +/// A STRUCT compared structurally, not a hash. A hash would make +/// authoritative equality probabilistic: a collision silently +/// accepts a stale gesture, which is the precise failure this key +/// exists to prevent. The emitted `mapping_generation` is still a +/// `u64` on the wire — only the daemon's own comparison is exact. +/// +/// **Deliberately EXCLUDED**, each an explicit contract: focus, +/// styling and theme, the cursor, and the selection. None changes +/// which byte a cell denotes, and a drag repaints the selection on +/// every motion — a key that moved with them would cancel the +/// gesture it protects after one step. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct PanelMappingSnapshot { + buffer_id: crate::buffer::BufferId, + /// Rows and columns held apart, never multiplied: a 2×6 panel + /// inverts nothing like a 6×2 one. + rows: u32, + cols: u32, + view_top: usize, + view_left: u32, + wrap: crate::view::WrapMode, + content_cols: u32, + fold_projection: bool, + folds: Vec, + content: PanelMappingContent, +} + +impl PanelMappingSnapshot { + /// Which domain decided this mapping. Exposed for the row that pins + /// the branch is taken by target kind. + #[must_use] + pub fn content(&self) -> &PanelMappingContent { + &self.content + } +} + +/// What decides the mapping BELOW the geometry, which differs by +/// target kind. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum PanelMappingContent { + /// A document panel: the buffer's content revision. + Document { + /// The buffer's content revision, or `None` if it is gone. + revision: Option, + }, + /// A terminal panel: the screen's **mapping revision** and the + /// view's scroll anchor. + /// + /// **Not the buffer's revision**, which tracks something else + /// entirely, and **not `Screen::generation`**, which advances + /// for style, title, bell, tab stops and cursor motion — none + /// of which changes what a coordinate denotes. + Terminal { + /// Content and topology identity, excluding style, title, bell, + /// tab stops and cursor motion. + mapping_revision: u64, + /// The view's scroll anchor; `None` follows the live tail. + anchor: Option, + }, +} + impl EditorState { /// Construct a fresh editor for an unnamed scratch buffer. /// @@ -2452,78 +2516,58 @@ impl EditorState { true } - /// Fingerprint of the panel's **inverse mapping** for one frontend - /// (§5b, Q#BP-R3). + /// Capture the panel's inverse mapping for one frontend. /// - /// This is the whole of "what decides which byte a cell means". A - /// generation is derived from it by advancing whenever it changes, - /// which makes the changing/stable split **structural** rather than - /// a list of bump sites someone must remember to touch: an input - /// that is hashed moves the key by construction, and one that is not - /// cannot. - /// - /// **Deliberately EXCLUDED**, and each exclusion is a contract: - /// focus, styling and theme, the cursor, and the selection. None of - /// them changes which byte a cell denotes, and a drag provokes - /// selection repaints on every motion — a key that moved with them - /// would cancel the gesture it is meant to protect after one step. - /// - /// Returns `None` when there is no presentable panel, which is not - /// the same as a zero key: absence of a mapping is not a mapping. - pub fn panel_mapping_fingerprint(&self, frontend_id: FrontendId) -> Option { - use std::hash::{Hash, Hasher}; - + /// `None` when no panel is presentable — the absence of a mapping, + /// which is not the same as a mapping of zero. + pub fn panel_mapping_snapshot(&self, frontend_id: FrontendId) -> Option { let core = self.core.borrow(); let size = core.panel_grid_size(frontend_id)?; let side = core.side_window_for(frontend_id)?; let window = core.windows.get(&side)?; + let buffer_id = window.buffer_id; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - // Identity first: a different buffer is a different mapping even - // if every geometry input coincides. - window.buffer_id.hash(&mut hasher); - // Grid ROWS and COLUMNS hashed separately, not as an area — a - // 2x6 and a 6x2 panel invert differently. - size.rows.hash(&mut hasher); - size.cols.hash(&mut hasher); - // Viewport, both axes. `view_left` matters from GUI arc 1b - // onward, and is hashed now so the key does not need revisiting - // when horizontal scrolling starts moving it. - window.view_top.hash(&mut hasher); - window.view_left.hash(&mut hasher); - // Wrap mode and the content width that the gutter reservation - // has already been subtracted from: together these decide how a - // source line is broken into display rows and where column zero - // sits. - window.last_wrap.hash(&mut hasher); - window.last_content_cols.hash(&mut hasher); - // Fold POLICY and fold CONTENT are separate inputs. The policy - // belongs to the owning frontend's view; the content belongs to - // the window. Either alone can change which source line a grid - // row shows. - core.views - .get(&frontend_id) - .is_some_and(|view| view.fold_projection) - .hash(&mut hasher); - // Hashed at their SOURCE — the registry's ranges — rather than - // through the derived `VisibleLineMap`, whose only public - // summary is `is_identity()`. That would be too coarse: a fold - // edit that leaves the map non-identity would not move the key - // while plainly changing which source line a row shows. - for range in core.fold_registry.folds(window.buffer_id) { - range.start.hash(&mut hasher); - range.end.hash(&mut hasher); - } - // Content. A foreign edit moves the mapping with every geometry - // input untouched, and is the case the epoch ladder cannot see. - let registry = core.registry.clone(); - let revision = registry - .borrow() - .get(window.buffer_id) - .ok() - .map(crate::buffer::Buffer::revision); - revision.hash(&mut hasher); - Some(hasher.finish()) + let content = if self.terminal_manager.borrow().is_terminal(buffer_id) { + let key = TerminalViewKey::new(frontend_id, side, buffer_id); + let (mapping_revision, anchor) = self + .terminal_manager + .borrow() + .view_mapping_identity(key) + .unwrap_or((0, None)); + PanelMappingContent::Terminal { + mapping_revision, + anchor, + } + } else { + let registry = core.registry.clone(); + let revision = registry + .borrow() + .get(buffer_id) + .ok() + .map(crate::buffer::Buffer::revision); + PanelMappingContent::Document { revision } + }; + + Some(PanelMappingSnapshot { + buffer_id, + rows: size.rows, + cols: size.cols, + view_top: window.view_top, + view_left: window.view_left, + wrap: window.last_wrap, + content_cols: window.last_content_cols, + fold_projection: core + .views + .get(&frontend_id) + .is_some_and(|view| view.fold_projection), + // Read at their SOURCE — the registry's ranges — rather than + // through the derived `VisibleLineMap`, whose only public + // summary is `is_identity()`. That is too coarse: a fold edit + // leaving the map non-identity still changes which source + // line a row shows. + folds: core.fold_registry.folds(buffer_id), + content, + }) } /// Paint one semantic frontend's side window into a panel-sized grid diff --git a/src/semantic_render.rs b/src/semantic_render.rs index b2c8d2b..ee1fe66 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -396,7 +396,7 @@ pub struct SemanticRenderState { /// backward, so this is a high-water mark for the session. /// `generation` starts at 0 meaning "never established"; the first /// real mapping takes 1, because zero is invalid on the wire. - panel_mapping: Option<(u64, u64)>, + panel_mapping: Option<(crate::editor::PanelMappingSnapshot, u64)>, /// Highest presentation epoch allocated for this session; `0` means /// none has been. Advanced only when a frame is actually shipped, so /// a frame that fails validation does not burn an identity the peer @@ -631,16 +631,22 @@ impl SemanticRenderState { /// **not** reset the key — the high-water mark survives `Absent`, /// so a frame delayed across a hide cannot come back with a lower /// generation and be believed. - pub fn panel_mapping_generation(&mut self, fingerprint: Option) -> Option { - let fingerprint = fingerprint?; - let next = match self.panel_mapping { - Some((seen, generation)) if seen == fingerprint => generation, + pub fn panel_mapping_generation( + &mut self, + snapshot: Option, + ) -> Option { + let snapshot = snapshot?; + let next = match &self.panel_mapping { + // Compared STRUCTURALLY. A hash would make this + // probabilistic, and a collision here silently accepts a + // stale gesture — the exact failure the key exists for. + Some((seen, generation)) if *seen == snapshot => *generation, Some((_, generation)) => generation.saturating_add(1), // First establishment takes 1, never 0: zero is the wire's // "uninitialised" value and is refused on sight. None => 1, }; - self.panel_mapping = Some((fingerprint, next)); + self.panel_mapping = Some((snapshot, next)); Some(next) } @@ -648,7 +654,9 @@ impl SemanticRenderState { /// callers that must not have a side effect. #[must_use] pub fn panel_mapping_generation_peek(&self) -> Option { - self.panel_mapping.map(|(_, generation)| generation) + self.panel_mapping + .as_ref() + .map(|(_, generation)| *generation) } /// Whether the last shipped declaration is a `Present` whose epochs diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index 11c92ef..87d4140 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -192,6 +192,9 @@ pub struct TerminalScreen { tab_stops: BTreeSet, title: Option, generation: u64, + /// §5b — see [`Screen::mapping_revision`]. Separate from + /// `generation`, which advances for style and title too. + mapping_revision: u64, published: ScreenProjection, sync_started: Option, next_line_id: u64, @@ -240,6 +243,7 @@ impl TerminalScreen { tab_stops: default_tab_stops(size.cols as usize), title: None, generation: 0, + mapping_revision: 0, published, sync_started: None, next_line_id, @@ -269,24 +273,24 @@ impl TerminalScreen { } AnsiEvent::SetStyle(style) => { self.style = style; - self.changed(); + self.display_only_changed(); None } AnsiEvent::CarriageReturn => { self.cursor.col = 0; self.cursor.pending_wrap = false; - self.changed(); + self.display_only_changed(); None } AnsiEvent::Backspace => { self.cursor.col = self.cursor.col.saturating_sub(1); self.cursor.pending_wrap = false; - self.changed(); + self.display_only_changed(); None } AnsiEvent::Bell => { self.bell_count = self.bell_count.saturating_add(1); - self.changed(); + self.display_only_changed(); None } AnsiEvent::LineFeed | AnsiEvent::Index => { @@ -308,17 +312,17 @@ impl TerminalScreen { } AnsiEvent::SetTabStop => { self.tab_stops.insert(self.cursor.col); - self.changed(); + self.display_only_changed(); None } AnsiEvent::ClearTabStop => { self.tab_stops.remove(&self.cursor.col); - self.changed(); + self.display_only_changed(); None } AnsiEvent::ClearAllTabStops => { self.tab_stops.clear(); - self.changed(); + self.display_only_changed(); None } AnsiEvent::CursorUp(n) => { @@ -432,23 +436,23 @@ impl TerminalScreen { CharacterSetSlot::G0 => self.g0 = charset, CharacterSetSlot::G1 => self.g1 = charset, } - self.changed(); + self.display_only_changed(); None } AnsiEvent::ShiftOut => { self.use_g1 = true; - self.changed(); + self.display_only_changed(); None } AnsiEvent::ShiftIn => { self.use_g1 = false; - self.changed(); + self.display_only_changed(); None } AnsiEvent::DeviceRequest(request) => Some(self.device_reply(request)), AnsiEvent::SetTitle(title) => { self.title = Some(sanitize_title(&title)); - self.changed(); + self.display_only_changed(); None } AnsiEvent::EraseToEol => { @@ -1466,6 +1470,32 @@ impl TerminalScreen { fn changed(&mut self) { self.generation = self.generation.saturating_add(1); + // §5b: by DEFAULT a change also moves the mapping. Anything not + // explicitly classified as display-only is treated as content, + // which fails in the safe direction — over-cancelling a gesture + // is a nuisance, under-cancelling one lets a stale coordinate + // reach a child. + self.mapping_revision = self.mapping_revision.saturating_add(1); + } + + /// A change that repaints but **cannot move what a coordinate + /// denotes** (§5b's stable controls). + /// + /// Style, title, bell, tab stops and pure cursor motion all land + /// here. The existing `generation` still advances — the screen does + /// look different — but `mapping_revision` does not, so a drag + /// survives them. Keying the panel's mapping on `generation` was + /// rejected for exactly this reason: it moves for all of these. + fn display_only_changed(&mut self) { + self.generation = self.generation.saturating_add(1); + } + + /// §5b — identity of what a terminal coordinate DENOTES. + /// + /// Advances with content and topology, and holds across the display + /// changes above. + pub fn mapping_revision(&self) -> u64 { + self.mapping_revision } fn current_snapshot(&self) -> ScreenSnapshot { ScreenSnapshot { @@ -1910,6 +1940,67 @@ mod tests { assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 0))); } + /// §5b G3 — the terminal **stable controls**. + /// + /// These are exactly the events that make `generation` unusable as a + /// mapping key: each one advances it. `mapping_revision` must hold + /// across all of them, or a drag over a panel terminal dies the + /// moment the child recolours a character or rings the bell. + #[test] + fn display_only_events_advance_the_generation_but_not_the_mapping() { + for (name, event) in [ + ("style", AnsiEvent::SetStyle(Style::default())), + ("title", AnsiEvent::SetTitle("t".to_owned())), + ("bell", AnsiEvent::Bell), + ("tab stop", AnsiEvent::SetTabStop), + ("clear tab stops", AnsiEvent::ClearAllTabStops), + ("carriage return", AnsiEvent::CarriageReturn), + ] { + let mut s = screen(2, 16); + let before_generation = s.snapshot().generation; + let before_mapping = s.mapping_revision(); + + s.apply_event(event); + + assert!( + s.snapshot().generation > before_generation, + "{name} repaints, so the display generation must advance \ + — otherwise this row proves nothing about the split" + ); + assert_eq!( + s.mapping_revision(), + before_mapping, + "{name} cannot change what a coordinate denotes, so the \ + MAPPING revision must hold" + ); + } + } + + /// §5b G2 — content and topology **do** move the mapping revision. + /// + /// The positive half. Without it, a `mapping_revision` that never + /// advanced at all would pass every stable control above. + #[test] + fn content_events_advance_the_mapping_revision() { + for (name, event) in [ + ("text", AnsiEvent::Text("hi".to_owned())), + ("line feed", AnsiEvent::LineFeed), + ( + "erase display", + AnsiEvent::EraseDisplay(crate::ansi::EraseMode::ToEnd), + ), + ("scroll up", AnsiEvent::ScrollUp(1)), + ] { + let mut s = screen(2, 16); + let before = s.mapping_revision(); + s.apply_event(event); + assert!( + s.mapping_revision() > before, + "{name} changes what a coordinate denotes" + ); + } + } + #[test] fn resize_only_adds_default_tab_stops_in_new_columns() { let mut s = screen(2, 16); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index b787dee..0719c6e 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -249,6 +249,25 @@ impl TerminalManager { self.screen_size(key.buffer_id) } + /// §5b — the terminal's **mapping revision** plus its per-view + /// scroll anchor: together, the identity of what a coordinate in + /// this view denotes. + /// + /// The anchor is part of it because the same coordinate names a + /// different retained row once the view scrolls, even with the + /// child's screen untouched. + #[must_use] + pub fn view_mapping_identity( + &self, + key: TerminalViewKey, + ) -> Option<(u64, Option)> { + let session = self.sessions.get(&key.buffer_id)?; + // `top` IS the anchor: `None` means following the live tail, + // which is itself a distinct state from any pinned row. + let anchor = self.views.get(&key).and_then(|view| view.top); + Some((session.screen.mapping_revision(), anchor)) + } + /// The shared screen's current size, read from the borrowed /// projection. /// diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index a379167..a0cda31 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -1392,8 +1392,8 @@ fn foreign_edit(session: &Session, text: &str) { /// The key as the daemon would compute it for `FID`, advancing on change. fn mapping_generation(session: &mut Session) -> Option { - let fingerprint = session.state.panel_mapping_fingerprint(FID); - session.render.panel_mapping_generation(fingerprint) + let snapshot = session.state.panel_mapping_snapshot(FID); + session.render.panel_mapping_generation(snapshot) } /// §5b G1 — a **foreign** edit before the next render moves the key. @@ -1617,61 +1617,130 @@ fn g2_each_input_of_the_inverse_mapping_moves_the_key_on_its_own() { } } -/// §5b G2 — grid **rows** and **columns** each move the key. +/// §5b G2 — grid **rows** and **columns** are independent inputs, +/// proven by a **transposition**. /// -/// **Honest limit, recorded because mutation testing found it:** these -/// two legs do NOT discriminate `rows`/`cols` from their product. -/// Collapsing the key to `rows * cols` leaves both GREEN, because -/// `last_content_cols` co-varies with a column change and the panel's -/// row count co-varies with a resize — the key still moves, by another -/// input. Only a **transposition** (2×6 → 6×2, identical product) would -/// isolate it, and no production path reaches one: rows come from the -/// band's height and columns from the frame declaration, and nothing -/// swaps them. -/// -/// The key hashes them separately anyway. That is cheap and correct, -/// and the alternative — hashing a product because no test can currently -/// tell the difference — would be choosing the weaker construction for -/// the convenience of the test suite. What these legs *do* pin is that -/// each dimension moves the key at all, which is what the rest of the -/// slice depends on. +/// Revision-16's version changed one dimension at a time and could not +/// discriminate: `last_content_cols` co-varies with a column change, so +/// collapsing the key to `rows * cols` stayed green. I recorded that as +/// unwitnessable and claimed no production path reached a +/// same-area transition. **That was wrong** — resize plus redeclare +/// gets there: 4×80 → 8×40 holds the product at 320 while swapping the +/// dimensions, and `last_content_cols` is not refreshed until the next +/// render, so the two grid fields are isolated. #[test] -fn g2_grid_rows_and_columns_are_independent_inputs() { - // ROWS come from the band's own height, not the frame's total rows — - // declaring a shorter frame leaves a 4-row panel a 4-row panel. The - // resize path is the one that actually changes them. +fn g2_a_transposed_grid_moves_the_key_at_an_unchanged_area() { + let mut session = Session::new(); + open_panel(&session, "g2t", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a key"); + + // 4×80 → 8×40. Same area, different shape, and no render between. + assert!( + session.state.apply_panel_resize_rows(FID, 8), + "the resize must be accepted, or the transposition never happens" + ); + session.declare(2, 24, 40); + + let after = mapping_generation(&mut session).expect("a key"); + assert!( + after > before, + "a transposed grid inverts differently at the same area — a key \ + hashing rows*cols would not notice ({before} → {after})" + ); +} + +/// §5b G3 — **focus** is a stable input. +/// +/// Missing from the first version of G3, which covered only idle and +/// cursor. Focus in particular is the one a naive +/// implementation gets wrong, because the panel frame carries a +/// `focused` flag and it is tempting to fold the whole frame into the +/// key. +#[test] +fn g3_focus_is_a_stable_input() { + let mut session = Session::new(); + open_panel(&session, "g3b", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let baseline = mapping_generation(&mut session).expect("a key"); + + // Focus. The band's `focused` flag flips; no byte moves. + { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("side"); + core.focus_window(FID, side); + } + assert_eq!( + mapping_generation(&mut session), + Some(baseline), + "focus decides CHROME, never which byte a cell denotes — and a \ + click focuses the panel mid-gesture" + ); + + // Styling is pinned STRUCTURALLY rather than by driving a theme + // change here: `PanelMappingSnapshot` has no style field at all, so + // there is nothing a recolour could touch. The terminal side — where + // a convenient style-bumping counter DOES exist and had to be + // rejected — is pinned in `screen.rs`'s own tests, at the level the + // classification lives. +} + +/// §5b — the snapshot **selects the right domain by target kind**. +/// +/// Added because mutation testing found the branch unwitnessed: routing +/// terminal panels through the DOCUMENT arm — keying them on the +/// buffer's revision, which §5b explicitly rejects — left all thirty-five +/// other rows green. The daemon-level half of the terminal contract is +/// that the branch is taken at all; `screen.rs` owns the half that says +/// the revision it reads classifies events correctly. +#[test] +fn the_mapping_snapshot_picks_the_terminal_domain_for_a_terminal_panel() { + // Document panel → the document domain. { let mut session = Session::new(); - open_panel(&session, "g2rows", 4); + open_panel(&session, "doc", 4); session.declare(1, 24, 80); let _ = session.present(); - - let before = mapping_generation(&mut session).expect("a key"); + let snapshot = session + .state + .panel_mapping_snapshot(FID) + .expect("a presentable document panel"); assert!( - session.state.apply_panel_resize_rows(FID, 6), - "the resize must be accepted, or this leg proves nothing" - ); - let after = mapping_generation(&mut session).expect("a key"); - assert!( - after > before, - "changing grid ROWS alone must move the key — an area product \ - would miss a transposition" + matches!( + snapshot.content(), + pmacs::editor::PanelMappingContent::Document { .. } + ), + "a document panel is keyed on its buffer's content revision" ); } - // COLUMNS come from the declaration. + // Terminal panel → the terminal domain. { let mut session = Session::new(); - open_panel(&session, "g2cols", 4); session.declare(1, 24, 80); - let _ = session.present(); - - let before = mapping_generation(&mut session).expect("a key"); - session.declare(2, 24, 40); - let after = mapping_generation(&mut session).expect("a key"); + exec( + &session.state, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"sleep 30\" }, display = \"panel\" }", + ); + let _ = session.frame(); + let snapshot = session + .state + .panel_mapping_snapshot(FID) + .expect("a presentable terminal panel"); assert!( - after > before, - "changing grid COLUMNS alone must move the key" + matches!( + snapshot.content(), + pmacs::editor::PanelMappingContent::Terminal { .. } + ), + "a terminal panel is keyed on the SCREEN's mapping revision \ + and scroll anchor — its buffer revision tracks something \ + else entirely and would both miss real changes and fire on \ + non-changes" ); } } From 0bdfc9b6ddd7e0f588055bbc0bf182ec36d5e98c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 15:10:07 +0200 Subject: [PATCH 08/35] fix(panel): the terminal mapping half --- classification, domain, publication Three of the four terminal gaps. The fourth is recorded as owed rather than faked; see below. **THE STABLE CLASSIFICATION WAS INCOMPLETE.** Cursor motion still advanced the mapping revision: `restore_cursor`, `horizontal_tab`, `move_vertical`, `move_horizontal`, `set_col` and `set_row` all called `changed()`. Moving the caret denotes nothing new, and a child that merely repositions its cursor would have cancelled a drag. All six take the display-only path now. Worse, **rewriting the same glyph under another style advanced it**, which is precisely the control SS5b requires to hold. `write_character` now compares the glyph before writing --- sampled BEFORE `clear_wide_at`, which blanks a cell that is part of a wide pair and would otherwise make every rewrite look like a change. That ordering was found by instrumenting the failing row, not by reading the code. **THE SNAPSHOT CARRIED DOCUMENT-ONLY STATE FOR TERMINALS.** `view_top`, `view_left`, wrap, content columns, fold policy and folds describe a document projection and take no part in a terminal's, where the child's screen decides the mapping. They live inside the `Document` arm now; only common geometry --- buffer identity, rows, columns --- stays outside. **AND THE REVISION WAS NOT PUBLICATION-CONSISTENT.** `view_mapping_identity` read the LIVE screen revision while `projection_ref` returns the last PUBLISHED cells, so buffered output under synchronized-output would stamp displayed cells with authority they were never painted under --- a frontend echoing a generation matching nothing it can see. `ScreenProjection` carries `mapping_revision` now and the published value is what is read. **The witnesses were separated across the seam**, which review named exactly: `screen.rs` proved the counter, the daemon proved enum selection, and a `view_mapping_identity` returning a constant would have left both green. A daemon-level row now drives real events through a panel terminal and asserts the daemon's generation moves on a new glyph and holds across a style-only rewrite and across cursor motion. **OWED, NOT DONE: the scroll-anchor row.** The anchor is in the key, but three attempts failed to drive a scroll from this fixture --- `scroll_lines` wants a viewport the projection registers on its own schedule, and `scroll_view` with an explicit size reports no movement after forty line feeds. Recorded in the ledger rather than faked or quietly dropped: without it, a constant ANCHOR alongside a live revision still passes every terminal row that exists. The two test hooks are `#[doc(hidden)] pub`, not `#[cfg(test)]`, because the rows needing them are integration tests and those link the library without `cfg(test)`. Verified: focused suite 37/37, `cargo test --lib` 1945 green, clippy clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 11 +++ src/editor.rs | 54 +++++++----- src/terminal/screen.rs | 62 +++++++++++-- src/terminal/view.rs | 39 ++++++++- .../bottom_panel_stage2b_daemon_acceptance.rs | 86 +++++++++++++++++++ 5 files changed, 223 insertions(+), 29 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index a0e5a9d..32b3f3f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -437,6 +437,17 @@ from #171 and #215. three of them lost to these two signatures. U9's synthetic-load control remains unrun and is the cheapest thing that would either implicate load or clear it. +- **OWED WITNESS — terminal scroll-anchor movement at the daemon + level.** The anchor is in the key (`PanelMappingContent::Terminal` + carries it) and the review asked for a row proving the daemon's + generation moves when it does. **Three attempts failed to drive a + scroll from the panel fixture**: `scroll_lines` needs a viewport the + panel projection registers on its own schedule, and `scroll_view` + with an explicit content size still reports no movement after forty + line feeds, so the history it would move into is not accumulating as + the fixture assumes. **Recorded rather than faked.** Without it, a + `view_mapping_identity` returning a constant ANCHOR while reporting a + live revision passes every terminal row that exists. - **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite, `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. diff --git a/src/editor.rs b/src/editor.rs index ecf6f0b..139bf84 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -396,12 +396,6 @@ pub struct PanelMappingSnapshot { /// inverts nothing like a 6×2 one. rows: u32, cols: u32, - view_top: usize, - view_left: u32, - wrap: crate::view::WrapMode, - content_cols: u32, - fold_projection: bool, - folds: Vec, content: PanelMappingContent, } @@ -422,6 +416,18 @@ pub enum PanelMappingContent { Document { /// The buffer's content revision, or `None` if it is gone. revision: Option, + /// Vertical viewport origin. + view_top: usize, + /// Horizontal viewport origin. GUI arc 1b makes this move. + view_left: u32, + /// Wrap mode: it decides how a source line becomes display rows. + wrap: crate::view::WrapMode, + /// Text width with the gutter reservation already subtracted. + content_cols: u32, + /// The owning frontend's fold projection policy. + fold_projection: bool, + /// Fold content, read at its source. + folds: Vec, }, /// A terminal panel: the screen's **mapping revision** and the /// view's scroll anchor. @@ -2545,27 +2551,33 @@ impl EditorState { .get(buffer_id) .ok() .map(crate::buffer::Buffer::revision); - PanelMappingContent::Document { revision } + PanelMappingContent::Document { + revision, + view_top: window.view_top, + view_left: window.view_left, + wrap: window.last_wrap, + content_cols: window.last_content_cols, + fold_projection: core + .views + .get(&frontend_id) + .is_some_and(|view| view.fold_projection), + // Read at their SOURCE — the registry's ranges — rather + // than through the derived `VisibleLineMap`, whose only + // public summary is `is_identity()`. Too coarse: a fold + // edit leaving the map non-identity still changes which + // source line a row shows. + folds: core.fold_registry.folds(buffer_id), + } }; + // Only COMMON geometry lives out here. Everything below is + // domain-specific: `view_top`, `view_left`, wrap, gutter width + // and folds describe a DOCUMENT projection and take no part in a + // terminal's, where the child's screen decides the mapping. Some(PanelMappingSnapshot { buffer_id, rows: size.rows, cols: size.cols, - view_top: window.view_top, - view_left: window.view_left, - wrap: window.last_wrap, - content_cols: window.last_content_cols, - fold_projection: core - .views - .get(&frontend_id) - .is_some_and(|view| view.fold_projection), - // Read at their SOURCE — the registry's ranges — rather than - // through the derived `VisibleLineMap`, whose only public - // summary is `is_identity()`. That is too coarse: a fold edit - // leaving the map non-identity still changes which source - // line a row shows. - folds: core.fold_registry.folds(buffer_id), content, }) } diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index 87d4140..516c5d7 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -114,6 +114,15 @@ pub struct ScreenProjection { pub title: Option, /// Screen generation represented by this projection. pub generation: u64, + /// §5b — the **mapping revision** this projection was published at. + /// + /// Carried here, not read live, because the two diverge while + /// synchronized output is held: `projection_ref` keeps returning the + /// last PUBLISHED cells while the live screen races ahead, so + /// stamping a frame with the live revision would give displayed + /// cells authority they were never painted under — a frontend would + /// then echo a generation that matches nothing it can see. + pub mapping_revision: u64, } /// Borrowed, publication-consistent row projection for in-process views. @@ -128,6 +137,8 @@ pub(crate) struct BorrowedScreenProjection<'a> { pub cursor: Option, pub title: Option<&'a str>, pub generation: u64, + /// §5b — the mapping revision this projection was published at. + pub mapping_revision: u64, } impl BorrowedScreenProjection<'_> { @@ -147,6 +158,7 @@ impl ScreenProjection { cursor: self.cursor, title: self.title.as_deref(), generation: self.generation, + mapping_revision: self.mapping_revision, } } } @@ -224,6 +236,7 @@ impl TerminalScreen { cursor: Some(CellCoord::new(0, 0)), title: None, generation: 0, + mapping_revision: 0, }; Ok(Self { size, @@ -599,6 +612,7 @@ impl TerminalScreen { .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), title: self.title.as_deref(), generation: self.generation, + mapping_revision: self.mapping_revision, } } @@ -658,7 +672,7 @@ impl TerminalScreen { self.g0 = saved.g0; self.g1 = saved.g1; self.use_g1 = saved.use_g1; - self.changed(); + self.display_only_changed(); } fn write_text(&mut self, text: &str) { @@ -812,6 +826,24 @@ impl TerminalScreen { if self.modes.insert { self.insert_characters(width as u32); } + // §5b — did the GLYPH change, or only the pen? Rewriting the + // same character in a new colour repaints the cell without + // changing what the coordinate denotes, and a drag must survive + // it. + // + // Sampled BEFORE `clear_wide_at`, which blanks the cell when it + // is part of a wide pair — sampling after would compare the new + // glyph against a default and call every rewrite a change. + let glyph_changed = { + let row = self.cursor.row; + let col = self.cursor.col; + let cells = &self.active().rows[row].cells; + cells[col].glyph != Glyph::Char(ch) + || (width == 2 + && cells + .get(col + 1) + .is_none_or(|next| next.glyph != Glyph::Continuation)) + }; self.clear_wide_at(self.cursor.row, self.cursor.col); if width == 2 { self.clear_wide_at(self.cursor.row, self.cursor.col + 1); @@ -832,7 +864,11 @@ impl TerminalScreen { self.cursor.pending_wrap = false; } self.last_grapheme = Some((row, col)); - self.changed(); + if glyph_changed { + self.changed(); + } else { + self.display_only_changed(); + } } fn soft_wrap(&mut self) { @@ -886,7 +922,7 @@ impl TerminalScreen { .copied() .unwrap_or(cols - 1); self.cursor.pending_wrap = false; - self.changed(); + self.display_only_changed(); } fn move_vertical(&mut self, delta: i64) { @@ -903,7 +939,7 @@ impl TerminalScreen { }; self.cursor.row = moved.clamp(lo, hi); self.cursor.pending_wrap = false; - self.changed(); + self.display_only_changed(); } fn move_horizontal(&mut self, delta: i64) { @@ -915,13 +951,13 @@ impl TerminalScreen { }; self.cursor.col = moved.min(self.size.cols as usize - 1); self.cursor.pending_wrap = false; - self.changed(); + self.display_only_changed(); } fn set_col(&mut self, col: u32) { self.cursor.col = col.saturating_sub(1).min(self.size.cols - 1) as usize; self.cursor.pending_wrap = false; - self.changed(); + self.display_only_changed(); } fn set_row(&mut self, row: u32) { let base = if self.modes.origin { @@ -936,7 +972,7 @@ impl TerminalScreen { }; self.cursor.row = (base + row.saturating_sub(1) as usize).min(hi); self.cursor.pending_wrap = false; - self.changed(); + self.display_only_changed(); } fn set_position(&mut self, row: u32, col: u32) { self.set_row(row); @@ -1525,8 +1561,20 @@ impl TerminalScreen { .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), title: self.title.clone(), generation: self.generation, + mapping_revision: self.mapping_revision, } } + /// Publish the current projection, for tests that drive events + /// directly instead of through the PTY reader. + /// + /// `#[doc(hidden)]` rather than `#[cfg(test)]`: the rows that need + /// it are integration tests, which link the library WITHOUT + /// `cfg(test)` and so cannot see a gated item. + #[doc(hidden)] + pub fn publish_for_test(&mut self) { + self.publish(); + } + fn publish(&mut self) { self.published = self.current_projection(); } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 0719c6e..414d607 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -249,6 +249,34 @@ impl TerminalManager { self.screen_size(key.buffer_id) } + /// Apply one parsed event to a session's screen, for tests. + /// + /// Terminal output normally arrives on the PTY reader thread, which + /// no daemon-level test can drive deterministically. §5b's terminal + /// rows must nevertheless be witnessed **across the seam** — the + /// screen counter and the daemon's key are separately provable, and + /// a `view_mapping_identity` returning a constant would leave both + /// green — so this exists to join them. + /// + /// `#[doc(hidden)]` rather than `#[cfg(test)]`, because the rows + /// that need it are integration tests and those link the library + /// without `cfg(test)`. + #[doc(hidden)] + pub fn apply_event_for_test( + &mut self, + buffer_id: BufferId, + event: crate::ansi::AnsiEvent, + ) -> bool { + match self.sessions.get_mut(&buffer_id) { + Some(session) => { + session.screen.apply_event(event); + session.screen.publish_for_test(); + true + } + None => false, + } + } + /// §5b — the terminal's **mapping revision** plus its per-view /// scroll anchor: together, the identity of what a coordinate in /// this view denotes. @@ -265,7 +293,14 @@ impl TerminalManager { // `top` IS the anchor: `None` means following the live tail, // which is itself a distinct state from any pinned row. let anchor = self.views.get(&key).and_then(|view| view.top); - Some((session.screen.mapping_revision(), anchor)) + // The PUBLISHED revision, not the live one. While synchronized + // output is held, `projection_ref` keeps returning the last + // published cells while the screen races ahead — reading + // `screen.mapping_revision()` there would stamp displayed cells + // with authority they were never painted under, and the frontend + // would echo a generation matching nothing it can see. + let published = session.screen.projection_ref().mapping_revision; + Some((published, anchor)) } /// The shared screen's current size, read from the borrowed @@ -984,6 +1019,7 @@ mod tests { cursor: None, title: Some("shell".into()), generation: 7, + mapping_revision: 0, } } @@ -1264,6 +1300,7 @@ mod tests { cursor: None, title: None, generation: 2, + mapping_revision: 0, }; let mut state = TerminalViewState { top: Some(LogicalCellAnchor { diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index a0cda31..aa8dab0 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -1744,3 +1744,89 @@ fn the_mapping_snapshot_picks_the_terminal_domain_for_a_terminal_panel() { ); } } + +/// §5b — the terminal key **across the seam**. +/// +/// `screen.rs` proves the counter classifies events correctly, and the +/// row above proves the snapshot picks the terminal domain. Neither +/// notices a `view_mapping_identity` that returns a CONSTANT: the +/// classification is right, the branch is taken, and the daemon's key +/// still never moves. These rows join the two halves. +#[test] +fn g2_g3_a_terminal_panels_key_tracks_its_screen_and_anchor() { + use pmacs::ansi::AnsiEvent; + + let mut session = Session::new(); + session.declare(1, 24, 80); + exec( + &session.state, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"sleep 30\" }, display = \"panel\" }", + ); + let _ = session.frame(); + + let buffer_id = { + let core = session.state.core.borrow(); + let side = core.side_window_for(FID).expect("side"); + core.windows[&side].buffer_id + }; + let feed = |session: &Session, event: AnsiEvent| { + assert!( + session + .state + .terminal_manager + .borrow_mut() + .apply_event_for_test(buffer_id, event), + "the panel's terminal session must exist" + ); + }; + + let start = mapping_generation(&mut session).expect("a terminal panel has a key"); + + // CHANGING: a glyph appears where none was. + feed(&session, AnsiEvent::Text("A".to_owned())); + let after_glyph = mapping_generation(&mut session).expect("a key"); + assert!( + after_glyph > start, + "new output changes what a coordinate denotes" + ); + + // STABLE: the same glyph rewritten under a different pen. This is + // the row that forced `write_character` to compare glyphs — a + // blanket bump made a recolour cancel the drag. + feed(&session, AnsiEvent::CursorPosition { row: 1, col: 1 }); + feed( + &session, + AnsiEvent::SetStyle(pmacs_protocol::Style::default()), + ); + feed(&session, AnsiEvent::Text("A".to_owned())); + assert_eq!( + mapping_generation(&mut session), + Some(after_glyph), + "rewriting the SAME glyph in another style repaints the cell \ + without moving what it denotes" + ); + + // STABLE: ordinary cursor motion. + feed(&session, AnsiEvent::CursorPosition { row: 2, col: 3 }); + assert_eq!( + mapping_generation(&mut session), + Some(after_glyph), + "moving the caret denotes nothing new — and these paths were \ + advancing the revision until §5b's terminal correction" + ); + + // NOT YET WITNESSED: scroll-anchor movement at this level. + // + // The anchor IS in the key — `PanelMappingContent::Terminal` carries + // it — but driving a scroll from here has defeated three attempts: + // `scroll_lines` needs a viewport the panel projection registers on + // its own schedule, and `scroll_view` with an explicit size still + // reports no movement after forty line feeds, so the history the + // scroll would move into is not accumulating the way this fixture + // assumes. + // + // Recorded as OWED rather than faked. Without it, a + // `view_mapping_identity` that returned a constant ANCHOR while + // reporting a live revision would pass every row above. +} From 921989a77ceb3fdcae8eda3e13dd1b71d4b181b6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 16:28:09 +0200 Subject: [PATCH 09/35] test(panel): the terminal scroll-anchor row, which I had given up on Closes the fourth terminal gap. The row I could not drive was reproducible; **my delta had the wrong sign** --- `scroll_view(key, viewport, 3)` moves, `-1` does not --- and I concluded the fixture was at fault after three attempts rather than trying the other direction. The sequence: `frame()` registers the panel terminal view, the key is built from the side window, the viewport is `panel_grid_size` less its mode-line row, forty published line feeds build the history, and the baseline is taken AFTER that history exists. Taking it after is what makes the leg discriminating --- a constant anchor leaves the post-history baseline unchanged, so the row fails even while `mapping_revision` is perfectly live. Two mutations, each failing this row alone: constant ANCHOR, live revision -> the anchor is not in the key constant REVISION, live anchor -> the screen is not in the key Neither passes on the other's evidence, which is the separation the terminal half needed: `screen.rs` proves the counter classifies events, the domain row proves the branch is taken, and these two prove the daemon's key actually reads both halves of what `view_mapping_identity` returns. The owed-witness note is removed from the ledger. Verified: focused suite 37/37, clippy clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 11 ---- .../bottom_panel_stage2b_daemon_acceptance.rs | 64 +++++++++++++++---- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 32b3f3f..a0e5a9d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -437,17 +437,6 @@ from #171 and #215. three of them lost to these two signatures. U9's synthetic-load control remains unrun and is the cheapest thing that would either implicate load or clear it. -- **OWED WITNESS — terminal scroll-anchor movement at the daemon - level.** The anchor is in the key (`PanelMappingContent::Terminal` - carries it) and the review asked for a row proving the daemon's - generation moves when it does. **Three attempts failed to drive a - scroll from the panel fixture**: `scroll_lines` needs a viewport the - panel projection registers on its own schedule, and `scroll_view` - with an explicit content size still reports no movement after forty - line feeds, so the history it would move into is not accumulating as - the fixture assumes. **Recorded rather than faked.** Without it, a - `view_mapping_identity` returning a constant ANCHOR while reporting a - live revision passes every terminal row that exists. - **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite, `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index aa8dab0..5a09801 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -1816,17 +1816,57 @@ fn g2_g3_a_terminal_panels_key_tracks_its_screen_and_anchor() { advancing the revision until §5b's terminal correction" ); - // NOT YET WITNESSED: scroll-anchor movement at this level. + // CHANGING: the view's **scroll anchor** moves, with the child's + // screen untouched. The same coordinate then names a different + // retained row. // - // The anchor IS in the key — `PanelMappingContent::Terminal` carries - // it — but driving a scroll from here has defeated three attempts: - // `scroll_lines` needs a viewport the panel projection registers on - // its own schedule, and `scroll_view` with an explicit size still - // reports no movement after forty line feeds, so the history the - // scroll would move into is not accumulating the way this fixture - // assumes. - // - // Recorded as OWED rather than faked. Without it, a - // `view_mapping_identity` that returned a constant ANCHOR while - // reporting a live revision would pass every row above. + // This is the leg that separates the anchor from the screen + // revision: the baseline is taken AFTER the history exists, so a + // constant anchor leaves it unchanged and the assertion below fails + // even though `mapping_revision` is perfectly live. + let key = { + let core = session.state.core.borrow(); + let side = core.side_window_for(FID).expect("side"); + pmacs::terminal::view::TerminalViewKey::new(FID, side, buffer_id) + }; + let viewport = { + let core = session.state.core.borrow(); + let grid = core.panel_grid_size(FID).expect("a presentable panel"); + pmacs_protocol::CellSize::new(grid.rows.saturating_sub(1), grid.cols) + }; + for _ in 0..40 { + feed(&session, AnsiEvent::LineFeed); + } + let anchor_of = |session: &Session| { + session + .state + .terminal_manager + .borrow() + .view_mapping_identity(key) + .expect("the panel's terminal view") + .1 + }; + + let before_scroll = mapping_generation(&mut session).expect("a key"); + let anchor_before = anchor_of(&session); + assert!( + session + .state + .terminal_manager + .borrow_mut() + .scroll_view(key, viewport, 3), + "the scroll must actually move, or this leg proves nothing" + ); + + assert_ne!( + anchor_of(&session), + anchor_before, + "scrolling pins the view to a retained row, so the anchor moves" + ); + let after_scroll = mapping_generation(&mut session).expect("a key"); + assert!( + after_scroll > before_scroll, + "the anchor is part of the mapping — the same coordinate now \ + names a different retained row, with the screen untouched" + ); } From 7da2f34b9449f4d2ef955048b3a53d5e801a21db Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 18:42:53 +0200 Subject: [PATCH 10/35] feat(panel): outbound family selection (G6a/G7a half) The producer half of bilateral gating. Inbound routing, the four refusal quadrants and the positive routing controls are the next commit; the full eleven-stage `--protocol` gate runs at that checkpoint, once all of them discriminate. `SemanticRenderState` gains `peer_knows_mapped_panel`, set in `for_peer` beside the six existing capability bools --- the pattern this codebase already uses to bake a negotiated version into the producer. A `>= v25` peer receives `PresentMapped`; every older peer receives legacy `Present`, unchanged. **THE ORDERING IS LOAD-BEARING AND IS WRITTEN DOWN AT THE SITE.** Projection first, THEN capture-and-advance the mapping key, THEN construct the payload. A terminal projection registers the view whose scroll anchor the key reads, so capturing earlier stamps a frame with a key derived from an anchor that does not yet exist. One case is deliberately not a fallback: if a `>= v25` peer has no presentable mapping to stamp, the producer publishes `Absent` rather than dropping to legacy `Present`. Falling back would hand a peer the family it did not negotiate --- the bypass the gate exists to close, arriving from the daemon's own side. **Thirty existing rows broke, and that is the gate working.** The fixture negotiates `PROTOCOL_VERSION`, so every panel test suddenly received the mapped family where it matched `Present` literally. Those rows are about the PROJECTION, not the wrapper, so the fixture helpers are family-agnostic now and which family carries a frame is pinned by the G6/G7/G8 rows rather than incidentally by thirty others. Verified: focused suite 37/37, `cargo test --lib` 1945 green, clippy clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/semantic_render.rs | 43 +++++++++++++++++-- .../bottom_panel_stage2b_daemon_acceptance.rs | 22 ++++++++-- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/semantic_render.rs b/src/semantic_render.rs index ee1fe66..5df199b 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -254,6 +254,15 @@ pub struct SemanticRenderState { /// rows form. Also gates the per-row detail lookup: a peer that /// cannot carry a detail does not pay to resolve one. peer_knows_minibuffer_rows: bool, + /// §5b — whether the peer negotiated `>= v25` and therefore takes + /// the **mapped** panel family. + /// + /// The families are exclusive in both directions: a `>= v25` peer + /// receives `PresentMapped` and never legacy `Present`, and a + /// `<= v24` peer the reverse. "Send whichever and let the receiver + /// cope" would make negotiation a sender convention rather than a + /// gate. + peer_knows_mapped_panel: bool, /// Last emitted `CompletionPopup` payload per buffer (Arc 1a /// Q#C5), for cached-compare suppression (see /// [`CompletionPopupFacts`]). @@ -536,6 +545,8 @@ impl SemanticRenderState { s.peer_knows_statusline_segments = negotiated_protocol_version >= 18; s.peer_knows_terminal_frames = negotiated_protocol_version >= 19; s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION; + s.peer_knows_mapped_panel = + negotiated_protocol_version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION; s } @@ -556,6 +567,7 @@ impl SemanticRenderState { last_menu_prompt: HashMap::new(), last_minibuffer: None, peer_knows_minibuffer_rows: true, + peer_knows_mapped_panel: false, last_completion_popup: HashMap::new(), last_summary: HashMap::new(), last_status: HashMap::new(), @@ -1436,7 +1448,7 @@ impl SemanticRenderState { self.publish_absent_panel(out); return; }; - let payload = PanelFramePayload::Present(PanelFrame { + let frame = PanelFrame { buffer_id: projection.buffer_id, panel_epoch, geometry_epoch: geometry.geometry_epoch, @@ -1444,7 +1456,28 @@ impl SemanticRenderState { cells: projection.cells, cursor: projection.cursor, focused: projection.focused, - }); + }; + // §5b — ORDER MATTERS. The projection is prepared above, THEN + // the key is captured, THEN the payload is built. A terminal + // projection registers the view whose scroll anchor the key + // reads, so capturing earlier would stamp a frame with a key + // derived from an unregistered anchor. + let payload = if self.peer_knows_mapped_panel { + let snapshot = state.panel_mapping_snapshot(self.frontend_id); + let Some(mapping_generation) = self.panel_mapping_generation(snapshot) else { + // No presentable mapping means nothing to stamp. Falling + // back to the legacy variant here would hand a v25 peer + // the family it did not negotiate. + self.publish_absent_panel(out); + return; + }; + PanelFramePayload::PresentMapped { + frame, + mapping_generation, + } + } else { + PanelFramePayload::Present(frame) + }; // Complete-payload comparison FIRST, like the terminal pass: only // validated payloads are ever stored, so a payload equal to the // baseline has already passed and re-running the per-cell width @@ -1453,8 +1486,10 @@ impl SemanticRenderState { self.panel_error_latched = false; return; } - let PanelFramePayload::Present(frame) = &payload else { - unreachable!("the Present payload was constructed immediately above"); + let (PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }) = + &payload + else { + unreachable!("a Present payload was constructed immediately above"); }; match frame.validate() { Ok(()) => { diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index 5a09801..880ed25 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -117,9 +117,16 @@ impl Session { first } + /// The frame from whichever Present family this session negotiated. + /// + /// Family-agnostic on purpose: these rows are about the PROJECTION, + /// and which wrapper carries it is §5b's own concern, pinned by the + /// G6/G7/G8 rows rather than incidentally by thirty others. fn present(&mut self) -> PanelFrame { match self.frame() { - Some(PanelFramePayload::Present(frame)) => frame, + Some( + PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }, + ) => frame, other => panic!("expected a Present panel payload, got {other:?}"), } } @@ -522,7 +529,10 @@ fn acc41_degenerate_geometry_fails_closed_to_zero_usable_grid() { session.declare(1, ROWS, COLS); open_panel(&session, "*panel*", 4); assert!( - matches!(session.frame(), Some(PanelFramePayload::Present(_))), + matches!( + session.frame(), + Some(PanelFramePayload::Present(_) | PanelFramePayload::PresentMapped { .. }) + ), "{label}: fixture precondition — a band was visible first" ); @@ -898,7 +908,9 @@ fn acc45_one_statusline_invocation_serves_the_document_and_the_panel() { let panel_rows = messages .iter() .find_map(|message| match message { - InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(rows_of(frame)), + InstanceMessage::PanelFrame( + PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }, + ) => Some(rows_of(frame)), _ => None, }) .expect("a panel frame"); @@ -1327,7 +1339,9 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() { session.state.core.borrow_mut().focus_window(FID, panel); let frame = match session.frame() { - Some(PanelFramePayload::Present(frame)) => frame, + Some( + PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }, + ) => frame, other => panic!( "a legally wide panel must still present its terminal; got {other:?} \ — and the durable state says hidden={}", From 0fff640d152208f55dc3aedd88448de9040c48a9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 19:02:53 +0200 Subject: [PATCH 11/35] feat(panel): inbound family gating and the mapping check The receiving half of bilateral gating, plus the constructor fix review found. The eight G6/G7/G8 witness rows and the GPU side are next; the full eleven-stage `--protocol` gate runs once they discriminate. **THE FAMILY IS DECIDED FIRST, FROM THE AUTHENTICATED SESSION.** `peer_uses_mapped_panel_family` reads `session_state(source)`, never the payload's `frontend_id` --- that field is untrusted on every inbound variant, and looking negotiation up by it would let a peer claim another session's family. The order is stated at both arms: family, then the existing epoch ladder, then the mapping generation, then dispatch. It cannot depend on the variant's contents, because it decides which variant is admissible at all. Both wrong-family cases are REFUSALS, not fallbacks. A `>= v25` session sending the bare `PanelPointer` is dropped rather than handled under legacy semantics --- handling it would leave the mapping hole reachable by choosing a discriminant, which is the entire bypass. A `<= v24` session sending `PanelPointerMapped` is dropped too, even though a peer compiled from this crate can encode the discriminant: negotiation is a gate, not a sender convention. `panel_mapping_is_current` is the ladder's finest rung. `buffer_id` catches an A->B replacement, `panel_epoch` a close/reopen, `geometry_epoch` a declaration race, and this catches the text under the cell changing --- a foreign edit, a fold, a reload, none of which moves an epoch. Zero is refused outright, and the check reads through the same accessor projection stamps with, so the two cannot drift. **And `new()` contradicted its own contract.** It documents a current-build peer and enables every other current capability, but I initialised `peer_knows_mapped_panel` to `false` --- so an implicitly current peer was sent the LEGACY family. Set true, with the doc extended to say the assumption covers later capabilities too. **Four daemon rows broke, and that is G8a firing.** They drive `FrontendEvent::PanelPointer` through sessions negotiated at `PROTOCOL_VERSION`, which is now 25 --- so the bare variant is refused, correctly. They negotiate `LEGACY_PANEL_VERSION` now, which both fixes them and makes them explicit legacy positive controls rather than rows that happened to pass. Note for the eventual rebase: this branch is based on main, where `dispatch_semantic_panel_pointer` still takes four arguments. Threading `mods` is R-a, owned by `panel-pointer-replay`; the mapped arm destructures `mods` into `..` here and will pass it through when the lanes meet. Verified: focused suite 37/37, `cargo test --lib` 1945 green, clippy clean. **Amended.** The first version of this message claimed the lib suite green when it was not: I piped `cargo test` through `tail`, so the pipeline exited 0 and my `&&` chain committed on a failure I had not read. The four rows above are what it was reporting. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 137 +++++++++++++++++++++++++++++++++++++---- src/semantic_render.rs | 9 ++- 2 files changed, 131 insertions(+), 15 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 3bba690..7f52a67 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -70,8 +70,8 @@ use crate::protocol::{ ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, - PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, SessionBootstrapRequest, - TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, + PANEL_MAPPING_MIN_VERSION, PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, + SessionBootstrapRequest, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; @@ -1033,6 +1033,53 @@ fn panel_event_epochs_are_current( /// The claimed `frontend_id` in the payload is never consulted anywhere: /// routing is by the authenticated transport `source`, so a forged id /// addresses nothing. +/// §5b — which panel-pointer family this session speaks. +/// +/// **Read from the AUTHENTICATED source, never from the payload's +/// `frontend_id`.** That field is untrusted on every inbound variant, +/// and looking the negotiation up by it would let a peer claim another +/// session's family — the forged cross-session claim G8e mutates for. +/// +/// Consulted BEFORE the payload is trusted, before any generation is +/// validated and before any mutation: the family decides which variant +/// is even admissible, so it cannot depend on the variant's contents. +fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: FrontendId) -> bool { + session_registry + .session_state(source) + .is_some_and(|state| state.negotiated_protocol_version >= PANEL_MAPPING_MIN_VERSION) +} + +/// §5b — whether an echoed mapping generation still names the mapping +/// the daemon holds. +/// +/// The last rung of the ladder and the finest: `buffer_id` catches an +/// A→B replacement, `panel_epoch` a close/reopen, `geometry_epoch` a +/// declaration race, and this catches **the text under that cell +/// changing** — a foreign edit, a fold, a reload, none of which moves +/// an epoch. +/// +/// **Zero is refused outright.** It is what a default-constructed or +/// half-initialised sender produces, so accepting it would let a peer +/// opt out of the check by sending nothing. +/// +/// Read through the SAME accessor projection stamps with, so "what the +/// frontend was shown" and "what the daemon checks" cannot drift. +fn panel_mapping_is_current( + editor: &EditorState, + semantic_states: &mut HashMap, + source: FrontendId, + echoed: u64, +) -> bool { + if echoed == 0 { + return false; + } + let snapshot = editor.panel_mapping_snapshot(source); + semantic_states + .get_mut(&source) + .and_then(|state| state.panel_mapping_generation(snapshot)) + .is_some_and(|current| current == echoed) +} + fn peer_may_send_panel_events( editor: &EditorState, session_registry: &SessionRegistry, @@ -2438,7 +2485,19 @@ fn handle_dispatcher_event( // the daemon's own state inside the dispatcher. Any // failure drops the event before any view, controller, // selection, menu, or PTY mutation. - if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + // §5b G8a — the family is decided FIRST, from the + // AUTHENTICATED session, and a `>= v25` session + // sending the bare variant is REFUSED rather than + // handled under legacy semantics. Handling it would + // leave the mapping hole reachable by choosing a + // discriminant, which is the whole of the bypass. + if !peer_uses_mapped_panel_family(session_registry, source) + && peer_may_send_panel_events( + editor, + session_registry, + semantic_states, + source, + ) && panel_event_epochs_are_current( editor, semantic_states, @@ -2450,6 +2509,50 @@ fn handle_dispatcher_event( editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); } } + FrontendEvent::PanelPointerMapped { + geometry_epoch, + panel_epoch, + buffer_id, + coord, + kind, + mapping_generation, + .. + } => { + // §5b — the mapped family, in this order: + // + // 1. family, from the AUTHENTICATED session + // 2. the existing epoch ladder + // 3. the mapping generation + // 4. only then, dispatch + // + // G8c is the first line: a `<= v24` session sending + // this variant is refused even though a peer built + // from this crate can encode the discriminant. + // Negotiation is a gate, not a sender convention. + if peer_uses_mapped_panel_family(session_registry, source) + && peer_may_send_panel_events( + editor, + session_registry, + semantic_states, + source, + ) + && panel_event_epochs_are_current( + editor, + semantic_states, + source, + geometry_epoch, + panel_epoch, + ) + && panel_mapping_is_current( + editor, + semantic_states, + source, + mapping_generation, + ) + { + editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + } + } FrontendEvent::Pointer { buffer_id, byte, @@ -5971,6 +6074,14 @@ mod tests { /// /// The session is registered because the dispatcher drops any event /// from an uninstalled session before it reaches a handler. + /// §5b — a session that negotiated the LEGACY panel family. + /// + /// These rows drive `FrontendEvent::PanelPointer`, which a `>= v25` + /// session may not send at all, so they must say which family they + /// are exercising. Naming it here also makes them explicit legacy + /// positive controls rather than tests that happened to pass. + const LEGACY_PANEL_VERSION: u32 = PANEL_MAPPING_MIN_VERSION - 1; + fn dispatch_panel_event( editor: &mut crate::editor::EditorState, fid: FrontendId, @@ -6271,7 +6382,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), event, @@ -6286,7 +6397,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), down(geometry_epoch, panel_epoch), @@ -6342,7 +6453,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6369,7 +6480,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6587,7 +6698,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6614,7 +6725,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelResizeRows { @@ -6643,7 +6754,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6700,7 +6811,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), wheel(buffer_id, geometry_epoch, panel_epoch), @@ -6716,7 +6827,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6782,7 +6893,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), wheel(terminal_buffer, geometry_epoch, panel_epoch), diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 5df199b..9fd8f57 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -551,7 +551,9 @@ impl SemanticRenderState { } /// Fresh session state for frontend `frontend_id`: no viewport - /// declared, nothing sent. Assumes a current-build peer (>= 18); + /// declared, nothing sent. Assumes a current-build peer (>= 18, and + /// current for every later capability too, including §5b's mapped + /// panel family); /// daemon sessions with a real negotiated version use /// [`Self::for_peer`]. #[must_use] @@ -567,7 +569,10 @@ impl SemanticRenderState { last_menu_prompt: HashMap::new(), last_minibuffer: None, peer_knows_minibuffer_rows: true, - peer_knows_mapped_panel: false, + // A current-build peer, like every other capability here. + // Leaving this `false` made `new()` contradict its own doc + // and emit the LEGACY family to an implicitly v25 peer. + peer_knows_mapped_panel: true, last_completion_popup: HashMap::new(), last_summary: HashMap::new(), last_status: HashMap::new(), From 65eef564bf2e9c69175266a2ca89601733e5bc5d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 19:15:40 +0200 Subject: [PATCH 12/35] fix(panel): one negotiated version per row, a literal boundary, and a reattached doc Three review corrections ahead of the witness rows. **THE LEGACY CONTROLS NEGOTIATED TWO DIFFERENT VERSIONS AT ONCE.** I moved their `SessionRegistry` to v24 and left their retained `SemanticRenderState` on `PROTOCOL_VERSION`, so the producer shipped `PresentMapped` to a session the daemon believed was v24. The rows passed, which is the problem: a control that negotiates two versions proves nothing about either. Five producers across the four rows now take `LEGACY_PANEL_VERSION`, the same constant the registry does. **The boundary is a LITERAL 24, not `PANEL_MAPPING_MIN_VERSION - 1`.** G6/G14 make the family boundary absolute; arithmetic against a moving constant would drag these rows forward on the next bump and they would silently begin testing v25 as "legacy". **And I split a rustdoc from its function again.** Inserting `peer_uses_mapped_panel_family` above `peer_may_send_panel_events` left the latter's doc block stranded, so my function inherited two incompatible descriptions and the documented one had none. Same mistake as `screen_size` two commits ago --- inserting an item at what looks like a blank line between declarations, when the line above is the next function's documentation. Each block sits directly above its own function now. Verified by EXIT STATUS, not by reading filtered output: `cargo test --lib` 0 (1945 passed), clippy 0, focused suite 0 (37 passed). The `composition_overhead_under_ten_percent` flake appeared once mid-run and passed isolated; it is the known wall-clock budget signature, not this diff. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 7f52a67..8d5db9a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1019,20 +1019,6 @@ fn panel_event_epochs_are_current( .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) } -/// Whether an authenticated source may send the v21 panel event family -/// (Q#BP9's "every gate keys on the daemon's own state"). -/// -/// All three inbound events require the same three facts, and they are -/// checked together so no arm can satisfy two and forget the third: an -/// installed **semantic** projection, a negotiated version that carries -/// the variants, and a `FrontendView` this daemon itself marked -/// panel-capable. A grid session, a pre-panel semantic peer, or a -/// non-panel-capable view is rejected before any payload state is -/// trusted. -/// -/// The claimed `frontend_id` in the payload is never consulted anywhere: -/// routing is by the authenticated transport `source`, so a forged id -/// addresses nothing. /// §5b — which panel-pointer family this session speaks. /// /// **Read from the AUTHENTICATED source, never from the payload's @@ -1080,6 +1066,20 @@ fn panel_mapping_is_current( .is_some_and(|current| current == echoed) } +/// Whether an authenticated source may send the v21 panel event family +/// (Q#BP9's "every gate keys on the daemon's own state"). +/// +/// All three inbound events require the same three facts, and they are +/// checked together so no arm can satisfy two and forget the third: an +/// installed **semantic** projection, a negotiated version that carries +/// the variants, and a `FrontendView` this daemon itself marked +/// panel-capable. A grid session, a pre-panel semantic peer, or a +/// non-panel-capable view is rejected before any payload state is +/// trusted. +/// +/// The claimed `frontend_id` in the payload is never consulted anywhere: +/// routing is by the authenticated transport `source`, so a forged id +/// addresses nothing. fn peer_may_send_panel_events( editor: &EditorState, session_registry: &SessionRegistry, @@ -6080,7 +6080,11 @@ mod tests { /// session may not send at all, so they must say which family they /// are exercising. Naming it here also makes them explicit legacy /// positive controls rather than tests that happened to pass. - const LEGACY_PANEL_VERSION: u32 = PANEL_MAPPING_MIN_VERSION - 1; + /// Stated as a LITERAL, not `PANEL_MAPPING_MIN_VERSION - 1`: + /// G6/G14 make the family boundary absolute, and arithmetic against + /// a moving constant would drag these rows forward on the next bump + /// — they would silently start testing v25 as "legacy". + const LEGACY_PANEL_VERSION: u32 = 24; fn dispatch_panel_event( editor: &mut crate::editor::EditorState, @@ -6354,7 +6358,7 @@ mod tests { let mut semantic_states = HashMap::new(); semantic_states.insert( fid, - crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + crate::semantic_render::SemanticRenderState::for_peer(fid, LEGACY_PANEL_VERSION), ); editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); @@ -6428,7 +6432,7 @@ mod tests { let mut semantic_states = HashMap::new(); semantic_states.insert( fid, - crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + crate::semantic_render::SemanticRenderState::for_peer(fid, LEGACY_PANEL_VERSION), ); editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); @@ -6652,7 +6656,7 @@ mod tests { let mut semantic_states = HashMap::new(); semantic_states.insert( fid, - crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + crate::semantic_render::SemanticRenderState::for_peer(fid, LEGACY_PANEL_VERSION), ); editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); @@ -6793,7 +6797,7 @@ mod tests { let mut semantic_states = HashMap::new(); semantic_states.insert( fid, - crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + crate::semantic_render::SemanticRenderState::for_peer(fid, LEGACY_PANEL_VERSION), ); editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); @@ -6880,7 +6884,7 @@ mod tests { let mut semantic_states = HashMap::new(); semantic_states.insert( fid, - crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + crate::semantic_render::SemanticRenderState::for_peer(fid, LEGACY_PANEL_VERSION), ); editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); From 7e28eb89369bdcf1433c4afe8e213b64a9b2910e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 19:25:15 +0200 Subject: [PATCH 13/35] feat(gpu): one negotiated panel family, used for acceptance and production The frontend half of bilateral gating. The nine discriminating rows (G6a/b, G7a/b, G8a-e) and the full protocol gate are the checkpoint after this. **ONE ENUM, DERIVED ONCE.** `PanelFamily::{Unsupported, Legacy, Mapped}` replaces the `panel_wire` bool, classified from `session_protocol_version` at all four existing sites --- initial attach and each reconnect --- and read by BOTH payload acceptance and pointer production. Deriving those two independently is how a frontend ends up accepting one family while producing the other: it would speak v25 inbound and v24 outbound, and neither side could tell. Acceptance refuses both wrong-family cases, atomically: a mapped session rejects legacy `Present` rather than painting a band whose cells it cannot safely invert (G8b), and a legacy session rejects `PresentMapped` (G8d). The mapped arm also refuses generation zero and any generation BELOW the one held --- nondecreasing, so a frame delayed across a hide cannot roll authority backward --- while a duplicate frame at a HIGHER generation is still accepted, because the daemon has re-keyed the mapping and echoing the stale value would have every gesture refused. Production goes through one family-aware sender used by both send sites, so they cannot drift. A mapped session with no retained generation sends NOTHING rather than the legacy variant: falling back is the frontend half of the bypass, and the daemon refuses it anyway. **And the live probe observed only the legacy family.** Left alone, mapped production could have worked end to end while the probe reported no panel --- a false negative in the one place that exists to tell us the band is real. It matches both now. One thing NOT done here, deliberately: the gesture-latch reset on an identity change is R-d, owned by `panel-pointer-replay`. I had copied it into the mapped arm before noticing `gesture_last_content_cell` does not exist on this branch --- it is replay-lane state. Two branches resetting the same latch would conflict at the rebase and neither would own the contract, so this arm installs the frame and its generation and nothing more. Verified by exit status: clippy 0, `-p pmacs-gpu` 0 (271 passed). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/attach.rs | 30 ++++++ pmacs-gpu/src/main.rs | 208 +++++++++++++++++++++++++++++++++------- 2 files changed, 203 insertions(+), 35 deletions(-) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 94a3bd8..cfb10cf 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -1106,6 +1106,36 @@ impl AttachClient { /// subsumes the other — the first catches an A→B buffer replacement, /// the second a close/hide/reopen of the *same* buffer — so both are /// carried rather than one being derived from the other. + /// §5b — the MAPPED gesture, echoing the generation of the frame + /// this frontend is displaying. + /// + /// A separate entry point rather than an `Option` on the one + /// above: the two variants are exclusive per session, and a + /// nullable field would let a caller send the mapped family with no + /// generation, which is the shape the daemon refuses. + #[allow(clippy::too_many_arguments)] // mirrors `PanelPointerMapped`'s wire shape exactly. + pub fn send_panel_pointer_mapped( + &self, + geometry_epoch: u64, + panel_epoch: u64, + buffer_id: BufferId, + coord: CellCoord, + kind: MouseKind, + mods: Modifiers, + mapping_generation: u64, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::PanelPointerMapped { + frontend_id: self.frontend_id, + geometry_epoch, + panel_epoch, + buffer_id, + coord, + kind, + mods, + mapping_generation, + }) + } + pub fn send_panel_pointer( &self, geometry_epoch: u64, diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 47d4664..523bf12 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -850,8 +850,14 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.last_title.clone_from(&frame.title); } match msg.as_ref() { + // §5b — BOTH families. Observing only the legacy + // one would let mapped production work perfectly + // while the live probe reported no panel at all, + // which is a false negative in the one place that + // exists to tell us the band is real. InstanceMessage::PanelFrame( - pmacs_protocol::panel::PanelFramePayload::Present(frame), + pmacs_protocol::panel::PanelFramePayload::Present(frame) + | pmacs_protocol::panel::PanelFramePayload::PresentMapped { frame, .. }, ) => { facts.panel_frames += 1; facts.panel_rows = frame.size.rows; @@ -1972,7 +1978,7 @@ struct State { /// Whether the negotiated session carries the panel wire at all. /// /// Keyed on the NEGOTIATED version, never the `Hello` baseline. - panel_wire: bool, + panel_family: PanelFamily, /// Set when a font/scale transaction has invalidated the panel's /// geometry declaration, so the caller that owns the client knows to /// re-declare under a `Metrics` trigger. @@ -2084,9 +2090,60 @@ struct PanelDrag { sent_rows: u32, } +/// §5b — which panel family this session negotiated. +/// +/// **One value, derived once from `session_protocol_version`, used for +/// BOTH payload acceptance and pointer production.** Deriving the two +/// independently is how a frontend ends up accepting one family while +/// producing the other, which is a bypass with extra steps: the peer +/// would be speaking v25 inbound and v24 outbound and neither side +/// could tell. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum PanelFamily { + /// Below `PANEL_MIN_VERSION`: no panel at all. + #[default] + Unsupported, + /// v21–v24: `Present` in, `PanelPointer` out. + Legacy, + /// v25 and later: `PresentMapped` in, `PanelPointerMapped` out. + Mapped, +} + +impl PanelFamily { + /// Classify a negotiated session version. + /// + /// Read from the NEGOTIATED version, never the `Hello` baseline: + /// that stays at the compatibility floor forever, so reading it + /// would leave the band permanently dark. + fn from_session_version(session_protocol_version: u32) -> Self { + if session_protocol_version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION { + Self::Mapped + } else if session_protocol_version >= PANEL_MIN_VERSION { + Self::Legacy + } else { + Self::Unsupported + } + } + + /// Whether a panel exists on this session at all. + fn carries_panel(self) -> bool { + !matches!(self, Self::Unsupported) + } +} + /// The GPU frontend's half of the bottom panel (Q#BP15, Q#BP15a, Q#BP16). #[derive(Default)] struct PanelBand { + /// §5b — the mapping generation the retained frame was published + /// at, echoed by every `PanelPointerMapped` this frontend sends. + /// + /// Moves ATOMICALLY with `frame`: a generation naming a frame that + /// was never installed would be authority for something the user + /// cannot see. + /// + /// **Nondecreasing, and NOT cleared by `Absent`** — a frame delayed + /// across a hide must not roll this frontend's authority backward. + mapping_generation: Option, /// The last **valid** frame received, retained until an authoritative /// `Absent`. /// @@ -2494,15 +2551,56 @@ impl App { else { return false; }; - let Some(client) = self.attach_client.as_ref() else { - return false; + // §5b — production reads the SAME negotiated family as + // acceptance. Deriving them separately is how a frontend ends + // up accepting one family and producing the other. + self.send_panel_gesture(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods); + true + } + + /// Ship one panel gesture in whichever family this session + /// negotiated (§5b). + /// + /// The single place that chooses, so acceptance and production + /// cannot disagree. A mapped session with no retained generation + /// sends NOTHING rather than falling back to the legacy variant: + /// falling back would be the frontend half of the bypass, and the + /// daemon would refuse it anyway. + fn send_panel_gesture( + &self, + geometry_epoch: u64, + panel_epoch: u64, + buffer_id: pmacs_protocol::BufferId, + coord: CellCoord, + kind: ProtocolMouseKind, + mods: Modifiers, + ) { + let (Some(client), Some(state)) = (self.attach_client.as_ref(), self.state.as_ref()) else { + return; }; - if let Err(e) = - client.send_panel_pointer(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods) - { + let sent = match state.panel_family { + PanelFamily::Unsupported => return, + PanelFamily::Legacy => { + client.send_panel_pointer(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods) + } + PanelFamily::Mapped => { + let Some(mapping_generation) = state.panel.mapping_generation else { + return; + }; + client.send_panel_pointer_mapped( + geometry_epoch, + panel_epoch, + buffer_id, + coord, + kind, + mods, + mapping_generation, + ) + } + }; + if let Err(e) = sent { eprintln!("pmacs-gpu: send_panel_pointer failed: {e}"); } - true } /// Ship a panel gesture at an explicitly chosen cell. @@ -2533,16 +2631,11 @@ impl App { let Some(coord) = coord else { return false; }; - if let Err(e) = client.send_panel_pointer( - frame.geometry_epoch, - frame.panel_epoch, - frame.buffer_id, - coord, - kind, - mods, - ) { - eprintln!("pmacs-gpu: send_panel_pointer failed: {e}"); - } + // §5b — through the one family-aware sender, so this site + // cannot drift from the other into producing the wrong variant. + let (geometry_epoch, panel_epoch, buffer_id) = + (frame.geometry_epoch, frame.panel_epoch, frame.buffer_id); + self.send_panel_gesture(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods); true } @@ -5389,7 +5482,7 @@ impl State { terminal: None, panel: PanelBand::default(), panel_text_buffers: Vec::new(), - panel_wire: false, + panel_family: PanelFamily::Unsupported, panel_metrics_changed: false, last_terminal_size_sent: None, terminal_frame_error_latched: false, @@ -6727,7 +6820,7 @@ impl State { /// `Hello` baseline: the baseline stays at the compatibility floor /// forever, so reading it here would leave the band permanently dark. fn set_panel_wire(&mut self, session_protocol_version: u32) { - self.panel_wire = session_protocol_version >= PANEL_MIN_VERSION; + self.panel_family = PanelFamily::from_session_version(session_protocol_version); } /// The band inset the document boundary is computed from. @@ -6845,7 +6938,7 @@ impl State { /// the attach client, so the whole state machine — dedup, exhaustion, /// the latch — is reachable without a daemon. fn next_geometry_declaration(&mut self, trigger: GeometryTrigger) -> Option<(u64, CellSize)> { - if !self.panel_wire || self.panel.exhausted { + if !self.panel_family.carries_panel() || self.panel.exhausted { return None; } let (total, advance) = self.declared_cell_total(); @@ -6895,20 +6988,13 @@ impl State { return false; } match payload { - // §5b — the mapped family is REFUSED until its gate lands. - // - // Refusing is the correct default at every intermediate - // commit, not a placeholder: G8d requires a `<= v24` - // frontend to reject `PresentMapped` outright, and until - // this frontend can prove it negotiated `>= v25` it is, for - // gating purposes, exactly such a peer. Painting it first - // and gating later would mean shipping a window in which the - // band is hit-tested with no mapping identity at all. - // - // The retention is ATOMIC, as G8d and G10 require: nothing - // about the previous frame, its generation or the pointer - // state is touched on the way out. - PanelFramePayload::PresentMapped { .. } => false, + // §5b G8d — a mapped frame is accepted ONLY by a session + // that negotiated the mapped family. Retention is ATOMIC on + // refusal: nothing about the previous frame, its generation + // or the pointer state is touched on the way out. + PanelFramePayload::PresentMapped { .. } if self.panel_family != PanelFamily::Mapped => { + false + } PanelFramePayload::Absent => { // Authoritative removal, and always safe. Note this does // NOT clear the geometry declaration: the frontend's frame @@ -6924,6 +7010,58 @@ impl State { self.panel.last_pointer_cell = None; had } + // §5b G8b — a mapped session REJECTS the legacy family + // rather than painting a band it cannot safely hit-test. A + // frame with no mapping identity is one whose cells cannot + // be inverted safely, so accepting it would reintroduce the + // hole from the receiving side. + PanelFramePayload::Present(_) if self.panel_family == PanelFamily::Mapped => false, + PanelFramePayload::PresentMapped { + frame, + mapping_generation, + } => { + // §5b — zero is the wire's uninitialised value and is + // refused like any mismatch, and the generation is + // NONDECREASING: a delayed lower frame must not roll + // this frontend's authority backward. + if mapping_generation == 0 + || self + .panel + .mapping_generation + .is_some_and(|held| mapping_generation < held) + { + return false; + } + if let Err(error) = frame.validate() { + eprintln!("pmacs-gpu: rejecting invalid mapped panel frame: {error}"); + return false; + } + // A duplicate frame at the SAME generation does no work. + // A duplicate at a HIGHER one still updates authority: + // the daemon has re-keyed the mapping, and echoing the + // old generation would have every gesture refused. + if self.panel.frame.as_ref() == Some(&frame) + && self.panel.mapping_generation == Some(mapping_generation) + { + return false; + } + // NOTE: the gesture-latch reset on an identity change is + // R-d, owned by `panel-pointer-replay`. It is not + // duplicated here — two branches resetting the same + // latch would conflict at the rebase and neither would + // own the contract. + let plan = TerminalPaintPlan::build_grid( + frame.size, + &frame.cells, + frame.cursor, + Self::terminal_palette(), + ); + self.panel.frame = Some(frame); + self.panel.mapping_generation = Some(mapping_generation); + self.panel.plan = Some(plan); + self.rebuild_panel_text_buffers(); + true + } PanelFramePayload::Present(frame) => { if let Err(error) = frame.validate() { eprintln!("pmacs-gpu: rejecting invalid panel frame: {error}"); From 4ecf09bf648a073134947249d58416053c8d1cf8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 20:18:12 +0200 Subject: [PATCH 14/35] fix(gpu): positive family gates, a probe that reads accepted state, and a third split doc **`Unsupported` WAS ACCEPTING LEGACY FRAMES.** I gated the legacy arm on `!= Mapped`, and `Unsupported` is neither --- so a session below `PANEL_MIN_VERSION` accepted a band it never negotiated. The `carries_panel()` check I had in mind guards `next_geometry_declaration`, a different seam entirely. Both present arms gate on their POSITIVE family now, which is the shape that cannot grow this hole again when a fourth family appears. **AND THE PROBE MEASURED THE PAYLOAD, NOT THE BAND.** It recorded panel facts before `apply_attach_message` ruled on the message, so once one valid frame had landed, a REJECTED frame --- wrong family, invalid, stale generation --- still supplied the expected text while the retained old frame supplied the rendering. The probe would report the band showing something it does not show, which is a false positive in the one place that exists to tell us the band is real. (The false NEGATIVE, observing only the legacy family, was the previous commit.) Facts now come from `state.panel.presented()` after the apply, and `panel_frames` counts only when the retained frame's identity actually moved: a duplicate or a refusal leaves the band exactly as it was, and counting either would say the daemon is painting when it is not. **Third rustdoc split in this slice**, same mechanism each time --- `screen_size`, `peer_may_send_panel_events`, now `send_panel_pointer`. I insert a function at what reads as a gap between declarations, when the lines above it are the NEXT function's documentation. The check is to look UP from the insertion point, not just down, and I will apply it rather than keep reporting the same correction. Verified by exit status: clippy 0, `-p pmacs-gpu` 0 (271 passed). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/attach.rs | 16 ++++----- pmacs-gpu/src/main.rs | 76 ++++++++++++++++++++++++++--------------- 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index cfb10cf..a21f111 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -1098,14 +1098,6 @@ impl AttachClient { }) } - /// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture - /// hit-tested locally to a panel CELL. Callers gate on - /// [`Self::session_protocol_version`] `>= 21`. - /// - /// `buffer_id` and `panel_epoch` close different holes and neither - /// subsumes the other — the first catches an A→B buffer replacement, - /// the second a close/hide/reopen of the *same* buffer — so both are - /// carried rather than one being derived from the other. /// §5b — the MAPPED gesture, echoing the generation of the frame /// this frontend is displaying. /// @@ -1136,6 +1128,14 @@ impl AttachClient { }) } + /// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture + /// hit-tested locally to a panel CELL. Callers gate on + /// [`Self::session_protocol_version`] `>= 21`. + /// + /// `buffer_id` and `panel_epoch` close different holes and neither + /// subsumes the other — the first catches an A→B buffer replacement, + /// the second a close/hide/reopen of the *same* buffer — so both are + /// carried rather than one being derived from the other. pub fn send_panel_pointer( &self, geometry_epoch: u64, diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 523bf12..94299cc 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -849,33 +849,50 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.last_frame_text = frame_probe_text(frame); facts.last_title.clone_from(&frame.title); } - match msg.as_ref() { - // §5b — BOTH families. Observing only the legacy - // one would let mapped production work perfectly - // while the live probe reported no panel at all, - // which is a false negative in the one place that - // exists to tell us the band is real. - InstanceMessage::PanelFrame( - pmacs_protocol::panel::PanelFramePayload::Present(frame) - | pmacs_protocol::panel::PanelFramePayload::PresentMapped { frame, .. }, - ) => { - facts.panel_frames += 1; - facts.panel_rows = frame.size.rows; - facts.panel_cols = frame.size.cols; - facts.panel_focused = frame.focused; - facts.panel_frame_text = grid_probe_text(&frame.cells); - if let Some(expected) = expected_panel_text.as_deref() - && facts.panel_frame_text.contains(expected) - { - facts.panel_text_observed = true; - } - } - InstanceMessage::PanelFrame( - pmacs_protocol::panel::PanelFramePayload::Absent, - ) => facts.panel_absent_observed = true, - _ => {} - } + // §5b — a panel message is only NOTED here; its facts + // are read from the retained state AFTER + // `apply_attach_message` has ruled on it. + // + // Reading the raw payload was a false-positive + // generator: once one valid frame had landed, a + // REJECTED frame — wrong family, invalid, stale + // generation — still supplied the expected text while + // the retained old frame supplied the rendering. The + // probe would report the band showing something it does + // not show. + let panel_message = matches!(msg.as_ref(), InstanceMessage::PanelFrame(_)); + let panel_before = state + .panel + .presented() + .map(|frame| (frame.panel_epoch, frame.geometry_epoch, frame.size)); + state.apply_attach_message(*msg); + + if panel_message { + match state.panel.presented() { + Some(frame) => { + let identity = (frame.panel_epoch, frame.geometry_epoch, frame.size); + // Counted only when the retained frame + // actually moved: a duplicate or a refusal + // leaves the band exactly as it was, and + // counting either would say the daemon is + // painting when it is not. + if panel_before != Some(identity) { + facts.panel_frames += 1; + } + facts.panel_rows = frame.size.rows; + facts.panel_cols = frame.size.cols; + facts.panel_focused = frame.focused; + facts.panel_frame_text = grid_probe_text(&frame.cells); + if let Some(expected) = expected_panel_text.as_deref() + && facts.panel_frame_text.contains(expected) + { + facts.panel_text_observed = true; + } + } + None => facts.panel_absent_observed = true, + } + } if is_snapshot { // The dual declaration: a byte viewport for a // document, a cell size for a terminal. The daemon @@ -7015,7 +7032,12 @@ impl State { // frame with no mapping identity is one whose cells cannot // be inverted safely, so accepting it would reintroduce the // hole from the receiving side. - PanelFramePayload::Present(_) if self.panel_family == PanelFamily::Mapped => false, + // Gated on the POSITIVE family, not on "not mapped". + // `Unsupported` is neither, and a `!= Mapped` test let it + // through — a session below `PANEL_MIN_VERSION` accepting a + // band it never negotiated. `carries_panel()` guards the + // geometry declaration, not this seam. + PanelFramePayload::Present(_) if self.panel_family != PanelFamily::Legacy => false, PanelFramePayload::PresentMapped { frame, mapping_generation, From a242038e8af50309e13547b8b39a5510ac2d124c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 21:05:49 +0200 Subject: [PATCH 15/35] test(panel): the nine family-gate rows, and three that proved nothing The G6/G7/G8 matrix, plus the probe correction and the corrections review found in my first attempt at these rows. This is the coherent checkpoint: full eleven-stage `--protocol` gate, green. **THREE OF THESE ROWS PASSED WITHOUT PROVING THEIR CLAIM**, and each failed differently: G8b/G8d had no atomicity. G8b installed a LEGACY frame and then switched the session to Mapped, so `mapping_generation` was `None` throughout --- asserting it stayed `None` after the refusal asserted nothing. Each direction now uses an independent state, accepts a CORRECT-FAMILY baseline so there is real authority to preserve, and primes both pointer latches. Two new mutations pin it: clearing authority before refusing fails G8b, discarding the retained frame fails G8d. The family-gate mutation touched neither. G8e covered one direction. An authority check that only holds one way is one a peer walks around by choosing which identity to forge, so a mapped session now also fails to borrow a legacy identity --- and BOTH claimed identities have real registered sessions, or a payload-keyed lookup fails for want of a session rather than for want of authority. That was why G8e's own named mutation did not bite on the first attempt. G6b measured ambient state. It pre-focused the panel and then asserted against `active_window_id()`, which tracks `active_frontend` too --- satisfiable by a frontend switch that never routed anything. Every routing and refusal row asserts `views[fid].active` now, with the document precondition stated rather than assumed. **And the probe measured the payload rather than the band, twice over.** Its identity tuple was `(panel_epoch, geometry_epoch, size)`, which ordinary content, focus, cursor and generation updates all leave unchanged --- so accepted frames went uncounted, including the identical-frame/higher-generation case this slice requires, and a fixture waiting for two frames would wait forever. It snapshots the complete accepted authority now, `(presented frame, mapping_generation)`, and keeps the raw payload kind ONLY to tell a real `Absent` from a refusal: inferring absence from `presented() == None` turned a rejection into "the daemon says there is no band", a different fact entirely. Nine rows, ten mutations, each biting its own: G6a legacy outbound G7a mapped outbound, live generation G6b legacy inbound routing G7b mapped inbound routing G8a bare from v25 refused G8c mapped from v24 refused G8b legacy at v25 refused, atomically G8d mapped at v24 refused, atomically G8e both forgery directions plus: an Unsupported session accepts NEITHER family G6c/G7c remain replay-lane effects. **The gate earned its keep**: it caught a real regression I would have shipped. `one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client` counter-offers `PROTOCOL_VERSION`, now 25, so it is a MAPPED session whose helper drained for legacy `Present` and timed out. Third suite whose helpers assumed one family --- daemon acceptance, the GPU probe, now GPU acceptance --- each written when only one family existed and each quietly deciding what "a panel arrived" means. Four `--protocol` runs were needed. Three failed on unrelated signatures: the composition budget twice, in different steps, and `setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` once, a new signature. All are recorded in the lane ledger rather than `ci-red-signatures.md`, which ends at U9 here while the unmerged replay branch already holds a U10. Gates: all eleven green under `env -u TMPDIR` with `--protocol`, log 20260815T185708Z, verified by exit status. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 13 + pmacs-gpu/src/main.rs | 198 ++++++++-- src/daemon.rs | 365 +++++++++++++++++++ src/semantic_render.rs | 7 + tests/bottom_panel_stage2b_gpu_acceptance.rs | 11 +- 5 files changed, 568 insertions(+), 26 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index a0e5a9d..9fb49bb 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -433,6 +433,19 @@ from #171 and #215. **1.146× is the smallest margin this budget has ever failed by** (U6 1.297×, U10 1.343×, U9 1.613×), and the "realistic" figure was **negative** (−25.8%) in the same run. Isolated rerun green. + - **`setsid_escapee_is_not_reaped_and_teardown_reclaims_readers`, + once** (log `20260815T184808Z`, step `08-sweep`). Fragment: `live + runtime probe` at `src/process.rs:5155` — `active_reader_probe` + returned `None` for a process that had just reported `Started`. A + live-process race under sweep load. Isolated rerun green. **Not in + the registry under any id**, so it is a new signature, owed like + the two above. + - **Three consecutive full-gate runs, three DIFFERENT unrelated + failures** (composition budget in `04-lib-crdt`, then composition + again in `10-sweep-crdt`, then this in `08-sweep`), against a diff + that touches panels and the wire. Recorded as a rate observation + only: no mechanism is claimed, and the standing leaked-daemon + confound is uncontrolled as always. - **Cost, stated plainly:** four `--protocol` gate runs on one commit, three of them lost to these two signatures. U9's synthetic-load control remains unrun and is the cheapest thing that would either diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 94299cc..b278823 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -860,37 +860,56 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { // the retained old frame supplied the rendering. The // probe would report the band showing something it does // not show. + // The payload KIND is kept only to tell a real `Absent` + // apart from a refusal; every fact below comes from the + // accepted state. + let panel_absent_payload = matches!( + msg.as_ref(), + InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent) + ); let panel_message = matches!(msg.as_ref(), InstanceMessage::PanelFrame(_)); - let panel_before = state + // The COMPLETE accepted authority, both halves. An + // epoch/size triple is not enough: ordinary content, + // focus, cursor and mapping-generation updates all leave + // it unchanged, so accepted frames would go uncounted — + // including the identical-frame/higher-generation case + // this slice requires — and a fixture waiting for two + // frames would wait forever. + let authority_before = state .panel .presented() - .map(|frame| (frame.panel_epoch, frame.geometry_epoch, frame.size)); + .cloned() + .map(|frame| (frame, state.panel.mapping_generation)); state.apply_attach_message(*msg); if panel_message { - match state.panel.presented() { - Some(frame) => { - let identity = (frame.panel_epoch, frame.geometry_epoch, frame.size); - // Counted only when the retained frame - // actually moved: a duplicate or a refusal - // leaves the band exactly as it was, and - // counting either would say the daemon is - // painting when it is not. - if panel_before != Some(identity) { - facts.panel_frames += 1; - } - facts.panel_rows = frame.size.rows; - facts.panel_cols = frame.size.cols; - facts.panel_focused = frame.focused; - facts.panel_frame_text = grid_probe_text(&frame.cells); - if let Some(expected) = expected_panel_text.as_deref() - && facts.panel_frame_text.contains(expected) - { - facts.panel_text_observed = true; - } + let authority_after = state + .panel + .presented() + .cloned() + .map(|frame| (frame, state.panel.mapping_generation)); + if authority_before != authority_after { + facts.panel_frames += 1; + } + if let Some((frame, _)) = authority_after.as_ref() { + facts.panel_rows = frame.size.rows; + facts.panel_cols = frame.size.cols; + facts.panel_focused = frame.focused; + facts.panel_frame_text = grid_probe_text(&frame.cells); + if let Some(expected) = expected_panel_text.as_deref() + && facts.panel_frame_text.contains(expected) + { + facts.panel_text_observed = true; } - None => facts.panel_absent_observed = true, + } + // Absence is only ever reported for an actual + // `Absent`. Inferring it from `presented() == None` + // turns a REFUSAL with nothing retained into "the + // daemon says there is no band", which is a + // different fact entirely. + if panel_absent_payload { + facts.panel_absent_observed = true; } } if is_snapshot { @@ -19983,6 +20002,139 @@ mod tests { } } + /// §5b G8b — a MAPPED frontend refuses the legacy family, and the + /// refusal is ATOMIC. + /// + /// The baseline is accepted in the CORRECT family first, so there is + /// real authority to preserve: a row that installs a legacy frame + /// and then switches to mapped has `mapping_generation == None` + /// throughout, and asserting it stayed `None` proves nothing. The + /// pointer latches are primed for the same reason — untouched state + /// that was never set is not evidence of atomicity. + #[test] + fn g8b_a_mapped_frontend_refuses_legacy_atomically() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 4); + state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION); + + // A correct-family baseline, so the retained authority is real. + let mut baseline = frame.clone(); + baseline.panel_epoch = frame.panel_epoch + 1; + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: baseline.clone(), + mapping_generation: 5, + }), + "fixture: the mapped baseline must install" + ); + state.set_panel_pointer_held(true); + state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(1, 1)); + + let mut intruder = frame.clone(); + intruder.panel_epoch = frame.panel_epoch + 2; + assert!( + !state.apply_panel_payload(PanelFramePayload::Present(intruder)), + "a v25 frontend must not paint a band it cannot safely \ + hit-test — the frame carries no mapping identity" + ); + + assert_eq!( + state.panel.frame.as_ref().map(|f| f.panel_epoch), + Some(baseline.panel_epoch), + "the retained frame survives the refusal" + ); + assert_eq!( + state.panel.mapping_generation, + Some(5), + "and so does its authority — this is the half a switched \ + baseline could not have shown" + ); + assert!(state.panel.pointer_held, "a refusal ends no live gesture"); + assert_eq!( + state.panel.last_pointer_cell, + Some(pmacs_protocol::CellCoord::new(1, 1)), + "nor discards its dedupe baseline" + ); + } + + /// §5b G8d — a LEGACY frontend refuses the mapped family, atomically. + /// + /// An independent state, not a continuation of G8b: sharing one + /// would let the second direction inherit whatever the first left + /// behind. + #[test] + fn g8d_a_legacy_frontend_refuses_mapped_atomically() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 4); + state.set_panel_wire(PANEL_MIN_VERSION); + + let mut baseline = frame.clone(); + baseline.panel_epoch = frame.panel_epoch + 1; + assert!( + state.apply_panel_payload(PanelFramePayload::Present(baseline.clone())), + "fixture: the legacy baseline must install" + ); + state.set_panel_pointer_held(true); + state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(2, 2)); + + let mut intruder = frame.clone(); + intruder.panel_epoch = frame.panel_epoch + 2; + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: intruder, + mapping_generation: 9, + }), + "a v24 frontend must reject a family it never negotiated" + ); + + assert_eq!( + state.panel.frame.as_ref().map(|f| f.panel_epoch), + Some(baseline.panel_epoch), + "the retained frame survives" + ); + assert_eq!( + state.panel.mapping_generation, None, + "and no authority is acquired from a refused frame" + ); + assert!(state.panel.pointer_held, "a refusal ends no live gesture"); + assert_eq!( + state.panel.last_pointer_cell, + Some(pmacs_protocol::CellCoord::new(2, 2)) + ); + } + + /// §5b — an UNSUPPORTED session accepts neither family. + /// + /// The case a `!= Mapped` gate let through: `Unsupported` is neither + /// family, so a negative test admitted a band the session never + /// negotiated. + #[test] + fn an_unsupported_session_accepts_neither_panel_family() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 4); + state.set_panel_wire(PANEL_MIN_VERSION - 1); + + let mut orphan = frame.clone(); + orphan.panel_epoch = frame.panel_epoch + 1; + assert!( + !state.apply_panel_payload(PanelFramePayload::Present(orphan.clone())), + "a sub-panel session has no band at all" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: orphan, + mapping_generation: 9, + }), + "in either family" + ); + } + /// F1 — a held left button makes motion a `Drag(Left)`, and the dedupe /// re-arms on every press and release. #[test] diff --git a/src/daemon.rs b/src/daemon.rs index 8d5db9a..0ab1bce 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -6074,6 +6074,16 @@ mod tests { /// /// The session is registered because the dispatcher drops any event /// from an uninstalled session before it reaches a handler. + /// §5b G8e — the frontend a forged payload claims to be. Registered + /// in every dispatch fixture at the MAPPED version, so "the claimed + /// id speaks the other family" is a real condition rather than an + /// absent session. + const FORGERY_TARGET_FID: FrontendId = FrontendId(767); + + /// §5b G8e, the other direction: a frontend that speaks the LEGACY + /// family, for a mapped session to try to borrow. + const FORGERY_TARGET_LEGACY_FID: FrontendId = FrontendId(768); + /// §5b — a session that negotiated the LEGACY panel family. /// /// These rows drive `FrontendEvent::PanelPointer`, which a `>= v25` @@ -6102,6 +6112,23 @@ mod tests { let mut bells = HashMap::new(); let mut registry = SessionRegistry::new(); registry.register_session(fid, session(version, !render_states.contains_key(&fid))); + // §5b G8e — a SECOND session that speaks the mapped family, so a + // payload claiming its id has something real to borrow. Without + // it, a payload-keyed lookup would fail for want of a session + // rather than for want of authority, and the mutation that + // swaps `source` for the payload's id would be invisible. + if fid != FORGERY_TARGET_FID { + registry.register_session( + FORGERY_TARGET_FID, + session(pmacs_protocol::PANEL_MAPPING_MIN_VERSION, true), + ); + } + if fid != FORGERY_TARGET_LEGACY_FID { + registry.register_session( + FORGERY_TARGET_LEGACY_FID, + session(LEGACY_PANEL_VERSION, true), + ); + } handle_dispatcher_event( DispatcherEvent::FrontendEvent { source: fid, event }, editor, @@ -6305,6 +6332,344 @@ mod tests { (frame.geometry_epoch, frame.panel_epoch) } + // ----------------------------------------------------------------- + // §5b G6–G8 — the family gate, both directions, all four quadrants. + // + // Every row negotiates ONE version for both the session registry and + // the retained producer: a control that negotiates two proves + // nothing about either. + // ----------------------------------------------------------------- + + /// A panel session at one negotiated version: the editor, its + /// producer, its render states, the side window, and the epochs a + /// gesture must echo. + type PanelSessionFixture = ( + crate::editor::EditorState, + HashMap, + HashMap, + crate::window::WindowId, + crate::window::WindowId, + (u64, u64), + ); + + /// Build a panel session at one negotiated version and ship its + /// declaration, returning the epochs a gesture must echo. + fn panel_session_at(version: u32, fid: FrontendId) -> PanelSessionFixture { + let editor = crate::editor::EditorState::new(); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, version), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let epochs = shipped_declaration(&editor, fid, &mut semantic_states); + ( + editor, + semantic_states, + HashMap::new(), + document, + panel, + epochs, + ) + } + + /// The mapping generation this session's producer most recently + /// stamped, which a mapped gesture must echo. + fn stamped_generation( + semantic_states: &HashMap, + fid: FrontendId, + ) -> u64 { + semantic_states + .get(&fid) + .expect("semantic projection") + .panel_mapping_generation_peek() + .expect("a stamped mapping generation") + } + + fn legacy_pointer( + fid: FrontendId, + epochs: (u64, u64), + buffer_id: crate::buffer::BufferId, + ) -> FrontendEvent { + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: epochs.0, + panel_epoch: epochs.1, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + } + } + + fn mapped_pointer( + fid: FrontendId, + epochs: (u64, u64), + buffer_id: crate::buffer::BufferId, + mapping_generation: u64, + ) -> FrontendEvent { + FrontendEvent::PanelPointerMapped { + frontend_id: fid, + geometry_epoch: epochs.0, + panel_epoch: epochs.1, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + mapping_generation, + } + } + + /// §5b G6a — **legacy OUTBOUND**: a v24 peer receives exactly + /// `Present`, never the mapped family. + #[test] + fn g6a_a_legacy_peer_receives_the_legacy_present_family() { + let fid = FrontendId(760); + let (_editor, semantic_states, _render, _document, _panel, _epochs) = + panel_session_at(LEGACY_PANEL_VERSION, fid); + let declaration = semantic_states + .get(&fid) + .expect("projection") + .last_panel_payload_for_test(); + assert!( + matches!( + declaration, + Some(pmacs_protocol::panel::PanelFramePayload::Present(_)) + ), + "a v24 session must receive the legacy family and only it; \ + got {declaration:?}" + ); + } + + /// §5b G7a — **mapped OUTBOUND**: a v25 peer receives + /// `PresentMapped` carrying a live, nonzero generation. + #[test] + fn g7a_a_mapped_peer_receives_present_mapped_with_a_live_generation() { + let fid = FrontendId(761); + let (_editor, semantic_states, _render, _document, _panel, _epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let declaration = semantic_states + .get(&fid) + .expect("projection") + .last_panel_payload_for_test(); + match declaration { + Some(pmacs_protocol::panel::PanelFramePayload::PresentMapped { + mapping_generation, + .. + }) => assert!( + mapping_generation >= 1, + "zero is the wire's uninitialised value and is refused on \ + sight, so a stamped frame must never carry it" + ), + other => panic!("a v25 session must receive the mapped family; got {other:?}"), + } + } + + /// §5b G6b — **legacy INBOUND routing**: a current legacy gesture + /// reaches the dispatcher and performs the landed focus activation. + #[test] + fn g6b_a_legacy_gesture_routes_and_activates() { + let fid = FrontendId(762); + let (mut editor, mut semantic_states, mut render, document, panel, epochs) = + panel_session_at(LEGACY_PANEL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + // THIS view's active window, asserted directly. The ambient + // `active_window_id()` tracks `active_frontend` too, so a row + // reading it can be satisfied by a frontend switch that never + // routed anything to the panel. + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "precondition: this frontend's active window is the document" + ); + + dispatch_panel_event( + &mut editor, + fid, + LEGACY_PANEL_VERSION, + &mut semantic_states, + &mut render, + legacy_pointer(fid, epochs, buffer_id), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "a legacy gesture from a legacy session must still route and \ + activate — the gate must not break the family it kept" + ); + } + + /// §5b G7b — **mapped INBOUND routing**: a current mapped gesture + /// reaches the dispatcher and activates. + #[test] + fn g7b_a_mapped_gesture_routes_and_activates() { + let fid = FrontendId(763); + let (mut editor, mut semantic_states, mut render, document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let generation = stamped_generation(&semantic_states, fid); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "precondition: this frontend's active window is the document" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + mapped_pointer(fid, epochs, buffer_id, generation), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "a mapped gesture echoing the current generation must route" + ); + } + + /// §5b G8a — a **bare `PanelPointer` from a v25 session is + /// REFUSED**, not handled under legacy semantics. + #[test] + fn g8a_a_legacy_gesture_from_a_mapped_session_is_refused() { + let fid = FrontendId(764); + let (mut editor, mut semantic_states, mut render, document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "precondition: this frontend's active window is the document" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + legacy_pointer(fid, epochs, buffer_id), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "handling it under legacy semantics would leave the mapping \ + hole reachable by choosing a discriminant" + ); + } + + /// §5b G8c — a **`PanelPointerMapped` from a v24 session is + /// REFUSED**, even though a peer built from this crate can encode + /// the discriminant. Negotiation is a gate, not a convention. + #[test] + fn g8c_a_mapped_gesture_from_a_legacy_session_is_refused() { + let fid = FrontendId(765); + let (mut editor, mut semantic_states, mut render, document, panel, epochs) = + panel_session_at(LEGACY_PANEL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "precondition: this frontend's active window is the document" + ); + + dispatch_panel_event( + &mut editor, + fid, + LEGACY_PANEL_VERSION, + &mut semantic_states, + &mut render, + // Any generation at all: the family gate refuses before the + // generation is even looked at. + mapped_pointer(fid, epochs, buffer_id, 1), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "inbound negotiation is a gate, not a sender convention" + ); + } + + /// §5b G8e — **authenticated-session authority**: claiming another + /// frontend whose session negotiated the desired family still + /// refuses. + /// + /// The mutation this row exists for is one line: look the + /// negotiation up by the payload's `frontend_id` instead of the + /// authenticated `source`, and the forged cross-session claim + /// succeeds. + #[test] + fn g8e_a_forged_frontend_id_cannot_borrow_another_sessions_family() { + let legacy_fid = FrontendId(766); + let mapped_fid = FORGERY_TARGET_FID; + let (mut editor, mut semantic_states, mut render, document, panel, epochs) = + panel_session_at(LEGACY_PANEL_VERSION, legacy_fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + assert_eq!( + editor.core.borrow().views[&legacy_fid].active, + document, + "precondition: this frontend's active window is the document" + ); + + // The AUTHENTICATED source is the legacy session; the payload + // claims a frontend that speaks the mapped family. + dispatch_panel_event( + &mut editor, + legacy_fid, + LEGACY_PANEL_VERSION, + &mut semantic_states, + &mut render, + mapped_pointer(mapped_fid, epochs, buffer_id, 1), + ); + assert_eq!( + editor.core.borrow().views[&legacy_fid].active, + document, + "the family comes from the AUTHENTICATED session; a payload \ + id is untrusted and must not borrow another's negotiation" + ); + } + + /// §5b G8e, the OTHER direction — a mapped session cannot borrow a + /// legacy identity to smuggle the bare variant through. + /// + /// Both directions, because an authority check that only holds one + /// way is one a peer walks around by choosing which identity to + /// forge. The claimed frontend has a REAL legacy session, so a + /// payload-keyed lookup succeeds far enough to expose the defect + /// rather than failing for want of a session. + #[test] + fn g8e_a_mapped_session_cannot_forge_a_legacy_identity() { + let fid = FrontendId(769); + let (mut editor, mut semantic_states, mut render, document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "precondition: this frontend's active window is the document" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + // Authenticated as the MAPPED session; the payload claims a + // frontend whose session speaks legacy. + legacy_pointer(FORGERY_TARGET_LEGACY_FID, epochs, buffer_id), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "a mapped session may not send the bare variant, and cannot \ + acquire permission by naming someone who may" + ); + } + /// Criterion 50: a gesture from a source whose latest declaration is /// not a visible `Present` is dropped. #[test] diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 9fd8f57..d91bd71 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -637,6 +637,13 @@ impl SemanticRenderState { } } + /// The panel payload most recently shipped, for the family rows. + #[doc(hidden)] + #[must_use] + pub fn last_panel_payload_for_test(&self) -> Option { + self.last_panel_payload.clone() + } + /// Advance-if-changed, then read: the authoritative mapping key. /// /// **The single seam §5b requires.** Projection stamps the frame diff --git a/tests/bottom_panel_stage2b_gpu_acceptance.rs b/tests/bottom_panel_stage2b_gpu_acceptance.rs index b24629c..6f158eb 100644 --- a/tests/bottom_panel_stage2b_gpu_acceptance.rs +++ b/tests/bottom_panel_stage2b_gpu_acceptance.rs @@ -182,8 +182,13 @@ fn press_and_await_panel(session: &mut Session) -> bool { }), ) .expect("write panel-open key"); + // §5b — whichever Present family this session negotiated. These + // rows are about the band ARRIVING; which wrapper carries it is + // pinned by the G6/G7/G8 rows, not incidentally here. drain_until(&mut session.stream, "panel", |message| match message { - InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(frame.size), + InstanceMessage::PanelFrame( + PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }, + ) => Some(frame.size), _ => None, }) .is_some() @@ -251,8 +256,8 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() { ); assert!( press_and_await_panel(&mut current), - "a v21-negotiated semantic session must be panel-capable and receive \ - a Present panel frame" + "a current-wire semantic session must be panel-capable and \ + receive a Present-family panel frame" ); // Half 3 — a semantic session that echoed the baseline is NOT From 8c5ca10698ccb7966dbdea9c802cfbfdca50c32b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 22:41:20 +0200 Subject: [PATCH 16/35] feat(panel): G5a's cancellation trigger, and the latch it needs G5a is the one G5 row this slice owns: the mapping key advancing must raise cancellation at the advance, not reactively when a later event is refused. Reactive cancellation loses a race --- if the successor frame reaches the frontend before the physical `Up`, the producer clears its latch and the cancelling event never arrives. Cancelling requires something to cancel, so the accepted-gesture latch lands with it: `AcceptedPanelGesture` (button, coord, buffer, and whether the press reached a child) in a per-frontend slot on `SemanticRenderState`, armed from the daemon's accepted inbound arms after every gate has passed. Writing that code decides three things the framing's deferred rows later assert about, so they are pinned here under SUBSTRATE names rather than under G5c/G5d/G5g/G5p. Those IDs stay on `panel-pointer-replay` per SS5b's split table --- each asserts something about a synthetic release or a real drag continuation that does not exist on this base, and claiming an ID in two branches is the merge hazard `active-work.md` already records surviving a clean merge once. Two consequences are recorded rather than fixed: - Cancellations are COUNTED, not queued. The record queue is what replay drains to deliver each release; landing it here would grow one entry per cancelled drag with nothing ever draining it. A saturating count is bounded and still separates a consume from a cancellation. - The other G5b transitions --- panel epoch, buffer replacement, same-size geometry, detach --- leave the latch armed on this base. Each strands a live gesture whose release can never be accepted. That is inert while nothing consumes the latch, and becomes a defect exactly when replay supplies effects, in the branch that owns the row. `Absent` is wired anyway, because `publish_absent_panel` clears input authority two lines later; leaving it out would be an inconsistency inside one function rather than a clean deferral. Five mutations, each biting only its named row: - drop the advance trigger (reactive cancellation) -> G5a alone - arm on every accepted pointer event -> the arming substrate alone - an ordinary `Up` no longer consumes -> the arming substrate alone - a consume counts as a cancellation -> the arming substrate alone - one global latch via a shared slot -> the ownership substrate alone Also repairs a fourth rustdoc split on this branch. Inserting `AcceptedPanelGesture` at what read as a blank gap adopted `SemanticRenderState`'s doc comment AND its `#[allow(clippy::struct_excessive_bools)]`, silently un-suppressing a lint on the struct that needed it. Same mechanism all four times; the ledger now records the check as "look UP from the insertion point". Verified: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --lib` (1956 passed); the two `bottom_panel_stage2b_*` acceptance suites (39 passed); `git diff --check`. The full eleven-stage `--protocol` gate is reserved for slice completion per the standing procedure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 34 ++++- docs/bottom-panel-framing.md | 37 +++++ src/daemon.rs | 283 +++++++++++++++++++++++++++++++++++ src/semantic_render.rs | 152 ++++++++++++++++++- 4 files changed, 500 insertions(+), 6 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 9fb49bb..b6ba1f8 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,8 +281,13 @@ from #171 and #215. **`githubsucks/panel-mapping-generation` is the authoritative tip.** Recover with `git fetch githubsucks && git checkout panel-mapping-generation`. -- **No PR yet. Checkpoint: framing revision 16 (§5b) APPROVED - 2026-08-15; NO IMPLEMENTATION.** +- **No PR yet. Checkpoint: `3c06176`** — framing revision 16 (§5b) + APPROVED 2026-08-15 at `7e85a6f`, then eleven implementation commits: + wire shapes and pins (G0), the authoritative key with its terminal + half (G1–G4), outbound family selection, inbound family gating, the + GPU's negotiated family enum, and the nine family-gate rows + (G6a–b, G7a–b, G8). **Full `--protocol` gate green at `3c06176`.** + In progress since, uncommitted: G5's accepted-gesture latch. - **PROTOCOL-BEARING — v25, and it runs alone.** `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**. - **Why it exists.** A `PanelPointer` names a cell and nothing on the @@ -384,6 +389,22 @@ from #171 and #215. invalidations and every document, terminal, complete producer-latch and click-chain effect through real call sites. No dead classification helper is added on a base without replay. +- **G5's latch lands here under SUBSTRATE names, not the deferred + IDs.** G5a needs something to cancel, so the per-frontend + `AcceptedPanelGesture` latch and its arming from the accepted inbound + arms are this slice's. Writing that code decides which events arm, + that an ordinary `Up` consumes without counting as a cancellation, + and that the latch is per frontend — pinned as `g5_substrate_*` + rather than as G5c/G5d/G5g/G5p, which stay on the replay lane per + §5b's split table. **Claiming an ID in two branches is the merge + hazard**, and this file already records duplicate ids surviving a + clean merge once. Cancellations are **counted, not queued**: the + record queue is what replay drains, and landing it here would grow + one entry per cancelled drag with no reader. The other G5b + transitions (panel epoch, buffer replacement, same-size geometry, + detach) deliberately leave the latch armed on this base — inert + while nothing consumes it, and a defect only once replay supplies + effects, in the branch that owns the row. - **Routing and effects are separate controls.** This slice can prove v24/v25 events reach the existing focus path, but selection, terminal reporting, stable-generation drag continuation, two-tick @@ -450,6 +471,15 @@ from #171 and #215. three of them lost to these two signatures. U9's synthetic-load control remains unrun and is the cheapest thing that would either implicate load or clear it. +- **Rustdoc split, FOUR occurrences on this branch** (`screen_size`, + `peer_may_send_panel_events`, `send_panel_pointer`, and + `SemanticRenderState`). Always the same mechanism: inserting an item + at what reads as a blank gap when the lines directly above are the + NEXT item's doc comment, which the insertion then adopts. The fourth + also stole an `#[allow(clippy::struct_excessive_bools)]`, silently + un-suppressing a lint on the struct that needed it. Three were caught + by the user in review, one by a `missing_docs` warning. **The check + is to look UP from the insertion point before writing, not down.** - **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite, `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index fd5349d..685d3e5 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -2460,6 +2460,43 @@ lane must run the whole G5 matrix through real document and terminal call sites. This is the same producer-emits/receiver-discards blind spot that forced the replay prerequisite in the first place. +#### The latch substrate that lands here, and what it does not claim + +G5a needs something to cancel, so the accepted-gesture latch itself — +`AcceptedPanelGesture` and the per-frontend slot on +`SemanticRenderState` — lands on this slice, armed from the daemon's +accepted inbound arms. Writing that code decides three things the +deferred rows later assert about, so those decisions are pinned here +under **substrate** names (`g5_substrate_*`) rather than under G5c, +G5d, G5g or G5p: + +- which events arm and which consume (`update_accepted_gesture`); +- that an ordinary `Up` consumes **without** counting as a + cancellation; +- that the latch is per frontend, so one frontend's loss cannot empty + another's. + +The framing rows keep their IDs on `panel-pointer-replay`, because each +of them asserts something about a synthetic release or a real drag +continuation that does not exist on this base. Two IDs claimed in two +branches is the merge hazard; substrate names avoid it. + +Two consequences are recorded rather than fixed: + +- **Cancellations are counted, not queued.** A queue of records is what + replay drains to deliver each release; landing it here would grow one + entry per cancelled drag with nothing ever draining it. A saturating + count is bounded and still separates a consume from a cancellation. +- **The other G5b transitions leave the latch armed on this base.** A + panel-epoch change, a buffer replacement, a same-size geometry change + and a detach all strand a live gesture — its release can never be + accepted. That is inert here, because nothing consumes the latch, and + becomes a defect exactly when replay gives it effects, in the branch + that owns the row. `Absent` is wired anyway, because + `publish_absent_panel` already clears input authority two lines + later and leaving that one out would be an inconsistency inside a + single function rather than a clean deferral. + ### Coherence impact (`COHERENCE.md` §20) - **Journey steps touched: 5** (document-panel editing and selection) diff --git a/src/daemon.rs b/src/daemon.rs index 0ab1bce..68e2b4e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1029,6 +1029,50 @@ fn panel_event_epochs_are_current( /// Consulted BEFORE the payload is trusted, before any generation is /// validated and before any mutation: the family decides which variant /// is even admissible, so it cannot depend on the variant's contents. +/// §5b G5c/G5d/G5g — update the accepted-gesture latch for one +/// ACCEPTED panel gesture. +/// +/// Called only after every gate has passed, so "accepted" means exactly +/// that. Three rules, and each closes its own hole: +/// +/// * only a left `Down` ARMS — a right press opens a menu and ends +/// there, and `Move`, wheel and the other buttons begin nothing, so +/// arming on them would manufacture a delayed release at the next +/// authority loss (G5g); +/// * a left `Up` CONSUMES, or a later invalidation finds a gesture it +/// believes live and duplicates its release (G5c); +/// * an `Up` with no armed gesture is INERT — it terminates nothing, +/// because nothing began (G5d). +fn update_accepted_gesture( + semantic_states: &mut HashMap, + source: FrontendId, + kind: pmacs_protocol::MouseKind, + coord: pmacs_protocol::CellCoord, + buffer_id: crate::buffer::BufferId, + reached_child: bool, +) { + use pmacs_protocol::{MouseButton, MouseKind}; + let Some(state) = semantic_states.get_mut(&source) else { + return; + }; + match kind { + MouseKind::Down(MouseButton::Left) => { + state.arm_accepted_gesture(crate::semantic_render::AcceptedPanelGesture { + button: MouseButton::Left, + coord, + buffer_id, + reached_child, + }); + } + MouseKind::Up(MouseButton::Left) => { + // Inert when nothing was armed: `take` on `None` is the + // whole of G5d. + let _ = state.consume_accepted_gesture(); + } + _ => {} + } +} + fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: FrontendId) -> bool { session_registry .session_state(source) @@ -2507,6 +2551,17 @@ fn handle_dispatcher_event( ) { 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 { @@ -2551,6 +2606,14 @@ fn handle_dispatcher_event( ) { editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + update_accepted_gesture( + semantic_states, + source, + kind, + coord, + buffer_id, + false, + ); } } FrontendEvent::Pointer { @@ -6670,6 +6733,226 @@ mod tests { ); } + /// §5b G5a — the key advancing RAISES cancellation, without waiting + /// for another pointer event. + /// + /// The daemon-side half. The EFFECTS — clearing an empty selection, + /// clearing the click chain, delivering the child's release — are + /// owed by `panel-pointer-replay`, which is the only branch where + /// replay exists; this pins the trigger and the record it produces. + #[test] + fn g5a_a_mapping_change_cancels_a_live_gesture_with_no_further_event() { + let fid = FrontendId(770); + let (mut editor, mut semantic_states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let generation = stamped_generation(&semantic_states, fid); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + mapped_pointer(fid, epochs, buffer_id, generation), + ); + assert!( + semantic_states[&fid].has_accepted_gesture(), + "fixture: an accepted left press arms the latch" + ); + + // A FOREIGN edit — nothing this frontend did, and no further + // pointer event. The key moves on the next read. + { + let core = editor.core.borrow(); + let registry = core.registry.clone(); + let mut reg = registry.borrow_mut(); + reg.get_mut(buffer_id) + .expect("panel buffer") + .set_generated_contents(b"moved\n") + .expect("a plain content change"); + } + let snapshot = editor.panel_mapping_snapshot(fid); + let state = semantic_states.get_mut(&fid).expect("projection"); + let advanced = state.panel_mapping_generation(snapshot); + assert_ne!(advanced, Some(generation), "the key must have moved"); + + assert!( + !state.has_accepted_gesture(), + "the advance itself ends the gesture — waiting for another \ + event loses the race where the successor frame lands first" + ); + assert_eq!( + state.panel_gesture_cancellations(), + 1, + "and it records exactly one cancellation for replay to \ + terminate — an ordinary consume would leave this at zero" + ); + } + + /// §5b — SUBSTRATE for the framing's G5c/G5d/G5g: the latch's + /// arming rules, as this branch's inbound arms decide them. + /// + /// Deliberately not named for those rows. G5c, G5d and G5g are + /// `panel-pointer-replay`'s per §5b's split table, and each asserts + /// something about a synthetic release that does not exist on this + /// branch. What is provable here is narrower and still worth + /// pinning, because `update_accepted_gesture` decides it: which + /// events arm, which consume, and that a consume is not a + /// cancellation. + #[test] + fn g5_substrate_only_an_accepted_left_press_arms_and_a_release_consumes() { + let fid = FrontendId(771); + let (mut editor, mut semantic_states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let generation = stamped_generation(&semantic_states, fid); + let mut send = |kind, semantic_states: &mut HashMap<_, _>, editor: &mut _| { + let mut event = mapped_pointer(fid, epochs, buffer_id, generation); + if let FrontendEvent::PanelPointerMapped { kind: k, .. } = &mut event { + *k = kind; + } + dispatch_panel_event( + editor, + fid, + PROTOCOL_VERSION, + semantic_states, + &mut render, + event, + ); + }; + + // G5d — a release with nothing armed is INERT. + send( + pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left), + &mut semantic_states, + &mut editor, + ); + assert!( + !semantic_states[&fid].has_accepted_gesture(), + "a stale release terminates nothing, because nothing began" + ); + + // G5g — a RIGHT press does not arm. + send( + pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Right), + &mut semantic_states, + &mut editor, + ); + assert!( + !semantic_states[&fid].has_accepted_gesture(), + "a right press opens a menu and ends there — arming would \ + manufacture a delayed release at the next authority loss" + ); + + // G5g — nor does a wheel step. + send( + pmacs_protocol::MouseKind::ScrollDown, + &mut semantic_states, + &mut editor, + ); + assert!(!semantic_states[&fid].has_accepted_gesture()); + + // Arming, then G5c — an ordinary release CONSUMES. + send( + pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + &mut semantic_states, + &mut editor, + ); + assert!(semantic_states[&fid].has_accepted_gesture()); + send( + pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left), + &mut semantic_states, + &mut editor, + ); + assert!( + !semantic_states[&fid].has_accepted_gesture(), + "leaving it armed lets a later invalidation duplicate the \ + release for a button already up" + ); + assert_eq!( + semantic_states[&fid].panel_gesture_cancellations(), + 0, + "an ordinary release is not a cancellation — counting it \ + would make replay deliver a release for a button the user \ + already lifted" + ); + } + + /// §5b — SUBSTRATE for the framing's G5p: per-frontend gesture + /// ownership. + /// + /// A and B hold gestures on their own panels; B losing authority + /// cancels B exactly once and leaves A's gesture intact. One global + /// latch would make either frontend's lifecycle cancel or erase the + /// other's. + /// + /// G5p itself is replay's per §5b's split table — it requires A's + /// next valid Drag to still APPLY, which needs replay. The + /// ownership shape underneath it is decided here, by putting the + /// latch on `SemanticRenderState` rather than beside the dispatcher, + /// so it is pinned here. + #[test] + fn g5_substrate_one_frontends_authority_loss_leaves_anothers_gesture_alone() { + let fid_a = FrontendId(772); + let fid_b = FrontendId(773); + let (mut editor_a, mut states_a, mut render_a, _doc_a, panel_a, epochs_a) = + panel_session_at(PROTOCOL_VERSION, fid_a); + let buffer_a = editor_a.core.borrow().windows[&panel_a].buffer_id; + let generation_a = stamped_generation(&states_a, fid_a); + + // B lives in the same projection map, so a global latch would be + // shared between them. + states_a.insert( + fid_b, + crate::semantic_render::SemanticRenderState::for_peer(fid_b, PROTOCOL_VERSION), + ); + states_a.get_mut(&fid_b).expect("B").arm_accepted_gesture( + crate::semantic_render::AcceptedPanelGesture { + button: pmacs_protocol::MouseButton::Left, + coord: pmacs_protocol::CellCoord::new(0, 0), + buffer_id: buffer_a, + reached_child: false, + }, + ); + + dispatch_panel_event( + &mut editor_a, + fid_a, + PROTOCOL_VERSION, + &mut states_a, + &mut render_a, + mapped_pointer(fid_a, epochs_a, buffer_a, generation_a), + ); + assert!(states_a[&fid_a].has_accepted_gesture(), "A is armed"); + assert!(states_a[&fid_b].has_accepted_gesture(), "B is armed"); + + // B loses authority. + states_a + .get_mut(&fid_b) + .expect("B") + .cancel_accepted_gesture(); + + assert!( + !states_a[&fid_b].has_accepted_gesture(), + "B's own gesture ends" + ); + assert_eq!( + states_a[&fid_b].panel_gesture_cancellations(), + 1, + "exactly once" + ); + assert!( + states_a[&fid_a].has_accepted_gesture(), + "and A's survives — a global latch would have erased it" + ); + assert_eq!( + states_a[&fid_a].panel_gesture_cancellations(), + 0, + "with no cancellation attributed to A" + ); + } + /// Criterion 50: a gesture from a source whose latest declaration is /// not a visible `Present` is dropped. #[test] diff --git a/src/semantic_render.rs b/src/semantic_render.rs index d91bd71..1774870 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -158,6 +158,29 @@ fn minibuffer_window(candidates: &[T], selected: Option) -> (Ve (window, selected_in_window) } +/// §5b G5 — what an accepted panel `Down` established, and what a +/// cancellation must therefore terminate. +/// +/// A release has to MATCH the press it ends: same button, same +/// encoding, at a coordinate the child was actually told about. A +/// cancellation that guessed any of those would put bytes in a child's +/// input stream describing an event that never happened. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AcceptedPanelGesture { + /// The button the accepted `Down` carried. + pub button: pmacs_protocol::MouseButton, + /// The last coordinate known valid for this gesture — where a + /// release is delivered when the pointer has since left content. + pub coord: pmacs_protocol::CellCoord, + /// The panel buffer the gesture belongs to. + pub buffer_id: BufferId, + /// Whether the press actually REACHED the child, for a reporting + /// terminal. A release is owed only if a press was delivered; + /// synthesising one for a press the child never saw is the same + /// defect in the other direction. + pub reached_child: bool, +} + /// Owns one `semantic_render` session's projection state: the last /// viewport the frontend declared, and the diff baseline per buffer /// for the `StyleSpans` and `Decorations` families. @@ -406,6 +429,31 @@ pub struct SemanticRenderState { /// `generation` starts at 0 meaning "never established"; the first /// real mapping takes 1, because zero is invalid on the wire. panel_mapping: Option<(crate::editor::PanelMappingSnapshot, u64)>, + /// §5b G5 — the panel gesture this frontend has an ACCEPTED `Down` + /// for, if any. + /// + /// Per frontend, never global: two frontends can hold gestures on + /// distinct panels at once, and one global slot would make either + /// one's authority loss cancel or erase the other's (G5p). + /// + /// Cancellation needs this to exist at all. Without a record of + /// what was accepted there is nothing to terminate: a release must + /// match the press it ends, and a stale `Up` with no accepted + /// `Down` must be inert rather than synthesising one. + accepted_gesture: Option, + /// §5b — how many armed gestures an authority loss has ended this + /// session. + /// + /// A COUNT, not a queue of records. The records are what replay + /// consumes to deliver each release, and replay is the branch that + /// introduces their reader — a queue landed here would grow one + /// entry per cancelled drag for the life of the daemon with nothing + /// ever draining it. A saturating count is bounded and still + /// distinguishes the two ways a latch empties: an ordinary `Up` + /// consumes it and leaves this alone, an authority loss ends it and + /// bumps it. It also does not collapse two losses in one dispatcher + /// burst the way a boolean flag would. + panel_gesture_cancellations: u64, /// Highest presentation epoch allocated for this session; `0` means /// none has been. Advanced only when a frame is actually shipped, so /// a frame that fails validation does not burn an identity the peer @@ -613,6 +661,8 @@ impl SemanticRenderState { // frame from shipping a redundant authoritative `Absent`. last_panel_payload: Some(PanelFramePayload::Absent), panel_mapping: None, + accepted_gesture: None, + panel_gesture_cancellations: 0, panel_epoch_used: 0, panel_presentation: None, panel_error_latched: false, @@ -644,6 +694,69 @@ impl SemanticRenderState { self.last_panel_payload.clone() } + /// §5b — how many armed gestures an authority loss has ended. + /// + /// Counts rather than collapses: two losses in one dispatcher burst + /// read as two. Coincident causes on ONE transition are a different + /// matter — those take the latch once, so the count moves once. + #[must_use] + pub fn panel_gesture_cancellations(&self) -> u64 { + self.panel_gesture_cancellations + } + + /// §5b — arm the latch, and ONLY for an accepted left press. + /// + /// A right press opens a menu and ends there; `Move`, wheel and the + /// other buttons begin nothing. Arming on any accepted pointer + /// event would let one of them manufacture a delayed release at the + /// next authority loss. + /// + /// This is the SUBSTRATE for the framing's G5g. G5g itself — the + /// table-driven non-gesture events followed by an authority loss, + /// asserted to emit no synthetic release — is `panel-pointer-replay`'s + /// per §5b's split table, because the release it denies does not + /// exist on this branch. + pub fn arm_accepted_gesture(&mut self, gesture: AcceptedPanelGesture) { + self.accepted_gesture = Some(gesture); + } + + /// §5b — an ordinary accepted release CONSUMES the latch, WITHOUT + /// counting as a cancellation. + /// + /// Otherwise a later invalidation finds a gesture it believes is + /// still live and synthesises a duplicate release for a button + /// already up. Substrate for the framing's G5c/G5d, whose duplicate- + /// release assertions are replay's. + pub fn consume_accepted_gesture(&mut self) -> Option { + self.accepted_gesture.take() + } + + /// §5b G5a — cancellation: end the live gesture, if any, and return + /// its record so the caller can terminate it. + /// + /// Returning the record rather than acting is deliberate. The + /// EFFECTS — clearing an empty selection, clearing the click chain, + /// delivering the child's release — live in panel replay, which is + /// `panel-pointer-replay`'s. This is the trigger and the state; the + /// two meet at that rebase. + /// + /// Idempotent on an empty latch: no gesture, no count. That is what + /// lets a duplicate `Absent` and a coincident advance both call it + /// without inventing a second cancellation. + pub fn cancel_accepted_gesture(&mut self) -> Option { + let cancelled = self.accepted_gesture.take(); + if cancelled.is_some() { + self.panel_gesture_cancellations = self.panel_gesture_cancellations.saturating_add(1); + } + cancelled + } + + /// Whether a gesture is currently accepted, for assertions. + #[must_use] + pub fn has_accepted_gesture(&self) -> bool { + self.accepted_gesture.is_some() + } + /// Advance-if-changed, then read: the authoritative mapping key. /// /// **The single seam §5b requires.** Projection stamps the frame @@ -660,12 +773,27 @@ impl SemanticRenderState { snapshot: Option, ) -> Option { let snapshot = snapshot?; - let next = match &self.panel_mapping { - // Compared STRUCTURALLY. A hash would make this + // Matched by value, not by reference: the changed arm CANCELS, + // and cancelling needs `&mut self`. + let seen = self + .panel_mapping + .as_ref() + .map(|(seen, generation)| (*seen == snapshot, *generation)); + let next = match seen { + // Compared STRUCTURALLY above. A hash would make this // probabilistic, and a collision here silently accepts a // stale gesture — the exact failure the key exists for. - Some((seen, generation)) if *seen == snapshot => *generation, - Some((_, generation)) => generation.saturating_add(1), + Some((true, generation)) => generation, + Some((false, generation)) => { + // §5b G5a — the key advancing IS the cancellation + // trigger, raised here rather than waiting for another + // pointer event. Reactive cancellation loses a race: if + // the successor frame reaches the frontend before the + // physical `Up`, the producer clears its latch and the + // cancelling event never arrives. + self.cancel_accepted_gesture(); + generation.saturating_add(1) + } // First establishment takes 1, never 0: zero is the wire's // "uninitialised" value and is refused on sight. None => 1, @@ -1542,6 +1670,22 @@ impl SemanticRenderState { /// deliberately survives: it is answered by the frontend, not by the /// panel's presence. fn publish_absent_panel(&mut self, out: &mut Vec) { + // §5b — `Absent` is a loss of gesture authority: it clears + // `panel_presentation` two lines below, so every later inbound + // event for that gesture is refused and its release can never + // arrive. Cancelled even on a DUPLICATE `Absent`, which does no + // wire work but must still leave no live gesture behind — the + // clears below are idempotent for the same reason. + // + // Only the producer half lands here. The framing's G5b matrix — + // the panel-epoch, buffer-replacement, same-size geometry and + // detach transitions, each with its v24 and v25 legs and its + // document and terminal effects — is `panel-pointer-replay`'s per + // §5b's split table. Those transitions leave the latch armed on + // this branch, which is inert here (nothing consumes it) and + // becomes a defect only once replay gives it effects, in the + // branch that owns the row. + self.cancel_accepted_gesture(); self.panel_presentation = None; // `Absent` also clears the peer's retained mode line. A later // `Present` under `NoMessage` therefore has nothing it can From a8a996c446d3e48e2f16aef779cd220fa1da9264 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 15 Aug 2026 22:56:42 +0200 Subject: [PATCH 17/35] feat(panel): the wheel exemption, exhaustion, and mapped coalescing Three rows that share a shape: each is a gate this slice owns whose downstream EFFECT belongs to the rebased replay lane. G10b --- ordering, and a carve-out. `panel_mapping_is_current` now takes the event kind. Zero is refused FIRST, then coordinate-free wheels skip the freshness comparison. The order is the row: run the carve-out first and a sender emitting zeroed wheels faces no check at all, which is an inbound opt-out through the exempt path. The exemption exists because a tick changes `view_top` and so advances the key --- the next tick already queued behind it echoes the previous generation, and without the carve-out the panel scrolls once per frame and appears dead. It returns before the read, so a wheel does not advance the key either; advancing would make a wheel invalidate the press after it. The framing's carve-out-to-the-carve-out, re-imposing the check for CHILD-REPORTED terminal wheels where SGR carries row and column, is replay's. Whether a wheel is forwarded is decided by the reporting mode, and no panel pointer coordinate is consumed on this base at all. G11a --- exhaustion fails CLOSED. `saturating_add` froze the key at the ceiling while the mapping kept moving underneath it: the stale-gesture hole the key exists to close, with the check still appearing to pass. Now `checked_add`, and overflow publishes `Absent`, clears input authority, and latches for the session. G13a/G13b --- `PanelPointerMapped` fell through `coalesce_kind` to `None`, so pixel-rate mapped motion was lossless and filled the bounded outbox. Two tags of its own; tail-replacement takes the whole event, so coordinate and generation advance together and a collapsed run can never pair a new coordinate with a stale one. Press, release and every wheel kind stay lossless. Mutation results, including two that changed the design: - exemption before the nonzero check -> G10b(zero) alone - no wheel exemption -> G10b(exemption) alone - saturating instead of checked add -> G11a alone - no exhaustion latch -> G11a, but only AFTER the row was extended. The first version of G11a did not bite: the latch had no proven job, because the overflow path already returns before storing the ceiling snapshot, so the next read re-takes the changed arm anyway. Measured, the two are ALTERNATIVES --- either alone keeps the band down; only removing both resurrects it. The latch is kept as the primary because it has a job the ordering does not: `peek` now honours it, so the peek and the authoritative read agree that an exhausted session has no key rather than reporting the ceiling. The source comment says this, rather than the "second half" claim it made before the measurement. - mapped variants untagged / one tag for all kinds / sharing the legacy tags -> the mapped coalescing row alone, three times Witness-shape note: the two G10b rows call the predicate directly, and say why. A wheel has no dispatcher-visible effect on this base --- a document panel focuses on `Down` only --- so asserting focus for a wheel would prove nothing. Each row carries a press leg, which does have an effect, to show the predicate is wired into the production arm. Verified: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --lib` (1959); `cargo test -p pmacs-gpu --bins` (275); both `bottom_panel_stage2b_*` suites (39); `git diff --check`. `composition_overhead_under_ten_percent` red once during this work and green in isolation --- a second occurrence of a signature the lane ledger already carries, now recorded there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 4 + pmacs-gpu/src/attach.rs | 131 ++++++++++++++++ src/daemon.rs | 334 ++++++++++++++++++++++++++++++++++++++-- src/semantic_render.rs | 63 +++++++- 4 files changed, 519 insertions(+), 13 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index b6ba1f8..b7a2b47 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -461,6 +461,10 @@ from #171 and #215. live-process race under sweep load. Isolated rerun green. **Not in the registry under any id**, so it is a new signature, owed like the two above. + - **`composition_overhead_under_ten_percent`, a SECOND time** + (2026-08-15, plain `cargo test --lib`, not a gate step). Isolated + rerun green. Same budget as the occurrence above, now seen in two + different selectors on one branch in one day. - **Three consecutive full-gate runs, three DIFFERENT unrelated failures** (composition budget in `04-lib-crdt`, then composition again in `10-sweep-crdt`, then this in `08-sweep`), against a diff diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index a21f111..e0ef7f2 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -379,6 +379,28 @@ fn coalesce_kind(event: &FrontendEvent) -> Option { kind: MouseKind::Drag(_), .. } => Some(7), + // §5b G13a — the v25 mapped family needs its OWN tags. Falling + // through to `None` would make pixel-rate mapped motion lossless + // and fill the bounded queue, which is `OUTBOX_MAX` events of + // hover before the socket closes. + // + // Tail-replacement takes the whole newer event, so the surviving + // event carries the latest coordinate AND the latest + // `mapping_generation` together — a collapsed run can never pair + // a new coordinate with a stale generation. + // + // Distinct from the legacy tags 6/7 rather than shared with + // them. A session negotiates one family and never mixes the two, + // so sharing would buy nothing and would let a family confusion + // collapse silently instead of showing up as two queued events. + FrontendEvent::PanelPointerMapped { + kind: MouseKind::Move, + .. + } => Some(8), + FrontendEvent::PanelPointerMapped { + kind: MouseKind::Drag(_), + .. + } => Some(9), _ => None, } } @@ -1482,6 +1504,115 @@ mod tests { } } + fn fe_panel_mapped(kind: MouseKind, row: u32, col: u32, generation: u64) -> FrontendEvent { + FrontendEvent::PanelPointerMapped { + frontend_id: FrontendId(1), + buffer_id: BufferId::from_raw(1), + coord: CellCoord::new(row, col), + kind, + mods: Modifiers::NONE, + geometry_epoch: 1, + panel_epoch: 1, + mapping_generation: generation, + } + } + + /// §5b G13a/G13b — the mapped family keeps the legacy family's + /// coalescing contract: `Move` and `Drag` collapse to the latest + /// coordinate AND generation together; press, release and every + /// wheel kind stay lossless and ordered. + #[test] + fn mapped_panel_motion_coalesces_carrying_its_latest_generation() { + let mut ob = Outbox::new(); + ob.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 0, 0, 4)); + ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 1, 4)); + ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 2, 5)); + ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 3, 6)); + ob.enqueue(fe_panel_mapped(MouseKind::Up(MouseButton::Left), 0, 3, 6)); + assert_eq!( + ob.queue.len(), + 3, + "the drag run collapsed to one; without a tag of its own the \ + mapped family is lossless and this is five" + ); + // The whole event is replaced, so coordinate and generation + // advance TOGETHER. A tag that replaced only the coordinate + // would leave generation 4 on a cell measured under 6, which the + // daemon then refuses as stale. + assert!( + matches!( + &ob.queue[1], + FrontendEvent::PanelPointerMapped { + kind: MouseKind::Drag(MouseButton::Left), + coord: CellCoord { row: 0, col: 3 }, + mapping_generation: 6, + .. + } + ), + "surviving drag: {:?}", + &ob.queue[1] + ); + + // G13b — wheel ticks carry scroll DISTANCE. Two ticks in one + // generation must both survive the queue, or the panel scrolls + // once and stops. + let mut wheel = Outbox::new(); + wheel.enqueue(fe_panel_mapped(MouseKind::ScrollUp, 1, 1, 7)); + wheel.enqueue(fe_panel_mapped(MouseKind::ScrollUp, 1, 1, 7)); + wheel.enqueue(fe_panel_mapped(MouseKind::ScrollDown, 1, 1, 7)); + wheel.enqueue(fe_panel_mapped(MouseKind::ScrollLeft, 1, 1, 7)); + wheel.enqueue(fe_panel_mapped(MouseKind::ScrollRight, 1, 1, 7)); + assert_eq!(wheel.queue.len(), 5, "every wheel kind stays lossless"); + + // Repeated presses are what the daemon reads as a multi-click, + // and a right press is the context gesture. + let mut presses = Outbox::new(); + presses.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 2, 2, 8)); + presses.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 2, 2, 8)); + presses.enqueue(fe_panel_mapped( + MouseKind::Down(MouseButton::Right), + 2, + 2, + 8, + )); + assert_eq!(presses.queue.len(), 3, "presses stay lossless"); + + // Motion does not collapse ACROSS an intervening lossless event. + let mut mixed = Outbox::new(); + mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 1, 9)); + mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 2, 9)); + assert_eq!(mixed.queue.len(), 1); + mixed.enqueue(fe_panel_mapped(MouseKind::ScrollDown, 3, 2, 9)); + mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 3, 9)); + assert_eq!( + mixed.queue.len(), + 3, + "the wheel tick between them must not be jumped" + ); + + // And Move does not fold into a Drag tail: they are separate + // gestures, and one tag for both would turn a hover into part of + // a selection. + let mut kinds = Outbox::new(); + kinds.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 4, 1, 9)); + kinds.enqueue(fe_panel_mapped(MouseKind::Move, 4, 2, 9)); + assert_eq!(kinds.queue.len(), 2); + + // The two FAMILIES do not coalesce into each other either. + let mut families = Outbox::new(); + families.enqueue(FrontendEvent::PanelPointer { + frontend_id: FrontendId(1), + buffer_id: BufferId::from_raw(1), + coord: CellCoord::new(5, 1), + kind: MouseKind::Move, + mods: Modifiers::NONE, + geometry_epoch: 1, + panel_epoch: 1, + }); + families.enqueue(fe_panel_mapped(MouseKind::Move, 5, 2, 9)); + assert_eq!(families.queue.len(), 2); + } + /// Acceptance 34: terminal move/drag runs coalesce to the latest /// cell, while press, release, and wheel stay lossless and ordered. #[test] diff --git a/src/daemon.rs b/src/daemon.rs index 68e2b4e..2a93ddf 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1088,9 +1088,29 @@ fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: Fro /// changing** — a foreign edit, a fold, a reload, none of which moves /// an epoch. /// -/// **Zero is refused outright.** It is what a default-constructed or -/// half-initialised sender produces, so accepting it would let a peer -/// opt out of the check by sending nothing. +/// **Zero is refused outright, and BEFORE the wheel exemption.** Zero is +/// what a default-constructed or half-initialised sender produces, so +/// accepting it would let a peer opt out of the check by sending +/// nothing. Ordering the exemption first would reopen that opt-out +/// through the exempt path: a sender emitting zeroed wheels would face +/// no check at all (G10b). +/// +/// **Coordinate-free wheels are then EXEMPT from the freshness +/// comparison.** A wheel tick changes `view_top`, which advances the +/// key; the next tick already queued behind it echoes the previous +/// generation and would be refused, so the panel would scroll exactly +/// once per frame and appear dead. The exemption is safe because the +/// coordinate is not what a wheel means — the tick is. It returns +/// before the read, so a wheel does not advance the key either; +/// advancing would make the wheel invalidate the press after it. +/// +/// The carve-out the framing states for **child-reported** terminal +/// wheels, where SGR does carry row and column, is +/// `panel-pointer-replay`'s. Whether a wheel is forwarded to a child is +/// decided by the reporting mode, and no panel pointer coordinate is +/// consumed on this base at all — `dispatch_semantic_panel_pointer` +/// bounds-checks and routes focus. Re-imposing the check belongs in the +/// branch that introduces the forwarding it protects. /// /// Read through the SAME accessor projection stamps with, so "what the /// frontend was shown" and "what the daemon checks" cannot drift. @@ -1098,11 +1118,22 @@ fn panel_mapping_is_current( editor: &EditorState, semantic_states: &mut HashMap, source: FrontendId, + kind: pmacs_protocol::MouseKind, echoed: u64, ) -> bool { + // ORDER IS LOAD-BEARING: nonzero first, exemption second. if echoed == 0 { return false; } + if matches!( + kind, + pmacs_protocol::MouseKind::ScrollUp + | pmacs_protocol::MouseKind::ScrollDown + | pmacs_protocol::MouseKind::ScrollLeft + | pmacs_protocol::MouseKind::ScrollRight + ) { + return true; + } let snapshot = editor.panel_mapping_snapshot(source); semantic_states .get_mut(&source) @@ -2602,6 +2633,7 @@ fn handle_dispatcher_event( editor, semantic_states, source, + kind, mapping_generation, ) { @@ -6438,6 +6470,22 @@ mod tests { ) } + /// An edit this frontend did NOT make: the case `view_top` cannot + /// catch, because nothing about the frontend's own state moves. + fn foreign_edit( + editor: &crate::editor::EditorState, + buffer_id: crate::buffer::BufferId, + text: &[u8], + ) { + let core = editor.core.borrow(); + let registry = core.registry.clone(); + let mut reg = registry.borrow_mut(); + reg.get_mut(buffer_id) + .expect("the panel's buffer") + .set_generated_contents(text) + .expect("a generated-contents write is a plain content change"); + } + /// The mapping generation this session's producer most recently /// stamped, which a mapped gesture must echo. fn stamped_generation( @@ -6733,6 +6781,276 @@ mod tests { ); } + /// §5b G10b — **zero is refused BEFORE the wheel exemption**. + /// + /// The ordering is the row. Zero is what a sender that never + /// initialised the field produces; the exemption is a liveness + /// carve-out for coordinate-free wheels. Run the carve-out first and + /// a zeroed wheel faces no check at all — an inbound opt-out through + /// the exempt path. + /// + /// The predicate is called directly because a wheel has **no + /// dispatcher-visible effect on this base**: a document panel + /// focuses on `Down` only, and no panel pointer coordinate is + /// consumed anywhere, so an accepted wheel and a refused one are + /// indistinguishable downstream. Asserting focus for a wheel would + /// be a witness that proves nothing. The end-to-end leg below uses a + /// press, which does have an effect, so the predicate is shown to be + /// wired into the production arm rather than merely correct in + /// isolation. + #[test] + fn g10b_generation_zero_is_refused_before_the_wheel_exemption_applies() { + use pmacs_protocol::{MouseButton, MouseKind}; + let fid = FrontendId(774); + let (mut editor, mut semantic_states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let generation = stamped_generation(&semantic_states, fid); + + // The exempt kind with an INVALID generation: refused. + assert!( + !panel_mapping_is_current(&editor, &mut semantic_states, fid, MouseKind::ScrollDown, 0), + "a zeroed wheel is refused: the exemption must not run first" + ); + // A non-exempt kind with the same zero: also refused, so the + // above is the zero and not the kind. + assert!(!panel_mapping_is_current( + &editor, + &mut semantic_states, + fid, + MouseKind::Down(MouseButton::Left), + 0 + )); + // POSITIVE CONTROL — the same wheel with a real generation + // passes, so the refusal is not a dead predicate. + assert!( + panel_mapping_is_current( + &editor, + &mut semantic_states, + fid, + MouseKind::ScrollDown, + generation + ), + "a wheel with a real generation passes" + ); + + // And the predicate is WIRED: a press is the kind whose + // acceptance is visible, so it carries the end-to-end leg. + let baseline = editor.core.borrow().views[&fid].active; + assert_ne!( + baseline, panel, + "fixture: the panel must NOT already be focused, or the \ + refusal below is indistinguishable from acceptance" + ); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + mapped_pointer(fid, epochs, buffer_id, 0), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + baseline, + "a zeroed press never reaches the focus path" + ); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + mapped_pointer(fid, epochs, buffer_id, generation), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "and a real one does" + ); + } + + /// §5b G10b — a coordinate-free wheel is EXEMPT from the freshness + /// comparison, so a second queued tick is not refused. + /// + /// Only the GATE is proven here. The framing's G12 — that both ticks + /// apply their scrolls — is `panel-pointer-replay`'s, because no + /// panel wheel moves a view on this base. What this pins is that the + /// second tick is not refused, which is the half that would + /// otherwise make G12 unreachable. + /// + /// The exemption must also NOT advance the key. Returning early + /// before the read is what gives that; advancing on a wheel would + /// make the wheel invalidate the press that follows it. + #[test] + fn g10b_a_stale_generation_on_a_wheel_is_exempt_but_fatal_on_a_press() { + use pmacs_protocol::{MouseButton, MouseKind}; + let fid = FrontendId(775); + let (editor, mut semantic_states, _render, _document, panel, _epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let stale = stamped_generation(&semantic_states, fid); + + // Move the mapping, so `stale` now names a mapping that is gone — + // exactly the state the first of two queued ticks leaves behind. + foreign_edit(&editor, buffer_id, b"moved\n"); + + // The wheel FIRST, while the key has not yet been re-read: the + // exempt path must neither compare nor advance. + let before = semantic_states[&fid] + .panel_mapping_generation_peek() + .expect("a stamped key"); + assert!( + panel_mapping_is_current( + &editor, + &mut semantic_states, + fid, + MouseKind::ScrollDown, + stale + ), + "the second queued tick still lands — without the exemption \ + the first tick advances the key and the panel scrolls once \ + and dies" + ); + assert_eq!( + semantic_states[&fid].panel_mapping_generation_peek(), + Some(before), + "and the exempt path does not advance the key: a wheel that \ + advanced it would invalidate the press that follows" + ); + + // NEGATIVE CONTROL — a press echoing the SAME stale value is + // refused, so the acceptance above is the exemption and not a + // check that never fires. + assert!( + !panel_mapping_is_current( + &editor, + &mut semantic_states, + fid, + MouseKind::Down(MouseButton::Left), + stale + ), + "a press against a stale mapping is refused" + ); + + // Every coordinate-free wheel kind is exempt, not just the two + // vertical ones. + for kind in [ + MouseKind::ScrollUp, + MouseKind::ScrollDown, + MouseKind::ScrollLeft, + MouseKind::ScrollRight, + ] { + assert!( + panel_mapping_is_current(&editor, &mut semantic_states, fid, kind, stale), + "{kind:?} is coordinate-free and exempt" + ); + } + } + + /// §5b G11a — **exhaustion fails CLOSED and does not leave a zombie + /// band**. + /// + /// Three obligations, and each has its own failure: publish + /// `Absent`, clear input authority, and LATCH so the next frame + /// cannot resurrect the band. + #[test] + fn g11a_generation_exhaustion_publishes_absent_and_latches_for_the_session() { + let fid = FrontendId(776); + let (mut editor, mut semantic_states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + + // Precondition: a live band, and a gesture the exhaustion must + // not silently strand. + assert!( + matches!( + semantic_states[&fid].last_panel_payload_for_test(), + Some(pmacs_protocol::PanelFramePayload::PresentMapped { .. }) + ), + "fixture: the band is live and mapped" + ); + + // Park the key one below the ceiling against the CURRENT + // snapshot, so the next mapping change is the advance that + // overflows. Seeding the snapshot too keeps the "unchanged" arm + // from firing instead. + let snapshot = editor + .panel_mapping_snapshot(fid) + .expect("a presentable mapping"); + semantic_states + .get_mut(&fid) + .expect("projection") + .seed_panel_mapping_generation_for_test(snapshot, u64::MAX); + + foreign_edit(&editor, buffer_id, b"one edit too many\n"); + let messages = semantic_states + .get_mut(&fid) + .expect("projection") + .render_frame(&editor); + + assert!( + messages.iter().any(|msg| matches!( + msg, + InstanceMessage::PanelFrame(pmacs_protocol::PanelFramePayload::Absent) + )), + "the band is published Absent: refusing input alone leaves a \ + stale panel painted and permanently inert" + ); + assert!( + semantic_states[&fid].panel_declaration().is_none(), + "and input authority is cleared with it" + ); + assert!( + semantic_states[&fid].panel_mapping_generation_exhausted(), + "and the session latches" + ); + assert_eq!( + semantic_states[&fid].panel_mapping_generation_peek(), + None, + "an exhausted session reports NO key: the stored pair still \ + holds the ceiling it stopped at, and naming it would \ + describe a key no frame carries and no gesture may echo" + ); + + // The NEXT frame must not resurrect the band. Without the latch + // the snapshot is unchanged, the "unchanged" arm returns the + // frozen ceiling, and a `PresentMapped` ships with a key that can + // no longer distinguish anything. + let next = semantic_states + .get_mut(&fid) + .expect("projection") + .render_frame(&editor); + assert!( + !next.iter().any(|msg| matches!( + msg, + InstanceMessage::PanelFrame( + pmacs_protocol::PanelFramePayload::PresentMapped { .. } + ) + )), + "no zombie band on the following frame" + ); + + // And inbound stays refused for the rest of the session. + let baseline = editor.core.borrow().views[&fid].active; + assert_ne!(baseline, panel, "fixture: the panel is not focused"); + for echoed in [1, u64::MAX] { + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + mapped_pointer(fid, epochs, buffer_id, echoed), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + baseline, + "an exhausted session accepts no gesture, whatever it echoes" + ); + } + } + /// §5b G5a — the key advancing RAISES cancellation, without waiting /// for another pointer event. /// @@ -6763,15 +7081,7 @@ mod tests { // A FOREIGN edit — nothing this frontend did, and no further // pointer event. The key moves on the next read. - { - let core = editor.core.borrow(); - let registry = core.registry.clone(); - let mut reg = registry.borrow_mut(); - reg.get_mut(buffer_id) - .expect("panel buffer") - .set_generated_contents(b"moved\n") - .expect("a plain content change"); - } + foreign_edit(&editor, buffer_id, b"moved\n"); let snapshot = editor.panel_mapping_snapshot(fid); let state = semantic_states.get_mut(&fid).expect("projection"); let advanced = state.panel_mapping_generation(snapshot); diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 1774870..286b972 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -429,6 +429,16 @@ pub struct SemanticRenderState { /// `generation` starts at 0 meaning "never established"; the first /// real mapping takes 1, because zero is invalid on the wire. panel_mapping: Option<(crate::editor::PanelMappingSnapshot, u64)>, + /// §5b G11a — set once the mapping generation cannot advance, and + /// never cleared for the session. + /// + /// Exhaustion fails **closed**: no key means no authority, so the + /// band is published `Absent` and every inbound panel event is + /// refused. The latch is what makes that permanent. Without it the + /// next projection finds an UNCHANGED snapshot, takes the + /// "unchanged" arm, and hands back the frozen ceiling — resurrecting + /// the band with a key that can no longer distinguish anything. + panel_mapping_exhausted: bool, /// §5b G5 — the panel gesture this frontend has an ACCEPTED `Down` /// for, if any. /// @@ -661,6 +671,7 @@ impl SemanticRenderState { // frame from shipping a redundant authoritative `Absent`. last_panel_payload: Some(PanelFramePayload::Absent), panel_mapping: None, + panel_mapping_exhausted: false, accepted_gesture: None, panel_gesture_cancellations: 0, panel_epoch_used: 0, @@ -772,6 +783,9 @@ impl SemanticRenderState { &mut self, snapshot: Option, ) -> Option { + if self.panel_mapping_exhausted { + return None; + } let snapshot = snapshot?; // Matched by value, not by reference: the changed arm CANCELS, // and cancelling needs `&mut self`. @@ -792,7 +806,30 @@ impl SemanticRenderState { // physical `Up`, the producer clears its latch and the // cancelling event never arrives. self.cancel_accepted_gesture(); - generation.saturating_add(1) + let Some(next) = generation.checked_add(1) else { + // §5b G11a — fail CLOSED, and return BEFORE the + // store below. A saturating add would freeze the key + // at the ceiling while the mapping kept moving + // underneath it, which is precisely the stale-gesture + // hole the key exists to close, with the check still + // looking like it passes. + // + // Returning BEFORE the store below is a second, + // redundant guard against the same zombie band: + // recording `(snapshot, MAX)` here would make the + // next read take the unchanged arm and hand back the + // ceiling. Measured, the two are ALTERNATIVES — + // either alone keeps the band down, and only + // removing both resurrects it. The latch is kept as + // the primary because it has a job the ordering + // does not: it is what makes `peek` and the + // authoritative read agree that this session has no + // key, rather than reporting the ceiling it stopped + // at. + self.panel_mapping_exhausted = true; + return None; + }; + next } // First establishment takes 1, never 0: zero is the wire's // "uninitialised" value and is refused on sight. @@ -802,10 +839,34 @@ impl SemanticRenderState { Some(next) } + /// §5b G11a — whether this session's key is exhausted. + #[must_use] + pub fn panel_mapping_generation_exhausted(&self) -> bool { + self.panel_mapping_exhausted + } + + /// Place the key one advance below the ceiling, so exhaustion is + /// reachable in a test without 2^64 mutations. + #[doc(hidden)] + pub fn seed_panel_mapping_generation_for_test( + &mut self, + snapshot: crate::editor::PanelMappingSnapshot, + generation: u64, + ) { + self.panel_mapping = Some((snapshot, generation)); + } + /// The current key without advancing it, for assertions and for /// callers that must not have a side effect. + /// + /// `None` once exhausted, matching the authoritative read. The + /// stored pair still holds the ceiling it stopped at, and reporting + /// that would name a key no frame carries and no gesture may echo. #[must_use] pub fn panel_mapping_generation_peek(&self) -> Option { + if self.panel_mapping_exhausted { + return None; + } self.panel_mapping .as_ref() .map(|(_, generation)| *generation) From 419b078c0f9b2cc3c294783be2679fbbf4244054 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 16 Aug 2026 08:28:54 +0200 Subject: [PATCH 18/35] feat(panel): the receiver rows --- dedupe, atomicity, and the TUI control Closes the rest of this slice's G9-G15 obligations. One production change, one design correction found by mutation, and the rest witnesses. G9b is the production change. `panel_motion_is_new` compared the cell alone, so the FIRST motion after the mapping moved was eaten --- the pointer has not travelled, but the cell now denotes different text, and that is exactly the motion the daemon needs to re-anchor the gesture. Now keyed by `(generation, cell)`. Deliberately read at the motion site rather than reset from the frame path: resetting on every accepted repaint would re-arm within one generation and bring pixel-rate traffic straight back. G9a, G9c, G10, G10a, G10c are witnesses over behaviour that was already correct: a generation change ships even when the visible cells are byte-identical; an identical frame at a higher generation still moves authority; invalid frames, zero generations and lower generations are each refused with frame AND generation retained; `Absent` does not erase the high-water mark. G15 is the TUI structural control, and its fixture IS the control: the session has no `SemanticRenderState` at all, so every local click, drag and wheel effect below is reached without a producer in existence. That is a stronger claim than asserting a value was not consulted. Both wheel ticks must land, for the same reason the mapped family needs its exemption. The design correction: G10a's first version asserted the zero refusal with a generation already HELD. Mutating the zero check away left it green --- zero is also *lower* than the held value, so the nondecreasing clause refused the frame and the row proved nothing about zero. Zero is only isolable before any authority exists, which is also the case the framing names: a sender that never initialised the field. Split into its own row with that setup, and the row says why. Mutations, each biting only its named row: - dedupe compares the cell alone -> G9b - the frame path re-arms the dedupe on every accepted frame -> G9b - daemon dedupes across the generation change -> G9a - return early on frame equality before applying the generation -> G9c - apply the generation before validating -> G10 - accept generation zero -> G10a (after the split; before it, this mutation SURVIVED) - `Absent` erases the high-water mark -> G10c - a lower generation is accepted -> G10c - panel input requires a token the TUI cannot have -> G15 Deferred, per SS5b's split table and unchanged here: G11b (exhaustion cancellation), G12a/G12b (both two-tick wheel EFFECTS), G6c/G7c. Verified: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --lib` (1959); `cargo test -p pmacs-gpu --bins` (280); `bottom_panel_stage1_acceptance` (47), `bottom_panel_stage2b_daemon_acceptance` (39), `bottom_panel_stage2b_gpu_acceptance` (2); `git diff --check`. Clippy caught two findings in the new test code after the suites were already green, which is why it runs as its own step. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/main.rs | 342 +++++++++++++++++- .../bottom_panel_stage2b_daemon_acceptance.rs | 217 +++++++++++ 2 files changed, 558 insertions(+), 1 deletion(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index b278823..c232c73 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2224,6 +2224,15 @@ struct PanelBand { /// because the first drag after a press must reach the daemon even at the /// cell the press landed on. last_pointer_cell: Option, + /// §5b G9b — the mapping generation `last_pointer_cell` was measured + /// under, so the dedupe re-arms across a mapping change. + /// + /// Comparing the cell alone EATS the first motion after the mapping + /// moves: the pointer has not travelled, but the cell now denotes + /// different text, and that is exactly the motion the daemon needs. + /// `None` for a legacy session, where it is constant and the dedupe + /// behaves as it always did. + last_pointer_generation: Option, /// Whether the pointer is currently over the divider strip, which /// decides the `RowResize` cursor icon. hover_divider: bool, @@ -7044,6 +7053,7 @@ impl State { self.panel.hover_divider = false; self.panel.pointer_held = false; self.panel.last_pointer_cell = None; + self.panel.last_pointer_generation = None; had } // §5b G8b — a mapped session REJECTS the legacy family @@ -7429,10 +7439,18 @@ impl State { /// traffic and every one of those is a daemon-side gesture — the same /// reason the terminal path dedupes. fn panel_motion_is_new(&mut self, coord: CellCoord) -> bool { - if self.panel.last_pointer_cell == Some(coord) { + // §5b G9b — keyed by generation as well as cell. Deliberately + // read here rather than reset from the frame path: resetting on + // every accepted repaint would re-arm within one generation and + // bring pixel-rate traffic back, and the dedupe would stop + // being a dedupe. + if self.panel.last_pointer_cell == Some(coord) + && self.panel.last_pointer_generation == self.panel.mapping_generation + { return false; } self.panel.last_pointer_cell = Some(coord); + self.panel.last_pointer_generation = self.panel.mapping_generation; true } @@ -7441,6 +7459,7 @@ impl State { fn set_panel_pointer_held(&mut self, held: bool) { self.panel.pointer_held = held; self.panel.last_pointer_cell = None; + self.panel.last_pointer_generation = None; } /// Begin a divider drag at surface pixel `y`, if the pointer is on the @@ -20002,6 +20021,327 @@ mod tests { } } + /// A mapped session with a real installed frame, and the frame. + /// + /// The baseline is installed in the CORRECT family, so every + /// assertion below has real authority to preserve rather than a + /// `None` that was never set. + fn mapped_panel_session(rows: u32, generation: u64) -> Option<(State, PanelFrame)> { + let mut state = headless_or_skip(800, 600, "alpha")?; + let frame = present_panel(&mut state, rows); + state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION); + let mut mapped = frame; + mapped.panel_epoch += 1; + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: mapped.clone(), + mapping_generation: generation, + }), + "fixture: the mapped baseline must install" + ); + Some((state, mapped)) + } + + /// §5b G9b — motion dedupe suppresses a repeat WITHIN one generation + /// and RE-ARMS across one. + /// + /// The pointer has not moved in either case. What differs is whether + /// the cell still denotes the same text: comparing the cell alone + /// eats the first motion after the mapping moves, which is precisely + /// the motion the daemon needs to re-anchor the gesture. + #[test] + fn g9b_motion_dedupe_is_keyed_by_generation_not_by_cell_alone() { + let Some((mut state, frame)) = mapped_panel_session(4, 5) else { + return; + }; + let cell = pmacs_protocol::CellCoord::new(1, 1); + + assert!( + state.panel_motion_is_new(cell), + "the first motion at a cell is always new" + ); + assert!( + !state.panel_motion_is_new(cell), + "a repeat within one generation is suppressed — without this \ + sub-cell motion becomes pixel-rate wire traffic" + ); + + // A same-generation repaint: a different frame, same key. The + // dedupe must NOT re-arm, or the suppression above is defeated + // by any style or selection repaint. + let mut repaint = frame.clone(); + repaint.focused = !frame.focused; + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: repaint, + mapping_generation: 5, + }), + "fixture: the repaint must be accepted, or it proves nothing" + ); + assert!( + !state.panel_motion_is_new(cell), + "a same-generation repaint does not re-arm the dedupe" + ); + + // The mapping MOVES. The pointer still has not. + let mut rekeyed = frame.clone(); + rekeyed.focused = frame.focused; + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: rekeyed, + mapping_generation: 6, + }), + "fixture: the re-keyed frame must be accepted" + ); + assert!( + state.panel_motion_is_new(cell), + "and across a generation the same cell IS new: it denotes \ + different text now" + ); + } + + /// §5b G9c — a byte-identical frame at a HIGHER generation still + /// updates authority. + /// + /// The visual result is nothing, which is what makes this easy to + /// get wrong: returning early on frame equality leaves the frontend + /// echoing a generation the daemon has already retired, and every + /// gesture it sends is then refused. + #[test] + fn g9c_an_identical_frame_at_a_higher_generation_still_updates_authority() { + let Some((mut state, frame)) = mapped_panel_session(4, 5) else { + return; + }; + + // Byte-identical, and the same generation: no work at all. + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: frame.clone(), + mapping_generation: 5, + }), + "a true duplicate does nothing" + ); + assert_eq!(state.panel.mapping_generation, Some(5)); + + // Byte-identical, HIGHER generation: authority moves. + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: frame.clone(), + mapping_generation: 6, + }), + "the daemon has re-keyed the mapping under an unchanged \ + picture — a fold or an edit off-screen does exactly that" + ); + assert_eq!( + state.panel.mapping_generation, + Some(6), + "echoing 5 from here would have every gesture refused" + ); + assert_eq!( + state.panel.frame.as_ref(), + Some(&frame), + "and the frame is unchanged, because it was identical" + ); + } + + /// §5b G10 — a structurally invalid mapped frame is refused + /// ATOMICALLY: previous frame and previous generation both retained. + /// + /// Updating one without the other leaves a valid generation naming a + /// frame that was never shown, which is the same stale-mapping hole + /// from the receiving side. + #[test] + fn g10_an_invalid_mapped_frame_is_refused_atomically() { + let Some((mut state, frame)) = mapped_panel_session(4, 5) else { + return; + }; + state.set_panel_pointer_held(true); + state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(1, 1)); + + // G10 — structurally invalid: the cell count contradicts `size`. + let mut invalid = frame.clone(); + invalid.cells.pop(); + assert!( + invalid.validate().is_err(), + "fixture: the frame must actually be invalid" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: invalid, + mapping_generation: 9, + }) + ); + assert_eq!(state.panel.frame.as_ref(), Some(&frame), "frame retained"); + assert_eq!( + state.panel.mapping_generation, + Some(5), + "and its generation with it — a valid 9 here would name a \ + frame this frontend never received" + ); + + // G10a — structurally VALID, generation zero. Zero is what a + // sender that never initialised the field produces; accepting it + // once disables the check for the session. + let mut zeroed = frame.clone(); + zeroed.panel_epoch += 7; + assert!( + zeroed.validate().is_ok(), + "fixture: this one is structurally fine, so the refusal is \ + about the zero" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: zeroed, + mapping_generation: 0, + }) + ); + assert_eq!(state.panel.frame.as_ref(), Some(&frame)); + assert_eq!(state.panel.mapping_generation, Some(5)); + assert!(state.panel.pointer_held, "a refusal ends no live gesture"); + assert_eq!( + state.panel.last_pointer_cell, + Some(pmacs_protocol::CellCoord::new(1, 1)), + "nor discards its dedupe baseline" + ); + } + + /// §5b G10a — a ZERO generation is refused when NO authority is + /// held yet. + /// + /// This is the only setup that isolates the zero check. With a + /// generation already held, zero is also *lower* than it, so the + /// nondecreasing clause refuses the frame and a row built that way + /// stays green with the zero check deleted — measured, not assumed. + /// + /// The case is the one the framing names: a sender that never + /// initialised the field. Accepting it once records `Some(0)`, and + /// from there no later frame is ever "lower", so the check is + /// disabled for the whole session. + #[test] + fn g10a_a_zero_generation_is_refused_before_any_authority_is_held() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + // A legacy frame first, so there is a real retained band to + // preserve, then the family switch. `mapping_generation` is + // genuinely `None` here — that is the state under test, not an + // accident of the fixture. + let baseline = present_panel(&mut state, 4); + state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION); + assert_eq!( + state.panel.mapping_generation, None, + "fixture: no authority held yet" + ); + + let mut zeroed = baseline.clone(); + zeroed.panel_epoch += 1; + assert!( + zeroed.validate().is_ok(), + "fixture: structurally fine, so the refusal is about the zero" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: zeroed, + mapping_generation: 0, + }), + "zero is the wire's uninitialised value and is never authority" + ); + assert_eq!( + state.panel.mapping_generation, None, + "and nothing is recorded — `Some(0)` would make every later \ + frame non-lower and disable the check for the session" + ); + assert_eq!( + state.panel.frame.as_ref().map(|f| f.panel_epoch), + Some(baseline.panel_epoch), + "the retained band survives the refusal" + ); + + // POSITIVE CONTROL — the same frame with a real generation + // installs, so the refusal is about the zero and not about the + // family switch. + let mut real = baseline.clone(); + real.panel_epoch += 2; + assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: real, + mapping_generation: 1, + })); + assert_eq!(state.panel.mapping_generation, Some(1)); + } + + /// §5b G10c — the generation is NONDECREASING, and `Absent` does not + /// erase the high-water mark. + /// + /// Values may SKIP: the daemon advances the key on mapping changes, + /// not on frames, so a frontend that misses two repaints legitimately + /// jumps. What may never happen is going backward — a delayed frame + /// must not roll this frontend's authority back to a mapping that is + /// gone. + #[test] + fn g10c_generation_is_nondecreasing_and_survives_absent() { + let Some((mut state, frame)) = mapped_panel_session(4, 5) else { + return; + }; + + // A SKIP forward is fine. + let mut skipped = frame.clone(); + skipped.panel_epoch += 1; + assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: skipped.clone(), + mapping_generation: 9, + })); + assert_eq!(state.panel.mapping_generation, Some(9)); + + // A LOWER one is refused, atomically. + let mut delayed = frame.clone(); + delayed.panel_epoch += 2; + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: delayed.clone(), + mapping_generation: 8, + }) + ); + assert_eq!( + state.panel.frame.as_ref().map(|f| f.panel_epoch), + Some(skipped.panel_epoch), + "the retained frame survives" + ); + assert_eq!(state.panel.mapping_generation, Some(9)); + + // An EQUAL generation with a different frame is accepted: a + // style or selection repaint does not move the mapping, and + // refusing it would freeze the band. + let mut repaint = skipped.clone(); + repaint.focused = !skipped.focused; + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: repaint, + mapping_generation: 9, + }), + "an equal-generation repaint is valid" + ); + assert_eq!(state.panel.mapping_generation, Some(9)); + + // `Absent` hides the band but must NOT erase the high-water + // mark: a frame delayed across the hide would otherwise come + // back believed. + assert!(state.apply_panel_payload(PanelFramePayload::Absent)); + assert!(state.panel.presented().is_none(), "the band is down"); + assert_eq!( + state.panel.mapping_generation, + Some(9), + "and the high-water mark stands" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: delayed, + mapping_generation: 8, + }), + "so the delayed frame is still refused after the hide" + ); + assert!(state.panel.presented().is_none(), "and stays down"); + } + /// §5b G8b — a MAPPED frontend refuses the legacy family, and the /// refusal is ATOMIC. /// diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index 880ed25..616fc69 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -1442,6 +1442,223 @@ fn g1_a_foreign_edit_moves_the_mapping_key_before_anything_renders() { ); } +/// §5b G15 — **TUI structural control**: the local panel click, drag +/// and wheel paths keep their effects with NO mapping generation +/// anywhere. +/// +/// The TUI hit-tests the daemon's own state directly. It receives no +/// `PanelFrame`, so it has no generation to echo and there is nothing +/// for a freshness check to compare. If the check ever migrates from +/// the authenticated semantic boundary into shared replay, local input +/// stops working entirely — and it would stop silently, because +/// refusing a gesture looks exactly like a gesture that did nothing. +/// +/// The control is the FIXTURE: this session has no +/// `SemanticRenderState` at all. Every effect below is therefore +/// reached without a producer in existence, which is the strongest form +/// of "no generation was consulted" — not an assertion about a value, +/// but the absence of anything that could hold one. +#[test] +#[allow( + clippy::too_many_lines, + reason = "one control over three input kinds: splitting it would \ + give each leg its own fixture, and the shared fixture — a \ + session with no SemanticRenderState at all — is the control" +)] +fn g15_local_tui_panel_input_keeps_its_effects_with_no_generation() { + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind as TuiMouseKind}; + + let state = EditorState::new_with_roots(&crate::iso::roots()); + exec(&state, "pmacs.lsp.config = {}"); + state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + exec( + &state, + "PANEL_BUF = pmacs.buffer.create(\"*g15*\") + pmacs.buffer.set_generated_contents(PANEL_BUF, \ + \"alpha alpha\\nbravo bravo\\ncharlie\\ndelta\\necho\\nfox\\ngolf\\nhotel\\n\") + pmacs.window.display(PANEL_BUF, { side = \"bottom\", height = 4 })", + ); + let mut state = state; + let panel = state + .core + .borrow() + .side_window_for(FrontendId::LOCAL) + .expect("a panel"); + let document = state + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("a document"); + + // Paint one frame first, exactly as the TUI does before a user can + // click anything. The window's text view is built from the buffer at + // paint time, so input dispatched against an unpainted window maps + // every row to offset zero and the row proves nothing. + { + let mut cells = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize]; + let mut grid = pmacs::cell::CellGrid { + cells: &mut cells, + stride: COLS, + size: CellSize::new(ROWS, COLS), + }; + pmacs::editor::paint_frame( + &state, + FrontendId::LOCAL, + &HashMap::new(), + &mut grid, + CellSize::new(ROWS, COLS), + ); + } + + // Where the panel actually is, from the same layout the TUI paints. + let panel_rect = { + let core = state.core.borrow(); + let view = core.views.get(&FrontendId::LOCAL).expect("LOCAL view"); + let area = pmacs::window::Rect::new(0, 0, ROWS - 1, COLS); + let fixed = core.panel_fixed_rows(FrontendId::LOCAL, area.size.rows); + view.layout.compute(area, &fixed)[&panel] + }; + let row = u16::try_from(panel_rect.origin.row + 1).expect("row fits"); + // Past the gutter, and inside a line that is long enough for the + // drag below to stay within it: clamping at the line end would make + // press and drag land on the same byte and prove nothing. + let gutter = state.core.borrow().windows[&panel].gutter_width(); + let col = u16::try_from(panel_rect.origin.col + gutter + 1).expect("col fits"); + let click = |kind| MouseEvent { + kind, + column: col, + row, + modifiers: KeyModifiers::NONE, + }; + + // Precondition, scoped to THIS view: the panel is not already + // focused, or every assertion below would pass without input. + assert_eq!( + state.core.borrow().views[&FrontendId::LOCAL].active, + document, + "precondition: the document is focused" + ); + + // CLICK — focuses the panel and positions the cursor. + state.dispatch_mouse( + FrontendId::LOCAL, + click(TuiMouseKind::Down(MouseButton::Left)), + CellSize::new(ROWS, COLS), + ); + assert_eq!( + state.core.borrow().views[&FrontendId::LOCAL].active, + panel, + "a local press focuses the panel — no token, no refusal" + ); + let pressed_cursor = state.core.borrow().windows[&panel].cursor; + assert_ne!( + pressed_cursor, 0, + "the press positioned the cursor inside the text, not at the \ + buffer start — a row that pressed into an unpainted window \ + would read zero here and never notice" + ); + + // DRAG — extends a selection inside the panel. + state.dispatch_mouse( + FrontendId::LOCAL, + MouseEvent { + kind: TuiMouseKind::Drag(MouseButton::Left), + column: col + 7, + row, + modifiers: KeyModifiers::NONE, + }, + CellSize::new(ROWS, COLS), + ); + assert!( + state.core.borrow().windows[&panel].selection.is_some(), + "a local drag selects inside the panel" + ); + assert_ne!( + state.core.borrow().windows[&panel].cursor, + pressed_cursor, + "and the drag moved the cursor, so the selection is a real range" + ); + + state.dispatch_mouse( + FrontendId::LOCAL, + MouseEvent { + kind: TuiMouseKind::Up(MouseButton::Left), + column: col + 7, + row, + modifiers: KeyModifiers::NONE, + }, + CellSize::new(ROWS, COLS), + ); + + // WHEEL — two ticks, both of which must land. The mapped family's + // wheel exemption exists so this stays true over the wire; here + // there is no wire, and it must be true for the same reason. + let before = state.core.borrow().windows[&panel].view_top; + for _ in 0..2 { + state.dispatch_mouse( + FrontendId::LOCAL, + click(TuiMouseKind::ScrollDown), + CellSize::new(ROWS, COLS), + ); + } + assert_ne!( + state.core.borrow().windows[&panel].view_top, + before, + "a local wheel scrolls the panel" + ); +} + +/// §5b G9a — a generation change is EMITTED even when the picture is +/// byte-identical. +/// +/// The panel shows four rows; an edit further down the buffer moves the +/// key without changing a single visible cell. Suppressing that frame +/// because the cells match would leave the frontend echoing a +/// generation the daemon has already retired, and every gesture it then +/// sends is refused — the panel goes quietly dead while looking +/// perfectly correct. +#[test] +fn g9a_a_generation_change_ships_even_when_the_cells_are_identical() { + let mut session = Session::new(); + open_panel(&session, "g9a", 4); + session.declare(1, 24, 80); + + // Enough lines that the visible four cannot see the edit below. + let mut lines = String::new(); + for line in 0..20 { + use std::fmt::Write as _; + writeln!(lines, "line {line}").expect("writing to a String never fails"); + } + foreign_edit(&session, &lines); + let baseline = session.present(); + let before = mapping_generation(&mut session).expect("a live key"); + + // Change line 15 only. Rows 0..4 are untouched. + let edited = lines.replace("line 15\n", "LINE 15 CHANGED\n"); + assert_ne!(edited, lines, "fixture: the edit must actually apply"); + foreign_edit(&session, &edited); + + let payload = session + .frame() + .expect("a generation change is not silence: the frame must ship"); + let (frame, shipped) = match payload { + PanelFramePayload::PresentMapped { + frame, + mapping_generation, + } => (frame, mapping_generation), + other => panic!("a v25 producer ships the mapped family, got {other:?}"), + }; + assert_eq!( + frame.cells, baseline.cells, + "fixture: the visible cells really are identical — if they \ + differ, this row is testing an ordinary repaint instead" + ); + assert!( + shipped > before, + "and the key moved with the edit below the fold" + ); +} + /// §5b G3 — the **stable** inputs, one row each. /// /// Every entry here is something that repaints a panel without changing From bf4ffe09950ac71e9dd64a77daf06880eb800998 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 16 Aug 2026 08:45:41 +0200 Subject: [PATCH 19/35] fix(panel): a second press ends the gesture it replaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by reading the arming path back after the G5 checkpoint, not by a failing row. `arm_accepted_gesture` overwrote an already-armed latch, so a dropped `Up` --- one lost to an outbox that closed under a stall --- was followed by the next press silently discarding the first gesture's record, without counting it as a cancellation. Inert on this base, where records are only counted. Once `panel-pointer-replay` attaches a child release to each record, the discarded one leaves a button held down with nothing left to release it, and the arming code that decides this is this slice's. Mutation: restore the plain overwrite -> the new row alone. Also records three CI-red observations from the slice-completion gate, which is the first entry on this lane with a MEASURED confound instead of the standing uncontrolled one. Three wall-clock-deadline rows red in one run --- criterion_1 by 0.12%, a PTY lifecycle race, and a 5s child -exit deadline --- all green in isolation, the last in 0.15s against its 5s deadline. `uptime` during the run went 14.02 -> 28.35, from an unrelated turso test suite on the same machine with one binary at 693% CPU. Not a controlled experiment, but the same evidence U9's synthetic -load control was meant to produce, and it points at load. Two process traps are recorded with them, because both were made here. The Bash tool caps a command at ten minutes and SIGTERMs it, which the gate reports as `FAILED (exit 143)` on whatever stage was running and which reads exactly like a real failure. And `pkill -f ` kills the invoking shell when the pattern appears in its own command line, so the intended target survives while the operator believes it died --- and here `pkill -f "cargo test"` would have destroyed an unrelated project's build. Identify by PID. Verified: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings`; the four §5b G5 rows. The full protocol gate follows on a quieter machine; the run described above is not evidence for this tree and is recorded as an observation only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 36 +++++++++++++++++++++++- docs/bottom-panel-framing.md | 41 +++++++++++++++++++++++++++ src/daemon.rs | 54 ++++++++++++++++++++++++++++++++++++ src/semantic_render.rs | 7 +++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index b7a2b47..f4e1e74 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -287,7 +287,12 @@ from #171 and #215. half (G1–G4), outbound family selection, inbound family gating, the GPU's negotiated family enum, and the nine family-gate rows (G6a–b, G7a–b, G8). **Full `--protocol` gate green at `3c06176`.** - In progress since, uncommitted: G5's accepted-gesture latch. + Then three more: G5a's trigger and its latch, the wheel exemption + with exhaustion and mapped coalescing, and the receiver rows. + **Every row this slice owns is now witnessed** — G0, G1–G4a, G5a plus + latch substrate, G6–G8, G9a–c, G10/G10a–c, G11a, G13a–b, G14a–b, G15 + — with G4b, G5b–e/g/i–p, G6c, G7c, G11b and G12a–b left to the replay + lane per §5b's split table. - **PROTOCOL-BEARING — v25, and it runs alone.** `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**. - **Why it exists.** A `PanelPointer` names a cell and nothing on the @@ -465,6 +470,35 @@ from #171 and #215. (2026-08-15, plain `cargo test --lib`, not a gate step). Isolated rerun green. Same budget as the occurrence above, now seen in two different selectors on one branch in one day. + - **Three wall-clock-deadline rows red in ONE run, under a MEASURED + foreign load** (2026-08-16, log `20260816T063330Z`). Steps + `04-lib-crdt` and `09-sweep-crdt`. Fragments: + `criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke` + — "per-keystroke orchestrator time **1.001196ms** exceeds 1ms", + **0.12% over**, the smallest margin any budget on this lane has + failed by; `m6_1_pty_mode_lifecycle_started_then_exited` — "must + observe stdout 'done'"; and + `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon` — "child + did not exit within 5s". All three green in isolation, the last in + **0.15s against its 5s deadline**, a 33x margin. + - **This is the first entry on this lane with a NAMED confound rather + than the standing uncontrolled one.** `uptime` during the run: + load average **14.02 → 28.35**, from an unrelated `turso` test + suite on the same machine (`./verify_task_state.sh + turso-without-rowid`, target dir `/opt/target`, one test binary at + **693% CPU**). It is not a controlled experiment, but it is the + same evidence U9's synthetic-load control was meant to produce, and + it points at load. U9 stays owed; its value is now lower. + - **Two process traps this cost, both worth carrying forward.** The + Bash tool caps a command at 10 minutes and SIGTERMs it, which the + gate reports as `FAILED (exit 143)` on whatever stage was running — + indistinguishable from a real failure in the summary line. Run the + protocol gate under `setsid` and watch the log. And `pkill -f + ` kills the invoking shell when the pattern appears in its + own command line, so the intended target survives and the operator + believes it died. **Both mistakes were made here**; the second + nearly killed an unrelated project's build, because + `pkill -f "cargo test"` would have matched it. Identify by PID. - **Three consecutive full-gate runs, three DIFFERENT unrelated failures** (composition budget in `04-lib-crdt`, then composition again in `10-sweep-crdt`, then this in `08-sweep`), against a diff diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 685d3e5..a00da80 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -2497,6 +2497,47 @@ Two consequences are recorded rather than fixed: later and leaving that one out would be an inconsistency inside a single function rather than a clean deferral. +#### What the slice actually landed, row by row + +Recorded because the split table above states the *intent*, and a +reader auditing this later needs the *outcome* — including the two +places where writing the witnesses changed the design. + +**Landed here:** G0 (wire shapes, positions, field order, accumulated +pins), G1–G4a (the authoritative key, document and terminal halves), +G5a plus the latch substrate, G6a–b/G7a–b/G8a–e (both directions, all +four wrong-family quadrants, forged identity), G9a/G9b/G9c, G10 and +G10a–c, G11a, G13a–b, G14a–b, G15. + +**Still the replay lane's**, unchanged: G4b, G5b–e, G5g, G5i–p, G6c, +G7c, G11b, G12a–b. + +Two corrections the mutation pass forced, both of which had passed +their first review as written: + +- **G10a proved nothing about zero.** Asserted with a generation + already held, zero is also *lower* than it, so the nondecreasing + clause did the refusing and deleting the zero check left the row + green. Zero is only isolable before any authority exists — which is + also the case the framing names, a sender that never initialised the + field. Split into its own row with that setup. +- **G11a's exhaustion latch had no proven job.** The overflow path + already returns before storing the ceiling snapshot, so the next read + re-takes the changed arm and the band stays down without any latch. + Measured, the two are alternatives: only removing both resurrects the + band. The latch is kept, and now earns its place through `peek`, + which honours it so the peek and the authoritative read agree that an + exhausted session has no key rather than reporting the ceiling. + +One witness-shape note, since it recurs: the two G10b rows call +`panel_mapping_is_current` directly. A wheel has **no +dispatcher-visible effect on this base** — a document panel focuses on +`Down` only, and no panel pointer coordinate is consumed anywhere — so +a row asserting focus for a wheel would be green whatever the gate did. +Each row carries a press leg alongside, which does have an effect, to +show the predicate is wired into the production arm rather than merely +correct in isolation. + ### Coherence impact (`COHERENCE.md` §20) - **Journey steps touched: 5** (document-panel editing and selection) diff --git a/src/daemon.rs b/src/daemon.rs index 2a93ddf..d338222 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7189,6 +7189,60 @@ mod tests { ); } + /// §5b — SUBSTRATE: a second accepted press ENDS the gesture it + /// replaces rather than overwriting it. + /// + /// Found by reading the arming path back, not by a failing row: a + /// dropped `Up` — one lost to a closed outbox under a stall — is + /// followed by the next press, and a plain overwrite discards the + /// first gesture's record without counting it. Inert on this base, + /// where records are only counted; once replay attaches a child + /// release to each record, the discarded one leaves a button held + /// down with nothing left to release it. + #[test] + fn g5_substrate_a_second_press_ends_the_gesture_it_replaces() { + let fid = FrontendId(777); + let (mut editor, mut semantic_states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let generation = stamped_generation(&semantic_states, fid); + let press = || mapped_pointer(fid, epochs, buffer_id, generation); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + press(), + ); + assert!(semantic_states[&fid].has_accepted_gesture()); + assert_eq!( + semantic_states[&fid].panel_gesture_cancellations(), + 0, + "fixture: the first press cancels nothing" + ); + + // The `Up` never arrives; the next press does. + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + press(), + ); + assert!( + semantic_states[&fid].has_accepted_gesture(), + "the new gesture is armed" + ); + assert_eq!( + semantic_states[&fid].panel_gesture_cancellations(), + 1, + "and the one it displaced was ENDED, not dropped on the floor" + ); + } + /// §5b — SUBSTRATE for the framing's G5p: per-frontend gesture /// ownership. /// diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 286b972..2f429f4 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -728,6 +728,13 @@ impl SemanticRenderState { /// per §5b's split table, because the release it denies does not /// exist on this branch. pub fn arm_accepted_gesture(&mut self, gesture: AcceptedPanelGesture) { + // A second accepted press while one is already armed means the + // first gesture's release never arrived — a dropped `Up`, or an + // outbox that closed under a stall. END it rather than + // overwrite it: overwriting discards the record silently, and + // once replay attaches effects to that record the child is left + // holding a button down with nothing left to release it. + self.cancel_accepted_gesture(); self.accepted_gesture = Some(gesture); } From 2c60509e75ccba01bb1d8de449fd967ef5399cbb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 16 Aug 2026 08:58:51 +0200 Subject: [PATCH 20/35] docs(lane): the slice-completion gate is RED, and why no PR followed Nine of ten stages green at 5174f73. `09-sweep-crdt` red twice on `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon` --- "child did not exit within 5s" --- which is a NEW signature, absent from `ci-red-signatures.md` under any id. The row holds a fixed 5s wall-clock deadline for a spawned child to exit after a signal, and it runs inside the heaviest stage the gate has. It is green alone and green with all fourteen of its suite siblings, the latter in 0.15s against that same 5s deadline. Nothing about it touches panels or the wire. Sweep contention could not be separated from foreign load, and that is recorded as inconclusive rather than dressed up: running the sweep-crdt command alone red the same row, but `uptime` hit 59.51 during that run. An unrelated turso workload is running in a LOOP on this machine, holding a 16-core box at load 20-60, so clean gate evidence is not obtainable here. The remaining work on this lane is one clean gate run and nothing else. Recorded in the lane ledger rather than the registry, per this branch's standing reason: the registry here ends at U9 while the unmerged replay branch already holds a U10, and duplicate ids have survived a clean merge in this file once before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index f4e1e74..269b496 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -470,6 +470,27 @@ from #171 and #215. (2026-08-15, plain `cargo test --lib`, not a gate step). Isolated rerun green. Same budget as the occurrence above, now seen in two different selectors on one branch in one day. + - **`ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`, a NEW + signature, red TWICE and reproducibly** (2026-08-16, logs + `20260816T063330Z` and `20260816T064549Z`, step `09-sweep-crdt` + both times). Fragment: `child did not exit within 5s` at + `tests/gpu_invocation_acceptance.rs:180`. **Not in + `docs/ci-red-signatures.md` under any id.** Green in isolation and + green with all fourteen of its suite siblings, the latter in + **0.15s against its own 5s deadline** — a 33x margin. It reds only + inside the full-workspace crdt sweep. + - **The row is marginal by construction, and the sweep is the + worst place for it.** It holds a FIXED 5s wall-clock deadline for + a spawned child to exit after a signal, and it runs inside the + heaviest stage the gate has — `cargo test --workspace --features + crdt`, which saturates all 16 cores by itself. Nothing about it + touches panels or the wire. + - **Sweep contention could not be separated from foreign load**, + and the attempt is recorded as inconclusive rather than dressed + up: running `09-sweep-crdt`'s exact command alone red the same + row, but `uptime` hit **59.51** during that run, so the control + proved nothing it was meant to. Part of that number is the + sweep's own parallelism. - **Three wall-clock-deadline rows red in ONE run, under a MEASURED foreign load** (2026-08-16, log `20260816T063330Z`). Steps `04-lib-crdt` and `09-sweep-crdt`. Fragments: @@ -518,6 +539,18 @@ from #171 and #215. un-suppressing a lint on the struct that needed it. Three were caught by the user in review, one by a `missing_docs` warning. **The check is to look UP from the insertion point before writing, not down.** +- **SLICE-COMPLETION GATE STATUS — read before assuming this is + mergeable.** Nine of ten stages green at `5174f73`; `09-sweep-crdt` + red on the load-sensitive row above, twice. **A gate with a red stage + is a red gate, and no PR was opened on it.** The evidence that the + red is environmental is the isolation runs, not a judgement call: + the row is unrelated to panels, green alone and green with its whole + suite. **Clean evidence is not obtainable on this machine right + now** — an unrelated `turso` workload + (`./verify_task_state.sh turso-without-rowid`, target `/opt/target`) + is running in a LOOP, holding a 16-core box at load 20-60. The + remaining work on this lane is one clean gate run, on a quiet + machine, and nothing else. - **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite, `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. From cb830df7a7a49fc321b3daa53274248eb57aa556 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 17 Aug 2026 19:25:18 +0200 Subject: [PATCH 21/35] docs(lane): three record corrections, one of which cost two gate runs Review corrections: - The header said "ACTIVE, framing only" and named 3c06176 as the checkpoint. Now code-complete at 5174f73, ledger tip fb40d88, gate red. - The gate-status entry claimed isolation established an ENVIRONMENTAL cause. It does not. Isolation establishes INTERMITTENCE. Narrowed to: intermittent; foreign load is a measured confound; cause unresolved. No experiment here separated sweep contention, foreign load, and a genuine defect in the row. And one found while checking the user's "eleven-stage" phrasing against what actually ran. Yesterday's runs printed ELEVEN stages, today's printed TEN, and the difference is `05-acceptance-bottom_panel_stage2b_ daemon_acceptance`. `--acceptance` is an explicit repeated flag; the gate derives nothing from the diff. Both of today's runs used bare `--protocol`, so **no acceptance stage ran at all** --- the suites were verified by hand instead, which is not the gate. The `Gates:` line named suites but never the flag form, which is how that happened. It now carries the exact invocation and the expected stage count, with the note that a ten-stage run is missing every acceptance stage. It also said "the four `bottom_panel_*` suites" when there are FIVE on disk; `bottom_panel_stage2b_protocol_acceptance` is the fifth and belongs in a protocol-bearing lane above all others. All five are listed rather than guessing which four an earlier writer meant. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 47 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 269b496..d92e53e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -270,7 +270,7 @@ 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) — ACTIVE, framing only +## Panel cell-mapping generation (v25) — CODE-COMPLETE, GATE RED **Written with the branch's FIRST commit**, per the standing correction from #171 and #215. @@ -281,7 +281,8 @@ from #171 and #215. **`githubsucks/panel-mapping-generation` is the authoritative tip.** Recover with `git fetch githubsucks && git checkout panel-mapping-generation`. -- **No PR yet. Checkpoint: `3c06176`** — framing revision 16 (§5b) +- **No PR yet. Code-complete at `5174f73`; ledger tip `fb40d88`; the + slice-completion gate is RED.** Framing revision 16 (§5b) APPROVED 2026-08-15 at `7e85a6f`, then eleven implementation commits: wire shapes and pins (G0), the authoritative key with its terminal half (G1–G4), outbound family selection, inbound family gating, the @@ -541,18 +542,46 @@ from #171 and #215. is to look UP from the insertion point before writing, not down.** - **SLICE-COMPLETION GATE STATUS — read before assuming this is mergeable.** Nine of ten stages green at `5174f73`; `09-sweep-crdt` - red on the load-sensitive row above, twice. **A gate with a red stage - is a red gate, and no PR was opened on it.** The evidence that the - red is environmental is the isolation runs, not a judgement call: - the row is unrelated to panels, green alone and green with its whole - suite. **Clean evidence is not obtainable on this machine right + red on the row above, twice. **A gate with a red stage is a red gate, + and no PR was opened on it.** + - **The cause is UNRESOLVED.** The row is **intermittent**: green + alone, green with all fourteen of its suite siblings, red inside + the full-workspace crdt sweep. Foreign load is a **measured + confound**, not an explanation — establishing intermittence is not + establishing an environmental cause, and no experiment here + separated sweep contention, foreign load, and a genuine defect in + the row. Do not record it as environmental until something does. + - **Clean evidence is not obtainable on this machine right now** — an unrelated `turso` workload (`./verify_task_state.sh turso-without-rowid`, target `/opt/target`) is running in a LOOP, holding a 16-core box at load 20-60. The remaining work on this lane is one clean gate run, on a quiet machine, and nothing else. -- **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite, - `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. +- **Gates — THE EXACT INVOCATION, not a list of suites.** Naming the + suites without the flag form is what let two full runs go by with + bare `--protocol` and **no acceptance stage at all**; the suites were + verified by hand instead, which is not the gate and is exactly the + substitution these records exist to prevent. `--acceptance` is an + EXPLICIT repeated flag — the gate derives nothing from the diff. + + ``` + ./scripts/gate --protocol \ + --acceptance bottom_panel_stage1_acceptance \ + --acceptance bottom_panel_stage2a_acceptance \ + --acceptance bottom_panel_stage2b_daemon_acceptance \ + --acceptance bottom_panel_stage2b_gpu_acceptance \ + --acceptance bottom_panel_stage2b_protocol_acceptance \ + --acceptance gui_stage1a_wire_acceptance + ``` + + Sixteen stages: ten from `--protocol` plus one per suite. **A run + that prints ten stages is missing every acceptance stage**, and a run + that prints eleven is the older single-suite form. +- **The "four `bottom_panel_*` suites" phrasing was WRONG — there are + five.** `bottom_panel_stage2b_protocol_acceptance` is the fifth and + belongs in a protocol-bearing lane above all others. The block above + runs all five rather than guessing which four an earlier writer + meant; a superset is the safe reading of an ambiguous record. ## GPU launcher / probe SIGINT teardown — MERGED as #241 (`f8033bc`) From 9562fa793028d1eb7028242fffba6a1b54e65019 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 18 Aug 2026 11:10:31 +0200 Subject: [PATCH 22/35] docs(lane): the sweep-crdt red is pre-existing on main Attribution is settled. The identical `build-crdt && sweep-crdt` pair, run at the merge base 72da24a in the primary worktree with its own target dir, fails the SAME test: 119 binaries green, one red, `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`. This branch is not implicated, and no branch can pass this gate stage on this machine until the underlying defect is fixed --- main included. The mechanism is unknown, and this records two explanations I offered and then refuted, so nobody re-runs them: - Load: refuted. Red on a quiet machine, load 2.77 at launch. - Memory pressure: refuted by correcting my own instrument. The sampler showed free memory at 543MB, which looked damning, but it recorded `free` --- not the meaningful figure on Linux. `available` was 27G. The 543MB was reclaimable cache. I had already reported the memory story before checking that, which was wrong. - Leaked daemons: refuted. Peak 58, up only 8 during the sweep, and the green standalone runs already ran at 46-50. What the bisect did establish: green in every smaller context tried --- the test alone three times at 0.15s against its own 5s deadline, its whole suite, a workspace run filtered to just it, the lib binary then the suite, and the three GPU suites in sweep order --- and red 4/4 in the full workspace sweep across two trees. Cumulative across the 37 binaries preceding it, and not flaky. Also notes that /tmp is a 30G tmpfs holding 21G of an unrelated project's stale target directories. Recorded as an observation about this machine, not as the cause, and not touched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index d92e53e..5cd4d9c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -471,6 +471,43 @@ from #171 and #215. (2026-08-15, plain `cargo test --lib`, not a gate step). Isolated rerun green. Same budget as the occurrence above, now seen in two different selectors on one branch in one day. + - **RESOLVED ATTRIBUTION: the sweep-crdt red is PRE-EXISTING ON + MAIN.** The identical `build-crdt && sweep-crdt` pair run at the + merge base **`72da24a`**, in the primary worktree with its own + target dir, fails the SAME test: 119 binaries green, one red, + `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`. **This + branch is not implicated**, and no branch can pass this gate stage + on this machine until the underlying defect is fixed. + - **The MECHANISM is unknown, and two plausible explanations were + tested and REFUTED.** Recorded so nobody re-runs them: + - *Machine load*: refuted. Red on a quiet machine (load 2.77 at + launch, foreign workload gone). + - *Memory pressure*: refuted, **by correcting my own instrument**. + A sampler showed free memory falling to 543 MB and that looked + damning — but it recorded `free`, which on Linux is not the + meaningful figure. `available` was **27 G**. The 543 MB was + reclaimable cache. + - *Leaked daemons*: refuted. Peak 58 during the sweep, up only 8 + from the resting 50, and the green standalone runs already ran + at 46-50. + - **What the bisect DID establish.** Green in every smaller context + tried, and deterministic in the largest: the test alone (x3, 0.15s + against its 5s deadline); its whole 15-test suite; a + workspace-wide run FILTERED to just this test; the lib binary + (2145 tests) followed by the suite; and the three GPU suites in + sweep order. Red 4/4 in the full workspace sweep, across two + different trees. So it is cumulative across the 37 binaries that + precede it, and it is **not** flaky. + - **`/tmp` is a 30G tmpfs holding 21G**, almost all stale + `levshell-*-target` cargo directories belonging to an unrelated + project, 3-4 days old. Recorded as an observation about this + machine, **not** as the cause — `available` memory refutes that. + Not touched: they are not this project's to delete. + - **The earlier signature entry below overstated its case** and is + kept only for its measurements. It read the isolation runs as + proof of an environmental cause; they establish intermittence at + most, and the base-comparison above is what actually settled + attribution. - **`ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`, a NEW signature, red TWICE and reproducibly** (2026-08-16, logs `20260816T063330Z` and `20260816T064549Z`, step `09-sweep-crdt` @@ -551,6 +588,11 @@ from #171 and #215. establishing an environmental cause, and no experiment here separated sweep contention, foreign load, and a genuine defect in the row. Do not record it as environmental until something does. + - **The gate cannot pass on this machine for ANY branch**, main + included, until the pre-existing sweep-crdt defect is fixed. That + is a decision point, not a thing to keep re-running: either the + defect gets its own lane, or this lane's readiness bar is restated + against a gate that main itself can pass. - **Clean evidence is not obtainable on this machine right now** — an unrelated `turso` workload (`./verify_task_state.sh turso-without-rowid`, target `/opt/target`) From a4117adef8291e8d4f15a0c03021ecbe8503ad36 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 18 Aug 2026 11:27:41 +0200 Subject: [PATCH 23/35] docs(lane): locate the sweep-crdt mechanism --- the GPU probe will not die Sampled the process table twice a second through a reproducing sweep. After the test SIGINTs the launcher's process group: - `pmacs --gpu --socket ...` sits in `do_wait` for the full 5s. It is waiting on a child, not ignoring the signal. - `pmacs-gpu --headless-managed-probe ...`, its child and in the same process group so it received the SIGINT, sits in `futex_do_wait` and never exits. The deadline is missed because the GPU probe does not tear down under SIGINT. The fix belongs in the probe's shutdown path; raising the 5s would only hide it. Why the child hangs ONLY in a complete sweep is still open --- every prior GPU suite has exercised the adapter by then, which is where to look first. Five explanations are recorded as refuted so nobody re-runs them: load, leaked daemons, inotify, `--workspace` feature unification, and any specific preceding test --- all 37 preceding targets plus the suite run green, which is the genuinely strange part. The tmpfs hypothesis got a real experiment rather than an argument: /tmp went 21G -> 1.2G, available memory 27G -> 45G, and the sweep stayed red. Recorded with the note that my earlier `available`-based dismissal was itself unsound, since tmpfs pages are not reclaimable yet still appear in buff/cache --- right conclusion, wrong reasoning, and it took the experiment to know which. Also notes the test exists in two binaries: gpu_initial_target_ acceptance includes it as a module, so a reproducing sweep fails it twice. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5cd4d9c..a684772 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -478,8 +478,23 @@ from #171 and #215. `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`. **This branch is not implicated**, and no branch can pass this gate stage on this machine until the underlying defect is fixed. - - **The MECHANISM is unknown, and two plausible explanations were - tested and REFUTED.** Recorded so nobody re-runs them: + - **MECHANISM LOCATED, by sampling the process table twice a second + through a reproducing sweep.** After the test SIGINTs the + launcher's process group: + - `pmacs --gpu --socket …` (the launcher) sits in **`do_wait`** for + the full 5s — it is waiting on a child, not ignoring the signal; + - `pmacs-gpu --headless-managed-probe …` (its child, same process + group, so it received the SIGINT too) sits in + **`futex_do_wait`** and never exits. + + So the deadline is missed because the GPU probe child does not tear + down under SIGINT, and the launcher blocks on it. **The next step + is the probe's shutdown path**, not the test's timeout — raising + the 5s would only hide it. Why the child hangs *only* in a complete + sweep is still open; every prior GPU suite in the run has already + exercised the adapter by then, which is where to look first. + - **Five further explanations were tested and REFUTED.** Recorded so + nobody re-runs them: - *Machine load*: refuted. Red on a quiet machine (load 2.77 at launch, foreign workload gone). - *Memory pressure*: refuted, **by correcting my own instrument**. @@ -490,6 +505,24 @@ from #171 and #215. - *Leaked daemons*: refuted. Peak 58 during the sweep, up only 8 from the resting 50, and the green standalone runs already ran at 46-50. + - *tmpfs starving the box*: refuted **by experiment**, not + argument. `/tmp` went from 21G used to 1.2G (available memory + 27G -> 45G) and the sweep stayed red, same test. Note the + earlier `available`-based dismissal was itself unsound — tmpfs + pages are NOT reclaimable yet still appear in `buff/cache` — so + the hypothesis deserved the experiment it eventually got. + - *inotify exhaustion*: refuted. 47 instances in use of 1024. + - *`--workspace` feature unification*: refuted. The same two + targets under `--workspace` are green. + - *A specific preceding test*: refuted, and this is the strange + one. **All 37 targets that precede it, plus the suite, run + green** — same binaries, same order, same tests. Only the + complete 119-target sweep reproduces it. Other packages' + targets run later still (log lines 4848+), after the failure at + 3066, so they cannot be implicated either. + - Note the test exists in **two** binaries: `tests/gpu_initial_ + target_acceptance.rs` includes it as a module, so a reproducing + sweep fails it twice, at log lines 3083 and 3117. - **What the bisect DID establish.** Green in every smaller context tried, and deterministic in the largest: the test alone (x3, 0.15s against its 5s deadline); its whole 15-test suite; a From c1cc98ad5a7e612b274ac5c32d9da7bd6d3337bb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 18 Aug 2026 14:19:45 +0200 Subject: [PATCH 24/35] docs(lane): retract the "mechanism located" claim --- it was unsupported a92ef7f said the mechanism was located: the launcher blocked in `do_wait` on a GPU probe child stuck in `futex_do_wait`. Checking the instrument against the claim shows it does not support it. The sampler caught 394 distinct launchers across a reproducing sweep and the longest-lived was 5s TOTAL. For this test to fail, a launcher must outlive its SIGINT by 5s, so its lifetime would be 8s or more. The failing instance was never captured. What I described is a healthy launcher from one of the suite's other tests --- the normal teardown shape, reported as the defect. This is the same error as the `available`-memory reading earlier in this lane: a measurement that looked conclusive, reported before checking that it discriminated. Retracted here rather than left to be found. Two facts do survive and constrain the next attempt: `pmacs-gpu` installs no signal handling at all, so the probe should die on SIGINT's default action; and its main loop is a 50ms `recv_timeout`, so it never blocks indefinitely. The next instrument must key on the failing instance --- launchers outliving ~6s, or a PID recorded by the test itself --- rather than sampling every launcher and hoping. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index a684772..62843ca 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -478,21 +478,33 @@ from #171 and #215. `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`. **This branch is not implicated**, and no branch can pass this gate stage on this machine until the underlying defect is fixed. - - **MECHANISM LOCATED, by sampling the process table twice a second - through a reproducing sweep.** After the test SIGINTs the - launcher's process group: + - **MECHANISM STILL NOT ESTABLISHED. The "located" claim below was + RETRACTED on its own evidence** — read this before trusting it. + A process-table sampler ran through a reproducing sweep and caught + 394 distinct launchers, but the **longest-lived was 5s total**. For + this test to fail, a launcher must outlive its SIGINT by 5s, so its + total lifetime would be 8s or more. **The failing instance was + therefore never captured**, and the `do_wait`/`futex_do_wait` pair + below is a healthy launcher from one of the suite's other tests, + not the one that blew the deadline. The observation is kept only + as a description of the NORMAL teardown shape. + - Provisional and unconfirmed, from that sampler: - `pmacs --gpu --socket …` (the launcher) sits in **`do_wait`** for the full 5s — it is waiting on a child, not ignoring the signal; - `pmacs-gpu --headless-managed-probe …` (its child, same process group, so it received the SIGINT too) sits in **`futex_do_wait`** and never exits. - So the deadline is missed because the GPU probe child does not tear - down under SIGINT, and the launcher blocks on it. **The next step - is the probe's shutdown path**, not the test's timeout — raising - the 5s would only hide it. Why the child hangs *only* in a complete - sweep is still open; every prior GPU suite in the run has already - exercised the adapter by then, which is where to look first. + That is what a HEALTHY teardown looks like; it is not evidence + about the failing one. Two facts do survive and constrain the next + attempt: `pmacs-gpu` installs **no signal handling whatsoever** + (grep for SIGINT/signal_hook/sigaction across `pmacs-gpu/src` + returns nothing), so the probe should die on SIGINT's default + action; and the probe's main loop is a 50ms `recv_timeout`, so it + is never blocked indefinitely. **The next instrument must key on + the failing instance specifically** — sample only launchers whose + lifetime exceeds ~6s, or have the test itself record the PID and + timing — rather than sampling every launcher and hoping. - **Five further explanations were tested and REFUTED.** Recorded so nobody re-runs them: - *Machine load*: refuted. Red on a quiet machine (load 2.77 at From 6adcf17c25c8ed054317854b478564db60b058d1 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 18 Aug 2026 15:36:06 +0200 Subject: [PATCH 25/35] docs(lane): correct the claims this held branch was still transporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pushing 16cf3a2 made the retraction portable but not the correction. This ledger still carried four falsified statements, and a held branch that transports them is worse than one that never recorded them. - "119 binaries green, one red" -> 119 green result summaries and TWO red binaries. `gpu_initial_target_acceptance` includes the suite as a module, so a reproducing sweep reds twice (:3097 and :3131). - The ">=8s lifetime" arithmetic behind the retraction is FALSE. Both reproducing binaries finish in ~5.19s INCLUDING the 5s timeout, so the failing launcher lives about 5.1s --- inside what the sampler saw. The ">6s selector" proposed as the remedy would have captured nothing. The "mechanism located" claim is therefore not refuted by that argument; it stays unproven because the suite spawns root launchers from six call sites under --features crdt, so command line alone cannot attribute one to this test. - "the probe should die on SIGINT's default action" -> withdrawn. Absence of handler code does not establish default disposition; SIG_IGN is inherited across fork and survives exec, which is why inherited ignore is the leading hypothesis. - "never blocked indefinitely" -> only the event loop is bounded, at 50ms. The process is not: the stdin reader blocks in read_to_end and the loop leaves only when stdin closes. Also records that the defect now has its own lane, `gpu-probe-sigint-teardown`, whose framing supersedes every diagnostic claim here, and that §5b is held behind it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 64 +++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 62843ca..4ea861b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -474,37 +474,39 @@ from #171 and #215. - **RESOLVED ATTRIBUTION: the sweep-crdt red is PRE-EXISTING ON MAIN.** The identical `build-crdt && sweep-crdt` pair run at the merge base **`72da24a`**, in the primary worktree with its own - target dir, fails the SAME test: 119 binaries green, one red, - `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`. **This - branch is not implicated**, and no branch can pass this gate stage - on this machine until the underlying defect is fixed. - - **MECHANISM STILL NOT ESTABLISHED. The "located" claim below was - RETRACTED on its own evidence** — read this before trusting it. - A process-table sampler ran through a reproducing sweep and caught - 394 distinct launchers, but the **longest-lived was 5s total**. For - this test to fail, a launcher must outlive its SIGINT by 5s, so its - total lifetime would be 8s or more. **The failing instance was - therefore never captured**, and the `do_wait`/`futex_do_wait` pair - below is a healthy launcher from one of the suite's other tests, - not the one that blew the deadline. The observation is kept only - as a description of the NORMAL teardown shape. - - Provisional and unconfirmed, from that sampler: - - `pmacs --gpu --socket …` (the launcher) sits in **`do_wait`** for - the full 5s — it is waiting on a child, not ignoring the signal; - - `pmacs-gpu --headless-managed-probe …` (its child, same process - group, so it received the SIGINT too) sits in - **`futex_do_wait`** and never exits. - - That is what a HEALTHY teardown looks like; it is not evidence - about the failing one. Two facts do survive and constrain the next - attempt: `pmacs-gpu` installs **no signal handling whatsoever** - (grep for SIGINT/signal_hook/sigaction across `pmacs-gpu/src` - returns nothing), so the probe should die on SIGINT's default - action; and the probe's main loop is a 50ms `recv_timeout`, so it - is never blocked indefinitely. **The next instrument must key on - the failing instance specifically** — sample only launchers whose - lifetime exceeds ~6s, or have the test itself record the PID and - timing — rather than sampling every launcher and hoping. + target dir, fails the SAME test, + `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon`: **119 + green result summaries and TWO red binaries** — + `gpu_initial_target_acceptance` includes the suite as a module, so a + reproducing sweep reds twice (`…-2144707/09-sweep-crdt.log:3097` + and `:3131`). **This branch is not implicated**, and no branch can + pass this gate stage on this machine until the underlying defect is + fixed. + - **The defect now has its own lane: + `gpu-probe-sigint-teardown`** (pushed; framing revision 3 at + `docs/gpu-probe-sigint-framing.md`, run provenance at + `docs/probe-sigint-evidence.md`). **§5b is held behind it.** That + lane's framing supersedes every diagnostic claim below; the entries + here are kept only as the record of how it was found. + - **MECHANISM STILL NOT ESTABLISHED — and the RETRACTION below was + itself wrong.** Two corrections, in order: + - A "mechanism located" claim (launcher in `do_wait`, probe child + in `futex_do_wait`) was retracted on the argument that the + failing launcher "must live 8s or more" while the sampler's + longest-lived was 5s. + - **That arithmetic is false.** Both reproducing binaries finish in + **~5.19s including the 5s timeout** (`:3097`, `:3131`), so + `phase=ready` lands in about a tenth of a second and the failing + launcher lives roughly **5.1s** — inside what the sampler + observed. A ">6s selector", proposed as the remedy, would have + captured **nothing**. + - So the "located" claim is **not refuted by that argument**. It + stays **unproven for a different reason**: under `--features + crdt` the suite spawns root launchers from **six** call sites, so + a launcher captured by command line alone cannot be attributed to + this test. + - **Do not key on process age. Key on the PID the test records**, + with snapshots before and after its own `kill`. - **Five further explanations were tested and REFUTED.** Recorded so nobody re-runs them: - *Machine load*: refuted. Red on a quiet machine (load 2.77 at From 118088829176ccbe847959074a99dbbccee0d427 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 18 Aug 2026 15:50:30 +0200 Subject: [PATCH 26/35] docs(lane): the R9 conclusions were still here, and the onset is datable 779a6bd corrected four claims but left the ones that mattered most. This ledger still said --workspace unification was refuted, that R9 ran the "same binaries, same order, same tests", that later packages cannot be implicated, and that the cause is cumulative across the preceding 37 binaries. All four are withdrawn: - R9 executed gpu_initial_target_acceptance-91f51d0b and gpu_invocation_acceptance-6b4b8223; the sweeps executed -5d9105cb and -d4dae4f0, byte-different. Command shape changes Cargo's fingerprint, so the comparison was never made. --workspace selection and the preceding tests are both OPEN, not refuted. - "Later packages cannot be implicated because their targets run after the failure" ignores that they affect the build graph and fingerprints before their tests execute. - No cumulative cause follows from reductions that ran different binaries. What is established is narrower: reproducible in the full sweep, not reproduced in any subset attempted. And a finding that reframes the defect. sweep-crdt appears SEVENTEEN times in this target dir's gate logs. The ctrl_c failure appears in exactly the last three, and the test passed --- both copies, "... ok" --- inside the stage before them. Last green 20260815T185708Z, first red 20260816T063330Z, no reboot between; the three earlier red sweeps failed on unrelated rows. "Pre-existing on main" still holds, since 72da24a reproduces it, but "always broken" is contradicted, and bisecting that window is now the sharpest lead. The red full-sweep count is SEVEN, not five, each enumerated with its own log digest in the teardown lane's docs/probe-sigint-evidence.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 57 ++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 4ea861b..8b13cec 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -482,6 +482,17 @@ from #171 and #215. and `:3131`). **This branch is not implicated**, and no branch can pass this gate stage on this machine until the underlying defect is fixed. + - **THE ONSET IS DATABLE.** `sweep-crdt` appears 17 times in this + target dir's gate logs; the `ctrl_c` failure appears in exactly the + **last three**, and the test passed — both copies — inside the + stage before them. Last green `20260815T185708Z`, first red + `20260816T063330Z`, no reboot between. The three earlier red + sweeps failed on unrelated rows. So "pre-existing on `main`" holds, + but **"always broken" is contradicted**, and bisecting that window + is the sharpest available lead. + - **The red full-sweep count is SEVEN**, not five; each run is + enumerated with its own log digest in the teardown lane's + `docs/probe-sigint-evidence.md`. - **The defect now has its own lane: `gpu-probe-sigint-teardown`** (pushed; framing revision 3 at `docs/gpu-probe-sigint-framing.md`, run provenance at @@ -526,25 +537,39 @@ from #171 and #215. pages are NOT reclaimable yet still appear in `buff/cache` — so the hypothesis deserved the experiment it eventually got. - *inotify exhaustion*: refuted. 47 instances in use of 1024. - - *`--workspace` feature unification*: refuted. The same two - targets under `--workspace` are green. - - *A specific preceding test*: refuted, and this is the strange - one. **All 37 targets that precede it, plus the suite, run - green** — same binaries, same order, same tests. Only the - complete 119-target sweep reproduces it. Other packages' - targets run later still (log lines 4848+), after the failure at - 3066, so they cannot be implicated either. + - *`--workspace` artifact selection*: **NOT refuted.** Two targets + under `--workspace` are green, and the preceding targets without + `--workspace` are green, but neither run isolates the factor — + see below. + - *A specific preceding test*: **NOT refuted, and the earlier entry + here was wrong.** It claimed all 37 preceding targets plus the + suite run green "same binaries, same order, same tests". The + binaries were **not** the same: that run executed + `gpu_initial_target_acceptance-91f51d0b` and + `gpu_invocation_acceptance-6b4b8223`, while the failing sweeps + executed `-5d9105cb` and `-d4dae4f0`, which are byte-different. + Cargo's target selection changes the fingerprint, so command + shape changes the executable. The comparison was never made. + - Also withdrawn: that other packages "cannot be implicated" + because their targets run after the failure. Later-selected + packages can affect Cargo's build graph and fingerprints + **before** their tests execute. + - Also withdrawn: that the cause is "cumulative across the + preceding 37 binaries". Nothing established that. - Note the test exists in **two** binaries: `tests/gpu_initial_ target_acceptance.rs` includes it as a module, so a reproducing sweep fails it twice, at log lines 3083 and 3117. - - **What the bisect DID establish.** Green in every smaller context - tried, and deterministic in the largest: the test alone (x3, 0.15s - against its 5s deadline); its whole 15-test suite; a - workspace-wide run FILTERED to just this test; the lib binary - (2145 tests) followed by the suite; and the three GPU suites in - sweep order. Red 4/4 in the full workspace sweep, across two - different trees. So it is cumulative across the 37 binaries that - precede it, and it is **not** flaky. + - **What the bisect established, and what it did NOT.** Green in + every smaller context tried — the test alone (x3, 0.15s against its + 5s deadline); its whole 15-test suite; a workspace-wide run + FILTERED to just this test; the lib binary (2145 tests) then the + suite; the three GPU suites in sweep order — and red in every full + workspace sweep, seven of them across two trees. + **No cumulative cause follows from that.** The subset runs and the + sweeps executed byte-different binaries, so the reductions never + made the comparison they appeared to make. What is established is + only: reproducible in the full sweep, not reproduced in any subset + attempted so far. - **`/tmp` is a 30G tmpfs holding 21G**, almost all stale `levshell-*-target` cargo directories belonging to an unrelated project, 3-4 days old. Recorded as an observation about this From 8b2bcb53dfdad004b6a38da1775a0ffa3cc641a2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 18 Aug 2026 16:06:22 +0200 Subject: [PATCH 27/35] docs(lane): reductions were never a bisect, and the onset is not a source boundary Three corrections to what this ledger still asserted. - It called the reductions a "bisect". They were not: no run isolated a variable, because the subsets and the sweeps executed different Cargo compilations. - It said those compilations were "byte-different". Withdrawn --- the bytes a historical run executed are not knowable now, since target dirs have been overwritten and a hash computed today is the current occupant's. What IS known is the differing Cargo suffixes, i.e. differing metadata hashes, which is enough to void the comparison and is all that is claimed. - The onset was described as a bisectable window. It is not. Reflog and commit times put HEAD at 7599661 during the last green --- 3c06176 landed 40s after that run finished --- and at 724b785 during the first red, since 5174f73 landed 08:45:41 after that run ended 08:42:01. Cleanliness was captured at neither endpoint. Decisively, 72da24a is an ANCESTOR of the passing 7599661 yet fails today, and no source-monotonic cause produces that. Reproduce clean endpoints before considering a bisect. Also corrects the onset counts: per test copy the 17 sweep-crdt logs are 13 both-copies-ok, 1 where neither copy executed (the stage died compiling pmacs with error[E0308]), and 3 both-failed. "14 passes" and "the earlier reds failed on unrelated tests" were both wrong --- one of those runs never reached the test at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 8b13cec..48b7822 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -494,8 +494,8 @@ from #171 and #215. enumerated with its own log digest in the teardown lane's `docs/probe-sigint-evidence.md`. - **The defect now has its own lane: - `gpu-probe-sigint-teardown`** (pushed; framing revision 3 at - `docs/gpu-probe-sigint-framing.md`, run provenance at + `gpu-probe-sigint-teardown`** (pushed; framing revision 5 at + `docs/gpu-probe-sigint-framing.md` (revision 5), run provenance at `docs/probe-sigint-evidence.md`). **§5b is held behind it.** That lane's framing supersedes every diagnostic claim below; the entries here are kept only as the record of how it was found. @@ -559,15 +559,19 @@ from #171 and #215. - Note the test exists in **two** binaries: `tests/gpu_initial_ target_acceptance.rs` includes it as a module, so a reproducing sweep fails it twice, at log lines 3083 and 3117. - - **What the bisect established, and what it did NOT.** Green in + - **What the reductions established, and what they did NOT** — they + were never a bisect, since no run isolated a variable. Green in every smaller context tried — the test alone (x3, 0.15s against its 5s deadline); its whole 15-test suite; a workspace-wide run FILTERED to just this test; the lib binary (2145 tests) then the suite; the three GPU suites in sweep order — and red in every full workspace sweep, seven of them across two trees. **No cumulative cause follows from that.** The subset runs and the - sweeps executed byte-different binaries, so the reductions never - made the comparison they appeared to make. What is established is + sweeps executed **different Cargo compilations** — differing + suffixes, hence differing metadata hashes — so the reductions never + made the comparison they appeared to make. (Calling them + "byte-different" is withdrawn: the bytes a historical run executed + are not knowable now, only the suffixes.) What is established is only: reproducible in the full sweep, not reproduced in any subset attempted so far. - **`/tmp` is a 30G tmpfs holding 21G**, almost all stale From 4751aa99e6035d11c33f81cb1c55cb80b3cec3d9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 18 Aug 2026 16:23:01 +0200 Subject: [PATCH 28/35] docs(lane): finish the byte-identity withdrawal and drop the bisect claim Two statements this ledger still carried after 3313d8a said withdrew them. - The reduction entry still said the artifacts "are byte-different". Historical byte identity is UNKNOWN and is not claimed: target dirs have been overwritten, so a hash computed today is the current occupant's. What is established is differing Cargo suffixes, hence differing metadata hashes, hence different compilations --- enough to void the comparison, and all that is asserted. - The onset entry still called the window the sharpest lead and called for bisecting it. Cleanliness was captured at neither endpoint, so the first move is reproducing 7599661 and 724b785 clean in isolated target dirs, and deciding from that. Also corrects the onset counts here to match the manifest: per test copy the 17 sweep-crdt logs are 13 both-copies-ok, 1 where neither copy executed (the stage died compiling pmacs, error[E0308]), and 3 both-failed --- not "three earlier reds on unrelated rows". And narrows the ancestry note. 72da24a being an ancestor of the passing 7599661 while failing today shows only that outcome is not determined by commit alone. It does not discriminate an environmental change, a source/environment interaction, or a fix before 7599661 with a regression before 724b785; an ancestor outside the interval says nothing about whether the interval regressed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 48b7822..634328f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -482,14 +482,16 @@ from #171 and #215. and `:3131`). **This branch is not implicated**, and no branch can pass this gate stage on this machine until the underlying defect is fixed. - - **THE ONSET IS DATABLE.** `sweep-crdt` appears 17 times in this - target dir's gate logs; the `ctrl_c` failure appears in exactly the - **last three**, and the test passed — both copies — inside the - stage before them. Last green `20260815T185708Z`, first red - `20260816T063330Z`, no reboot between. The three earlier red - sweeps failed on unrelated rows. So "pre-existing on `main`" holds, - but **"always broken" is contradicted**, and bisecting that window - is the sharpest available lead. + - **THE ONSET IS DATABLE.** `sweep-crdt` has 17 logs here. Counted + per test copy: **13** both copies `... ok`, **1** where neither + executed (the stage died compiling `pmacs`, `error[E0308]`), **3** + both `FAILED`. Last observed green `20260815T185708Z`, first + observed red `20260816T063330Z`; boot began 08-14 09:30, so no + reboot between. So "pre-existing on `main`" holds, + but **"always broken" is contradicted**. The window is **not yet a + bisect target**: cleanliness was captured at neither endpoint, so + the lane's first move is to reproduce `7599661` and `724b785` + clean, in isolated target dirs, and decide from that. - **The red full-sweep count is SEVEN**, not five; each run is enumerated with its own log digest in the teardown lane's `docs/probe-sigint-evidence.md`. @@ -544,12 +546,14 @@ from #171 and #215. - *A specific preceding test*: **NOT refuted, and the earlier entry here was wrong.** It claimed all 37 preceding targets plus the suite run green "same binaries, same order, same tests". The - binaries were **not** the same: that run executed + **compilations** were not the same: that run executed `gpu_initial_target_acceptance-91f51d0b` and `gpu_invocation_acceptance-6b4b8223`, while the failing sweeps - executed `-5d9105cb` and `-d4dae4f0`, which are byte-different. - Cargo's target selection changes the fingerprint, so command - shape changes the executable. The comparison was never made. + executed `-5d9105cb` and `-d4dae4f0`. Differing Cargo suffixes + mean differing metadata hashes, so command shape changed the + compilation and the comparison was never made. **Historical byte + identity is UNKNOWN** and is not claimed — target dirs have been + overwritten since. - Also withdrawn: that other packages "cannot be implicated" because their targets run after the failure. Later-selected packages can affect Cargo's build graph and fingerprints From c8ae975ed938e1256b44038bcca1944d60381555 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 18 Aug 2026 18:03:52 +0200 Subject: [PATCH 29/35] docs(lane): the ancestry pair is non-comparable; point at revision 7 Two corrections. - This ledger still said 72da24a being an ancestor of the passing 7599661 while failing today is something "no source-monotonic cause explains". Withdrawn: the two observations differ in commit AND environment AND time, so they are non-comparable and support no causal conclusion of any kind. Different commits can deterministically produce different outcomes, so the pair says nothing about determinism either. - It identified the teardown lane's framing as revision 5; the portable lane is revision 7. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 634328f..8bd3c61 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -497,7 +497,7 @@ from #171 and #215. `docs/probe-sigint-evidence.md`. - **The defect now has its own lane: `gpu-probe-sigint-teardown`** (pushed; framing revision 5 at - `docs/gpu-probe-sigint-framing.md` (revision 5), run provenance at + `docs/gpu-probe-sigint-framing.md` (revision 7, `e2084fb`+), run provenance at `docs/probe-sigint-evidence.md`). **§5b is held behind it.** That lane's framing supersedes every diagnostic claim below; the entries here are kept only as the record of how it was found. From 6b0ad8e5dddccca2202077b360255e42e95aa12f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 19 Aug 2026 10:07:50 +0200 Subject: [PATCH 30/35] docs(lane): stop naming a stale ledger tip, and point at revision 8 Two state pointers were wrong. It named ledger tip fb40d88 while the branch was four commits past it, and identified the teardown framing as revision 5 on one line and revision 8 on the next. The tip pointer is removed rather than corrected. A literal SHA naming this branch's own head is stale the moment the next commit lands --- which is precisely how it came to say fb40d88 --- so the ledger now points at the branch head itself and says why. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 8bd3c61..494f06b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,8 +281,12 @@ from #171 and #215. **`githubsucks/panel-mapping-generation` is the authoritative tip.** Recover with `git fetch githubsucks && git checkout panel-mapping-generation`. -- **No PR yet. Code-complete at `5174f73`; ledger tip `fb40d88`; the - slice-completion gate is RED.** Framing revision 16 (§5b) +- **No PR yet. Code-complete at `5174f73`; the slice-completion gate is + RED.** The ledger tip is **the branch head itself** + (`githubsucks/panel-mapping-generation`) — a literal SHA here goes + stale the moment the next commit lands, which is exactly how this + line came to name `fb40d88` while the branch was four commits past + it. Framing revision 16 (§5b) APPROVED 2026-08-15 at `7e85a6f`, then eleven implementation commits: wire shapes and pins (G0), the authoritative key with its terminal half (G1–G4), outbound family selection, inbound family gating, the @@ -496,8 +500,8 @@ from #171 and #215. enumerated with its own log digest in the teardown lane's `docs/probe-sigint-evidence.md`. - **The defect now has its own lane: - `gpu-probe-sigint-teardown`** (pushed; framing revision 5 at - `docs/gpu-probe-sigint-framing.md` (revision 7, `e2084fb`+), run provenance at + `gpu-probe-sigint-teardown`** (pushed; framing revision 8 at + `docs/gpu-probe-sigint-framing.md` (revision 8), run provenance at `docs/probe-sigint-evidence.md`). **§5b is held behind it.** That lane's framing supersedes every diagnostic claim below; the entries here are kept only as the record of how it was found. From 08588361a54799fb58cf7e89ab288300ab86a550 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 19 Aug 2026 10:41:30 +0200 Subject: [PATCH 31/35] docs(lane): point at teardown framing revision 9 The held lane still identified the teardown framing as revision 8, so the approved state was not portable from this branch. Revision 9 adds the total run classifier, the void budget, the endpoint verdict table, the bisect-step policy and counterbalanced ordering --- none of which a reader arriving here would have found. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 494f06b..133b2fa 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -500,8 +500,8 @@ from #171 and #215. enumerated with its own log digest in the teardown lane's `docs/probe-sigint-evidence.md`. - **The defect now has its own lane: - `gpu-probe-sigint-teardown`** (pushed; framing revision 8 at - `docs/gpu-probe-sigint-framing.md` (revision 8), run provenance at + `gpu-probe-sigint-teardown`** (pushed; framing revision 9 at + `docs/gpu-probe-sigint-framing.md` (revision 9), run provenance at `docs/probe-sigint-evidence.md`). **§5b is held behind it.** That lane's framing supersedes every diagnostic claim below; the entries here are kept only as the record of how it was found. From e06fb838509f2c55fa55bdbcf4c517c2a6d3616b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 16:52:28 +0200 Subject: [PATCH 32/35] =?UTF-8?q?docs(lane):=20=C2=A75b=20rebased=20onto?= =?UTF-8?q?=20post-#241=20main,=20and=20gated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto f13506c at 3e68b76 --- 31 ahead, 0 behind, clean, signed. One conflict, in this file, where main's merged-#241 block and this lane's own first-commit block landed at the same position; both kept. No code file overlapped: #241 touched scripts/, tests/common/ and two suites, while this lane touches the protocol, daemon and GPU sources. The blocker is gone --- #241 fixed the sweep-crdt defect this lane was held behind. Gate run in two labelled pieces, because 16 stages exceed the 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, at load 5.28), build-crdt green, and sweep-crdt red on exactly two rows --- m4_24_bare_string_glob_stays_relative and m4_24_d3_fallback_base_is_the_smallest_attachment_dir. Those two are PRE-EXISTING and local-only: they fail identically at current main f13506c, and they passed in CI on #241's Test (crdt) job. Also records that an earlier attempt is not evidence. I wrapped it in `timeout 580` to fit the command cap, which killed the sweep mid-run and reported it as a stage failure --- the same self-inflicted shape as the SIGTERM truncation in the probe lane, and it should not be read as a result. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 133b2fa..498820f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -270,7 +270,35 @@ 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) — CODE-COMPLETE, GATE RED +## Panel cell-mapping generation (v25) — REBASED, GATE GREEN except two pre-existing rows + +- **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. +- **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. +- **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. **Written with the branch's FIRST commit**, per the standing correction from #171 and #215. From 026bb4955f7e6f36f53969e434c623d53e34f8de Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 17:41:17 +0200 Subject: [PATCH 33/35] 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) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 117 ++++++++++++++---- src/daemon.rs | 295 ++++++++++++++++++++++++++++++++++++++++---- src/editor.rs | 5 + 3 files changed, 371 insertions(+), 46 deletions(-) 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, From 70b334d9519fd05d717b53cd2a722cfc8603aac7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 17:56:06 +0200 Subject: [PATCH 34/35] docs(lane): rebase onto 5f2015c, and note the gate that follows is head-exact The branch was two behind, not one --- 24e4039 and 5f2015c both landed on main after the first rebase. Rebased onto 5f2015c cleanly, no conflicts; both commits picked up are docs-only. The previously recorded 16-stage green ran about a minute BEFORE 3d08255 was committed, which makes it worktree evidence rather than head-exact evidence. This commit is the last content change before the gate is re-run, so the run that follows measures the head that is proposed for merge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index ea03521..11b44ba 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -272,13 +272,16 @@ waits for a signal that is not coming. ## Panel cell-mapping generation (v25) — REBASED, FULL GATE GREEN, review round 4 fixed -- **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 +- **BASE IS `5f2015c`**, current `githubsucks/main`. Rebased there + cleanly, no conflicts; the two commits picked up are docs-only (the + `rd_precondition` occurrence record and the handoff's `timeout` + rule). +- The **first** rebase, onto `f13506c` (post-#241 main), took 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. + lane above merged. No code file has overlapped across either rebase — + #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 From 61f0faf12cb2e88310cbe9e49fb813c4cadf2c4c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 18:20:23 +0200 Subject: [PATCH 35/35] docs(ci-reds): U10 --- the budget red rotates across one commit Two consecutive scripts/gate runs at 70b334d, worktree verified clean before and after each run, were 15/16 green apiece. Run A redded 13-sweep on dired_open_renders_10k_entries_under_200ms at 263.961465ms against a 200ms budget; run B redded 15-sweep-crdt on criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke at 1.044609ms against a 1ms budget. Each red is green in the other run, and both are green isolated at load 9.34. That excludes the tree more strongly than U7 could: not "the diff touches no render path" but the SAME COMMIT passing and failing each row. Neither failing path is touched by the branch under test. It does NOT establish load as the cause --- load was not sampled during either failing step, and the row says so rather than borrowing a reading taken elsewhere in the run. Honours both escalation rules it trips. U7 says a repeat of one of its selectors is a separate incident, and run A repeated one; U6 says one of its pair redding alone is a separate incident, and run B did that. Both are filed here rather than appended. The row also declines to pick between the repeat and the rotation, because both are true of these observations. Closes one rival U7 left open: per-worktree gate target directories mean no sibling shared this one. Names the standing discriminating control U9 already specified and which remains unrun --- pin test-binary concurrency to 1, and separately load a lone --lib binary --- and asks that this family stop consuming review rounds until it runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 14 ++++++++++++++ docs/ci-red-signatures.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 11b44ba..21c9f7b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -361,6 +361,20 @@ waits for a signal that is not coming. 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. +- **HEAD-EXACT GATING IS BLOCKED BY THE BUDGET-ROW FAMILY, recorded as + `docs/ci-red-signatures.md` U10.** Two consecutive gate runs at + `70b334d`, worktree verified clean before and after each, were + **15/16 green each** — and the red **rotated**: run A took + `13-sweep` (`dired_open_renders_10k_entries_under_200ms`, + 263.961465ms against 200ms), run B took `15-sweep-crdt` + (`criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke`, + 1.044609ms against 1ms). **Each red is green in the other run**, and + both are green isolated at load 9.34. Neither path is touched by this + branch. The earlier `20260820T153017Z-147411` all-green run was on + the same code, one docs commit earlier. +- **No PR was opened on that evidence.** The standing condition is a + head-exact GREEN gate, and 15/16-twice-with-a-rotating-red is not it, + however well the rotation is explained. - **A prior gate attempt is NOT evidence**: I wrapped it in `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 diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 4177b75..e5acbbc 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -893,3 +893,39 @@ claim is the one a later reader would otherwise reach for.* | **relation to U2 — a NEAR MISS, do not match it there** | the PTY fragment is U2's exact family (`stty -a output was: ""`), but U2's selector field names only `m6_1_pty_raw_mode_disables_kernel_echo`. U2's occurrence 2 saw raw **and** canonical fail together; here **canonical redded alone and raw passed**, which U2's evidence has never shown. It is recorded here rather than folded into U2 so that the "canonical alone" case stays visible | | **relation to U6 — its own instruction, honoured** | `composition_overhead_under_ten_percent` is one of U6's two selectors, and U6 says plainly: "If a future run reds **one** of these without the other, that is a different incident and should be judged as one." It redded without `criterion_1_end_of_line_typing…`, in a different step, at a far larger margin (1.613× here against U6's 1.297×). Judged as a different incident, as instructed | | **what this row does NOT assert** | that the two selectors share a mechanism. They failed together once; they belong to different subsystems; and U7 already refused this exact merge for U6. The **co-failure inside one step with an in-run green control** is the signature — not either name, and not a shared cause | + +### U10 — the budget red ROTATES between two consecutive runs of one commit + +Recorded during §5b review round 4, 2026-08-20. **Two consecutive +`scripts/gate` runs at the same commit with a clean worktree verified +at both ends of each run** — `70b334d`, `git status --porcelain` empty +before and after, both times. Each run was 15/16 green. Each red is a +**wall-clock budget assertion in a different step**, and **each is +green in the other run**. + +| field | value | +| --- | --- | +| **run A** | log `20260820T155616Z-359755`, step `13-sweep` red: `dired_open_renders_10k_entries_under_200ms`, **263.961465ms against 200ms** (32% over). Step `15-sweep-crdt` **green** | +| **run B** | log `20260820T160806Z-578046`, step `15-sweep-crdt` red: `optimistic::tests::criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke`, **1.044609ms against 1ms** (4.5% over), 2148 passed. Step `13-sweep` **green** | +| **required fragments** | `M8.2 spec: 10K entries must render within 200ms; took ` / `criterion 1: per-keystroke orchestrator time` + `exceeds 1ms` | +| **status** | **two occurrences, neither reproduced; every selector green on rerun** | +| **isolated controls** | both green in an isolated rerun of their own selector at load average 9.34, through the gate's target directory — the same control shape U6 and U7 each used | +| **what IS established** | **the tree is excluded, as strongly as this repository can exclude it.** Not "the diff touches no render path" — the **same commit** produced a pass and a fail of each row, with the worktree verified clean at both ends of both runs. Neither failing path is touched by the branch under test (`src/optimistic.rs`, `tests/m8_2_acceptance.rs` and the dired paths are all absent from `git diff --name-only githubsucks/main...HEAD`) | +| **what is NOT** | **that load caused it.** Load was **not sampled during either failing step**. A 76.63 reading exists for run A but was taken later in the same run, while step 15 was compiling; run B began at 23.46. Neither figure measures the failing moment, and this row does not pretend otherwise | +| **rival CLOSED since U7** | the shared `CARGO_TARGET_DIR` confound U7 left "real and again unmeasured". Each worktree now gets its own gate target directory (`pmacs-mapping-gen-8cb089c8`); no sibling shared it. Excluded **for these occurrences only** — it says nothing about U7's | +| **machine context, NOT a cause** | 150 leaked `pmacs` daemons were live throughout, 1.9 GB resident, oldest ~6.3 days — the standing "Leaked daemons — NEEDS A LANE" item. Their instantaneous CPU sampled at ~0%. Recorded because it is true of the machine, **not** because anything here shows it mattered | +| **relation to U7 — its escalation rule, honoured and CUT BOTH WAYS** | U7 says "a future run that reds the **same** one of these twice is a different incident and should be judged as one." Run A redded `dired_open_renders_10k_entries_under_200ms`, which was U7's run-1 selector, so that selector has now redded twice, 11 days apart — filed here rather than appended to U7, as instructed. **But within this pair the selector ROTATED**, which is U7's own core signature, and run B's selector is U6's. The repeat and the rotation are both true, and this row asserts neither as the finding | +| **relation to U6** | run B's selector is one of U6's two, redding **without** `composition_overhead_under_ten_percent`. U6 instructs that one-without-the-other is a different incident; honoured here | +| **what this row does NOT assert** | a shared mechanism between the two rows, or any mechanism at all. **The signature is the rotation across an identical commit** — not either name | + +**Why this family keeps recurring, stated plainly.** Every row in it is +a wall-clock budget asserted **inside a workspace-wide parallel test +run**. `cargo test --workspace` starts many test binaries at once, so +each budget competes with the rest of the sweep in **every** run, +including the ones that pass. A 4.5% overshoot on a 1ms budget is not a +signal about the code. **U9 already named the discriminating control** +— pin test-binary concurrency to 1 and separately load a lone `--lib` +binary — and it remains unrun. Until it runs, this family should not +consume another review round. + +**Widening a budget is not the fix**, and R1 already rejected it.