diff --git a/docs/active-work.md b/docs/active-work.md index 6460507..21c9f7b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -270,6 +270,558 @@ 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, FULL GATE GREEN, review round 4 fixed + +- **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 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 + 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. +- **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. +- **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 + 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. + +- **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. 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 + GPU's negotiated family enum, and the nine family-gate rows + (G6a–b, G7a–b, G8). **Full `--protocol` gate green at `3c06176`.** + 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 + 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. +- **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 + 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. 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 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`), 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**, 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 + 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. +- **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. +- **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 + 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 + 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. + - **`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. + - **`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. + - **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, + `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 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`. + - **The defect now has its own lane: + `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. + - **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 + 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. + - *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` 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 + **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`. 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 + **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 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 **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 + `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` + 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: + `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 + 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 + 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.** +- **SLICE-COMPLETION GATE STATUS — read before assuming this is + mergeable.** Nine of ten stages green at `5174f73`; `09-sweep-crdt` + 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. + - **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`) + 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 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`) - **MERGED 2026-08-20** at approved head `5089715`, merge commit diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 35abd22..a00da80 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -1770,6 +1770,800 @@ 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 16 — APPROVED 2026-08-15. Nothing implemented.** +Revision 16 answers review of 15; three of its items reverse a rule 15 +introduced: + +- **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 + 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. +- **"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. +- **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 +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. + +### 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"**. + +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 +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 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: + +- **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 +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 +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 at the END, with the field order stated + +Postcard encodes enums **positionally**, so a shipped variant's field +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.** + +```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. +- 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. + +`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** | + +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 + 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. + +#### 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. + +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 +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**: + +- **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 — and it is +PROACTIVE, driven by the key advancing, not reactive to a refused +event.** + +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 daemon cancels at the transition that revokes authority, not at +the next pointer event: + +| 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 +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. + +**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: + +| 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 + +**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 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 | + +**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. 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(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 + 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. + +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 +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 | +| **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` +(`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 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 | +| 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 +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. + +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. + +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 + +**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 +`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** | +|---|---| +| 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) | + +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. + +#### 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. + +#### 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) + 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 + 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 +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/ci-red-signatures.md b/docs/ci-red-signatures.md index cdc4818..e5acbbc 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 @@ -866,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. diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index fd1a647..b76f0ff 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,11 +567,11 @@ 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 | -| **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` | diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 94a3bd8..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, } } @@ -1098,6 +1120,36 @@ impl AttachClient { }) } + /// §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, + }) + } + /// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture /// hit-tested locally to a panel CELL. Callers gate on /// [`Self::session_protocol_version`] `>= 21`. @@ -1452,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/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index c42fd94..c232c73 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -849,11 +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() { - InstanceMessage::PanelFrame( - pmacs_protocol::panel::PanelFramePayload::Present(frame), - ) => { + // §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. + // 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(_)); + // 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() + .cloned() + .map(|frame| (frame, state.panel.mapping_generation)); + + state.apply_attach_message(*msg); + + if panel_message { + 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; @@ -864,12 +903,15 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.panel_text_observed = true; } } - InstanceMessage::PanelFrame( - pmacs_protocol::panel::PanelFramePayload::Absent, - ) => 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; + } } - state.apply_attach_message(*msg); if is_snapshot { // The dual declaration: a byte viewport for a // document, a cell size for a terminal. The daemon @@ -1972,7 +2014,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 +2126,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`. /// @@ -2131,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, @@ -2494,15 +2596,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 +2676,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 +5527,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 +6865,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 +6983,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,6 +7033,13 @@ impl State { return false; } match payload { + // §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 @@ -6908,8 +7053,66 @@ 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 + // 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. + // 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, + } => { + // §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}"); @@ -7236,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 } @@ -7248,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 @@ -19809,6 +20021,460 @@ 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. + /// + /// 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/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..2345821 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}; @@ -1019,6 +1019,128 @@ fn panel_event_epochs_are_current( .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) } +/// §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. +/// §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) + .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, 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. +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) + .and_then(|state| state.panel_mapping_generation(snapshot)) + .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"). /// @@ -2438,7 +2560,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, @@ -2447,7 +2581,96 @@ fn handle_dispatcher_event( panel_epoch, ) { - editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + // 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 { + 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, + kind, + mapping_generation, + ) + { + // 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 { @@ -3607,6 +3830,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 +3877,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 @@ -5958,6 +6194,28 @@ 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` + /// 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. + /// 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, fid: FrontendId, @@ -5974,6 +6232,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, @@ -6177,6 +6452,1124 @@ 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, + ) + } + + /// 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( + 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" + ); + } + + /// §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. + /// + /// 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. + 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); + 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: 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. + /// + /// 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" + ); + } + + // ----------------------------------------------------------------- + // §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] @@ -6230,7 +7623,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); @@ -6258,7 +7651,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), event, @@ -6273,7 +7666,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), @@ -6304,7 +7697,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); @@ -6329,7 +7722,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6356,7 +7749,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6528,7 +7921,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); @@ -6574,7 +7967,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6601,7 +7994,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelResizeRows { @@ -6630,7 +8023,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6669,7 +8062,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); @@ -6687,7 +8080,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), @@ -6703,7 +8096,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6756,7 +8149,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); @@ -6769,7 +8162,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/editor.rs b/src/editor.rs index b08c26a..d908259 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -375,6 +375,76 @@ 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, + 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, + /// 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. + /// + /// **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,6 +2522,66 @@ impl EditorState { true } + /// Capture the panel's inverse mapping for one frontend. + /// + /// `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 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, + 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, + content, + }) + } + /// Paint one semantic frontend's side window into a panel-sized grid /// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17). /// @@ -2671,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, 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..2f429f4 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. @@ -254,6 +277,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`]). @@ -377,6 +409,61 @@ 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<(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. + /// + /// 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 @@ -516,11 +603,15 @@ 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 } /// 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] @@ -536,6 +627,10 @@ impl SemanticRenderState { last_menu_prompt: HashMap::new(), last_minibuffer: None, peer_knows_minibuffer_rows: true, + // 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(), @@ -575,6 +670,10 @@ 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_mapping_exhausted: false, + accepted_gesture: None, + panel_gesture_cancellations: 0, panel_epoch_used: 0, panel_presentation: None, panel_error_latched: false, @@ -592,11 +691,194 @@ 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, } } + /// 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() + } + + /// §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) { + // 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); + } + + /// §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 + /// 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, + 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`. + 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((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(); + 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. + None => 1, + }; + self.panel_mapping = Some((snapshot, next)); + 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) + } + /// 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). @@ -1374,7 +1656,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, @@ -1382,7 +1664,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 @@ -1391,8 +1694,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(()) => { @@ -1433,6 +1738,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 diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index 11c92ef..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, } } } @@ -192,6 +204,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, @@ -221,6 +236,7 @@ impl TerminalScreen { cursor: Some(CellCoord::new(0, 0)), title: None, generation: 0, + mapping_revision: 0, }; Ok(Self { size, @@ -240,6 +256,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 +286,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 +325,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 +449,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 => { @@ -595,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, } } @@ -654,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) { @@ -808,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); @@ -828,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) { @@ -882,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) { @@ -899,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) { @@ -911,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 { @@ -932,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); @@ -1466,6 +1506,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 { @@ -1495,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(); } @@ -1910,6 +1988,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..414d607 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -249,6 +249,60 @@ 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. + /// + /// 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); + // 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 /// projection. /// @@ -965,6 +1019,7 @@ mod tests { cursor: None, title: Some("shell".into()), generation: 7, + mapping_revision: 0, } } @@ -1245,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 e73109a..616fc69 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={}", @@ -1362,3 +1376,728 @@ 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 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. +/// +/// 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 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 +/// 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** are independent inputs, +/// proven by a **transposition**. +/// +/// 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_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, "doc", 4); + session.declare(1, 24, 80); + let _ = session.present(); + let snapshot = session + .state + .panel_mapping_snapshot(FID) + .expect("a presentable document panel"); + assert!( + matches!( + snapshot.content(), + pmacs::editor::PanelMappingContent::Document { .. } + ), + "a document panel is keyed on its buffer's content revision" + ); + } + + // Terminal panel → the terminal domain. + { + 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 snapshot = session + .state + .panel_mapping_snapshot(FID) + .expect("a presentable terminal panel"); + assert!( + 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" + ); + } +} + +/// §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" + ); + + // CHANGING: the view's **scroll anchor** moves, with the child's + // screen untouched. The same coordinate then names a different + // retained row. + // + // 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" + ); +} diff --git a/tests/bottom_panel_stage2b_gpu_acceptance.rs b/tests/bottom_panel_stage2b_gpu_acceptance.rs index 7da08ea..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 @@ -337,10 +342,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"),