Merge githubsucks/main into panel-pointer-replay

Merged rather than rebased, by decision: the lane's 12 commits include
10 framing revisions that all touch the same 800-1000 line doc regions,
so a rebase meant twelve rounds of large-block conflict resolution ---
the operation that produced a committed diff3 marker on the last lane.
One pass instead, with all 12 commits preserved.

Resolutions:

- src/daemon.rs --- took main's structure whole, both inbound arms with
  the latch gated on the dispatcher's answer, and threaded replay's
  `mods` through both call sites. `mods` is newly BOUND in the mapped
  arm, which SS5b left in `..`; the mapped family carries the same
  modifiers, so leaving it would have given a v25 session the inverted
  Shift behaviour that parent 48 R-a fixed for v24.
- pmacs-gpu/src/main.rs --- additive throughout: both new struct fields
  (`gesture_last_content_cell`, `last_pointer_generation`), both resets
  at each site, and both test blocks.
- src/editor.rs --- auto-merged; the merged dispatcher keeps SS5b's
  `#[must_use]`, its four rejection paths and its `-> bool`, plus
  replay's `&mut self`, `mods`, chrome/mode-line handling and terminal
  gesture application.
- docs/active-work.md --- the active replay lane above main's corrected
  #239/#240/#242 headers.
- docs/bottom-panel-framing.md --- 5a then 5b. The paragraph arguing
  the v25->v26 bump should be "recorded as required rather than made"
  is marked superseded: SS5b made it and merged as #242.

Workspace compiles clean, all targets, no warnings.

THE MERGE SURFACES A SEMANTIC COLLISION THE FRAMING MUST RULE ON, and
it is not resolved here. The two branches give the dispatcher's bool
different meanings: for SS5b `true` means the gesture was ACCEPTED, and
it drives the accepted-gesture latch; for replay `true` means the event
was CONSUMED HERE, including chrome swallows. So a press on the band's
mode line now returns true and ARMS the latch --- a gesture that never
began in content, which is the defect class SS5b's review round four
found and fixed. Recorded, not patched, because which rows own the
answer is a framing question and the next revision owes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-20 19:51:50 +02:00
commit b758c2e76f
No known key found for this signature in database
29 changed files with 8719 additions and 118 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2547,6 +2547,51 @@ its own step, never `&&`-chained.
**Run it with `scripts/gate`. Do not retype it.**
**NEVER start the gate — or `cargo test` — from a shell that ignores
`SIGINT`.** A shell backgrounding a job without job control sets
`SIGINT` (and `SIGQUIT`) to `SIG_IGN`; `nohup` adds `SIGHUP`. `SIG_IGN`
is **inherited across `fork` and survives `exec`**, so it reaches
`cargo`, the test binary, and everything they spawn. Any test that
signals a child and waits for it then hangs to its own deadline and
reports a *product* defect that is not there.
Concretely: `setsid nohup ./scripts/gate … &` is **forbidden**. This
cost one lane seven red full sweeps and nine framing revisions chasing
a GPU teardown bug that never existed
(`docs/gpu-probe-sigint-framing.md` §4c). Long runs do not need it —
measured, an ordinary tool-level background launch leaves `SIGINT`
deliverable.
`scripts/gate` now refuses to start in that state, before any stage,
and **there is no override**: a run under ignored `SIGINT` cannot
produce valid evidence. If you see
```
pmacs: SIGINT is ignored; run this command with SIGINT deliverable
gate: REFUSING TO RUN --- see the diagnosis above.
```
the fix is to re-run it in the foreground, not to work around the
guard. `scripts/check-sigint-deliverable` answers the question on its
own: exit **0** deliverable, **1** ignored, **2** undecidable.
**AND NEVER WRAP THE GATE IN `timeout` TO FIT AN AGENT COMMAND CAP.**
The full 16-stage suite outruns a ten-minute cap, and a `timeout 580`
around it kills `sweep` mid-run; the runner then records that stage as
a **failure**, which reads in the log exactly like a real red. This
already produced one false §5b gate result. It is the same defect as
the one above in a different costume: a harness convenience silently
becoming evidence.
The supported way to run past the cap is the **tool-level background
launch**, which is measured `safe` by
`scripts/check-sigint-deliverable`, so the guard admits it and the run
is valid. Check it directly if in doubt --- the helper is one command
and answers for whatever shell actually invoked it. Failing that, run
the suite in labelled pieces and say in the record which piece produced
which result. **A truncated run is not a result, and must never be
reported as one.**
```
scripts/gate [--acceptance SUITE]... [--protocol]
```

View File

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

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` |
| **job / flavor** | local (Linux), `cargo test --workspace --features crdt --no-fail-fast`, i.e. under full-sweep load |
| **required fragments** | `transient sequence must attach` + `Handshake(Io(` + `BrokenPipe` (or `code: 32`) |
| **status** | **FOURTH OCCURRENCE 2026-08-13 — causal status still UNRESOLVED. A NEW candidate mechanism is introduced by the observing lane and is NOT excluded (see below)** |
| **status** | **FIFTH OCCURRENCE 2026-08-15 — causal status still UNRESOLVED.** The fifth carries the strongest tree exclusion this row has had: a **documentation-only diff** |
| **what IS established** | **three** occurrences at `pmacs-gpu/src/attach.rs:1680`, the second and third with all three fragments **verified** rather than inferred; the test drives a scripted transient-then-success sequence over a real socket pair. **The added GPU test is not the mechanism** — see the third-occurrence control below |
| **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** |
| **rerun evidence** | occurrence 1: 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Occurrence 2: **30 green on the observing branch** (15 isolated selector, 15 full `-p pmacs-gpu`) **plus a 15-run merge-base control, also green**. Occurrence 3: 5 isolated selector runs green, 10 full `-p pmacs-gpu` runs green **with** the added test, and **1 failure in 10 with the added test `#[ignore]`d** — the first rerun in this row's history that reproduced anything. Per the rerun rule the green runs establish intermittence only; the red control run is what carries the exclusion |
| **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe |
**Fifth occurrence — panel cell-mapping generation (§5b) framing,
2026-08-15, local (Linux).** The `scripts/gate` **`gpu` step** again,
the same flavor as occurrence 2, inside a `--protocol` run
(log `20260815T072601Z-2230169`).
* **All three fragments verified** from the durable log, not a filtered
stream: `transient sequence must attach: Attach(Handshake(Io(Os {
code: 32, kind: BrokenPipe, message: "Broken pipe" })))`.
* **The line moved and that is not a fragment.** It is
`pmacs-gpu/src/attach.rs:1728` here against `:1680` in the earlier
occurrences — `attach.rs` has changed since, and this row's
convention already treats a `:LINE` suffix as occurrence-specific.
* **The tree exclusion is the strongest available in this row's
history: the branch's entire diff is DOCUMENTATION.** No Rust, no
wire surface, no `pmacs-gpu` file. Occurrences 1 and 4 argued
"unrelated lane"; this one cannot be related at all.
* **Rerun: isolated selector green** (`1 passed`, 0.01 s). Per this
file's rerun rule that establishes **intermittence only** and does
not exonerate the tree — though here there is no tree change to
exonerate.
**What five occurrences now support, stated carefully:** the failure is
**not lane-correlated**. It has appeared under three flavors across
five unrelated lanes, once on a diff that touches no code whatsoever.
That is evidence about *where the cause is not*, and still says nothing
about what it is. **The retirement condition is unchanged.**
**Not attributed to the observing lane**, and in neither case is the
reasoning merely "my diff looks unrelated": long-lines Stage 4 added no
wire surface, no protocol version change, and touched no file in
@ -866,3 +893,73 @@ claim is the one a later reader would otherwise reach for.*
| **relation to U2 — a NEAR MISS, do not match it there** | the PTY fragment is U2's exact family (`stty -a output was: ""`), but U2's selector field names only `m6_1_pty_raw_mode_disables_kernel_echo`. U2's occurrence 2 saw raw **and** canonical fail together; here **canonical redded alone and raw passed**, which U2's evidence has never shown. It is recorded here rather than folded into U2 so that the "canonical alone" case stays visible |
| **relation to U6 — its own instruction, honoured** | `composition_overhead_under_ten_percent` is one of U6's two selectors, and U6 says plainly: "If a future run reds **one** of these without the other, that is a different incident and should be judged as one." It redded without `criterion_1_end_of_line_typing…`, in a different step, at a far larger margin (1.613× here against U6's 1.297×). Judged as a different incident, as instructed |
| **what this row does NOT assert** | that the two selectors share a mechanism. They failed together once; they belong to different subsystems; and U7 already refused this exact merge for U6. The **co-failure inside one step with an in-run green control** is the signature — not either name, and not a shared cause |
### U10 — the budget red ROTATES between two consecutive runs of one commit
Recorded during §5b review round 4, 2026-08-20. **Two consecutive
`scripts/gate` runs at the same commit with a clean worktree verified
at both ends of each run** — `70b334d`, `git status --porcelain` empty
before and after, both times. Each run was 15/16 green. Each red is a
**wall-clock budget assertion in a different step**, and **each is
green in the other run**.
| field | value |
| --- | --- |
| **run A** | log `20260820T155616Z-359755`, step `13-sweep` red: `dired_open_renders_10k_entries_under_200ms`, **263.961465ms against 200ms** (32% over). Step `15-sweep-crdt` **green** |
| **run B** | log `20260820T160806Z-578046`, step `15-sweep-crdt` red: `optimistic::tests::criterion_1_end_of_line_typing_completes_sub_frame_per_keystroke`, **1.044609ms against 1ms** (4.5% over), 2148 passed. Step `13-sweep` **green** |
| **required fragments** | `M8.2 spec: 10K entries must render within 200ms; took ` / `criterion 1: per-keystroke orchestrator time` + `exceeds 1ms` |
| **status** | **two occurrences, neither reproduced; every selector green on rerun** |
| **isolated controls** | both green in an isolated rerun of their own selector at load average 9.34, through the gate's target directory — the same control shape U6 and U7 each used |
| **what IS established** | **the tree is excluded, as strongly as this repository can exclude it.** Not "the diff touches no render path" — the **same commit** produced a pass and a fail of each row, with the worktree verified clean at both ends of both runs. Neither failing path is touched by the branch under test (`src/optimistic.rs`, `tests/m8_2_acceptance.rs` and the dired paths are all absent from `git diff --name-only githubsucks/main...HEAD`) |
| **what is NOT** | **that load caused it.** Load was **not sampled during either failing step**. A 76.63 reading exists for run A but was taken later in the same run, while step 15 was compiling; run B began at 23.46. Neither figure measures the failing moment, and this row does not pretend otherwise |
| **rival CLOSED since U7** | the shared `CARGO_TARGET_DIR` confound U7 left "real and again unmeasured". Each worktree now gets its own gate target directory (`pmacs-mapping-gen-8cb089c8`); no sibling shared it. Excluded **for these occurrences only** — it says nothing about U7's |
| **machine context, NOT a cause** | 150 leaked `pmacs` daemons were live throughout, 1.9 GB resident, oldest ~6.3 days — the standing "Leaked daemons — NEEDS A LANE" item. Their instantaneous CPU sampled at ~0%. Recorded because it is true of the machine, **not** because anything here shows it mattered |
| **relation to U7 — its escalation rule, honoured and CUT BOTH WAYS** | U7 says "a future run that reds the **same** one of these twice is a different incident and should be judged as one." Run A redded `dired_open_renders_10k_entries_under_200ms`, which was U7's run-1 selector, so that selector has now redded twice, 11 days apart — filed here rather than appended to U7, as instructed. **But within this pair the selector ROTATED**, which is U7's own core signature, and run B's selector is U6's. The repeat and the rotation are both true, and this row asserts neither as the finding |
| **relation to U6** | run B's selector is one of U6's two, redding **without** `composition_overhead_under_ten_percent`. U6 instructs that one-without-the-other is a different incident; honoured here |
| **what this row does NOT assert** | a shared mechanism between the two rows, or any mechanism at all. **The signature is the rotation across an identical commit** — not either name |
**Why this family keeps recurring, stated plainly.** Every row in it is
a wall-clock budget asserted **inside a workspace-wide parallel test
run**. `cargo test --workspace` starts many test binaries at once, so
each budget competes with the rest of the sweep in **every** run,
including the ones that pass. A 4.5% overshoot on a 1ms budget is not a
signal about the code. **U9 already named the discriminating control**
— pin test-binary concurrency to 1 and separately load a lone `--lib`
binary — and it remains unrun. Until it runs, this family should not
consume another review round.
**Widening a budget is not the fix**, and R1 already rejected it.
### U11 — `dispatch_parse_round_trips_a_rust_source_file`, macOS `lua54`, one occurrence, green on rerun
Recorded from PR #242, 2026-08-20, on head `61f0faf` — the head that
merged as `47b5463`. **Deferred to post-merge absorption by decision**,
so that no docs commit would invalidate that PR's head-exact gate
evidence; the fragments lived in
[PR #242's comment](https://github.com/levineuwirth/pmacs/pull/242#issuecomment-5359360750)
in the interim, because the raw job log is machine-local and **not
portable evidence**.
| field | value |
| --- | --- |
| **selector** | `--lib async_runtime::tests::dispatch_parse_round_trips_a_rust_source_file` |
| **job / flavor** | `Test (macos-latest / lua54)`, run `32393462318` |
| **required fragment** | `trivial parse should be fast`, panicking at `src/async_runtime.rs:3328` |
| **counts** | `1960 passed; 1 failed; 3 ignored`, finished in 64.49s |
| **attempt 1** | job `96504773333` — **failure** |
| **attempt 2, rerun** | job `96511228345` — **success** |
| **status** | **one occurrence, not reproduced** |
| **THE MARGIN IS UNRECOVERABLE** | the assertion is `assert!(duration_ms < 100, "trivial parse should be fast")` — a 100ms wall-clock budget on a 30-byte source, **with `duration_ms` omitted from the message**. The red cannot say whether it missed by 1ms or by 900ms, and **no future occurrence can be compared against this one**. This is a property of the assertion, not of the observation |
| **what IS established** | **non-determinism across these observations, and nothing more.** Four passes of the same selector on the exact head in local gate `20260820T163107Z-879828` — one each in `03-lib`, `04-lib-crdt`, `13-sweep`, `15-sweep-crdt`. Two `Test (macos-latest / luajit)` greens on the same commit and OS, jobs `96504773331` and `96511275402`. `src/async_runtime.rs` **byte-identical to `main`**, blob `9310ce3fca8c5fd8ebd39a68c29ad6985e256049` on both sides — not merely an empty diff |
| **what is NOT** | environmental cause, or harmlessness. The luajit greens narrow the red below "macOS at this commit"; they do not explain it. **A green rerun establishes intermittence only** |
| **relation to U10** | **not U10.** Different selector, different fragment, different subsystem, and remote rather than local. Filed separately for the same reason U10 was filed apart from U6 and U7 |
**A recurrence is not another instance of this row.** Because the margin
was never captured, a second red cannot be compared with the first, so
it owes a **merge-base control** before any claim is made about the
tree.
**The one-line fix that would make this family diagnosable** — include
the measured `duration_ms` in the assertion message — is **owed its own
small lane**. It was deliberately kept out of #242, whose diff does not
touch `src/async_runtime.rs`.

File diff suppressed because it is too large Load Diff

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
IMPLEMENTED**; 1a onward may begin from this document.
**v26, not v25 — corrected by the panel mapping-generation slice.**
That slice (`docs/bottom-panel-framing.md` §5b) takes **v25** for
`PanelFramePayload::PresentMapped` / `FrontendEvent::PanelPointerMapped`,
and it lands ahead of 1e because panel-pointer replay blocks 1b.
Protocol slices stay serialized; one was inserted in front.
`ADVERTISED_PROTOCOL_VERSION` remains pinned at **20**.
**Verification base:** §2 is **re-measured at `4f77491`** (2026-08-12),
the tip after 1-pre; it was originally taken at `a994f37`. Sections
other than §2 were written against `a994f37` and their *rulings* are
@ -350,7 +357,7 @@ says nothing about glyphs or hit tests staying at scale 1.
| D5 | Overlay clears on **empty `Preedit`, `Ime::Disabled`, and focus loss** — all three | no overlay | clear on focus loss only → `Disabled` row leaves stale text |
| D6 | Dead-key state owned here; 1a buffers nothing | dead keys dropped | buffer in 1a → D6 by construction |
### 1e — `OpenTarget` (v25)
### 1e — `OpenTarget` (v26)
| # | Contract | Witness (fails today because) | Mutation |
|---|---|---|---|
@ -560,11 +567,11 @@ and flushed to the socket**. **Bound: 250 ms.**
| | **1a — `TextInput`** | **1e — `OpenTarget` + `OpenTargetResult`** |
|---|---|---|
| **floor** | **v24** | **v25**, after v24, serialized |
| **floor** | **v24** | **v26**, after v25's mapping generation, serialized |
| **encoding** | **appended variant**; never widen a field in place — postcard is positional | appended variants |
| **byte pin** | frozen-byte fixture on the **previous final variant** | same |
| **gate** | daemon accepts from `>= 24`; producer withholds below | `>= 25`; producer withholds below |
| **old peer** | a `< 24` frontend **retains its existing `Key` behaviour and its existing limitations** — it truncates multi-scalar input today and ignores IME, and continues to. **The guarantee is NO REGRESSION, not retroactive correctness** | a `< 25` frontend cannot drop-open; nothing it already had degrades |
| **gate** | daemon accepts from `>= 24`; producer withholds below | **`>= 26`**; producer withholds below |
| **old peer** | a `< 24` frontend **retains its existing `Key` behaviour and its existing limitations** — it truncates multi-scalar input today and ignores IME, and continues to. **The guarantee is NO REGRESSION, not retroactive correctness** | a **`< 26`** frontend cannot drop-open; nothing it already had degrades |
| **bounds** | **64 KiB** UTF-8; oversize **rejected** | **32 KiB** per raw path; non-empty path; absolute non-empty cwd; **embedded NUL rejected**; `Failed.message` capped at the **existing 4 KiB** error cap |
| **pins** | frozen bytes on `FrontendEvent`'s previous final variant | **two independent pins** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for `OpenTargetResult` |

View File

@ -0,0 +1,454 @@
# GPU probe SIGINT lane — run manifest
Every physical run behind
`docs/gpu-probe-sigint-framing.md` §4. Pushed so the evidence travels;
the log bodies stay machine-local under
`/home/jeans/build/pmacs-gate-targets/probe-sigint-evidence/` and are
identified here by SHA-256 prefix and byte count.
## Provenance honesty
These runs were made **before** this manifest existed, so their
provenance is **reconstructed, not captured**. Specifically:
- **Commands** are exact and complete argv. Revision 3 abbreviated
R7–R9 as "`--test` ×N (targets 6–37)" and F5 as "`--acceptance` ×6",
which are descriptions, not reconstructable invocations. They are
written out in full below.
- **Worktree** is exact.
- **HEAD** is given as a range where the run cannot be pinned to one
commit, and marked `~`. It is never guessed at single-commit
precision.
- **Cleanliness** was not recorded at the time and is therefore
`UNKNOWN` for every pre-manifest run. It is not inferred.
**D0 (below) exists because of this.** No conclusion in §4 should rest
on a `UNKNOWN`-cleanliness row once D0 has replaced it.
## Artifact identity — suffix is NOT byte identity
Two distinct things were conflated in revision 3 and are separated here.
**Cargo suffix** is recorded in each log and is therefore *known* per
run. **Byte identity** is not: target directories have been overwritten
many times since, so a hash computed today is the hash of whatever
occupies that path now, not of what a given run executed.
That the two differ is demonstrated, not assumed. F1 ran in the `main`
worktree (`pmacs-fdccc423`) and executed suffixes `-5d9105cb7047aab8`
and `-d4dae4f01bcdef62` — **the same suffixes** as the panel-worktree
sweeps — yet the bytes at those paths differ by worktree:
| worktree | `gpu_initial_target…-5d9105cb` | `gpu_invocation…-d4dae4f0` |
|---|---|---|
| `pmacs-fdccc423` (main) | `e057803988c34cf7` | `00f06aeb089ce38d` |
| `pmacs-mapping-gen-…` (§5b) | `1b3cc86cbb8d6092` | `ede0c07dd9abb456` |
Same suffix, different source head, different bytes **today**. So
**"workspace artifact family" was not an identity class** and is
withdrawn as a grouping.
Three levels of knowledge, kept apart:
0. **Portability caveat.** Suffixes below are read from logs that are
**machine-local**; this manifest is the portable record of them, and
a reader elsewhere is trusting this transcription, not verifying it.
Rows that were never logged say `UNKNOWN` and are not guessed.
1. **Suffix — known**, from each log. A differing suffix means Cargo
computed a different metadata hash, i.e. it treated the two as
distinct compilations.
2. **Today's bytes at a path — known**, and shown above.
3. **The bytes a historical run executed — UNKNOWN.** Target
directories have been overwritten repeatedly since; a hash computed
now is the hash of the current occupant.
So statements of the form "R9 and the sweeps ran byte-different
binaries" are **withdrawn everywhere**. What is established is that
they ran **different Cargo compilations** (different suffixes), which
is enough to void the comparison and is all that is claimed. R1 and R2
have no preserved log and cannot claim even a suffix.
## Runs
All in worktree `pmacs-mapping-gen` unless stated. `WT=mg` is
`/home/jeans/Repos/personal/pmacs-mapping-gen`; `WT=main` is
`/home/jeans/Repos/personal/pmacs` at `72da24a`. All carry
`CARGO_TARGET_DIR=/home/jeans/build/pmacs-gate-targets/pmacs-mapping-gen-8cb089c8`
except `WT=main`, which uses `…/pmacs-fdccc423`.
| id | exact command (after `cargo`) | WT | HEAD | clean | Cargo suffixes executed | result | log (sha256/16, bytes) |
|---|---|---|---|---|---|---|---|
| R1 | `test --features crdt --test gpu_invocation_acceptance ctrl_c_on_launcher_group` ×3 | mg | ~`724b785`–`5174f73` | UNKNOWN | **UNKNOWN** (no log) | green, 0.15–0.17 s | **none preserved** |
| R2 | `test --features crdt --test gpu_invocation_acceptance` | mg | ~`724b785`–`5174f73` | UNKNOWN | **UNKNOWN** (no log) | green, 15 passed | **none preserved** |
| R3 | `test --workspace --features crdt --no-fail-fast -- --skip basedpyright ctrl_c_on_launcher_group` | mg | ~`724b785`–`5174f73` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | green | `e09a96512035284e` 33113 |
| R4 | `test --features crdt --lib --test gpu_invocation_acceptance --no-fail-fast` | mg | ~`724b785`–`5174f73` | UNKNOWN | `-6b4b8223` only | green, 2145 + 15 | `89050c702de22d57` 158812 |
| R5 | `test --features crdt --no-fail-fast --test gate_script_acceptance --test gpu_invocation_acceptance` | mg | ~`5174f73`–`b72843a` | UNKNOWN | `-6b4b8223` only | green | `31b3e5249b475479` 3706 |
| R6 | `test --features crdt --no-fail-fast --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`5174f73`–`b72843a` | UNKNOWN | `-91f51d0b` / `-6b4b8223` | green, 11+15+15 | `332693a39c73731a` 4569 |
| R7 | `test --features crdt --no-fail-fast --lib --bins --test acceptance --test ambient_isolation_acceptance --test auto_indent_acceptance --test auto_indent_crdt_acceptance --test auto_pair_acceptance --test auto_pair_crdt_acceptance --test autosave_acceptance --test bottom_panel_stage1_acceptance --test bottom_panel_stage2a_acceptance --test bottom_panel_stage2b_daemon_acceptance --test bottom_panel_stage2b_gpu_acceptance --test bottom_panel_stage2b_protocol_acceptance --test comment_toggle_acceptance --test compile_mode_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | `gpu_invocation…-6b4b8223` **only** — R7 does not select `gpu_initial_target` | green | `9e1ebc59ed9f0dd4` 187531 |
| R8 | `test --features crdt --no-fail-fast --test compile_mode_crdt_acceptance --test completion_popup_acceptance --test config_registry_acceptance --test cua_region_acceptance --test desktop_acceptance --test destination_capture_acceptance --test dired_acceptance --test discovery_acceptance --test discovery_stage2_acceptance --test editops_acceptance --test find_file_acceptance --test folding_acceptance --test folding_stage2_acceptance --test full_grid_resync_acceptance --test gate_script_acceptance --test git_status_stage1_acceptance --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | `-91f51d0b` / `-6b4b8223` (`half2.log:438`, `:459`) | green | `8b26ebfcf5f871b4` 28677 |
| R9 | `test --features crdt --no-fail-fast --lib --bins --test acceptance --test ambient_isolation_acceptance --test auto_indent_acceptance --test auto_indent_crdt_acceptance --test auto_pair_acceptance --test auto_pair_crdt_acceptance --test autosave_acceptance --test bottom_panel_stage1_acceptance --test bottom_panel_stage2a_acceptance --test bottom_panel_stage2b_daemon_acceptance --test bottom_panel_stage2b_gpu_acceptance --test bottom_panel_stage2b_protocol_acceptance --test comment_toggle_acceptance --test compile_mode_acceptance --test compile_mode_crdt_acceptance --test completion_popup_acceptance --test config_registry_acceptance --test cua_region_acceptance --test desktop_acceptance --test destination_capture_acceptance --test dired_acceptance --test discovery_acceptance --test discovery_stage2_acceptance --test editops_acceptance --test find_file_acceptance --test folding_acceptance --test folding_stage2_acceptance --test full_grid_resync_acceptance --test gate_script_acceptance --test git_status_stage1_acceptance --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | **`-91f51d0b` / `-6b4b8223`** (log `:3066`, `:3087`) | green | `b31d98ee2f427eca` 214566 |
| R10 | `test --workspace --features crdt --no-fail-fast --test gpu_initial_target_acceptance --test gpu_invocation_acceptance -- --skip basedpyright` | mg | ~`b72843a` | UNKNOWN | **`-5d9105cb` AND `-d4dae4f0`** (log `:3`, `:24`) | green | `81b48fd7a0e261dc` 3553 |
| F1 | `build --workspace --no-default-features --features luajit,crdt && test --workspace --features crdt --no-fail-fast -- --skip basedpyright` | **main** | `72da24a` | clean (verified `git status --porcelain` empty) | `-5d9105cb` / `-d4dae4f0`; today's occupants `e0578039…` / `00f06aeb…` | **red** | `10b55b8ba8741125` 334446 |
| F2 | `test --workspace --features crdt --no-fail-fast -- --skip basedpyright` | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`postclean.log`) | **red** | `474f88f0dad581fe` 338555 |
| F3 | same argv as F2, with a resource sampler running | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`sweep-inst.log`) | **red** | `7b8519e7300e8bb3` 338555 |
| F4 | same argv as F2, with a process-table sampler running | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`sweep-diag.log`) | **red** | `5ccdefc5d89eece3` 338555 |
| F5 | stage 15 of `./scripts/gate --protocol --acceptance bottom_panel_stage1_acceptance --acceptance bottom_panel_stage2a_acceptance --acceptance bottom_panel_stage2b_daemon_acceptance --acceptance bottom_panel_stage2b_gpu_acceptance --acceptance bottom_panel_stage2b_protocol_acceptance --acceptance gui_stage1a_wire_acceptance` | mg | `5174f73` + uncommitted docs | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (3 bins: both ctrl_c copies + `m6_1_pty_mode_lifecycle`) | `20260817T172537Z-2375685/15-sweep-crdt.log`, sha `e5bdc911e366` |
| F6 | stage 9 of `./scripts/gate --protocol` | mg | **`724b785`** (see below) | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (2 bins, both ctrl_c copies) | `20260816T063330Z-1977433/09-sweep-crdt.log`, sha `7a75d999ac4f` |
| F7 | stage 9 of `./scripts/gate --protocol` | mg | `5174f73` (committed 08:45:41, run began 08:45:49) | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (2 bins, both ctrl_c copies) | `20260816T064549Z-2144707/09-sweep-crdt.log`, sha `9d3c6ad1bfc9` |
Supporting, not a reduction: `9b8a01076b44bb7c` 98838
(`proc-sample.log`) is the process-table sampler output behind the
retracted "mechanism located" claim.
**The red count is 7, not 5.** Revision 3 said "F1–F5, red 5/5" while
the framing separately cited gate `…-2144707`, a *different* physical
run the manifest never listed. Both exist, both are red, and there is a
third gate run too. F1–F7 above enumerate all seven, each with its own
log digest. F5 also has an extra failing binary
(`m6_1_pty_mode_lifecycle_started_then_exited`) that F6/F7 do not.
**R2 and R6 are distinct runs.** Revision 2's §4 cited `gpu3.log` for
both; that log is R6's three-suite run only, and R2's log was never
preserved. R1 likewise has no log. Both are marked accordingly rather
than backfilled.
## The onset is datable — and this supersedes the reduction matrix
`sweep-crdt` has 17 log files in this target directory. Counted **per
test copy**, not per stage:
| outcome | runs |
|---|---|
| both copies `... ok` | **13** |
| **neither copy executed** — the stage died compiling `pmacs` (`error[E0308]: mismatched types`), log `20260815T182846Z-708693` | **1** |
| both copies `FAILED` | **3** (`…-1977433`, `…-2144707`, `…-2375685`) |
Revision 4 said "14 runs, 11 green, 3 red on other tests" and that the
earlier reds "failed on unrelated tests". **Both were wrong**: the
count is 13 / 1 / 3, and one of the earlier non-passing runs is a
**compile failure that never reached either copy**, not a red on
another test. Of the genuinely red-on-other-tests sweeps, `…-2839374`
(08-15 09:55) failed protocol and version rows and `…-830195`
(08-15 18:37) failed `composition_overhead_under_ten_percent` and a
v21/v20 row — those two did execute `ctrl_c`, and it passed.
So the failure is **not long-standing**. Last observed green:
`20260815T185708Z`. First observed red: `20260816T063330Z`. The machine
was not rebooted across it — the current boot began 08-14 09:30.
**But this is not yet a source boundary.** Reflog and commit times put
HEAD at `7599661` during the last green (`3c06176` was committed 40 s
*after* that run finished) and at `724b785` during the first red
(`5174f73` was committed 08:45:41, after that run ended at 08:42:01 —
revision 4's manifest wrongly recorded F6 at `5174f73`). Cleanliness
was captured for **neither**, and the tree was being edited throughout.
So the window dates a **machine/worktree-state transition**, not two
clean source revisions.
One relationship is worth recording **for exactly what it shows**:
`72da24a` is an **ancestor** of `7599661` (`git merge-base
--is-ancestor`), yet fails today (F1) while `7599661` passed on 08-15.
The two observations differ in commit **and** environment **and**
time, so they are **non-comparable and support no causal conclusion of
any kind** — not even "outcome is not determined by commit alone",
since different commits can deterministically produce different
outcomes. The pair does not discriminate an environmental change, a
source/environment interaction, or a fix before `7599661` followed by a
regression before `724b785`. And an ancestor outside the interval is
irrelevant to whether the interval contains a regression.
## D1/D2 — EXECUTED 2026-08-19. The outer invocation is the variable
**The causal variable is the OUTER invocation of the test runner**, so
it is recorded here as a first-class column. Earlier "exact commands"
in this file are incomplete for that reason: they gave the inner
`cargo` argv and omitted how the runner itself was started.
### Controlled arms, committed head, worktree-local target
Head `77b623c`, `dirty=0`, worktree
`/home/jeans/Repos/personal/pmacs-probe-sigint`, target
`/home/jeans/build/pmacs-gate-targets/pmacs-probe-sigint-84ed0f9e`,
`TMPDIR=/home/jeans/build/pmacs-gate-targets/tmp/arms`.
Inner command, identical in both arms:
```
cargo test --features crdt --no-fail-fast \
--test gpu_invocation_acceptance --test gpu_initial_target_acceptance \
-- ctrl_c_on_launcher_group
```
The **actual evidence-producing outer invocations** were:
```
# fg arm
/home/jeans/build/pmacs-gate-targets/d0a/arms2.sh fg
# bg arm
setsid nohup /home/jeans/build/pmacs-gate-targets/d0a/arms2.sh bg \
> /dev/null 2>&1 & disown
```
`arms2.sh` is machine-local, so naming it is not portable provenance by
itself. This is the complete body that produced `arms2.tsv`; it records
the inner `cargo` exit rather than the status of the disowned outer job:
```sh
#!/bin/sh
# D1/D2 controlled arms, v2: full binary SHA-256 captured PER RUN,
# immediately after the run, before anything can rebuild them.
set -u
WT=/home/jeans/Repos/personal/pmacs-probe-sigint
TD=/home/jeans/build/pmacs-gate-targets/pmacs-probe-sigint-84ed0f9e
TMP=/home/jeans/build/pmacs-gate-targets/tmp/arms; mkdir -p "$TMP"
OUT=/home/jeans/build/pmacs-gate-targets/d0a
ARM="$1"; LOG="$OUT/arm2-$ARM.log"
HEAD=$(git -C "$WT" rev-parse HEAD); DIRTY=$(git -C "$WT" status --porcelain | wc -l)
env TMPDIR="$TMP" CARGO_TARGET_DIR="$TD" sh -c \
"cd $WT && cargo test --features crdt --no-fail-fast --test gpu_invocation_acceptance --test gpu_initial_target_acceptance -- ctrl_c_on_launcher_group" \
> "$LOG" 2>&1
EXIT=$?
OK=$(grep -c "ctrl_c_on_launcher_group_does_not_reach_spawned_daemon \.\.\. ok" "$LOG")
BAD=$(grep -c "ctrl_c_on_launcher_group_does_not_reach_spawned_daemon \.\.\. FAILED" "$LOG")
SIG=$(grep -m1 -o "SigIgn=[0-9a-f]*" "$LOG" || echo "SigIgn=not-captured")
# Full digests of exactly the binaries this run executed, taken now.
HASHES=""
for b in $(grep -E "Running tests/gpu_" "$LOG" | sed 's|.*deps/||; s|)||'); do
HASHES="$HASHES $b=$(sha256sum "$TD/debug/deps/$b" | cut -d' ' -f1)"
done
printf '%s\tarm=%s\thead=%s\tdirty=%s\texit=%s\tok=%s\tfailed=%s\t%s\tlogsha=%s\thashes=%s\n' \
"$(date -Iseconds)" "$ARM" "$(echo $HEAD | cut -c1-7)" "$DIRTY" "$EXIT" "$OK" "$BAD" "$SIG" \
"$(sha256sum "$LOG" | cut -d' ' -f1)" "$HASHES" >> "$OUT/arms2.tsv"
echo "arm2 $ARM: exit=$EXIT ok=$OK failed=$BAD $SIG"
```
### Result — head `77b623c`, `dirty=0`, digests captured PER RUN
| arm | outer | cargo exit | ok | failed | `SigIgn` | log sha256 (full) |
|---|---|---|---|---|---|---|
| fg | foreground | 0 | 2 | 0 | not captured (no failure ⇒ no dump) | `b6117619242f3dabadbe5826f59f11cef451cd09a2beb304e9a3dec1a4616465` |
| bg | `setsid nohup … &` | 101 | 0 | 2 | `0000000000001007` | `30ac6568e345c485ddf23734a239d71bd06d4a3c2d549f1b4a8942a930527507` |
Binary digests, **full SHA-256, taken immediately after each run before
anything could rebuild them** — not read later from a reused path:
| binary | fg arm | bg arm |
|---|---|---|
| `gpu_initial_target_acceptance-91f51d0b5303ff9f` | `0890b78cca22ac1e80b79845f85fb6e88def3330db15ae123a2a672d3084124c` | `0890b78cca22ac1e80b79845f85fb6e88def3330db15ae123a2a672d3084124c` |
| `gpu_invocation_acceptance-6b4b8223dea45247` | `ef6ff1c15e11062ab53a075763814f32c1bbc9be1b146d068c60e91fa247c696` | `ef6ff1c15e11062ab53a075763814f32c1bbc9be1b146d068c60e91fa247c696` |
**These are byte-identical, and that claim is now carried by the
capture rather than by inference.** The earlier arms table recorded only
16-character prefixes at run time and its full values were read
afterwards from reused paths — which is exactly the provenance rule
§7/D0 states, applied against my own record. Those rows are superseded
by the table above. The raw `arms.tsv` and `arms2.tsv` remain
machine-local; the literal harness and full rows above are their
portable transcription.
### Disposition — UNRECORDED CORROBORATION, not a controlled arm
This table was read ad hoc from `/proc/self/status` in the two shells
and **its runs were not captured**: no head, no cleanliness, no log,
no digest. It agrees with the arms above and with §4c's capture, and it
is labelled separately for that reason — it corroborates, it does not
evidence.
| context | child `SigIgn` | `SIGINT` |
|---|---|---|
| foreground | `0000000000001000` | bit 12 (SIGPIPE) only — deliverable |
| `setsid nohup … &` | `0000000000000007` | SIGHUP, SIGINT, SIGQUIT — ignored |
The raw behavioural probe underlying the selected helper (framing §7c)
supersedes it as the *recorded* mechanism check:
`sh -c 'trap "exit 23" 2; kill -INT $$; exit 0'` exits **23** when
`SIGINT` is deliverable and **0** when it is inherited as ignored.
Verified in both contexts. It is **not itself the remedy** because it
does not preserve `kill` failure; §7c specifies the total checked-in
helper and its 0/1/2 consumer ABI.
### The first D1/D2 capture, and why it is superseded
The capture quoted in framing §4c came from `d12.log`, which finished
14:10 — **five minutes before `afe3631` committed the diagnostic
code** — and ran in the reused `d0a-B` target directory rather than
this worktree's. Its signal facts agree with the arms above, but it is
**not admissible provenance**: uncommitted tree, foreign target. The
arms table replaces it, and `d12.log` is retained only as the first
sighting.
### Historical foreground/background mapping — RECONSTRUCTED
The claim that "every reduction was foreground and every full sweep was
backgrounded" is **reconstructed from this session's transcript, not
captured at run time**. No run before today recorded its outer
invocation, because none of the harnesses knew it mattered. It is
consistent with every observation and with the two arms above, but it
is inference, and rows R1–R10 and F1–F7 carry **no outer-invocation
field**. That gap is the direct cause of nine revisions spent on a
confounded matrix.
## D0a — EXECUTED 2026-08-19. Verdict: difference NOT captured
Ten runs, counterbalanced `A B B A A B B A A B`, N = 5 per endpoint,
**zero voids, zero splits**. Endpoints checked out detached and clean
in dedicated worktrees (`pmacs-d0a-A`, `pmacs-d0a-B`), each with its own
target directory, each run performing the gate's `build-crdt`
precondition then the `sweep-crdt` command. `dirty=0` verified per run.
| run | endpoint | HEAD | class | ctrl_c ok/failed | red bins | log |
|---|---|---|---|---|---|---|
| A#1 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-1.log` |
| B#1 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-1.log` |
| B#2 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-2.log` |
| A#2 | A | `7599661` | **red** | 0 / 2 | 4 | `d0a/A-2.log` |
| A#3 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-3.log` |
| B#3 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-3.log` |
| B#4 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-4.log` |
| A#4 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-4.log` |
| A#5 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-5.log` |
| B#5 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-5.log` |
**Exact commands.** Every run, in full. `<WT>` is
`/home/jeans/Repos/personal/pmacs-d0a-A` (detached at `7599661`) or
`/home/jeans/Repos/personal/pmacs-d0a-B` (detached at `724b785`);
`<TD>` is `/home/jeans/build/pmacs-gate-targets/d0a-A` or
`/home/jeans/build/pmacs-gate-targets/d0a-B` correspondingly:
```
env TMPDIR=/home/jeans/build/pmacs-gate-targets/tmp/d0a \
CARGO_TARGET_DIR=<TD> \
sh -c 'cd <WT> \
&& cargo build --workspace --no-default-features --features luajit,crdt \
&& cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright'
```
**Per-run provenance, transcribed.** All runs: `exit=101`, `dirty=0`,
`ok=0 failed=2`, suffixes `-5d9105cb` / `-d4dae4f0`, `/tmp` 3 G of 30 G.
Times are local 2026-08-19. `load` is the 1/5/15 average at run start;
`MemFree`/`MemAvail` in MB; `daemons` counts live `pmacs --daemon`:
| start | run | class | red bins | load | MemFree | MemAvail | daemons | log sha256/16 |
|---|---|---|---|---|---|---|---|---|
| 12:21:20 | A#1 | red | 3 | 2.51 2.85 3.57 | 1549 | 42069 | 72 | `e1c0fe47d55d8f5e` |
| 12:26:55 | B#1 | red | 2 | 7.86 13.48 8.97 | 9405 | 42725 | 76 | `3105794d515e6ec3` |
| 12:32:12 | B#2 | red | 2 | 8.04 18.28 13.23 | 9761 | 43370 | 80 | `07a662fb5ca15687` |
| 12:35:39 | A#2 | red | 4 | 11.34 21.23 16.20 | 8933 | 43281 | 84 | `450aacb9d15244c9` |
| 12:39:35 | A#3 | red | 3 | 13.40 26.07 20.45 | 10628 | 42989 | 88 | `19156bbbc852e2d3` |
| 12:43:26 | B#3 | red | 2 | 8.66 22.18 20.82 | 8778 | 43588 | 92 | `6f0d768a76ed9cb0` |
| 12:47:20 | B#4 | red | 2 | 17.96 33.10 27.07 | 10403 | 43617 | 96 | `75a043a3d5568598` |
| 12:50:49 | A#4 | red | 3 | 10.99 27.07 26.52 | 10702 | 43335 | 100 | `c1b6d88c08764d9a` |
| 12:54:47 | A#5 | red | 3 | 9.75 28.50 28.70 | 8494 | 43053 | 104 | `2ae21a3f53fc1a1a` |
| 12:58:38 | B#5 | red | 2 | 11.43 26.69 28.97 | 8562 | 43122 | 108 | `82c5baa0c7e49e37` |
**Two defects in the previous transcription of this table, recorded
rather than silently fixed.** Every digest had lost its leading hex
character — A#1 read `1c0fe47d55d8f5e…` where the value is
`e1c0fe47d55d8f5e` — because the extraction started one byte late in
`logsha=<value>`. And `/tmp` and `MemAvailable` were captured by the
harness but dropped from the table. A transcription that silently
corrupts its own digests is worse than a pointer to the raw file, since
it looks verifiable and is not.
**`uptime` was NOT captured, and is `UNKNOWN` for all ten runs.** §7's
condition list names `uptime`; the harness recorded only the load
averages from it and discarded the elapsed time. The classifications
stand — none of them depends on it — but the condition list was **not
fully satisfied**, and D1/D2's harness must capture it. Recorded rather
than quietly treated as met.
Note the leaked-daemon count climbing 72 → 108, four per run. Recorded,
not implicated: it rises monotonically while every run classifies the
same. Raw logs stay machine-local at
`/home/jeans/build/pmacs-gate-targets/d0a/`; the table above is the
portable record.
**Endpoint verdicts: A uniform-red, B uniform-red.** By the approved
table this is the *both endpoints uniform the same way* row:
> **the difference is not captured by those two commits** under current
> conditions, and the question becomes what else changed across the
> window.
### What this settles
- **The two commits do not discriminate under current conditions.**
`7599661` passed inside `sweep-crdt` on 08-15 and now fails **5/5**
clean. **No bisect of `7599661..724b785` is justified under current
conditions**, and none will be run.
- **That is the whole of the causal claim.** An earlier wording here
said "the source hypothesis is eliminated" and that "the interval
cannot contain the transition"; both are **withdrawn**. Uniform-red
today is silent about what was true on 08-15 — a historical source
regression could be **masked** by a later environmental effect, or by
a source/environment interaction that makes both commits fail now.
Not discriminating is not the same as not differing.
- **The onset window is deprioritised, not excluded.** It remains a
true observation, and it remains *possible* that source matters
within it; what is established is only that source cannot be
probed **by this comparison, now**.
- **A reliable reproduction now exists.** 10/10 today, on two different
commits, at ~4 minutes per run. **This is the most useful thing D0a
produced**: the mechanism diagnostics D1/D2 no longer depend on a
rare event, and can proceed immediately.
- **A's three extra failures are recorded, not swept up**:
`a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal`,
`one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client`, and
`m6_1_pty_mode_lifecycle_started_then_exited`. The v21/v20 row is
expected to differ at that older commit; the other two are
process/PTY-spawn rows, the same family as the target. They do not
affect classification, which reads only the two target copies.
### What it does not settle
Nothing about the mechanism. "What else changed across the window" has
one cheap negative result so far: **no package activity in the window**
(`/var/log/pacman.log` shows nothing between 08-15 19:57 and 08-16
06:33; nearest is 08-18). The `1.88` rust toolchain directory has mtime
08-15 22:39, inside the window, but the gate builds with `1.95.0`.
Neither is pursued further, because with a reliable reproduction in
hand **direct measurement (D1/D2) dominates archaeology**.
## D0 — re-run the matrix with captured provenance
Before any §4 row is relied on for a conclusion, re-run the reductions
under a harness that records, per run and at the time: exact argv and
environment, worktree, `git rev-parse HEAD`, `git status --porcelain`
emptiness, the artifact hashes actually executed, the result, and the
log digest. Two constraints learned the hard way:
- run them **at `main`**, not on a feature branch — R1–R10 ran in the
`panel-mapping-generation` worktree, which carries §5b changes;
- record the **artifact hash per run at run time**, since command shape
changes it — the reason R9's result did not mean what it appeared to
mean — and since a hash computed later reflects only what occupies
that path now;
- **first, reproduce the two candidate endpoints CLEANLY** —
`7599661` (last observed green) and `724b785` (first observed red) —
each checked out clean, each in its **own isolated target
directory**. A decision procedure with **no predicted outcome**, and
**not decided by one run per endpoint**. The framing's §7 D0a holds
the governing contract — the total run classifier
(green / red / split / void), the void budget, the endpoint table and
the bisect-step policy. In summary, keeping the two conclusions with
the verdicts they actually belong to:
- **expected-direction clean split** (`7599661` uniform green,
`724b785` uniform red) → a bisect of `7599661..724b785` is
permitted;
- **inverted clean split** (`7599661` uniform red, `724b785` uniform
green) → the commits differ, but the observed direction contradicts
the onset reading; record it and re-examine that reading before any
bisect;
- **mixed at either endpoint** → the failure is **intermittent under
fixed source**; no bisect;
- **both endpoints uniform the same way**, green or red → **the
difference is not captured by those two commits** under current
conditions, and the question becomes what else changed across the
window.
Revision 8 attached "the difference is not captured" to the *mixed*
clause. That was wrong — mixed means intermittency, not absence of a
difference — and revision 9 corrected it in the framing but **left
this file untouched**, because the edit's anchor silently missed.

View File

@ -379,6 +379,28 @@ fn coalesce_kind(event: &FrontendEvent) -> Option<u8> {
kind: MouseKind::Drag(_),
..
} => Some(7),
// §5b G13a — the v25 mapped family needs its OWN tags. Falling
// through to `None` would make pixel-rate mapped motion lossless
// and fill the bounded queue, which is `OUTBOX_MAX` events of
// hover before the socket closes.
//
// Tail-replacement takes the whole newer event, so the surviving
// event carries the latest coordinate AND the latest
// `mapping_generation` together — a collapsed run can never pair
// a new coordinate with a stale generation.
//
// Distinct from the legacy tags 6/7 rather than shared with
// them. A session negotiates one family and never mixes the two,
// so sharing would buy nothing and would let a family confusion
// collapse silently instead of showing up as two queued events.
FrontendEvent::PanelPointerMapped {
kind: MouseKind::Move,
..
} => Some(8),
FrontendEvent::PanelPointerMapped {
kind: MouseKind::Drag(_),
..
} => Some(9),
_ => None,
}
}
@ -1098,6 +1120,36 @@ impl AttachClient {
})
}
/// §5b — the MAPPED gesture, echoing the generation of the frame
/// this frontend is displaying.
///
/// A separate entry point rather than an `Option<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
/// hit-tested locally to a panel CELL. Callers gate on
/// [`Self::session_protocol_version`] `>= 21`.
@ -1452,6 +1504,115 @@ mod tests {
}
}
fn fe_panel_mapped(kind: MouseKind, row: u32, col: u32, generation: u64) -> FrontendEvent {
FrontendEvent::PanelPointerMapped {
frontend_id: FrontendId(1),
buffer_id: BufferId::from_raw(1),
coord: CellCoord::new(row, col),
kind,
mods: Modifiers::NONE,
geometry_epoch: 1,
panel_epoch: 1,
mapping_generation: generation,
}
}
/// §5b G13a/G13b — the mapped family keeps the legacy family's
/// coalescing contract: `Move` and `Drag` collapse to the latest
/// coordinate AND generation together; press, release and every
/// wheel kind stay lossless and ordered.
#[test]
fn mapped_panel_motion_coalesces_carrying_its_latest_generation() {
let mut ob = Outbox::new();
ob.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 0, 0, 4));
ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 1, 4));
ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 2, 5));
ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 3, 6));
ob.enqueue(fe_panel_mapped(MouseKind::Up(MouseButton::Left), 0, 3, 6));
assert_eq!(
ob.queue.len(),
3,
"the drag run collapsed to one; without a tag of its own the \
mapped family is lossless and this is five"
);
// The whole event is replaced, so coordinate and generation
// advance TOGETHER. A tag that replaced only the coordinate
// would leave generation 4 on a cell measured under 6, which the
// daemon then refuses as stale.
assert!(
matches!(
&ob.queue[1],
FrontendEvent::PanelPointerMapped {
kind: MouseKind::Drag(MouseButton::Left),
coord: CellCoord { row: 0, col: 3 },
mapping_generation: 6,
..
}
),
"surviving drag: {:?}",
&ob.queue[1]
);
// G13b — wheel ticks carry scroll DISTANCE. Two ticks in one
// generation must both survive the queue, or the panel scrolls
// once and stops.
let mut wheel = Outbox::new();
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollUp, 1, 1, 7));
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollUp, 1, 1, 7));
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollDown, 1, 1, 7));
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollLeft, 1, 1, 7));
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollRight, 1, 1, 7));
assert_eq!(wheel.queue.len(), 5, "every wheel kind stays lossless");
// Repeated presses are what the daemon reads as a multi-click,
// and a right press is the context gesture.
let mut presses = Outbox::new();
presses.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 2, 2, 8));
presses.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 2, 2, 8));
presses.enqueue(fe_panel_mapped(
MouseKind::Down(MouseButton::Right),
2,
2,
8,
));
assert_eq!(presses.queue.len(), 3, "presses stay lossless");
// Motion does not collapse ACROSS an intervening lossless event.
let mut mixed = Outbox::new();
mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 1, 9));
mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 2, 9));
assert_eq!(mixed.queue.len(), 1);
mixed.enqueue(fe_panel_mapped(MouseKind::ScrollDown, 3, 2, 9));
mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 3, 9));
assert_eq!(
mixed.queue.len(),
3,
"the wheel tick between them must not be jumped"
);
// And Move does not fold into a Drag tail: they are separate
// gestures, and one tag for both would turn a hover into part of
// a selection.
let mut kinds = Outbox::new();
kinds.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 4, 1, 9));
kinds.enqueue(fe_panel_mapped(MouseKind::Move, 4, 2, 9));
assert_eq!(kinds.queue.len(), 2);
// The two FAMILIES do not coalesce into each other either.
let mut families = Outbox::new();
families.enqueue(FrontendEvent::PanelPointer {
frontend_id: FrontendId(1),
buffer_id: BufferId::from_raw(1),
coord: CellCoord::new(5, 1),
kind: MouseKind::Move,
mods: Modifiers::NONE,
geometry_epoch: 1,
panel_epoch: 1,
});
families.enqueue(fe_panel_mapped(MouseKind::Move, 5, 2, 9));
assert_eq!(families.queue.len(), 2);
}
/// Acceptance 34: terminal move/drag runs coalesce to the latest
/// cell, while press, release, and wheel stay lossless and ordered.
#[test]

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_title.clone_from(&frame.title);
}
match msg.as_ref() {
InstanceMessage::PanelFrame(
pmacs_protocol::panel::PanelFramePayload::Present(frame),
) => {
// §5b — a panel message is only NOTED here; its facts
// are read from the retained state AFTER
// `apply_attach_message` has ruled on it.
//
// Reading the raw payload was a false-positive
// generator: once one valid frame had landed, a
// REJECTED frame — wrong family, invalid, stale
// generation — still supplied the expected text while
// the retained old frame supplied the rendering. The
// probe would report the band showing something it does
// not show.
// The payload KIND is kept only to tell a real `Absent`
// apart from a refusal; every fact below comes from the
// accepted state.
let panel_absent_payload = matches!(
msg.as_ref(),
InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent)
);
let panel_message = matches!(msg.as_ref(), InstanceMessage::PanelFrame(_));
// The COMPLETE accepted authority, both halves. An
// epoch/size triple is not enough: ordinary content,
// focus, cursor and mapping-generation updates all leave
// it unchanged, so accepted frames would go uncounted —
// including the identical-frame/higher-generation case
// this slice requires — and a fixture waiting for two
// frames would wait forever.
let authority_before = state
.panel
.presented()
.cloned()
.map(|frame| (frame, state.panel.mapping_generation));
state.apply_attach_message(*msg);
if panel_message {
let authority_after = state
.panel
.presented()
.cloned()
.map(|frame| (frame, state.panel.mapping_generation));
if authority_before != authority_after {
facts.panel_frames += 1;
}
if let Some((frame, _)) = authority_after.as_ref() {
facts.panel_rows = frame.size.rows;
facts.panel_cols = frame.size.cols;
facts.panel_focused = frame.focused;
@ -864,12 +903,15 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
facts.panel_text_observed = true;
}
}
InstanceMessage::PanelFrame(
pmacs_protocol::panel::PanelFramePayload::Absent,
) => facts.panel_absent_observed = true,
_ => {}
// Absence is only ever reported for an actual
// `Absent`. Inferring it from `presented() == None`
// turns a REFUSAL with nothing retained into "the
// daemon says there is no band", which is a
// different fact entirely.
if panel_absent_payload {
facts.panel_absent_observed = true;
}
}
state.apply_attach_message(*msg);
if is_snapshot {
// The dual declaration: a byte viewport for a
// document, a cell size for a terminal. The daemon
@ -1972,7 +2014,7 @@ struct State {
/// Whether the negotiated session carries the panel wire at all.
///
/// Keyed on the NEGOTIATED version, never the `Hello` baseline.
panel_wire: bool,
panel_family: PanelFamily,
/// Set when a font/scale transaction has invalidated the panel's
/// geometry declaration, so the caller that owns the client knows to
/// re-declare under a `Metrics` trigger.
@ -2084,9 +2126,60 @@ struct PanelDrag {
sent_rows: u32,
}
/// §5b — which panel family this session negotiated.
///
/// **One value, derived once from `session_protocol_version`, used for
/// BOTH payload acceptance and pointer production.** Deriving the two
/// independently is how a frontend ends up accepting one family while
/// producing the other, which is a bypass with extra steps: the peer
/// would be speaking v25 inbound and v24 outbound and neither side
/// could tell.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum PanelFamily {
/// Below `PANEL_MIN_VERSION`: no panel at all.
#[default]
Unsupported,
/// v21–v24: `Present` in, `PanelPointer` out.
Legacy,
/// v25 and later: `PresentMapped` in, `PanelPointerMapped` out.
Mapped,
}
impl PanelFamily {
/// Classify a negotiated session version.
///
/// Read from the NEGOTIATED version, never the `Hello` baseline:
/// that stays at the compatibility floor forever, so reading it
/// would leave the band permanently dark.
fn from_session_version(session_protocol_version: u32) -> Self {
if session_protocol_version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION {
Self::Mapped
} else if session_protocol_version >= PANEL_MIN_VERSION {
Self::Legacy
} else {
Self::Unsupported
}
}
/// Whether a panel exists on this session at all.
fn carries_panel(self) -> bool {
!matches!(self, Self::Unsupported)
}
}
/// The GPU frontend's half of the bottom panel (Q#BP15, Q#BP15a, Q#BP16).
#[derive(Default)]
struct PanelBand {
/// §5b — the mapping generation the retained frame was published
/// at, echoed by every `PanelPointerMapped` this frontend sends.
///
/// Moves ATOMICALLY with `frame`: a generation naming a frame that
/// was never installed would be authority for something the user
/// cannot see.
///
/// **Nondecreasing, and NOT cleared by `Absent`** — a frame delayed
/// across a hide must not roll this frontend's authority backward.
mapping_generation: Option<u64>,
/// The last **valid** frame received, retained until an authoritative
/// `Absent`.
///
@ -2141,6 +2234,15 @@ struct PanelBand {
/// suppress it. This one is a TERMINATION FALLBACK with a different
/// lifetime, and `panel_motion_is_new` never consults it.
gesture_last_content_cell: Option<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
/// decides the `RowResize` cursor icon.
hover_divider: bool,
@ -2504,15 +2606,56 @@ impl App {
else {
return false;
};
let Some(client) = self.attach_client.as_ref() else {
return false;
// §5b — production reads the SAME negotiated family as
// acceptance. Deriving them separately is how a frontend ends
// up accepting one family and producing the other.
self.send_panel_gesture(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods);
true
}
/// Ship one panel gesture in whichever family this session
/// negotiated (§5b).
///
/// The single place that chooses, so acceptance and production
/// cannot disagree. A mapped session with no retained generation
/// sends NOTHING rather than falling back to the legacy variant:
/// falling back would be the frontend half of the bypass, and the
/// daemon would refuse it anyway.
fn send_panel_gesture(
&self,
geometry_epoch: u64,
panel_epoch: u64,
buffer_id: pmacs_protocol::BufferId,
coord: CellCoord,
kind: ProtocolMouseKind,
mods: Modifiers,
) {
let (Some(client), Some(state)) = (self.attach_client.as_ref(), self.state.as_ref()) else {
return;
};
if let Err(e) =
client.send_panel_pointer(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods)
{
let sent = match state.panel_family {
PanelFamily::Unsupported => return,
PanelFamily::Legacy => {
client.send_panel_pointer(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods)
}
PanelFamily::Mapped => {
let Some(mapping_generation) = state.panel.mapping_generation else {
return;
};
client.send_panel_pointer_mapped(
geometry_epoch,
panel_epoch,
buffer_id,
coord,
kind,
mods,
mapping_generation,
)
}
};
if let Err(e) = sent {
eprintln!("pmacs-gpu: send_panel_pointer failed: {e}");
}
true
}
/// Ship a panel gesture at an explicitly chosen cell.
@ -2543,16 +2686,11 @@ impl App {
let Some(coord) = coord else {
return false;
};
if let Err(e) = client.send_panel_pointer(
frame.geometry_epoch,
frame.panel_epoch,
frame.buffer_id,
coord,
kind,
mods,
) {
eprintln!("pmacs-gpu: send_panel_pointer failed: {e}");
}
// §5b — through the one family-aware sender, so this site
// cannot drift from the other into producing the wrong variant.
let (geometry_epoch, panel_epoch, buffer_id) =
(frame.geometry_epoch, frame.panel_epoch, frame.buffer_id);
self.send_panel_gesture(geometry_epoch, panel_epoch, buffer_id, coord, kind, mods);
true
}
@ -5541,7 +5679,7 @@ impl State {
terminal: None,
panel: PanelBand::default(),
panel_text_buffers: Vec::new(),
panel_wire: false,
panel_family: PanelFamily::Unsupported,
panel_metrics_changed: false,
last_terminal_size_sent: None,
terminal_frame_error_latched: false,
@ -6879,7 +7017,7 @@ impl State {
/// `Hello` baseline: the baseline stays at the compatibility floor
/// forever, so reading it here would leave the band permanently dark.
fn set_panel_wire(&mut self, session_protocol_version: u32) {
self.panel_wire = session_protocol_version >= PANEL_MIN_VERSION;
self.panel_family = PanelFamily::from_session_version(session_protocol_version);
}
/// The band inset the document boundary is computed from.
@ -6997,7 +7135,7 @@ impl State {
/// the attach client, so the whole state machine — dedup, exhaustion,
/// the latch — is reachable without a daemon.
fn next_geometry_declaration(&mut self, trigger: GeometryTrigger) -> Option<(u64, CellSize)> {
if !self.panel_wire || self.panel.exhausted {
if !self.panel_family.carries_panel() || self.panel.exhausted {
return None;
}
let (total, advance) = self.declared_cell_total();
@ -7047,6 +7185,13 @@ impl State {
return false;
}
match payload {
// §5b G8d — a mapped frame is accepted ONLY by a session
// that negotiated the mapped family. Retention is ATOMIC on
// refusal: nothing about the previous frame, its generation
// or the pointer state is touched on the way out.
PanelFramePayload::PresentMapped { .. } if self.panel_family != PanelFamily::Mapped => {
false
}
PanelFramePayload::Absent => {
// Authoritative removal, and always safe. Note this does
// NOT clear the geometry declaration: the frontend's frame
@ -7061,8 +7206,66 @@ impl State {
self.panel.pointer_held = false;
self.panel.last_pointer_cell = None;
self.panel.gesture_last_content_cell = None;
self.panel.last_pointer_generation = None;
had
}
// §5b G8b — a mapped session REJECTS the legacy family
// rather than painting a band it cannot safely hit-test. A
// frame with no mapping identity is one whose cells cannot
// be inverted safely, so accepting it would reintroduce the
// hole from the receiving side.
// Gated on the POSITIVE family, not on "not mapped".
// `Unsupported` is neither, and a `!= Mapped` test let it
// through — a session below `PANEL_MIN_VERSION` accepting a
// band it never negotiated. `carries_panel()` guards the
// geometry declaration, not this seam.
PanelFramePayload::Present(_) if self.panel_family != PanelFamily::Legacy => false,
PanelFramePayload::PresentMapped {
frame,
mapping_generation,
} => {
// §5b — zero is the wire's uninitialised value and is
// refused like any mismatch, and the generation is
// NONDECREASING: a delayed lower frame must not roll
// this frontend's authority backward.
if mapping_generation == 0
|| self
.panel
.mapping_generation
.is_some_and(|held| mapping_generation < held)
{
return false;
}
if let Err(error) = frame.validate() {
eprintln!("pmacs-gpu: rejecting invalid mapped panel frame: {error}");
return false;
}
// A duplicate frame at the SAME generation does no work.
// A duplicate at a HIGHER one still updates authority:
// the daemon has re-keyed the mapping, and echoing the
// old generation would have every gesture refused.
if self.panel.frame.as_ref() == Some(&frame)
&& self.panel.mapping_generation == Some(mapping_generation)
{
return false;
}
// NOTE: the gesture-latch reset on an identity change is
// R-d, owned by `panel-pointer-replay`. It is not
// duplicated here — two branches resetting the same
// latch would conflict at the rebase and neither would
// own the contract.
let plan = TerminalPaintPlan::build_grid(
frame.size,
&frame.cells,
frame.cursor,
Self::terminal_palette(),
);
self.panel.frame = Some(frame);
self.panel.mapping_generation = Some(mapping_generation);
self.panel.plan = Some(plan);
self.rebuild_panel_text_buffers();
true
}
PanelFramePayload::Present(frame) => {
if let Err(error) = frame.validate() {
eprintln!("pmacs-gpu: rejecting invalid panel frame: {error}");
@ -7435,10 +7638,18 @@ impl State {
/// traffic and every one of those is a daemon-side gesture — the same
/// reason the terminal path dedupes.
fn panel_motion_is_new(&mut self, coord: CellCoord) -> bool {
if self.panel.last_pointer_cell == Some(coord) {
// §5b G9b — keyed by generation as well as cell. Deliberately
// read here rather than reset from the frame path: resetting on
// every accepted repaint would re-arm within one generation and
// bring pixel-rate traffic back, and the dedupe would stop
// being a dedupe.
if self.panel.last_pointer_cell == Some(coord)
&& self.panel.last_pointer_generation == self.panel.mapping_generation
{
return false;
}
self.panel.last_pointer_cell = Some(coord);
self.panel.last_pointer_generation = self.panel.mapping_generation;
true
}
@ -7451,6 +7662,7 @@ impl State {
// Safe in both directions: the press path rewrites it immediately
// after arming, and a release READS it before this runs.
self.panel.gesture_last_content_cell = None;
self.panel.last_pointer_generation = None;
}
/// Begin a divider drag at surface pixel `y`, if the pointer is on the
@ -20012,6 +20224,460 @@ mod tests {
}
}
/// A mapped session with a real installed frame, and the frame.
///
/// The baseline is installed in the CORRECT family, so every
/// assertion below has real authority to preserve rather than a
/// `None` that was never set.
fn mapped_panel_session(rows: u32, generation: u64) -> Option<(State, PanelFrame)> {
let mut state = headless_or_skip(800, 600, "alpha")?;
let frame = present_panel(&mut state, rows);
state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
let mut mapped = frame;
mapped.panel_epoch += 1;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: mapped.clone(),
mapping_generation: generation,
}),
"fixture: the mapped baseline must install"
);
Some((state, mapped))
}
/// §5b G9b — motion dedupe suppresses a repeat WITHIN one generation
/// and RE-ARMS across one.
///
/// The pointer has not moved in either case. What differs is whether
/// the cell still denotes the same text: comparing the cell alone
/// eats the first motion after the mapping moves, which is precisely
/// the motion the daemon needs to re-anchor the gesture.
#[test]
fn g9b_motion_dedupe_is_keyed_by_generation_not_by_cell_alone() {
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
return;
};
let cell = pmacs_protocol::CellCoord::new(1, 1);
assert!(
state.panel_motion_is_new(cell),
"the first motion at a cell is always new"
);
assert!(
!state.panel_motion_is_new(cell),
"a repeat within one generation is suppressed — without this \
sub-cell motion becomes pixel-rate wire traffic"
);
// A same-generation repaint: a different frame, same key. The
// dedupe must NOT re-arm, or the suppression above is defeated
// by any style or selection repaint.
let mut repaint = frame.clone();
repaint.focused = !frame.focused;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: repaint,
mapping_generation: 5,
}),
"fixture: the repaint must be accepted, or it proves nothing"
);
assert!(
!state.panel_motion_is_new(cell),
"a same-generation repaint does not re-arm the dedupe"
);
// The mapping MOVES. The pointer still has not.
let mut rekeyed = frame.clone();
rekeyed.focused = frame.focused;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: rekeyed,
mapping_generation: 6,
}),
"fixture: the re-keyed frame must be accepted"
);
assert!(
state.panel_motion_is_new(cell),
"and across a generation the same cell IS new: it denotes \
different text now"
);
}
/// §5b G9c — a byte-identical frame at a HIGHER generation still
/// updates authority.
///
/// The visual result is nothing, which is what makes this easy to
/// get wrong: returning early on frame equality leaves the frontend
/// echoing a generation the daemon has already retired, and every
/// gesture it sends is then refused.
#[test]
fn g9c_an_identical_frame_at_a_higher_generation_still_updates_authority() {
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
return;
};
// Byte-identical, and the same generation: no work at all.
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: frame.clone(),
mapping_generation: 5,
}),
"a true duplicate does nothing"
);
assert_eq!(state.panel.mapping_generation, Some(5));
// Byte-identical, HIGHER generation: authority moves.
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: frame.clone(),
mapping_generation: 6,
}),
"the daemon has re-keyed the mapping under an unchanged \
picture — a fold or an edit off-screen does exactly that"
);
assert_eq!(
state.panel.mapping_generation,
Some(6),
"echoing 5 from here would have every gesture refused"
);
assert_eq!(
state.panel.frame.as_ref(),
Some(&frame),
"and the frame is unchanged, because it was identical"
);
}
/// §5b G10 — a structurally invalid mapped frame is refused
/// ATOMICALLY: previous frame and previous generation both retained.
///
/// Updating one without the other leaves a valid generation naming a
/// frame that was never shown, which is the same stale-mapping hole
/// from the receiving side.
#[test]
fn g10_an_invalid_mapped_frame_is_refused_atomically() {
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
return;
};
state.set_panel_pointer_held(true);
state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(1, 1));
// G10 — structurally invalid: the cell count contradicts `size`.
let mut invalid = frame.clone();
invalid.cells.pop();
assert!(
invalid.validate().is_err(),
"fixture: the frame must actually be invalid"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: invalid,
mapping_generation: 9,
})
);
assert_eq!(state.panel.frame.as_ref(), Some(&frame), "frame retained");
assert_eq!(
state.panel.mapping_generation,
Some(5),
"and its generation with it — a valid 9 here would name a \
frame this frontend never received"
);
// G10a — structurally VALID, generation zero. Zero is what a
// sender that never initialised the field produces; accepting it
// once disables the check for the session.
let mut zeroed = frame.clone();
zeroed.panel_epoch += 7;
assert!(
zeroed.validate().is_ok(),
"fixture: this one is structurally fine, so the refusal is \
about the zero"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: zeroed,
mapping_generation: 0,
})
);
assert_eq!(state.panel.frame.as_ref(), Some(&frame));
assert_eq!(state.panel.mapping_generation, Some(5));
assert!(state.panel.pointer_held, "a refusal ends no live gesture");
assert_eq!(
state.panel.last_pointer_cell,
Some(pmacs_protocol::CellCoord::new(1, 1)),
"nor discards its dedupe baseline"
);
}
/// §5b G10a — a ZERO generation is refused when NO authority is
/// held yet.
///
/// This is the only setup that isolates the zero check. With a
/// generation already held, zero is also *lower* than it, so the
/// nondecreasing clause refuses the frame and a row built that way
/// stays green with the zero check deleted — measured, not assumed.
///
/// The case is the one the framing names: a sender that never
/// initialised the field. Accepting it once records `Some(0)`, and
/// from there no later frame is ever "lower", so the check is
/// disabled for the whole session.
#[test]
fn g10a_a_zero_generation_is_refused_before_any_authority_is_held() {
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
return;
};
// A legacy frame first, so there is a real retained band to
// preserve, then the family switch. `mapping_generation` is
// genuinely `None` here — that is the state under test, not an
// accident of the fixture.
let baseline = present_panel(&mut state, 4);
state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
assert_eq!(
state.panel.mapping_generation, None,
"fixture: no authority held yet"
);
let mut zeroed = baseline.clone();
zeroed.panel_epoch += 1;
assert!(
zeroed.validate().is_ok(),
"fixture: structurally fine, so the refusal is about the zero"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: zeroed,
mapping_generation: 0,
}),
"zero is the wire's uninitialised value and is never authority"
);
assert_eq!(
state.panel.mapping_generation, None,
"and nothing is recorded — `Some(0)` would make every later \
frame non-lower and disable the check for the session"
);
assert_eq!(
state.panel.frame.as_ref().map(|f| f.panel_epoch),
Some(baseline.panel_epoch),
"the retained band survives the refusal"
);
// POSITIVE CONTROL — the same frame with a real generation
// installs, so the refusal is about the zero and not about the
// family switch.
let mut real = baseline.clone();
real.panel_epoch += 2;
assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: real,
mapping_generation: 1,
}));
assert_eq!(state.panel.mapping_generation, Some(1));
}
/// §5b G10c — the generation is NONDECREASING, and `Absent` does not
/// erase the high-water mark.
///
/// Values may SKIP: the daemon advances the key on mapping changes,
/// not on frames, so a frontend that misses two repaints legitimately
/// jumps. What may never happen is going backward — a delayed frame
/// must not roll this frontend's authority back to a mapping that is
/// gone.
#[test]
fn g10c_generation_is_nondecreasing_and_survives_absent() {
let Some((mut state, frame)) = mapped_panel_session(4, 5) else {
return;
};
// A SKIP forward is fine.
let mut skipped = frame.clone();
skipped.panel_epoch += 1;
assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: skipped.clone(),
mapping_generation: 9,
}));
assert_eq!(state.panel.mapping_generation, Some(9));
// A LOWER one is refused, atomically.
let mut delayed = frame.clone();
delayed.panel_epoch += 2;
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: delayed.clone(),
mapping_generation: 8,
})
);
assert_eq!(
state.panel.frame.as_ref().map(|f| f.panel_epoch),
Some(skipped.panel_epoch),
"the retained frame survives"
);
assert_eq!(state.panel.mapping_generation, Some(9));
// An EQUAL generation with a different frame is accepted: a
// style or selection repaint does not move the mapping, and
// refusing it would freeze the band.
let mut repaint = skipped.clone();
repaint.focused = !skipped.focused;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: repaint,
mapping_generation: 9,
}),
"an equal-generation repaint is valid"
);
assert_eq!(state.panel.mapping_generation, Some(9));
// `Absent` hides the band but must NOT erase the high-water
// mark: a frame delayed across the hide would otherwise come
// back believed.
assert!(state.apply_panel_payload(PanelFramePayload::Absent));
assert!(state.panel.presented().is_none(), "the band is down");
assert_eq!(
state.panel.mapping_generation,
Some(9),
"and the high-water mark stands"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: delayed,
mapping_generation: 8,
}),
"so the delayed frame is still refused after the hide"
);
assert!(state.panel.presented().is_none(), "and stays down");
}
/// §5b G8b — a MAPPED frontend refuses the legacy family, and the
/// refusal is ATOMIC.
///
/// The baseline is accepted in the CORRECT family first, so there is
/// real authority to preserve: a row that installs a legacy frame
/// and then switches to mapped has `mapping_generation == None`
/// throughout, and asserting it stayed `None` proves nothing. The
/// pointer latches are primed for the same reason — untouched state
/// that was never set is not evidence of atomicity.
#[test]
fn g8b_a_mapped_frontend_refuses_legacy_atomically() {
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
return;
};
let frame = present_panel(&mut state, 4);
state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
// A correct-family baseline, so the retained authority is real.
let mut baseline = frame.clone();
baseline.panel_epoch = frame.panel_epoch + 1;
assert!(
state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: baseline.clone(),
mapping_generation: 5,
}),
"fixture: the mapped baseline must install"
);
state.set_panel_pointer_held(true);
state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(1, 1));
let mut intruder = frame.clone();
intruder.panel_epoch = frame.panel_epoch + 2;
assert!(
!state.apply_panel_payload(PanelFramePayload::Present(intruder)),
"a v25 frontend must not paint a band it cannot safely \
hit-test — the frame carries no mapping identity"
);
assert_eq!(
state.panel.frame.as_ref().map(|f| f.panel_epoch),
Some(baseline.panel_epoch),
"the retained frame survives the refusal"
);
assert_eq!(
state.panel.mapping_generation,
Some(5),
"and so does its authority — this is the half a switched \
baseline could not have shown"
);
assert!(state.panel.pointer_held, "a refusal ends no live gesture");
assert_eq!(
state.panel.last_pointer_cell,
Some(pmacs_protocol::CellCoord::new(1, 1)),
"nor discards its dedupe baseline"
);
}
/// §5b G8d — a LEGACY frontend refuses the mapped family, atomically.
///
/// An independent state, not a continuation of G8b: sharing one
/// would let the second direction inherit whatever the first left
/// behind.
#[test]
fn g8d_a_legacy_frontend_refuses_mapped_atomically() {
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
return;
};
let frame = present_panel(&mut state, 4);
state.set_panel_wire(PANEL_MIN_VERSION);
let mut baseline = frame.clone();
baseline.panel_epoch = frame.panel_epoch + 1;
assert!(
state.apply_panel_payload(PanelFramePayload::Present(baseline.clone())),
"fixture: the legacy baseline must install"
);
state.set_panel_pointer_held(true);
state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(2, 2));
let mut intruder = frame.clone();
intruder.panel_epoch = frame.panel_epoch + 2;
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: intruder,
mapping_generation: 9,
}),
"a v24 frontend must reject a family it never negotiated"
);
assert_eq!(
state.panel.frame.as_ref().map(|f| f.panel_epoch),
Some(baseline.panel_epoch),
"the retained frame survives"
);
assert_eq!(
state.panel.mapping_generation, None,
"and no authority is acquired from a refused frame"
);
assert!(state.panel.pointer_held, "a refusal ends no live gesture");
assert_eq!(
state.panel.last_pointer_cell,
Some(pmacs_protocol::CellCoord::new(2, 2))
);
}
/// §5b — an UNSUPPORTED session accepts neither family.
///
/// The case a `!= Mapped` gate let through: `Unsupported` is neither
/// family, so a negative test admitted a band the session never
/// negotiated.
#[test]
fn an_unsupported_session_accepts_neither_panel_family() {
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
return;
};
let frame = present_panel(&mut state, 4);
state.set_panel_wire(PANEL_MIN_VERSION - 1);
let mut orphan = frame.clone();
orphan.panel_epoch = frame.panel_epoch + 1;
assert!(
!state.apply_panel_payload(PanelFramePayload::Present(orphan.clone())),
"a sub-panel session has no band at all"
);
assert!(
!state.apply_panel_payload(PanelFramePayload::PresentMapped {
frame: orphan,
mapping_generation: 9,
}),
"in either family"
);
}
/// Parent 48 R-c — the band's MODE-LINE row is chrome, and the
/// producer must not arm a gesture there.
///

View File

@ -66,7 +66,7 @@ pub use message::{
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
MenuPromptRow, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind,
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
NegotiatedCapabilities, PANEL_MAPPING_MIN_VERSION, PROTOCOL_VERSION, PointerKind, ResourceBody,
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace,
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,

View File

@ -550,8 +550,75 @@ pub enum FrontendEvent {
/// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`].
text: String,
},
/// Bottom panel §5b (protocol v25): a panel-cell gesture that also
/// names the **inverse mapping** the frontend was looking at.
///
/// **APPENDED LAST, after [`Self::TextInput`].** "Beside
/// `PanelPointer`" would be adjacent insertion, which shifts every
/// discriminant below it; postcard encodes variants positionally, so
/// only the end of the enum is safe.
///
/// **Why the mapping and not the epochs.** A cell must be inverted
/// to a byte, and the existing ladder cannot tell whether the text
/// under that cell still means what it meant: `buffer_id` catches an
/// A→B replacement, `panel_epoch` catches close/reopen,
/// `geometry_epoch` catches a declaration race, and **nothing
/// catches a foreign edit** — which moves the mapping while every
/// epoch holds. `mapping_generation` closes exactly that.
///
/// **Not a per-frame token.** A token moving with each frame would
/// invalidate a gesture on every repaint and break drags outright,
/// which is why [`crate::panel::PanelFrame::panel_epoch`] is stable
/// across ordinary frames. This identifies the *mapping*: it moves
/// with the viewport, folds, wrap and gutter geometry, buffer
/// content and terminal topology, and holds across focus, styling,
/// selection-only repaints and absorbed cursor motion.
///
/// **Gated bilaterally on [`PANEL_MAPPING_MIN_VERSION`]**, and the
/// gate is a REFUSAL rather than a fallback: a `>= v25` session
/// sending the bare [`Self::PanelPointer`] is refused, and a
/// `<= v24` session sending this variant is refused too. Treating
/// either as "handled under the other family's semantics" would
/// leave the hole reachable by choosing a discriminant.
PanelPointerMapped {
/// Which frontend produced the gesture (untrusted, as above).
frontend_id: FrontendId,
/// Geometry declaration this gesture was hit-tested against.
geometry_epoch: u64,
/// Presentation identity this gesture addresses.
panel_epoch: u64,
/// Buffer the frontend believed the panel was displaying.
buffer_id: crate::BufferId,
/// Cell the pointer is over, within the declared panel grid.
coord: CellCoord,
/// Which gesture step this is.
kind: MouseKind,
/// Modifiers held during the gesture.
mods: Modifiers,
/// The inverse mapping the frontend was displaying.
///
/// **Zero is invalid** and is refused like a mismatch: it is
/// what a default-constructed or half-initialised sender
/// produces, so accepting it would let a peer opt out of the
/// check by sending nothing. A live key starts at 1.
///
/// Appended **last within the variant**, so this mirrors
/// [`Self::PanelPointer`]'s field order exactly and the two stay
/// diffable by eye.
mapping_generation: u64,
},
}
/// First protocol version carrying the **mapped panel family** —
/// [`FrontendEvent::PanelPointerMapped`] and
/// [`crate::panel::PanelFramePayload::PresentMapped`].
///
/// Read as a constant rather than compared against a literal, and
/// **not** derived from [`PROTOCOL_VERSION`]: expressing the boundary
/// against a moving ceiling would silently drag this feature forward
/// on the next bump.
pub const PANEL_MAPPING_MIN_VERSION: u32 = 25;
/// First protocol version carrying [`FrontendEvent::TextInput`].
///
/// A frontend older than this **retains its existing `Key` behaviour and
@ -617,7 +684,8 @@ impl FrontendEvent {
| Self::FrontendCellGeometry { frontend_id, .. }
| Self::PanelResizeRows { frontend_id, .. }
| Self::PanelPointer { frontend_id, .. }
| Self::TextInput { frontend_id, .. } => *frontend_id,
| Self::TextInput { frontend_id, .. }
| Self::PanelPointerMapped { frontend_id, .. } => *frontend_id,
}
}
}
@ -1920,7 +1988,18 @@ pub enum ResourceBody {
/// negotiated version. What is unusual here is only that the extension
/// is **inbound-only** — there is no outbound counterpart to withhold,
/// so the receiver check is the whole of the daemon's half.
pub const PROTOCOL_VERSION: u32 = 24;
///
/// Bottom panel §5b: bumped 24 → 25 for the mapped panel family,
/// [`FrontendEvent::PanelPointerMapped`] and
/// [`crate::panel::PanelFramePayload::PresentMapped`]. Both variants are
/// appended after their enum's v24 final variant. The family is gated in
/// both directions from the authenticated session's negotiated version:
/// v6–v24 sessions use only `PanelPointer`/`Present`, while v25 sessions
/// use only the mapped pair; choosing the other discriminant is refused,
/// not reinterpreted. [`crate::panel::PanelFramePayload::Absent`] remains
/// common because hiding a band carries no mapping. The advertised
/// baseline remains 20.
pub const PROTOCOL_VERSION: u32 = 25;
/// Protocol version placed in the daemon's server-first [`Hello`].
///
@ -2108,8 +2187,16 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 {
/// cannot rely on the producer withholding. [`ADVERTISED_PROTOCOL_VERSION`] does not move: a v23
/// frontend negotiates v23, never sends the variant, and keeps today's
/// first-scalar behaviour.
///
/// Bottom panel §5b: extended to `[6, ..., 25]` for
/// [`FrontendEvent::PanelPointerMapped`] and
/// [`crate::panel::PanelFramePayload::PresentMapped`]. The authenticated
/// negotiated version selects exactly one legacy/mapped family in each
/// direction; a peer compiled from this crate can encode either variant,
/// so receiver-side refusal is load-bearing. `Absent` is common, and
/// [`ADVERTISED_PROTOCOL_VERSION`] remains 20.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
];
/// T M10.5: predicate for the handshake check. Returns `true` if

View File

@ -103,6 +103,29 @@ pub enum PanelFramePayload {
Present(PanelFrame),
/// No panel is visible; clear any retained frame.
Absent,
/// §5b (protocol v25): a visible panel **plus the identity of the
/// inverse mapping** it was painted from.
///
/// **APPENDED AFTER `Absent`, not beside `Present`.** postcard
/// encodes variants positionally, so inserting adjacent to
/// `Present` would shift `Absent`'s discriminant and silently
/// re-interpret every older peer's "hide the band" as something
/// else.
///
/// `Absent` is deliberately **shared by both families** — hiding a
/// band carries no mapping, so it needs no mapped twin.
PresentMapped {
/// The frame, reused whole and unchanged.
frame: PanelFrame,
/// Identity of the inverse mapping this frame was painted from.
///
/// A `PanelPointerMapped` echoes it, and the daemon refuses a
/// gesture whose echo no longer matches. **Zero is invalid**;
/// a live key starts at 1 and is **nondecreasing**, including
/// across `Absent` — a delayed lower frame must not roll the
/// producer's authority backward.
mapping_generation: u64,
},
}
/// Why a [`PanelFrame`] is not structurally valid.

View File

@ -0,0 +1,63 @@
#!/bin/sh
# Is SIGINT deliverable to this process tree?
#
# WHY THIS EXISTS. A shell running a command in the background without
# job control sets SIGINT (and SIGQUIT) to SIG_IGN in the child; nohup
# adds SIGHUP. SIG_IGN is inherited across fork AND survives exec, so
# the disposition reaches every descendant --- cargo, a test binary, and
# anything either of them spawns. Tests that signal a child then wait
# for it to die will hang until their own deadline and report that as a
# teardown defect. One lane spent nine framing revisions on exactly that
# misreading; see docs/gpu-probe-sigint-framing.md §4c.
#
# INTERFACE (docs/gpu-probe-sigint-framing.md §7c) --- a validated
# (status, token) PAIR. Callers rely on both halves and MUST NOT
# re-derive the classification:
#
# 0 safe stdout: pmacs-sigint-v1:safe no diagnostic
# 1 ignored stdout: pmacs-sigint-v1:ignored canonical diagnostic
# 2 error stdout: pmacs-sigint-v1:error distinct diagnostic
#
# The token is the ONLY thing on stdout; diagnostics go to stderr. Any
# other pair --- including a plausible status with no token --- is a
# BOUNDARY error for the caller, mapped to 2.
#
# WHY A TOKEN AND NOT A STATUS ALONE. CI proved a status-only ABI
# unsound: on macOS a shell that cannot execute this file exits 1,
# which the old ABI read as `ignored`, so a broken guard told the
# operator their environment ignores SIGINT. No exit status can prove
# this script ran; a token it must have printed can.
#
# `error` is never folded into `ignored`. "Your environment ignores
# SIGINT" and "the guard could not run" are different problems, and
# conflating them fails callers for the wrong reason.
#
# THE PROBE. A child sends itself SIGINT. Deliverable => the trap runs
# => 23. Ignored => the kill is a no-op => the child falls through to
# `exit 0`. The `|| exit 24` arms matter: without them a FAILED kill
# would also fall through to `exit 0` and be misread as `ignored`,
# which is the one wrong answer this helper exists to avoid.
#
# POSIX shell only --- trap, kill, $$ --- so no /proc and no sigaction:
# the mechanism is not Linux-specific and adds no unsafe code.
probe_status=0
sh -c 'trap "exit 23" 2 || exit 24; kill -INT "$$" || exit 24; exit 0' \
|| probe_status=$?
case "$probe_status" in
23)
echo 'pmacs-sigint-v1:safe'
exit 0
;;
0)
echo 'pmacs-sigint-v1:ignored'
echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2
exit 1
;;
*)
echo 'pmacs-sigint-v1:error'
echo "pmacs: could not determine whether SIGINT is deliverable (probe status $probe_status)" >&2
exit 2
;;
esac

View File

@ -506,6 +506,116 @@ esac
WT=$(canon "$(worktree_root)")
cd "$WT"
# R-b (framing SS7c): refuse to start when SIGINT is not deliverable.
#
# HERE, immediately after the worktree resolves and BEFORE any log
# directory, ambient root or temporary dir exists: a refused run should
# leave nothing behind to clean up or mistake for evidence.
#
# WHY AT ALL. A shell running a command in the background without job
# control sets SIGINT to SIG_IGN; that survives fork AND exec, so every
# test which signals a child and waits for it hangs to its own
# deadline. Seven red sweeps were read as a product teardown defect
# before this was found (framing SS4c).
#
# The helper owns the classification and the wording; this consumer
# re-derives neither. It continues only on 0, and otherwise stops after
# letting the helper's stderr through untouched.
#
# `|| sigint_status=$?` IS LOAD-BEARING, and two wrong shapes were tried
# before this one:
#
# * a bare invocation dies under `set -e` at the helper's non-zero
# exit, so the refusal below never prints;
# * `if ! helper; then sigint_status=$?; fi` captures the status of
# the NEGATED condition --- always 0 --- so the guard printed the
# diagnosis and then ran the whole gate anyway.
#
# In a `cmd || assignment` list the failure is handled, so `set -e` does
# not fire and `$?` is the helper's own status. This is the same shape
# the helper uses internally.
#
# NO OVERRIDE, deliberately: a run in this state cannot produce valid
# evidence, so a flag to proceed anyway would only manufacture red gates
# that mean nothing.
if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then
# The guard owns its capture directory: it runs BEFORE the log dir,
# ambient root and GATE_TMPDIR exist, and must still leave nothing
# behind (A8).
capture=$(mktemp -d "${TMPDIR:-/tmp}/pmacs-sigint.XXXXXX") || {
echo 'gate: could not create the SIGINT guard capture directory (status=unavailable token=missing)' >&2
echo 'gate: REFUSING TO RUN --- no stage has run.' >&2
exit 2
}
cleanup_sigint_capture() { rm -rf "$capture"; }
trap cleanup_sigint_capture EXIT HUP INT TERM
# `|| sigint_status=$?` IS LOAD-BEARING under `set -eu`: a bare
# invocation dies at the helper's non-zero exit and never reaches
# the assignment. That was the originally shipped bug.
sigint_status=0
"$WT/scripts/check-sigint-deliverable" \
>"$capture/out" 2>"$capture/err" || sigint_status=$?
# Select an expected token only for public statuses. This MUST
# precede any use of expected_token: `set -u` is on, and an
# out-of-range status has no expected token.
expected_token=
case "$sigint_status" in
0) expected_token=pmacs-sigint-v1:safe ;;
1) expected_token=pmacs-sigint-v1:ignored ;;
2) expected_token=pmacs-sigint-v1:error ;;
esac
# Byte comparison against both permitted encodings. Files preserve
# every byte including NUL; a shell variable would not, and command
# substitution's NUL handling differs between sh and zsh.
sigint_token_ok=0
if [ -n "$expected_token" ]; then
printf '%s' "$expected_token" >"$capture/want"
printf '%s\n' "$expected_token" >"$capture/want_lf"
if cmp -s "$capture/out" "$capture/want" ||
cmp -s "$capture/out" "$capture/want_lf"; then
sigint_token_ok=1
fi
fi
if [ ! -s "$capture/out" ]; then
sigint_token_state=missing
elif [ "$sigint_token_ok" -eq 1 ]; then
sigint_token_state=valid
else
sigint_token_state=unexpected
fi
case "$sigint_status:$sigint_token_ok" in
0:1)
# The sole continuing path. Tidy up and disarm before the
# gate installs its own, unrelated cleanup trap.
cleanup_sigint_capture
trap - EXIT HUP INT TERM
;;
1:1 | 2:1)
# A VALIDATED verdict: the helper's stderr IS the diagnosis
# and is surfaced unchanged.
cat "$capture/err" >&2
printf 'gate: REFUSING TO RUN (status=%s token=%s) --- no stage has run.\n' \
"$sigint_status" "$sigint_token_state" >&2
exit "$sigint_status"
;;
*)
# BOUNDARY error. The captured stderr is UNTRUSTED and is
# deliberately not surfaced: a helper exiting 1 with no
# token but the canonical ignored wording would otherwise
# tell the operator their environment ignores SIGINT (A6b).
printf 'gate: SIGINT guard boundary error (status=%s token=%s)\n' \
"$sigint_status" "$sigint_token_state" >&2
printf 'gate: REFUSING TO RUN --- no stage has run.\n' >&2
exit 2
;;
esac
fi
TARGET=$(ensure_target_dir "$WT")
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
# $$ as well as the timestamp: two invocations in the same worktree

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.
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 {
/// Construct a fresh editor for an unnamed scratch buffer.
///
@ -2452,6 +2522,66 @@ impl EditorState {
true
}
/// Capture the panel's inverse mapping for one frontend.
///
/// `None` when no panel is presentable — the absence of a mapping,
/// which is not the same as a mapping of zero.
pub fn panel_mapping_snapshot(&self, frontend_id: FrontendId) -> Option<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
/// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17).
///
@ -2671,6 +2801,11 @@ impl EditorState {
/// which needs the GPU band and lands in Stage 2B-3.
///
/// Returns whether the gesture was accepted.
///
/// `#[must_use]` because the accepted-gesture latch is driven off
/// this answer, and discarding it silently arms on rejected presses
/// and consumes on rejected releases.
#[must_use]
pub fn dispatch_semantic_panel_pointer(
&mut self,
frontend_id: FrontendId,

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_twenty_four_for_text_input() {
fn protocol_version_is_twenty_five_for_the_mapped_panel_family() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1741,10 +1741,11 @@ mod tests {
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
// pinned by literal bytes below.
//
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) — an
// APPENDED variant, which is why the freeze above survives it
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) and v25
// is the mapped panel family (bottom-panel §5b) — both APPENDED
// variants, which is why the freeze above survives them
// untouched: nothing in `MinibufferPrompt`'s encoding moved.
assert_eq!(PROTOCOL_VERSION, 24);
assert_eq!(PROTOCOL_VERSION, 25);
}
#[test]
@ -1822,18 +1823,19 @@ mod tests {
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
// v20 (semantic initial-target bootstrap), v21 (the bottom
// panel band), v22 (`LineWrapFacts`), v23
// (`MinibufferPromptRows`), and v24 (`TextInput`, GUI arc Stage
// 1a) all interoperate.
for accepted in 6..=24 {
// (`MinibufferPromptRows`), v24 (`TextInput`, GUI arc Stage 1a)
// and v25 (`PanelPointerMapped` + `PresentMapped`, bottom-panel
// §5b) all interoperate.
for accepted in 6..=PROTOCOL_VERSION {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 25, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, PROTOCOL_VERSION + 1, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v24 binary"
"v{rejected} must be rejected by the current binary"
);
}
}
@ -2008,6 +2010,167 @@ mod tests {
);
}
/// §5b G0a — the **previous-final `FrontendEvent`** is now
/// `TextInput`, and it gets its own exact pin.
///
/// Pins ACCUMULATE rather than move. The `PanelPointer` pin above
/// still protects the v21–v23 population; this one protects v24's,
/// and appending `PanelPointerMapped` is exactly the edit that would
/// shift it if it were inserted rather than appended.
#[test]
fn text_input_encoding_is_unchanged_by_the_v25_build() {
let ev = FrontendEvent::TextInput {
frontend_id: FrontendId(2),
text: "hi".to_owned(),
};
assert_eq!(
postcard::to_allocvec(&ev).expect("encode"),
[16, 2, 2, 104, 105],
"TextInput's v24 wire bytes changed — a variant was inserted \
before it; append new FrontendEvent variants at the end"
);
}
/// §5b G0a — the **previous-final `PanelFramePayload`** is `Absent`,
/// pinned through its real nesting inside `InstanceMessage`.
///
/// Nested deliberately: `PanelFramePayload` never travels alone, and
/// a pin on the bare enum would miss a shift in the message
/// discriminant that carries it.
#[test]
fn absent_panel_payload_encoding_is_unchanged_by_the_v25_build() {
let msg = InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent);
assert_eq!(
postcard::to_allocvec(&msg).expect("encode"),
[28, 1],
"Absent's wire bytes changed — a PanelFramePayload variant \
was inserted before it, or InstanceMessage's discriminant \
moved; append PresentMapped AFTER Absent"
);
}
/// §5b G0b — exact **encode and decode** for the mapped pointer.
///
/// **Every adjacent same-typed field carries a different value**, so
/// a reordering is visible. `geometry_epoch`, `panel_epoch` and
/// `mapping_generation` are all `u64` and all distinct (3, 7, 11);
/// `coord.row`/`coord.col` likewise (5, 9). A round-trip alone would
/// stay green through any swap of those — it is self-consistent by
/// construction — which is why the exact bytes are asserted in both
/// directions.
#[test]
fn panel_pointer_mapped_encodes_and_decodes_exactly() {
const WIRE: [u8; 11] = [17, 2, 3, 7, 4, 5, 9, 0, 0, 0, 11];
let ev = FrontendEvent::PanelPointerMapped {
frontend_id: FrontendId(2),
geometry_epoch: 3,
panel_epoch: 7,
buffer_id: pmacs_protocol::BufferId::from_raw(4),
coord: CellCoord { row: 5, col: 9 },
kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left),
mods: Modifiers::NONE,
mapping_generation: 11,
};
assert_eq!(
postcard::to_allocvec(&ev).expect("encode"),
WIRE,
"PanelPointerMapped's v25 wire bytes changed — it must be \
APPENDED after TextInput, with mapping_generation last"
);
let decoded: FrontendEvent = postcard::from_bytes(&WIRE).expect("decode");
assert_eq!(
decoded, ev,
"the frozen historical bytes must decode back to the same \
value — round-trip alone would only witness the current \
encoder and decoder agreeing with each other"
);
}
/// §5b G0b — exact **encode and decode** for the mapped frame,
/// through its real nesting.
///
/// Reordering named variant fields still compiles and changes
/// postcard's positional bytes, so the exact pin is load-bearing.
/// The frame's own `panel_epoch` (8) and `geometry_epoch` (12) are
/// adjacent `u64`s and are pinned distinct for the same reason as
/// above.
#[test]
fn present_mapped_encodes_and_decodes_exactly() {
const WIRE: [u8; 33] = [
28, 2, 6, 8, 12, 1, 2, 2, 0, 1, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 32, 0, 0, 0, 0, 0, 0,
0, 0, 0, 1, 13,
];
let frame = pmacs_protocol::panel::PanelFrame {
buffer_id: pmacs_protocol::BufferId::from_raw(6),
panel_epoch: 8,
geometry_epoch: 12,
size: CellSize::new(1, 2),
cells: vec![Cell::default(); 2],
cursor: None,
focused: true,
};
let msg =
InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::PresentMapped {
frame,
mapping_generation: 13,
});
assert_eq!(
postcard::to_allocvec(&msg).expect("encode"),
WIRE,
"PresentMapped's v25 wire bytes changed — it must be APPENDED \
after Absent, with mapping_generation last"
);
let decoded: InstanceMessage = postcard::from_bytes(&WIRE).expect("decode");
assert_eq!(decoded, msg, "the frozen bytes must decode back");
}
/// §5b G14a — the version constants move together, and the
/// advertised baseline does not move at all.
#[test]
fn the_mapping_slice_takes_v25_and_the_advertised_baseline_stays_pinned() {
assert_eq!(pmacs_protocol::PROTOCOL_VERSION, 25);
assert_eq!(pmacs_protocol::PANEL_MAPPING_MIN_VERSION, 25);
assert!(
pmacs_protocol::SUPPORTED_PROTOCOL_VERSIONS.contains(&pmacs_protocol::PROTOCOL_VERSION),
"a wire version the handshake does not support fails against \
this daemon's own peers"
);
assert_eq!(
pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
20,
"the advertised baseline is PERMANENT — it is not bumped to \
chase the wire version"
);
}
/// §5b G14b — the supported set's boundary, and the family boundary,
/// are both literal.
#[test]
fn the_supported_set_ends_at_the_current_wire_version() {
assert!(pmacs_protocol::is_supported_protocol_version(25));
assert!(
!pmacs_protocol::is_supported_protocol_version(26),
"an unreleased version must not negotiate"
);
// The family boundary is stated against a LITERAL, not against
// `PROTOCOL_VERSION`: expressing it arithmetically would drag
// this feature forward on the next bump, so v24 sessions would
// silently start being offered a family they never negotiated.
//
// Asserted as equality rather than `24 < MIN`, which clippy
// correctly calls a compile-time tautology — it holds for every
// value above 24 and so pins nothing.
assert_eq!(25, pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
assert_ne!(
pmacs_protocol::PANEL_MAPPING_MIN_VERSION,
pmacs_protocol::TEXT_INPUT_MIN_VERSION,
"the mapped family must not share v24's gate — that would \
admit it on sessions that negotiated only TextInput"
);
}
#[test]
fn terminal_family_round_trips_and_pins_its_discriminants() {
let bid = pmacs_protocol::BufferId::from_raw(9);

View File

@ -158,6 +158,29 @@ fn minibuffer_window<T: Clone>(candidates: &[T], selected: Option<usize>) -> (Ve
(window, selected_in_window)
}
/// §5b G5 — what an accepted panel `Down` established, and what a
/// cancellation must therefore terminate.
///
/// A release has to MATCH the press it ends: same button, same
/// encoding, at a coordinate the child was actually told about. A
/// cancellation that guessed any of those would put bytes in a child's
/// input stream describing an event that never happened.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AcceptedPanelGesture {
/// The button the accepted `Down` carried.
pub button: pmacs_protocol::MouseButton,
/// The last coordinate known valid for this gesture — where a
/// release is delivered when the pointer has since left content.
pub coord: pmacs_protocol::CellCoord,
/// The panel buffer the gesture belongs to.
pub buffer_id: BufferId,
/// Whether the press actually REACHED the child, for a reporting
/// terminal. A release is owed only if a press was delivered;
/// synthesising one for a press the child never saw is the same
/// defect in the other direction.
pub reached_child: bool,
}
/// Owns one `semantic_render` session's projection state: the last
/// viewport the frontend declared, and the diff baseline per buffer
/// for the `StyleSpans` and `Decorations` families.
@ -254,6 +277,15 @@ pub struct SemanticRenderState {
/// rows form. Also gates the per-row detail lookup: a peer that
/// cannot carry a detail does not pay to resolve one.
peer_knows_minibuffer_rows: bool,
/// §5b — whether the peer negotiated `>= v25` and therefore takes
/// the **mapped** panel family.
///
/// The families are exclusive in both directions: a `>= v25` peer
/// receives `PresentMapped` and never legacy `Present`, and a
/// `<= v24` peer the reverse. "Send whichever and let the receiver
/// cope" would make negotiation a sender convention rather than a
/// gate.
peer_knows_mapped_panel: bool,
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
/// Q#C5), for cached-compare suppression (see
/// [`CompletionPopupFacts`]).
@ -377,6 +409,61 @@ pub struct SemanticRenderState {
/// retains its last valid frame and silence would leave a stale band
/// on screen indefinitely (Q#BP15).
last_panel_payload: Option<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
/// none has been. Advanced only when a frame is actually shipped, so
/// a frame that fails validation does not burn an identity the peer
@ -516,11 +603,15 @@ impl SemanticRenderState {
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
s.peer_knows_mapped_panel =
negotiated_protocol_version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION;
s
}
/// Fresh session state for frontend `frontend_id`: no viewport
/// declared, nothing sent. Assumes a current-build peer (>= 18);
/// declared, nothing sent. Assumes a current-build peer (>= 18, and
/// current for every later capability too, including §5b's mapped
/// panel family);
/// daemon sessions with a real negotiated version use
/// [`Self::for_peer`].
#[must_use]
@ -536,6 +627,10 @@ impl SemanticRenderState {
last_menu_prompt: HashMap::new(),
last_minibuffer: None,
peer_knows_minibuffer_rows: true,
// A current-build peer, like every other capability here.
// Leaving this `false` made `new()` contradict its own doc
// and emit the LEGACY family to an implicitly v25 peer.
peer_knows_mapped_panel: true,
last_completion_popup: HashMap::new(),
last_summary: HashMap::new(),
last_status: HashMap::new(),
@ -575,6 +670,10 @@ impl SemanticRenderState {
// peer already holds. Seeding the baseline keeps the first
// frame from shipping a redundant authoritative `Absent`.
last_panel_payload: Some(PanelFramePayload::Absent),
panel_mapping: None,
panel_mapping_exhausted: false,
accepted_gesture: None,
panel_gesture_cancellations: 0,
panel_epoch_used: 0,
panel_presentation: None,
panel_error_latched: false,
@ -592,11 +691,194 @@ impl SemanticRenderState {
#[must_use]
pub fn panel_declaration(&self) -> Option<&PanelFrame> {
match self.last_panel_payload.as_ref()? {
PanelFramePayload::Present(frame) => Some(frame),
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. } => {
Some(frame)
}
PanelFramePayload::Absent => None,
}
}
/// The panel payload most recently shipped, for the family rows.
#[doc(hidden)]
#[must_use]
pub fn last_panel_payload_for_test(&self) -> Option<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
/// both match an inbound panel event **and** which still describes
/// the side window that is live now (Q#BP16 steps 2–4).
@ -1374,7 +1656,7 @@ impl SemanticRenderState {
self.publish_absent_panel(out);
return;
};
let payload = PanelFramePayload::Present(PanelFrame {
let frame = PanelFrame {
buffer_id: projection.buffer_id,
panel_epoch,
geometry_epoch: geometry.geometry_epoch,
@ -1382,7 +1664,28 @@ impl SemanticRenderState {
cells: projection.cells,
cursor: projection.cursor,
focused: projection.focused,
});
};
// §5b — ORDER MATTERS. The projection is prepared above, THEN
// the key is captured, THEN the payload is built. A terminal
// projection registers the view whose scroll anchor the key
// reads, so capturing earlier would stamp a frame with a key
// derived from an unregistered anchor.
let payload = if self.peer_knows_mapped_panel {
let snapshot = state.panel_mapping_snapshot(self.frontend_id);
let Some(mapping_generation) = self.panel_mapping_generation(snapshot) else {
// No presentable mapping means nothing to stamp. Falling
// back to the legacy variant here would hand a v25 peer
// the family it did not negotiate.
self.publish_absent_panel(out);
return;
};
PanelFramePayload::PresentMapped {
frame,
mapping_generation,
}
} else {
PanelFramePayload::Present(frame)
};
// Complete-payload comparison FIRST, like the terminal pass: only
// validated payloads are ever stored, so a payload equal to the
// baseline has already passed and re-running the per-cell width
@ -1391,8 +1694,10 @@ impl SemanticRenderState {
self.panel_error_latched = false;
return;
}
let PanelFramePayload::Present(frame) = &payload else {
unreachable!("the Present payload was constructed immediately above");
let (PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }) =
&payload
else {
unreachable!("a Present payload was constructed immediately above");
};
match frame.validate() {
Ok(()) => {
@ -1433,6 +1738,22 @@ impl SemanticRenderState {
/// deliberately survives: it is answered by the frontend, not by the
/// panel's presence.
fn publish_absent_panel(&mut self, out: &mut Vec<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;
// `Absent` also clears the peer's retained mode line. A later
// `Present` under `NoMessage` therefore has nothing it can

View File

@ -114,6 +114,15 @@ pub struct ScreenProjection {
pub title: Option<String>,
/// Screen generation represented by this projection.
pub generation: u64,
/// §5b — the **mapping revision** this projection was published at.
///
/// Carried here, not read live, because the two diverge while
/// synchronized output is held: `projection_ref` keeps returning the
/// last PUBLISHED cells while the live screen races ahead, so
/// stamping a frame with the live revision would give displayed
/// cells authority they were never painted under — a frontend would
/// then echo a generation that matches nothing it can see.
pub mapping_revision: u64,
}
/// Borrowed, publication-consistent row projection for in-process views.
@ -128,6 +137,8 @@ pub(crate) struct BorrowedScreenProjection<'a> {
pub cursor: Option<CellCoord>,
pub title: Option<&'a str>,
pub generation: u64,
/// §5b — the mapping revision this projection was published at.
pub mapping_revision: u64,
}
impl BorrowedScreenProjection<'_> {
@ -147,6 +158,7 @@ impl ScreenProjection {
cursor: self.cursor,
title: self.title.as_deref(),
generation: self.generation,
mapping_revision: self.mapping_revision,
}
}
}
@ -192,6 +204,9 @@ pub struct TerminalScreen {
tab_stops: BTreeSet<usize>,
title: Option<String>,
generation: u64,
/// §5b — see [`Screen::mapping_revision`]. Separate from
/// `generation`, which advances for style and title too.
mapping_revision: u64,
published: ScreenProjection,
sync_started: Option<Instant>,
next_line_id: u64,
@ -221,6 +236,7 @@ impl TerminalScreen {
cursor: Some(CellCoord::new(0, 0)),
title: None,
generation: 0,
mapping_revision: 0,
};
Ok(Self {
size,
@ -240,6 +256,7 @@ impl TerminalScreen {
tab_stops: default_tab_stops(size.cols as usize),
title: None,
generation: 0,
mapping_revision: 0,
published,
sync_started: None,
next_line_id,
@ -269,24 +286,24 @@ impl TerminalScreen {
}
AnsiEvent::SetStyle(style) => {
self.style = style;
self.changed();
self.display_only_changed();
None
}
AnsiEvent::CarriageReturn => {
self.cursor.col = 0;
self.cursor.pending_wrap = false;
self.changed();
self.display_only_changed();
None
}
AnsiEvent::Backspace => {
self.cursor.col = self.cursor.col.saturating_sub(1);
self.cursor.pending_wrap = false;
self.changed();
self.display_only_changed();
None
}
AnsiEvent::Bell => {
self.bell_count = self.bell_count.saturating_add(1);
self.changed();
self.display_only_changed();
None
}
AnsiEvent::LineFeed | AnsiEvent::Index => {
@ -308,17 +325,17 @@ impl TerminalScreen {
}
AnsiEvent::SetTabStop => {
self.tab_stops.insert(self.cursor.col);
self.changed();
self.display_only_changed();
None
}
AnsiEvent::ClearTabStop => {
self.tab_stops.remove(&self.cursor.col);
self.changed();
self.display_only_changed();
None
}
AnsiEvent::ClearAllTabStops => {
self.tab_stops.clear();
self.changed();
self.display_only_changed();
None
}
AnsiEvent::CursorUp(n) => {
@ -432,23 +449,23 @@ impl TerminalScreen {
CharacterSetSlot::G0 => self.g0 = charset,
CharacterSetSlot::G1 => self.g1 = charset,
}
self.changed();
self.display_only_changed();
None
}
AnsiEvent::ShiftOut => {
self.use_g1 = true;
self.changed();
self.display_only_changed();
None
}
AnsiEvent::ShiftIn => {
self.use_g1 = false;
self.changed();
self.display_only_changed();
None
}
AnsiEvent::DeviceRequest(request) => Some(self.device_reply(request)),
AnsiEvent::SetTitle(title) => {
self.title = Some(sanitize_title(&title));
self.changed();
self.display_only_changed();
None
}
AnsiEvent::EraseToEol => {
@ -595,6 +612,7 @@ impl TerminalScreen {
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
title: self.title.as_deref(),
generation: self.generation,
mapping_revision: self.mapping_revision,
}
}
@ -654,7 +672,7 @@ impl TerminalScreen {
self.g0 = saved.g0;
self.g1 = saved.g1;
self.use_g1 = saved.use_g1;
self.changed();
self.display_only_changed();
}
fn write_text(&mut self, text: &str) {
@ -808,6 +826,24 @@ impl TerminalScreen {
if self.modes.insert {
self.insert_characters(width as u32);
}
// §5b — did the GLYPH change, or only the pen? Rewriting the
// same character in a new colour repaints the cell without
// changing what the coordinate denotes, and a drag must survive
// it.
//
// Sampled BEFORE `clear_wide_at`, which blanks the cell when it
// is part of a wide pair — sampling after would compare the new
// glyph against a default and call every rewrite a change.
let glyph_changed = {
let row = self.cursor.row;
let col = self.cursor.col;
let cells = &self.active().rows[row].cells;
cells[col].glyph != Glyph::Char(ch)
|| (width == 2
&& cells
.get(col + 1)
.is_none_or(|next| next.glyph != Glyph::Continuation))
};
self.clear_wide_at(self.cursor.row, self.cursor.col);
if width == 2 {
self.clear_wide_at(self.cursor.row, self.cursor.col + 1);
@ -828,7 +864,11 @@ impl TerminalScreen {
self.cursor.pending_wrap = false;
}
self.last_grapheme = Some((row, col));
self.changed();
if glyph_changed {
self.changed();
} else {
self.display_only_changed();
}
}
fn soft_wrap(&mut self) {
@ -882,7 +922,7 @@ impl TerminalScreen {
.copied()
.unwrap_or(cols - 1);
self.cursor.pending_wrap = false;
self.changed();
self.display_only_changed();
}
fn move_vertical(&mut self, delta: i64) {
@ -899,7 +939,7 @@ impl TerminalScreen {
};
self.cursor.row = moved.clamp(lo, hi);
self.cursor.pending_wrap = false;
self.changed();
self.display_only_changed();
}
fn move_horizontal(&mut self, delta: i64) {
@ -911,13 +951,13 @@ impl TerminalScreen {
};
self.cursor.col = moved.min(self.size.cols as usize - 1);
self.cursor.pending_wrap = false;
self.changed();
self.display_only_changed();
}
fn set_col(&mut self, col: u32) {
self.cursor.col = col.saturating_sub(1).min(self.size.cols - 1) as usize;
self.cursor.pending_wrap = false;
self.changed();
self.display_only_changed();
}
fn set_row(&mut self, row: u32) {
let base = if self.modes.origin {
@ -932,7 +972,7 @@ impl TerminalScreen {
};
self.cursor.row = (base + row.saturating_sub(1) as usize).min(hi);
self.cursor.pending_wrap = false;
self.changed();
self.display_only_changed();
}
fn set_position(&mut self, row: u32, col: u32) {
self.set_row(row);
@ -1466,6 +1506,32 @@ impl TerminalScreen {
fn changed(&mut self) {
self.generation = self.generation.saturating_add(1);
// §5b: by DEFAULT a change also moves the mapping. Anything not
// explicitly classified as display-only is treated as content,
// which fails in the safe direction — over-cancelling a gesture
// is a nuisance, under-cancelling one lets a stale coordinate
// reach a child.
self.mapping_revision = self.mapping_revision.saturating_add(1);
}
/// A change that repaints but **cannot move what a coordinate
/// denotes** (§5b's stable controls).
///
/// Style, title, bell, tab stops and pure cursor motion all land
/// here. The existing `generation` still advances — the screen does
/// look different — but `mapping_revision` does not, so a drag
/// survives them. Keying the panel's mapping on `generation` was
/// rejected for exactly this reason: it moves for all of these.
fn display_only_changed(&mut self) {
self.generation = self.generation.saturating_add(1);
}
/// §5b — identity of what a terminal coordinate DENOTES.
///
/// Advances with content and topology, and holds across the display
/// changes above.
pub fn mapping_revision(&self) -> u64 {
self.mapping_revision
}
fn current_snapshot(&self) -> ScreenSnapshot {
ScreenSnapshot {
@ -1495,8 +1561,20 @@ impl TerminalScreen {
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
title: self.title.clone(),
generation: self.generation,
mapping_revision: self.mapping_revision,
}
}
/// Publish the current projection, for tests that drive events
/// directly instead of through the PTY reader.
///
/// `#[doc(hidden)]` rather than `#[cfg(test)]`: the rows that need
/// it are integration tests, which link the library WITHOUT
/// `cfg(test)` and so cannot see a gated item.
#[doc(hidden)]
pub fn publish_for_test(&mut self) {
self.publish();
}
fn publish(&mut self) {
self.published = self.current_projection();
}
@ -1910,6 +1988,67 @@ mod tests {
assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 0)));
}
/// §5b G3 — the terminal **stable controls**.
///
/// These are exactly the events that make `generation` unusable as a
/// mapping key: each one advances it. `mapping_revision` must hold
/// across all of them, or a drag over a panel terminal dies the
/// moment the child recolours a character or rings the bell.
#[test]
fn display_only_events_advance_the_generation_but_not_the_mapping() {
for (name, event) in [
("style", AnsiEvent::SetStyle(Style::default())),
("title", AnsiEvent::SetTitle("t".to_owned())),
("bell", AnsiEvent::Bell),
("tab stop", AnsiEvent::SetTabStop),
("clear tab stops", AnsiEvent::ClearAllTabStops),
("carriage return", AnsiEvent::CarriageReturn),
] {
let mut s = screen(2, 16);
let before_generation = s.snapshot().generation;
let before_mapping = s.mapping_revision();
s.apply_event(event);
assert!(
s.snapshot().generation > before_generation,
"{name} repaints, so the display generation must advance \
— otherwise this row proves nothing about the split"
);
assert_eq!(
s.mapping_revision(),
before_mapping,
"{name} cannot change what a coordinate denotes, so the \
MAPPING revision must hold"
);
}
}
/// §5b G2 — content and topology **do** move the mapping revision.
///
/// The positive half. Without it, a `mapping_revision` that never
/// advanced at all would pass every stable control above.
#[test]
fn content_events_advance_the_mapping_revision() {
for (name, event) in [
("text", AnsiEvent::Text("hi".to_owned())),
("line feed", AnsiEvent::LineFeed),
(
"erase display",
AnsiEvent::EraseDisplay(crate::ansi::EraseMode::ToEnd),
),
("scroll up", AnsiEvent::ScrollUp(1)),
] {
let mut s = screen(2, 16);
let before = s.mapping_revision();
s.apply_event(event);
assert!(
s.mapping_revision() > before,
"{name} changes what a coordinate denotes"
);
}
}
#[test]
fn resize_only_adds_default_tab_stops_in_new_columns() {
let mut s = screen(2, 16);

View File

@ -249,6 +249,60 @@ impl TerminalManager {
self.screen_size(key.buffer_id)
}
/// Apply one parsed event to a session's screen, for tests.
///
/// Terminal output normally arrives on the PTY reader thread, which
/// no daemon-level test can drive deterministically. §5b's terminal
/// rows must nevertheless be witnessed **across the seam** — the
/// screen counter and the daemon's key are separately provable, and
/// a `view_mapping_identity` returning a constant would leave both
/// green — so this exists to join them.
///
/// `#[doc(hidden)]` rather than `#[cfg(test)]`, because the rows
/// that need it are integration tests and those link the library
/// without `cfg(test)`.
#[doc(hidden)]
pub fn apply_event_for_test(
&mut self,
buffer_id: BufferId,
event: crate::ansi::AnsiEvent,
) -> bool {
match self.sessions.get_mut(&buffer_id) {
Some(session) => {
session.screen.apply_event(event);
session.screen.publish_for_test();
true
}
None => false,
}
}
/// §5b — the terminal's **mapping revision** plus its per-view
/// scroll anchor: together, the identity of what a coordinate in
/// this view denotes.
///
/// The anchor is part of it because the same coordinate names a
/// different retained row once the view scrolls, even with the
/// child's screen untouched.
#[must_use]
pub fn view_mapping_identity(
&self,
key: TerminalViewKey,
) -> Option<(u64, Option<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
/// projection.
///
@ -965,6 +1019,7 @@ mod tests {
cursor: None,
title: Some("shell".into()),
generation: 7,
mapping_revision: 0,
}
}
@ -1245,6 +1300,7 @@ mod tests {
cursor: None,
title: None,
generation: 2,
mapping_revision: 0,
};
let mut state = TerminalViewState {
top: Some(LogicalCellAnchor {

View File

@ -117,9 +117,16 @@ impl Session {
first
}
/// The frame from whichever Present family this session negotiated.
///
/// Family-agnostic on purpose: these rows are about the PROJECTION,
/// and which wrapper carries it is §5b's own concern, pinned by the
/// G6/G7/G8 rows rather than incidentally by thirty others.
fn present(&mut self) -> PanelFrame {
match self.frame() {
Some(PanelFramePayload::Present(frame)) => frame,
Some(
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
) => frame,
other => panic!("expected a Present panel payload, got {other:?}"),
}
}
@ -522,7 +529,10 @@ fn acc41_degenerate_geometry_fails_closed_to_zero_usable_grid() {
session.declare(1, ROWS, COLS);
open_panel(&session, "*panel*", 4);
assert!(
matches!(session.frame(), Some(PanelFramePayload::Present(_))),
matches!(
session.frame(),
Some(PanelFramePayload::Present(_) | PanelFramePayload::PresentMapped { .. })
),
"{label}: fixture precondition — a band was visible first"
);
@ -898,7 +908,9 @@ fn acc45_one_statusline_invocation_serves_the_document_and_the_panel() {
let panel_rows = messages
.iter()
.find_map(|message| match message {
InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(rows_of(frame)),
InstanceMessage::PanelFrame(
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
) => Some(rows_of(frame)),
_ => None,
})
.expect("a panel frame");
@ -1327,7 +1339,9 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() {
session.state.core.borrow_mut().focus_window(FID, panel);
let frame = match session.frame() {
Some(PanelFramePayload::Present(frame)) => frame,
Some(
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
) => frame,
other => panic!(
"a legally wide panel must still present its terminal; got {other:?} \
— and the durable state says hidden={}",
@ -1362,3 +1376,728 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() {
// write into their real data root.
#[path = "common/iso.rs"]
mod iso;
// ---------------------------------------------------------------------------
// §5b G1–G4 — the authoritative cell-mapping key
//
// The key is derived from a FINGERPRINT of the inverse mapping's inputs,
// so the changing/stable split is structural: an input that is hashed
// moves the key by construction, and one that is not cannot. These rows
// pin each input individually, because a single "it changed" row cannot
// show WHICH input moved it — and a key that silently ignored, say,
// `view_left` would pass every row that only scrolls vertically.
// ---------------------------------------------------------------------------
/// Edit the panel's buffer from OUTSIDE any gesture — the "foreign
/// edit" the ladder cannot see. Done in Rust rather than Lua because it
/// must be a plain content mutation with no view, cursor or command
/// state attached to it.
fn foreign_edit(session: &Session, text: &str) {
let core = session.state.core.borrow();
let side = core.side_window_for(FID).expect("a side window");
let buffer_id = core.windows[&side].buffer_id;
let registry = core.registry.clone();
let mut reg = registry.borrow_mut();
let buffer = reg.get_mut(buffer_id).expect("the panel's buffer");
buffer
.set_generated_contents(text.as_bytes())
.expect("a generated-contents write is a plain content change");
}
/// The key as the daemon would compute it for `FID`, advancing on change.
fn mapping_generation(session: &mut Session) -> Option<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");
// §5b — whichever Present family this session negotiated. These
// rows are about the band ARRIVING; which wrapper carries it is
// pinned by the G6/G7/G8 rows, not incidentally here.
drain_until(&mut session.stream, "panel", |message| match message {
InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(frame.size),
InstanceMessage::PanelFrame(
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
) => Some(frame.size),
_ => None,
})
.is_some()
@ -251,8 +256,8 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
);
assert!(
press_and_await_panel(&mut current),
"a v21-negotiated semantic session must be panel-capable and receive \
a Present panel frame"
"a current-wire semantic session must be panel-capable and \
receive a Present-family panel frame"
);
// Half 3 — a semantic session that echoed the baseline is NOT
@ -337,10 +342,11 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
#[test]
fn the_baseline_stays_and_the_counter_offer_activates() {
// A deliberate tripwire: bumping the wire must be a conscious edit
// here, not a silent one. v24 is `TextInput` (GUI arc Stage 1a);
// v23 was `MinibufferPromptRows` (Discovery Stage 2); v22 was
// `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 24);
// here, not a silent one. v25 is the mapped panel family
// (`PanelPointerMapped` + `PresentMapped`, bottom-panel §5b); v24 is
// `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows`
// (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 25);
assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20,
"moving this is the incompatible act the mechanism exists to avoid"

View File

@ -25,3 +25,4 @@
pub mod daemon;
pub mod iso;
pub mod pty;
pub mod sigint_conformance;

View File

@ -0,0 +1,213 @@
//! Shared SIGINT-guard conformance vectors
//! (`docs/gpu-probe-sigint-framing.md` §7c).
//!
//! # Why these live here rather than in each suite
//!
//! The contract is that the **shell** consumer (`scripts/gate`) and the
//! **Rust** consumer (R-d's `sigint_diagnosis`) agree on every case.
//! Two independently written copies of the list can drift while both
//! still report "45 cases" — the same-length-different-content
//! divergence this matrix exists to rule out. One generator, two
//! consumers.
/// What a consumer must do with a given `(status, stdout)` pair.
///
/// `ValidatedError` and `Boundary` **both exit 2**, so a test comparing
/// only exit codes cannot separate them — and a validator that accepted
/// *every* status 2 would pass. They are told apart by whether the
/// child's stderr is surfaced: a validated verdict speaks with the
/// helper's voice; a boundary failure must not.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Safe,
ValidatedIgnored,
ValidatedError,
Boundary,
}
pub const TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe";
pub const TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored";
pub const TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error";
/// Emitted on stderr by the **branch-discriminating** cases — the
/// cross-product rows and X1 — so a consumer's output can be searched
/// for it: present ⇒ the child's stderr was surfaced.
///
/// **X3 and X4 deliberately carry their own payloads instead**, which
/// is what makes them distinct inputs rather than duplicates of
/// `1/E/empty` and `0/V/safe/bare`. An earlier revision gave every case
/// this same sentinel and so shipped 45 entries over 43 distinct
/// inputs; [`shared_cases`] now asserts uniqueness so that cannot
/// recur silently.
pub const SENTINEL: &str = "PMACS-CONFORMANCE-SENTINEL";
/// The canonical wording the helper uses for `ignored`. X3 emits it
/// **without** a valid token, so a consumer that surfaced untrusted
/// stderr would repeat it — the defect A6b forbids.
pub const CANONICAL_IGNORED: &str =
"pmacs: SIGINT is ignored; run this command with SIGINT deliverable";
pub struct Case {
pub name: String,
pub status: i32,
pub stdout: Vec<u8>,
/// Exact stderr this stub emits. Most cases use [`SENTINEL`]; X3
/// and X4 carry their own payloads, which is what makes them
/// distinct inputs rather than duplicates of other rows.
pub stderr: String,
pub expect: Outcome,
}
/// A `/bin/sh` stub reproducing one case exactly: its stdout bytes, its
/// own stderr payload, and its status.
#[must_use]
pub fn stub_script(case: &Case) -> String {
let octal = case.stdout.iter().fold(String::new(), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "\\{b:03o}");
acc
});
format!(
"#!/bin/sh\nprintf '{octal}'\necho '{}' >&2\nexit {}\n",
case.stderr, case.status
)
}
/// The shared set: ten classes × encodings × three statuses, plus
/// X1/X3/X4. Only the diagonal validates.
///
/// X2 — a spawn error with no status — is deliberately absent: the
/// shell boundary cannot represent it, because an `exec` failure there
/// becomes a status. Rust exercises it separately.
#[must_use]
#[allow(
clippy::too_many_lines,
reason = "the bulk is the generated vector list; splitting it would \
separate a case from the outcome it encodes, which is the \
one thing this file exists to keep together"
)]
pub fn shared_cases() -> Vec<Case> {
let toks: [(&str, &[u8]); 3] = [
("safe", TOKEN_SAFE),
("ignored", TOKEN_IGNORED),
("error", TOKEN_ERROR),
];
let mut out = Vec::new();
for (idx, (name, correct)) in toks.iter().enumerate() {
let status = i32::try_from(idx).expect("0..=2");
let diagonal = match status {
0 => Outcome::Safe,
1 => Outcome::ValidatedIgnored,
_ => Outcome::ValidatedError,
};
let mut lf = correct.to_vec();
lf.push(b'\n');
out.push(Case {
name: format!("{status}/V/{name}/bare"),
status,
stdout: correct.to_vec(),
stderr: SENTINEL.to_owned(),
expect: diagonal,
});
out.push(Case {
name: format!("{status}/V/{name}/lf"),
status,
stdout: lf,
stderr: SENTINEL.to_owned(),
expect: diagonal,
});
for (other, bytes) in &toks {
if other == name {
continue;
}
let mut olf = bytes.to_vec();
olf.push(b'\n');
out.push(Case {
name: format!("{status}/M/{other}/bare"),
status,
stdout: bytes.to_vec(),
stderr: SENTINEL.to_owned(),
expect: Outcome::Boundary,
});
out.push(Case {
name: format!("{status}/M/{other}/lf"),
status,
stdout: olf,
stderr: SENTINEL.to_owned(),
expect: Outcome::Boundary,
});
}
let mut leading = vec![b'\n'];
leading.extend_from_slice(correct);
let mut extra = correct.to_vec();
extra.extend_from_slice(b"\n\n");
let mut spaces = b" ".to_vec();
spaces.extend_from_slice(correct);
spaces.push(b' ');
let mut crlf = correct.to_vec();
crlf.extend_from_slice(b"\r\n");
let mut doubled = correct.to_vec();
doubled.extend_from_slice(correct);
let mut nul = correct.to_vec();
nul.push(0);
for (cls, bytes) in [
("E/empty", Vec::new()),
("U/unknown", b"pmacs-sigint-v2:safe".to_vec()),
("L/leading-lf", leading),
("X/extra-lf", extra),
("S/spaces", spaces),
("C/crlf", crlf),
("D/doubled", doubled),
("N/nul", nul),
] {
out.push(Case {
name: format!("{status}/{cls}"),
status,
stdout: bytes,
stderr: SENTINEL.to_owned(),
expect: Outcome::Boundary,
});
}
}
out.push(Case {
name: "X1/status-126".to_owned(),
status: 126,
stdout: TOKEN_SAFE.to_vec(),
stderr: SENTINEL.to_owned(),
expect: Outcome::Boundary,
});
out.push(Case {
name: "X3/ignored-text-no-token".to_owned(),
status: 1,
stdout: Vec::new(),
// The canonical ignored wording WITHOUT a token: a consumer
// that surfaced untrusted stderr would repeat it.
stderr: CANONICAL_IGNORED.to_owned(),
expect: Outcome::Boundary,
});
out.push(Case {
name: "X4/stderr-noise".to_owned(),
status: 0,
stdout: TOKEN_SAFE.to_vec(),
// Noise on stderr must not affect classification --- and this
// payload is what distinguishes X4 from 0/V/safe/bare.
stderr: "unrelated chatter on stderr".to_owned(),
expect: Outcome::Safe,
});
// The set must be 45 DISTINCT inputs, not merely 45 entries. A
// previous revision gave every case the same stderr, which silently
// collapsed X3 into `1/E/empty` and X4 into `0/V/safe/bare` — 45
// entries, 43 inputs, and two framing-specified cases quietly not
// exercised. Asserted here rather than in each suite so no consumer
// can forget it.
let mut seen = std::collections::HashSet::new();
for case in &out {
assert!(
seen.insert((case.status, case.stdout.clone(), case.stderr.clone())),
"duplicate conformance input at {}: (status, stdout, stderr) already present",
case.name
);
}
assert_eq!(seen.len(), out.len(), "every case must be a distinct input");
out
}

View File

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

View File

@ -28,6 +28,8 @@
//! `~/build/pmacs-gate-targets`, which matters most for the prune
//! tests — a prune bug is unrecoverable.
mod common;
use std::path::{Path, PathBuf};
use std::process::Command;
@ -35,6 +37,72 @@ fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
/// The SIGINT-deliverability helper the gate and the panel suite share
/// (`docs/gpu-probe-sigint-framing.md` §7c).
fn sigint_helper() -> PathBuf {
repo_root().join("scripts/check-sigint-deliverable")
}
/// Run `cmd` with `SIGINT` set to `SIG_IGN`, the way a shell that
/// backgrounds a job without job control does.
///
/// `trap "" INT` sets the ignore in the wrapper shell, and `SIG_IGN` is
/// inherited across `fork` **and survives `exec`** — which is the whole
/// mechanism under test, so simulating it this way exercises the real
/// thing rather than a stand-in.
fn under_ignored_sigint(
program: &Path,
args: &[&str],
cwd: &Path,
env: &[(&str, &str)],
) -> std::process::Output {
// `exec "$@"` with the program and arguments passed POSITIONALLY.
// Interpolating them into the script text would break on any path
// containing a space or a shell metacharacter, and every path here
// comes from a `tempdir` or `CARGO_MANIFEST_DIR` — neither of which
// this test controls.
let mut command = Command::new("sh");
command
.arg("-c")
.arg("trap \"\" INT; exec \"$@\"")
.arg("sh")
.arg(program)
.args(args)
.current_dir(cwd);
for (key, value) in env {
command.env(key, value);
}
command.output().expect("spawn shell with SIGINT ignored")
}
/// A minimal git worktree holding a copy of `scripts/gate` and a
/// **stub** `check-sigint-deliverable`, so the gate's handling of each
/// helper status can be driven on its real path without touching the
/// checked-in helper.
fn gate_with_stub_helper(stub_body: &str, executable: bool) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let scripts = dir.path().join("scripts");
std::fs::create_dir_all(&scripts).expect("scripts dir");
std::fs::copy(gate(), scripts.join("gate")).expect("copy gate");
let helper = scripts.join("check-sigint-deliverable");
std::fs::write(&helper, stub_body).expect("write stub helper");
let mode = if executable { 0o755 } else { 0o644 };
std::fs::set_permissions(&helper, std::os::unix::fs::PermissionsExt::from_mode(mode))
.expect("chmod stub helper");
std::fs::set_permissions(
scripts.join("gate"),
std::os::unix::fs::PermissionsExt::from_mode(0o755),
)
.expect("chmod gate copy");
let ok = Command::new("git")
.args(["init", "-q"])
.current_dir(dir.path())
.status()
.expect("git init");
assert!(ok.success(), "the stub worktree must be a git worktree");
dir
}
fn gate() -> PathBuf {
repo_root().join("scripts/gate")
}
@ -93,6 +161,345 @@ fn run(root: &Path, args: &[&str]) -> (String, String, bool) {
// §3, nothing else in the repository would notice. `--print-plan`
// exists to make that checkable without executing anything.
/// A6, gate consumer: a helper verdict of `error` (2) refuses the run
/// with the ERROR wording, and never claims `SIGINT` is ignored.
///
/// Driven through a stub worktree so the gate's real code path runs
/// against a controlled helper status; the helper's own classification
/// is covered by its own rows above.
#[test]
fn gate_refuses_on_helper_error_without_claiming_sigint_is_ignored() {
let root = tempfile::tempdir().expect("tempdir");
let repo = gate_with_stub_helper(
"#!/bin/sh\necho 'pmacs: could not determine whether SIGINT is deliverable (probe status 42)' >&2\nexit 2\n",
true,
);
let out = Command::new(repo.path().join("scripts/gate"))
.arg("--self-test")
.current_dir(repo.path())
.env("PMACS_GATE_TARGET_ROOT", root.path())
.output()
.expect("run the stub-worktree gate");
assert_eq!(
out.status.code(),
Some(2),
"an error verdict exits 2, not 1"
);
let err = String::from_utf8_lossy(&out.stderr);
// This stub emits the error TEXT but no token, so under the pair
// ABI it is a BOUNDARY error --- and its stderr is untrusted, hence
// deliberately not surfaced.
assert!(
err.contains("SIGINT guard boundary error"),
"an unvalidated pair is a boundary error: {err}"
);
assert!(
!err.contains("could not determine"),
"and the untrusted child stderr is NOT shown: {err}"
);
assert!(
!err.contains("SIGINT is ignored"),
"an undecidable probe must never be reported as ignored: {err}"
);
assert!(err.contains("no stage has run"), "and no stage ran: {err}");
}
/// A6, gate boundary: a helper that cannot be EXECUTED is an `error` at
/// the call boundary — mapped to 2 — never evidence that `SIGINT` is
/// ignored.
///
/// This is the case the original guard got wrong twice: under `set -e`
/// a bare invocation died before any mapping, and 126/127 would have
/// escaped raw.
#[test]
fn gate_maps_an_unexecutable_helper_to_error_not_ignored() {
let root = tempfile::tempdir().expect("tempdir");
let repo = gate_with_stub_helper("#!/bin/sh\nexit 0\n", false);
let out = Command::new(repo.path().join("scripts/gate"))
.arg("--self-test")
.current_dir(repo.path())
.env("PMACS_GATE_TARGET_ROOT", root.path())
.output()
.expect("run the stub-worktree gate");
let err = String::from_utf8_lossy(&out.stderr);
// The gate prints the raw probe status it saw. Carry it into every
// assertion message: this row failed on macOS with exit 1 where 2
// was expected, and the log could not say which status produced it
// because the message discarded stderr.
assert_eq!(
out.status.code(),
Some(2),
"boundary failures map to 2; gate said:\n{err}"
);
assert!(
err.contains("SIGINT guard boundary error"),
"the boundary has its own wording: {err}"
);
assert!(
err.contains("token=missing"),
"and names the token state, not just the status: {err}"
);
assert!(
!err.contains("SIGINT is ignored"),
"an unrunnable guard is not evidence about the signal: {err}"
);
}
/// A6/A6b/A6c, shell consumer: the shared vectors, asserting the exact
/// branch rather than only the exit code.
///
/// `ValidatedError` and `Boundary` both exit 2, so comparing codes
/// alone would let a validator that accepts every status 2 pass. The
/// branch-discriminating cases emit a sentinel on stderr; a validated
/// verdict surfaces it, while X3/X4 carry dedicated payloads and a
/// boundary failure must withhold untrusted stderr.
#[test]
fn gate_validates_the_whole_shared_conformance_set() {
use common::sigint_conformance::{
CANONICAL_IGNORED, Outcome, SENTINEL, shared_cases, stub_script,
};
let cases = shared_cases();
assert_eq!(cases.len(), 45, "the shared set is 45 cases");
for case in cases {
let root = tempfile::tempdir().expect("tempdir");
let repo = gate_with_stub_helper(&stub_script(&case), true);
let out = Command::new(repo.path().join("scripts/gate"))
.arg("--self-test")
.current_dir(repo.path())
.env("PMACS_GATE_TARGET_ROOT", root.path())
.env("TMPDIR", root.path())
.output()
.expect("run the stub-worktree gate");
let err = String::from_utf8_lossy(&out.stderr);
let name = &case.name;
match case.expect {
Outcome::Safe => assert!(
!err.contains("REFUSING TO RUN"),
"case {name}: a validated safe pair must continue: {err}"
),
Outcome::ValidatedIgnored => {
assert_eq!(out.status.code(), Some(1), "case {name}: {err}");
assert!(
err.contains(SENTINEL),
"case {name}: a validated verdict surfaces the helper's \
stderr: {err}"
);
assert!(err.contains("token=valid"), "case {name}: {err}");
}
Outcome::ValidatedError => {
assert_eq!(out.status.code(), Some(2), "case {name}: {err}");
assert!(
err.contains(SENTINEL),
"case {name}: a validated error also speaks with the \
helper's voice: {err}"
);
assert!(err.contains("token=valid"), "case {name}: {err}");
}
Outcome::Boundary => {
assert_eq!(out.status.code(), Some(2), "case {name}: {err}");
assert!(
err.contains("SIGINT guard boundary error"),
"case {name}: {err}"
);
assert!(
!err.contains(SENTINEL),
"case {name}: a boundary failure must NOT surface the \
child's stderr --- this is what separates it from a \
validated error, which shares its exit code: {err}"
);
assert!(
!err.contains(CANONICAL_IGNORED),
"case {name}: and it must never repeat the canonical \
ignored wording --- X3 emits exactly that on stderr \
with no token: {err}"
);
}
}
// A8: no capture directory survives, on any path. `root` is RAII
// --- it is dropped at the end of this iteration.
let residue: Vec<_> = std::fs::read_dir(root.path())
.expect("read tmpdir")
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().starts_with("pmacs-sigint."))
.collect();
assert!(residue.is_empty(), "case {name}: capture residue survived");
}
}
/// A8: the guard cannot create its capture directory.
///
/// Bounded --- it never reaches a stage. `TMPDIR` points at a path that
/// does not exist, so `mktemp -d` fails and the guard must refuse
/// before running the helper at all.
#[test]
fn gate_refuses_when_the_capture_directory_cannot_be_created() {
let root = tempfile::tempdir().expect("tempdir");
let repo = gate_with_stub_helper("#!/bin/sh\nprintf 'pmacs-sigint-v1:safe'\nexit 0\n", true);
let out = Command::new(repo.path().join("scripts/gate"))
.arg("--self-test")
.current_dir(repo.path())
.env("PMACS_GATE_TARGET_ROOT", root.path())
.env("TMPDIR", root.path().join("absent-directory"))
.output()
.expect("run the stub-worktree gate");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "boundary error: {err}");
assert!(
err.contains("capture directory"),
"the failure names what could not be created: {err}"
);
assert!(err.contains("no stage has run"), "and no stage ran: {err}");
assert!(
!String::from_utf8_lossy(&out.stdout).contains("[01]"),
"no stage may run"
);
// A8 on this path too: the temporary root is inspected BEFORE its
// RAII drop, and must contain nothing the guard left behind.
let residue: Vec<_> = std::fs::read_dir(root.path())
.expect("read tmpdir")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(
residue.is_empty(),
"a guard that could not create its capture directory must leave \
nothing behind: {residue:?}"
);
}
/// Each helper arm emits its exact token on stdout.
#[test]
fn sigint_helper_emits_the_exact_token_for_each_arm() {
use common::sigint_conformance::{TOKEN_ERROR, TOKEN_IGNORED, TOKEN_SAFE};
let safe = Command::new(sigint_helper()).output().expect("run helper");
assert_eq!(safe.status.code(), Some(0));
assert_eq!(
safe.stdout,
[TOKEN_SAFE, b"\n"].concat(),
"the safe arm emits exactly its token plus one LF"
);
let erroring = Command::new(sigint_helper())
.env("PATH", "")
.output()
.expect("run helper");
assert_eq!(erroring.status.code(), Some(2));
assert_eq!(erroring.stdout, [TOKEN_ERROR, b"\n"].concat());
// The ignored arm needs a shell that ignores SIGINT; assert its
// STDOUT, not merely its status and stderr.
let ignored = under_ignored_sigint(&sigint_helper(), &[], &repo_root(), &[]);
assert_eq!(ignored.status.code(), Some(1));
assert_eq!(
ignored.stdout,
[TOKEN_IGNORED, b"\n"].concat(),
"the ignored arm emits exactly its token plus one LF"
);
}
/// §7c: the helper answers `safe` when `SIGINT` is deliverable.
#[test]
fn sigint_helper_reports_safe_when_the_signal_is_deliverable() {
let out = Command::new(sigint_helper())
.output()
.expect("run the sigint helper");
assert_eq!(out.status.code(), Some(0), "safe is exit 0");
assert!(
out.stderr.is_empty(),
"the safe path is silent, so a clean run says nothing: {}",
String::from_utf8_lossy(&out.stderr)
);
}
/// §7c: the helper answers `ignored` — exit 1, canonical wording — when
/// `SIGINT` is inherited as `SIG_IGN`.
#[test]
fn sigint_helper_reports_ignored_when_the_signal_is_inherited_ignored() {
let out = under_ignored_sigint(&sigint_helper(), &[], &repo_root(), &[]);
assert_eq!(out.status.code(), Some(1), "ignored is exit 1");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("SIGINT is ignored"),
"the canonical ignored diagnosis is the helper's to own: {err}"
);
}
/// §7c: the helper answers `error` — exit 2, a DISTINCT diagnosis — when
/// the probe cannot decide.
///
/// The probe shells out, so an empty `PATH` makes its inner `sh`
/// unfindable. This is the case a naive `exit 0` would misreport as
/// `ignored`, failing the caller for the wrong reason.
#[test]
fn sigint_helper_reports_error_and_never_ignored_when_the_probe_cannot_run() {
let out = Command::new(sigint_helper())
.env("PATH", "")
.output()
.expect("run the sigint helper with no PATH");
assert_eq!(out.status.code(), Some(2), "error is exit 2, never 1");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("could not determine"),
"error has its own wording: {err}"
);
assert!(
!err.contains("SIGINT is ignored"),
"error must NOT be reported as ignored --- they are different \
problems, and conflating them is the defect the helper exists \
to avoid: {err}"
);
}
/// R-b: the gate refuses under ignored `SIGINT`, **before any stage**.
///
/// This is the row whose absence let a real bug ship: the first
/// implementation ran the helper as a bare command under `set -e`, so
/// the shell died at the non-zero exit and the refusal never printed;
/// the second captured `$?` inside `if !`, which is the status of the
/// negated condition — always zero — so the gate printed the diagnosis
/// and then ran the whole suite anyway. Both passed every other test in
/// this file.
#[test]
fn gate_refuses_to_start_when_sigint_is_ignored() {
let root = tempfile::tempdir().expect("tempdir");
// `--self-test`, NOT the ordinary gate. If the guard ever regresses,
// this row must not launch eight real gate stages inside the gate
// suite — the recursion constraint this file opens with. Self-test
// drives the same runner over a hardcoded synthetic plan, so the
// negative path stays bounded whatever the guard does.
let out = under_ignored_sigint(
&gate(),
&["--self-test"],
&repo_root(),
&[("PMACS_GATE_TARGET_ROOT", &root.path().display().to_string())],
);
assert_eq!(
out.status.code(),
Some(1),
"the helper's verdict passes through"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("SIGINT is ignored"),
"the gate surfaces the helper's stderr unchanged rather than \
inventing its own wording: {err}"
);
assert!(
err.contains("no stage has run"),
"and says the run is not a test failure: {err}"
);
let combined = format!("{}{err}", String::from_utf8_lossy(&out.stdout));
assert!(
!combined.contains("[01]"),
"NO stage may run --- the guard sits before stage 1: {combined}"
);
}
#[test]
fn the_plan_sweeps_the_workspace_and_never_only_the_tests() {
let root = tempfile::Builder::new()

View File

@ -2,6 +2,8 @@
#![cfg(unix)]
mod common;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
@ -171,6 +173,177 @@ mod crdt {
let _ = kill(Pid::from_raw(pid.cast_signed()), signal);
}
/// R-d (framing §7c): refuse to run this test when `SIGINT` is not
/// deliverable, and say so.
///
/// The test signals a process group and requires the launcher to
/// exit. If `SIGINT` is inherited as `SIG_IGN` --- which a shell
/// running a command in the background without job control sets,
/// and which survives `fork` and `exec` --- the signal is a no-op
/// and the launcher waits out the deadline. Without this the
/// failure reads "child did not exit within 5s", which names a
/// teardown defect that is not there; that misreading cost nine
/// framing revisions (§4c).
///
/// Both consumers use the **same checked-in helper**, but that alone
/// no longer makes them agree: each validates the
/// `(status, token)` pair independently, in a different language.
/// Revision 12's "they can never disagree" is withdrawn, and the
/// shared matrix in `tests/common/sigint_conformance.rs` replaces
/// it — both validators run the same vectors.
///
/// This consumer proceeds only on a validated `(0, safe)` pair. On a
/// **boundary** failure the helper's stderr is untrusted and is
/// withheld; only a validated verdict speaks with the helper's
/// voice.
const SIGINT_TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe";
const SIGINT_TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored";
const SIGINT_TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error";
/// Does `stdout` carry exactly `token`, in one of the two permitted
/// encodings? Grammar is `TOKEN | TOKEN LF` **as bytes** — no
/// trimming, so a leading newline, a second newline, surrounding
/// spaces, CRLF, a doubled token or a trailing NUL all fail.
fn sigint_token_matches(stdout: &[u8], token: &[u8]) -> bool {
stdout == token
|| (stdout.len() == token.len() + 1
&& stdout.starts_with(token)
&& stdout[token.len()] == b'\n')
}
/// The diagnosis for one helper invocation, validating the
/// `(status, token)` pair rather than the status alone.
///
/// A status arriving without its token did not come from this
/// helper — not hypothetical: on macOS a shell that cannot execute
/// the helper exits 1, which a status-only ABI read as `ignored`
/// (§4d). Rust compares `Command::output()` bytes directly; only
/// the shell consumer needs capture files.
fn sigint_diagnosis(helper: &Path) -> Result<(), String> {
let out = match Command::new(helper).output() {
// Rust's boundary differs from the shell's: a spawn error
// has NO status, where a shell turns the same failure into
// one. Conformance X2, Rust-only.
Err(error) => {
return Err(format!(
"precondition undecidable --- SIGINT guard boundary error \
(status=unavailable token=missing): could not execute {}: {error}",
helper.display()
));
}
Ok(out) => out,
};
let expected: &[u8] = match out.status.code() {
Some(0) => SIGINT_TOKEN_SAFE,
Some(1) => SIGINT_TOKEN_IGNORED,
Some(2) => SIGINT_TOKEN_ERROR,
_ => b"",
};
let token_ok = !expected.is_empty() && sigint_token_matches(&out.stdout, expected);
let token_state = if out.stdout.is_empty() {
"missing"
} else if token_ok {
"valid"
} else {
"unexpected"
};
let status = out
.status
.code()
.map_or_else(|| "signal".to_owned(), |c| c.to_string());
match (out.status.code(), token_ok) {
(Some(0), true) => Ok(()),
// A VALIDATED verdict: the helper's stderr is the diagnosis.
(Some(1 | 2), true) => Err(format!(
"precondition failed --- this is NOT a teardown defect. \
(status={status} token={token_state})\n{}",
String::from_utf8_lossy(&out.stderr).trim_end()
)),
// BOUNDARY: the child's stderr is UNTRUSTED and is not shown,
// or a helper exiting 1 with no token but the canonical
// ignored wording would still mislead the reader (A6b).
_ => Err(format!(
"precondition undecidable --- SIGINT guard boundary error \
(status={status} token={token_state}). The helper's own \
output is not trusted here and is not shown."
)),
}
}
fn sigint_helper_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-sigint-deliverable")
}
fn require_sigint_deliverable() {
if let Err(diagnosis) = sigint_diagnosis(&sigint_helper_path()) {
panic!("{diagnosis}");
}
}
/// A6/A6b/A6c, R-d consumer: the shared vectors, asserting the exact
/// branch.
///
/// `ValidatedError` and `Boundary` both produce `Err`, so comparing
/// `is_ok()` alone would let a validator that accepts every status 2
/// pass. The branch-discriminating cases emit a sentinel on stderr;
/// a validated verdict surfaces it, while X3/X4 carry dedicated
/// payloads and a boundary failure must withhold untrusted stderr.
#[test]
fn rd_precondition_validates_the_whole_conformance_set() {
// `super::` and NOT `crate::`: this file is ALSO compiled as a
// nested module of `gpu_initial_target_acceptance.rs`, where
// `crate::` is the outer test crate and has no `common`.
use super::common::sigint_conformance::{
CANONICAL_IGNORED, Outcome, SENTINEL, shared_cases, stub_script,
};
let dir = tempfile::tempdir().expect("tempdir");
let cases = shared_cases();
assert_eq!(cases.len(), 45, "the shared set is 45 cases");
for case in cases {
let path = dir.path().join(case.name.replace('/', "_"));
fs::write(&path, stub_script(&case)).expect("write stub");
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
let got = sigint_diagnosis(&path);
let name = &case.name;
match case.expect {
Outcome::Safe => assert!(got.is_ok(), "case {name}: {got:?}"),
Outcome::ValidatedIgnored | Outcome::ValidatedError => {
let message = got.expect_err("a validated refusal");
assert!(
message.contains(SENTINEL),
"case {name}: a validated verdict surfaces the helper's stderr: {message}"
);
assert!(message.contains("token=valid"), "case {name}: {message}");
}
Outcome::Boundary => {
let message = got.expect_err("a boundary refusal");
assert!(message.contains("boundary error"), "case {name}: {message}");
assert!(
!message.contains(CANONICAL_IGNORED),
"case {name}: never repeats the canonical ignored wording \
--- X3 emits exactly that with no token: {message}"
);
assert!(
!message.contains(SENTINEL),
"case {name}: a boundary failure must NOT surface the child's \
stderr --- that is what separates it from a validated error, \
which shares its outcome type: {message}"
);
}
}
}
// X2 --- Rust only: a shell exec failure becomes a status, so the
// shell consumer cannot present a status-less spawn error.
let message = sigint_diagnosis(&dir.path().join("absent")).expect_err("must not validate");
assert!(
message.contains("status=unavailable") && !message.contains(SENTINEL),
"X2: {message}"
);
}
fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {
let deadline = Instant::now() + timeout;
loop {
@ -1083,6 +1256,7 @@ mod crdt {
#[test]
fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() {
require_sigint_deliverable();
let temp = secure_tempdir();
let socket = temp.path().join("signal.sock");
let report = temp.path().join("signal-report");

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

View File

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