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:
commit
b758c2e76f
1051
docs/active-work.md
1051
docs/active-work.md
File diff suppressed because it is too large
Load Diff
|
|
@ -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]
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
110
scripts/gate
110
scripts/gate
|
|
@ -506,6 +506,116 @@ esac
|
|||
WT=$(canon "$(worktree_root)")
|
||||
cd "$WT"
|
||||
|
||||
# R-b (framing SS7c): refuse to start when SIGINT is not deliverable.
|
||||
#
|
||||
# HERE, immediately after the worktree resolves and BEFORE any log
|
||||
# directory, ambient root or temporary dir exists: a refused run should
|
||||
# leave nothing behind to clean up or mistake for evidence.
|
||||
#
|
||||
# WHY AT ALL. A shell running a command in the background without job
|
||||
# control sets SIGINT to SIG_IGN; that survives fork AND exec, so every
|
||||
# test which signals a child and waits for it hangs to its own
|
||||
# deadline. Seven red sweeps were read as a product teardown defect
|
||||
# before this was found (framing SS4c).
|
||||
#
|
||||
# The helper owns the classification and the wording; this consumer
|
||||
# re-derives neither. It continues only on 0, and otherwise stops after
|
||||
# letting the helper's stderr through untouched.
|
||||
#
|
||||
# `|| sigint_status=$?` IS LOAD-BEARING, and two wrong shapes were tried
|
||||
# before this one:
|
||||
#
|
||||
# * a bare invocation dies under `set -e` at the helper's non-zero
|
||||
# exit, so the refusal below never prints;
|
||||
# * `if ! helper; then sigint_status=$?; fi` captures the status of
|
||||
# the NEGATED condition --- always 0 --- so the guard printed the
|
||||
# diagnosis and then ran the whole gate anyway.
|
||||
#
|
||||
# In a `cmd || assignment` list the failure is handled, so `set -e` does
|
||||
# not fire and `$?` is the helper's own status. This is the same shape
|
||||
# the helper uses internally.
|
||||
#
|
||||
# NO OVERRIDE, deliberately: a run in this state cannot produce valid
|
||||
# evidence, so a flag to proceed anyway would only manufacture red gates
|
||||
# that mean nothing.
|
||||
if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then
|
||||
# The guard owns its capture directory: it runs BEFORE the log dir,
|
||||
# ambient root and GATE_TMPDIR exist, and must still leave nothing
|
||||
# behind (A8).
|
||||
capture=$(mktemp -d "${TMPDIR:-/tmp}/pmacs-sigint.XXXXXX") || {
|
||||
echo 'gate: could not create the SIGINT guard capture directory (status=unavailable token=missing)' >&2
|
||||
echo 'gate: REFUSING TO RUN --- no stage has run.' >&2
|
||||
exit 2
|
||||
}
|
||||
cleanup_sigint_capture() { rm -rf "$capture"; }
|
||||
trap cleanup_sigint_capture EXIT HUP INT TERM
|
||||
|
||||
# `|| sigint_status=$?` IS LOAD-BEARING under `set -eu`: a bare
|
||||
# invocation dies at the helper's non-zero exit and never reaches
|
||||
# the assignment. That was the originally shipped bug.
|
||||
sigint_status=0
|
||||
"$WT/scripts/check-sigint-deliverable" \
|
||||
>"$capture/out" 2>"$capture/err" || sigint_status=$?
|
||||
|
||||
# Select an expected token only for public statuses. This MUST
|
||||
# precede any use of expected_token: `set -u` is on, and an
|
||||
# out-of-range status has no expected token.
|
||||
expected_token=
|
||||
case "$sigint_status" in
|
||||
0) expected_token=pmacs-sigint-v1:safe ;;
|
||||
1) expected_token=pmacs-sigint-v1:ignored ;;
|
||||
2) expected_token=pmacs-sigint-v1:error ;;
|
||||
esac
|
||||
|
||||
# Byte comparison against both permitted encodings. Files preserve
|
||||
# every byte including NUL; a shell variable would not, and command
|
||||
# substitution's NUL handling differs between sh and zsh.
|
||||
sigint_token_ok=0
|
||||
if [ -n "$expected_token" ]; then
|
||||
printf '%s' "$expected_token" >"$capture/want"
|
||||
printf '%s\n' "$expected_token" >"$capture/want_lf"
|
||||
if cmp -s "$capture/out" "$capture/want" ||
|
||||
cmp -s "$capture/out" "$capture/want_lf"; then
|
||||
sigint_token_ok=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -s "$capture/out" ]; then
|
||||
sigint_token_state=missing
|
||||
elif [ "$sigint_token_ok" -eq 1 ]; then
|
||||
sigint_token_state=valid
|
||||
else
|
||||
sigint_token_state=unexpected
|
||||
fi
|
||||
|
||||
case "$sigint_status:$sigint_token_ok" in
|
||||
0:1)
|
||||
# The sole continuing path. Tidy up and disarm before the
|
||||
# gate installs its own, unrelated cleanup trap.
|
||||
cleanup_sigint_capture
|
||||
trap - EXIT HUP INT TERM
|
||||
;;
|
||||
1:1 | 2:1)
|
||||
# A VALIDATED verdict: the helper's stderr IS the diagnosis
|
||||
# and is surfaced unchanged.
|
||||
cat "$capture/err" >&2
|
||||
printf 'gate: REFUSING TO RUN (status=%s token=%s) --- no stage has run.\n' \
|
||||
"$sigint_status" "$sigint_token_state" >&2
|
||||
exit "$sigint_status"
|
||||
;;
|
||||
*)
|
||||
# BOUNDARY error. The captured stderr is UNTRUSTED and is
|
||||
# deliberately not surfaced: a helper exiting 1 with no
|
||||
# token but the canonical ignored wording would otherwise
|
||||
# tell the operator their environment ignores SIGINT (A6b).
|
||||
printf 'gate: SIGINT guard boundary error (status=%s token=%s)\n' \
|
||||
"$sigint_status" "$sigint_token_state" >&2
|
||||
printf 'gate: REFUSING TO RUN --- no stage has run.\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
TARGET=$(ensure_target_dir "$WT")
|
||||
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
# $$ as well as the timestamp: two invocations in the same worktree
|
||||
|
|
|
|||
1436
src/daemon.rs
1436
src/daemon.rs
File diff suppressed because it is too large
Load Diff
135
src/editor.rs
135
src/editor.rs
|
|
@ -375,6 +375,76 @@ const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500);
|
|||
/// column and clobbers no information.
|
||||
const DIVIDER_HANDLE_GLYPH: char = '⇕';
|
||||
|
||||
/// The panel's **inverse mapping**, captured exactly (§5b,
|
||||
/// Q#BP-R3).
|
||||
///
|
||||
/// A STRUCT compared structurally, not a hash. A hash would make
|
||||
/// authoritative equality probabilistic: a collision silently
|
||||
/// accepts a stale gesture, which is the precise failure this key
|
||||
/// exists to prevent. The emitted `mapping_generation` is still a
|
||||
/// `u64` on the wire — only the daemon's own comparison is exact.
|
||||
///
|
||||
/// **Deliberately EXCLUDED**, each an explicit contract: focus,
|
||||
/// styling and theme, the cursor, and the selection. None changes
|
||||
/// which byte a cell denotes, and a drag repaints the selection on
|
||||
/// every motion — a key that moved with them would cancel the
|
||||
/// gesture it protects after one step.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct PanelMappingSnapshot {
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
/// Rows and columns held apart, never multiplied: a 2×6 panel
|
||||
/// inverts nothing like a 6×2 one.
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
content: PanelMappingContent,
|
||||
}
|
||||
|
||||
impl PanelMappingSnapshot {
|
||||
/// Which domain decided this mapping. Exposed for the row that pins
|
||||
/// the branch is taken by target kind.
|
||||
#[must_use]
|
||||
pub fn content(&self) -> &PanelMappingContent {
|
||||
&self.content
|
||||
}
|
||||
}
|
||||
|
||||
/// What decides the mapping BELOW the geometry, which differs by
|
||||
/// target kind.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum PanelMappingContent {
|
||||
/// A document panel: the buffer's content revision.
|
||||
Document {
|
||||
/// The buffer's content revision, or `None` if it is gone.
|
||||
revision: Option<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,
|
||||
|
|
|
|||
181
src/protocol.rs
181
src/protocol.rs
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[test]
|
||||
fn protocol_version_is_twenty_four_for_text_input() {
|
||||
fn protocol_version_is_twenty_five_for_the_mapped_panel_family() {
|
||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||
|
|
@ -1741,10 +1741,11 @@ mod tests {
|
|||
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
|
||||
// pinned by literal bytes below.
|
||||
//
|
||||
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) — an
|
||||
// APPENDED variant, which is why the freeze above survives it
|
||||
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) and v25
|
||||
// is the mapped panel family (bottom-panel §5b) — both APPENDED
|
||||
// variants, which is why the freeze above survives them
|
||||
// untouched: nothing in `MinibufferPrompt`'s encoding moved.
|
||||
assert_eq!(PROTOCOL_VERSION, 24);
|
||||
assert_eq!(PROTOCOL_VERSION, 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1822,18 +1823,19 @@ mod tests {
|
|||
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
|
||||
// v20 (semantic initial-target bootstrap), v21 (the bottom
|
||||
// panel band), v22 (`LineWrapFacts`), v23
|
||||
// (`MinibufferPromptRows`), and v24 (`TextInput`, GUI arc Stage
|
||||
// 1a) all interoperate.
|
||||
for accepted in 6..=24 {
|
||||
// (`MinibufferPromptRows`), v24 (`TextInput`, GUI arc Stage 1a)
|
||||
// and v25 (`PanelPointerMapped` + `PresentMapped`, bottom-panel
|
||||
// §5b) all interoperate.
|
||||
for accepted in 6..=PROTOCOL_VERSION {
|
||||
assert!(
|
||||
is_supported_protocol_version(accepted),
|
||||
"v{accepted} must be accepted"
|
||||
);
|
||||
}
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 25, u32::MAX] {
|
||||
for rejected in [0, 1, 2, 3, 4, 5, PROTOCOL_VERSION + 1, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by a v24 binary"
|
||||
"v{rejected} must be rejected by the current binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2008,6 +2010,167 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// §5b G0a — the **previous-final `FrontendEvent`** is now
|
||||
/// `TextInput`, and it gets its own exact pin.
|
||||
///
|
||||
/// Pins ACCUMULATE rather than move. The `PanelPointer` pin above
|
||||
/// still protects the v21–v23 population; this one protects v24's,
|
||||
/// and appending `PanelPointerMapped` is exactly the edit that would
|
||||
/// shift it if it were inserted rather than appended.
|
||||
#[test]
|
||||
fn text_input_encoding_is_unchanged_by_the_v25_build() {
|
||||
let ev = FrontendEvent::TextInput {
|
||||
frontend_id: FrontendId(2),
|
||||
text: "hi".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&ev).expect("encode"),
|
||||
[16, 2, 2, 104, 105],
|
||||
"TextInput's v24 wire bytes changed — a variant was inserted \
|
||||
before it; append new FrontendEvent variants at the end"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G0a — the **previous-final `PanelFramePayload`** is `Absent`,
|
||||
/// pinned through its real nesting inside `InstanceMessage`.
|
||||
///
|
||||
/// Nested deliberately: `PanelFramePayload` never travels alone, and
|
||||
/// a pin on the bare enum would miss a shift in the message
|
||||
/// discriminant that carries it.
|
||||
#[test]
|
||||
fn absent_panel_payload_encoding_is_unchanged_by_the_v25_build() {
|
||||
let msg = InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent);
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&msg).expect("encode"),
|
||||
[28, 1],
|
||||
"Absent's wire bytes changed — a PanelFramePayload variant \
|
||||
was inserted before it, or InstanceMessage's discriminant \
|
||||
moved; append PresentMapped AFTER Absent"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G0b — exact **encode and decode** for the mapped pointer.
|
||||
///
|
||||
/// **Every adjacent same-typed field carries a different value**, so
|
||||
/// a reordering is visible. `geometry_epoch`, `panel_epoch` and
|
||||
/// `mapping_generation` are all `u64` and all distinct (3, 7, 11);
|
||||
/// `coord.row`/`coord.col` likewise (5, 9). A round-trip alone would
|
||||
/// stay green through any swap of those — it is self-consistent by
|
||||
/// construction — which is why the exact bytes are asserted in both
|
||||
/// directions.
|
||||
#[test]
|
||||
fn panel_pointer_mapped_encodes_and_decodes_exactly() {
|
||||
const WIRE: [u8; 11] = [17, 2, 3, 7, 4, 5, 9, 0, 0, 0, 11];
|
||||
let ev = FrontendEvent::PanelPointerMapped {
|
||||
frontend_id: FrontendId(2),
|
||||
geometry_epoch: 3,
|
||||
panel_epoch: 7,
|
||||
buffer_id: pmacs_protocol::BufferId::from_raw(4),
|
||||
coord: CellCoord { row: 5, col: 9 },
|
||||
kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left),
|
||||
mods: Modifiers::NONE,
|
||||
mapping_generation: 11,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&ev).expect("encode"),
|
||||
WIRE,
|
||||
"PanelPointerMapped's v25 wire bytes changed — it must be \
|
||||
APPENDED after TextInput, with mapping_generation last"
|
||||
);
|
||||
let decoded: FrontendEvent = postcard::from_bytes(&WIRE).expect("decode");
|
||||
assert_eq!(
|
||||
decoded, ev,
|
||||
"the frozen historical bytes must decode back to the same \
|
||||
value — round-trip alone would only witness the current \
|
||||
encoder and decoder agreeing with each other"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G0b — exact **encode and decode** for the mapped frame,
|
||||
/// through its real nesting.
|
||||
///
|
||||
/// Reordering named variant fields still compiles and changes
|
||||
/// postcard's positional bytes, so the exact pin is load-bearing.
|
||||
/// The frame's own `panel_epoch` (8) and `geometry_epoch` (12) are
|
||||
/// adjacent `u64`s and are pinned distinct for the same reason as
|
||||
/// above.
|
||||
#[test]
|
||||
fn present_mapped_encodes_and_decodes_exactly() {
|
||||
const WIRE: [u8; 33] = [
|
||||
28, 2, 6, 8, 12, 1, 2, 2, 0, 1, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 32, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 1, 13,
|
||||
];
|
||||
let frame = pmacs_protocol::panel::PanelFrame {
|
||||
buffer_id: pmacs_protocol::BufferId::from_raw(6),
|
||||
panel_epoch: 8,
|
||||
geometry_epoch: 12,
|
||||
size: CellSize::new(1, 2),
|
||||
cells: vec![Cell::default(); 2],
|
||||
cursor: None,
|
||||
focused: true,
|
||||
};
|
||||
let msg =
|
||||
InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::PresentMapped {
|
||||
frame,
|
||||
mapping_generation: 13,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&msg).expect("encode"),
|
||||
WIRE,
|
||||
"PresentMapped's v25 wire bytes changed — it must be APPENDED \
|
||||
after Absent, with mapping_generation last"
|
||||
);
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&WIRE).expect("decode");
|
||||
assert_eq!(decoded, msg, "the frozen bytes must decode back");
|
||||
}
|
||||
|
||||
/// §5b G14a — the version constants move together, and the
|
||||
/// advertised baseline does not move at all.
|
||||
#[test]
|
||||
fn the_mapping_slice_takes_v25_and_the_advertised_baseline_stays_pinned() {
|
||||
assert_eq!(pmacs_protocol::PROTOCOL_VERSION, 25);
|
||||
assert_eq!(pmacs_protocol::PANEL_MAPPING_MIN_VERSION, 25);
|
||||
assert!(
|
||||
pmacs_protocol::SUPPORTED_PROTOCOL_VERSIONS.contains(&pmacs_protocol::PROTOCOL_VERSION),
|
||||
"a wire version the handshake does not support fails against \
|
||||
this daemon's own peers"
|
||||
);
|
||||
assert_eq!(
|
||||
pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
|
||||
20,
|
||||
"the advertised baseline is PERMANENT — it is not bumped to \
|
||||
chase the wire version"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G14b — the supported set's boundary, and the family boundary,
|
||||
/// are both literal.
|
||||
#[test]
|
||||
fn the_supported_set_ends_at_the_current_wire_version() {
|
||||
assert!(pmacs_protocol::is_supported_protocol_version(25));
|
||||
assert!(
|
||||
!pmacs_protocol::is_supported_protocol_version(26),
|
||||
"an unreleased version must not negotiate"
|
||||
);
|
||||
// The family boundary is stated against a LITERAL, not against
|
||||
// `PROTOCOL_VERSION`: expressing it arithmetically would drag
|
||||
// this feature forward on the next bump, so v24 sessions would
|
||||
// silently start being offered a family they never negotiated.
|
||||
//
|
||||
// Asserted as equality rather than `24 < MIN`, which clippy
|
||||
// correctly calls a compile-time tautology — it holds for every
|
||||
// value above 24 and so pins nothing.
|
||||
assert_eq!(25, pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
|
||||
assert_ne!(
|
||||
pmacs_protocol::PANEL_MAPPING_MIN_VERSION,
|
||||
pmacs_protocol::TEXT_INPUT_MIN_VERSION,
|
||||
"the mapped family must not share v24's gate — that would \
|
||||
admit it on sessions that negotiated only TextInput"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_family_round_trips_and_pins_its_discriminants() {
|
||||
let bid = pmacs_protocol::BufferId::from_raw(9);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -25,3 +25,4 @@
|
|||
pub mod daemon;
|
||||
pub mod iso;
|
||||
pub mod pty;
|
||||
pub mod sigint_conformance;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue