Merge pull request #242 from levineuwirth/panel-mapping-generation

Panel cell-mapping generation (v25) — §5b
This commit is contained in:
Levi Neuwirth 2026-08-20 17:25:01 +00:00 committed by GitHub
commit 47b54631f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 5419 additions and 112 deletions

View File

@ -270,6 +270,558 @@ hazard in a shape that looks committed. **A documented error message
that never appears is worse than no documentation**, because the reader that never appears is worse than no documentation**, because the reader
waits for a signal that is not coming. waits for a signal that is not coming.
## Panel cell-mapping generation (v25) — REBASED, FULL GATE GREEN, review round 4 fixed
- **BASE IS `5f2015c`**, current `githubsucks/main`. Rebased there
cleanly, no conflicts; the two commits picked up are docs-only (the
`rd_precondition` occurrence record and the handoff's `timeout`
rule).
- The **first** rebase, onto `f13506c` (post-#241 main), took one
conflict in this file: main's merged-#241 block and this lane's own
first-commit block landed at the same position; both kept, active
lane above merged. No code file has overlapped across either rebase —
#241 touched `scripts/`, `tests/common/` and two test suites; this
lane touches the protocol, daemon and GPU sources.
- **NO AHEAD-COUNT OR TIP SHA IS RECORDED HERE, deliberately.** Both
were wrong within one commit the last two times they were written,
because the commit that updates this file is itself the commit that
invalidates them. Read them instead:
`git log --oneline githubsucks/main..HEAD | wc -l` and
`git rev-parse HEAD`. Immutable EVENT shas — the rebase, a fix, a
merge — are recorded; the moving tip is not.
- **REVIEW ROUND 4 (2026-08-20) — three blockers, all fixed.**
- **FUNCTIONAL: rejected gestures mutated the accepted-gesture
latch.** Both inbound arms discarded the bool from
`dispatch_semantic_panel_pointer` and called
`update_accepted_gesture` unconditionally. The ladder authenticates
the SENDER; only the dispatcher re-derives the TARGET, so an event
can clear every rung and still be refused for an out-of-grid
coordinate, an absent side window, or a buffer that is no longer
the one in the side window. **A rejected `Down` therefore ARMED** —
so a later authority loss counted a cancellation for a gesture that
never began, and once replay attaches effects it would release a
child that was never pressed. **A rejected `Up` CONSUMED a real
armed gesture** — so the authority loss that should have ended it
found nothing, and that child holds the button down for good. A
rejected `Down` on top of a live gesture was worse still:
`arm_accepted_gesture` ends what it overwrites, so it also counted
a spurious cancellation.
- **Fixed** by gating both arms on the return, and by marking the
dispatcher `#[must_use]` so the class cannot recur silently —
clippy runs with `-D warnings`, so a future discarded answer is a
build failure rather than a review finding.
- **Four new rows**, `g5_substrate_a_refused_{press_never_arms,
release_never_consumes}_on_the_{legacy,mapped}_arm`. Each drives
the refusal from a coordinate ONE PAST the last grid row and **ends
in a positive control differing only in that coordinate** —
without it a row would pass just as well if an unrelated rung had
dropped the event, which is how a negative test of this shape rots.
Mapped rows read the generation through the validator's own
accessor, so a mapping-rung refusal cannot masquerade as a
dispatcher refusal. **Three mutations, each biting its named rows:**
ungating the legacy arm fails exactly the two legacy rows, ungating
the mapped arm exactly the two mapped rows, and relaxing the
dispatcher's `>=` to `>` fails all four.
- **DOCS: committed conflict residue** — an orphaned diff3
`|||||||` ancestor line survived an earlier resolution in this
file, with the other three markers removed and both sides' content
kept. Deleted; the section below it was the only copy.
- **THE GATE CANNOT SEE THAT CLASS, and this is not fixed here.**
`git diff --check` compares the WORKTREE against the index, so a
marker that is already committed passes a clean-worktree gate
forever. `git diff --check githubsucks/main...HEAD` exits 2 on it.
Widening the gate's check to the branch range is a **candidate
lane** — a gate change owes a framing doc, and it should not ride
in on a §5b review round.
- **The blocker is gone.** #241 fixed the `sweep-crdt` defect this lane
was held behind, and the SIGINT guard now refuses a gate run started
with `SIGINT` ignored — which is what made stage 15 unreachable.
- **FULL 16-STAGE GATE GREEN**, log `20260820T153017Z-147411`, one
unsplit `./scripts/gate --protocol` run with all six touched
acceptance suites. `sweep-crdt` reports **zero** failures.
- **THE TWO `m4_24_*` FAILURES WERE MY OWN MEASUREMENT ERROR, and the
"CI is the arbiter" framing built on them is WITHDRAWN.** I had run
the CRDT sweep OUTSIDE `scripts/gate`, so it inherited
`TMPDIR=/tmp` instead of the gate's isolated one, and I then reported
the local sweep as unable to go green for pre-existing reasons. It
goes green. CI never needed to substitute for it.
- **MECHANISM, and it is the exact hazard #240 fixed:** `/tmp/.git`
exists on this machine (an empty directory, 2026-08-15). Project
detection **walks upward**, so every `tempfile::tempdir()` under
`/tmp` inherits `/tmp` as a project root. Both failing rows are
base-resolution rows — `m4_24_bare_string_glob_stays_relative` and
`m4_24_d3_fallback_base_is_the_smallest_attachment_dir` — so both
resolve against the wrong base and fail. CI's `/tmp` carries no
such marker, which is why they passed there.
- **The gate's `TMPDIR` is disk-backed under `<gate-root>/tmp/` on
btrfs, in a directory the gate owns**, with no stray ancestor.
Running the sweep by hand defeats exactly the isolation #240 built
for this signature.
- **`/tmp/.git` IS STILL THERE and is not mine to delete** — it is
outside the repo and I did not create it. Any hand-run test that
resolves a project root from a temp dir will keep failing on this
machine until it is removed.
- **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
<pattern>` 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`) ## GPU launcher / probe SIGINT teardown — MERGED as #241 (`f8033bc`)
- **MERGED 2026-08-20** at approved head `5089715`, merge commit - **MERGED 2026-08-20** at approved head `5089715`, merge commit

View File

@ -1770,6 +1770,800 @@ cannot preserve the old panel-focused attach leak.
preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR
then runs the full gate suite. then runs the full gate suite.
## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3)
**Status: revision 16 — APPROVED 2026-08-15. Nothing implemented.**
Revision 16 answers review of 15; three of its items reverse a rule 15
introduced:
- **The mapped-generation producer rule contradicted proactive
cancellation.** Since the daemon cancels *before* emitting that
successor frame, the frontend simply **clears its latch on accepting
the frame and sends nothing**. 15's "emit a tail or retain the
latch" was redundant in the first case and **actively harmful** in
the second — retaining it manufactures a `Drag` under the new
generation with no accepted `Down`, the exact orphan the section
exists to prevent. Other authority losses have their own reset
signals below.
- **The latch had one trigger and needed five.** Cancellation runs on
every loss of gesture authority: generation advance, `Absent`, panel
epoch or buffer identity change, geometry-epoch change **even at an
unchanged cell total**, and detach. The last four apply to the legacy
and mapped families alike; generation advance exists only in the
mapped family. **An ordinary accepted `Up(Left)` must clear the
latch**, or a later invalidation synthesises a duplicate release.
- **"The replacement frame" was not one producer signal.** A mapped
generation or panel/buffer replacement clears on atomic acceptance of
its valid frame; `Absent` clears when accepted; a local geometry
declaration and detach clear at their own transition, before any
replacement frame can be required. Invalid frame payloads and
same-identity repaints retain the latch.
- **Gesture liveness and click counting are different state.** The
accepted-gesture latch covers an accepted `Down(Left)` only and is
consumed by `Up(Left)` or loss of authority. `MouseClickState`
deliberately survives an ordinary `Up` for multi-click detection,
but its mapping identity must be cleared or re-keyed on every mapping
invalidation even when no gesture is held.
- **Routing controls had been written as effects.** This base can prove
legacy and mapped events reach the already-landed focus path; it
cannot prove selection, terminal reporting, drag continuation,
two-tick scrolling, or exhaustion cleanup before replay exists.
Those controls are split, and the effect halves are obligations of
the rebased replay lane rather than claims made by this slice.
- **Bilateral gating needed all four wrong-family legs.** The v25
refusal rows did not constrain a v24 session sending or accepting the
mapped variants. Same-crate peers can encode them regardless of what
they negotiated, so both inbound and outbound are now witnessed at
v24 and v25, with exact-family positive controls.
- **G9b's mutation was a valid implementation.** Keying dedupe by
`(mapping_generation, coord)` is a correct design, so requiring it to
fail would have forbidden a good one. Replaced with two real defects.
- **"Projected cell identity" contradicted the styling control** — wire
`Cell` equality includes `style`. Terminal identity is now glyph and
row topology plus the view anchor, **excluding face, style and
cursor**, with a same-glyph/different-style control.
- Zero-generation rows added **in both directions**; G7 split into
outbound and inbound legs; G2's grid and fold composites split;
journey steps **5** and **8** named in §20.
- **The folded closure pass made the remaining implicit contracts
executable in one matrix:** authenticated-session family authority;
a frontend generation high-water mark surviving `Absent`; exact pins
for the new variants as well as their previous finals; left-only and
fixed-domain terminal gestures; exact-once coincident invalidations;
independent click identity; the semantic/TUI boundary; and explicit
ownership of every route versus effect row (G0–G15).
**Previously, revision 15 — SUPERSEDED.**
Revision 15 answers review of 14, and four of its items reverse
something 14 asserted:
- **"Beside `Present`/`PanelPointer`" was positionally dangerous.**
Appended means **last**: `PresentMapped` after **`Absent`**,
`PanelPointerMapped` after **`TextInput`**. Exact field order, `u64`,
**zero invalid**, and `PANEL_MAPPING_MIN_VERSION` are now stated.
- **Blanket refusal stopped the wheel after one tick.** The first
effective wheel advances the key and the next queued tick carries the
old one. **Coordinate-free** gestures — document wheel, local
scrollback — are **exempt**; **child-reported** wheels keep the
check, because SGR carries row and column. Two-tick witnesses added.
- **Cancellation is now PROACTIVE and has a latch.** Reactive
cancellation loses a race: a replacement frame arriving before the
physical `Up` makes the producer suppress the very event that would
have cancelled. It triggers on the key advancing, and the daemon
keeps an accepted-gesture latch — was the `Down` accepted, did it
reach the child, with which coordinate, button and encoding. A stale
`Up` with no accepted `Down` is inert, and cancellation never
reclaims a controller another frontend took.
- **The existing screen generation cannot be the terminal key** — it
advances from 39 sites including style, bell, tab-stops and
cursor-only motion. A **dedicated terminal mapping revision** is
defined, with those events as **stable controls**.
- **G5's effects moved to the rebased replay lane**, which is the only
branch where they exist; **G5a stays here**, so the proactive rule
keeps a witness in the slice that introduces it.
- **The `PanelPointer` pin exists** (`src/protocol.rs:1975`) — revision
14's claim that it could not be found was wrong.
**Previously, revision 14 — SUPERSEDED.** This
slice **blocks** panel-pointer replay (`panel-pointer-replay`), which
blocks GUI arc 1b. It is protocol-bearing and runs alone.
### What it fixes
A `PanelPointer` names a **cell**; the daemon must invert that to a
**byte**. Nothing on the wire says which inverse mapping the frontend
was looking at, so the daemon inverts against whatever is current — and
the mapping can move for reasons the clicking frontend neither caused
nor can observe.
This is the one hole in the ladder: `buffer_id` catches replacement,
`panel_epoch` catches close/reopen, `geometry_epoch` catches a
declaration race, and **nothing catches "the text under that cell
changed"**.
The hole exists only across the **semantic frontend's asynchronous
projection/inbound seam**. The TUI hit-tests and dispatches against its
current daemon state in one process; it neither receives `PanelFrame`
nor echoes a mapping generation. This slice adds no TUI token, gate or
gesture state, and the existing direct TUI panel effects remain a
regression control. Applying the mapped-family check inside the shared
replay body instead of at the semantic-session boundary would break
that control.
**Revision 12 accepted this as narrow. It is not.** A **foreign** edit
moves the mapping with `view_top` untouched; ticks, paging, folds,
edits and reloads accumulate without bound; and the window lasts until
the frontend **presents** the replacement frame, which a backed-up
frontend widens arbitrarily.
### The generation, and why not a token
**A per-frame token is the wrong object.** Panels repaint constantly,
and a token that moved with the frame would invalidate a live drag on
the next repaint — the mistake `panel_epoch` is stable to avoid.
**`mapping_generation` identifies the INVERSE MAPPING.**
| changes it | leaves it alone |
|---|---|
| `view_top` | focus gained or lost |
| **`view_left`** — 1b makes horizontal scrolling real | styling, theme, face changes |
| panel grid size | selection-only changes |
| fold state | a re-emitted identical frame |
| wrap mode, gutter geometry | **cursor motion that moves nothing else** |
| **buffer content — any edit, from any source** | |
| **terminal output or scrollback movement** (terminal panels) | **terminal buffer revision** (see below) |
**"Cursor movement is stable" is CONDITIONAL, and revision 13 stated it
flatly.** A cursor move that triggers vertical or horizontal follow
changes `view_top` or `view_left`, and therefore **does** change the
generation. The stable case is a cursor move the follow rules absorb.
**Terminal panels need their OWN mapping revision, and the existing
screen generation cannot supply it.** What a coordinate denotes there
is decided by the terminal's screen — so output and scrollback movement
change the mapping, while the buffer's revision counter tracks
something else entirely.
But `Screen::changed()` is **not** that signal.
(`src/terminal/screen.rs:1467`) bumps a single generation from **39
call sites**, including `SetStyle` (`:270`), `Bell` (`:287`), the
tab-stop operations (`:309`–`:319`), cursor-only motion (`:324`) and
`SetTitle` (`:449`). **None of those changes what a coordinate
denotes**, and keying on it would cancel a drag every time the child
recoloured a character or rang the bell.
**Define a dedicated terminal mapping revision** over the things that
actually decide the inverse:
- **glyph and row TOPOLOGY** — which glyphs occupy which cells, and
which rows the projection holds;
- **retained-row identity** — scrollback rows entering or leaving the
projection;
- **the per-view scroll anchor**.
**Explicitly EXCLUDING face, style and cursor**, and revision 15's
"projected cell identity" got this wrong. The wire `Cell` derives
`PartialEq` over `glyph`, **`style`** and `attachment`
(`pmacs-protocol/src/cell.rs:153`), so an identity keyed on cell
equality **moves on a pure recolour** — which contradicts the very
stable control that rules style out, in the same section. The two
statements could not both hold.
**Control row: same glyphs, different style** — the child repaints the
projection in a new colour and the revision **does not move**, so a
drag survives it. That is the row that catches an implementation
reaching for `Cell` equality because it is right there.
**Stable controls are required, not optional**: style, title, bell,
tab-stop and cursor-only operations each get a row asserting the
revision **does not move**. Those are precisely the events the
convenient existing counter would have caught, so a reader who later
reaches for it fails a test instead of shipping a cancelled drag.
**The stability half is load-bearing, not an optimisation.** A drag
provokes selection repaints on every motion; a generation that moved
with them would cancel the drag after one step.
### Ownership and refresh timing
**One authoritative per-frontend mapping key**, owned by the daemon,
**used by both projection and inbound validation**. Not two derivations
that agree by inspection.
- It **advances after any mapping mutation and BEFORE the next inbound
pointer is handled**, whether or not a frame has been rendered or
emitted since.
- **Comparing against the last EMITTED frame recreates the hole.** A
mutation that has not yet been painted still changes the inverse
mapping, and a gesture arriving in that gap must be refused.
- Projection stamps the frame with the same key it validates against,
so "what the frontend was shown" and "what the daemon checks" cannot
drift.
### Wire shape — appended at the END, with the field order stated
Postcard encodes enums **positionally**, so a shipped variant's field
list is frozen **and so is every discriminant**. Revision 14 said
"beside `Present`" and "beside `PanelPointer`", which is **positionally
dangerous** — "beside" reads as *adjacent*, and inserting adjacent to
an existing variant shifts every discriminant below it. **Appended
means LAST.**
```text
PanelFramePayload FrontendEvent
0 Present(PanelFrame) …
1 Absent n-1 PanelPointer (v21)
2 PresentMapped <- NEW n TextInput (v24)
n+1 PanelPointerMapped <- NEW
```
`PresentMapped` goes **after `Absent`**, not after `Present`;
`PanelPointerMapped` goes **after `TextInput`**, not after
`PanelPointer`.
**Exact shapes**, field order frozen on landing:
```rust
PanelFramePayload::PresentMapped {
frame: PanelFrame, // unchanged, reused whole
mapping_generation: u64,
}
FrontendEvent::PanelPointerMapped {
frontend_id: FrontendId, // untrusted, as every inbound variant
geometry_epoch: u64,
panel_epoch: u64,
buffer_id: BufferId,
coord: CellCoord,
kind: MouseKind,
mods: Modifiers,
mapping_generation: u64, // appended last within the variant
}
```
`PanelPointerMapped` deliberately mirrors `PanelPointer`'s field order
with the generation **appended**, so the two are diffable by eye and a
future reader can see that nothing was reordered.
- **`mapping_generation` is `u64`.**
- **Zero is INVALID** and is refused like a mismatch: it is the value a
default-constructed or partially-initialised sender produces, and
accepting it would let a peer opt out of the check by sending
nothing. A live key starts at 1.
- It is **monotonic for the authenticated frontend session**. A
frontend accepts a valid generation above its retained high-water
mark (gaps are fine), and accepts an equal-generation repaint because
styling, focus and selection may legitimately change while the
mapping holds. It rejects a lower generation atomically. `Absent`
clears the presentation and pointer state but **retains the
high-water mark**; otherwise a delayed pre-`Absent` frame can roll
authority backward. Detach ends the session and its high-water mark.
- **`PANEL_MAPPING_MIN_VERSION = 25`**, beside the existing family
constants, and the gate reads that constant rather than a literal.
`Absent` is unchanged and **common to both families** — hiding a band
carries no mapping.
### Bilateral gating — REFUSAL, not fallback
Revision 13 said an unmapped event from a new peer is "handled under
the old semantics". **That is a bypass**: it leaves the exact hole the
slice exists to close, reachable by omitting a field.
| negotiated | daemon sends | daemon accepts | frontend sends | frontend accepts |
|---|---|---|---|---|
| **≤ v24** | `Present` | `PanelPointer` | `PanelPointer` | `Present` |
| **≥ v25** | `PresentMapped` | `PanelPointerMapped` **only** | `PanelPointerMapped` | `PresentMapped` **only** |
Both choices use the **authenticated session's negotiated version**.
Inbound code never derives the family gate from the payload's claimed
`frontend_id`; like every inbound variant, that field is untrusted and
is validated separately. Outbound projection uses the destination
session's negotiated version, not the crate's compile-time ability to
encode either discriminant. The family gate runs before focus,
controller, click-chain, gesture-latch or document mutation.
- A **≥ v25 session sending bare `PanelPointer` is REFUSED**, dropped
before any mutation, exactly as an out-of-epoch event is.
- A **≥ v25 frontend receiving legacy `Present` REJECTS it** rather
than painting a band it cannot safely hit-test.
- Only a negotiated **≤ v24** session retains legacy semantics.
- `Absent` is accepted from either family.
### Enforcement, and the liveness it must not break
On `PanelPointerMapped`, the daemon compares the echoed generation with
the authoritative key and refuses the gesture before any mutation when
they differ.
#### The wheel exception — or scrolling stops after one tick
**A blanket refusal breaks the wheel, and revision 14's was blanket.**
The first effective document wheel changes `view_top`, which *is* a
mapping change, so the key advances. A second wheel event already
queued behind it carries the **old** generation and would be refused —
**the panel scrolls exactly one tick and then goes dead** until the
frontend observes the new frame. Local terminal scrollback has the same
shape, moving its view anchor.
The discriminator is **whether the gesture uses its coordinate**:
| gesture | uses `coord`? | generation check |
|---|---|---|
| document wheel | **no** — `scroll_window` is window-level | **EXEMPT** |
| terminal wheel, **local scrollback** (not reporting) | **no** — moves the view anchor | **EXEMPT** |
| terminal wheel, **child-reported** | **yes** — SGR carries row and column | **REQUIRED** |
| every press, drag, release, context gesture | yes | **REQUIRED** |
**Coordinate-free gestures cannot be mis-aimed by a stale mapping**,
because they never invert a cell. Refusing them buys nothing and costs
the feature. A **child-reported** wheel is the opposite case: it puts a
row and column into the child's input stream, so a stale one aims an
application action at a cell the user never pointed at — the same
hazard as a stale click, and it keeps the check.
The exemption skips **only generation equality**. It does not bypass
the authenticated family gate, the nonzero-generation requirement,
frontend/panel/geometry/buffer identity, coordinate bounds, or target
resolution. In particular, a zero-generation coordinate-free wheel is
still refused; zero means "no mapping identity was supplied", not "one
was supplied and has since gone stale".
**Witnesses: two ticks under ONE generation.** A document wheel and a
non-reporting terminal wheel must each scroll **twice** when the second
event carries the generation the first invalidated. Without a
two-tick row, a blanket-refusal implementation passes every
single-event row in the matrix.
**But a blanket drop breaks liveness, and revision 13's did.** A
refused **tail** is not the same as a refused **beginning**:
- **Stale BEGINNINGS may simply drop.** A `Down` that never took effect
leaves nothing behind.
- **Stale TAILS must TERMINATE the gesture, not vanish.** A dropped
`Up` leaves an empty document selection armed with a stale anchor,
and leaves a **reporting terminal child holding a button forever**.
**Cancellation is therefore ruled as a first-class outcome — and it is
PROACTIVE, driven by the key advancing, not reactive to a refused
event.**
Revision 14 made it reactive, and that has a race it cannot win: if the
replacement mapped frame reaches the frontend **before** the physical
button comes up, the producer resets `pointer_held`, **suppresses its
own `Up`**, and the daemon is never told anything — so the selection
stays armed and the child keeps holding its button. **The cancelling
event never arrives.**
So the daemon cancels at the transition that revokes authority, not at
the next pointer event:
| authority loss | daemon cancellation point | protocol families |
|---|---|---|
| mapping generation advances | after the mapping mutation and **before** publishing the next `PresentMapped` | mapped, ≥ v25 only |
| `Absent` | **before** publishing `Absent` | legacy and mapped |
| panel epoch or buffer identity changes | before publishing the successor `Present`/`PresentMapped` | legacy and mapped |
| geometry epoch changes | when the new frontend geometry declaration is accepted, even when its rows and columns equal the old declaration | legacy and mapped |
| detach | in that frontend's teardown, before its pointer state is discarded | legacy and mapped |
If an accepted left-button gesture is live, cancellation is taken
**exactly once**:
- **document:** clear an empty selection, preserve an already-nonempty
region, and apply no cursor move;
- **terminal:** deliver the child's **release**, at the last coordinate
known valid, with the left button and encoding the accepted press
used.
The click chain is invalidated at the same authority transitions but is
**not conditional on a live gesture**; its lifetime is specified
separately below.
Authority losses may coincide. A changed-size geometry declaration, for
example, advances both `geometry_epoch` and the mapping generation;
buffer/panel replacement can also change the projected mapping. All
causes take the **same** accepted-gesture latch, so the first
cancellation settles it and later causes observe no live gesture.
Click-chain invalidation is likewise idempotent. Trigger ordering may
not create two releases or two selection cleanups.
**Revision 15 asked the producer to emit a cancellation tail or retain
the latch. Both are wrong, and the second is actively harmful.** A tail
is redundant — the daemon cancelled first and would receive a release
for a gesture it has already settled, which is the duplicate release
the latch exists to prevent. **Retaining the latch is worse: it
manufactures a `Drag` under the NEW generation with no accepted
`Down`** — precisely the orphan this whole section is about, created by
the rule meant to avoid it.
**There is no single producer-side "replacement frame" transition.**
The producer clears its local held-gesture state at the first
authoritative signal available for the cause:
| cause | producer reset point |
|---|---|
| mapped generation changes | atomically accepting the valid successor `PresentMapped` |
| panel epoch or buffer identity changes | atomically accepting the valid successor `Present`/`PresentMapped` |
| `Absent` | accepting `Absent` |
| geometry epoch changes | locally, when `next_geometry_declaration` advances the epoch (`pmacs-gpu/src/main.rs:6847`), **before** sending the declaration |
| detach | frontend teardown |
An invalid frame and a repaint with the same generation and identities
**do not clear** the producer latch. A local geometry declaration is
different: advancing the local epoch has already revoked the old grid,
so the producer clears before the daemon can accept or refuse the
declaration. For the daemon-authored frame cases, ordering is cancel
then emit; the frame's arrival is the producer's signal, so no second
channel is needed. For geometry and detach, the local transition is
itself the signal and no replacement frame is required.
After any reset, subsequent motion and the physical `Up` produce **no
new drag and no duplicate release**. The producer never emits a
cancellation tail and never carries the held state into the successor
identity.
##### The latch's full lifecycle
**Cancellation runs on EVERY loss of gesture authority, not only a
mapping-generation advance.** Revision 15 named one trigger and left
the rest to be inferred:
| trigger | why it ends the gesture |
|---|---|
| **mapping generation advances** | the cells mean different bytes |
| **`Absent`** | the band the gesture belongs to is gone |
| **panel epoch or buffer identity change** | the successor never saw the press |
| **geometry-epoch change** | a new declaration, **even when the cell total is unchanged** — the frontend re-declared, so the grid it hit-tested against is not the one in force |
| **detach** | there is no frontend left to finish the gesture |
**And an ordinary accepted `Up` MUST clear the latch.** Otherwise a
later invalidation finds a gesture it believes is still live and
**synthesises a duplicate release** for a button already up. That is
the same orphan race the replay lane's D1/D2 producer resets expose,
arriving from the daemon's side instead of the frontend's.
**The daemon needs an ACCEPTED-GESTURE LATCH** to do any of this, and
revision 14 assumed state that does not exist. The latch is common to
legacy `PanelPointer` and mapped `PanelPointerMapped`, because four of
the five authority losses are common. Per frontend it records:
- whether a **`Down(Left)` was ACCEPTED**, and for a terminal whether
it actually reached the child;
- the last valid coordinate and the encoding the child was told — a
release must match the press it terminates;
- enough identity to know the gesture is still the one that began.
It arms only **after** `Down(Left)` is accepted, updates its last valid
coordinate on an accepted drag, and is taken by either an ordinary
accepted `Up(Left)` or one authority-loss transition. `Move`, wheel,
context, and right/middle press events never arm it. `MouseKind` has
right and middle `Down` variants (`pmacs-protocol/src/message.rs:220`),
and the GPU panel path currently emits `Down(Right)` from the
press-only `apply_right_press` route
(`pmacs-gpu/src/main.rs:2997`, `:3029`). Treating every `Down` as held
would therefore manufacture a delayed release at the next
invalidation. If right/middle release semantics are added, they need
their own complete producer/replay contract rather than silently
borrowing this left-drag latch.
For a terminal, the accepted `Down(Left)` also fixes the gesture's
**domain** until it ends: child-reported, or local terminal selection.
Shift precedence and the child's reporting mode decide the domain at
the beginning; later modifier or mode changes do not switch a live
gesture into a path that never saw its Down. Thus a child-reported
gesture sends Drag/Up using the recorded reporting contract and exact
release encoding even if reporting turns off or Shift is pressed, and
a locally-started gesture stays local even if Shift is released or
reporting turns on. This does not freeze wheel precedence: a wheel is
self-contained and never arms the gesture latch.
Two consequences fall out of the latch and are ruled here:
- **A stale `Up` with no accepted `Down` is INERT.** It terminates
nothing, because nothing began; it must not synthesise a release or
clear another gesture's state.
- **Cancellation never reclaims a controller another frontend has since
taken.** The release settles *this* gesture; if ownership moved on,
the claim is not taken back. Reclaiming would make a stale gesture
steal a live one's terminal.
Detach has an additional ordering constraint: if the departing
frontend still owns the controller, its child's matching release is
delivered **before** normal detach teardown releases that claim and
discards the gesture record. If another frontend owns it, the release
still settles the recorded gesture without changing that newer claim.
Clearing controller/gesture state first would make the teardown path
look clean while leaving the child button down.
**A cancelled gesture is not a replayed one**: the release is delivered
for liveness, and no selection or scroll effect is applied from the
stale event.
##### Click-chain identity is independent
`MouseClickState` (`src/editor.rs:352`) is **not** the
accepted-gesture latch. An ordinary
`Up(Left)` consumes the gesture latch, while click state intentionally
survives long enough to recognize a second click. Consequently, only
clearing click state from the cancellation path is insufficient: after
a completed click there may be no live gesture to cancel.
The click chain therefore carries, or is cleared against, the complete
mapping identity: frontend, target window and buffer, panel epoch,
geometry epoch, and — for mapped sessions — mapping generation. Every
authority transition in the table above invalidates it independently
of the gesture latch. A completed click followed by a foreign edit and
the successor mapped frame, then a `Down` at the same cell inside the
multi-click interval, is a **single** click under the new mapping, not
a double-click on unrelated text. The same rule holds for the common
legacy identity transitions even though v24 has no mapping generation.
This extends rather than replaces Q#M5 and the existing TUI event
lifetime: only an **unmodified `Down(Left)`** writes click state;
Shift-Down never enters the double-click chain. Drag, Move, wheel and
right/context gestures clear it, while ordinary `Up(Left)` and a
same-identity repaint preserve it. Mapping invalidation is an
additional clear, not permission to couple click state to every frame
or to the accepted-gesture latch.
Semantic click chains are **keyed per frontend**, then carry the target
identity above. Merely storing `frontend_id` inside one global slot
prevents a false cross-frontend double-click but lets B's click erase
A's legitimate chain. The adjacent `window_drag` comment already names
that exact defect and uses a `HashMap<FrontendId, ..>` 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<MouseClickState>` 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) ## 6. Deferred (named)
Left / right / top side windows; multiple slots per side; **rehoming a leaf Left / right / top side windows; multiple slots per side; **rehoming a leaf

View File

@ -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` | | **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 | | **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`) | | **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 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** | | **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 | | **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 | | **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 **Not attributed to the observing lane**, and in neither case is the
reasoning merely "my diff looks unrelated": long-lines Stage 4 added no reasoning merely "my diff looks unrelated": long-lines Stage 4 added no
wire surface, no protocol version change, and touched no file in wire surface, no protocol version change, and touched no file in
@ -866,3 +893,39 @@ claim is the one a later reader would otherwise reach for.*
| **relation to U2 — a NEAR MISS, do not match it there** | the PTY fragment is U2's exact family (`stty -a output was: ""`), but U2's selector field names only `m6_1_pty_raw_mode_disables_kernel_echo`. U2's occurrence 2 saw raw **and** canonical fail together; here **canonical redded alone and raw passed**, which U2's evidence has never shown. It is recorded here rather than folded into U2 so that the "canonical alone" case stays visible | | **relation to 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 | | **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 | | **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.

View File

@ -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 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. 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), **Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12),
the tip after 1-pre; it was originally taken at `a994f37`. Sections the tip after 1-pre; it was originally taken at `a994f37`. Sections
other than §2 were written against `a994f37` and their *rulings* are 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 | | 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 | | 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 | | # | Contract | Witness (fails today because) | Mutation |
|---|---|---|---| |---|---|---|---|
@ -560,11 +567,11 @@ and flushed to the socket**. **Bound: 250 ms.**
| | **1a — `TextInput`** | **1e — `OpenTarget` + `OpenTargetResult`** | | | **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 | | **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 | | **byte pin** | frozen-byte fixture on the **previous final variant** | same |
| **gate** | daemon accepts from `>= 24`; producer withholds below | `>= 25`; producer withholds below | | **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 `< 25` frontend cannot drop-open; nothing it already had degrades | | **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 | | **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` | | **pins** | frozen bytes on `FrontendEvent`'s previous final variant | **two independent pins** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for `OpenTargetResult` |

View File

@ -379,6 +379,28 @@ fn coalesce_kind(event: &FrontendEvent) -> Option<u8> {
kind: MouseKind::Drag(_), kind: MouseKind::Drag(_),
.. ..
} => Some(7), } => 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, _ => 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<u64>` 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 /// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture
/// hit-tested locally to a panel CELL. Callers gate on /// hit-tested locally to a panel CELL. Callers gate on
/// [`Self::session_protocol_version`] `>= 21`. /// [`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 /// Acceptance 34: terminal move/drag runs coalesce to the latest
/// cell, while press, release, and wheel stay lossless and ordered. /// cell, while press, release, and wheel stay lossless and ordered.
#[test] #[test]

View File

@ -849,11 +849,50 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
facts.last_frame_text = frame_probe_text(frame); facts.last_frame_text = frame_probe_text(frame);
facts.last_title.clone_from(&frame.title); facts.last_title.clone_from(&frame.title);
} }
match msg.as_ref() { // §5b — a panel message is only NOTED here; its facts
InstanceMessage::PanelFrame( // are read from the retained state AFTER
pmacs_protocol::panel::PanelFramePayload::Present(frame), // `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; facts.panel_frames += 1;
}
if let Some((frame, _)) = authority_after.as_ref() {
facts.panel_rows = frame.size.rows; facts.panel_rows = frame.size.rows;
facts.panel_cols = frame.size.cols; facts.panel_cols = frame.size.cols;
facts.panel_focused = frame.focused; facts.panel_focused = frame.focused;
@ -864,12 +903,15 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
facts.panel_text_observed = true; facts.panel_text_observed = true;
} }
} }
InstanceMessage::PanelFrame( // Absence is only ever reported for an actual
pmacs_protocol::panel::PanelFramePayload::Absent, // `Absent`. Inferring it from `presented() == None`
) => facts.panel_absent_observed = true, // 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 { if is_snapshot {
// The dual declaration: a byte viewport for a // The dual declaration: a byte viewport for a
// document, a cell size for a terminal. The daemon // 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. /// Whether the negotiated session carries the panel wire at all.
/// ///
/// Keyed on the NEGOTIATED version, never the `Hello` baseline. /// 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 /// Set when a font/scale transaction has invalidated the panel's
/// geometry declaration, so the caller that owns the client knows to /// geometry declaration, so the caller that owns the client knows to
/// re-declare under a `Metrics` trigger. /// re-declare under a `Metrics` trigger.
@ -2084,9 +2126,60 @@ struct PanelDrag {
sent_rows: u32, 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). /// The GPU frontend's half of the bottom panel (Q#BP15, Q#BP15a, Q#BP16).
#[derive(Default)] #[derive(Default)]
struct PanelBand { 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<u64>,
/// The last **valid** frame received, retained until an authoritative /// The last **valid** frame received, retained until an authoritative
/// `Absent`. /// `Absent`.
/// ///
@ -2131,6 +2224,15 @@ struct PanelBand {
/// because the first drag after a press must reach the daemon even at the /// because the first drag after a press must reach the daemon even at the
/// cell the press landed on. /// cell the press landed on.
last_pointer_cell: Option<CellCoord>, last_pointer_cell: Option<CellCoord>,
/// §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<u64>,
/// Whether the pointer is currently over the divider strip, which /// Whether the pointer is currently over the divider strip, which
/// decides the `RowResize` cursor icon. /// decides the `RowResize` cursor icon.
hover_divider: bool, hover_divider: bool,
@ -2494,15 +2596,56 @@ impl App {
else { else {
return false; return false;
}; };
let Some(client) = self.attach_client.as_ref() else { // §5b — production reads the SAME negotiated family as
return false; // 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) = let sent = match state.panel_family {
PanelFamily::Unsupported => return,
PanelFamily::Legacy => {
client.send_panel_pointer(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods) 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}"); eprintln!("pmacs-gpu: send_panel_pointer failed: {e}");
} }
true
} }
/// Ship a panel gesture at an explicitly chosen cell. /// Ship a panel gesture at an explicitly chosen cell.
@ -2533,16 +2676,11 @@ impl App {
let Some(coord) = coord else { let Some(coord) = coord else {
return false; return false;
}; };
if let Err(e) = client.send_panel_pointer( // §5b — through the one family-aware sender, so this site
frame.geometry_epoch, // cannot drift from the other into producing the wrong variant.
frame.panel_epoch, let (geometry_epoch, panel_epoch, buffer_id) =
frame.buffer_id, (frame.geometry_epoch, frame.panel_epoch, frame.buffer_id);
coord, self.send_panel_gesture(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods);
kind,
mods,
) {
eprintln!("pmacs-gpu: send_panel_pointer failed: {e}");
}
true true
} }
@ -5389,7 +5527,7 @@ impl State {
terminal: None, terminal: None,
panel: PanelBand::default(), panel: PanelBand::default(),
panel_text_buffers: Vec::new(), panel_text_buffers: Vec::new(),
panel_wire: false, panel_family: PanelFamily::Unsupported,
panel_metrics_changed: false, panel_metrics_changed: false,
last_terminal_size_sent: None, last_terminal_size_sent: None,
terminal_frame_error_latched: false, terminal_frame_error_latched: false,
@ -6727,7 +6865,7 @@ impl State {
/// `Hello` baseline: the baseline stays at the compatibility floor /// `Hello` baseline: the baseline stays at the compatibility floor
/// forever, so reading it here would leave the band permanently dark. /// forever, so reading it here would leave the band permanently dark.
fn set_panel_wire(&mut self, session_protocol_version: u32) { 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. /// The band inset the document boundary is computed from.
@ -6845,7 +6983,7 @@ impl State {
/// the attach client, so the whole state machine — dedup, exhaustion, /// the attach client, so the whole state machine — dedup, exhaustion,
/// the latch — is reachable without a daemon. /// the latch — is reachable without a daemon.
fn next_geometry_declaration(&mut self, trigger: GeometryTrigger) -> Option<(u64, CellSize)> { 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; return None;
} }
let (total, advance) = self.declared_cell_total(); let (total, advance) = self.declared_cell_total();
@ -6895,6 +7033,13 @@ impl State {
return false; return false;
} }
match payload { 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 => { PanelFramePayload::Absent => {
// Authoritative removal, and always safe. Note this does // Authoritative removal, and always safe. Note this does
// NOT clear the geometry declaration: the frontend's frame // NOT clear the geometry declaration: the frontend's frame
@ -6908,8 +7053,66 @@ impl State {
self.panel.hover_divider = false; self.panel.hover_divider = false;
self.panel.pointer_held = false; self.panel.pointer_held = false;
self.panel.last_pointer_cell = None; self.panel.last_pointer_cell = None;
self.panel.last_pointer_generation = None;
had 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) => { PanelFramePayload::Present(frame) => {
if let Err(error) = frame.validate() { if let Err(error) = frame.validate() {
eprintln!("pmacs-gpu: rejecting invalid panel frame: {error}"); eprintln!("pmacs-gpu: rejecting invalid panel frame: {error}");
@ -7236,10 +7439,18 @@ impl State {
/// traffic and every one of those is a daemon-side gesture — the same /// traffic and every one of those is a daemon-side gesture — the same
/// reason the terminal path dedupes. /// reason the terminal path dedupes.
fn panel_motion_is_new(&mut self, coord: CellCoord) -> bool { 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; return false;
} }
self.panel.last_pointer_cell = Some(coord); self.panel.last_pointer_cell = Some(coord);
self.panel.last_pointer_generation = self.panel.mapping_generation;
true true
} }
@ -7248,6 +7459,7 @@ impl State {
fn set_panel_pointer_held(&mut self, held: bool) { fn set_panel_pointer_held(&mut self, held: bool) {
self.panel.pointer_held = held; self.panel.pointer_held = held;
self.panel.last_pointer_cell = None; self.panel.last_pointer_cell = None;
self.panel.last_pointer_generation = None;
} }
/// Begin a divider drag at surface pixel `y`, if the pointer is on the /// Begin a divider drag at surface pixel `y`, if the pointer is on the
@ -19809,6 +20021,460 @@ mod tests {
} }
} }
/// A mapped session with a real installed frame, and the frame.
///
/// The baseline is installed in the CORRECT family, so every
/// assertion below has real authority to preserve rather than a
/// `None` that was never set.
fn mapped_panel_session(rows: u32, generation: u64) -> Option<(State, PanelFrame)> {
let mut state = headless_or_skip(800, 600, "alpha")?;
let frame = present_panel(&mut state, rows);
state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
let mut mapped = frame;
mapped.panel_epoch += 1;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: mapped.clone(),
mapping_generation: generation,
}),
"fixture: the mapped baseline must install"
);
Some((state, mapped))
}
/// §5b G9b — motion dedupe suppresses a repeat WITHIN one generation
/// and RE-ARMS across one.
///
/// The pointer has not moved in either case. What differs is whether
/// the cell still denotes the same text: comparing the cell alone
/// eats the first motion after the mapping moves, which is precisely
/// the motion the daemon needs to re-anchor the gesture.
#[test]
fn g9b_motion_dedupe_is_keyed_by_generation_not_by_cell_alone() {
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
return;
};
let cell = pmacs_protocol::CellCoord::new(1, 1);
assert!(
state.panel_motion_is_new(cell),
"the first motion at a cell is always new"
);
assert!(
!state.panel_motion_is_new(cell),
"a repeat within one generation is suppressed — without this \
sub-cell motion becomes pixel-rate wire traffic"
);
// A same-generation repaint: a different frame, same key. The
// dedupe must NOT re-arm, or the suppression above is defeated
// by any style or selection repaint.
let mut repaint = frame.clone();
repaint.focused = !frame.focused;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: repaint,
mapping_generation: 5,
}),
"fixture: the repaint must be accepted, or it proves nothing"
);
assert!(
!state.panel_motion_is_new(cell),
"a same-generation repaint does not re-arm the dedupe"
);
// The mapping MOVES. The pointer still has not.
let mut rekeyed = frame.clone();
rekeyed.focused = frame.focused;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: rekeyed,
mapping_generation: 6,
}),
"fixture: the re-keyed frame must be accepted"
);
assert!(
state.panel_motion_is_new(cell),
"and across a generation the same cell IS new: it denotes \
different text now"
);
}
/// §5b G9c — a byte-identical frame at a HIGHER generation still
/// updates authority.
///
/// The visual result is nothing, which is what makes this easy to
/// get wrong: returning early on frame equality leaves the frontend
/// echoing a generation the daemon has already retired, and every
/// gesture it sends is then refused.
#[test]
fn g9c_an_identical_frame_at_a_higher_generation_still_updates_authority() {
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
return;
};
// Byte-identical, and the same generation: no work at all.
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: frame.clone(),
mapping_generation: 5,
}),
"a true duplicate does nothing"
);
assert_eq!(state.panel.mapping_generation, Some(5));
// Byte-identical, HIGHER generation: authority moves.
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: frame.clone(),
mapping_generation: 6,
}),
"the daemon has re-keyed the mapping under an unchanged \
picture — a fold or an edit off-screen does exactly that"
);
assert_eq!(
state.panel.mapping_generation,
Some(6),
"echoing 5 from here would have every gesture refused"
);
assert_eq!(
state.panel.frame.as_ref(),
Some(&frame),
"and the frame is unchanged, because it was identical"
);
}
/// §5b G10 — a structurally invalid mapped frame is refused
/// ATOMICALLY: previous frame and previous generation both retained.
///
/// Updating one without the other leaves a valid generation naming a
/// frame that was never shown, which is the same stale-mapping hole
/// from the receiving side.
#[test]
fn g10_an_invalid_mapped_frame_is_refused_atomically() {
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
return;
};
state.set_panel_pointer_held(true);
state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(1, 1));
// G10 — structurally invalid: the cell count contradicts `size`.
let mut invalid = frame.clone();
invalid.cells.pop();
assert!(
invalid.validate().is_err(),
"fixture: the frame must actually be invalid"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: invalid,
mapping_generation: 9,
})
);
assert_eq!(state.panel.frame.as_ref(), Some(&frame), "frame retained");
assert_eq!(
state.panel.mapping_generation,
Some(5),
"and its generation with it — a valid 9 here would name a \
frame this frontend never received"
);
// G10a — structurally VALID, generation zero. Zero is what a
// sender that never initialised the field produces; accepting it
// once disables the check for the session.
let mut zeroed = frame.clone();
zeroed.panel_epoch += 7;
assert!(
zeroed.validate().is_ok(),
"fixture: this one is structurally fine, so the refusal is \
about the zero"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: zeroed,
mapping_generation: 0,
})
);
assert_eq!(state.panel.frame.as_ref(), Some(&frame));
assert_eq!(state.panel.mapping_generation, Some(5));
assert!(state.panel.pointer_held, "a refusal ends no live gesture");
assert_eq!(
state.panel.last_pointer_cell,
Some(pmacs_protocol::CellCoord::new(1, 1)),
"nor discards its dedupe baseline"
);
}
/// §5b G10a — a ZERO generation is refused when NO authority is
/// held yet.
///
/// This is the only setup that isolates the zero check. With a
/// generation already held, zero is also *lower* than it, so the
/// nondecreasing clause refuses the frame and a row built that way
/// stays green with the zero check deleted — measured, not assumed.
///
/// The case is the one the framing names: a sender that never
/// initialised the field. Accepting it once records `Some(0)`, and
/// from there no later frame is ever "lower", so the check is
/// disabled for the whole session.
#[test]
fn g10a_a_zero_generation_is_refused_before_any_authority_is_held() {
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
return;
};
// A legacy frame first, so there is a real retained band to
// preserve, then the family switch. `mapping_generation` is
// genuinely `None` here — that is the state under test, not an
// accident of the fixture.
let baseline = present_panel(&mut state, 4);
state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
assert_eq!(
state.panel.mapping_generation, None,
"fixture: no authority held yet"
);
let mut zeroed = baseline.clone();
zeroed.panel_epoch += 1;
assert!(
zeroed.validate().is_ok(),
"fixture: structurally fine, so the refusal is about the zero"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: zeroed,
mapping_generation: 0,
}),
"zero is the wire's uninitialised value and is never authority"
);
assert_eq!(
state.panel.mapping_generation, None,
"and nothing is recorded — `Some(0)` would make every later \
frame non-lower and disable the check for the session"
);
assert_eq!(
state.panel.frame.as_ref().map(|f| f.panel_epoch),
Some(baseline.panel_epoch),
"the retained band survives the refusal"
);
// POSITIVE CONTROL — the same frame with a real generation
// installs, so the refusal is about the zero and not about the
// family switch.
let mut real = baseline.clone();
real.panel_epoch += 2;
assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: real,
mapping_generation: 1,
}));
assert_eq!(state.panel.mapping_generation, Some(1));
}
/// §5b G10c — the generation is NONDECREASING, and `Absent` does not
/// erase the high-water mark.
///
/// Values may SKIP: the daemon advances the key on mapping changes,
/// not on frames, so a frontend that misses two repaints legitimately
/// jumps. What may never happen is going backward — a delayed frame
/// must not roll this frontend's authority back to a mapping that is
/// gone.
#[test]
fn g10c_generation_is_nondecreasing_and_survives_absent() {
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
return;
};
// A SKIP forward is fine.
let mut skipped = frame.clone();
skipped.panel_epoch += 1;
assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: skipped.clone(),
mapping_generation: 9,
}));
assert_eq!(state.panel.mapping_generation, Some(9));
// A LOWER one is refused, atomically.
let mut delayed = frame.clone();
delayed.panel_epoch += 2;
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: delayed.clone(),
mapping_generation: 8,
})
);
assert_eq!(
state.panel.frame.as_ref().map(|f| f.panel_epoch),
Some(skipped.panel_epoch),
"the retained frame survives"
);
assert_eq!(state.panel.mapping_generation, Some(9));
// An EQUAL generation with a different frame is accepted: a
// style or selection repaint does not move the mapping, and
// refusing it would freeze the band.
let mut repaint = skipped.clone();
repaint.focused = !skipped.focused;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: repaint,
mapping_generation: 9,
}),
"an equal-generation repaint is valid"
);
assert_eq!(state.panel.mapping_generation, Some(9));
// `Absent` hides the band but must NOT erase the high-water
// mark: a frame delayed across the hide would otherwise come
// back believed.
assert!(state.apply_panel_payload(PanelFramePayload::Absent));
assert!(state.panel.presented().is_none(), "the band is down");
assert_eq!(
state.panel.mapping_generation,
Some(9),
"and the high-water mark stands"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: delayed,
mapping_generation: 8,
}),
"so the delayed frame is still refused after the hide"
);
assert!(state.panel.presented().is_none(), "and stays down");
}
/// §5b G8b — a MAPPED frontend refuses the legacy family, and the
/// refusal is ATOMIC.
///
/// The baseline is accepted in the CORRECT family first, so there is
/// real authority to preserve: a row that installs a legacy frame
/// and then switches to mapped has `mapping_generation == None`
/// throughout, and asserting it stayed `None` proves nothing. The
/// pointer latches are primed for the same reason — untouched state
/// that was never set is not evidence of atomicity.
#[test]
fn g8b_a_mapped_frontend_refuses_legacy_atomically() {
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
return;
};
let frame = present_panel(&mut state, 4);
state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
// A correct-family baseline, so the retained authority is real.
let mut baseline = frame.clone();
baseline.panel_epoch = frame.panel_epoch + 1;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: baseline.clone(),
mapping_generation: 5,
}),
"fixture: the mapped baseline must install"
);
state.set_panel_pointer_held(true);
state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(1, 1));
let mut intruder = frame.clone();
intruder.panel_epoch = frame.panel_epoch + 2;
assert!(
!state.apply_panel_payload(PanelFramePayload::Present(intruder)),
"a v25 frontend must not paint a band it cannot safely \
hit-test — the frame carries no mapping identity"
);
assert_eq!(
state.panel.frame.as_ref().map(|f| f.panel_epoch),
Some(baseline.panel_epoch),
"the retained frame survives the refusal"
);
assert_eq!(
state.panel.mapping_generation,
Some(5),
"and so does its authority — this is the half a switched \
baseline could not have shown"
);
assert!(state.panel.pointer_held, "a refusal ends no live gesture");
assert_eq!(
state.panel.last_pointer_cell,
Some(pmacs_protocol::CellCoord::new(1, 1)),
"nor discards its dedupe baseline"
);
}
/// §5b G8d — a LEGACY frontend refuses the mapped family, atomically.
///
/// An independent state, not a continuation of G8b: sharing one
/// would let the second direction inherit whatever the first left
/// behind.
#[test]
fn g8d_a_legacy_frontend_refuses_mapped_atomically() {
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
return;
};
let frame = present_panel(&mut state, 4);
state.set_panel_wire(PANEL_MIN_VERSION);
let mut baseline = frame.clone();
baseline.panel_epoch = frame.panel_epoch + 1;
assert!(
state.apply_panel_payload(PanelFramePayload::Present(baseline.clone())),
"fixture: the legacy baseline must install"
);
state.set_panel_pointer_held(true);
state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(2, 2));
let mut intruder = frame.clone();
intruder.panel_epoch = frame.panel_epoch + 2;
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: intruder,
mapping_generation: 9,
}),
"a v24 frontend must reject a family it never negotiated"
);
assert_eq!(
state.panel.frame.as_ref().map(|f| f.panel_epoch),
Some(baseline.panel_epoch),
"the retained frame survives"
);
assert_eq!(
state.panel.mapping_generation, None,
"and no authority is acquired from a refused frame"
);
assert!(state.panel.pointer_held, "a refusal ends no live gesture");
assert_eq!(
state.panel.last_pointer_cell,
Some(pmacs_protocol::CellCoord::new(2, 2))
);
}
/// §5b — an UNSUPPORTED session accepts neither family.
///
/// The case a `!= Mapped` gate let through: `Unsupported` is neither
/// family, so a negative test admitted a band the session never
/// negotiated.
#[test]
fn an_unsupported_session_accepts_neither_panel_family() {
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
return;
};
let frame = present_panel(&mut state, 4);
state.set_panel_wire(PANEL_MIN_VERSION - 1);
let mut orphan = frame.clone();
orphan.panel_epoch = frame.panel_epoch + 1;
assert!(
!state.apply_panel_payload(PanelFramePayload::Present(orphan.clone())),
"a sub-panel session has no band at all"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: orphan,
mapping_generation: 9,
}),
"in either family"
);
}
/// F1 — a held left button makes motion a `Drag(Left)`, and the dedupe /// F1 — a held left button makes motion a `Drag(Left)`, and the dedupe
/// re-arms on every press and release. /// re-arms on every press and release.
#[test] #[test]

View File

@ -66,7 +66,7 @@ pub use message::{
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, 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, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind, 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, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace, 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, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,

View File

@ -550,8 +550,75 @@ pub enum FrontendEvent {
/// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`]. /// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`].
text: String, 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`]. /// First protocol version carrying [`FrontendEvent::TextInput`].
/// ///
/// A frontend older than this **retains its existing `Key` behaviour and /// A frontend older than this **retains its existing `Key` behaviour and
@ -617,7 +684,8 @@ impl FrontendEvent {
| Self::FrontendCellGeometry { frontend_id, .. } | Self::FrontendCellGeometry { frontend_id, .. }
| Self::PanelResizeRows { frontend_id, .. } | Self::PanelResizeRows { frontend_id, .. }
| Self::PanelPointer { 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 /// negotiated version. What is unusual here is only that the extension
/// is **inbound-only** — there is no outbound counterpart to withhold, /// is **inbound-only** — there is no outbound counterpart to withhold,
/// so the receiver check is the whole of the daemon's half. /// 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`]. /// 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 /// 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 /// frontend negotiates v23, never sends the variant, and keeps today's
/// first-scalar behaviour. /// 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] = &[ 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 /// T M10.5: predicate for the handshake check. Returns `true` if

View File

@ -103,6 +103,29 @@ pub enum PanelFramePayload {
Present(PanelFrame), Present(PanelFrame),
/// No panel is visible; clear any retained frame. /// No panel is visible; clear any retained frame.
Absent, 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. /// Why a [`PanelFrame`] is not structurally valid.

File diff suppressed because it is too large Load Diff

View File

@ -375,6 +375,76 @@ const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500);
/// column and clobbers no information. /// column and clobbers no information.
const DIVIDER_HANDLE_GLYPH: char = '⇕'; 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<u64>,
/// 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<pmacs_protocol::ByteRange>,
},
/// 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<crate::terminal::view::LogicalCellAnchor>,
},
}
impl EditorState { impl EditorState {
/// Construct a fresh editor for an unnamed scratch buffer. /// Construct a fresh editor for an unnamed scratch buffer.
/// ///
@ -2452,6 +2522,66 @@ impl EditorState {
true 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<PanelMappingSnapshot> {
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 /// Paint one semantic frontend's side window into a panel-sized grid
/// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17). /// (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. /// which needs the GPU band and lands in Stage 2B-3.
/// ///
/// Returns whether the gesture was accepted. /// 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( pub fn dispatch_semantic_panel_pointer(
&self, &self,
frontend_id: FrontendId, frontend_id: FrontendId,

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips --- // --- M5.5a handshake & postcard round-trips ---
#[test] #[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 / // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6 // SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1741,10 +1741,11 @@ mod tests {
// minibuffer at all. `MinibufferPrompt` is therefore frozen and // minibuffer at all. `MinibufferPrompt` is therefore frozen and
// pinned by literal bytes below. // pinned by literal bytes below.
// //
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) — an // v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) and v25
// APPENDED variant, which is why the freeze above survives it // 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. // untouched: nothing in `MinibufferPrompt`'s encoding moved.
assert_eq!(PROTOCOL_VERSION, 24); assert_eq!(PROTOCOL_VERSION, 25);
} }
#[test] #[test]
@ -1822,18 +1823,19 @@ mod tests {
// v18 (`StatuslineSegments`), v19 (the vterm terminal family), // v18 (`StatuslineSegments`), v19 (the vterm terminal family),
// v20 (semantic initial-target bootstrap), v21 (the bottom // v20 (semantic initial-target bootstrap), v21 (the bottom
// panel band), v22 (`LineWrapFacts`), v23 // panel band), v22 (`LineWrapFacts`), v23
// (`MinibufferPromptRows`), and v24 (`TextInput`, GUI arc Stage // (`MinibufferPromptRows`), v24 (`TextInput`, GUI arc Stage 1a)
// 1a) all interoperate. // and v25 (`PanelPointerMapped` + `PresentMapped`, bottom-panel
for accepted in 6..=24 { // §5b) all interoperate.
for accepted in 6..=PROTOCOL_VERSION {
assert!( assert!(
is_supported_protocol_version(accepted), is_supported_protocol_version(accepted),
"v{accepted} must be 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!( assert!(
!is_supported_protocol_version(rejected), !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] #[test]
fn terminal_family_round_trips_and_pins_its_discriminants() { fn terminal_family_round_trips_and_pins_its_discriminants() {
let bid = pmacs_protocol::BufferId::from_raw(9); let bid = pmacs_protocol::BufferId::from_raw(9);

View File

@ -158,6 +158,29 @@ fn minibuffer_window<T: Clone>(candidates: &[T], selected: Option<usize>) -> (Ve
(window, selected_in_window) (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 /// Owns one `semantic_render` session's projection state: the last
/// viewport the frontend declared, and the diff baseline per buffer /// viewport the frontend declared, and the diff baseline per buffer
/// for the `StyleSpans` and `Decorations` families. /// 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 /// rows form. Also gates the per-row detail lookup: a peer that
/// cannot carry a detail does not pay to resolve one. /// cannot carry a detail does not pay to resolve one.
peer_knows_minibuffer_rows: bool, 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 /// Last emitted `CompletionPopup` payload per buffer (Arc 1a
/// Q#C5), for cached-compare suppression (see /// Q#C5), for cached-compare suppression (see
/// [`CompletionPopupFacts`]). /// [`CompletionPopupFacts`]).
@ -377,6 +409,61 @@ pub struct SemanticRenderState {
/// retains its last valid frame and silence would leave a stale band /// retains its last valid frame and silence would leave a stale band
/// on screen indefinitely (Q#BP15). /// on screen indefinitely (Q#BP15).
last_panel_payload: Option<PanelFramePayload>, last_panel_payload: Option<PanelFramePayload>,
/// §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<AcceptedPanelGesture>,
/// §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 /// Highest presentation epoch allocated for this session; `0` means
/// none has been. Advanced only when a frame is actually shipped, so /// none has been. Advanced only when a frame is actually shipped, so
/// a frame that fails validation does not burn an identity the peer /// 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_statusline_segments = negotiated_protocol_version >= 18;
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19; s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION; 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 s
} }
/// Fresh session state for frontend `frontend_id`: no viewport /// 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 /// daemon sessions with a real negotiated version use
/// [`Self::for_peer`]. /// [`Self::for_peer`].
#[must_use] #[must_use]
@ -536,6 +627,10 @@ impl SemanticRenderState {
last_menu_prompt: HashMap::new(), last_menu_prompt: HashMap::new(),
last_minibuffer: None, last_minibuffer: None,
peer_knows_minibuffer_rows: true, 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_completion_popup: HashMap::new(),
last_summary: HashMap::new(), last_summary: HashMap::new(),
last_status: HashMap::new(), last_status: HashMap::new(),
@ -575,6 +670,10 @@ impl SemanticRenderState {
// peer already holds. Seeding the baseline keeps the first // peer already holds. Seeding the baseline keeps the first
// frame from shipping a redundant authoritative `Absent`. // frame from shipping a redundant authoritative `Absent`.
last_panel_payload: Some(PanelFramePayload::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_epoch_used: 0,
panel_presentation: None, panel_presentation: None,
panel_error_latched: false, panel_error_latched: false,
@ -592,11 +691,194 @@ impl SemanticRenderState {
#[must_use] #[must_use]
pub fn panel_declaration(&self) -> Option<&PanelFrame> { pub fn panel_declaration(&self) -> Option<&PanelFrame> {
match self.last_panel_payload.as_ref()? { match self.last_panel_payload.as_ref()? {
PanelFramePayload::Present(frame) => Some(frame), PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. } => {
Some(frame)
}
PanelFramePayload::Absent => None, 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<PanelFramePayload> {
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<AcceptedPanelGesture> {
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<AcceptedPanelGesture> {
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<crate::editor::PanelMappingSnapshot>,
) -> Option<u64> {
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<u64> {
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 /// Whether the last shipped declaration is a `Present` whose epochs
/// both match an inbound panel event **and** which still describes /// both match an inbound panel event **and** which still describes
/// the side window that is live now (Q#BP16 steps 2–4). /// the side window that is live now (Q#BP16 steps 2–4).
@ -1374,7 +1656,7 @@ impl SemanticRenderState {
self.publish_absent_panel(out); self.publish_absent_panel(out);
return; return;
}; };
let payload = PanelFramePayload::Present(PanelFrame { let frame = PanelFrame {
buffer_id: projection.buffer_id, buffer_id: projection.buffer_id,
panel_epoch, panel_epoch,
geometry_epoch: geometry.geometry_epoch, geometry_epoch: geometry.geometry_epoch,
@ -1382,7 +1664,28 @@ impl SemanticRenderState {
cells: projection.cells, cells: projection.cells,
cursor: projection.cursor, cursor: projection.cursor,
focused: projection.focused, 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 // Complete-payload comparison FIRST, like the terminal pass: only
// validated payloads are ever stored, so a payload equal to the // validated payloads are ever stored, so a payload equal to the
// baseline has already passed and re-running the per-cell width // baseline has already passed and re-running the per-cell width
@ -1391,8 +1694,10 @@ impl SemanticRenderState {
self.panel_error_latched = false; self.panel_error_latched = false;
return; return;
} }
let PanelFramePayload::Present(frame) = &payload else { let (PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }) =
unreachable!("the Present payload was constructed immediately above"); &payload
else {
unreachable!("a Present payload was constructed immediately above");
}; };
match frame.validate() { match frame.validate() {
Ok(()) => { Ok(()) => {
@ -1433,6 +1738,22 @@ impl SemanticRenderState {
/// deliberately survives: it is answered by the frontend, not by the /// deliberately survives: it is answered by the frontend, not by the
/// panel's presence. /// panel's presence.
fn publish_absent_panel(&mut self, out: &mut Vec<InstanceMessage>) { fn publish_absent_panel(&mut self, out: &mut Vec<InstanceMessage>) {
// §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; self.panel_presentation = None;
// `Absent` also clears the peer's retained mode line. A later // `Absent` also clears the peer's retained mode line. A later
// `Present` under `NoMessage` therefore has nothing it can // `Present` under `NoMessage` therefore has nothing it can

View File

@ -114,6 +114,15 @@ pub struct ScreenProjection {
pub title: Option<String>, pub title: Option<String>,
/// Screen generation represented by this projection. /// Screen generation represented by this projection.
pub generation: u64, 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. /// Borrowed, publication-consistent row projection for in-process views.
@ -128,6 +137,8 @@ pub(crate) struct BorrowedScreenProjection<'a> {
pub cursor: Option<CellCoord>, pub cursor: Option<CellCoord>,
pub title: Option<&'a str>, pub title: Option<&'a str>,
pub generation: u64, pub generation: u64,
/// §5b — the mapping revision this projection was published at.
pub mapping_revision: u64,
} }
impl BorrowedScreenProjection<'_> { impl BorrowedScreenProjection<'_> {
@ -147,6 +158,7 @@ impl ScreenProjection {
cursor: self.cursor, cursor: self.cursor,
title: self.title.as_deref(), title: self.title.as_deref(),
generation: self.generation, generation: self.generation,
mapping_revision: self.mapping_revision,
} }
} }
} }
@ -192,6 +204,9 @@ pub struct TerminalScreen {
tab_stops: BTreeSet<usize>, tab_stops: BTreeSet<usize>,
title: Option<String>, title: Option<String>,
generation: u64, generation: u64,
/// §5b — see [`Screen::mapping_revision`]. Separate from
/// `generation`, which advances for style and title too.
mapping_revision: u64,
published: ScreenProjection, published: ScreenProjection,
sync_started: Option<Instant>, sync_started: Option<Instant>,
next_line_id: u64, next_line_id: u64,
@ -221,6 +236,7 @@ impl TerminalScreen {
cursor: Some(CellCoord::new(0, 0)), cursor: Some(CellCoord::new(0, 0)),
title: None, title: None,
generation: 0, generation: 0,
mapping_revision: 0,
}; };
Ok(Self { Ok(Self {
size, size,
@ -240,6 +256,7 @@ impl TerminalScreen {
tab_stops: default_tab_stops(size.cols as usize), tab_stops: default_tab_stops(size.cols as usize),
title: None, title: None,
generation: 0, generation: 0,
mapping_revision: 0,
published, published,
sync_started: None, sync_started: None,
next_line_id, next_line_id,
@ -269,24 +286,24 @@ impl TerminalScreen {
} }
AnsiEvent::SetStyle(style) => { AnsiEvent::SetStyle(style) => {
self.style = style; self.style = style;
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::CarriageReturn => { AnsiEvent::CarriageReturn => {
self.cursor.col = 0; self.cursor.col = 0;
self.cursor.pending_wrap = false; self.cursor.pending_wrap = false;
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::Backspace => { AnsiEvent::Backspace => {
self.cursor.col = self.cursor.col.saturating_sub(1); self.cursor.col = self.cursor.col.saturating_sub(1);
self.cursor.pending_wrap = false; self.cursor.pending_wrap = false;
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::Bell => { AnsiEvent::Bell => {
self.bell_count = self.bell_count.saturating_add(1); self.bell_count = self.bell_count.saturating_add(1);
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::LineFeed | AnsiEvent::Index => { AnsiEvent::LineFeed | AnsiEvent::Index => {
@ -308,17 +325,17 @@ impl TerminalScreen {
} }
AnsiEvent::SetTabStop => { AnsiEvent::SetTabStop => {
self.tab_stops.insert(self.cursor.col); self.tab_stops.insert(self.cursor.col);
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::ClearTabStop => { AnsiEvent::ClearTabStop => {
self.tab_stops.remove(&self.cursor.col); self.tab_stops.remove(&self.cursor.col);
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::ClearAllTabStops => { AnsiEvent::ClearAllTabStops => {
self.tab_stops.clear(); self.tab_stops.clear();
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::CursorUp(n) => { AnsiEvent::CursorUp(n) => {
@ -432,23 +449,23 @@ impl TerminalScreen {
CharacterSetSlot::G0 => self.g0 = charset, CharacterSetSlot::G0 => self.g0 = charset,
CharacterSetSlot::G1 => self.g1 = charset, CharacterSetSlot::G1 => self.g1 = charset,
} }
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::ShiftOut => { AnsiEvent::ShiftOut => {
self.use_g1 = true; self.use_g1 = true;
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::ShiftIn => { AnsiEvent::ShiftIn => {
self.use_g1 = false; self.use_g1 = false;
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::DeviceRequest(request) => Some(self.device_reply(request)), AnsiEvent::DeviceRequest(request) => Some(self.device_reply(request)),
AnsiEvent::SetTitle(title) => { AnsiEvent::SetTitle(title) => {
self.title = Some(sanitize_title(&title)); self.title = Some(sanitize_title(&title));
self.changed(); self.display_only_changed();
None None
} }
AnsiEvent::EraseToEol => { AnsiEvent::EraseToEol => {
@ -595,6 +612,7 @@ impl TerminalScreen {
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
title: self.title.as_deref(), title: self.title.as_deref(),
generation: self.generation, generation: self.generation,
mapping_revision: self.mapping_revision,
} }
} }
@ -654,7 +672,7 @@ impl TerminalScreen {
self.g0 = saved.g0; self.g0 = saved.g0;
self.g1 = saved.g1; self.g1 = saved.g1;
self.use_g1 = saved.use_g1; self.use_g1 = saved.use_g1;
self.changed(); self.display_only_changed();
} }
fn write_text(&mut self, text: &str) { fn write_text(&mut self, text: &str) {
@ -808,6 +826,24 @@ impl TerminalScreen {
if self.modes.insert { if self.modes.insert {
self.insert_characters(width as u32); 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); self.clear_wide_at(self.cursor.row, self.cursor.col);
if width == 2 { if width == 2 {
self.clear_wide_at(self.cursor.row, self.cursor.col + 1); self.clear_wide_at(self.cursor.row, self.cursor.col + 1);
@ -828,7 +864,11 @@ impl TerminalScreen {
self.cursor.pending_wrap = false; self.cursor.pending_wrap = false;
} }
self.last_grapheme = Some((row, col)); self.last_grapheme = Some((row, col));
if glyph_changed {
self.changed(); self.changed();
} else {
self.display_only_changed();
}
} }
fn soft_wrap(&mut self) { fn soft_wrap(&mut self) {
@ -882,7 +922,7 @@ impl TerminalScreen {
.copied() .copied()
.unwrap_or(cols - 1); .unwrap_or(cols - 1);
self.cursor.pending_wrap = false; self.cursor.pending_wrap = false;
self.changed(); self.display_only_changed();
} }
fn move_vertical(&mut self, delta: i64) { fn move_vertical(&mut self, delta: i64) {
@ -899,7 +939,7 @@ impl TerminalScreen {
}; };
self.cursor.row = moved.clamp(lo, hi); self.cursor.row = moved.clamp(lo, hi);
self.cursor.pending_wrap = false; self.cursor.pending_wrap = false;
self.changed(); self.display_only_changed();
} }
fn move_horizontal(&mut self, delta: i64) { 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.col = moved.min(self.size.cols as usize - 1);
self.cursor.pending_wrap = false; self.cursor.pending_wrap = false;
self.changed(); self.display_only_changed();
} }
fn set_col(&mut self, col: u32) { fn set_col(&mut self, col: u32) {
self.cursor.col = col.saturating_sub(1).min(self.size.cols - 1) as usize; self.cursor.col = col.saturating_sub(1).min(self.size.cols - 1) as usize;
self.cursor.pending_wrap = false; self.cursor.pending_wrap = false;
self.changed(); self.display_only_changed();
} }
fn set_row(&mut self, row: u32) { fn set_row(&mut self, row: u32) {
let base = if self.modes.origin { 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.row = (base + row.saturating_sub(1) as usize).min(hi);
self.cursor.pending_wrap = false; self.cursor.pending_wrap = false;
self.changed(); self.display_only_changed();
} }
fn set_position(&mut self, row: u32, col: u32) { fn set_position(&mut self, row: u32, col: u32) {
self.set_row(row); self.set_row(row);
@ -1466,6 +1506,32 @@ impl TerminalScreen {
fn changed(&mut self) { fn changed(&mut self) {
self.generation = self.generation.saturating_add(1); 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 { fn current_snapshot(&self) -> ScreenSnapshot {
ScreenSnapshot { ScreenSnapshot {
@ -1495,8 +1561,20 @@ impl TerminalScreen {
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
title: self.title.clone(), title: self.title.clone(),
generation: self.generation, 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) { fn publish(&mut self) {
self.published = self.current_projection(); self.published = self.current_projection();
} }
@ -1910,6 +1988,67 @@ mod tests {
assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 0))); 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] #[test]
fn resize_only_adds_default_tab_stops_in_new_columns() { fn resize_only_adds_default_tab_stops_in_new_columns() {
let mut s = screen(2, 16); let mut s = screen(2, 16);

View File

@ -249,6 +249,60 @@ impl TerminalManager {
self.screen_size(key.buffer_id) 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<LogicalCellAnchor>)> {
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 /// The shared screen's current size, read from the borrowed
/// projection. /// projection.
/// ///
@ -965,6 +1019,7 @@ mod tests {
cursor: None, cursor: None,
title: Some("shell".into()), title: Some("shell".into()),
generation: 7, generation: 7,
mapping_revision: 0,
} }
} }
@ -1245,6 +1300,7 @@ mod tests {
cursor: None, cursor: None,
title: None, title: None,
generation: 2, generation: 2,
mapping_revision: 0,
}; };
let mut state = TerminalViewState { let mut state = TerminalViewState {
top: Some(LogicalCellAnchor { top: Some(LogicalCellAnchor {

View File

@ -117,9 +117,16 @@ impl Session {
first 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 { fn present(&mut self) -> PanelFrame {
match self.frame() { 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:?}"), 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); session.declare(1, ROWS, COLS);
open_panel(&session, "*panel*", 4); open_panel(&session, "*panel*", 4);
assert!( assert!(
matches!(session.frame(), Some(PanelFramePayload::Present(_))), matches!(
session.frame(),
Some(PanelFramePayload::Present(_) | PanelFramePayload::PresentMapped { .. })
),
"{label}: fixture precondition — a band was visible first" "{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 let panel_rows = messages
.iter() .iter()
.find_map(|message| match message { .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, _ => None,
}) })
.expect("a panel frame"); .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); session.state.core.borrow_mut().focus_window(FID, panel);
let frame = match session.frame() { let frame = match session.frame() {
Some(PanelFramePayload::Present(frame)) => frame, Some(
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
) => frame,
other => panic!( other => panic!(
"a legally wide panel must still present its terminal; got {other:?} \ "a legally wide panel must still present its terminal; got {other:?} \
— and the durable state says hidden={}", — 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. // write into their real data root.
#[path = "common/iso.rs"] #[path = "common/iso.rs"]
mod iso; 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<u64> {
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"
);
}

View File

@ -182,8 +182,13 @@ fn press_and_await_panel(session: &mut Session) -> bool {
}), }),
) )
.expect("write panel-open key"); .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 { 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, _ => None,
}) })
.is_some() .is_some()
@ -251,8 +256,8 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
); );
assert!( assert!(
press_and_await_panel(&mut current), press_and_await_panel(&mut current),
"a v21-negotiated semantic session must be panel-capable and receive \ "a current-wire semantic session must be panel-capable and \
a Present panel frame" receive a Present-family panel frame"
); );
// Half 3 — a semantic session that echoed the baseline is NOT // 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] #[test]
fn the_baseline_stays_and_the_counter_offer_activates() { fn the_baseline_stays_and_the_counter_offer_activates() {
// A deliberate tripwire: bumping the wire must be a conscious edit // A deliberate tripwire: bumping the wire must be a conscious edit
// here, not a silent one. v24 is `TextInput` (GUI arc Stage 1a); // here, not a silent one. v25 is the mapped panel family
// v23 was `MinibufferPromptRows` (Discovery Stage 2); v22 was // (`PanelPointerMapped` + `PresentMapped`, bottom-panel §5b); v24 is
// `LineWrapFacts` (long-lines Stage 3). // `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows`
assert_eq!(PROTOCOL_VERSION, 24); // (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 25);
assert_eq!( assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20, ADVERTISED_PROTOCOL_VERSION, 20,
"moving this is the incompatible act the mechanism exists to avoid" "moving this is the incompatible act the mechanism exists to avoid"

View File

@ -67,10 +67,11 @@ use common::daemon::{TestDaemon, build_default_caps};
/// server-first, so moving it locks out every already-shipped frontend /// server-first, so moving it locks out every already-shipped frontend
/// before it can counter-offer. An additive family never needs it. /// before it can counter-offer. An additive family never needs it.
#[test] #[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!( assert_eq!(
PROTOCOL_VERSION, 24, PROTOCOL_VERSION, 25,
"v24 is TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows" "v25 is the mapped panel family (bottom-panel §5b); v24 was \
TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows"
); );
assert_eq!( assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20, 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. // rather than after the interesting half has already passed.
let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22); let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22);
let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION); 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 // 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 // probe command by typing it — the candidate window is ten rows out

View File

@ -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 // three lines on purpose. The ceiling assertion is the load-bearing
// one — it says the supported set ENDS here, which is what makes an // one — it says the supported set ENDS here, which is what makes an
// accidentally-widened set a failure rather than a silent pass. // accidentally-widened set a failure rather than a silent pass.
assert_eq!(PROTOCOL_VERSION, 24); assert_eq!(PROTOCOL_VERSION, 25);
for version in 6..=24 { for version in 6..=PROTOCOL_VERSION {
assert!(is_supported_protocol_version(version)); assert!(is_supported_protocol_version(version));
} }
assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1)); assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1));

View File

@ -888,10 +888,11 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
panic!("timed out waiting for {what}"); panic!("timed out waiting for {what}");
} }
// Tripwire: a wire bump must be a conscious edit here. v24 is // Tripwire: a wire bump must be a conscious edit here. v25 is the
// `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows` // mapped panel family (bottom-panel §5b); v24 is `TextInput` (GUI
// (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3). // arc Stage 1a); v23 was `MinibufferPromptRows` (Discovery Stage 2);
assert_eq!(PROTOCOL_VERSION, 24); // v22 was `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 25);
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
&[ &[
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"), ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),