diff --git a/docs/active-work.md b/docs/active-work.md index f84aee4..0aead7b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -437,7 +437,1050 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. Those corrections are **left to that branch**; this lane does not duplicate them. The conflict is a normal insertion collision. -## `scripts/gate` TMPDIR isolation — PR #240 OPEN +## Panel cell-mapping generation (v25) — MERGED as #242 (`47b5463`) + +- **MERGED 2026-08-20T17:25:01Z** at approved head `61f0faf`, merge + commit `47b5463`, via `--match-head-commit` so the merge is provably + of the reviewed head. **14/14 CI green on that exact head**, and the + full 16-stage local gate green on it too (log + `20260820T163107Z-879828`), with `HEAD` and a clean worktree captured + before and after the run. +- **`PROTOCOL_VERSION` is now 25.** `PANEL_MAPPING_MIN_VERSION` is a + literal 25; `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**. GUI + arc 1e's `OpenTarget` moved to **v26**, corrected in + `docs/gui-stage1-input-framing.md` by the slice itself. +- **Replay effects remain `panel-pointer-replay`'s** — this slice + shipped the trigger and the state, not the effects. That lane is now + unblocked and is the next step in the arc. +- **Review round 4 fixed one functional defect**: both inbound arms + discarded the dispatcher's bool and updated the accepted-gesture + latch unconditionally, so a rejected `Down` armed and a rejected `Up` + consumed a real gesture. Both arms now gate on the return, and + `dispatch_semantic_panel_pointer` is `#[must_use]` so a future + discarded answer is a clippy failure. Four rows cover it, each with a + positive control, and three mutations each bite their named rows. +- **`docs/ci-red-signatures.md` gained U10** from this lane's gating. +- **U11 is recorded below from PR #242's comment**, per the deferral + agreed during review. + +### Superseded lane state, kept only for the record + +- **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 + `f8033bc`. **14/14 CI checks green on that exact head**, including + `Test (crdt)` and **both** macOS jobs. +- **A6a CLOSED BY MEASUREMENT**: `status 1 + no token → boundary error, + never ignored`, green on macOS — the platform whose shell exits 1 for + an exec failure, which is what produced the original defect. +- **A7 is no longer "satisfied by disclosure".** Both macOS flavours + (`lua54`, `luajit`) exercised the helper and gate consumers across the + full 45-case shared set. **The R-d consumer stays Linux-only** — its + test is crdt-gated and the macOS jobs build without `crdt`, while + `Test (crdt)` is `runs-on: ubuntu-latest`. Recorded as an open gap, + not closed. +- **ONE UNEXPLAINED FAILURE, 2026-08-20, AND NO MECHANISM IS RECORDED + FOR IT.** The row was + `rd_precondition_validates_the_whole_conformance_set`. + The row failed once in a `sweep-crdt` run in the `pmacs-mapping-gen` + worktree and passed on the two sweeps after it. **The failure message + was not captured**, so there is nothing to reason from — which is + exactly why no cause is asserted here. + - **NOT REPRODUCED**: 30 consecutive runs at load ~10.5, 1,350 + successful stub executions, no recurrence (user-run, 2026-08-20). + - **A "concurrent spawn pressure" explanation was offered and is + WITHDRAWN as false on its facts.** The test runs its **45 stubs + SEQUENTIALLY**, plus one intentional nonexistent-path spawn probe. + There is no concurrency to be pressured, and 46 was a miscount of + 45-plus-a-probe. + - **ON RECURRENCE, CAPTURE THE EXACT CASE AND ERROR** before + theorising. A single uncaptured failure supports no diagnosis, and + the sequential design means the failing case is identifiable. +- **What shipped:** `scripts/check-sigint-deliverable` (validated + `(status, token)` pair, 0 safe / 1 ignored / 2 error), a gate guard + that refuses before any stage with no override, a target test that + reports the precondition instead of a phantom deadline, and the rule + in `docs/agent-handoff.md` §3 forbidding `setsid nohup … &` for the + gate and `cargo test`. **No product behaviour changed** — this was + gate/test correctness only. +- **Two `m4_24_*` rows fail locally under `crdt` and DO NOT reproduce in + CI**, on this branch and at `72da24a` alike: local-environment + -specific, not a code defect and not this lane's. +- **`panel-mapping-generation` (§5b) is UNBLOCKED** by this merge. Its + sixteen-stage gate must run **in the foreground** — the condition its + stage 15 always needed, and the reason this lane existed. + +**Written with the branch's FIRST commit**, per the standing correction +from #171 and #215. + +**Written with the branch's FIRST commit**, per the standing correction +from #171 and #215. + +- **Branch `gpu-probe-sigint-teardown`**, base `githubsucks/main` @ + **`72da24a`**, worktree + `/home/jeans/Repos/personal/pmacs-probe-sigint`. Recover with + `git fetch githubsucks && git checkout gpu-probe-sigint-teardown`. +- **REVISION 13 IMPLEMENTED at `bc7d776`.** Helper emits the token on + stdout; both consumers validate the `(status, token)` pair. The gate + owns a guard-local capture dir, keeps `|| status=$?` under `set -eu`, + selects `expected_token` before any `set -u`-sensitive use, compares + bytes with `cmp` against both permitted encodings, surfaces the + helper's stderr **only** for validated verdicts, and prints + `status=`/`token=` on every refusing branch. R-d validates the same + pair from `Command::output()` bytes — no capture files, since only + the shell needs them. +- **Conformance vectors live in `tests/common/sigint_conformance.rs`** + and are consumed by BOTH validators, so the two copies cannot drift + while each still reports "45 cases". The **branch-discriminating** cases emit a + sentinel on stderr, which is what separates `ValidatedError` from + `Boundary` — + they share exit 2, so comparing codes alone let a validator that + accepted every status 2 pass the whole matrix. An `Outcome` enum + (Safe / ValidatedIgnored / ValidatedError / Boundary) is asserted + branch-exact in both suites, and each helper arm's exact stdout token + is asserted too. +- **A8 is complete**: a bounded row points `TMPDIR` at a missing + directory so `mktemp -d` fails, and asserts boundary error 2, no + stage, and no residue. Temporary directories are RAII throughout; + the `keep()`-plus-manual-cleanup shape is gone. +- **36 gate rows, 16 GPU rows.** +- **HEAD-EXACT GATE on `8802d6a`: green, all 8 stages**, log + `20260820T072102Z-3009434`. The first attempt on the same head + (`…-2931214`) failed `07-sweep` on + `composition_overhead_under_ten_percent` — a perf budget unrelated to + this lane, green in isolation, and a signature the + `panel-mapping-generation` ledger already records recurring. **Both + runs are recorded; no causal attribution is made** for the first, + only that the second is the head-exact evidence. +- **The earlier `…-2647615` run is NOT head-exact evidence**: it + finished about 30 seconds before `bc7d776` was committed, so it + describes the implementation tree rather than a committed head. +- **A4 mutations, each biting the MATRIX now**, not a dedicated row: + accepting any status 2 regardless of token, and surfacing child + stderr on a boundary failure, both fail + `gate_validates_the_whole_shared_conformance_set`; token-to-stderr + fails `sigint_helper_reports_safe_…`. An earlier entry said the + status-2 mutation was caught only by the dedicated row — that was + true of the pre-sentinel matrix and is **superseded**: the + branch-discriminating cases now carry a sentinel, so the matrix can + see which branch produced the exit 2. +- **X3 and X4 deliberately carry their OWN stderr payloads**, not the + sentinel — X3 the canonical ignored wording with no token, X4 noise — + which is what makes them distinct inputs. `shared_cases()` asserts + uniqueness over `(status, stdout, stderr)`, so the earlier + 45-entries-over-43-inputs collapse cannot recur silently; reverting + either payload now fails by name. +- **Two PRE-EXISTING crdt-only failures found while gating properly:** + `m4_24_bare_string_glob_stays_relative` and + `m4_24_d3_fallback_base_is_the_smallest_attachment_dir`. They + reproduce in isolation (so not load) and **fail identically at + `72da24a`**, so they are not this lane's. They are crdt-only — the + plain gate's `05-m4` stage runs without `crdt` and passes. Recorded, + not attributed; whether they are environment-specific is for CI to + say. +- **My local gate did NOT cover what CI covers, and CI caught it.** + Plain `./scripts/gate` omits `sweep-crdt`, which is the only stage + that compiles the nested `gpu_initial_target_acceptance` under + `crdt`; `04-lib-crdt` builds the lib alone. A `crate::common` path + that cannot resolve when nested, and a clippy lint, both shipped + green locally. **This lane gates with `--protocol`.** +- **CI ON `916007b`: 12 GREEN, 2 RED — both macOS `Test` jobs**, and it + is the **pre-declared A7 portability finding**, not an environment + excuse. Exactly one row: + `gate_maps_an_unexecutable_helper_to_error_not_ignored`, + `left: Some(1) right: Some(2)`. The other five SIGINT rows pass on + macOS, including both `error` cases, so helper and gate consumers are + otherwise exercised there. + - **The gate returned 1 = `ignored` for a helper it could not + execute** — the exact conflation §7c forbids. + - **ESTABLISHED, not a hypothesis.** The macOS log shows the shell's + `Permission denied` followed by the gate's `1 | 2)` message — so + `sigint_status` was **1**: macOS `/bin/sh` returns **1** for an + exec failure where Linux returns **126**. The status-only ABI + cannot separate that from the helper's own `ignored`. + - **The raw status was NOT printed.** An earlier entry here said the + stderr carries it; the gate interpolates the number only in its + catch-all branch, and this failure took the `1 | 2)` branch. The + path was identified by **which message text appeared**, not by a + number. + - **A proposed repair was REJECTED in review and is recorded so it is + not retried:** moving `ignored` from 1 to 3 relocates the collision + without closing it, because an exec failure can return **any** + nonzero status. The generalisation: **no exit status can prove the + helper ran.** + - **Framing revision 13 — APPROVED 2026-08-19 at `5dece3e` — + replaces the ABI with a validated `(status, token)` pair**: + `0`/`1`/`2` with + `pmacs-sigint-v1:safe|ignored|error`, token alone on stdout, + diagnostics on stderr, and any other pair — including macOS's + status 1 with no token — a boundary error mapped to 2. Every + refusing branch must print the observed status and token state as + diagnostic context, never as the classifier. A shared **conformance + matrix** replaces revision 12's withdrawn "both consumers use the + same helper so they can never disagree", which stopped being true + once each consumer validates the pair independently. + - **A6a is scoped to the GATE.** R-d never sees a shell status: Rust's + `Command` returns a spawn error with no exit status, and R-d's test + is crdt-gated so macOS CI does not compile it. R-d on macOS is + **unexercised**, recorded as a gap. + - **A7 is no longer "satisfied by disclosure"** — macOS was reached + and measured: five of six helper/gate rows pass, one defect + (`gate_maps_an_unexecutable_helper_to_error_not_ignored`), R-d + Linux-only. +- **PR #241** (`https://github.com/levineuwirth/pmacs/pull/241`), opened + 2026-08-19 from `gpu-probe-sigint-teardown` into `main`. **Not merged; + awaiting review rounds.** +- **CHECKPOINT — immutable event SHAs, because "this entry's own + commit" goes stale exactly the way a tip SHA does:** implementation + landed at **`3206433`**; the A6 both-consumer rows and the bounded + negative path at **`167d830`**; the two factual corrections at + **`c9cc8dd`**; the gate-run record at **`7cef9ca`**; the PR record at + **`d64d300`**. Only the **branch tip** stays symbolic — a literal SHA + naming a branch's own tip goes stale the + moment the next commit lands, which this ledger has already recorded + once. State at `3206433`: pushed, signed `G`, worktree clean, + `git diff --check` clean, **full default gate green (8/8) in the + foreground**, 31 gate-acceptance rows passing. After this commit: + **33 gate-acceptance rows and 16 `gpu_invocation_acceptance` rows**. +- **FULL GATE GREEN ON THE COMMITTED HEAD `c9cc8dd`** — all 8 stages, + log `20260819T160220Z-2339958`, started at load 3.90. Recorded at + `7cef9ca`, which is docs-only on top of the gated tree; that + exemption is what stops gate-result records recursing forever. +- **The preceding attempt on the same head was RED, and is kept.** + Log `20260819T152209Z-2073040`: `04-lib-crdt` and `07-sweep` failed + on four wall-clock rows — + `composition_overhead_under_ten_percent`, + `full_buffer_summary_flatten_scales_on_large_grammar_file`, + `dired_renders_10k_entries_within_200ms`, + `file_progress_notification_is_recorded_for_its_document` — none + touching this lane's change. Load average was **49.6**, and + `./verify_task_state.sh review/my-ruff-task golden` was running under + a separate toolchain at `/usr/local/rustup` with four `rustc` + processes, having started about three minutes into the run and + overlapping exactly the two stages that failed. + **That is evidence of WHEN, not proof of WHY**, and it was recorded + as "not valid evidence" rather than "environmental" — this lane has + already retracted one confident environmental attribution. The green + run on the same commit is what settles it; had any of the four failed + again on a quiet machine, it would have been a real finding here. (An earlier + draft said 35: that figure was the `git_status_stage1_acceptance` + result line immediately below `gate_script_acceptance`'s in the sweep + log, misread as this suite's.) +- **Framing revision 12 at `docs/gpu-probe-sigint-framing.md`, + approved 2026-08-19 at `1fc0df6` and IMPLEMENTED; **superseded by + revision 13, approved 2026-08-19 at `5dece3e`** — revision 10 was + approved at `4fba9f6` and revision 9 at `15c25ec`; neither approval covered the + later mechanism finding and remedy selection. **D1/D2 HAVE RUN and + found the mechanism: `SIGINT` was ignored group-wide + (`SigIgn=0x1007`) because + the test runner was launched in the background — `SIG_IGN` is + inherited across `fork` and survives `exec`, so it reached the + launcher and probe, and `kill(-pgid, SIGINT)` was a no-op.** + Controlled arms re-run on committed head `77b623c` with full SHA-256 + captured per arm and byte-identical + binaries: foreground both copies ok, `setsid nohup … &` both FAILED. + **I caused this** by adopting background launches on 08-16 to evade + the Bash tool's ten-minute cap — that is the "onset", and the + subset-vs-full matrix was confounded with it throughout. A3's + subset/full obligation is discharged by explanation, so D0b is not + needed. **Bet 1 withdrawn by scope and A5 retired by scope — D4 was + never executed**, so no claim is made that a real session behaves + correctly, only that no evidence of a user-facing defect survives. + **A3/D0b are SATISFIED by that explanation** — D0b is not owed and + will not run. The approved revision 12 **selects the remedy**: R-b + + R-d via one checked-in helper, + `scripts/check-sigint-deliverable`. Its preserved-status inner probe + maps to one complete ABI: helper exit **0** = `safe`, **1** = + `ignored`, **2** = probe `error`; the helper owns the two failure + diagnostics, and both consumers surface its stderr rather than + interpreting raw probe statuses. Inability to execute the helper is + `error`, never evidence of `SIG_IGN`. POSIX shell only: no `/proc`, + so not Linux-only; no `sigaction`, so no `unsafe`. `scripts/gate` + fails immediately with the explicit diagnosis; the target test + reports the same precondition failure if run directly; + **no override**, because a gate under ignored `SIGINT` cannot produce + valid evidence. R-c rejected. The Linux-only D1/D2 instrumentation is + removed once its evidence is portable. A1–A7 witness guard bite, + direct-test diagnosis, unaffected foreground success, mutation, an + otherwise unchanged gate, a distinct error outcome in **both** + consumers, and qualified non-Linux-unix portability. +- **IMPLEMENTED.** `scripts/check-sigint-deliverable` is the shared + helper (0 safe / 1 ignored / 2 error); `scripts/gate` refuses before + any stage; the target test reports the precondition instead of the 5s + deadline; the Linux-only `/proc` instrument is removed. +- **Two bugs shipped in the first guard, both caught in review.** A bare + invocation under `set -eu` killed the shell at the helper's non-zero + exit, so the refusal never printed. Replacing it with + `if ! helper; then status=$?` captured the status of the **negated + condition** — always 0 — so the gate printed the diagnosis and then + ran the whole suite anyway. The working shape is + `helper || status=$?`, the idiom the helper uses internally. The + guard also moved to immediately after the worktree resolves, so a + refused run leaves no log dir, ambient root or tmpdir behind. +- **Four durable rows in `gate_script_acceptance`** (31, was 27): + helper safe / ignored / error, and gate refusal before stage 1. + **Verified to bite** — mutating the gate back to either shipped bug + fails `gate_refuses_to_start_when_sigint_is_ignored` and nothing + else. Their absence is why 27 passing tests missed both. +- **A7 — revision-12 HISTORY, superseded.** It then read: satisfied by + disclosure, Linux `x86_64` only, no non-Linux unix reachable. **That + is no longer true** — macOS CI reached it and measured it red. The + live record is the CI entry above: five of six helper/gate rows pass + on macOS, one defect, R-d Linux-only because its test is crdt-gated. +- **D0a EXECUTED 2026-08-19 — verdict: difference NOT captured by the + two commits.** 10 runs, counterbalanced, N=5 per endpoint, clean + detached worktrees with isolated target dirs, `dirty=0` per run, zero + voids, zero splits. **A (`7599661`) uniform-red; B (`724b785`) + uniform-red.** So `7599661`, which passed inside `sweep-crdt` on + 08-15, fails 5/5 clean today: **the two commits do not discriminate + under current conditions, so no bisect will run.** That is the whole + claim — "source hypothesis eliminated" and "unreachable by source" + are **withdrawn**, since a historical regression could be masked by a + later environmental effect or a source/environment interaction. + Failing to discriminate is not the same as not differing. The onset + window is deprioritised, not excluded. No package activity in the + window (`pacman.log`) — a cheap negative, not pursued further. +- **The useful product is a RELIABLE REPRODUCTION** — 10/10 across two + commits at ~4 min/run — which is what let D1/D2 run at once. **D1/D2 + are DONE**; the mechanism entry above supersedes this. Per-run provenance in `docs/probe-sigint-evidence.md` + §D0a, transcribed in full into that committed document — after the + first transcription corrupted every log digest by one hex character + and dropped `/tmp` and `MemAvailable`. **`uptime` was never captured** + and is `UNKNOWN` for all ten runs: §7 names it, the harness kept only + the load averages, so that condition list was **not fully + satisfied**. The classifications stand; D1/D2's harness must capture + the whole list. +- **Why it exists.** `ctrl_c_on_launcher_group_does_not_reach_spawned_daemon` + fails in gate stage `sweep-crdt` with "child did not exit within 5s". + **Pre-existing on `main`** — `72da24a` fails it in a clean worktree + with its own target dir. The correct count is **119 green result + summaries and TWO red binaries**: `gpu_initial_target_acceptance` + includes the suite as a module, so a reproducing sweep reds twice + (log `…-2144707/09-sweep-crdt.log:3097` and `:3131`). While it reds, + **no branch can present a green sixteen-stage gate, `main` + included.** +- **`panel-mapping-generation` (§5b) is HELD BEHIND THIS LANE** by + explicit instruction. That lane is code-complete at `5174f73` with + its own fifteen stages green; its sixteenth stage is this defect. +- **Reproduction is 7/7 across full sweeps (F1–F7); every reduction + R1–R10 is green.** Each run is enumerated with exact command, worktree, HEAD, + cleanliness and log digest in `docs/probe-sigint-evidence.md` — "0/N" + is not a record. +- **But R9 did NOT run the same compilations as the sweep.** It + executed `…-91f51d0b…` / `…-6b4b8223…`; the sweeps executed + `…-5d9105cb…` / `…-d4dae4f0…`. **Differing Cargo suffixes mean + differing metadata hashes — different compilations.** Historical byte + identity is **UNKNOWN** and is never claimed: target dirs have been + overwritten, so a hash computed today is the current occupant's. R9 + establishes **same target names and order**, not same compilations. What the evidence is **consistent + with**, not what it isolates: prior targets alone (R9) green, + workspace selection alone (R10) green, both together (F1–F7) red. + That is an observation, not a finding. `--workspace` selection + is **not sufficient by itself and not ruled out** — later-selected + packages can affect the build graph before their tests ever run, so + "their targets execute after the failure" does not exonerate them. +- **Ruled out by measurement — do not re-run:** machine load; tmpfs + starving RAM (settled by experiment, not argument — `/tmp` 21G→1.2G, + available 27G→45G, still red); leaked daemons; inotify. +- **NOT ruled out, contrary to earlier entries here:** `--workspace` + artifact selection, and the preceding tests. R9 appeared to clear + them but ran **different Cargo compilations**, so the comparison was + never made. Both are open. +- **Ground truth, and what it does NOT establish.** `run_gpu`'s own + path installs no handler: `run_gpu` (`src/main.rs:324`) blocks + in `command.status()` with no handler, and grepping all of + `pmacs-gpu/src` for signal machinery returns nothing. **But the + `pmacs` binary DOES contain signal machinery** — + `install_signal_handlers` (`src/daemon.rs:628`) registers `SIGINT` + and `SIGTERM`; it is simply not on `run_gpu`'s path. A source grep + also cannot exclude a runtime or dependency installing a disposition. + So the established fact is only: **no explicit installation on + `run_gpu`'s path**, and "whatever disposition they hold was + inherited" stays a **hypothesis** until D2 measures it. The probe's + **event loop** wakes at least every 50ms + (`pmacs-gpu/src/main.rs:1065`) — but the process is **not** bounded: + its stdin reader blocks in `read_to_end` (`:1109`) and, once ready, + the loop leaves only when stdin closes (`:1212`). + **No claim is made that either process holds the DEFAULT + disposition** — absence of handler code cannot establish that, and + inherited ignore is the leading hypothesis precisely because the + source is silent. +- **TWO retracted claims, both mine.** (a) "Mechanism located" — + launcher in `do_wait`, probe child in `futex_do_wait`. (b) The + retraction of (a), which argued the failing launcher "must live ≥8s". + **(b)'s arithmetic is false**: both reproducing binaries finish in + ~5.19s *including* the 5s timeout, so the failing launcher lives + about **5.1s** — inside what the sampler saw, and a ">6s" selector + would have captured nothing, repeating the error it was meant to + correct. (a) is therefore not refuted by (b); it stays **unproven for + a different reason** — under `--features crdt` the suite spawns root + launchers from **six** call sites, so command line alone cannot + attribute one to this test. The six (`:509, :534, :544, + :574, :725, :1097`, all inside `#[cfg(feature = "crdt")] mod crdt`); + the other two `--gpu` arguments sit under `#[cfg(not(…))]` and are + compiled out. **Do not key on process age. Key on the PID the test + records.** +- **Diagnostics must DISCRIMINATE** blocked delivery, inherited ignore, + and an escaped process group: snapshots **before and after** the + signal, for test parent / launcher / probe; **per-thread** `SigBlk` + from `/proc//task/*/status`; `SigPnd`/`ShdPnd`; and + `PID`/`PPID`/`PGID`/`SID`. A post-failure snapshot cannot prove + inheritance. +- **Why that matters:** `SIG_IGN` is inherited across `fork` and + survives `exec`, while handlers do not. So a runtime disposition can + arrive from the test harness, `cargo`, or the invoking shell without + appearing anywhere in the source. Revision 1's "two processes with + default disposition" contradicted its own hypothesis and is + withdrawn; the assertion no longer appears above it either. +- **Run provenance is a pushed document**, `docs/probe-sigint-evidence.md`: + exact command, worktree, HEAD, cleanliness, the Cargo suffixes + executed, result and log digest per physical run. Three caveats stated there rather + than smoothed over — **R1 and R2 have no preserved log** (revision 2 + double-counted one log as both R2 and R6), **cleanliness is UNKNOWN** + for every pre-manifest run, and **R1–R10 ran in the + `panel-mapping-generation` worktree**, not at `main`. Log bodies are + machine-local under + `/home/jeans/build/pmacs-gate-targets/probe-sigint-evidence/`; `/tmp` + is a tmpfs and they were nearly lost to a cleanup mid-lane. +- **THE ONSET IS DATABLE, and it reframes the lane.** `sweep-crdt` + has **17** logs here. Counted per test copy: **13** with both copies + `... ok`, **1** where neither executed (stage died compiling `pmacs`, + `error[E0308]`, `…-708693`), **3** with both `FAILED`. Last observed + green `20260815T185708Z`, first observed red `20260816T063330Z`; boot + began 08-14 09:30, so no reboot between. "Pre-existing on `main`" + holds (F1 at `72da24a`) but **"always broken" is contradicted**. +- **The onset is NOT a source boundary.** Reflog/commit times put HEAD + at `7599661` during the last green (`3c06176` landed 40s after it + finished) and `724b785` during the first red (`5174f73` landed + 08:45:41, after that run ended 08:42:01). **Cleanliness captured for + neither.** `72da24a` is an **ancestor** of `7599661` yet fails today + while `7599661` passed on 08-15 — but those two observations differ + in commit AND environment AND time, so they are **non-comparable and + support no causal conclusion of any kind**. Earlier wordings here + ("no source-monotonic cause does that", "outcome is not determined by + commit alone") are both **withdrawn**: different commits can + deterministically produce different outcomes, so the pair says + nothing about determinism either. +- **D0a is a decision procedure with no predicted outcome**, and one + run per endpoint decides nothing. That the failure has appeared only + in the full sweep is **what has been observed so far**, not a + property established of the defect. + **N = 5 full `sweep-crdt` runs per endpoint, counterbalanced + `AB BA AB BA AB`** — not strict `A/B/A/B`, which leaves B always + following A and owning the final slot; counterbalancing removes + systematic order confounding, and the residual last-slot asymmetry is + accepted and stated. Same captured conditions as D0b plus `uptime`, + `free`, `/tmp` usage and leaked-daemon count. +- **The run classifier is TOTAL**, and reads only the **two copies** of + the target test: **green** (both `... ok`), **red** (both `FAILED`), + **split** (copies disagree → stop; that is its own defect), **void** + (either copy never executed → discard and re-run, budget 3, then + D0a stops). A sweep red only on **unrelated** rows is a `green` run; + both outcomes occur in the historical logs — `…-708693` is a void + (compile failure), and `…-2839374`/`…-830195` are unrelated-red with + both copies passing. A bisect of `7599661..724b785` is permitted + **only on the expected-direction clean split** — all N green at + `7599661`, all N red at `724b785`. The inverted split is a real + difference but contradicts the onset reading, so it is recorded and + that reading is re-examined before any bisect. A mixed result means + the failure is intermittent under fixed source and **no bisect is + justified**. +- **Red full-sweep count is SEVEN, not five** (F1–F7 in the manifest), + each with its own log digest; revision 3 said 5/5 while the framing + separately cited a gate run the manifest never listed. +- **D0 precedes every other diagnostic**, in two parts: **(a) + reproduce the onset endpoints `7599661` and `724b785` clean, in + isolated target dirs**, under the N = 5 interleaved clean-split + contract below — a bare difference decides nothing. **(a) is DONE.** + **(b) is RETIRED as a precondition** (2026-08-19) because D0a yielded + a reliable direct reproduction and D1/D2 measure the mechanism + itself. **Its obligation is now SATISFIED** by §4c's controlled + explanation of the subset/full difference, so **D0b is not owed and + will not run**. As originally written it said: its obligation + survives under A3 — if D1/D2 do not + account for the subset-vs-full difference, D0b runs before the lane + closes. It read: re-run + the matrix at `main` under a harness capturing provenance **and the + artifact hashes executed at run time**, since command shape silently + changed the binary once already and a hash computed later reflects + only what occupies that path now. +- **Coherence: journey steps touched: NONE** (framing §9). Earlier + entries here assigned 12(a) "closing is clean" on the premise that + the lane repairs Ctrl-C teardown; §4c withdraws that premise, because + no product behaviour changes. What the lane affects is **gate + trustworthiness**. +- **Gates:** `./scripts/gate --protocol --acceptance + gpu_invocation_acceptance` at minimum. **The old three-consecutive- + run A2 contract is SUPERSEDED** — it was written for a teardown fix + whose flakiness was unexplained. The mechanism is now known and + deterministic, so the acceptance set is framing §8's A1–A7: guard + bite, direct-test diagnosis, unaffected foreground success, mutation, + an otherwise unchanged gate, a distinct `error` outcome, and a + non-Linux-unix statement. + +## `scripts/gate` TMPDIR isolation — MERGED as #240 (`72da24a`) + +- **MERGED 2026-08-13T20:12:58Z** at head `cf09f5a`, merge commit + `72da24a`. Absorbed 2026-08-20; the block below was written while the + PR was open and is kept for its reasoning, not its status. +- **Its isolation earned itself back during §5b review round 4.** Two + `m4_24_*` base-resolution rows were reported as pre-existing local + failures that only CI could arbitrate. They were neither: the sweep + had been run BY HAND, outside `scripts/gate`, so it inherited + `TMPDIR=/tmp` — and `/tmp/.git` exists on that machine, which project + detection walks up into. Run through the gate, both rows pass. **The + hazard this lane fixed is exactly the one that reappeared the moment + the gate was bypassed.** **Written with the branch's first commit**, per the standing correction from #171 and #215. @@ -530,7 +1573,11 @@ from #171 and #215. - **Out of scope, deliberately:** the overdue absorption of #239. This lane is the gate fix and nothing else. -## GUI arc Stage 1a — `TextInput` at v24 — PR #239 OPEN +## GUI arc Stage 1a — `TextInput` at v24 — MERGED as #239 (`ca92796`) + +- **MERGED 2026-08-13T13:00:14Z** at head `36a3296`, merge commit + `ca92796`. Absorbed 2026-08-20; the block below was written while the + PR was open and is kept for its reasoning, not its status. **Written with the branch's first commit**, per the standing correction from #171 and #215. diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index b852072..02b4e82 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -2547,6 +2547,51 @@ its own step, never `&&`-chained. **Run it with `scripts/gate`. Do not retype it.** +**NEVER start the gate — or `cargo test` — from a shell that ignores +`SIGINT`.** A shell backgrounding a job without job control sets +`SIGINT` (and `SIGQUIT`) to `SIG_IGN`; `nohup` adds `SIGHUP`. `SIG_IGN` +is **inherited across `fork` and survives `exec`**, so it reaches +`cargo`, the test binary, and everything they spawn. Any test that +signals a child and waits for it then hangs to its own deadline and +reports a *product* defect that is not there. + +Concretely: `setsid nohup ./scripts/gate … &` is **forbidden**. This +cost one lane seven red full sweeps and nine framing revisions chasing +a GPU teardown bug that never existed +(`docs/gpu-probe-sigint-framing.md` §4c). Long runs do not need it — +measured, an ordinary tool-level background launch leaves `SIGINT` +deliverable. + +`scripts/gate` now refuses to start in that state, before any stage, +and **there is no override**: a run under ignored `SIGINT` cannot +produce valid evidence. If you see + +``` +pmacs: SIGINT is ignored; run this command with SIGINT deliverable +gate: REFUSING TO RUN --- see the diagnosis above. +``` + +the fix is to re-run it in the foreground, not to work around the +guard. `scripts/check-sigint-deliverable` answers the question on its +own: exit **0** deliverable, **1** ignored, **2** undecidable. + +**AND NEVER WRAP THE GATE IN `timeout` TO FIT AN AGENT COMMAND CAP.** +The full 16-stage suite outruns a ten-minute cap, and a `timeout 580` +around it kills `sweep` mid-run; the runner then records that stage as +a **failure**, which reads in the log exactly like a real red. This +already produced one false §5b gate result. It is the same defect as +the one above in a different costume: a harness convenience silently +becoming evidence. + +The supported way to run past the cap is the **tool-level background +launch**, which is measured `safe` by +`scripts/check-sigint-deliverable`, so the guard admits it and the run +is valid. Check it directly if in doubt --- the helper is one command +and answers for whatever shell actually invoked it. Failing that, run +the suite in labelled pieces and say in the record which piece produced +which result. **A truncated run is not a result, and must never be +reported as one.** + ``` scripts/gate [--acceptance SUITE]... [--protocol] ``` diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index f5907d2..3c86649 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -2721,9 +2721,805 @@ new one inserted ahead of 1e. `docs/gui-stage1-input-framing.md` says *"1e — `OpenTarget` (v25)"* (`:1011`) and *"floor: v25, after v24, serialized"* (`:1221`), and that document is heavily revised on `gui-stage1b-pointer-scroll` -(revisions 13–18). Changing it here would collide at the rebase 1b is -already scheduled for. **It is recorded as required rather than made**, -so the version bump lands with the branch that owns the file. +(revisions 13–18). **This paragraph is superseded.** It argued the bump +should be recorded rather than made, to avoid colliding at 1b's +scheduled rebase. §5b **made it**, and merged as #242 — an expected +rebase conflict was not grounds for leaving a canonical document +saying v25 after v25 was taken. Kept, struck, because the reasoning it +records was overruled rather than forgotten. +## 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) diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index cdc4818..fdf1360 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,73 @@ 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. + +### U11 — `dispatch_parse_round_trips_a_rust_source_file`, macOS `lua54`, one occurrence, green on rerun + +Recorded from PR #242, 2026-08-20, on head `61f0faf` — the head that +merged as `47b5463`. **Deferred to post-merge absorption by decision**, +so that no docs commit would invalidate that PR's head-exact gate +evidence; the fragments lived in +[PR #242's comment](https://github.com/levineuwirth/pmacs/pull/242#issuecomment-5359360750) +in the interim, because the raw job log is machine-local and **not +portable evidence**. + +| field | value | +| --- | --- | +| **selector** | `--lib async_runtime::tests::dispatch_parse_round_trips_a_rust_source_file` | +| **job / flavor** | `Test (macos-latest / lua54)`, run `32393462318` | +| **required fragment** | `trivial parse should be fast`, panicking at `src/async_runtime.rs:3328` | +| **counts** | `1960 passed; 1 failed; 3 ignored`, finished in 64.49s | +| **attempt 1** | job `96504773333` — **failure** | +| **attempt 2, rerun** | job `96511228345` — **success** | +| **status** | **one occurrence, not reproduced** | +| **THE MARGIN IS UNRECOVERABLE** | the assertion is `assert!(duration_ms < 100, "trivial parse should be fast")` — a 100ms wall-clock budget on a 30-byte source, **with `duration_ms` omitted from the message**. The red cannot say whether it missed by 1ms or by 900ms, and **no future occurrence can be compared against this one**. This is a property of the assertion, not of the observation | +| **what IS established** | **non-determinism across these observations, and nothing more.** Four passes of the same selector on the exact head in local gate `20260820T163107Z-879828` — one each in `03-lib`, `04-lib-crdt`, `13-sweep`, `15-sweep-crdt`. Two `Test (macos-latest / luajit)` greens on the same commit and OS, jobs `96504773331` and `96511275402`. `src/async_runtime.rs` **byte-identical to `main`**, blob `9310ce3fca8c5fd8ebd39a68c29ad6985e256049` on both sides — not merely an empty diff | +| **what is NOT** | environmental cause, or harmlessness. The luajit greens narrow the red below "macOS at this commit"; they do not explain it. **A green rerun establishes intermittence only** | +| **relation to U10** | **not U10.** Different selector, different fragment, different subsystem, and remote rather than local. Filed separately for the same reason U10 was filed apart from U6 and U7 | + +**A recurrence is not another instance of this row.** Because the margin +was never captured, a second red cannot be compared with the first, so +it owes a **merge-base control** before any claim is made about the +tree. + +**The one-line fix that would make this family diagnosable** — include +the measured `duration_ms` in the assertion message — is **owed its own +small lane**. It was deliberately kept out of #242, whose diff does not +touch `src/async_runtime.rs`. diff --git a/docs/gpu-probe-sigint-framing.md b/docs/gpu-probe-sigint-framing.md new file mode 100644 index 0000000..7427939 --- /dev/null +++ b/docs/gpu-probe-sigint-framing.md @@ -0,0 +1,1293 @@ +# GPU launcher / probe SIGINT teardown — framing + +Revision 13. Status: **APPROVED 2026-08-19 at `5dece3e` — the shipped +ABI is defective on macOS (§4d), and revision 13 replaces it. +Implementation may proceed under §8's A1–A8 contract.** + +Revision 12 was approved at `1fc0df6` and implemented; CI then found its +status-only ABI unsound on a platform this session could not reach. +Mechanism in §4c; the ABI defect and its remedy in §4d and §7c. Still no +product change — this remains gate/test correctness only. + +Revision 10 was approved 2026-08-19 at `4fba9f6`, authorising +diagnostic-only D1/D2. They ran, and found the mechanism on the first +reproducing sweep. It is not what this document was built around: bet 1 +is **withdrawn by scope** and A5 **retired by scope** (D4 was never +executed, so no claim is made about a real session either way), and +nothing in the evidence implicates the probe's shutdown path. + +Revision 9 was approved 2026-08-19 at `15c25ec`. **That approval did +not extend to revision 10**, because retiring D0b (§7) materially +changed the approved diagnostic sequence — the revision 9 text made +D0b mandatory before every other diagnostic. Revision 10's approval +covers that retirement and the A3 contingency that preserves its +obligation. **D1/D2 have since been executed under it and found the +mechanism (§4c).** + +Revisions 1 and 2 were each rejected on five findings. Every correction +is recorded in place rather than quietly rewritten, because three of +them were claims this document itself had advanced: + +- r1 → r2: the ">6 s selector" and the "≥8 s lifetime" arithmetic + (§5); "two processes with default disposition" (§3); "119 binaries + green, one red" (§1); an unobtainable A2 (§8); "journey steps + touched: none" (§9). +- r2 → r3: **"R9 ran the same binaries" — it did not** (§4); "the probe + never blocks indefinitely" (§3); the launcher call-site count (§5); + reduction provenance, now in `docs/probe-sigint-evidence.md`; and + ledger corrections that had not been made portable (§11). +- r3 → r4: the interaction table overstated (§4); the red count was 7, + not 5, and abbreviated argv were not argv (manifest); "workspace + artifact family" conflated Cargo suffix with byte identity + (manifest); ledgers still carried the falsified R9 conclusions (§11). + **And a finding that reframes the lane: the failure has a datable + onset (§4a) and is not long-standing.** +- r9 → r10: **D0b retired as a precondition** (§7), which changes the + approved sequence and is why this revision needs its own approval; + D0a executed and its causal conclusion narrowed twice — "source + hypothesis eliminated" withdrawn in favour of "the commits do not + discriminate under current conditions" (§4b, and the endpoint + table's two uniform-same rows); portable provenance corrected after + it corrupted every log digest and silently dropped `/tmp` and + `MemAvailable`; and `uptime` recorded as **UNKNOWN**, since §7 names + it but the harness kept only the load averages. +- r8 → r9: D0a's classifier was not total — it named only "clean + split" and "mixed", leaving both-green, both-red, non-execution, + copy-disagreement and unrelated-failure outcomes unprescribed, all of + which occur in the historical logs (§7); and strict A/B/A/B does not + equalise drift (§7). +- r7 → r8: the superseded one-run D0 rule survived in three places + (§4a, §7, manifest, ledger); D0a still overstated its rates and left + the bisect's own classifier unspecified (§7); residual artifact + wording and four wrong suffix attributions (§4, manifest). +- r6 → r7: the ancestry supports **no** causal statement at all — the + observations are non-comparable, and even "outcome is not determined + by commit alone" is withdrawn (§4a); D0a was not yet a valid decision + procedure + (§7); "neither binary contains signal-handling code" is false — the + `pmacs` binary registers SIGINT in daemon mode (§3); residual + artifact wording (§4, manifest). +- r5 → r6: the ancestry argument overreached (§4a) — it shows outcome + is not determined by commit alone, and nothing more; residual + byte-identity and "artifact family" wording in both ledgers (§11); + and three provenance slips (§4, manifest). +- r4 → r5: the section summaries still carried revision-3 counts and + groupings (§4); the onset count was 13/1/3, not 14 (§4a); "byte- + different" overstated what is knowable about historical artifacts + (§4, manifest); and **the onset is not a source boundary** (§4a). + +## 1. The problem, stated as what is observed + +`ctrl_c_on_launcher_group_does_not_reach_spawned_daemon` fails with + +``` +child did not exit within 5s (tests/gpu_invocation_acceptance.rs:180, called from :1115) +``` + +The test spawns `pmacs --gpu --socket ` in its own process group +(`process_group(0)`, `:1107`), waits for the probe to report +`phase=ready`, sends `SIGINT` to the **group** (`:1113`), and requires +the launcher to exit within five seconds. + +**It fails in two binaries, not one.** `tests/gpu_initial_target_acceptance.rs` +includes the suite as a module, so a reproducing sweep reds twice. +Reference run `20260816T064549Z-2144707/09-sweep-crdt.log`: + +| | line | result | +|---|---|---| +| `gpu_initial_target_acceptance` | 3097 | `FAILED. 14 passed; 1 failed … 5.19s` | +| `gpu_invocation_acceptance` | 3131 | `FAILED. 14 passed; 1 failed … 5.18s` | +| green result summaries | — | **119** | + +So the correct statement is **119 green result summaries and two red +binaries**. Revision 1 said "119 binaries green, one red", which was +wrong on both halves. + +**This is pre-existing on `main`.** The identical `build-crdt && +sweep-crdt` pair at `72da24a`, clean worktree, own target directory, +fails the same test. + +## 2. Why this blocks more than one lane + +`sweep-crdt` is stage 15 of the sixteen-stage `--protocol` gate. While +it reds, **no branch can present a green gate**, `main` included. +`panel-mapping-generation` (§5b) is complete with its own fifteen +stages green and is held behind this lane by explicit instruction. + +## 3. Ground truth (cited), and what it does *not* establish + +- **`run_gpu`'s own path installs no handler.** It + (`src/main.rs:324`) blocks in `command.status()` (`:363`) — a plain + `waitpid` — with nothing installed along the way. Grepping + `SIGINT|signal_hook|sigaction|ctrlc|set_handler|pthread_sigmask|sigprocmask` + across `pmacs-gpu/src` returns nothing. + **Revision 6 said "neither binary contains signal-handling code"; + that is false.** The `pmacs` binary *does* — `install_signal_handlers` + (`src/daemon.rs:628`) registers `SIGINT` and `SIGTERM` — it simply is + not on `run_gpu`'s path. And a grep of project sources cannot exclude + a runtime or dependency installing a disposition. So the established + fact is narrow: **no explicit installation on `run_gpu`'s path**. +- **The probe's event loop wakes at least every 50 ms.** + `run_headless_managed_probe` (`pmacs-gpu/src/main.rs:1065`) loops on + `event_rx.recv_timeout(Duration::from_millis(50))`. **Revision 2 said + "never blocks indefinitely", which is false**: the probe's stdin + reader thread blocks in `read_to_end` (`:1109`) with no timeout, and + once `ready` the loop has **no deadline of its own** — it leaves only + when stdin closes (`:1212`). So the process is not bounded; only the + event wakeup is. +- **The daemon *does* handle signals, deliberately.** + `src/daemon.rs:629-641` registers `SIGTERM`/`SIGINT` via + `signal_hook::flag`. The daemon is the process the test asserts must + **survive**, detached from the launcher's group. + +**Correction to revision 1.** Revision 1 concluded from the first two +bullets that "two processes with default `SIGINT` disposition should +both die at once". **That does not follow, and it contradicted this +document's own leading hypothesis.** Absence of handler *code* says +nothing about runtime *disposition*: `SIG_IGN` is inherited across +`fork` **and** survives `exec`, so either process can hold a +non-default disposition it never installed — from the test harness, +from `cargo`, or from the invoking shell. Inherited ignore is a live +candidate precisely because `run_gpu`'s path is silent. But **"whatever +disposition they hold was inherited" is a hypothesis, not a finding** — +revision 6 stated it as established, which it is not, since neither a +source grep nor an absent call proves what the runtime disposition is. +D2 measures it. Until then it is one candidate among the three D1/D2 +are built to separate. + +## 4. Reductions attempted + +**Full provenance lives in `docs/probe-sigint-evidence.md`**, which is +pushed with this branch: exact command, worktree, HEAD, cleanliness, +the Cargo suffixes actually executed, result, and log digest for every +physical run. Log bodies stay machine-local under +`/home/jeans/build/pmacs-gate-targets/probe-sigint-evidence/` — `/tmp` +is a tmpfs and they were nearly lost to a cleanup mid-lane. + +Three provenance caveats are recorded there rather than smoothed over: +**R1 and R2 have no preserved log** (revision 2 cited `gpu3.log` for +both R2 and R6; that log is R6's three-suite run alone, and counting +one run as two was wrong); **cleanliness is `UNKNOWN` for every +pre-manifest run**, because it was not recorded at the time and is not +inferrable; and **R1–R10 ran in the `panel-mapping-generation` +worktree**, not at `main`. `D0` re-runs the matrix under a harness that +captures all of it, at `main`, before any row here is relied on. + +All rows carry `--features crdt`. Full argv, worktree, HEAD, +cleanliness and Cargo suffixes per run: `docs/probe-sigint-evidence.md`. + +| # | reduction (after `cargo test`) | runs | result | log | +|---|---|---|---|---| +| R1 | `--test gpu_invocation_acceptance ctrl_c_on_launcher_group` | 3 | green, 0.15–0.17 s | **no log preserved** | +| R2 | `--test gpu_invocation_acceptance` (whole suite) | 1 | green, 15 passed | **no log preserved** | +| R3 | `--workspace --no-fail-fast -- --skip basedpyright ctrl_c_on_launcher_group` | 1 | green — every binary runs, only this test executes | `filtered.log` | +| R4 | `--lib --test gpu_invocation_acceptance --no-fail-fast` | 1 | green, 2145 + 15 | `two.log` | +| R5 | `--test gate_script_acceptance --test gpu_invocation_acceptance` | 1 | green | `suspect.log` | +| R6 | `--test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | 1 | green | `gpu3.log` | +| R7 | `--lib --bins` + `--test`×14 (targets 6–19) + the suite | 1 | green | `half1.log` | +| R8 | `--test`×18 (targets 20–37) + the suite | 1 | green | `half2.log` | +| R9 | `--lib --bins` + `--test`×32 (targets 6–37) + the suite | 1 | green (`-91f51d0b`, `-6b4b8223`) | `prefix.log` | +| R10 | `--workspace ... --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | 1 | green (`-5d9105cb`, `-d4dae4f0`) | `wsonly.log` | +| F1–F7 | full `--workspace --no-fail-fast -- --skip basedpyright`, plus three gate `sweep-crdt` stages | **7** | **red, 7/7** | `base-sweep.log` (at `72da24a`), `postclean.log`, `sweep-inst.log`, `sweep-diag.log`, gates `…-1977433`, `…-2144707`, `…-2375685` | + +**Correction to revision 2: R9 did not run the same compilations.** It +executed `gpu_invocation_acceptance-6b4b8223dea45247`; the failing +sweeps executed `-d4dae4f01bcdef62`. **Differing Cargo suffixes mean +Cargo computed different metadata hashes — different compilations.** +Revision 4 went further and called them "byte-different"; that is +**withdrawn**, because the bytes a historical run executed are not +knowable now — target directories have been overwritten, and a hash +computed today is the hash of the current occupant. The weaker claim is +sufficient: R9 establishes **same target names and order**, not the +same compilations. + +What the evidence is **consistent with** is an interaction. It does not +isolate one, because the rows differ in more than the two columns shown +— different source heads, different worktrees, unknown cleanliness, and +different Cargo compilations: + +| prior targets execute | compilation set | result | +|---|---|---| +| yes | `-91f51d0b` + `-6b4b8223` (`prefix.log:3066`, `:3087`) | R9 green | +| no | `-5d9105cb` + `-d4dae4f0` (`wsonly.log:3`, `:24`) | R10 green | +| yes | `-5d9105cb` + `-d4dae4f0`, all seven | **F1–F7 red (7)** | + +Neither factor alone reproduced it **in these runs**. That is the +whole of the claim. `--workspace` artifact selection is **not +sufficient by itself and not ruled out**; later-selected packages can +influence Cargo's build graph and fingerprints *before* their test +executables run, so "their targets execute after the failure at line +3066" does not exonerate them — that claim is withdrawn. And since +§4's own preamble says no historical row should be relied on until D0, +**this table is a description of what was observed, not a finding**. +Revision 3 asserted it as an interaction while simultaneously +disclaiming its inputs, which cannot both be true. + +Also refuted, by measurement: machine load (red on a quiet box, load +2.77); tmpfs starving RAM (**tested by experiment** — `/tmp` 21 G → +1.2 G, available 27 G → 45 G, still red); leaked daemons (peak 58, +8 +per sweep, green runs already at 46–60); inotify (47 of 1024). + +## 4a. The onset is datable — and it reframes the lane + +`sweep-crdt` appears **17 times** in this target directory's gate logs. +`ctrl_c` fails in **exactly the last three**, and passed — both copies, +`... ok` — in the runs before them. + +Counted **per test copy** across the 17 `sweep-crdt` logs: + +| outcome | runs | +|---|---| +| both copies `... ok` | **13** | +| **neither copy executed** — stage died compiling `pmacs` (`error[E0308]`), `…-708693` | **1** | +| both copies `FAILED` | **3** (`…-1977433`, `…-2144707`, `…-2375685`) | + +Revision 4 said "14 runs, 11 green, 3 red on other tests" and that the +earlier reds failed on unrelated tests. **Both wrong.** The count is +13 / 1 / 3, and one earlier non-passing run is a **compile failure that +never reached either copy**. The two genuinely red-on-other-tests +sweeps did execute `ctrl_c`, and it passed. + +**So the failure is not long-standing.** "Pre-existing on `main`" +remains true — F1 at `72da24a` reproduces it — but "always broken" was +never established and is now contradicted. Last green containing it: +`20260815T185708Z`. First red: `20260816T063330Z`. The machine was not +rebooted across that boundary. + +**But the onset is NOT a source boundary, and a Git bisect is not yet +justified.** Reflog and commit times put HEAD at `7599661` during the +last green — `3c06176` was committed 40 s after that run finished — and +at `724b785` during the first red, since `5174f73` landed at 08:45:41, +after that run ended at 08:42:01. **Cleanliness was captured for +neither**, and the tree was under active edit throughout. So the window +dates a **machine/worktree-state transition**, not two clean revisions. + +One further relationship is worth recording **only to say what it +cannot support**: `72da24a` is an **ancestor** of `7599661` (verified +by `git merge-base --is-ancestor`), yet `72da24a` fails today while +`7599661` passed on 08-15. **These two observations are +non-comparable** — they differ in commit *and* in environment *and* in +time — so **no causal conclusion of any kind may be drawn from the +pair**. + +Revision 6 read it as "outcome is not determined by commit alone". +**That is withdrawn too**: different commits can deterministically +produce different outcomes, and this document's own fix-then-regression +scenario is an example. The pair supports nothing about determinism +either way. + +**Revision 5 drew still more from it.** It said a source cause was +"positively discouraged", that the ancestry "says to expect" equal +endpoints, and that "whatever changed is environmental, cached, or +uncommitted". None of that follows either. Nothing in the pair +distinguishes: + +- an environmental change; +- a source/environment interaction; or +- a source fix landing before `7599661` and a regression landing before + `724b785`. + +And an older ancestor outside the interval behaving badly is simply +**irrelevant** to whether `7599661..724b785` contains a regression. The +endpoints, under the N = 5 contract in §7 D0a, settle only **whether a +bisect is currently justified** — not whether the interval contains a +regression. Those are different questions, and D0a's outcome +(both-uniform-red) answers the first and leaves the second open. (Revision 6 wrote "needs only that the +two clean endpoints differ *now*", which is the superseded one-run +rule; a bare difference decides nothing.) + +**This still supersedes the reduction matrix as the lane's first +move**, as endpoint reproduction — which is a decision procedure, not a +prediction. + +## 4b. D0a result — the commits do not discriminate today + +Executed 2026-08-19 under the approved contract **with one departure, +stated up front**: the contract's captured-conditions list names +`uptime`, and the harness kept only the load averages from it. `uptime` +is therefore `UNKNOWN` for all ten runs. Everything else held — 10 runs, +counterbalanced `A B B A A B B A A B`, N = 5 per endpoint, clean +detached worktrees, isolated target directories, `dirty=0` verified per +run, **zero voids, zero splits** — and no classification depends on the +missing field, so the verdict stands. D1/D2's harness must capture the +full list. + +**A (`7599661`) uniform-red. B (`724b785`) uniform-red.** By the +endpoint table this is *both endpoints uniform the same way*: the +difference is **not captured by those two commits**. + +- **No bisect of `7599661..724b785` is justified UNDER CURRENT + CONDITIONS**, and none will run. `7599661` passed inside `sweep-crdt` + on 08-15 and fails 5/5 clean today, so the two commits **do not + discriminate now**. +- **That is the entire causal claim.** Earlier wording here — "the + source hypothesis is eliminated", "the interval cannot contain the + transition", "not reachable by source" — is **withdrawn**. + Uniform-red today says nothing about 08-15: a historical source + regression could be **masked** by a later environmental effect, or by + a source/environment interaction under which both commits now fail. + **Failing to discriminate is not the same as not differing.** +- **The onset window is deprioritised, not excluded.** +- **A reliable reproduction now exists** — 10/10 today across two + commits, ~4 minutes per run. This is D0a's most useful product: + **D1/D2 no longer wait on a rare event.** +- One cheap negative on "what else changed": **no package activity in + the window** (`/var/log/pacman.log`, 08-15 19:57 → 08-16 06:33; + nearest 08-18). Not pursued further — with a reproduction in hand, + direct measurement dominates archaeology. + +Per-run provenance: `docs/probe-sigint-evidence.md` §D0a. + +## 4c. D1/D2 RESULT — the mechanism, and it is my own artifact + +**`SIGINT` was ignored by every process in the target group, because I +launched the test runner in the background.** + +Captured at the moment of the test's own `kill`: + +``` +test parent pid 8252 SigIgn=0000000000001007 +launcher pid 8281 ppid=8252 pgid=8281 SigIgn=0000000000001007 + SigCgt=0000000000000440 wchan=do_wait +probe pid 8284 ppid=8281 pgid=8281 SigIgn=0000000000001007 +``` + +`SigIgn=0x1007` is signals 1, 2, 3 and 13 — and **signal 2 is +`SIGINT`**. The launcher's `SigCgt=0x440` is signals 7 and 11 only, +Rust's SIGBUS/SIGSEGV handlers; there is no `SIGINT` handler anywhere. +Every `SigPnd`/`ShdPnd` is zero and every per-thread `SigBlk` is zero, +so this is **ignored** delivery, not **blocked** delivery. Launcher and +probe share `pgid=8281`, so nothing escaped the group either. All three +candidates D1/D2 was built to separate are thereby separated. + +`kill(-pgid, SIGINT)` is a **no-op for every member**. The launcher +waits in `do_wait` for a child that was never told to stop, and the 5 s +deadline fires. + +### Where the ignore comes from — measured in both directions + +| invocation | child's `SigIgn` | `SIGINT` | +|---|---|---| +| foreground | `0000000000001000` | bit 12 only (SIGPIPE) — **deliverable** | +| `setsid nohup … &` | `0000000000000007` | SIGHUP, SIGINT, SIGQUIT — **ignored** | + +A shell running a command in the background without job control sets +`SIGINT`/`SIGQUIT` to `SIG_IGN` in the child; `nohup` adds `SIGHUP`. +**`SIG_IGN` is inherited across `fork` and survives `exec`**, so it +propagates shell → `cargo` → test binary → launcher → probe. + +### The controlled experiment + +Same command, same tree, same target directory, minutes apart — only +the invocation differs: + +| arm | invocation | both target copies | +|---|---|---| +| 1 | foreground | **ok** | +| 2 | `setsid nohup … &` | **FAILED** | + +### This invalidates most of this lane's investigation, and I caused it + +I adopted `setsid nohup … &` on 08-16 to stop the Bash tool's +ten-minute cap truncating gate runs. **That is the "onset".** + +- **The subset-vs-full distinction was never real.** Every reduction I + ran was foreground; every full sweep was backgrounded. The two + variables were perfectly confounded, so §4's matrix measured my + invocation method rather than the code, and R9's "paradox" dissolves. +- **The 08-15 → 08-16 window** dates my method change, not the machine + and not the source. +- **D0a's both-uniform-red is consistent and was right** — its harness + backgrounded both arms, so both were red; the cause was invisible to + a comparison in which it did not vary. +- **"Pre-existing on `main`" is true but trivial**: `main` fails the + same way backgrounded and passes foreground. + +### Consequences for the contract + +- **Bet 1 is WITHDRAWN BY SCOPE; A5 is RETIRED BY SCOPE.** D4 was + never executed, so **nothing here establishes that a real + `pmacs --gpu` session behaves correctly** — only that no observed + evidence of a user-facing defect survives, every red run being + explained by the runner's invocation. Any user-facing claim needs its + own lane and its own evidence. +- **The §7/§8 remedy no longer follows.** What remains is narrower and + genuinely real: + 1. **The gate must not be invoked so that `SIGINT` is ignored** — a + runner practice, and the direct cause of all seven red sweeps. + 2. **The test should not fail obscurely when its precondition is + absent.** "child did not exit within 5s" sent this lane chasing a + teardown defect for nine revisions. It should detect an ignored + `SIGINT` and say so. Silently skipping is not acceptable — + `scripts/gate`'s own comments record that self-skipping tests + "void coverage silently". +- **A3's subset/full obligation is discharged by explanation**, not by + D0b: the difference was invocation mode, demonstrated in both + directions. + +`#![forbid(unsafe_code)]` rules out `pre_exec` as one *mechanism*; it +does **not** select the remedy, and revision 11's leap from the first to +the second did not follow. §7b weighs the candidates and §7c records +the decision. + +## 4d. The shipped ABI is defective on macOS — found by CI + +`70f0bc9`, `Test (macos-latest / lua54)` and `… / luajit`, one row: + +``` +scripts/gate: line 543: .../check-sigint-deliverable: Permission denied +gate: no stage has run; this is not a test failure. + left: Some(1) right: Some(2) +``` + +**macOS `/bin/sh` returns 1 when it cannot execute a file; Linux +returns 126.** The gate took its `1 | 2)` branch — identifiable only +because that branch's message text differs, since it withholds the +number — so a helper that never ran was classified **`ignored`**. A +broken guard told the operator their environment ignores `SIGINT`. + +That is the conflation §7c forbids, reached by a route §7c did not +anticipate. Five of six SIGINT rows pass on macOS; this is the sixth. + +**A7 earned its keep here.** It was satisfied *by disclosure* precisely +because the POSIX-portability claim was argued rather than measured. +The first time it was measured, the claim was wrong in a specific, +narrow way — which is the outcome a disclosed-but-untested criterion +exists to make visible. + +**A rejected repair, recorded so it is not retried.** Moving `ignored` +from 1 to 3 was proposed and refused: it relocates the collision +without closing it, because an execution failure can return **any** +nonzero status. The generalisation is the useful part — +**no exit status can prove the helper ran.** + +## 5. Two retracted claims, both mine, kept as warnings + +**Claim A — "mechanism located".** Reported the launcher blocked in +`do_wait` on a probe child in `futex_do_wait`. + +**Claim B — the retraction of A.** Argued A was unsupported because the +failing launcher "must live ≥ 8 s" while the sampler's longest-lived +was 5 s. + +**Claim B's arithmetic is false.** Both reproducing binaries finish in +**~5.19 s including the five-second timeout** (`:3097`, `:3131`), so +`phase=ready` is reached in roughly a tenth of a second and the failing +launcher lives about **5.1 s total** — squarely inside what the sampler +observed. A ">6 s" selector would therefore have captured **nothing**, +repeating the very sampling error it was written to correct. + +So A is **not** refuted by B. A remains **unproven for a different +reason**: under `--features crdt` the suite spawns root launchers from +**six** call sites — `:509, :534, :544, :574, :725, :1097`, all inside +`#[cfg(feature = "crdt")] mod crdt` (`:88`). Eight `--gpu` arguments +appear in the file, but `:38` and `:65` sit under +`#[cfg(not(feature = "crdt"))]` (`:26`) and are compiled out of the +failing configuration. Revision 2 said "five" while citing eight, which +was wrong twice over. Six is the number; what matters is that it is +more than one, so a launcher captured by command line alone cannot be +attributed to *this* test. The `do_wait` / +`futex_do_wait` pair is consistent with the failing instance and +consistent with a healthy sibling, and nothing recorded distinguishes +them. + +The standing lesson is now the opposite of revision 1's: **do not key +on process age at all.** Key on identity. + +## 6. Bets + +1. ~~The failure is a real teardown defect a user meets.~~ + **WITHDRAWN BY SCOPE — not falsified.** Every observed red run is + explained by inherited `SIG_IGN` from a background invocation + (§4c), so **no observed evidence of a user-facing defect remains**. + That is weaker than proving a real wgpu session is correct, and + **D4 was never executed** (§7), so the correct statement is: this + lane is now **gate/test correctness only**, and any user-facing + claim is out of its scope and unevidenced in both directions. +2. **UPHELD.** It is **not** a timing margin — confirmed twice over: a + green run finishes in ~0.19 s against a 5 s deadline, and the + foreground arm passes while the background arm fails with the same + binaries. +3. **UPHELD, and now load-bearing.** Raising the deadline is not a fix + and remains out of scope: the signal is never delivered, so no + deadline is long enough. + +## 7. First step — diagnostics keyed on identity, not age + +**EXECUTED. The mechanism is known (§4c): inherited `SIG_IGN`.** This +section is kept as the record of what was run. D1/D2 discriminated the +three candidates — ignored rather than blocked delivery (`SigPnd` and +per-thread `SigBlk` all zero), and no escape from the group (shared +`pgid`). D3 is discharged by the controlled arms. **D4 was NOT +executed**, and bet 1 is withdrawn by scope rather than falsified. + +As written, the step read: the first commit is diagnostic only, and it +must **discriminate** the three live candidates: blocked delivery, +inherited ignore, and an escaped or wrong process +group. + +- **D0a — reproduce the onset endpoints CLEANLY** (§4a): `7599661` + (last observed green) and `724b785` (first observed red), each + checked out clean, each in its own isolated target directory. This is + a **decision procedure with no predicted outcome**. One run per + endpoint decides nothing: **so far** the failure has been observed + only in the full sweep, which is a statement about what has been run, + not a property established of the defect. The procedure is therefore + specified rather than left to judgement: + - **N = 5 full `sweep-crdt` runs per endpoint.** Five is a + **predefined evidentiary threshold, chosen in advance so the + outcome cannot be argued after the fact** — it does not + "mathematically separate" anything. The historical 7/7 red and + 13/13 green are **not endpoint-specific rates** and must not be + read as such: of the seven reds only F6 ran at `724b785`, and of + the greens only the last ran at `7599661`, both with **unknown + cleanliness**. + - **Counterbalanced order**, not strict alternation. Runs go in + `AB BA AB BA AB` pairs, so neither endpoint systematically follows + the other. Revision 8 claimed strict `A/B/A/B…` makes session drift + "hit both arms equally"; **it does not** — under strict + alternation B always follows A and owns the final time point. What + counterbalancing buys is the removal of *systematic order + confounding*; with an even run count one arm still holds the last + slot, and that residue is accepted and stated rather than papered + over. + - **Identical captured conditions per run** — and D0a satisfied this + list only **partially**: it captured everything below except + `uptime`, keeping the load averages and discarding elapsed time. + D1/D2's harness must capture the whole list. Same harness as D0b — + argv, worktree, `git rev-parse HEAD`, `git status --porcelain` + emptiness, the Cargo suffixes executed, result, log digest — plus + the machine facts that have already misled this lane once + (`uptime`, `free`, `/tmp` usage, leaked-daemon count). + + **Classifying a single run.** The unit is *the two copies of the + target test* — `crdt::ctrl_c_…` and + `gpu_invocation_acceptance::crdt::ctrl_c_…` — and nothing else in the + sweep: + + | run outcome | definition | + |---|---| + | **green** | both copies executed and both `... ok` | + | **red** | both copies executed and both `FAILED` | + | **split** | both executed, copies **disagree** | + | **void** | either copy **did not execute** | + + Two of these are not hypothetical. `20260815T182846Z-708693` is a + **void**: the stage died compiling `pmacs` (`error[E0308]`) and + neither copy ran. And sweeps red on *unrelated* rows are ordinary — + `…-2839374` and `…-830195` both failed other tests while both target + copies passed. **A sweep red only on unrelated tests is a `green` + run** under this classifier, because the classifier reads the two + copies and nothing else. Unrelated failures are still recorded, as + evidence about environment stability. + + **Handling each outcome:** + + - **void** — discard and re-run, up to **3 voids across the whole + procedure**. Beyond that the environment is too unstable to + classify anything and D0a **stops**; that is itself the finding. + - **split** — **stop immediately.** Two copies of the same source in + different binaries disagreeing within one run is a distinct defect, + and characterising it takes priority over the endpoint question. + + **Endpoint verdicts**, from 5 valid (non-void) runs each: *uniform + green* (5/5), *uniform red* (5/5), or **mixed** (anything else). + + | `7599661` | `724b785` | conclusion | + |---|---|---| + | uniform green | uniform red | **clean split → bisect `7599661..724b785` permitted** | + | uniform red | uniform green | clean split, **direction inverted** — a real difference, but it falsifies which endpoint was believed good; record loudly and re-examine the onset reading before bisecting | + | uniform green | uniform green | **the commits do not discriminate under current conditions** → no bisect now; ask what else changed across the window. This does **not** exclude a source difference that current conditions mask | + | uniform red | uniform red | **the commits do not discriminate under current conditions** → same. A historical regression masked by a later environmental effect, or a source/environment interaction, remains possible | + | mixed | any | **intermittent under fixed source → no bisect**; back to D1/D2 | + | any | mixed | as above | + + - **Permitting a bisect requires the clean-split row.** Every other + row forbids one. + - **The bisect itself uses the same classifier.** Every intermediate + commit is classified by the identical N = 5 protocol under the same + captured conditions; a commit that classifies **mixed** — or + produces a **split**, or exceeds the void budget — **aborts the + bisect** rather than being guessed, skipped, or rerun until it + agrees. A bisect whose steps are cheaper than its endpoints would + inherit exactly the weakness this contract exists to remove. + +- **D0b — RETIRED as a precondition on 2026-08-19, kept as a + contingency.** It existed to make the §4 reduction matrix trustworthy + so the subset-vs-full comparison could locate the mechanism + *indirectly*. D0a has since produced a **reliable direct + reproduction** (10/10 across two commits, ~4 min/run), and D1/D2 + measure the mechanism itself. Sharpening an indirect instrument while + a direct one is in hand is the wrong order of work. + + **The obligation is now SATISFIED, by explanation rather than by + running D0b.** A3 asked that the subset/full difference be accounted + for: §4c accounts for it — every subset ran foreground and every full + sweep backgrounded, and the controlled arms demonstrate the + difference in both directions with byte-identical binaries. **D0b is + therefore not owed and will not run.** + + As written, the retired step read: re-run the §4 matrix, at `main`, + recording the artifact hashes actually executed **at run time**. + Revision 2's strongest claim collapsed because command shape silently + changed the binary; no further reduction should be trusted until each + row names the executable it ran. +- **D1 — key on the PID this test records.** The test already owns + `launcher.id()`. Capture around its own `kill`, not by scanning for + age or command line. +- **D2 — snapshot before *and* after the signal**, for the test parent, + the launcher, and the probe: + - `SigIgn`, `SigCgt`, `SigBlk` — **per thread**, from + `/proc//task/*/status`, since `SigBlk` is thread-specific and + a process-wide reading would hide a blocked delivery on the one + thread that matters; + - `SigPnd` and `ShdPnd` — a pending-but-undelivered `SIGINT` is + exactly what distinguishes blocked delivery from ignore; + - `PID`, `PPID`, `PGID`, `SID` for each — which settles whether the + signal was even addressed to the right group, and whether anything + escaped it. + A post-failure snapshot alone cannot prove inheritance; the + before/after pair is what makes the claim provable. +- **D3 — run the full sweep under D1/D2 until the failure is captured + *with* its diagnostics.** Only then propose a fix. +- **D4 — NOT EXECUTED.** It would have established whether a real + `pmacs --gpu` session, rather than the wrapper/headless probe, + reproduces the hang. It is **not run and not needed**, because bet 1 + is withdrawn by scope: with every observed failure explained by the + runner's invocation, there is no user-facing claim left for this lane + to make. **A5 is retired by scope, not falsified** — nothing here + demonstrates a real session behaves correctly, only that no evidence + of the contrary survives. + +## 7b. Remedy options — revision 11 evaluation, superseded by §7c + +Revision 11 jumped from "`pre_exec` is `unsafe`" to "therefore a +precondition assertion". That does not follow: ruling out one mechanism +does not select another. Four candidates, with the trade-off that +decides each: + +| # | remedy | effect | cost / risk | +|---|---|---|---| +| R-a | **Runner normalisation** — never invoke the gate so that `SIGINT` is ignored; if backgrounding is needed, restore the disposition first | removes the cause for every test at once | a *practice*, not a mechanism: nothing enforces it, and this lane exists because I violated it silently | +| R-b | **Early gate guard** — `scripts/gate` refuses to start when `SIGINT` is `SIG_IGN`, naming the reason | enforces R-a mechanically, once, for all suites | refuses runs that would mostly have succeeded. *This row originally added "needs an explicit override for deliberate background use"; §7c rejects that — see there* | +| R-c | **Fixture isolation** — the test restores the default disposition in the spawned launcher | fixes the test wherever it runs, background included | `pre_exec` is `unsafe`, and `#![forbid(unsafe_code)]` binds the lib crate; an integration test could technically opt out, but doing so to dodge a project invariant needs its own argument | +| R-d | **Test-local precondition assertion** — detect `SIG_IGN` on `SIGINT` and fail with that reason instead of "child did not exit within 5s" | converts nine revisions of misdirection into one accurate line | does not make the test *pass* when backgrounded; it only stops it lying about why it failed | + +**They are not exclusive**, and the likely answer is R-b + R-d: a guard +that stops the whole gate from running in a state where several suites +are meaningless, plus a test that explains itself if it is ever reached +that way. R-a alone is what already failed. R-c is the only one that +makes the test genuinely invocation-independent, and it is also the one +that touches an `unsafe` prohibition, so it needs a separate decision. + +**Portability is a selection criterion, not an afterthought.** Reading +`SigIgn` from `/proc/self/status` is Linux-only. The suite is already +`#![cfg(unix)]`, so a `/proc`-based guard would narrow it further to +Linux; `sigaction`-based querying would be portable but is `unsafe`. +Whichever is chosen must state what it does on a non-`/proc` unix. + +Revision 11 implemented no remedy and selected none. Section 7c records +the later selection that governs implementation. + +## 7c. Remedy — SELECTED: R-b + R-d, via one portable probe + +**Decided 2026-08-19.** R-b (early gate guard) and R-d (test-local +precondition diagnosis), implemented through a **single checked-in +helper** built on a behavioural probe rather than `/proc` or `unsafe`: + +```sh +sh -c 'trap "exit 23" 2; kill -INT $$; exit 0' +``` + +It sends itself `SIGINT`. If the signal is deliverable the trap runs and +it exits **23**; if `SIGINT` was inherited as `SIG_IGN` the kill is a +no-op and it exits **0**. Verified in both contexts: 23 foreground, 0 +under `setsid nohup … &`. + +**The raw probe is not a total classifier, and must not be used as +one.** Its `exit 0` is reached both when the `kill` was a no-op *and* +when the `kill` itself failed — a failed `kill` overwrites its own +status and lands on the same exit. Read naively, a broken probe reports +"inherited `SIG_IGN`", which would fail the gate for the wrong reason +and send the next reader down this lane again. + +So the **helper owns the classification**. The checked-in executable is +`scripts/check-sigint-deliverable`; this is its complete interface: + +**Revision 13 replaces the status-only ABI with a validated +`(status, token)` pair.** The interface is: + +| outcome | exit | token on **stdout** | +|---|---|---| +| `safe` | 0 | `pmacs-sigint-v1:safe` | +| `ignored` | 1 | `pmacs-sigint-v1:ignored` | +| `error` | 2 | `pmacs-sigint-v1:error` | + +**Any other pair is a boundary error, mapped to 2** — including a +correct-looking status with no token, a token that does not match its +status, an unknown token, or a status outside 0–2. Diagnostics stay on +**stderr**; the token is the only thing on stdout, so parsing it cannot +be confused by human-readable text. + +**Why the token, and why the earlier design was wrong.** Revision 12's +ABI carried the verdict in the exit status alone. CI proved that +insufficient: on macOS a shell that cannot execute the helper exits +**1**, which the ABI already reads as `ignored`, so a broken guard told +the operator their environment ignores `SIGINT`. The first proposed +repair — move `ignored` to 3 — was **rejected, correctly**: it only +relocates the collision, because an execution failure can return *any* +nonzero status. **No exit status can prove the helper ran.** A token it +must have printed can. + +Consumers therefore validate the exact pair and treat every mismatch as +`error`. They still do not re-derive the classification: the helper +decides, and the pair is what makes the helper's decision +distinguishable from a shell's. + +Its complete POSIX-shell shape. Each arm emits **exactly one token on +stdout** and its diagnostic on stderr, so a status is never the only +thing a consumer sees: + +```sh +probe_status=0 +sh -c 'trap "exit 23" 2 || exit 24; kill -INT "$$" || exit 24; exit 0' \ + || probe_status=$? +case "$probe_status" in + 23) + echo 'pmacs-sigint-v1:safe' + exit 0 + ;; + 0) + echo 'pmacs-sigint-v1:ignored' + echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2 + exit 1 + ;; + *) + echo 'pmacs-sigint-v1:error' + echo "pmacs: could not determine whether SIGINT is deliverable (probe status $probe_status)" >&2 + exit 2 + ;; +esac +``` + +The helper maps inner 23 → `(0, safe)`, inner 0 → `(1, ignored)`, and +every other status → `(2, error)`. Consumers do not parse the inner +23/0/24 statuses and do not supply their own signal diagnosis. + +**The consumer flow is pair-validation, not status inspection:** + +1. Run the helper, capturing **status**, **stdout** and **stderr** + separately. The two process boundaries differ and the contract keeps + that difference explicit: the shell consumer always receives a shell + status, including when `exec` fails; Rust's `Command` instead returns a + spawn error with **no status to inspect at all**. Either route can + produce boundary `error`, but they are not the same input. +2. **Compare stdout as BYTES against an exact grammar. There is no + trimming.** + + ``` + stdout := TOKEN | TOKEN LF + TOKEN := "pmacs-sigint-v1:" ("safe" | "ignored" | "error") + LF := 0x0A + ``` + + Nothing else validates: not leading whitespace, not a second + newline, not CR, not interior or trailing spaces, not empty. + + **Revision 13's first grammar was unimplementable identically.** It + said "strip one trailing newline, then trim ASCII whitespace" — but + trimming removes *further* newlines, so `TOKEN\n\n` would have + validated while the same clause demanded single-line output. Worse, + POSIX command substitution `$(cmd)` strips **all** trailing + newlines while Rust's `Command` returns raw bytes, so the two + consumers could not have agreed even on a correct rule. + + **Variable capture cannot implement this, for two independent + reasons — both measured, not reasoned:** + + - **It destroys the status.** `out=$("$helper"; printf x)` returns + `printf`'s status, not the helper's: a helper exiting 1 yields + assignment status **0**. Revision 13 specified exactly this idiom. + - **It is not byte-preserving, and differs by shell.** Command + substitution drops NUL in POSIX `sh`/bash; **zsh keeps it** + (verified). So `TOKEN NUL` validates in one shell and not another + — a contract two consumers cannot implement identically. + + **The shell consumer therefore captures to files and compares bytes.** + Rust already receives byte vectors from `Command::output()` and compares + those directly; it does not need or create capture files. + + ```sh + # The guard runs BEFORE the gate's own temporary roots exist, so it + # creates and owns its capture directory --- and arms the cleanup + # BEFORE the helper can be invoked, so no path can leave residue. + capture=$(mktemp -d "${TMPDIR:-/tmp}/pmacs-sigint.XXXXXX") || { + echo 'gate: could not create the SIGINT guard capture directory (status=unavailable token=missing)' >&2 + exit 2 + } + cleanup_sigint_capture() { rm -rf "$capture"; } + trap cleanup_sigint_capture EXIT HUP INT TERM + + # `|| status=$?` IS LOAD-BEARING under `set -eu`: a bare invocation + # dies at the helper's non-zero exit and never reaches the + # assignment. This is the original shipped bug, and an earlier draft + # of THIS SECTION reintroduced it. + status=0 + "$helper" >"$capture/out" 2>"$capture/err" || status=$? + + # Select an expected token only for public helper statuses. This case + # MUST precede any use of expected_token: the gate runs under `set -u`, + # and an out-of-range shell status has no expected token. + expected_token= + case "$status" in + 0) expected_token=pmacs-sigint-v1:safe ;; + 1) expected_token=pmacs-sigint-v1:ignored ;; + 2) expected_token=pmacs-sigint-v1:error ;; + esac + + token_ok=0 + if [ -n "$expected_token" ]; then + printf '%s' "$expected_token" >"$capture/want" + printf '%s\n' "$expected_token" >"$capture/want_lf" + if cmp -s "$capture/out" "$capture/want" \ + || cmp -s "$capture/out" "$capture/want_lf" + then token_ok=1; fi + fi + + if [ ! -s "$capture/out" ]; then + token_state=missing + elif [ "$token_ok" -eq 1 ]; then + token_state=valid + else + token_state=unexpected + fi + + case "$status:$token_ok" in + 0:1) + # SAFE is the sole continuing path. Remove the guard-local + # directory and disarm its trap BEFORE the gate installs its + # later, unrelated cleanup trap. + cleanup_sigint_capture + trap - EXIT HUP INT TERM + ;; + 1:1) + cat "$capture/err" >&2 + printf 'gate: SIGINT guard status=1 token=valid\n' >&2 + exit 1 + ;; + 2:1) + cat "$capture/err" >&2 + printf 'gate: SIGINT guard status=2 token=valid\n' >&2 + exit 2 + ;; + *) + # The captured stderr is untrusted here and is not surfaced as + # the diagnosis. EXIT runs cleanup_sigint_capture. + printf 'gate: SIGINT guard boundary error (status=%s token=%s)\n' \ + "$status" "$token_state" >&2 + exit 2 + ;; + esac + ``` + + **The capture directory is guard-local by necessity.** The guard sits + immediately after the worktree resolves and deliberately *precedes* + the gate's log directory, ambient root and `GATE_TMPDIR`, so none of + those exist yet. It must therefore create its own, and it inherits + the same **no-residue invariant** the guard was placed early to + honour: a refused run leaves nothing behind. + + Files preserve every byte including NUL, `cmp` compares bytes, and + `status` is the helper's own. Rust performs the same comparison on + `out.stdout` against `TOKEN` and `TOKEN + b"\n"`. Neither consumer + trims, and neither routes stdout through a shell variable. + + If a future consumer *must* use variable capture, the status has to + be carried out explicitly — + `out=$("$helper"; st=$?; printf x; exit "$st")` — and the NUL + divergence still bars it from claiming byte equality. + +3. Accept **only** these three pairs; every other combination is + `error`: + + | status | stdout bytes | outcome | + |---|---|---| + | 0 | `pmacs-sigint-v1:safe` | `safe` | + | 1 | `pmacs-sigint-v1:ignored` | `ignored` | + | 2 | `pmacs-sigint-v1:error` | `error` | + +4. Proceed only on `safe`. Otherwise stop — and **which stderr is + authoritative depends on whether the pair validated**: + + - **Validated pair** (`ignored` or `error`): the helper's stderr + *is* the diagnosis. Surface it unchanged. + - **Boundary failure** (any invalid pair, or no pair at all): the + helper's stderr is **untrusted and must not be presented as the + diagnosis.** The consumer emits its own boundary wording, and + either omits the child's stderr or reproduces it under an explicit + untrusted label. + + **This closes a hole revision 13 left open.** A helper exiting + **1 with no token but the canonical `SIGINT is ignored` text on + stderr** would classify as boundary `error` — correctly — and then + tell the operator their environment ignores `SIGINT`, which is A6's + prohibition arriving through the diagnostic instead of the + classification. A verdict that cannot be trusted cannot supply + trusted wording either. + +**`safe` is validated like the others.** Revision 12 let a consumer +proceed on exit 0 alone; under revision 13, `0` with a missing or wrong +token is `error` and the consumer stops. That is deliberate — a status +that arrives without the token did not come from this helper. + +That produces one of three total outcomes: + +| outcome | pair required | reached when | +|---|---|---| +| `safe` | `(0, pmacs-sigint-v1:safe)` | inner probe exits 23 | +| `ignored` | `(1, pmacs-sigint-v1:ignored)` | inner probe exits 0 after a successful `kill` | +| `error` | `(2, pmacs-sigint-v1:error)` | `kill` failed, `sh` unavailable, unexpected exit, another signal | +| `error` (boundary) | **anything else**, including *no* pair | helper missing or unexecutable; a status with a missing, mismatched, unknown or malformed token; **macOS's status 1 with no token** | + +`error` is **not** treated as `ignored`. It fails the gate too, but with +a different diagnosis, because "your environment ignores SIGINT" and +"the guard could not run" are different problems and conflating them is +what a naive `exit 0` would do. + +This is **POSIX shell only** — `trap`, `kill`, `$$` — so the mechanism +does not depend on `/proc` or `sigaction`: it is not Linux-only and adds +no `unsafe`. That is a contract-level portability argument, not a claim +that every supported Unix has already exercised it; A7 keeps the +implementation record explicit about which platforms were actually +tried. + +**Both consumers use the same helper — but that alone no longer makes +them agree.** Under revision 12 the helper's exit status *was* the +verdict, so a shared helper guaranteed a shared answer. Under +revision 13 each consumer **independently validates the pair**, in a +different language, so they can now disagree by validating differently. +Revision 12's claim that they "can never disagree" is withdrawn. + +What replaces it is a **shared conformance matrix plus one +consumer-specific boundary case**. Both validators exercise the shared +set and must agree on it; Rust alone exercises the no-status spawn-error +case that the shell boundary cannot represent. + +**Two distinct failing outcomes**, which an earlier draft collapsed: + +- **`error (validated)`** — the pair `(2, …:error)`. The helper ran and + reported that it could not decide. +- **`error (boundary)`** — anything else. Nothing trustworthy was + returned, so the consumer owns the wording (see step 4). + +**The matrix is a generated cross-product over token class, encoding +and status, plus four out-of-band cases — enumerated below and counted +honestly.** An earlier draft applied the +malformed classes only at status 0, so a validator that checked tokens +strictly for `0` and accepted arbitrary output at `1` passed every row. + +**Encodings matter, and one of them is what production actually +emits.** The helper prints with `echo`, so the real output is +**`TOKEN` + LF**. Both encodings validate: + +``` +E1 := TOKEN (bare) +E2 := TOKEN LF (what the shipped helper emits) +``` + +Per status, the cases are: + +| class | stdout | count | expected | +|---|---|---|---| +| **V** | the **correct** token for this status, in `E1` and `E2` | 2 | **validates** | +| **M** | each of the **two other valid tokens**, in `E1` and `E2` | 4 | boundary | +| **E** | empty | 1 | boundary | +| **U** | `pmacs-sigint-v2:…` | 1 | boundary | +| **L** | LF + token | 1 | boundary | +| **X** | token + LF + LF | 1 | boundary | +| **S** | `␠` token `␠` | 1 | boundary | +| **C** | token + CR + LF | 1 | boundary | +| **D** | token token (one line) | 1 | boundary | +| **N** | token + NUL | 1 | boundary | + +That is **14 per status × 3 statuses = 42**. + +The **six mismatched valid-token pairs** are enumerated rather than +sampled, because choosing one per status would leave half of them +untested: + +| status | wrong tokens (each in `E1` and `E2`) | +|---|---| +| 0 | `…:ignored`, `…:error` | +| 1 | `…:safe`, `…:error` | +| 2 | `…:safe`, `…:ignored` | + +Only **V validates** — `(0,safe)` → `safe`, `(1,ignored)` → `ignored`, +`(2,error)` → `error (validated)`. The other **36** are +`error (boundary)`. + +Out-of-band cases and their applicable consumers: + +| # | consumers | case | expected | +|---|---|---|---| +| X1 | shell + Rust | status 126 with a correct token | `error (boundary)` — status outside 0–2 | +| X2 | **Rust only** | spawn failure (missing / unexecutable helper) | `error (boundary)`, **no status inspected** | +| X3 | shell + Rust | status 1, empty stdout, stderr = canonical ignored text | `error (boundary)`, and the output **must not** present "SIGINT is ignored" as the diagnosis | +| X4 | shell + Rust | status 0, correct token, plus extra bytes on **stderr** | `safe` — stderr is not consulted for classification | + +**Truthful totals:** the shared set is **45 cases** — the 42-case +cross-product plus X1, X3 and X4 — and both validators must agree on all +45. Rust additionally exercises X2, for **46 distinct cases overall**; +the shell exercises 45 because an `exec` failure there necessarily +becomes a shell status. Earlier drafts said twelve, then twenty-three, +then thirty-four; each was a count of a set that had not actually been +enumerated. + +The two consumers: + +- **`scripts/gate` fails immediately**, before any stage, with an + explicit ignored-`SIGINT` diagnosis. +- **The target test invokes it** and reports the same precondition + failure if run directly, instead of "child did not exit within 5s". + +**Every refusing branch must print the observed helper status AND the +token state** — valid, missing, or unexpected. This is **diagnostic +context, never the classifier**: the classification is the validated +pair, and the printout exists so a failure is legible without another +CI round-trip. Revision 12 printed the raw status only in its catch-all +branch, so when macOS failed, the path had to be identified indirectly +by which message text appeared — the log could not simply say. + +**One practical finding, measured after the guard was written: +backgrounding is not the problem — one *way* of backgrounding is.** This +session's tool-level background mode leaves `SIGINT` deliverable (helper +exits 0); `setsid nohup … &` does not (helper exits 1). The construct +that caused this lane was never necessary, which makes the guard cheap: +it forbids only what was already avoidable. + +**No override.** A full gate run under ignored `SIGINT` cannot produce +valid evidence, so there is no flag to proceed anyway — a switch that +lets the gate run in a state where several suites are meaningless would +recreate exactly the failure this lane spent nine revisions on. + +**R-c is rejected**: restoring the child's disposition needs +`pre_exec`, which is `unsafe`, and dodging a project invariant to make +one test invocation-independent is not a trade this lane will make. + +**The Linux-only D1/D2 instrumentation is removed** once its evidence is +portable — it read `/proc`, it has produced its finding, and leaving it +in place would carry a platform dependency for no further return. + +## 8. Acceptance criteria — REPLACED for the selected remedy + +The A1–A5 written for a teardown fix no longer describe this work; they +are superseded wholesale. What the guard-and-diagnosis change must +show: + +- **A1 — the guard bites.** `scripts/gate` invoked with `SIGINT` + ignored exits immediately, before any stage runs, naming the ignored + signal as the reason. +- **A2 — the direct-test diagnosis bites.** The target test run + directly with `SIGINT` ignored fails with the precondition message, + **not** with "child did not exit within 5s". +- **A3 — foreground success is unaffected.** Both target copies pass + foreground, and the guard does not fire, so the remedy costs nothing + in the normal case. +- **A4 — mutation.** Removing the probe's `trap` makes A3 fail: a + normal foreground signal terminates the inner shell and is classified + as `error`, not `safe`. Treating inner exit 0 as `safe` makes A1 and + A2 fail by allowing inherited ignore through. Collapsing `error` into + `ignored` makes A6 fail. Each mutation is named against the distinct + row it must bite. + + **Measured 2026-08-19; every prediction holds:** + + | mutation | helper fg | helper bg | forced error | bites | + |---|---|---|---|---| + | baseline | 0 | 1 | 2 | — | + | remove the probe's `trap` | **2** | 1 | — | **A3** — foreground degrades to `error`; backgrounded classification unchanged | + | treat inner exit 0 as `safe` | 0 | **0** | — | **A1 and A2** — inherited ignore passes through both consumers | + | collapse `error` into `ignored` | — | — | **1** | **A6** — a forced failure reports the ignored wording | + + **Revision 13 adds token mutations**, each of which must bite: + + | mutation | must fail | + |---|---| + | consumers accept a **missing** token (status only) | A6 and the macOS row — this is exactly the shipped defect | + | consumers accept a **wrong** token for the status (e.g. `…:safe` with exit 1) | A6 | + | consumers accept an **unknown** token (`pmacs-sigint-v2:safe`) | A6 | + | helper prints the token to **stderr** instead of stdout | **A1 and A3** — see below | + | consumer surfaces child stderr as the diagnosis on a **boundary** failure | **A6b** | + | consumer accepts any status 2 regardless of token | **A6c** | + | consumer trims whitespace before comparing | the **S/C classes** in the shared 42-case cross-product | + + **Why that last one maps to A1/A3 and not A2.** With the token on + stderr, stdout is empty, so *every* outcome becomes boundary `error`. + A1 (gate refuses under ignored `SIGINT`) still refuses but with the + wrong diagnosis, and A3 (foreground success unaffected) breaks + outright because `safe` no longer validates — both bite. **A2 does + not**, because A2 only requires the direct test to report *a* + precondition failure rather than the 5 s deadline, and a boundary + `error` satisfies that as written. Revision 13 listed A2 here + incorrectly. Either mapping is defensible; this framing keeps A2 + broad — the property it protects is "never the misleading deadline + message" — and relies on A6 to pin *which* diagnosis appears. +- **A5 — the gate is otherwise unchanged**: a normal foreground run + reaches and passes every stage it did before, with no stage added, + skipped, reordered, or made conditional. +- **A6 — the `error` outcome is distinct, and cannot be counterfeited + by a status alone.** With the probe forced to fail, **both the gate + and the direct target test** report `error`, not `ignored`, and + neither claims the environment ignores `SIGINT`. Extended by + revision 13 to cover the pair: a **missing**, **mismatched** or + **unknown** token is `error` in both consumers, whatever the status + accompanying it. +- **A8 — the guard leaves no residue, on every path.** The guard + creates its own capture directory because it runs before the gate's + temporary roots exist, and arms its cleanup **before** invoking the + helper. After `safe`, `ignored`, validated `error`, boundary `error`, + and a failure to create the directory at all, **no capture directory + survives**. This is the same no-residue invariant that put the guard + early in the first place; adding a capture directory must not weaken + it. +- **A6b — a boundary failure never speaks with the helper's voice.** + A helper exiting **1 with no token but the canonical + `SIGINT is ignored` text on stderr** classifies as boundary `error` + in both consumers, **and neither presents "SIGINT is ignored" as the + diagnosis** — the child's stderr is omitted or explicitly labelled + untrusted. Shared case X3. Without this, A6 is satisfiable in the + classification while being violated in the message the operator + actually reads. +- **A6c — exact-pair validation at status 2.** `(2, …:error)` is + `error (validated)`; `(2, missing)`, `(2, …:safe)`, `(2, …:ignored)` + and `(2, unknown-version)` are each **boundary** errors. The status-2 + slice of the shared cross-product exercises both permitted encodings, + every mismatched valid token and every malformed class. A validator + that accepts any status 2 regardless of token must fail this row. +- **A6a — the macOS row, SCOPED TO THE GATE.** An unexecutable helper + on macOS makes `/bin/sh` exit **1 with no token**; the gate must + classify that as **boundary error → 2**, never `ignored`. Not + hypothetical: it is the observed CI failure on `70f0bc9` + (`Test (macos-latest / lua54)` and `… / luajit`), and the row is + satisfied only when that platform is green. + + **It does not apply to R-d, for two independent reasons**, and + revision 13 was wrong to state it for "both consumers": + - **R-d never sees that status.** The gate invokes the helper through + `/bin/sh`, which converts an exec failure into a shell exit status. + R-d uses Rust's `Command`, which returns a **spawn error with no + exit status at all** — a different code path reaching `error` by a + different route (conformance row 12, not row 5). + - **macOS CI does not compile R-d's test.** It lives inside + `#[cfg(feature = "crdt")] mod crdt`, and the macOS jobs run + `--no-default-features --features ` with no `crdt`; + `Test (crdt)` is `runs-on: ubuntu-latest`. + + So R-d's macOS behaviour is **unexercised**, and this framing does not + pretend otherwise. Closing that would need either a non-crdt-gated + R-d row or a macOS crdt job — **neither is proposed here**, and A7 + records the gap instead of hiding it. +- **A7 — PARTIALLY EXERCISED ON macOS, one defect found, R-d still + Linux-only.** Revision 12 closed this by disclosure because no + non-Linux unix was reachable. **That is now stale: macOS CI reached + it and measured it red**, so the disclosure fallback no longer + applies and the criterion is restated against evidence. + - **Exercised on macOS (`Test (macos-latest / lua54)` and + `… / luajit`, head `70f0bc9`):** five of the six helper/gate rows + pass — all three helper outcomes, gate refusal on `ignored`, and + gate refusal on a helper-reported `error`. + - **One known defect on macOS:** + `gate_maps_an_unexecutable_helper_to_error_not_ignored` fails, + status 1 with no token classified as `ignored`. This is the whole + reason for revision 13 (§4d), and A6a is the row that closes it. + - **R-d: Linux-only, unexercised on macOS**, because its test is + crdt-gated and the macOS jobs build without `crdt`. Stated as a + gap, not argued away. + - **Everything else remains a contract argument**: the helper is + POSIX shell only, reads no `/proc` and calls no `sigaction`. BSD + and other unixes are still untried. + +## 8b. Superseded criteria, kept for the record + +These were written for a teardown fix that is no longer the work. They +are retained so the change of target is visible rather than silent; +**none of them binds.** + +Written when this lane still expected a teardown repair. **Superseded +by §8**; kept verbatim below. + +- **A1.** The mechanism is stated and demonstrated, not inferred: a + witness failing before the change and passing after, plus a mutation + showing the witness bites its own clause. +- **A2.** `sweep-crdt` green for **three consecutive full runs on the + reviewed fixed head of this branch**. Not "on main" — that is + unobtainable before this lane is approved, gated and merged, and + revision 1 stated an impossible precondition. Post-merge + confirmation on `main` is a follow-up, not a gate on the fix. +- **A3.** There is no established "R9 paradox" to explain — R9 ran + different Cargo compilations, so the comparison it appeared to make + was never made. What A3 requires instead: **the demonstrated D1/D2 + mechanism accounts for the subset/full difference, or D0b recreates + that comparison under captured provenance before this lane closes.** + In the first case, record the mechanism's explanation. In the second, + whatever D0b shows is either explained by the fix or explicitly + recorded as unexplained. A fix that greens the sweep without either + path leaves the gap stated, not hidden. +- **A4.** No deadline raised, no test skipped, retried, or serialised + to obtain green. +- **A5.** **Conditional on D4.** If bet 1 holds, this is unconditional: + Ctrl-C on a real `pmacs --gpu` session tears down the frontend and + leaves the daemon running. **RETIRED BY SCOPE**: D4 was not executed, + bet 1 is withdrawn, and this lane is recorded as **gate/test + correctness only**. A5 is not claimed satisfied and not claimed + falsified — it is out of scope, and a user-facing teardown claim would + need its own lane and its own evidence. + +## 9. Coherence impact (`COHERENCE.md` §20) + +- **Journey steps touched: NONE, as finally established.** Earlier + revisions claimed 12(a) "closing is clean", on the premise that this + lane repairs Ctrl-C teardown. §4c withdraws that premise: no product + behaviour changes, because the failure is an artifact of how the test + runner is invoked. Revision 1's "none" reached the right answer by + the wrong route (grade movement, which §20 warns against); this is + the right answer for the stated reason. +- What the lane does touch is **gate trustworthiness**: seven red + sweeps that named a product defect and had none. +- **Grade movement: none expected.** This restores a property that is + supposed to hold, rather than opening a new one. +- **Interaction islands: none added.** +- **Config registry: not touched. Background-work attribution: not + touched.** +- What the lane restores is every *other* lane's ability to prove + itself: while the gate can be run in a state where several suites are + meaningless, a red stage 15 tells you nothing about the branch. + +## 10. Out of scope + +- Raising or removing the 5 s deadline (bet 3). +- The ~10 daemons each sweep leaks — real, separately recorded, and not + implicated: green runs already ran at 46–60 leaked daemons. +- `gpu_initial_target_acceptance` including the suite as a module. It + is why the failure reds twice, and it is a tidiness question, not a + correctness one. + +## 11. Record corrections owed to other ledgers + +A correction is not made until it is portable. Two were outstanding +when revision 2 was reviewed, and both are closed by this revision: + +- **This branch's ledger** asserted that two default-disposition + processes "should both die at once" and then withdrew that same claim + further down. The assertion is removed; only the withdrawal and its + reasoning remain. +- **`panel-mapping-generation`** carried "119 binaries green, one + red", the ≥8 s arithmetic, the "default action" claim and the ">6 s + selector"; `779a6bd` corrected those. **It still carried more**, + found on re-review: `--workspace` unification "refuted", R9 running + the "same binaries", later packages that "cannot be implicated", and + a cause "cumulative across the preceding 37 binaries". Revision 3's + claim here that the held lane no longer transports falsified claims + was **premature**; those are corrected now, and this section should + be read as a checklist that has been re-verified rather than an + assurance. 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/docs/probe-sigint-evidence.md b/docs/probe-sigint-evidence.md new file mode 100644 index 0000000..5b191fe --- /dev/null +++ b/docs/probe-sigint-evidence.md @@ -0,0 +1,454 @@ +# GPU probe SIGINT lane — run manifest + +Every physical run behind +`docs/gpu-probe-sigint-framing.md` §4. Pushed so the evidence travels; +the log bodies stay machine-local under +`/home/jeans/build/pmacs-gate-targets/probe-sigint-evidence/` and are +identified here by SHA-256 prefix and byte count. + +## Provenance honesty + +These runs were made **before** this manifest existed, so their +provenance is **reconstructed, not captured**. Specifically: + +- **Commands** are exact and complete argv. Revision 3 abbreviated + R7–R9 as "`--test` ×N (targets 6–37)" and F5 as "`--acceptance` ×6", + which are descriptions, not reconstructable invocations. They are + written out in full below. +- **Worktree** is exact. +- **HEAD** is given as a range where the run cannot be pinned to one + commit, and marked `~`. It is never guessed at single-commit + precision. +- **Cleanliness** was not recorded at the time and is therefore + `UNKNOWN` for every pre-manifest run. It is not inferred. + +**D0 (below) exists because of this.** No conclusion in §4 should rest +on a `UNKNOWN`-cleanliness row once D0 has replaced it. + +## Artifact identity — suffix is NOT byte identity + +Two distinct things were conflated in revision 3 and are separated here. + +**Cargo suffix** is recorded in each log and is therefore *known* per +run. **Byte identity** is not: target directories have been overwritten +many times since, so a hash computed today is the hash of whatever +occupies that path now, not of what a given run executed. + +That the two differ is demonstrated, not assumed. F1 ran in the `main` +worktree (`pmacs-fdccc423`) and executed suffixes `-5d9105cb7047aab8` +and `-d4dae4f01bcdef62` — **the same suffixes** as the panel-worktree +sweeps — yet the bytes at those paths differ by worktree: + +| worktree | `gpu_initial_target…-5d9105cb` | `gpu_invocation…-d4dae4f0` | +|---|---|---| +| `pmacs-fdccc423` (main) | `e057803988c34cf7` | `00f06aeb089ce38d` | +| `pmacs-mapping-gen-…` (§5b) | `1b3cc86cbb8d6092` | `ede0c07dd9abb456` | + +Same suffix, different source head, different bytes **today**. So +**"workspace artifact family" was not an identity class** and is +withdrawn as a grouping. + +Three levels of knowledge, kept apart: + +0. **Portability caveat.** Suffixes below are read from logs that are + **machine-local**; this manifest is the portable record of them, and + a reader elsewhere is trusting this transcription, not verifying it. + Rows that were never logged say `UNKNOWN` and are not guessed. +1. **Suffix — known**, from each log. A differing suffix means Cargo + computed a different metadata hash, i.e. it treated the two as + distinct compilations. +2. **Today's bytes at a path — known**, and shown above. +3. **The bytes a historical run executed — UNKNOWN.** Target + directories have been overwritten repeatedly since; a hash computed + now is the hash of the current occupant. + +So statements of the form "R9 and the sweeps ran byte-different +binaries" are **withdrawn everywhere**. What is established is that +they ran **different Cargo compilations** (different suffixes), which +is enough to void the comparison and is all that is claimed. R1 and R2 +have no preserved log and cannot claim even a suffix. + +## Runs + +All in worktree `pmacs-mapping-gen` unless stated. `WT=mg` is +`/home/jeans/Repos/personal/pmacs-mapping-gen`; `WT=main` is +`/home/jeans/Repos/personal/pmacs` at `72da24a`. All carry +`CARGO_TARGET_DIR=/home/jeans/build/pmacs-gate-targets/pmacs-mapping-gen-8cb089c8` +except `WT=main`, which uses `…/pmacs-fdccc423`. + +| id | exact command (after `cargo`) | WT | HEAD | clean | Cargo suffixes executed | result | log (sha256/16, bytes) | +|---|---|---|---|---|---|---|---| +| R1 | `test --features crdt --test gpu_invocation_acceptance ctrl_c_on_launcher_group` ×3 | mg | ~`724b785`–`5174f73` | UNKNOWN | **UNKNOWN** (no log) | green, 0.15–0.17 s | **none preserved** | +| R2 | `test --features crdt --test gpu_invocation_acceptance` | mg | ~`724b785`–`5174f73` | UNKNOWN | **UNKNOWN** (no log) | green, 15 passed | **none preserved** | +| R3 | `test --workspace --features crdt --no-fail-fast -- --skip basedpyright ctrl_c_on_launcher_group` | mg | ~`724b785`–`5174f73` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | green | `e09a96512035284e` 33113 | +| R4 | `test --features crdt --lib --test gpu_invocation_acceptance --no-fail-fast` | mg | ~`724b785`–`5174f73` | UNKNOWN | `-6b4b8223` only | green, 2145 + 15 | `89050c702de22d57` 158812 | +| R5 | `test --features crdt --no-fail-fast --test gate_script_acceptance --test gpu_invocation_acceptance` | mg | ~`5174f73`–`b72843a` | UNKNOWN | `-6b4b8223` only | green | `31b3e5249b475479` 3706 | +| R6 | `test --features crdt --no-fail-fast --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`5174f73`–`b72843a` | UNKNOWN | `-91f51d0b` / `-6b4b8223` | green, 11+15+15 | `332693a39c73731a` 4569 | +| R7 | `test --features crdt --no-fail-fast --lib --bins --test acceptance --test ambient_isolation_acceptance --test auto_indent_acceptance --test auto_indent_crdt_acceptance --test auto_pair_acceptance --test auto_pair_crdt_acceptance --test autosave_acceptance --test bottom_panel_stage1_acceptance --test bottom_panel_stage2a_acceptance --test bottom_panel_stage2b_daemon_acceptance --test bottom_panel_stage2b_gpu_acceptance --test bottom_panel_stage2b_protocol_acceptance --test comment_toggle_acceptance --test compile_mode_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | `gpu_invocation…-6b4b8223` **only** — R7 does not select `gpu_initial_target` | green | `9e1ebc59ed9f0dd4` 187531 | +| R8 | `test --features crdt --no-fail-fast --test compile_mode_crdt_acceptance --test completion_popup_acceptance --test config_registry_acceptance --test cua_region_acceptance --test desktop_acceptance --test destination_capture_acceptance --test dired_acceptance --test discovery_acceptance --test discovery_stage2_acceptance --test editops_acceptance --test find_file_acceptance --test folding_acceptance --test folding_stage2_acceptance --test full_grid_resync_acceptance --test gate_script_acceptance --test git_status_stage1_acceptance --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | `-91f51d0b` / `-6b4b8223` (`half2.log:438`, `:459`) | green | `8b26ebfcf5f871b4` 28677 | +| R9 | `test --features crdt --no-fail-fast --lib --bins --test acceptance --test ambient_isolation_acceptance --test auto_indent_acceptance --test auto_indent_crdt_acceptance --test auto_pair_acceptance --test auto_pair_crdt_acceptance --test autosave_acceptance --test bottom_panel_stage1_acceptance --test bottom_panel_stage2a_acceptance --test bottom_panel_stage2b_daemon_acceptance --test bottom_panel_stage2b_gpu_acceptance --test bottom_panel_stage2b_protocol_acceptance --test comment_toggle_acceptance --test compile_mode_acceptance --test compile_mode_crdt_acceptance --test completion_popup_acceptance --test config_registry_acceptance --test cua_region_acceptance --test desktop_acceptance --test destination_capture_acceptance --test dired_acceptance --test discovery_acceptance --test discovery_stage2_acceptance --test editops_acceptance --test find_file_acceptance --test folding_acceptance --test folding_stage2_acceptance --test full_grid_resync_acceptance --test gate_script_acceptance --test git_status_stage1_acceptance --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | **`-91f51d0b` / `-6b4b8223`** (log `:3066`, `:3087`) | green | `b31d98ee2f427eca` 214566 | +| R10 | `test --workspace --features crdt --no-fail-fast --test gpu_initial_target_acceptance --test gpu_invocation_acceptance -- --skip basedpyright` | mg | ~`b72843a` | UNKNOWN | **`-5d9105cb` AND `-d4dae4f0`** (log `:3`, `:24`) | green | `81b48fd7a0e261dc` 3553 | +| F1 | `build --workspace --no-default-features --features luajit,crdt && test --workspace --features crdt --no-fail-fast -- --skip basedpyright` | **main** | `72da24a` | clean (verified `git status --porcelain` empty) | `-5d9105cb` / `-d4dae4f0`; today's occupants `e0578039…` / `00f06aeb…` | **red** | `10b55b8ba8741125` 334446 | +| F2 | `test --workspace --features crdt --no-fail-fast -- --skip basedpyright` | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`postclean.log`) | **red** | `474f88f0dad581fe` 338555 | +| F3 | same argv as F2, with a resource sampler running | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`sweep-inst.log`) | **red** | `7b8519e7300e8bb3` 338555 | +| F4 | same argv as F2, with a process-table sampler running | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`sweep-diag.log`) | **red** | `5ccdefc5d89eece3` 338555 | +| F5 | stage 15 of `./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` | mg | `5174f73` + uncommitted docs | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (3 bins: both ctrl_c copies + `m6_1_pty_mode_lifecycle`) | `20260817T172537Z-2375685/15-sweep-crdt.log`, sha `e5bdc911e366` | +| F6 | stage 9 of `./scripts/gate --protocol` | mg | **`724b785`** (see below) | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (2 bins, both ctrl_c copies) | `20260816T063330Z-1977433/09-sweep-crdt.log`, sha `7a75d999ac4f` | +| F7 | stage 9 of `./scripts/gate --protocol` | mg | `5174f73` (committed 08:45:41, run began 08:45:49) | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (2 bins, both ctrl_c copies) | `20260816T064549Z-2144707/09-sweep-crdt.log`, sha `9d3c6ad1bfc9` | + +Supporting, not a reduction: `9b8a01076b44bb7c` 98838 +(`proc-sample.log`) is the process-table sampler output behind the +retracted "mechanism located" claim. + +**The red count is 7, not 5.** Revision 3 said "F1–F5, red 5/5" while +the framing separately cited gate `…-2144707`, a *different* physical +run the manifest never listed. Both exist, both are red, and there is a +third gate run too. F1–F7 above enumerate all seven, each with its own +log digest. F5 also has an extra failing binary +(`m6_1_pty_mode_lifecycle_started_then_exited`) that F6/F7 do not. + +**R2 and R6 are distinct runs.** Revision 2's §4 cited `gpu3.log` for +both; that log is R6's three-suite run only, and R2's log was never +preserved. R1 likewise has no log. Both are marked accordingly rather +than backfilled. + +## The onset is datable — and this supersedes the reduction matrix + +`sweep-crdt` has 17 log files in this target directory. Counted **per +test copy**, not per stage: + +| outcome | runs | +|---|---| +| both copies `... ok` | **13** | +| **neither copy executed** — the stage died compiling `pmacs` (`error[E0308]: mismatched types`), log `20260815T182846Z-708693` | **1** | +| both copies `FAILED` | **3** (`…-1977433`, `…-2144707`, `…-2375685`) | + +Revision 4 said "14 runs, 11 green, 3 red on other tests" and that the +earlier reds "failed on unrelated tests". **Both were wrong**: the +count is 13 / 1 / 3, and one of the earlier non-passing runs is a +**compile failure that never reached either copy**, not a red on +another test. Of the genuinely red-on-other-tests sweeps, `…-2839374` +(08-15 09:55) failed protocol and version rows and `…-830195` +(08-15 18:37) failed `composition_overhead_under_ten_percent` and a +v21/v20 row — those two did execute `ctrl_c`, and it passed. + +So the failure is **not long-standing**. Last observed green: +`20260815T185708Z`. First observed red: `20260816T063330Z`. The machine +was not rebooted across it — the current boot began 08-14 09:30. + +**But this is not yet a source boundary.** Reflog and commit times put +HEAD at `7599661` during the last green (`3c06176` was committed 40 s +*after* that run finished) and at `724b785` during the first red +(`5174f73` was committed 08:45:41, after that run ended at 08:42:01 — +revision 4's manifest wrongly recorded F6 at `5174f73`). Cleanliness +was captured for **neither**, and the tree was being edited throughout. +So the window dates a **machine/worktree-state transition**, not two +clean source revisions. + +One relationship is worth recording **for exactly what it shows**: +`72da24a` is an **ancestor** of `7599661` (`git merge-base +--is-ancestor`), yet fails today (F1) while `7599661` passed on 08-15. +The two observations differ in commit **and** environment **and** +time, so they are **non-comparable and support no causal conclusion of +any kind** — not even "outcome is not determined by commit alone", +since different commits can deterministically produce different +outcomes. The pair does not discriminate an environmental change, a +source/environment interaction, or a fix before `7599661` followed by a +regression before `724b785`. And an ancestor outside the interval is +irrelevant to whether the interval contains a regression. + +## D1/D2 — EXECUTED 2026-08-19. The outer invocation is the variable + +**The causal variable is the OUTER invocation of the test runner**, so +it is recorded here as a first-class column. Earlier "exact commands" +in this file are incomplete for that reason: they gave the inner +`cargo` argv and omitted how the runner itself was started. + +### Controlled arms, committed head, worktree-local target + +Head `77b623c`, `dirty=0`, worktree +`/home/jeans/Repos/personal/pmacs-probe-sigint`, target +`/home/jeans/build/pmacs-gate-targets/pmacs-probe-sigint-84ed0f9e`, +`TMPDIR=/home/jeans/build/pmacs-gate-targets/tmp/arms`. + +Inner command, identical in both arms: + +``` +cargo test --features crdt --no-fail-fast \ + --test gpu_invocation_acceptance --test gpu_initial_target_acceptance \ + -- ctrl_c_on_launcher_group +``` + +The **actual evidence-producing outer invocations** were: + +``` +# fg arm +/home/jeans/build/pmacs-gate-targets/d0a/arms2.sh fg + +# bg arm +setsid nohup /home/jeans/build/pmacs-gate-targets/d0a/arms2.sh bg \ + > /dev/null 2>&1 & disown +``` + +`arms2.sh` is machine-local, so naming it is not portable provenance by +itself. This is the complete body that produced `arms2.tsv`; it records +the inner `cargo` exit rather than the status of the disowned outer job: + +```sh +#!/bin/sh +# D1/D2 controlled arms, v2: full binary SHA-256 captured PER RUN, +# immediately after the run, before anything can rebuild them. +set -u +WT=/home/jeans/Repos/personal/pmacs-probe-sigint +TD=/home/jeans/build/pmacs-gate-targets/pmacs-probe-sigint-84ed0f9e +TMP=/home/jeans/build/pmacs-gate-targets/tmp/arms; mkdir -p "$TMP" +OUT=/home/jeans/build/pmacs-gate-targets/d0a +ARM="$1"; LOG="$OUT/arm2-$ARM.log" +HEAD=$(git -C "$WT" rev-parse HEAD); DIRTY=$(git -C "$WT" status --porcelain | wc -l) +env TMPDIR="$TMP" CARGO_TARGET_DIR="$TD" sh -c \ + "cd $WT && cargo test --features crdt --no-fail-fast --test gpu_invocation_acceptance --test gpu_initial_target_acceptance -- ctrl_c_on_launcher_group" \ + > "$LOG" 2>&1 +EXIT=$? +OK=$(grep -c "ctrl_c_on_launcher_group_does_not_reach_spawned_daemon \.\.\. ok" "$LOG") +BAD=$(grep -c "ctrl_c_on_launcher_group_does_not_reach_spawned_daemon \.\.\. FAILED" "$LOG") +SIG=$(grep -m1 -o "SigIgn=[0-9a-f]*" "$LOG" || echo "SigIgn=not-captured") +# Full digests of exactly the binaries this run executed, taken now. +HASHES="" +for b in $(grep -E "Running tests/gpu_" "$LOG" | sed 's|.*deps/||; s|)||'); do + HASHES="$HASHES $b=$(sha256sum "$TD/debug/deps/$b" | cut -d' ' -f1)" +done +printf '%s\tarm=%s\thead=%s\tdirty=%s\texit=%s\tok=%s\tfailed=%s\t%s\tlogsha=%s\thashes=%s\n' \ + "$(date -Iseconds)" "$ARM" "$(echo $HEAD | cut -c1-7)" "$DIRTY" "$EXIT" "$OK" "$BAD" "$SIG" \ + "$(sha256sum "$LOG" | cut -d' ' -f1)" "$HASHES" >> "$OUT/arms2.tsv" +echo "arm2 $ARM: exit=$EXIT ok=$OK failed=$BAD $SIG" +``` + +### Result — head `77b623c`, `dirty=0`, digests captured PER RUN + +| arm | outer | cargo exit | ok | failed | `SigIgn` | log sha256 (full) | +|---|---|---|---|---|---|---| +| fg | foreground | 0 | 2 | 0 | not captured (no failure ⇒ no dump) | `b6117619242f3dabadbe5826f59f11cef451cd09a2beb304e9a3dec1a4616465` | +| bg | `setsid nohup … &` | 101 | 0 | 2 | `0000000000001007` | `30ac6568e345c485ddf23734a239d71bd06d4a3c2d549f1b4a8942a930527507` | + +Binary digests, **full SHA-256, taken immediately after each run before +anything could rebuild them** — not read later from a reused path: + +| binary | fg arm | bg arm | +|---|---|---| +| `gpu_initial_target_acceptance-91f51d0b5303ff9f` | `0890b78cca22ac1e80b79845f85fb6e88def3330db15ae123a2a672d3084124c` | `0890b78cca22ac1e80b79845f85fb6e88def3330db15ae123a2a672d3084124c` | +| `gpu_invocation_acceptance-6b4b8223dea45247` | `ef6ff1c15e11062ab53a075763814f32c1bbc9be1b146d068c60e91fa247c696` | `ef6ff1c15e11062ab53a075763814f32c1bbc9be1b146d068c60e91fa247c696` | + +**These are byte-identical, and that claim is now carried by the +capture rather than by inference.** The earlier arms table recorded only +16-character prefixes at run time and its full values were read +afterwards from reused paths — which is exactly the provenance rule +§7/D0 states, applied against my own record. Those rows are superseded +by the table above. The raw `arms.tsv` and `arms2.tsv` remain +machine-local; the literal harness and full rows above are their +portable transcription. + +### Disposition — UNRECORDED CORROBORATION, not a controlled arm + +This table was read ad hoc from `/proc/self/status` in the two shells +and **its runs were not captured**: no head, no cleanliness, no log, +no digest. It agrees with the arms above and with §4c's capture, and it +is labelled separately for that reason — it corroborates, it does not +evidence. + +| context | child `SigIgn` | `SIGINT` | +|---|---|---| +| foreground | `0000000000001000` | bit 12 (SIGPIPE) only — deliverable | +| `setsid nohup … &` | `0000000000000007` | SIGHUP, SIGINT, SIGQUIT — ignored | + +The raw behavioural probe underlying the selected helper (framing §7c) +supersedes it as the *recorded* mechanism check: +`sh -c 'trap "exit 23" 2; kill -INT $$; exit 0'` exits **23** when +`SIGINT` is deliverable and **0** when it is inherited as ignored. +Verified in both contexts. It is **not itself the remedy** because it +does not preserve `kill` failure; §7c specifies the total checked-in +helper and its 0/1/2 consumer ABI. + +### The first D1/D2 capture, and why it is superseded + +The capture quoted in framing §4c came from `d12.log`, which finished +14:10 — **five minutes before `afe3631` committed the diagnostic +code** — and ran in the reused `d0a-B` target directory rather than +this worktree's. Its signal facts agree with the arms above, but it is +**not admissible provenance**: uncommitted tree, foreign target. The +arms table replaces it, and `d12.log` is retained only as the first +sighting. + +### Historical foreground/background mapping — RECONSTRUCTED + +The claim that "every reduction was foreground and every full sweep was +backgrounded" is **reconstructed from this session's transcript, not +captured at run time**. No run before today recorded its outer +invocation, because none of the harnesses knew it mattered. It is +consistent with every observation and with the two arms above, but it +is inference, and rows R1–R10 and F1–F7 carry **no outer-invocation +field**. That gap is the direct cause of nine revisions spent on a +confounded matrix. + +## D0a — EXECUTED 2026-08-19. Verdict: difference NOT captured + +Ten runs, counterbalanced `A B B A A B B A A B`, N = 5 per endpoint, +**zero voids, zero splits**. Endpoints checked out detached and clean +in dedicated worktrees (`pmacs-d0a-A`, `pmacs-d0a-B`), each with its own +target directory, each run performing the gate's `build-crdt` +precondition then the `sweep-crdt` command. `dirty=0` verified per run. + +| run | endpoint | HEAD | class | ctrl_c ok/failed | red bins | log | +|---|---|---|---|---|---|---| +| A#1 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-1.log` | +| B#1 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-1.log` | +| B#2 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-2.log` | +| A#2 | A | `7599661` | **red** | 0 / 2 | 4 | `d0a/A-2.log` | +| A#3 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-3.log` | +| B#3 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-3.log` | +| B#4 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-4.log` | +| A#4 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-4.log` | +| A#5 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-5.log` | +| B#5 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-5.log` | + +**Exact commands.** Every run, in full. `` is +`/home/jeans/Repos/personal/pmacs-d0a-A` (detached at `7599661`) or +`/home/jeans/Repos/personal/pmacs-d0a-B` (detached at `724b785`); +`` is `/home/jeans/build/pmacs-gate-targets/d0a-A` or +`/home/jeans/build/pmacs-gate-targets/d0a-B` correspondingly: + +``` +env TMPDIR=/home/jeans/build/pmacs-gate-targets/tmp/d0a \ + CARGO_TARGET_DIR= \ + sh -c 'cd \ + && cargo build --workspace --no-default-features --features luajit,crdt \ + && cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright' +``` + +**Per-run provenance, transcribed.** All runs: `exit=101`, `dirty=0`, +`ok=0 failed=2`, suffixes `-5d9105cb` / `-d4dae4f0`, `/tmp` 3 G of 30 G. +Times are local 2026-08-19. `load` is the 1/5/15 average at run start; +`MemFree`/`MemAvail` in MB; `daemons` counts live `pmacs --daemon`: + +| start | run | class | red bins | load | MemFree | MemAvail | daemons | log sha256/16 | +|---|---|---|---|---|---|---|---|---| +| 12:21:20 | A#1 | red | 3 | 2.51 2.85 3.57 | 1549 | 42069 | 72 | `e1c0fe47d55d8f5e` | +| 12:26:55 | B#1 | red | 2 | 7.86 13.48 8.97 | 9405 | 42725 | 76 | `3105794d515e6ec3` | +| 12:32:12 | B#2 | red | 2 | 8.04 18.28 13.23 | 9761 | 43370 | 80 | `07a662fb5ca15687` | +| 12:35:39 | A#2 | red | 4 | 11.34 21.23 16.20 | 8933 | 43281 | 84 | `450aacb9d15244c9` | +| 12:39:35 | A#3 | red | 3 | 13.40 26.07 20.45 | 10628 | 42989 | 88 | `19156bbbc852e2d3` | +| 12:43:26 | B#3 | red | 2 | 8.66 22.18 20.82 | 8778 | 43588 | 92 | `6f0d768a76ed9cb0` | +| 12:47:20 | B#4 | red | 2 | 17.96 33.10 27.07 | 10403 | 43617 | 96 | `75a043a3d5568598` | +| 12:50:49 | A#4 | red | 3 | 10.99 27.07 26.52 | 10702 | 43335 | 100 | `c1b6d88c08764d9a` | +| 12:54:47 | A#5 | red | 3 | 9.75 28.50 28.70 | 8494 | 43053 | 104 | `2ae21a3f53fc1a1a` | +| 12:58:38 | B#5 | red | 2 | 11.43 26.69 28.97 | 8562 | 43122 | 108 | `82c5baa0c7e49e37` | + +**Two defects in the previous transcription of this table, recorded +rather than silently fixed.** Every digest had lost its leading hex +character — A#1 read `1c0fe47d55d8f5e…` where the value is +`e1c0fe47d55d8f5e` — because the extraction started one byte late in +`logsha=`. And `/tmp` and `MemAvailable` were captured by the +harness but dropped from the table. A transcription that silently +corrupts its own digests is worse than a pointer to the raw file, since +it looks verifiable and is not. + +**`uptime` was NOT captured, and is `UNKNOWN` for all ten runs.** §7's +condition list names `uptime`; the harness recorded only the load +averages from it and discarded the elapsed time. The classifications +stand — none of them depends on it — but the condition list was **not +fully satisfied**, and D1/D2's harness must capture it. Recorded rather +than quietly treated as met. + +Note the leaked-daemon count climbing 72 → 108, four per run. Recorded, +not implicated: it rises monotonically while every run classifies the +same. Raw logs stay machine-local at +`/home/jeans/build/pmacs-gate-targets/d0a/`; the table above is the +portable record. + +**Endpoint verdicts: A uniform-red, B uniform-red.** By the approved +table this is the *both endpoints uniform the same way* row: + +> **the difference is not captured by those two commits** under current +> conditions, and the question becomes what else changed across the +> window. + +### What this settles + +- **The two commits do not discriminate under current conditions.** + `7599661` passed inside `sweep-crdt` on 08-15 and now fails **5/5** + clean. **No bisect of `7599661..724b785` is justified under current + conditions**, and none will be run. +- **That is the whole of the causal claim.** An earlier wording here + said "the source hypothesis is eliminated" and that "the interval + cannot contain the transition"; both are **withdrawn**. Uniform-red + today is silent about what was true on 08-15 — a historical source + regression could be **masked** by a later environmental effect, or by + a source/environment interaction that makes both commits fail now. + Not discriminating is not the same as not differing. +- **The onset window is deprioritised, not excluded.** It remains a + true observation, and it remains *possible* that source matters + within it; what is established is only that source cannot be + probed **by this comparison, now**. +- **A reliable reproduction now exists.** 10/10 today, on two different + commits, at ~4 minutes per run. **This is the most useful thing D0a + produced**: the mechanism diagnostics D1/D2 no longer depend on a + rare event, and can proceed immediately. +- **A's three extra failures are recorded, not swept up**: + `a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal`, + `one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client`, and + `m6_1_pty_mode_lifecycle_started_then_exited`. The v21/v20 row is + expected to differ at that older commit; the other two are + process/PTY-spawn rows, the same family as the target. They do not + affect classification, which reads only the two target copies. + +### What it does not settle + +Nothing about the mechanism. "What else changed across the window" has +one cheap negative result so far: **no package activity in the window** +(`/var/log/pacman.log` shows nothing between 08-15 19:57 and 08-16 +06:33; nearest is 08-18). The `1.88` rust toolchain directory has mtime +08-15 22:39, inside the window, but the gate builds with `1.95.0`. +Neither is pursued further, because with a reliable reproduction in +hand **direct measurement (D1/D2) dominates archaeology**. + +## D0 — re-run the matrix with captured provenance + +Before any §4 row is relied on for a conclusion, re-run the reductions +under a harness that records, per run and at the time: exact argv and +environment, worktree, `git rev-parse HEAD`, `git status --porcelain` +emptiness, the artifact hashes actually executed, the result, and the +log digest. Two constraints learned the hard way: + +- run them **at `main`**, not on a feature branch — R1–R10 ran in the + `panel-mapping-generation` worktree, which carries §5b changes; +- record the **artifact hash per run at run time**, since command shape + changes it — the reason R9's result did not mean what it appeared to + mean — and since a hash computed later reflects only what occupies + that path now; +- **first, reproduce the two candidate endpoints CLEANLY** — + `7599661` (last observed green) and `724b785` (first observed red) — + each checked out clean, each in its **own isolated target + directory**. A decision procedure with **no predicted outcome**, and + **not decided by one run per endpoint**. The framing's §7 D0a holds + the governing contract — the total run classifier + (green / red / split / void), the void budget, the endpoint table and + the bisect-step policy. In summary, keeping the two conclusions with + the verdicts they actually belong to: + + - **expected-direction clean split** (`7599661` uniform green, + `724b785` uniform red) → a bisect of `7599661..724b785` is + permitted; + - **inverted clean split** (`7599661` uniform red, `724b785` uniform + green) → the commits differ, but the observed direction contradicts + the onset reading; record it and re-examine that reading before any + bisect; + - **mixed at either endpoint** → the failure is **intermittent under + fixed source**; no bisect; + - **both endpoints uniform the same way**, green or red → **the + difference is not captured by those two commits** under current + conditions, and the question becomes what else changed across the + window. + + Revision 8 attached "the difference is not captured" to the *mixed* + clause. That was wrong — mixed means intermittency, not absence of a + difference — and revision 9 corrected it in the framing but **left + this file untouched**, because the edit's anchor silently missed. 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 cbc7a7c..9b2c113 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`. /// @@ -2141,6 +2234,15 @@ struct PanelBand { /// suppress it. This one is a TERMINATION FALLBACK with a different /// lifetime, and `panel_motion_is_new` never consults it. gesture_last_content_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, @@ -2504,15 +2606,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. @@ -2543,16 +2686,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 } @@ -5541,7 +5679,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, @@ -6879,7 +7017,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. @@ -6997,7 +7135,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(); @@ -7047,6 +7185,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 @@ -7061,8 +7206,66 @@ impl State { self.panel.pointer_held = false; self.panel.last_pointer_cell = None; self.panel.gesture_last_content_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}"); @@ -7435,10 +7638,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 } @@ -7451,6 +7662,7 @@ impl State { // Safe in both directions: the press path rewrites it immediately // after arming, and a release READS it before this runs. self.panel.gesture_last_content_cell = None; + self.panel.last_pointer_generation = None; } /// Begin a divider drag at surface pixel `y`, if the pointer is on the @@ -20012,6 +20224,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" + ); + } + /// Parent 48 R-c — the band's MODE-LINE row is chrome, and the /// producer must not arm a gesture there. /// 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/scripts/check-sigint-deliverable b/scripts/check-sigint-deliverable new file mode 100755 index 0000000..1b7b8ce --- /dev/null +++ b/scripts/check-sigint-deliverable @@ -0,0 +1,63 @@ +#!/bin/sh +# Is SIGINT deliverable to this process tree? +# +# WHY THIS EXISTS. A shell running a command in the background without +# job control sets SIGINT (and SIGQUIT) to SIG_IGN in the child; nohup +# adds SIGHUP. SIG_IGN is inherited across fork AND survives exec, so +# the disposition reaches every descendant --- cargo, a test binary, and +# anything either of them spawns. Tests that signal a child then wait +# for it to die will hang until their own deadline and report that as a +# teardown defect. One lane spent nine framing revisions on exactly that +# misreading; see docs/gpu-probe-sigint-framing.md §4c. +# +# INTERFACE (docs/gpu-probe-sigint-framing.md §7c) --- a validated +# (status, token) PAIR. Callers rely on both halves and MUST NOT +# re-derive the classification: +# +# 0 safe stdout: pmacs-sigint-v1:safe no diagnostic +# 1 ignored stdout: pmacs-sigint-v1:ignored canonical diagnostic +# 2 error stdout: pmacs-sigint-v1:error distinct diagnostic +# +# The token is the ONLY thing on stdout; diagnostics go to stderr. Any +# other pair --- including a plausible status with no token --- is a +# BOUNDARY error for the caller, mapped to 2. +# +# WHY A TOKEN AND NOT A STATUS ALONE. CI proved a status-only ABI +# unsound: on macOS a shell that cannot execute this file exits 1, +# which the old ABI read as `ignored`, so a broken guard told the +# operator their environment ignores SIGINT. No exit status can prove +# this script ran; a token it must have printed can. +# +# `error` is never folded into `ignored`. "Your environment ignores +# SIGINT" and "the guard could not run" are different problems, and +# conflating them fails callers for the wrong reason. +# +# THE PROBE. A child sends itself SIGINT. Deliverable => the trap runs +# => 23. Ignored => the kill is a no-op => the child falls through to +# `exit 0`. The `|| exit 24` arms matter: without them a FAILED kill +# would also fall through to `exit 0` and be misread as `ignored`, +# which is the one wrong answer this helper exists to avoid. +# +# POSIX shell only --- trap, kill, $$ --- so no /proc and no sigaction: +# the mechanism is not Linux-specific and adds no unsafe code. + +probe_status=0 +sh -c 'trap "exit 23" 2 || exit 24; kill -INT "$$" || exit 24; exit 0' \ + || probe_status=$? + +case "$probe_status" in + 23) + echo 'pmacs-sigint-v1:safe' + exit 0 + ;; + 0) + echo 'pmacs-sigint-v1:ignored' + echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2 + exit 1 + ;; + *) + echo 'pmacs-sigint-v1:error' + echo "pmacs: could not determine whether SIGINT is deliverable (probe status $probe_status)" >&2 + exit 2 + ;; +esac diff --git a/scripts/gate b/scripts/gate index 2ddd9d7..817bdd8 100755 --- a/scripts/gate +++ b/scripts/gate @@ -506,6 +506,116 @@ esac WT=$(canon "$(worktree_root)") cd "$WT" +# R-b (framing SS7c): refuse to start when SIGINT is not deliverable. +# +# HERE, immediately after the worktree resolves and BEFORE any log +# directory, ambient root or temporary dir exists: a refused run should +# leave nothing behind to clean up or mistake for evidence. +# +# WHY AT ALL. A shell running a command in the background without job +# control sets SIGINT to SIG_IGN; that survives fork AND exec, so every +# test which signals a child and waits for it hangs to its own +# deadline. Seven red sweeps were read as a product teardown defect +# before this was found (framing SS4c). +# +# The helper owns the classification and the wording; this consumer +# re-derives neither. It continues only on 0, and otherwise stops after +# letting the helper's stderr through untouched. +# +# `|| sigint_status=$?` IS LOAD-BEARING, and two wrong shapes were tried +# before this one: +# +# * a bare invocation dies under `set -e` at the helper's non-zero +# exit, so the refusal below never prints; +# * `if ! helper; then sigint_status=$?; fi` captures the status of +# the NEGATED condition --- always 0 --- so the guard printed the +# diagnosis and then ran the whole gate anyway. +# +# In a `cmd || assignment` list the failure is handled, so `set -e` does +# not fire and `$?` is the helper's own status. This is the same shape +# the helper uses internally. +# +# NO OVERRIDE, deliberately: a run in this state cannot produce valid +# evidence, so a flag to proceed anyway would only manufacture red gates +# that mean nothing. +if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then + # The guard owns its capture directory: it runs BEFORE the log dir, + # ambient root and GATE_TMPDIR exist, and must still leave nothing + # behind (A8). + capture=$(mktemp -d "${TMPDIR:-/tmp}/pmacs-sigint.XXXXXX") || { + echo 'gate: could not create the SIGINT guard capture directory (status=unavailable token=missing)' >&2 + echo 'gate: REFUSING TO RUN --- no stage has run.' >&2 + exit 2 + } + cleanup_sigint_capture() { rm -rf "$capture"; } + trap cleanup_sigint_capture EXIT HUP INT TERM + + # `|| sigint_status=$?` IS LOAD-BEARING under `set -eu`: a bare + # invocation dies at the helper's non-zero exit and never reaches + # the assignment. That was the originally shipped bug. + sigint_status=0 + "$WT/scripts/check-sigint-deliverable" \ + >"$capture/out" 2>"$capture/err" || sigint_status=$? + + # Select an expected token only for public statuses. This MUST + # precede any use of expected_token: `set -u` is on, and an + # out-of-range status has no expected token. + expected_token= + case "$sigint_status" in + 0) expected_token=pmacs-sigint-v1:safe ;; + 1) expected_token=pmacs-sigint-v1:ignored ;; + 2) expected_token=pmacs-sigint-v1:error ;; + esac + + # Byte comparison against both permitted encodings. Files preserve + # every byte including NUL; a shell variable would not, and command + # substitution's NUL handling differs between sh and zsh. + sigint_token_ok=0 + if [ -n "$expected_token" ]; then + printf '%s' "$expected_token" >"$capture/want" + printf '%s\n' "$expected_token" >"$capture/want_lf" + if cmp -s "$capture/out" "$capture/want" || + cmp -s "$capture/out" "$capture/want_lf"; then + sigint_token_ok=1 + fi + fi + + if [ ! -s "$capture/out" ]; then + sigint_token_state=missing + elif [ "$sigint_token_ok" -eq 1 ]; then + sigint_token_state=valid + else + sigint_token_state=unexpected + fi + + case "$sigint_status:$sigint_token_ok" in + 0:1) + # The sole continuing path. Tidy up and disarm before the + # gate installs its own, unrelated cleanup trap. + cleanup_sigint_capture + trap - EXIT HUP INT TERM + ;; + 1:1 | 2:1) + # A VALIDATED verdict: the helper's stderr IS the diagnosis + # and is surfaced unchanged. + cat "$capture/err" >&2 + printf 'gate: REFUSING TO RUN (status=%s token=%s) --- no stage has run.\n' \ + "$sigint_status" "$sigint_token_state" >&2 + exit "$sigint_status" + ;; + *) + # BOUNDARY error. The captured stderr is UNTRUSTED and is + # deliberately not surfaced: a helper exiting 1 with no + # token but the canonical ignored wording would otherwise + # tell the operator their environment ignores SIGINT (A6b). + printf 'gate: SIGINT guard boundary error (status=%s token=%s)\n' \ + "$sigint_status" "$sigint_token_state" >&2 + printf 'gate: REFUSING TO RUN --- no stage has run.\n' >&2 + exit 2 + ;; + esac +fi + TARGET=$(ensure_target_dir "$WT") STAMP=$(date -u +%Y%m%dT%H%M%SZ) # $$ as well as the timestamp: two invocations in the same worktree diff --git a/src/daemon.rs b/src/daemon.rs index 4d57f97..7a2fc89 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"). /// @@ -2445,7 +2567,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, @@ -2454,8 +2588,100 @@ fn handle_dispatcher_event( panel_epoch, ) { - editor - .dispatch_semantic_panel_pointer(source, buffer_id, coord, kind, mods); + // 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, mods) + { + 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, mods) + { + update_accepted_gesture( + semantic_states, + source, + kind, + coord, + buffer_id, + false, + ); + } } } FrontendEvent::Pointer { @@ -3615,6 +3841,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. _ => {} } } @@ -3655,6 +3888,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 @@ -5966,6 +6205,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, @@ -5982,6 +6243,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, @@ -6185,6 +6463,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] @@ -6238,7 +7634,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); @@ -6266,7 +7662,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), event, @@ -6281,7 +7677,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), @@ -6312,7 +7708,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); @@ -6337,7 +7733,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6364,7 +7760,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6536,7 +7932,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); @@ -6582,7 +7978,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6609,7 +8005,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelResizeRows { @@ -6638,7 +8034,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6677,7 +8073,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); @@ -6695,7 +8091,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), @@ -6711,7 +8107,7 @@ mod tests { dispatch_panel_event( &mut editor, fid, - PROTOCOL_VERSION, + LEGACY_PANEL_VERSION, &mut semantic_states, &mut HashMap::new(), FrontendEvent::PanelPointer { @@ -6764,7 +8160,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); @@ -6777,7 +8173,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 e45388c..adc925c 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( &mut 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/common/mod.rs b/tests/common/mod.rs index 7d2e4ca..5ecc4bd 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -25,3 +25,4 @@ pub mod daemon; pub mod iso; pub mod pty; +pub mod sigint_conformance; diff --git a/tests/common/sigint_conformance.rs b/tests/common/sigint_conformance.rs new file mode 100644 index 0000000..f7f2d68 --- /dev/null +++ b/tests/common/sigint_conformance.rs @@ -0,0 +1,213 @@ +//! Shared SIGINT-guard conformance vectors +//! (`docs/gpu-probe-sigint-framing.md` §7c). +//! +//! # Why these live here rather than in each suite +//! +//! The contract is that the **shell** consumer (`scripts/gate`) and the +//! **Rust** consumer (R-d's `sigint_diagnosis`) agree on every case. +//! Two independently written copies of the list can drift while both +//! still report "45 cases" — the same-length-different-content +//! divergence this matrix exists to rule out. One generator, two +//! consumers. + +/// What a consumer must do with a given `(status, stdout)` pair. +/// +/// `ValidatedError` and `Boundary` **both exit 2**, so a test comparing +/// only exit codes cannot separate them — and a validator that accepted +/// *every* status 2 would pass. They are told apart by whether the +/// child's stderr is surfaced: a validated verdict speaks with the +/// helper's voice; a boundary failure must not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + Safe, + ValidatedIgnored, + ValidatedError, + Boundary, +} + +pub const TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe"; +pub const TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored"; +pub const TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error"; + +/// Emitted on stderr by the **branch-discriminating** cases — the +/// cross-product rows and X1 — so a consumer's output can be searched +/// for it: present ⇒ the child's stderr was surfaced. +/// +/// **X3 and X4 deliberately carry their own payloads instead**, which +/// is what makes them distinct inputs rather than duplicates of +/// `1/E/empty` and `0/V/safe/bare`. An earlier revision gave every case +/// this same sentinel and so shipped 45 entries over 43 distinct +/// inputs; [`shared_cases`] now asserts uniqueness so that cannot +/// recur silently. +pub const SENTINEL: &str = "PMACS-CONFORMANCE-SENTINEL"; + +/// The canonical wording the helper uses for `ignored`. X3 emits it +/// **without** a valid token, so a consumer that surfaced untrusted +/// stderr would repeat it — the defect A6b forbids. +pub const CANONICAL_IGNORED: &str = + "pmacs: SIGINT is ignored; run this command with SIGINT deliverable"; + +pub struct Case { + pub name: String, + pub status: i32, + pub stdout: Vec, + /// Exact stderr this stub emits. Most cases use [`SENTINEL`]; X3 + /// and X4 carry their own payloads, which is what makes them + /// distinct inputs rather than duplicates of other rows. + pub stderr: String, + pub expect: Outcome, +} + +/// A `/bin/sh` stub reproducing one case exactly: its stdout bytes, its +/// own stderr payload, and its status. +#[must_use] +pub fn stub_script(case: &Case) -> String { + let octal = case.stdout.iter().fold(String::new(), |mut acc, b| { + use std::fmt::Write as _; + let _ = write!(acc, "\\{b:03o}"); + acc + }); + format!( + "#!/bin/sh\nprintf '{octal}'\necho '{}' >&2\nexit {}\n", + case.stderr, case.status + ) +} + +/// The shared set: ten classes × encodings × three statuses, plus +/// X1/X3/X4. Only the diagonal validates. +/// +/// X2 — a spawn error with no status — is deliberately absent: the +/// shell boundary cannot represent it, because an `exec` failure there +/// becomes a status. Rust exercises it separately. +#[must_use] +#[allow( + clippy::too_many_lines, + reason = "the bulk is the generated vector list; splitting it would \ + separate a case from the outcome it encodes, which is the \ + one thing this file exists to keep together" +)] +pub fn shared_cases() -> Vec { + let toks: [(&str, &[u8]); 3] = [ + ("safe", TOKEN_SAFE), + ("ignored", TOKEN_IGNORED), + ("error", TOKEN_ERROR), + ]; + let mut out = Vec::new(); + for (idx, (name, correct)) in toks.iter().enumerate() { + let status = i32::try_from(idx).expect("0..=2"); + let diagonal = match status { + 0 => Outcome::Safe, + 1 => Outcome::ValidatedIgnored, + _ => Outcome::ValidatedError, + }; + let mut lf = correct.to_vec(); + lf.push(b'\n'); + out.push(Case { + name: format!("{status}/V/{name}/bare"), + status, + stdout: correct.to_vec(), + stderr: SENTINEL.to_owned(), + expect: diagonal, + }); + out.push(Case { + name: format!("{status}/V/{name}/lf"), + status, + stdout: lf, + stderr: SENTINEL.to_owned(), + expect: diagonal, + }); + for (other, bytes) in &toks { + if other == name { + continue; + } + let mut olf = bytes.to_vec(); + olf.push(b'\n'); + out.push(Case { + name: format!("{status}/M/{other}/bare"), + status, + stdout: bytes.to_vec(), + stderr: SENTINEL.to_owned(), + expect: Outcome::Boundary, + }); + out.push(Case { + name: format!("{status}/M/{other}/lf"), + status, + stdout: olf, + stderr: SENTINEL.to_owned(), + expect: Outcome::Boundary, + }); + } + let mut leading = vec![b'\n']; + leading.extend_from_slice(correct); + let mut extra = correct.to_vec(); + extra.extend_from_slice(b"\n\n"); + let mut spaces = b" ".to_vec(); + spaces.extend_from_slice(correct); + spaces.push(b' '); + let mut crlf = correct.to_vec(); + crlf.extend_from_slice(b"\r\n"); + let mut doubled = correct.to_vec(); + doubled.extend_from_slice(correct); + let mut nul = correct.to_vec(); + nul.push(0); + for (cls, bytes) in [ + ("E/empty", Vec::new()), + ("U/unknown", b"pmacs-sigint-v2:safe".to_vec()), + ("L/leading-lf", leading), + ("X/extra-lf", extra), + ("S/spaces", spaces), + ("C/crlf", crlf), + ("D/doubled", doubled), + ("N/nul", nul), + ] { + out.push(Case { + name: format!("{status}/{cls}"), + status, + stdout: bytes, + stderr: SENTINEL.to_owned(), + expect: Outcome::Boundary, + }); + } + } + out.push(Case { + name: "X1/status-126".to_owned(), + status: 126, + stdout: TOKEN_SAFE.to_vec(), + stderr: SENTINEL.to_owned(), + expect: Outcome::Boundary, + }); + out.push(Case { + name: "X3/ignored-text-no-token".to_owned(), + status: 1, + stdout: Vec::new(), + // The canonical ignored wording WITHOUT a token: a consumer + // that surfaced untrusted stderr would repeat it. + stderr: CANONICAL_IGNORED.to_owned(), + expect: Outcome::Boundary, + }); + out.push(Case { + name: "X4/stderr-noise".to_owned(), + status: 0, + stdout: TOKEN_SAFE.to_vec(), + // Noise on stderr must not affect classification --- and this + // payload is what distinguishes X4 from 0/V/safe/bare. + stderr: "unrelated chatter on stderr".to_owned(), + expect: Outcome::Safe, + }); + // The set must be 45 DISTINCT inputs, not merely 45 entries. A + // previous revision gave every case the same stderr, which silently + // collapsed X3 into `1/E/empty` and X4 into `0/V/safe/bare` — 45 + // entries, 43 inputs, and two framing-specified cases quietly not + // exercised. Asserted here rather than in each suite so no consumer + // can forget it. + let mut seen = std::collections::HashSet::new(); + for case in &out { + assert!( + seen.insert((case.status, case.stdout.clone(), case.stderr.clone())), + "duplicate conformance input at {}: (status, stdout, stderr) already present", + case.name + ); + } + assert_eq!(seen.len(), out.len(), "every case must be a distinct input"); + out +} 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/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index 15b36b5..fff9a58 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -28,6 +28,8 @@ //! `~/build/pmacs-gate-targets`, which matters most for the prune //! tests — a prune bug is unrecoverable. +mod common; + use std::path::{Path, PathBuf}; use std::process::Command; @@ -35,6 +37,72 @@ fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } +/// The SIGINT-deliverability helper the gate and the panel suite share +/// (`docs/gpu-probe-sigint-framing.md` §7c). +fn sigint_helper() -> PathBuf { + repo_root().join("scripts/check-sigint-deliverable") +} + +/// Run `cmd` with `SIGINT` set to `SIG_IGN`, the way a shell that +/// backgrounds a job without job control does. +/// +/// `trap "" INT` sets the ignore in the wrapper shell, and `SIG_IGN` is +/// inherited across `fork` **and survives `exec`** — which is the whole +/// mechanism under test, so simulating it this way exercises the real +/// thing rather than a stand-in. +fn under_ignored_sigint( + program: &Path, + args: &[&str], + cwd: &Path, + env: &[(&str, &str)], +) -> std::process::Output { + // `exec "$@"` with the program and arguments passed POSITIONALLY. + // Interpolating them into the script text would break on any path + // containing a space or a shell metacharacter, and every path here + // comes from a `tempdir` or `CARGO_MANIFEST_DIR` — neither of which + // this test controls. + let mut command = Command::new("sh"); + command + .arg("-c") + .arg("trap \"\" INT; exec \"$@\"") + .arg("sh") + .arg(program) + .args(args) + .current_dir(cwd); + for (key, value) in env { + command.env(key, value); + } + command.output().expect("spawn shell with SIGINT ignored") +} + +/// A minimal git worktree holding a copy of `scripts/gate` and a +/// **stub** `check-sigint-deliverable`, so the gate's handling of each +/// helper status can be driven on its real path without touching the +/// checked-in helper. +fn gate_with_stub_helper(stub_body: &str, executable: bool) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + let scripts = dir.path().join("scripts"); + std::fs::create_dir_all(&scripts).expect("scripts dir"); + std::fs::copy(gate(), scripts.join("gate")).expect("copy gate"); + let helper = scripts.join("check-sigint-deliverable"); + std::fs::write(&helper, stub_body).expect("write stub helper"); + let mode = if executable { 0o755 } else { 0o644 }; + std::fs::set_permissions(&helper, std::os::unix::fs::PermissionsExt::from_mode(mode)) + .expect("chmod stub helper"); + std::fs::set_permissions( + scripts.join("gate"), + std::os::unix::fs::PermissionsExt::from_mode(0o755), + ) + .expect("chmod gate copy"); + let ok = Command::new("git") + .args(["init", "-q"]) + .current_dir(dir.path()) + .status() + .expect("git init"); + assert!(ok.success(), "the stub worktree must be a git worktree"); + dir +} + fn gate() -> PathBuf { repo_root().join("scripts/gate") } @@ -93,6 +161,345 @@ fn run(root: &Path, args: &[&str]) -> (String, String, bool) { // §3, nothing else in the repository would notice. `--print-plan` // exists to make that checkable without executing anything. +/// A6, gate consumer: a helper verdict of `error` (2) refuses the run +/// with the ERROR wording, and never claims `SIGINT` is ignored. +/// +/// Driven through a stub worktree so the gate's real code path runs +/// against a controlled helper status; the helper's own classification +/// is covered by its own rows above. +#[test] +fn gate_refuses_on_helper_error_without_claiming_sigint_is_ignored() { + let root = tempfile::tempdir().expect("tempdir"); + let repo = gate_with_stub_helper( + "#!/bin/sh\necho 'pmacs: could not determine whether SIGINT is deliverable (probe status 42)' >&2\nexit 2\n", + true, + ); + let out = Command::new(repo.path().join("scripts/gate")) + .arg("--self-test") + .current_dir(repo.path()) + .env("PMACS_GATE_TARGET_ROOT", root.path()) + .output() + .expect("run the stub-worktree gate"); + assert_eq!( + out.status.code(), + Some(2), + "an error verdict exits 2, not 1" + ); + let err = String::from_utf8_lossy(&out.stderr); + // This stub emits the error TEXT but no token, so under the pair + // ABI it is a BOUNDARY error --- and its stderr is untrusted, hence + // deliberately not surfaced. + assert!( + err.contains("SIGINT guard boundary error"), + "an unvalidated pair is a boundary error: {err}" + ); + assert!( + !err.contains("could not determine"), + "and the untrusted child stderr is NOT shown: {err}" + ); + assert!( + !err.contains("SIGINT is ignored"), + "an undecidable probe must never be reported as ignored: {err}" + ); + assert!(err.contains("no stage has run"), "and no stage ran: {err}"); +} + +/// A6, gate boundary: a helper that cannot be EXECUTED is an `error` at +/// the call boundary — mapped to 2 — never evidence that `SIGINT` is +/// ignored. +/// +/// This is the case the original guard got wrong twice: under `set -e` +/// a bare invocation died before any mapping, and 126/127 would have +/// escaped raw. +#[test] +fn gate_maps_an_unexecutable_helper_to_error_not_ignored() { + let root = tempfile::tempdir().expect("tempdir"); + let repo = gate_with_stub_helper("#!/bin/sh\nexit 0\n", false); + let out = Command::new(repo.path().join("scripts/gate")) + .arg("--self-test") + .current_dir(repo.path()) + .env("PMACS_GATE_TARGET_ROOT", root.path()) + .output() + .expect("run the stub-worktree gate"); + let err = String::from_utf8_lossy(&out.stderr); + // The gate prints the raw probe status it saw. Carry it into every + // assertion message: this row failed on macOS with exit 1 where 2 + // was expected, and the log could not say which status produced it + // because the message discarded stderr. + assert_eq!( + out.status.code(), + Some(2), + "boundary failures map to 2; gate said:\n{err}" + ); + assert!( + err.contains("SIGINT guard boundary error"), + "the boundary has its own wording: {err}" + ); + assert!( + err.contains("token=missing"), + "and names the token state, not just the status: {err}" + ); + assert!( + !err.contains("SIGINT is ignored"), + "an unrunnable guard is not evidence about the signal: {err}" + ); +} + +/// A6/A6b/A6c, shell consumer: the shared vectors, asserting the exact +/// branch rather than only the exit code. +/// +/// `ValidatedError` and `Boundary` both exit 2, so comparing codes +/// alone would let a validator that accepts every status 2 pass. The +/// branch-discriminating cases emit a sentinel on stderr; a validated +/// verdict surfaces it, while X3/X4 carry dedicated payloads and a +/// boundary failure must withhold untrusted stderr. +#[test] +fn gate_validates_the_whole_shared_conformance_set() { + use common::sigint_conformance::{ + CANONICAL_IGNORED, Outcome, SENTINEL, shared_cases, stub_script, + }; + + let cases = shared_cases(); + assert_eq!(cases.len(), 45, "the shared set is 45 cases"); + + for case in cases { + let root = tempfile::tempdir().expect("tempdir"); + let repo = gate_with_stub_helper(&stub_script(&case), true); + let out = Command::new(repo.path().join("scripts/gate")) + .arg("--self-test") + .current_dir(repo.path()) + .env("PMACS_GATE_TARGET_ROOT", root.path()) + .env("TMPDIR", root.path()) + .output() + .expect("run the stub-worktree gate"); + let err = String::from_utf8_lossy(&out.stderr); + let name = &case.name; + + match case.expect { + Outcome::Safe => assert!( + !err.contains("REFUSING TO RUN"), + "case {name}: a validated safe pair must continue: {err}" + ), + Outcome::ValidatedIgnored => { + assert_eq!(out.status.code(), Some(1), "case {name}: {err}"); + assert!( + err.contains(SENTINEL), + "case {name}: a validated verdict surfaces the helper's \ + stderr: {err}" + ); + assert!(err.contains("token=valid"), "case {name}: {err}"); + } + Outcome::ValidatedError => { + assert_eq!(out.status.code(), Some(2), "case {name}: {err}"); + assert!( + err.contains(SENTINEL), + "case {name}: a validated error also speaks with the \ + helper's voice: {err}" + ); + assert!(err.contains("token=valid"), "case {name}: {err}"); + } + Outcome::Boundary => { + assert_eq!(out.status.code(), Some(2), "case {name}: {err}"); + assert!( + err.contains("SIGINT guard boundary error"), + "case {name}: {err}" + ); + assert!( + !err.contains(SENTINEL), + "case {name}: a boundary failure must NOT surface the \ + child's stderr --- this is what separates it from a \ + validated error, which shares its exit code: {err}" + ); + assert!( + !err.contains(CANONICAL_IGNORED), + "case {name}: and it must never repeat the canonical \ + ignored wording --- X3 emits exactly that on stderr \ + with no token: {err}" + ); + } + } + + // A8: no capture directory survives, on any path. `root` is RAII + // --- it is dropped at the end of this iteration. + let residue: Vec<_> = std::fs::read_dir(root.path()) + .expect("read tmpdir") + .filter_map(Result::ok) + .filter(|e| e.file_name().to_string_lossy().starts_with("pmacs-sigint.")) + .collect(); + assert!(residue.is_empty(), "case {name}: capture residue survived"); + } +} + +/// A8: the guard cannot create its capture directory. +/// +/// Bounded --- it never reaches a stage. `TMPDIR` points at a path that +/// does not exist, so `mktemp -d` fails and the guard must refuse +/// before running the helper at all. +#[test] +fn gate_refuses_when_the_capture_directory_cannot_be_created() { + let root = tempfile::tempdir().expect("tempdir"); + let repo = gate_with_stub_helper("#!/bin/sh\nprintf 'pmacs-sigint-v1:safe'\nexit 0\n", true); + let out = Command::new(repo.path().join("scripts/gate")) + .arg("--self-test") + .current_dir(repo.path()) + .env("PMACS_GATE_TARGET_ROOT", root.path()) + .env("TMPDIR", root.path().join("absent-directory")) + .output() + .expect("run the stub-worktree gate"); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "boundary error: {err}"); + assert!( + err.contains("capture directory"), + "the failure names what could not be created: {err}" + ); + assert!(err.contains("no stage has run"), "and no stage ran: {err}"); + assert!( + !String::from_utf8_lossy(&out.stdout).contains("[01]"), + "no stage may run" + ); + // A8 on this path too: the temporary root is inspected BEFORE its + // RAII drop, and must contain nothing the guard left behind. + let residue: Vec<_> = std::fs::read_dir(root.path()) + .expect("read tmpdir") + .filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + residue.is_empty(), + "a guard that could not create its capture directory must leave \ + nothing behind: {residue:?}" + ); +} + +/// Each helper arm emits its exact token on stdout. +#[test] +fn sigint_helper_emits_the_exact_token_for_each_arm() { + use common::sigint_conformance::{TOKEN_ERROR, TOKEN_IGNORED, TOKEN_SAFE}; + + let safe = Command::new(sigint_helper()).output().expect("run helper"); + assert_eq!(safe.status.code(), Some(0)); + assert_eq!( + safe.stdout, + [TOKEN_SAFE, b"\n"].concat(), + "the safe arm emits exactly its token plus one LF" + ); + + let erroring = Command::new(sigint_helper()) + .env("PATH", "") + .output() + .expect("run helper"); + assert_eq!(erroring.status.code(), Some(2)); + assert_eq!(erroring.stdout, [TOKEN_ERROR, b"\n"].concat()); + + // The ignored arm needs a shell that ignores SIGINT; assert its + // STDOUT, not merely its status and stderr. + let ignored = under_ignored_sigint(&sigint_helper(), &[], &repo_root(), &[]); + assert_eq!(ignored.status.code(), Some(1)); + assert_eq!( + ignored.stdout, + [TOKEN_IGNORED, b"\n"].concat(), + "the ignored arm emits exactly its token plus one LF" + ); +} + +/// §7c: the helper answers `safe` when `SIGINT` is deliverable. +#[test] +fn sigint_helper_reports_safe_when_the_signal_is_deliverable() { + let out = Command::new(sigint_helper()) + .output() + .expect("run the sigint helper"); + assert_eq!(out.status.code(), Some(0), "safe is exit 0"); + assert!( + out.stderr.is_empty(), + "the safe path is silent, so a clean run says nothing: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// §7c: the helper answers `ignored` — exit 1, canonical wording — when +/// `SIGINT` is inherited as `SIG_IGN`. +#[test] +fn sigint_helper_reports_ignored_when_the_signal_is_inherited_ignored() { + let out = under_ignored_sigint(&sigint_helper(), &[], &repo_root(), &[]); + assert_eq!(out.status.code(), Some(1), "ignored is exit 1"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("SIGINT is ignored"), + "the canonical ignored diagnosis is the helper's to own: {err}" + ); +} + +/// §7c: the helper answers `error` — exit 2, a DISTINCT diagnosis — when +/// the probe cannot decide. +/// +/// The probe shells out, so an empty `PATH` makes its inner `sh` +/// unfindable. This is the case a naive `exit 0` would misreport as +/// `ignored`, failing the caller for the wrong reason. +#[test] +fn sigint_helper_reports_error_and_never_ignored_when_the_probe_cannot_run() { + let out = Command::new(sigint_helper()) + .env("PATH", "") + .output() + .expect("run the sigint helper with no PATH"); + assert_eq!(out.status.code(), Some(2), "error is exit 2, never 1"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("could not determine"), + "error has its own wording: {err}" + ); + assert!( + !err.contains("SIGINT is ignored"), + "error must NOT be reported as ignored --- they are different \ + problems, and conflating them is the defect the helper exists \ + to avoid: {err}" + ); +} + +/// R-b: the gate refuses under ignored `SIGINT`, **before any stage**. +/// +/// This is the row whose absence let a real bug ship: the first +/// implementation ran the helper as a bare command under `set -e`, so +/// the shell died at the non-zero exit and the refusal never printed; +/// the second captured `$?` inside `if !`, which is the status of the +/// negated condition — always zero — so the gate printed the diagnosis +/// and then ran the whole suite anyway. Both passed every other test in +/// this file. +#[test] +fn gate_refuses_to_start_when_sigint_is_ignored() { + let root = tempfile::tempdir().expect("tempdir"); + // `--self-test`, NOT the ordinary gate. If the guard ever regresses, + // this row must not launch eight real gate stages inside the gate + // suite — the recursion constraint this file opens with. Self-test + // drives the same runner over a hardcoded synthetic plan, so the + // negative path stays bounded whatever the guard does. + let out = under_ignored_sigint( + &gate(), + &["--self-test"], + &repo_root(), + &[("PMACS_GATE_TARGET_ROOT", &root.path().display().to_string())], + ); + assert_eq!( + out.status.code(), + Some(1), + "the helper's verdict passes through" + ); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("SIGINT is ignored"), + "the gate surfaces the helper's stderr unchanged rather than \ + inventing its own wording: {err}" + ); + assert!( + err.contains("no stage has run"), + "and says the run is not a test failure: {err}" + ); + let combined = format!("{}{err}", String::from_utf8_lossy(&out.stdout)); + assert!( + !combined.contains("[01]"), + "NO stage may run --- the guard sits before stage 1: {combined}" + ); +} + #[test] fn the_plan_sweeps_the_workspace_and_never_only_the_tests() { let root = tempfile::Builder::new() diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 5a3bb88..27501d2 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -2,6 +2,8 @@ #![cfg(unix)] +mod common; + use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; @@ -171,6 +173,177 @@ mod crdt { let _ = kill(Pid::from_raw(pid.cast_signed()), signal); } + /// R-d (framing §7c): refuse to run this test when `SIGINT` is not + /// deliverable, and say so. + /// + /// The test signals a process group and requires the launcher to + /// exit. If `SIGINT` is inherited as `SIG_IGN` --- which a shell + /// running a command in the background without job control sets, + /// and which survives `fork` and `exec` --- the signal is a no-op + /// and the launcher waits out the deadline. Without this the + /// failure reads "child did not exit within 5s", which names a + /// teardown defect that is not there; that misreading cost nine + /// framing revisions (§4c). + /// + /// Both consumers use the **same checked-in helper**, but that alone + /// no longer makes them agree: each validates the + /// `(status, token)` pair independently, in a different language. + /// Revision 12's "they can never disagree" is withdrawn, and the + /// shared matrix in `tests/common/sigint_conformance.rs` replaces + /// it — both validators run the same vectors. + /// + /// This consumer proceeds only on a validated `(0, safe)` pair. On a + /// **boundary** failure the helper's stderr is untrusted and is + /// withheld; only a validated verdict speaks with the helper's + /// voice. + const SIGINT_TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe"; + const SIGINT_TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored"; + const SIGINT_TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error"; + + /// Does `stdout` carry exactly `token`, in one of the two permitted + /// encodings? Grammar is `TOKEN | TOKEN LF` **as bytes** — no + /// trimming, so a leading newline, a second newline, surrounding + /// spaces, CRLF, a doubled token or a trailing NUL all fail. + fn sigint_token_matches(stdout: &[u8], token: &[u8]) -> bool { + stdout == token + || (stdout.len() == token.len() + 1 + && stdout.starts_with(token) + && stdout[token.len()] == b'\n') + } + + /// The diagnosis for one helper invocation, validating the + /// `(status, token)` pair rather than the status alone. + /// + /// A status arriving without its token did not come from this + /// helper — not hypothetical: on macOS a shell that cannot execute + /// the helper exits 1, which a status-only ABI read as `ignored` + /// (§4d). Rust compares `Command::output()` bytes directly; only + /// the shell consumer needs capture files. + fn sigint_diagnosis(helper: &Path) -> Result<(), String> { + let out = match Command::new(helper).output() { + // Rust's boundary differs from the shell's: a spawn error + // has NO status, where a shell turns the same failure into + // one. Conformance X2, Rust-only. + Err(error) => { + return Err(format!( + "precondition undecidable --- SIGINT guard boundary error \ + (status=unavailable token=missing): could not execute {}: {error}", + helper.display() + )); + } + Ok(out) => out, + }; + let expected: &[u8] = match out.status.code() { + Some(0) => SIGINT_TOKEN_SAFE, + Some(1) => SIGINT_TOKEN_IGNORED, + Some(2) => SIGINT_TOKEN_ERROR, + _ => b"", + }; + let token_ok = !expected.is_empty() && sigint_token_matches(&out.stdout, expected); + let token_state = if out.stdout.is_empty() { + "missing" + } else if token_ok { + "valid" + } else { + "unexpected" + }; + let status = out + .status + .code() + .map_or_else(|| "signal".to_owned(), |c| c.to_string()); + match (out.status.code(), token_ok) { + (Some(0), true) => Ok(()), + // A VALIDATED verdict: the helper's stderr is the diagnosis. + (Some(1 | 2), true) => Err(format!( + "precondition failed --- this is NOT a teardown defect. \ + (status={status} token={token_state})\n{}", + String::from_utf8_lossy(&out.stderr).trim_end() + )), + // BOUNDARY: the child's stderr is UNTRUSTED and is not shown, + // or a helper exiting 1 with no token but the canonical + // ignored wording would still mislead the reader (A6b). + _ => Err(format!( + "precondition undecidable --- SIGINT guard boundary error \ + (status={status} token={token_state}). The helper's own \ + output is not trusted here and is not shown." + )), + } + } + + fn sigint_helper_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-sigint-deliverable") + } + + fn require_sigint_deliverable() { + if let Err(diagnosis) = sigint_diagnosis(&sigint_helper_path()) { + panic!("{diagnosis}"); + } + } + + /// A6/A6b/A6c, R-d consumer: the shared vectors, asserting the exact + /// branch. + /// + /// `ValidatedError` and `Boundary` both produce `Err`, so comparing + /// `is_ok()` alone would let a validator that accepts every status 2 + /// pass. The branch-discriminating cases emit a sentinel on stderr; + /// a validated verdict surfaces it, while X3/X4 carry dedicated + /// payloads and a boundary failure must withhold untrusted stderr. + #[test] + fn rd_precondition_validates_the_whole_conformance_set() { + // `super::` and NOT `crate::`: this file is ALSO compiled as a + // nested module of `gpu_initial_target_acceptance.rs`, where + // `crate::` is the outer test crate and has no `common`. + use super::common::sigint_conformance::{ + CANONICAL_IGNORED, Outcome, SENTINEL, shared_cases, stub_script, + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let cases = shared_cases(); + assert_eq!(cases.len(), 45, "the shared set is 45 cases"); + + for case in cases { + let path = dir.path().join(case.name.replace('/', "_")); + fs::write(&path, stub_script(&case)).expect("write stub"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod"); + let got = sigint_diagnosis(&path); + let name = &case.name; + match case.expect { + Outcome::Safe => assert!(got.is_ok(), "case {name}: {got:?}"), + Outcome::ValidatedIgnored | Outcome::ValidatedError => { + let message = got.expect_err("a validated refusal"); + assert!( + message.contains(SENTINEL), + "case {name}: a validated verdict surfaces the helper's stderr: {message}" + ); + assert!(message.contains("token=valid"), "case {name}: {message}"); + } + Outcome::Boundary => { + let message = got.expect_err("a boundary refusal"); + assert!(message.contains("boundary error"), "case {name}: {message}"); + assert!( + !message.contains(CANONICAL_IGNORED), + "case {name}: never repeats the canonical ignored wording \ + --- X3 emits exactly that with no token: {message}" + ); + assert!( + !message.contains(SENTINEL), + "case {name}: a boundary failure must NOT surface the child's \ + stderr --- that is what separates it from a validated error, \ + which shares its outcome type: {message}" + ); + } + } + } + + // X2 --- Rust only: a shell exec failure becomes a status, so the + // shell consumer cannot present a status-less spawn error. + let message = sigint_diagnosis(&dir.path().join("absent")).expect_err("must not validate"); + assert!( + message.contains("status=unavailable") && !message.contains(SENTINEL), + "X2: {message}" + ); + } + fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus { let deadline = Instant::now() + timeout; loop { @@ -1083,6 +1256,7 @@ mod crdt { #[test] fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() { + require_sigint_deliverable(); let temp = secure_tempdir(); let socket = temp.path().join("signal.sock"); let report = temp.path().join("signal-report"); 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"),