Merge remote-tracking branch 'githubsucks/main' into process-signal-diagnostic-completeness

This commit is contained in:
Levi Neuwirth 2026-07-30 12:21:12 -04:00
commit 77887d0683
19 changed files with 4066 additions and 242 deletions

View File

@ -107,7 +107,7 @@ remain open to them.
| 11 | Config layering + provenance | **Partial (foundation only)** | Typed registry is right; 5 settings live in it; no value provenance |
| 12 | Profiles | **Missing** | One hardcoded default keymap; not a named concept |
| 13 | Package lifecycle UX | **Resolution without lifecycle** | Mature resolver/lockfile; init-only install; no uninstall/disable/search |
| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive but only 3 call sites, all LSP panels; buffer-list and search re-implement it; bottom panel landed (#155) |
| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive but only 3 call sites, all LSP panels; buffer-list and search re-implement it; bottom panel complete on BOTH frontends (#155 + Stage 2) |
| 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all |
| 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced |
| 17 | Distribution | **Missing** | CI is test-only; no binaries, channels, checksums, or update path |
@ -1280,10 +1280,17 @@ Primitive-by-primitive against the list above:
vocabulary is fixed Rust-side).
- **Contextual popup** ✓ — completion popup, context menu (each a
shadow, §6).
- **Bottom/side panel** ✓ — landed as bottom-panel Stage 1 (#155):
- **Bottom/side panel** ✓ — Stage 1 (#155) gave the substrate:
`WindowParams` side/fixed_rows/dedicated, `display = "current" |
"panel"` adopted by listview/compile/terminal, quit-action, divider
drag. Stage 2 (GPU band) pending its own framing.
drag. **Stage 2 completed it on the second frontend** (2A #177, 2B-1
#184, 2B-2 #187, 2B-3): the GPU frontend now renders a real panel
band with its own divider, pointer routing, and resize drag, and a
semantic session that negotiates the panel wire is panel-capable.
Both frontends therefore share one placement primitive rather than
the GPU silently taking the Stage 1 non-side fallback. Stage 3 — the
adopter default flip, so omitting `display` resolves to the panel
policy — is the remaining step.
- **Task/progress view** △ — `*workers*` exists but joins nothing
(§9).
- **Help view** △ — exists twice (§5); needs unification, not
@ -1366,10 +1373,15 @@ its asks are already practiced.**
- Versioned protocol schema `SUPPORTED=[6..=21]` with deliberate
encoding-breaking bumps, both-frontends support required per bump,
and byte-pin discipline for appended variants (handoff §4). The v21
bottom-panel family landed with Stage 2B-1 (#184) and is reserved but
dark: because `Hello` is server-first, the production daemon still
advertises v20 so shipped v20 clients remain attachable; compatible
v21 activation belongs to Stage 2B-3.
bottom-panel family landed with Stage 2B-1 (#184) and is **live in
production since Stage 2B-3** — activated without an incompatible
handshake change. Because `Hello` is server-first, the daemon's
advertised version is now a permanent compatibility **baseline**
(still 20) and the session's real version is settled one message
later by the frontend's `AttachRequest` counter-offer. That split —
advertise the floor, negotiate up — is the reusable pattern for every
future additive family, and it means bumping the advertised version is
reserved for a change that cannot be expressed additively at all.
- Two genuine frontends share the conceptual model; CRDT concurrent
editing with presence across them; remote attach + reconnect.
- **Graceful per-frontend degradation is practiced, not aspirational**:
@ -1569,10 +1581,12 @@ and §18's floor ride on this.
### Priority 5: Finish the workbench convergence
**State: partial and moving (§14) — bottom panel Stage 1 landed, GPU
band pending; listview proven.** Remaining: the tree primitive (build
it before dired and the worker tree invent two), table/inspector/diff,
help unification. Wiring plus one modest model piece (the tree model).
**State: partial and moving (§14) — the bottom panel is now complete on
both frontends (Stage 1 #155 through Stage 2B-3), and listview is
proven.** Only the adopter default flip (Stage 3) remains on the panel
itself. Remaining elsewhere: the tree primitive (build it before dired
and the worker tree invent two), table/inspector/diff, help unification.
Wiring plus one modest model piece (the tree model).
### Priority 6: Productize configuration

View File

@ -1,6 +1,6 @@
# Active work — cross-machine resume ledger
**Snapshot: 2026-07-28.** This file records volatile work that has not
**Snapshot: 2026-07-29.** This file records volatile work that has not
landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed
entries when their PR merges; do not let this become a second permanent
backlog.
@ -378,24 +378,133 @@ The contract is now
cargo test --test terminal_copy_mode_acceptance --features crdt
```
## Bottom-panel lane (Arc 7) — 2B-2 MERGED; 2B-3 IS NEXT
## Bottom-panel lane (Arc 7) — 2B-3 OPEN; Stage 2 is COMPLETE with it
Stage 1, the Stage 2 framing, Stage 2A, Stage 2B-1, and **Stage 2B-2 are
all on `main`**. Framing revision 5's three-way split of 2B was
explicitly approved on 2026-07-27; revision 6 records PR #184's review
correction. **2B-2 — the daemon panel projection and epoch machine —
landed as [PR #187](https://github.com/levineuwirth/pmacs/pull/187)**,
one review round of five findings on top of the implementation, 12/12
green, 22/22 mutations biting. Its durable lessons are in
`docs/agent-handoff.md` §1; what remains below is the 2B-3 plan.
Stage 1, the Stage 2 framing, Stage 2A, Stage 2B-1, and Stage 2B-2 are
all on `main`. **Stage 2B-3 — the GPU panel band, compatible protocol-v21
activation, and the negotiated `panel_capable` flip — is this lane's open
work**, and it completes Stage 2. Framing revision 5's three-way split of
2B was explicitly approved on 2026-07-27; revision 6 records PR #184's
review correction.
**2B-2's boundaries, restated because they are easy to overrun:** the
production `Hello` stays at v20 and `panel_capable` stays `false`. The
slice is dark/test-only capability exactly as 2B-1 was. Compatible v21
activation, the GPU band, and the negotiated capability flip are all
2B-3's, and 2B-3 may **not** simply change the unsolicited `Hello` to
21.
- **Branch `bottom-panel-stage2b3`, worktree `../pmacs-bp-2b3`, cut fresh
from landed `main` @ `e003b81`** (`Merge pull request #190 from
levineuwirth/resource-op-delete-guard-impl`) — measured with
`git log --oneline -1 githubsucks/main`, not carried over from a
briefing. Not stacked on `../pmacs-bp-stage2b2`: each slice starts from
landed main, which is the house pattern and what 2B-2 did.
- **The activation mechanism, and why the alternatives fail.** The
constraint inherited from 2B-1's review is that the unsolicited `Hello`
may not become 21: it is server-first, so a shipped v20 frontend rejects
an unknown version *before* it can send `AttachRequest`. What 2B-3 ships
instead is a **frontend counter-offer**:
`ADVERTISED_PROTOCOL_VERSION` becomes a permanent compatibility
*baseline* (20), the frontend answers
`requested_protocol_version(baseline)` — its own `PROTOCOL_VERSION` when
the baseline is current, a verbatim echo of anything older — and the
daemon records `negotiated_session_version(offer)`. The daemon needed
**no change** to accept it; it already recorded `req.protocol_version`.
Rejected alternatives, each for a reason the server-first shape forces:
growing `Hello`/`InstanceCapabilities` (postcard structs are positional,
so it breaks the *other* direction symmetrically and cannot be verified
against shipped binaries); a client-first hint (an old client writes
nothing until it has read `Hello`, so any probe is a timeout);
sniffing `instance_identity.pmacs_version` (version-string branching on
a field documented as display-only); and a second post-`Hello` daemon
message (an old client reads it as an `InstanceMessage` and dies on the
unknown discriminant).
- **The window this leaves open is named, not hidden.** A daemon whose own
`PROTOCOL_VERSION` equals the baseline rejects an offer above its
supported range. A single `AttachRequest` cannot mean both "I want 21"
and "≤ 20", so compatibility is preserved for old *frontends* — the
direction that matters, since the daemon is what a user leaves running —
and the window closes on the next daemon restart. It surfaces as an
explicit `GoodbyeReason::VersionMismatch` naming both versions, pinned by
`an_unsupported_offer_is_refused_by_name`.
- **Review round 1 closed six findings at `PR #198`, four of them one
shape.** The panel layer was a *partial port* of the document/terminal
layer and the new tests asserted the declaration side only, so each
omission was invisible. Audited as a port rather than patched as a list,
which found two more gaps of the same shape that review had not named:
the headless probe never armed the panel wire at all (so nothing could
ever exercise a band), and a disconnect left the band on screen.
- **Geometry agreed in one place instead of three.** The declaration used
the stable probe while painting and hit-testing used the
document-dependent `mono_advance`, and the declaration inset its width
by `TEXT_LEFT` against the parent framing's explicit x=0 full-width
contract. The advance is now cached **behind the declaration**
(`PanelBand::declared_advance`), so the three cannot drift: there is one
value. Framing rev 7 R7-1/R7-2 records the widened contracts.
- **One classifier owns "does the band claim this pixel".** Four pointer
handlers each decided for themselves and three simply did not ask, so
right-click and wheel were applied to the document underneath and a held
left button was reported as a hover. `PointerSurface` /
`classify_pointer_surface` makes forgetting the band impossible to do
quietly, and makes the routing testable without a window or a daemon.
- **`focused` decides the caret.** The producer ships `cursor` for a
passive panel too, so painting it unconditionally put a second insertion
caret on screen.
- **Planned underlines were never consumed.** Straight forms now ride the
quad batch, curly the squiggle pipeline — the terminal path's split.
- **`VersionMismatch { server }` is the instance's `PROTOCOL_VERSION`.**
Reporting the advertised baseline told a frontend the daemon topped out
at 20 while it spoke 21. **The acceptance had pinned the wrong value**,
so the test was holding the bug in place; fixed and re-pinned.
- **Criterion 54 is satisfied, not caveated.** The panel-hosted probe is
`a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal`
— one real daemon, one real PTY child, real wgpu, through
`display = "panel"`. Opening a terminal and *moving* it was the first
attempt and is subtly wrong: the buffer ends up displayed twice and the
document window keeps projecting it, so the acceptance cannot tell a
panel-hosted child from a document one.
- **Round 1 verification: 41 bites, 41 OK.** The original 23 re-run after the
round-1 changes (none went vacuous) plus 18 new ones covering every changed
pin. The forbidden act is bited twice: mutating the constant trips a
const-block assertion at *build* time (stronger outcome, weaker evidence),
so a narrowed swap that compiles both ways mutates the daemon's `Hello`
value directly. **A probe bite must rebuild `pmacs-gpu`**`cargo test`
does not rebuild the sibling binary the acceptance runs as a process, and
without that the mutation never reaches it and the bite reads as vacuous
for the wrong reason.
- **What 12/12 green did not catch.** The reviewer confirmed all twelve
checks, `bottom_panel_stage2b_gpu_acceptance` 4/4, and required GPU 216/216
on the head that carried all six defects. The gate suite is not the check
on a slice like this; the assertions are.
- **Six commits:** the activation mechanism, the three-boundary split plus
the band's machinery, the paint/input wiring, the acceptance suites, the
bite fixes, and the round-1 port completion — plus docs.
- **Verification on the reviewed head** (all redirected to files and
checked by their own exit codes, never through a pipe): recorded in the
PR body and reproduced by the commands in `CLAUDE.md`.
- **23 falsifying mutations, all executed, all biting**, using an in-place
mutation runner rather than `scripts/bite` because several claims live in
the *same file* as their test and a whole-file swap would not compile.
**Three assertions were VACUOUS on the first pass and the mutation runs
are what found them** — a fixed-point contrast check, a pixel test that
passed with the band painting nothing, and a monospace fixture that could
not tell two glyph-advance derivations apart. A fourth finding was about
the code, not the tests: "no panel frame reaches a v20 session" is
defence in depth, not the placement gate, and passes with the capability
gate removed entirely.
- **Two real defects the new tests caught in the implementation**, both
recorded in `docs/agent-handoff.md` §1: `edge_scroll_direction` has no
upper bound, so reclassifying its boundary was necessary but not
sufficient; and `apply_panel_payload` ignored the exhaustion latch.
- **Cross-machine recovery (fresh clone):**
```sh
git fetch githubsucks --prune
git switch --track -c bottom-panel-stage2b3 githubsucks/bottom-panel-stage2b3
git rev-parse HEAD
```
- **Stage 3 (the adopter default flip) is the arc's last step** and is not
started. This lane is not removed at 2B-3's merge: Stage 3 remains ahead
of it.
- **Stage 2B-2 MERGED as #187.** One review round of five findings on top
of the implementation, 12/12 green, 22/22 mutations biting. Its durable
lessons are in `docs/agent-handoff.md` §1.
- **What PR #187 shipped, dark by construction:** the semantic daemon's
`FrontendCellGeometry` epoch machine; one reconciled panel grid
derivation; `PanelFrame::{Present, Absent}` projection on both document
@ -441,7 +550,7 @@ activation, the GPU band, and the negotiated capability flip are all
```
#187 has landed, so `githubsucks/main` already contains this work and
the branch is retained only for provenance. Start 2B-3 from `main`.
the branch is retained only for provenance. 2B-3 was cut from `main`.
- **Stage 2B-1 MERGED as #184** (`main` @ `6bee09d`, 2026-07-28; all
twelve checks green on the reviewed head `5539b6e`; two review rounds
plus a gate-found follow-up). Branch
@ -455,11 +564,11 @@ activation, the GPU band, and the negotiated capability flip are all
`panel_capable` is still `false` for every semantic session, so the
journey grade is unchanged and every shipped v20 client remains
attachable.
- **2B-3 inherits a hard constraint from 2B-1's review**: it owns a
*compatibility-preserving* v21 activation mechanism and may **not**
simply change the unsolicited `Hello` to 21. The handshake is
server-first, so that one-line change locks out every shipped v20
frontend before it can even send an `AttachRequest`.
- **2B-3 discharged the hard constraint 2B-1's review set**: the
*compatibility-preserving* activation is the frontend counter-offer
described at the top of this lane, and the unsolicited `Hello` is
unchanged. 2B-1's real-daemon acceptance for the v20 rejection point
still passes untouched.
- **Two review rounds, and what each cost.** Round 1: `PanelFrame`
needed an explicit `buffer_id`, the transport ratchet had to drive the
real attach path rather than a detached codec assertion, and shared

View File

@ -1,6 +1,9 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-28, as bottom-panel Stage 2B-2 (#187) — the
**Last updated: 2026-07-29, as bottom-panel Stage 2B-3 — the GPU panel
band, compatible protocol-v21 activation, and the negotiated
`panel_capable` flip, completing Arc 7 Stage 2 — opens atop `e003b81`.
Beneath it, bottom-panel Stage 2B-2 (#187) — the
daemon panel projection and the epoch machine — lands on `6c9e765`,
which is the dired Stage 2 framing (#171) atop the resource-op delete
guard framing (#186). Those two are framing-only: both are approved
@ -77,14 +80,13 @@ commands, read `docs/active-work.md` immediately after this file.
#165, the GPU terminal input fix #166, Lean 4 Stage 2 #161, the dired
framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160,
minimap blank-slab #159, bottom-panel Stage 1 #155).
**Protocol schema support is now `v6..=v21`, while the production
server-first `Hello` still advertises v20.** Those are two different
facts and #184 landed only the first: the v21 bottom-panel wire family
exists, is gated in both directions, and has no producer, no consumer,
and no capability behind it. Compatible v21 activation and the
production advertisement move belong to Stage 2B-3. The bullets below
describe the arcs in their own terms; this line is the head-of-`main`
anchor.
**Protocol schema support is `v6..=v21`, the production server-first
`Hello` advertises v20, and a current session nevertheless negotiates
v21.** All three are true at once, and Stage 2B-3 is what made them
compatible: the advertised version is a permanent **baseline** and the
session's real version is settled one message later by the frontend's
`AttachRequest` counter-offer. The bullets below describe the arcs in
their own terms; this line is the head-of-`main` anchor.
- **`COHERENCE.md` is now required reading and a required framing input
#163.** It carries the product-coherence thesis, an audited
scorecard, per-concern gaps, and §20's priority order, and it is the
@ -457,14 +459,17 @@ commands, read `docs/active-work.md` immediately after this file.
unchanged, which is the additivity gate for the `read_dir` change; M4
121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep
3,205 across 93 suites. 15 claims bite-verified.
- Canonical `main` is protocol **v20** (`SUPPORTED=[6..=20]`; v16 =
- Canonical `main` is protocol **v21** (`SUPPORTED=[6..=21]`; v16 =
`ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 =
terminal frames/events, v20 = the GPU initial-target semantic
bootstrap family). Bottom-panel Stage 2B-1's in-review schema is v21
(`SUPPORTED=[6..=21]`), but its production daemon deliberately
advertises v20: the handshake is server-first, so advertising 21
would make shipped v20 GPU/TUI clients reject before
`AttachRequest`. Stage 2B-3 owns compatible production activation.
bootstrap family, v21 = the bottom-panel band family).
**`ADVERTISED_PROTOCOL_VERSION` stays 20, permanently, and that is the
activation mechanism rather than a hedge** — see the Stage 2B-3 bullet.
The rule for every future additive family: advertise the baseline,
negotiate up from the frontend's `AttachRequest`. Moving the advertised
version is reserved for a change that cannot be expressed additively at
all, because a server-first `Hello` reaches a shipped frontend before it
can identify itself.
- **Bottom panel Stage 1 (window placement + TUI side windows) LANDED —
#155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review
rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now
@ -529,23 +534,21 @@ commands, read `docs/active-work.md` immediately after this file.
`bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121;
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
Stage 2 48. All 12 CI checks green at merge.
- **Stage 2 (the GPU panel band) is FRAMED; its first two slices have
LANDED and its third is open as PR #187** —
- **Stage 2 (the GPU panel band) is COMPLETE: 2A #177, 2B-1 #184,
2B-2 #187, and 2B-3 (this lane)** —
`docs/bottom-panel-stage2-framing.md` rev 6, four
framing review rounds, no open framing items; the rev-5
implementation split was explicitly approved 2026-07-27 and rev 6
records PR #184's server-first compatibility and gate correction.
It reserves protocol **v21** and ships as four serial
It reserved protocol **v21** and shipped as four serial
implementation slices: **2A** classified census routing +
per-window painter extraction (no wire change, #177), **2B-1** the
wire (#184), **2B-2** the daemon projection and epoch machine
(implemented but not landed in PR #187), then **2B-3** the GPU band,
compatible v21 activation, and the
negotiated `panel_capable` flip. Production attachment remains v20
through 2B-2. Parent acceptance 3755 remains authoritative.
Stage 3 is the adopter default flip. **Do not start 2B-3 until PR
#187 lands.** Its branch, checkpoints, two review rounds, verification,
and exact recovery commands live in `docs/active-work.md`.
(#187), then **2B-3** the GPU band, compatible v21 activation, and
the negotiated `panel_capable` flip. Production attachment stayed
v20 through 2B-2 and negotiates v21 from 2B-3 on. Parent acceptance
3755 remains authoritative. **Stage 3, the adopter default flip, is
the arc's remaining step.**
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
Projection class (#1#12, #21#22) routes through
`primary_document_window`; focus/input (#13#15, #23), focus chrome
@ -567,9 +570,10 @@ commands, read `docs/active-work.md` immediately after this file.
version is therefore an incompatible act on its own, independent of
whether any new message is ever sent. A real-daemon acceptance
emulates that exact rejection point and then requires the
attachment to reach its initial grid. **2B-3 must ship a
compatibility-preserving activation mechanism; it may not simply
change the unsolicited `Hello` to 21.**
attachment to reach its initial grid. **Stage 2B-3 discharged this
without touching the unsolicited `Hello`** — the advertisement is a
permanent baseline and the frontend counter-offers; see its bullet
below. That acceptance still passes unchanged, which is the point.
- **One shared grid validator, split along a stated boundary**
(`pmacs-protocol/src/wire_grid.rs`). Shared: checked area, the
visible-cell bound (262,144), cell count, cursor bounds, glyph
@ -650,7 +654,91 @@ commands, read `docs/active-work.md` immediately after this file.
stays a per-frame `Absent`, flagged rather than hidden, because
making it durable needs a new "presentation permanently
unavailable" reason in `FrontendView` for a state requiring 2^64
presentations in one session.
presentations in one session. **Stage 2B-3 did not make it cheap**
the frontend half latches instead, which is a different remedy, so
the daemon-side hole stays open as recorded.
- **Bottom panel Stage 2B-3 (the GPU band, compatible v21 activation,
and the negotiated capability flip) — the arc's Stage 2 is COMPLETE.**
This is the slice a user can see: a semantic GPU session now renders a
real panel band instead of taking the Stage 1 non-side fallback, which
is what closes the journey-steps-710 divergence §6 of the Stage 2
framing names. Durable facts:
- **A server-first handshake is negotiated from the CLIENT side, not by
moving the advertisement.** `ADVERTISED_PROTOCOL_VERSION` is now a
permanent compatibility *baseline*; the frontend answers
`requested_protocol_version(baseline)` — its own `PROTOCOL_VERSION`
when the baseline is the current one, a verbatim echo of anything
older — and the daemon records
`negotiated_session_version(offer)`. A shipped baseline frontend
echoes and gets a baseline session, byte-for-byte as before; a
current frontend offers up and gets the current wire. The `Hello`
encoding and value never change, which is why the old frontend never
sees a version it must reject. **The daemon needed no change to
accept the offer** — it already recorded `req.protocol_version` as
the negotiated version, so the whole mechanism is one value the
frontend chooses plus a documented clamp.
- **The window this leaves open, named rather than hidden:** a daemon
whose own `PROTOCOL_VERSION` equals the baseline rejects an offer
above its supported range. Compatibility can be preserved for old
*frontends* or old *daemons* — a single `AttachRequest` cannot mean
both "I want 21" and "≤ 20" — and the frontend direction is the one
that matters, because the daemon is what a user leaves running. It
closes on the next daemon restart and surfaces as an explicit
`GoodbyeReason::VersionMismatch` naming both versions.
- **`server_protocol_version` split into two facts on the GPU client**:
`session_protocol_version` (what the session speaks — every wire gate
keys on this) and `baseline_protocol_version` (what `Hello` said).
They now DIFFER in the normal case, and that difference *is* the
compatibility property, so both headless probe reports emit both and
the two ratchets that read them assert both directions. Asserting
only the session version would pass if the baseline had been bumped
too, which is the exact incompatible change the mechanism avoids.
- **The GPU document bottom is three boundaries and each call site was
classified individually** — 20 production sites (8 status-owned, 12
document-owned), 1 definition, 8 test sites, 29 matches, arithmetic
stated. `geometry_capacity_bottom` reserves the divider *while the
panel is absent* (that asymmetry is what breaks the first-open cycle)
while `document_text_bottom` costs the document nothing until a
`Present` frame paints.
- **Moving a boundary is not always sufficient.** `edge_scroll_direction`
has no upper bound, so a pixel *inside* the band still read as
"further down the document" and armed the document's auto-scroll —
the exact named symptom, surviving a correct reclassification. Any
consumer that treats "past the bottom" as unbounded needs the band as
an explicit exclusion, not just a moved boundary.
- **Three of the first-pass assertions were VACUOUS, and the mutation
runs are what found them.** (a) The contrast assertion compared
`status_band_top` before and after — a *fixed point* — so the blanket
rewrite it exists to prevent moved both readings together and passed;
it is now anchored to an independent formula. (b) The criterion-46
pixel test passed with the band painting *nothing*, because
installing a panel reshapes the document and that produced the whole
diff; it now counts differing pixels in the divider and cell rows.
(c) The probe-versus-document-advance fixture compared two ASCII
documents, which in a monospace family have identical advances.
- **"No panel frame reaches a v20 session" is defence in depth, not the
placement gate.** The producer's peer flag and the write-loop filter
both suppress `PanelFrame` below the panel version independently of
`panel_capable`, so that assertion passes with the capability gate
removed entirely. The load-bearing claim is *placement*: the adopter's
buffer must land in the pre-panel session's own document window,
because a side window it cannot render is simply invisible.
- `PanelBand::presented()` is the ONE frontend-side derivation of "is a
band on screen" — retained valid frame, matching `geometry_epoch`,
latch clear — behind the band inset, the painter, the hit-tester, and
the drag. A latched frontend also stops absorbing payloads, so
`presented()` is not the only thing between a disowned declaration and
a painted band.
- Panel columns come from the **stable normal-face probe**, never
`mono_advance`'s document-glyph fallback, and `panel_cell_capacity`
carries the daemon's **virtual status row** (`frontend_area_rows` is
`total.rows - 1`) with **no per-axis cap**, because a panel may
legitimately be wider than a PTY.
- `PANEL_MIN_VERSION` moved into `pmacs-protocol` so the GPU frontend
aliases one definition instead of restating 21.
- **Stage 3 (the adopter default flip) is the arc's last step**, and it
is the only thing between today's state and omitting `display`
resolving to the panel policy.
- **dired Stage 2 framing LANDED (document only) — #171**
(`docs/dired-stage2-framing.md`, revision 9; seven review rounds).
**Approved as a framing; no runtime code and no implementation

View File

@ -1,7 +1,7 @@
# Bottom panel Stage 2 — the GPU panel band (framing)
**Revision 6 — PR #184 review correction; the underlying Stage 2
framing remains APPROVED 2026-07-27. 2A (#177) and 2B-1 (#184) are both
**Revision 7 — Stage 2B-3 review round 1 corrections; the underlying
Stage 2 framing remains APPROVED 2026-07-27. 2A (#177) and 2B-1 (#184) are both
merged, and 2B-2 is the next slice. Ground truth: canonical `main` @
`6bee09d`, where protocol schema support is `v6..=v21` while the
production server-first handshake still deliberately advertises v20.**
@ -35,6 +35,52 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and
## 0. Revision history
### 0.-1 Rev 6 → rev 7 — Stage 2B-3 review round 1, three stated
contracts corrected
- **R7-1 (P1) — the stable-probe decision covers all THREE cell
computations, not only the declaration.** §5.3 decided that the panel
geometry *declaration* resolves its advance from
`probe_mono_advance` rather than `State::mono_advance`. That wording
let an implementation declare with the probe while *painting* and
*hit-testing* with the document-dependent advance — which is what
happened, and which means the daemon's column count, the painted
column positions, and the cell a click resolves to are three
different grids. The decision now reads: **the panel's advance is
resolved once, cached behind the declaration, and used by the
declaration, the painter, and the hit test.** They agree by
construction rather than by three call sites choosing correctly.
A test that asserts only the declaration cannot see this, so §7.2.3
A2B-3 gains the painting and hit-testing halves explicitly.
- **R7-2 (P1) — the x = 0 full-width contract governs the band RECT,
not only `total.cols`.** Parent Q#BP15a already states that
`total.cols` describes "the full-width panel grid beginning at x=0;
document `TEXT_LEFT`/gutter padding is unrelated". Rev 6 left that
as a statement about the *declaration*, and an implementation
inset both the declared width and the painted rect by `TEXT_LEFT`.
The contract is restated here as covering the band's content
rectangle: **origin x = 0, full surface width**, with the fractional
right-edge remainder painted as band background that maps to no cell
and emits no `PanelPointer`.
- **R7-3 (P2) — `GoodbyeReason::VersionMismatch { server }` is the
instance's `PROTOCOL_VERSION`, never the advertised baseline.** Once
§3.5's activation split the two, a daemon reporting its baseline told
a frontend it topped out at 20 while it in fact spoke 21 — the
inverse of the upgrade advice the reason exists to give. The wire
field's own documentation already said "the instance's
`PROTOCOL_VERSION`"; this records that the split makes it load-bearing
rather than incidental. A *frontend* raising the same reason locally
can only report the baseline it was handed.
- **R7-4 — criterion 54's gate is named.** The panel-hosted
`--headless-probe` run lives in
`bottom_panel_stage2b_gpu_acceptance` as
`a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal`,
and it drives the real `display = "panel"` adopter opt-in rather than
opening a terminal and moving it. Opening first and moving after
leaves the buffer displayed twice, the document window still
projecting it as a full-window terminal, and the acceptance unable to
tell a panel-hosted child from a document one.
### 0.0 Rev 5 → rev 6 — PR #184 review round 2, four findings closed
- **R6-1 (P1) — v21 is reserved, not advertised, in 2B-1.** The
@ -843,7 +889,21 @@ attach.
driving selection, terminal mouse reporting, and click-to-focus
without disturbing the document mirror), **54** (the
`--headless-probe` run: one real daemon, real PTY, real wgpu, through
a panel-hosted terminal), **55**.
a panel-hosted terminal — see rev 7 R7-4 for the exact fixture),
**55**.
**48 is the whole gesture set, and a partial port of it is invisible**
(rev 7): `Move` alone satisfies nothing. A left press must ARM the
gesture so motion becomes `Drag(Left)`; a release outside the band
must still end the gesture the press began, at the last reported cell,
or the daemon holds a button down forever; right-click and wheel must
consult the band before the document, or they are applied to the
surface underneath. Every handler resolves the band through **one**
classifier, because four handlers each deciding for themselves is how
three of them came to skip the question. A **passive** panel's caret is
not painted: the producer ships `cursor` for it too, so `focused` is
what decides. Planned underlines must actually render — straight forms
on the quad batch, curly on the squiggle pipeline, as in the terminal
path.
- **41, the GPU half:** the pixel→cell conversion pinned at fractional
widths and heights, and geometry refresh on window resize, font
change, and scale change.
@ -858,11 +918,18 @@ attach.
neither paints nor hit-tests until a matching `Present` arrives. This
is the case daemon value dedup cannot see and is why option 1 was
chosen.
- **A2B-3.** Panel columns are derived from the **stable normal-face
- **A2B-3.** Panel cells are derived from the **stable normal-face
probe**, not `State::mono_advance`'s document-glyph fallback: two GPU
frontends with identical metrics and different documents derive
identical `total.cols`, and a probe returning `None` declares zero
usable geometry rather than falling back to a document sample.
**All three consumers are asserted, not just the declaration** (rev 7
R7-1): the declared total, the pixel a run is painted at, and the cell
a click hit-tests to must all follow the same advance, and the fixture
must make the two candidate advances differ so each assertion
discriminates. The band's rect is asserted at **x = 0 across the full
surface width**, with the right-edge remainder resolving to no cell
(R7-2).
- **A2B-4 (contrast assertion).** Installing a panel moves **all twelve
document-owned consumers** of §5.3 by exactly
`installed_panel_height + divider_height`, **while all eight

View File

@ -34,7 +34,7 @@ use pmacs_protocol::{
FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key,
KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS,
SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message,
write_message,
requested_protocol_version, write_message,
};
use winit::event_loop::EventLoopProxy;
@ -355,6 +355,30 @@ fn coalesce_kind(event: &FrontendEvent) -> Option<u8> {
kind: MouseKind::Drag(_),
..
} => Some(3),
// Bottom panel Stage 2B-3 (framing §5.2) — four more tail-only
// tags, each with its own reason.
//
// Geometry is latest-wins because epochs need only INCREASE, not
// be consecutive: the daemon accepts a jump from 3 to 7 and the
// dropped declarations described geometry that no longer exists.
FrontendEvent::FrontendCellGeometry { .. } => Some(4),
// A resize drag coalesces over the complete event including its
// epochs, so a collapsed run cannot mix a new row count with an
// old presentation identity.
FrontendEvent::PanelResizeRows { .. } => Some(5),
// Panel move and drag mirror their terminal twins. Down / Up /
// wheel / context stay LOSSLESS and ordered: repeated left Downs
// are what the daemon's click state reads as a multi-click, and
// Down(Right) is the context-menu gesture, so collapsing either
// silently changes the gesture's meaning.
FrontendEvent::PanelPointer {
kind: MouseKind::Move,
..
} => Some(6),
FrontendEvent::PanelPointer {
kind: MouseKind::Drag(_),
..
} => Some(7),
_ => None,
}
}
@ -557,8 +581,16 @@ fn connect_stream_with_sink(
// AttachRequest — declare the capabilities a semantic frontend
// needs. `multi_frontend` is included because the existing daemon
// gates `crdt_replica` behind it (M10.x dependency).
// Bottom-panel Stage 2B-3: the `Hello` version is a compatibility
// BASELINE, and this counter-offer is what activates anything above
// it. The handshake is server-first, so the daemon cannot advertise a
// version a shipped frontend might reject; the frontend is the only
// party that can safely name a higher one, because by this point it
// has already accepted the baseline. `requested_protocol_version`
// echoes anything older than the current baseline verbatim.
let session_protocol_version = requested_protocol_version(hello.protocol_version);
let req = AttachRequest {
protocol_version: hello.protocol_version,
protocol_version: session_protocol_version,
frontend_capabilities: FrontendCapabilities {
synchronized_output: false,
unicode_smp: true,
@ -678,7 +710,8 @@ fn connect_stream_with_sink(
outbox,
shutdown_handle,
frontend_id: hello.assigned_frontend_id,
server_protocol_version: hello.protocol_version,
session_protocol_version,
baseline_protocol_version: hello.protocol_version,
initial_message,
})
}
@ -859,10 +892,25 @@ pub struct AttachClient {
/// `FrontendEvent` carries this so the daemon can route input back
/// to the per-session `SemanticRenderState`.
frontend_id: FrontendId,
/// The daemon's `Hello.protocol_version`. Wire variants newer
/// than the daemon (e.g. `Pointer`, v5) must be gated on this —
/// an older daemon hard-errors decoding an unknown variant.
server_protocol_version: u32,
/// The version this session actually speaks: the frontend's
/// `AttachRequest` counter-offer, which the daemon adopts. Wire
/// variants newer than the session (e.g. `Pointer`, v5) must be gated
/// on this — a daemon below the variant's floor hard-errors decoding
/// an unknown discriminant.
///
/// Bottom-panel Stage 2B-3: this is deliberately NOT
/// `Hello.protocol_version` any more. The server-first `Hello` carries
/// a compatibility *baseline* that no shipped frontend may be forced
/// to reject, so it under-reports what the pair can speak; the offer
/// this frontend made is the session's real ceiling.
session_protocol_version: u32,
/// The baseline the daemon advertised in its server-first `Hello`.
///
/// Kept beside the negotiated version because they answer different
/// questions, and because "the daemon still advertises 20 while this
/// session runs 21" is precisely the compatibility property Stage
/// 2B-3 has to be able to demonstrate.
baseline_protocol_version: u32,
/// Target snapshot retained across the pre-window readiness barrier.
initial_message: Option<InstanceMessage>,
}
@ -914,7 +962,7 @@ impl AttachClient {
/// Send a `FrontendEvent::Pointer` (session M-2): a locally
/// hit-tested gesture in source bytes. Callers gate on
/// [`Self::server_protocol_version`] `>= 5`.
/// [`Self::session_protocol_version`] `>= 5`.
pub fn send_pointer(
&self,
buffer_id: BufferId,
@ -944,7 +992,7 @@ impl AttachClient {
/// Send a `FrontendEvent::TerminalResize` (Vterm Stage 3): the
/// terminal-cell geometry this frontend has on screen. Callers gate
/// on [`Self::server_protocol_version`] `>= 19`.
/// on [`Self::session_protocol_version`] `>= 19`.
///
/// Cells, never pixels — the frontend divides its own drawable
/// rectangle by its own metrics, keeping the no-pixels contract the
@ -963,7 +1011,7 @@ impl AttachClient {
/// Send a `FrontendEvent::TerminalPointer` (Vterm Stage 3): a
/// gesture hit-tested locally to a terminal cell. Callers gate on
/// [`Self::server_protocol_version`] `>= 19`.
/// [`Self::session_protocol_version`] `>= 19`.
pub fn send_terminal_pointer(
&self,
buffer_id: BufferId,
@ -980,6 +1028,74 @@ impl AttachClient {
})
}
/// Send a `FrontendEvent::FrontendCellGeometry` (Q#BP15a): this
/// frontend's authoritative whole-cell layout capacity. Callers gate on
/// [`Self::session_protocol_version`] `>= 21`.
///
/// Valid **without** a side window on purpose — the daemon needs
/// columns before it can paint a first panel frame, so gating this on
/// panel presence would deadlock the first open.
pub fn send_frontend_cell_geometry(
&self,
geometry_epoch: u64,
total: CellSize,
) -> Result<(), TransportError> {
self.send_event(FrontendEvent::FrontendCellGeometry {
frontend_id: self.frontend_id,
geometry_epoch,
total,
})
}
/// Send a `FrontendEvent::PanelResizeRows` (Q#BP15a): the fixed panel
/// rows a divider drag is requesting. Callers gate on
/// [`Self::session_protocol_version`] `>= 21`.
///
/// Both epochs ride along as identities, not geometry: the daemon
/// accepts the request only for the panel it most recently declared,
/// under the geometry it most recently accepted.
pub fn send_panel_resize_rows(
&self,
geometry_epoch: u64,
panel_epoch: u64,
rows: u32,
) -> Result<(), TransportError> {
self.send_event(FrontendEvent::PanelResizeRows {
frontend_id: self.frontend_id,
geometry_epoch,
panel_epoch,
rows,
})
}
/// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture
/// hit-tested locally to a panel CELL. Callers gate on
/// [`Self::session_protocol_version`] `>= 21`.
///
/// `buffer_id` and `panel_epoch` close different holes and neither
/// subsumes the other — the first catches an A→B buffer replacement,
/// the second a close/hide/reopen of the *same* buffer — so both are
/// carried rather than one being derived from the other.
pub fn send_panel_pointer(
&self,
geometry_epoch: u64,
panel_epoch: u64,
buffer_id: BufferId,
coord: CellCoord,
kind: MouseKind,
mods: Modifiers,
) -> Result<(), TransportError> {
self.send_event(FrontendEvent::PanelPointer {
frontend_id: self.frontend_id,
geometry_epoch,
panel_epoch,
buffer_id,
coord,
kind,
mods,
})
}
/// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu
/// navigation hit-tested locally against the popup we drew. `index`
/// is the row the pointer is over (`None` = off the menu); `invoke`
@ -996,9 +1112,22 @@ impl AttachClient {
})
}
/// The daemon's negotiated wire version from `Hello`.
pub fn server_protocol_version(&self) -> u32 {
self.server_protocol_version
/// The version this session negotiated — the frontend's
/// `AttachRequest` offer, which the daemon adopts.
///
/// Every "is this variant on the wire?" gate keys on this, never on
/// [`Self::baseline_protocol_version`].
pub fn session_protocol_version(&self) -> u32 {
self.session_protocol_version
}
/// The compatibility baseline the daemon advertised in `Hello`.
///
/// Only the handshake itself needs this. It is *lower* than
/// [`Self::session_protocol_version`] whenever an additive family has
/// been activated by counter-offer, which is the normal case.
pub fn baseline_protocol_version(&self) -> u32 {
self.baseline_protocol_version
}
/// Send a locally-authored CRDT operation to the daemon. The GPU
@ -1385,7 +1514,8 @@ mod tests {
outbox: Arc::new((Mutex::new(outbox), Condvar::new())),
shutdown_handle: b,
frontend_id: FrontendId::LOCAL,
server_protocol_version: PROTOCOL_VERSION,
session_protocol_version: PROTOCOL_VERSION,
baseline_protocol_version: pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
initial_message: None,
};
// A send against the closed outbox fails *and* shuts the socket
@ -1506,8 +1636,12 @@ mod tests {
let socket = temp.path().join("managed.sock");
let (client_stream, mut server_stream) = UnixStream::pair().expect("socket pair");
let server = thread::spawn(move || {
// Advertises the BASELINE, like a real daemon: the session
// version is settled by the client's counter-offer below, and a
// fixture that advertised the current wire could not tell the
// two apart.
let hello = Hello {
protocol_version: PROTOCOL_VERSION,
protocol_version: pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
assigned_frontend_id: FrontendId::LOCAL,
instance_identity: InstanceIdentity {
pmacs_version: "managed-retry-test".to_owned(),
@ -1546,7 +1680,16 @@ mod tests {
.expect("transient sequence must attach");
assert_eq!(attempts, 4);
assert!(managed.daemon.spawned_daemon());
assert_eq!(managed.client.server_protocol_version(), PROTOCOL_VERSION);
assert_eq!(
managed.client.session_protocol_version(),
PROTOCOL_VERSION,
"a managed attach negotiates this binary's wire, not the Hello baseline"
);
assert_eq!(
managed.client.baseline_protocol_version(),
pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
"while the daemon's server-first Hello still advertises the baseline"
);
server.join().expect("handshake server");
}

File diff suppressed because it is too large Load Diff

View File

@ -212,21 +212,10 @@ impl TerminalPaintPlan {
/// wide-continuation topology, and the selection spans are sound.
#[must_use]
pub fn build(frame: &TerminalFrame, palette: TerminalPalette) -> Self {
let cols = frame.size.cols as usize;
let mut plan = Self {
size: frame.size,
..Self::default()
};
if cols == 0 {
return plan;
}
for row in 0..frame.size.rows {
let row_start = row as usize * cols;
let row_cells = &frame.cells[row_start..row_start + cols];
plan.plan_row(row, row_cells, palette);
}
let mut plan = Self::build_grid(frame.size, &frame.cells, frame.cursor, palette);
// Selection is the one piece a panel grid does not have: it is a
// terminal-only span list on the wire, so it stays out of the
// shared derivation rather than being faked as empty inside it.
plan.selection = frame
.selection
.iter()
@ -236,13 +225,46 @@ impl TerminalPaintPlan {
end_col: span.end_col,
})
.collect();
plan
}
plan.cursor = frame.cursor.map(|cursor| CellRun {
/// The shared half: resolve any validated wire cell grid into
/// cell-space paint data.
///
/// Extracted for the bottom panel band, which paints the same cell
/// vocabulary from [`pmacs_protocol::panel::PanelFrame`]. Both callers
/// go through one row planner, so a panel and a terminal cannot come
/// to disagree about how a wide-continuation pair or an attribute
/// break is drawn.
///
/// The grid must already have passed its own message's `validate`;
/// this assumes the cell count, the wide-continuation topology, and
/// the cursor bounds are sound.
#[must_use]
pub fn build_grid(
size: CellSize,
cells: &[Cell],
cursor: Option<pmacs_protocol::CellCoord>,
palette: TerminalPalette,
) -> Self {
let cols = size.cols as usize;
let mut plan = Self {
size,
..Self::default()
};
if cols == 0 {
return plan;
}
for row in 0..size.rows {
let row_start = row as usize * cols;
let row_cells = &cells[row_start..row_start + cols];
plan.plan_row(row, row_cells, palette);
}
plan.cursor = cursor.map(|cursor| CellRun {
row: cursor.row,
start_col: cursor.col,
end_col: cursor.col + 1,
});
plan
}
@ -402,6 +424,61 @@ impl TerminalPaintPlan {
}
}
/// The whole-cell capacity of a frontend frame, for the bottom panel's
/// geometry declaration (Q#BP15a).
///
/// Two deliberate differences from [`cell_viewport`], the terminal
/// projection:
///
/// * **No per-axis cap.** A panel does not inherit the 512-column PTY
/// limit — a 4K surface at a small font is legitimately wider than that,
/// and the wire's *area* bound is what keeps the encoding inside the
/// transport budget. Clamping here would silently narrow a legal panel.
/// * **The virtual status row.** The daemon's layout area is
/// `total.rows - 1`, because its grid model reserves one row for the mode
/// line that this frontend paints as its own status band. Declaring only
/// the document rows would lose one row of layout on every frame.
///
/// `None` on any degenerate input — zero, negative, or non-finite metrics,
/// or a surface too small for one whole cell — so the caller fails closed
/// to zero usable geometry instead of computing an absurd row count.
#[must_use]
pub fn panel_cell_capacity(
width_px: f32,
height_px: f32,
advance_px: f32,
line_px: f32,
) -> Option<CellSize> {
if !(advance_px.is_finite() && line_px.is_finite()) || advance_px <= 0.0 || line_px <= 0.0 {
return None;
}
if !(width_px.is_finite() && height_px.is_finite()) || width_px <= 0.0 || height_px <= 0.0 {
return None;
}
let cols = (width_px / advance_px).floor();
let rows = (height_px / line_px).floor();
if cols < 1.0 || rows < 1.0 {
return None;
}
// `as u32` saturates at u32::MAX for an absurd finite ratio, which is
// bounded rather than wrapping; the area check below then rejects it.
let cols = cols as u32;
let rows = rows as u32;
// Shed rows rather than emit an area the wire must reject. The bound
// is on the PANEL grid the daemon will derive, and the daemon's own
// clamp repeats it — two independent clamps because neither side may
// depend on the other having applied it.
let max_rows =
u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1))
.unwrap_or(u32::MAX);
let rows = rows.min(max_rows);
if rows == 0 {
return None;
}
// The virtual status row rides on top of the document rows.
Some(CellSize::new(rows.saturating_add(1), cols))
}
/// The terminal cell viewport a drawable rectangle admits.
///
/// Rows and columns are `floor(extent / metric)`, clamped through the

View File

@ -68,9 +68,11 @@ pub use message::{
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace,
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
negotiate_capabilities,
negotiate_capabilities, negotiated_session_version, requested_protocol_version,
};
pub use panel::{
MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload,
};
pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload};
pub use terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,

View File

@ -604,7 +604,14 @@ pub enum GoodbyeReason {
/// Frontend's `protocol_version` does not match the instance's.
/// The handshake fails before any further messages.
VersionMismatch {
/// The instance's `PROTOCOL_VERSION`.
/// The instance's [`PROTOCOL_VERSION`] — the highest wire it can
/// speak, **not** the [`ADVERTISED_PROTOCOL_VERSION`] baseline it put
/// in [`Hello`]. Since those diverged (the baseline is a permanent
/// compatibility floor), reporting the baseline here would understate
/// the daemon's ceiling and invert the upgrade advice.
///
/// A *frontend* raising this locally can only report the baseline it
/// was handed, because that is all the daemon told it.
server: u32,
/// The version the frontend announced in its `AttachRequest`.
client: u32,
@ -1667,14 +1674,87 @@ pub const PROTOCOL_VERSION: u32 = 21;
/// Protocol version placed in the daemon's server-first [`Hello`].
///
/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but
/// production attachment remains on v20 until the Stage 2B-3 capability
/// activation can preserve compatibility with existing v20 frontends.
/// Those frontends reject an unknown server-first version before they can
/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would
/// make the otherwise-dark protocol slice user-visible.
/// **This is a compatibility *baseline*, not a ceiling, and Stage 2B-3
/// makes that permanent.** The handshake is server-first: the daemon
/// writes [`Hello`] before the frontend has said anything at all, and a
/// frontend rejects an unrecognized `protocol_version` *before* it can
/// send [`AttachRequest`]. Advertising [`PROTOCOL_VERSION`] here would
/// therefore lock out every already-shipped frontend whose supported
/// range ends lower — an incompatible act on its own, independent of
/// whether a single new message is ever exchanged.
///
/// So the baseline stays at the highest version every shipped frontend
/// is known to accept, and the session's actual version is settled by
/// the frontend's [`AttachRequest`] instead:
///
/// 1. the daemon advertises this baseline;
/// 2. the frontend answers with [`requested_protocol_version`] — its own
/// [`PROTOCOL_VERSION`] when the baseline is this constant, and a
/// verbatim echo of anything older;
/// 3. the daemon negotiates [`negotiated_session_version`] of that offer.
///
/// A shipped baseline-version frontend echoes the baseline and gets a
/// baseline session, exactly as before. A current frontend offers up and
/// gets the current wire. Nothing about the `Hello` encoding or its
/// value changes, which is why the old frontend never sees a version it
/// must reject.
///
/// Moving this constant is therefore a **deliberately incompatible**
/// act, reserved for a wire change that cannot be expressed additively.
/// An additive family — like the bottom panel's v21 shapes — never needs
/// it.
pub const ADVERTISED_PROTOCOL_VERSION: u32 = 20;
/// The version a frontend puts in its [`AttachRequest`], given the
/// server-first [`Hello`] baseline it just read.
///
/// The counter-offer is confined to the *current* baseline on purpose. A
/// daemon advertising anything other than [`ADVERTISED_PROTOCOL_VERSION`]
/// is genuinely older than this ladder rung, so its baseline is echoed
/// verbatim and that attachment takes byte-for-byte the pre-Stage-2B-3
/// path. Only the current baseline — the one every daemon built from
/// this ladder sends — is answered with this binary's own
/// [`PROTOCOL_VERSION`].
///
/// The offer is never *lower* than the baseline: a frontend that
/// supported less than the daemon advertised would already have rejected
/// the `Hello` via [`is_supported_protocol_version`].
///
/// # The one-way window this leaves open
///
/// A daemon whose own `PROTOCOL_VERSION` *equals* the baseline also
/// advertises the baseline, and rejects an offer above its supported
/// range with [`GoodbyeReason::VersionMismatch`]. That is the price of a
/// server-first handshake with no client-first hint: compatibility can
/// be preserved for old *frontends* (the direction that matters, since
/// the daemon is what a user leaves running) or for old *daemons*, but a
/// single `AttachRequest` cannot mean both "I want 21" and "≤ 20" at
/// once. The window closes as soon as the running daemon is restarted on
/// a binary from this ladder rung or later, and it is one-connection
/// visible — [`GoodbyeReason::VersionMismatch`] names both versions.
#[must_use]
pub fn requested_protocol_version(server_baseline: u32) -> u32 {
if server_baseline == ADVERTISED_PROTOCOL_VERSION {
PROTOCOL_VERSION
} else {
server_baseline
}
}
/// The version a daemon records for a session, given the frontend's
/// [`AttachRequest`] offer.
///
/// The offer has already passed [`is_supported_protocol_version`], so
/// this clamp cannot bind today; it is here because "the session speaks
/// the lower of the two ceilings" is the *rule*, and leaving it implicit
/// in a membership test is how a future ladder widening (accepting a
/// version this binary cannot itself produce) would silently ship an
/// over-negotiated session.
#[must_use]
pub fn negotiated_session_version(frontend_offer: u32) -> u32 {
frontend_offer.min(PROTOCOL_VERSION)
}
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat

View File

@ -18,6 +18,25 @@ use crate::wire_grid::{
MAX_WIRE_GRID_GLYPH_BYTES, WireGridError, WireGridLimits, validate_wire_grid,
};
/// Lowest negotiated protocol version that carries the bottom-panel wire
/// family (Q#BP9): [`crate::InstanceMessage::PanelFrame`] daemon→frontend,
/// and `FrontendEvent::{FrontendCellGeometry, PanelResizeRows,
/// PanelPointer}` frontend→daemon.
///
/// One constant rather than a literal at each gate, because the panel bump
/// gates in **both** directions: the daemon's send filter, the producer's
/// peer flag, the three inbound event gates, and — since Stage 2B-3 — the
/// GPU frontend's own declaration and paint gates must move together, or
/// one side starts trusting a wire the other never negotiated. It lives in
/// the protocol crate precisely so the frontend aliases this definition
/// rather than restating the number.
///
/// Distinct from [`crate::ADVERTISED_PROTOCOL_VERSION`], which stays at the
/// compatibility baseline permanently: a session reaches this version by
/// the frontend's `AttachRequest` counter-offer, never by the daemon
/// advertising it.
pub const PANEL_MIN_VERSION: u32 = 21;
/// Shared visible-cell ceiling for a panel grid.
///
/// Identical to the terminal bound: it is the transport-safety limit,

View File

@ -450,8 +450,15 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> {
// its AttachRequest (the v0.1 daemon's strict-equality check will
// accept). The frontend's runtime behavior on the wire is the
// intersection of features both sides support.
//
// Bottom-panel Stage 2B-3: that echo is now a *floor*, not the whole
// rule. `Hello` is server-first, so the daemon must advertise a
// baseline every shipped frontend accepts; the session's real version
// is settled here, by this frontend counter-offering its own
// `PROTOCOL_VERSION` when the baseline is the current one. Anything
// older is still echoed verbatim.
let req = AttachRequest {
protocol_version: hello.protocol_version,
protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version),
frontend_capabilities: build_capabilities(),
initial_size,
};
@ -1914,9 +1921,12 @@ fn run_one_session(
// T M10.5: match the server's protocol version so v1.0 frontends
// attaching to v0.1 daemons advertise protocol_version=1. Same
// pattern as the local-socket path above.
// pattern as the local-socket path above, including Stage 2B-3's
// counter-offer: this path *spawns* the daemon from the running
// executable, so the peer is always this same ladder rung and the
// counter-offer is always accepted.
let req = AttachRequest {
protocol_version: hello.protocol_version,
protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version),
frontend_capabilities: build_capabilities(),
initial_size,
};

View File

@ -735,11 +735,26 @@ fn per_attach_thread(
daemon_debug(format!("received AttachRequest from {frontend_id:?}"));
// T M10.5 version check.
//
// Bottom-panel Stage 2B-3: this is where a session's version is
// actually settled. `Hello` above carried only the compatibility
// BASELINE (`ADVERTISED_PROTOCOL_VERSION`) — it has to, because a
// server-first handshake reaches a shipped frontend before that
// frontend can say anything, and a version it does not recognize is
// rejected outright. The frontend's counter-offer is therefore the
// upper half of the negotiation, and this membership test is what
// bounds it.
if !crate::protocol::is_supported_protocol_version(req.protocol_version) {
let _ = write_message(
&mut stream,
&InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch {
server: ADVERTISED_PROTOCOL_VERSION,
// The wire field is "the instance's `PROTOCOL_VERSION`", not
// the version it advertised. Since Stage 2B-3 those differ:
// the `Hello` baseline is a compatibility floor, and reporting
// it here would tell a frontend the daemon tops out at 20 when
// it in fact speaks 21 — the exact opposite of the upgrade
// diagnostic this reason exists to give.
server: pmacs_protocol::PROTOCOL_VERSION,
client: req.protocol_version,
}),
);
@ -812,8 +827,16 @@ fn per_attach_thread(
u8::try_from(frontend_id.0 % (crate::overlay_color::PALETTE_LEN as u64)).unwrap_or(0)
};
let session_state =
crate::presence::SessionState::new(req.protocol_version, negotiated_caps, color_slot);
// The session speaks the lower of the two ceilings. The membership
// test above already bounds the offer, so this clamp cannot bind
// today; it is applied through the shared rule anyway so a future
// ladder widening cannot silently record a version this binary is
// unable to produce.
let session_state = crate::presence::SessionState::new(
crate::protocol::negotiated_session_version(req.protocol_version),
negotiated_caps,
color_slot,
);
// Hand the write-half to the dispatcher; keep a read-half for
// this thread's reader loop. **Reader loop starts immediately
@ -899,23 +922,30 @@ fn peer_declared_terminal_support(
///
/// Grid sessions paint the whole cell grid the daemon composes, so a side
/// window is just another leaf for them. A semantic session needs the GPU
/// band, which does not exist yet — so this still answers `false` for
/// every semantic peer, whatever it declares. No client-asserted
/// band, which Stage 2B-3 lands — so it is panel-capable exactly when it
/// negotiated a wire that can carry the band. No client-asserted
/// standalone boolean is trusted: the answer is derived from the daemon's
/// own negotiated state.
///
/// **Stage 2B-2 deliberately does not turn the version arm on.** The
/// daemon-side projection and epoch machine below are complete and
/// exercised through a test-only panel-capable view, but the production
/// flip (`semantic_render && negotiated_protocol_version >=
/// PANEL_MIN_VERSION`) belongs to Stage 2B-3, together with the
/// compatibility-preserving activation the server-first `Hello` requires:
/// **Stage 2B-3 turns the version arm on** (framing §3.5): `panel_capable`
/// is true for an authenticated semantic session that negotiated
/// [`PANEL_MIN_VERSION`] or later, and false for every earlier one. The
/// gate is on *placement*, not only on transport — denying the events
/// while still putting a pre-panel peer's window in a side panel it cannot
/// render would leave that window invisible, so a v6v20 semantic session
/// keeps the Stage 1 fallback with every side-specific parameter
/// discarded (Q#BP2c).
///
/// The version reaching this predicate is the *negotiated* one, which is
/// the frontend's `AttachRequest` counter-offer rather than the
/// [`ADVERTISED_PROTOCOL_VERSION`](pmacs_protocol::ADVERTISED_PROTOCOL_VERSION)
/// is still 20, so no session can negotiate 21 yet, and denying only the
/// events while still *placing* such a peer in a side window would leave
/// its window invisible.
/// baseline the daemon put in `Hello`. That distinction is the whole
/// activation mechanism: the baseline stays where every shipped frontend
/// can accept it, and only a frontend that named the newer wire itself
/// becomes panel-capable.
fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool {
!session_state.negotiated_capabilities.semantic_render
|| session_state.negotiated_protocol_version >= PANEL_MIN_VERSION
}
/// The same belt-and-braces write-loop gate for the additive
@ -1959,10 +1989,11 @@ fn handle_session_established(
// collapses folds, a semantic one keeps raw-line reckoning until
// Stage 3.
// Bottom-panel arc (Q#BP13): panel capability comes from the SAME
// negotiated bit in this same transaction. Stage 1 ships the TUI
// side windows only, so a semantic session is not panel-capable and
// a `side` request falls back to its document target with every
// side-specific parameter discarded.
// negotiated state in this same transaction. Stage 2B-3 made the
// semantic arm live: a semantic session that negotiated
// `PANEL_MIN_VERSION` or later can render the GPU band and is
// panel-capable, while a v6-v20 semantic session still falls back to
// its document target with every side-specific parameter discarded.
let fresh_view = build_fresh_frontend_view(
editor,
!session_state.negotiated_capabilities.semantic_render,
@ -5570,7 +5601,7 @@ mod tests {
fn viewport_aligns_the_document_without_taking_focus_from_the_panel() {
let (mut editor, fid, document, panel) = panel_focused_semantic_fixture();
let other = {
let mut core = editor.core.borrow_mut();
let core = editor.core.borrow_mut();
core.registry.borrow_mut().create("*other*")
};

View File

@ -60,21 +60,6 @@ use std::path::PathBuf;
// directly.
pub use pmacs_protocol::*;
/// Lowest negotiated protocol version that carries the bottom-panel wire
/// family (Q#BP9): [`InstanceMessage::PanelFrame`] daemon→frontend, and
/// `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`
/// frontend→daemon.
///
/// One constant rather than a literal at each gate, because the panel
/// bump gates in **both** directions: the send filter, the producer's
/// peer flag, and the three inbound event gates must move together or one
/// side starts trusting a wire the other never negotiated.
///
/// Distinct from [`ADVERTISED_PROTOCOL_VERSION`], which the production
/// daemon still holds at 20: the v21 schema is reserved, and the
/// compatibility-preserving activation is bottom-panel Stage 2B-3's.
pub const PANEL_MIN_VERSION: u32 = 21;
// ---------------------------------------------------------------------------
// Attachment
// ---------------------------------------------------------------------------

View File

@ -4,10 +4,16 @@
//! The daemon panel projection and the epoch machine.
//!
//! Everything here runs through a **test-only** panel-capable semantic
//! view: production negotiation still sets `panel_capable = false` for
//! every semantic session, and the compatibility-preserving v21
//! activation is Stage 2B-3's. Nothing in this slice is user-reachable.
//! Everything here runs through an explicitly panel-capable semantic
//! view constructed by the fixture. When this suite was written that was
//! a *test-only* configuration — production negotiation set
//! `panel_capable = false` for every semantic session. **Stage 2B-3 made
//! it production-reachable**: a semantic session that negotiates
//! `PANEL_MIN_VERSION` is panel-capable, activated by the frontend's
//! `AttachRequest` counter-offer rather than by moving the advertised
//! `Hello` version. These assertions did not change, and that is the
//! point — the projection they pin is the one production now drives.
//! `bottom_panel_stage2b_gpu_acceptance` covers the negotiation itself.
//!
//! Two disciplines the framing is explicit about:
//!

View File

@ -0,0 +1,632 @@
// bottom_panel_stage2b_gpu_acceptance.rs --- bottom-panel Stage 2B-3
// (docs/bottom-panel-stage2-framing.md §7.2.3; A2B-5 and the production
// re-assertion of 42/43/44/45/51/52 through the real capability flip).
//! Compatible v21 activation and the negotiated `panel_capable` flip.
//!
//! The band's own pixel geometry, the epoch latch, the probe-derived
//! column count, and the three-boundary contrast assertion live in
//! `pmacs-gpu`'s own tests, because they need a real `State` and a real
//! surface. What lives here is everything that needs a **real daemon**:
//! the handshake, the negotiation, and what the daemon does with a
//! session's negotiated version.
//!
//! The discipline this suite is built around: **every acceptance runs
//! both directions in the same fixture.** A test that only proved "a v21
//! frontend gets a panel" would pass with the compatibility half broken,
//! and a test that only proved "a v20 frontend still attaches" would pass
//! with the activation missing entirely. Neither half is meaningful
//! alone, so neither appears alone.
mod common;
// Most of this suite needs a real daemon and therefore the `crdt` feature:
// a semantic session is necessarily a text replica, so a non-CRDT build
// cannot host one at all. The negotiation-rule tests are the exception and
// run in both configurations, which is why the imports split here.
#[cfg(feature = "crdt")]
use std::io::Read;
#[cfg(feature = "crdt")]
use std::os::unix::net::UnixStream;
#[cfg(feature = "crdt")]
use std::time::{Duration, Instant};
#[cfg(feature = "crdt")]
use pmacs_protocol::cell::CellSize;
#[cfg(feature = "crdt")]
use pmacs_protocol::message::{
AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key,
KeyEvent, Modifiers, SessionBootstrapRequest,
};
use pmacs_protocol::panel::PANEL_MIN_VERSION;
#[cfg(feature = "crdt")]
use pmacs_protocol::panel::PanelFramePayload;
#[cfg(feature = "crdt")]
use pmacs_protocol::transport::{read_message, write_message};
use pmacs_protocol::{
ADVERTISED_PROTOCOL_VERSION, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS,
is_supported_protocol_version, negotiated_session_version, requested_protocol_version,
};
#[cfg(feature = "crdt")]
use common::daemon::{TestDaemon, build_default_caps};
/// Opens a bottom panel in whichever frontend pressed the key.
///
/// A real adopter path rather than a Lua eval hook: `pmacs.window.display`
/// with an explicit `side` is exactly what a Stage 1 adopter does, and the
/// acting frontend is the one that sent the key — so whether this produces
/// a side window is precisely the `panel_capable` question.
#[cfg(feature = "crdt")]
const PANEL_ON_KEY: &str = r#"
pmacs.command.define {
name = "bp-probe.panel",
description = "Open the Stage 2B-3 acceptance panel.",
fn = function()
pmacs.window.display(pmacs.buffer.create("*bp-probe*"), { side = "bottom", height = 4 })
end,
}
pmacs.keymap.bind { scope = "global", sequence = "C-M-p", command = "bp-probe.panel" }
"#;
#[cfg(feature = "crdt")]
fn semantic_caps() -> FrontendCapabilities {
FrontendCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
..build_default_caps()
}
}
/// One attached session, with the version it actually offered.
#[cfg(feature = "crdt")]
struct Session {
stream: UnixStream,
offered: u32,
}
/// Attach a semantic frontend that offers exactly `offer`.
///
/// The `Hello` baseline is asserted here rather than in one dedicated
/// test, because every fixture in this file depends on it: if the daemon
/// ever advertised something above the baseline, a shipped frontend would
/// reject before reaching any of these code paths, and the tests below
/// would still pass while the product was broken.
#[cfg(feature = "crdt")]
fn attach_semantic(daemon: &TestDaemon, offer: u32) -> Session {
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set read timeout");
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
assert_eq!(
hello.protocol_version, ADVERTISED_PROTOCOL_VERSION,
"the server-first Hello must stay at the compatibility baseline"
);
write_message(
&mut stream,
&AttachRequest {
protocol_version: offer,
frontend_capabilities: semantic_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("write AttachRequest");
// A v20-or-later semantic session sends the bootstrap envelope; the
// daemon reads it unconditionally for those, so skipping it would
// desynchronize the stream rather than merely omit a target.
if offer >= 20 {
write_message(
&mut stream,
&SessionBootstrapRequest {
initial_target: None,
},
)
.expect("write bootstrap");
}
Session {
stream,
offered: offer,
}
}
/// Read messages until `want` returns `Some`, or the deadline passes.
#[cfg(feature = "crdt")]
fn drain_until<T>(
stream: &mut UnixStream,
label: &str,
mut want: impl FnMut(&InstanceMessage) -> Option<T>,
) -> Option<T> {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
match read_message::<InstanceMessage>(stream) {
Ok(message) => {
if let Some(found) = want(&message) {
return Some(found);
}
}
Err(error) => {
eprintln!("{label}: read stopped: {error}");
return None;
}
}
}
None
}
/// Press `C-M-p`, then report whether a `Present` panel frame arrived.
#[cfg(feature = "crdt")]
fn press_and_await_panel(session: &mut Session) -> bool {
// The declaration first: the daemon needs columns before it can paint
// a first panel frame, and it is valid without a side window for
// exactly that reason.
if session.offered >= PANEL_MIN_VERSION {
write_message(
&mut session.stream,
&FrontendEvent::FrontendCellGeometry {
frontend_id: pmacs_protocol::FrontendId(0),
geometry_epoch: 1,
total: CellSize::new(40, 120),
},
)
.expect("write geometry declaration");
}
write_message(
&mut session.stream,
&FrontendEvent::Key(KeyEvent {
frontend_id: pmacs_protocol::FrontendId(0),
key: Key::Char('p'),
mods: Modifiers::CTRL | Modifiers::ALT,
timestamp_ns: 0,
}),
)
.expect("write panel-open key");
drain_until(&mut session.stream, "panel", |message| match message {
InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(frame.size),
_ => None,
})
.is_some()
}
// ---------------------------------------------------------------------------
// A2B-5 — the activation mechanism, both directions in one fixture
// ---------------------------------------------------------------------------
/// The whole mechanism, on one live daemon: the v21 frontend gets a band,
/// the v20 frontend still attaches and reaches its initial grid, and the
/// daemon's advertised version never moves.
///
/// One daemon rather than two, deliberately. Two daemons could each pass
/// their own half while the *same* build was incapable of serving both,
/// which is the only property that matters here.
#[cfg(feature = "crdt")]
#[test]
fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
let daemon = TestDaemon::spawn_with_config(PANEL_ON_KEY);
// Half 1 — the shipped v20 client. This runs FIRST on purpose: it is
// the half a broken activation destroys, and running it first means a
// regression fails here rather than after the interesting half passed.
let mut legacy = daemon.connect();
legacy
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set legacy timeout");
let hello: Hello = read_message(&mut legacy).expect("read Hello");
let shipped_v20_range = 6..=20;
assert!(
shipped_v20_range.contains(&hello.protocol_version),
"a shipped v20 client rejects the server-first Hello before it can \
send AttachRequest, so this is the rejection point: {}",
hello.protocol_version
);
write_message(
&mut legacy,
&AttachRequest {
protocol_version: hello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("write v20 AttachRequest");
assert!(
drain_until(&mut legacy, "legacy", |message| matches!(
message,
InstanceMessage::CellDelta {
full_grid: true,
..
}
)
.then_some(()))
.is_some(),
"the v20 session must reach its initial grid, not merely receive an \
acceptable Hello"
);
// Half 2 — the current frontend counter-offers and gets the band.
let mut current = attach_semantic(&daemon, requested_protocol_version(hello.protocol_version));
assert_eq!(
current.offered, PROTOCOL_VERSION,
"the counter-offer is this binary's own wire"
);
assert!(
press_and_await_panel(&mut current),
"a v21-negotiated semantic session must be panel-capable and receive \
a Present panel frame"
);
// Half 3 — a semantic session that echoed the baseline is NOT
// panel-capable, and the gate is on PLACEMENT rather than only on
// transport: it keeps a working document window instead of an
// invisible side one.
let mut pre_panel = attach_semantic(&daemon, ADVERTISED_PROTOCOL_VERSION);
let document = drain_until(
&mut pre_panel.stream,
"pre-panel snapshot",
|message| match message {
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
_ => None,
},
)
.expect("a semantic attach receives a buffer snapshot");
// A real semantic frontend declares a viewport; the daemon produces no
// styling until it does, so without this the "document still works"
// half below would be unobservable rather than false.
write_message(
&mut pre_panel.stream,
&FrontendEvent::Viewport {
frontend_id: pmacs_protocol::FrontendId(0),
buffer_id: document,
visible: pmacs_protocol::ByteRange { start: 0, end: 0 },
generation: 0,
},
)
.expect("declare a viewport");
// The same panel-open key the v21 session used. One drain, classifying
// both outcomes: a `PanelFrame` fails immediately, and the document's
// own semantic traffic is what proves the fallback window is live.
write_message(
&mut pre_panel.stream,
&FrontendEvent::Key(KeyEvent {
frontend_id: pmacs_protocol::FrontendId(0),
key: Key::Char('p'),
mods: Modifiers::CTRL | Modifiers::ALT,
timestamp_ns: 0,
}),
)
.expect("write panel-open key");
// Two claims in one drain, and the SECOND is the load-bearing one.
//
// "No panel frame arrives" is defence in depth, not the placement gate:
// the producer's peer flag and the write-loop filter both suppress
// `PanelFrame` for a peer below the panel version independently of
// `panel_capable`, so that claim passes even with the capability gate
// removed entirely. The placement claim is what only `panel_capable`
// can decide — the buffer the adopter asked for must land in this
// session's DOCUMENT window, not in a side window it cannot render,
// because a side window here would simply be invisible.
let mut placed_in_document = None;
let _ = drain_until(&mut pre_panel.stream, "fallback", |message| {
assert!(
!matches!(message, InstanceMessage::PanelFrame(_)),
"a v20 semantic session must never be sent a panel frame: {message:?}"
);
if let InstanceMessage::CursorByte { buffer_id, .. } = message
&& *buffer_id != document
{
placed_in_document = Some(*buffer_id);
return Some(());
}
None
});
assert!(
placed_in_document.is_some(),
"the adopter's buffer must be placed in this session's own document \
window (Q#BP2c fallback, every side parameter discarded) a \
panel-capable session would have put it in a side window and left \
this session's document buffer unchanged"
);
}
// ---------------------------------------------------------------------------
// The negotiation rules themselves
// ---------------------------------------------------------------------------
/// The advertised baseline is a compatibility floor that does NOT move,
/// and the counter-offer is what reaches the current wire.
#[test]
fn the_baseline_stays_and_the_counter_offer_activates() {
assert_eq!(PROTOCOL_VERSION, 21);
assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20,
"moving this is the incompatible act the mechanism exists to avoid"
);
const { assert!(PROTOCOL_VERSION > ADVERTISED_PROTOCOL_VERSION) };
assert_eq!(PANEL_MIN_VERSION, PROTOCOL_VERSION);
// The current baseline is answered with this binary's own version.
assert_eq!(
requested_protocol_version(ADVERTISED_PROTOCOL_VERSION),
PROTOCOL_VERSION
);
// Anything older is echoed VERBATIM, so a genuinely older daemon takes
// byte-for-byte the pre-activation path.
for older in 6..ADVERTISED_PROTOCOL_VERSION {
assert_eq!(
requested_protocol_version(older),
older,
"a daemon advertising v{older} must be echoed, not counter-offered"
);
}
// The offer is never below the baseline: a frontend that supported less
// would already have rejected the Hello.
for baseline in SUPPORTED_PROTOCOL_VERSIONS {
assert!(requested_protocol_version(*baseline) >= *baseline);
}
}
/// The session speaks the lower of the two ceilings.
#[test]
fn the_daemon_negotiates_the_lower_of_the_two_ceilings() {
for offer in SUPPORTED_PROTOCOL_VERSIONS {
assert_eq!(
negotiated_session_version(*offer),
*offer,
"every supported offer is adopted as-is"
);
}
// An offer above this binary's own wire is clamped rather than
// recorded. It cannot arrive today — the membership test rejects it
// first — which is exactly why the rule is written down instead of
// left implicit in that test.
assert_eq!(
negotiated_session_version(PROTOCOL_VERSION + 1),
PROTOCOL_VERSION
);
assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1));
}
/// An offer outside the supported set is still refused with an explicit
/// `VersionMismatch` naming both versions, so the one-way window the
/// counter-offer leaves open is visible rather than a silent hang.
#[cfg(feature = "crdt")]
#[test]
fn an_unsupported_offer_is_refused_by_name() {
let daemon = TestDaemon::spawn();
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set timeout");
let hello: Hello = read_message(&mut stream).expect("read Hello");
write_message(
&mut stream,
&AttachRequest {
protocol_version: PROTOCOL_VERSION + 7,
frontend_capabilities: semantic_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("write over-offer");
let message: InstanceMessage = read_message(&mut stream).expect("read refusal");
match message {
InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client }) => {
// The daemon reports the wire it can SPEAK, not the baseline it
// advertised. Those differ now, and pinning the baseline here
// would hold in place a diagnostic telling the operator to
// downgrade to a version the daemon has already moved past.
assert_eq!(
server, PROTOCOL_VERSION,
"the instance must report its own PROTOCOL_VERSION"
);
assert_ne!(
server, ADVERTISED_PROTOCOL_VERSION,
"and that is deliberately not the advertised baseline"
);
assert_eq!(client, PROTOCOL_VERSION + 7);
}
other => panic!("expected a named VersionMismatch, got {other:?}"),
}
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
// And the connection closes rather than lingering half-open.
let mut sink = [0u8; 1];
assert!(
matches!(stream.read(&mut sink), Ok(0) | Err(_)),
"a refused attach must close"
);
}
// ---------------------------------------------------------------------------
// Parent acceptance 54 — one real daemon + real PTY + real wgpu, through a
// PANEL-HOSTED terminal
// ---------------------------------------------------------------------------
/// The daemon config for the panel-hosted probe: a command that opens a
/// controlled terminal child **and then displays it in a bottom panel**,
/// bound to a key the probe presses.
///
/// The distinction from the Vterm Stage 3 fixture is the whole point. There,
/// `terminal.open` leaves the child in the frontend's own full-window buffer,
/// so the GPU enters terminal mode and the band is never involved. Here the
/// adopter passes `display = "panel"` — the real Stage 1 opt-in that Stage 3
/// will make the default — so the child is projected as a `PanelFrame` while
/// the frontend's document window stays a document.
///
/// Opening the terminal and *then* moving it with `window.display` was the
/// first attempt and it is subtly wrong: the buffer ends up displayed twice,
/// the document window keeps projecting it as a full-window terminal, and the
/// acceptance can no longer tell a panel-hosted child from a document one.
#[cfg(feature = "crdt")]
const PANEL_TERMINAL_INIT_LUA: &str = r#"
pmacs.command.define {
name = "bp-probe.panel-terminal",
description = "Open the Stage 2B-3 acceptance terminal inside a bottom panel.",
fn = function()
return pmacs.terminal.open {
command = "/bin/sh",
args = { "-c",
"i=0; while [ $i -lt 400 ]; do printf 'PANELROW%02d\n' \"$i\"; i=$((i+1)); sleep 0.05; done" },
display = "panel",
}
end,
}
-- C-M-p is deliberately an unbound chord: `bind` is strict and refuses to
-- shadow an existing binding, so a bound one would fail init and leave the
-- probe pressing nothing.
pmacs.keymap.bind { scope = "global", sequence = "C-M-p", command = "bp-probe.panel-terminal" }
"#;
#[cfg(feature = "crdt")]
fn decode_hex(encoded: &str) -> String {
let bytes: Vec<u8> = encoded
.as_bytes()
.chunks(2)
.filter_map(|pair| {
std::str::from_utf8(pair)
.ok()
.and_then(|hex| u8::from_str_radix(hex, 16).ok())
})
.collect();
String::from_utf8_lossy(&bytes).into_owned()
}
/// Parent acceptance 54: a `--headless-probe` run drives one real daemon, one
/// real PTY child, and real wgpu **through a panel-hosted terminal**.
///
/// The probe is the real attach client — same handshake, same reader, same
/// outbox — driven as a process, because `pmacs-gpu` deliberately depends only
/// on `pmacs-protocol`. Nothing here is emulated: the daemon opens a `/bin/sh`
/// child, moves it into a side window, projects it as a `PanelFrame`, and the
/// probe composites real frames from the band.
///
/// **This test's green is worth nothing unless it actually ran**, which is the
/// standing trap for every probe acceptance in this repo: without the binary
/// built it returns early, and it is `crdt`-gated so CI never reaches it. The
/// skip is therefore an assertion failure under `PMACS_REQUIRE_GPU`, and the
/// report's own `completion_observed` is asserted so a run that merely waited
/// out its safety deadline cannot read as a pass.
#[cfg(feature = "crdt")]
#[test]
fn a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal() {
use std::path::{Path, PathBuf};
fn gpu_binary() -> PathBuf {
Path::new(env!("CARGO_BIN_EXE_pmacs"))
.parent()
.expect("test binary directory")
.join("pmacs-gpu")
}
let required = std::env::var_os("PMACS_REQUIRE_GPU").is_some();
let binary = gpu_binary();
if !binary.exists() {
assert!(
!required,
"PMACS_REQUIRE_GPU is set but {} is not built; build the workspace first",
binary.display()
);
eprintln!("skipping a54: {} is not built", binary.display());
return;
}
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
&[
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
("PMACS_INSTANCE_MULTI_FRONTEND", "1"),
],
PANEL_TERMINAL_INIT_LUA,
);
let report = daemon
.socket_path()
.parent()
.expect("socket parent")
.join("gpu-panel-probe.txt");
let output = std::process::Command::new(&binary)
.arg("--headless-probe")
.arg(daemon.socket_path())
.arg(&report)
.env("PMACS_GPU_PROBE_OPEN_KEY", "p")
// The BAND's own breadcrumb. Naming it separately from the terminal
// fixture's is what keeps one fixture's evidence from satisfying the
// other's loop exit.
.env("PMACS_GPU_PROBE_EXPECT_PANEL_TEXT", "PANELROW")
.output()
.expect("run the headless GPU panel probe");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let no_adapter = output.status.code() == Some(3);
assert!(
no_adapter && !required,
"headless GPU panel probe failed (status {:?}):\n{stderr}",
output.status.code()
);
eprintln!("skipping a54: no wgpu adapter available");
return;
}
let text = std::fs::read_to_string(&report).expect("probe report");
let facts: std::collections::HashMap<&str, &str> = text
.lines()
.filter_map(|line| line.split_once('='))
.collect();
let fact = |key: &str| facts.get(key).copied().unwrap_or_default();
let number = |key: &str| fact(key).parse::<u32>().unwrap_or_default();
// The probe reached its own stated evidence rather than its deadline.
assert_eq!(
fact("completion_observed"),
"true",
"a deadline-driven pass must not read as success: {text}"
);
// The activation, end to end on a real socket.
assert_eq!(fact("session_protocol_version"), "21", "{text}");
assert_eq!(fact("baseline_protocol_version"), "20", "{text}");
// The band is real: declared, projected, focused, and carrying the child.
assert!(
number("panel_declarations") >= 2,
"the probe declares geometry at attach and again after its resize: {text}"
);
assert!(
number("panel_frames") >= 2,
"the daemon must project the panel-hosted terminal: {text}"
);
assert!(
number("panel_rows") >= 2 && number("panel_cols") > 0,
"the projected band must have a real grid: {text}"
);
assert_eq!(
fact("panel_focused"),
"true",
"the adopter selected the panel, so the projection must say so: {text}"
);
assert_eq!(
fact("panel_text_observed"),
"true",
"the PTY child's output must arrive IN THE BAND: {text}"
);
assert!(
decode_hex(fact("panel_frame_text_hex")).contains("PANELROW")
|| fact("panel_text_observed") == "true",
"and the band's own text is reported for diagnosis: {text}"
);
assert_eq!(
fact("panel_observed_resized_frame"),
"true",
"a surface resize must round-trip: new declaration, new band width: {text}"
);
assert!(
number("rendered_nonuniform_frames") >= 2,
"real wgpu must have composited the band more than once: {text}"
);
// And the terminal did NOT take over the frontend's own window: that is
// the difference between this acceptance and the Vterm Stage 3 one, and
// without it a full-window terminal would satisfy every assertion above.
assert_eq!(
fact("entered_terminal_mode"),
"false",
"the child belongs to the panel, not to the document window: {text}"
);
}

View File

@ -109,9 +109,13 @@ fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame {
fn the_panel_stage_takes_protocol_v21() {
assert_eq!(PROTOCOL_VERSION, 21);
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21));
// The wire family is reserved before it is activated: the production
// server-first Hello must remain acceptable to already-shipped v20
// clients throughout the dark protocol and daemon slices.
// The advertised version is a compatibility BASELINE, and Stage 2B-3
// made that permanent rather than temporary: the server-first Hello
// reaches an already-shipped frontend before that frontend can send
// anything, so it must stay at a version none of them has to reject.
// v21 is activated by the frontend's AttachRequest counter-offer
// instead, which is why this stays 20 even though the panel wire is
// now live in production.
assert_eq!(ADVERTISED_PROTOCOL_VERSION, 20);
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20));
}

View File

@ -579,9 +579,19 @@ mod crdt {
);
let facts = parse_report(&report);
assert_eq!(facts.get("phase").map(String::as_str), Some("complete"));
// Stage 2B-3: the negotiated session version and the advertised
// baseline are different facts, and this managed spawn pins both.
// The daemon it spawned advertises the baseline; the client it
// handed back counter-offered this binary's own wire.
assert_eq!(
facts
.get("server_protocol_version")
.get("session_protocol_version")
.and_then(|value| value.parse::<u32>().ok()),
Some(PROTOCOL_VERSION)
);
assert_eq!(
facts
.get("baseline_protocol_version")
.and_then(|value| value.parse::<u32>().ok()),
Some(ADVERTISED_PROTOCOL_VERSION)
);

View File

@ -429,9 +429,32 @@ fn version_mismatch_clean_disconnect() {
write_message(&mut stream, &req).expect("write");
// Expect Goodbye(VersionMismatch).
//
// `server` is the wire this instance can SPEAK, which since
// bottom-panel Stage 2B-3 is no longer the baseline it advertised in
// `Hello` above: the baseline is a compatibility floor and
// `PROTOCOL_VERSION` is the ceiling a frontend may counter-offer up
// to. Reporting the floor here would tell a rejected frontend the
// daemon tops out at v20 when it in fact speaks v21 — the opposite of
// the upgrade diagnostic this reason exists to give.
//
// Until Stage 2B-3 the two constants were equal, so this assertion
// could not distinguish them and silently pinned the wrong one. Both
// directions are asserted now so neither can drift back: line 421
// holds the advertised floor, and the `assert_ne!` holds the
// divergence itself. That matters here specifically because the
// Stage 2B-3 pin for this same rule is `#[cfg(feature = "crdt")]`
// and therefore dark in CI — this test is the one CI actually runs.
match read_message::<InstanceMessage>(&mut stream) {
Ok(InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client })) => {
assert_eq!(server, ADVERTISED_PROTOCOL_VERSION);
assert_eq!(
server, PROTOCOL_VERSION,
"the instance must report the version it can speak"
);
assert_ne!(
server, ADVERTISED_PROTOCOL_VERSION,
"and deliberately not the advertised compatibility floor"
);
assert_eq!(client, 999);
}
other => panic!("expected VersionMismatch Goodbye, got {other:?}"),

View File

@ -717,10 +717,25 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
.filter_map(|line| line.split_once('='))
.collect();
// Bottom-panel Stage 2B-3 activated v21 by frontend counter-offer, so
// the two halves of the handshake now report DIFFERENT numbers and both
// are load-bearing: the daemon still advertises the v20 compatibility
// baseline in its server-first `Hello` (so a shipped v20 frontend is
// never handed a version it must reject), while this real client
// negotiated v21 and is therefore panel-capable. Asserting only the
// session version would pass if the baseline had been bumped too —
// which is exactly the incompatible change this mechanism exists to
// avoid — and asserting only the baseline would pass with the whole
// activation missing.
assert_eq!(
facts.get("server_protocol_version").copied(),
facts.get("session_protocol_version").copied(),
Some("21"),
"the real client must negotiate the v21 panel wire: {text}"
);
assert_eq!(
facts.get("baseline_protocol_version").copied(),
Some("20"),
"the dark v21 wire slice must keep the real client on v20: {text}"
"while the daemon's server-first Hello still advertises v20: {text}"
);
assert_eq!(
facts.get("entered_terminal_mode").copied(),