Merge remote-tracking branch 'githubsucks/main' into process-signal-diagnostic-completeness
This commit is contained in:
commit
77887d0683
36
COHERENCE.md
36
COHERENCE.md
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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).
|
vocabulary is fixed Rust-side).
|
||||||
- **Contextual popup** ✓ — completion popup, context menu (each a
|
- **Contextual popup** ✓ — completion popup, context menu (each a
|
||||||
shadow, §6).
|
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" |
|
`WindowParams` side/fixed_rows/dedicated, `display = "current" |
|
||||||
"panel"` adopted by listview/compile/terminal, quit-action, divider
|
"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
|
- **Task/progress view** △ — `*workers*` exists but joins nothing
|
||||||
(§9).
|
(§9).
|
||||||
- **Help view** △ — exists twice (§5); needs unification, not
|
- **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
|
- Versioned protocol schema `SUPPORTED=[6..=21]` with deliberate
|
||||||
encoding-breaking bumps, both-frontends support required per bump,
|
encoding-breaking bumps, both-frontends support required per bump,
|
||||||
and byte-pin discipline for appended variants (handoff §4). The v21
|
and byte-pin discipline for appended variants (handoff §4). The v21
|
||||||
bottom-panel family landed with Stage 2B-1 (#184) and is reserved but
|
bottom-panel family landed with Stage 2B-1 (#184) and is **live in
|
||||||
dark: because `Hello` is server-first, the production daemon still
|
production since Stage 2B-3** — activated without an incompatible
|
||||||
advertises v20 so shipped v20 clients remain attachable; compatible
|
handshake change. Because `Hello` is server-first, the daemon's
|
||||||
v21 activation belongs to Stage 2B-3.
|
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
|
- Two genuine frontends share the conceptual model; CRDT concurrent
|
||||||
editing with presence across them; remote attach + reconnect.
|
editing with presence across them; remote attach + reconnect.
|
||||||
- **Graceful per-frontend degradation is practiced, not aspirational**:
|
- **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
|
### Priority 5: Finish the workbench convergence
|
||||||
|
|
||||||
**State: partial and moving (§14) — bottom panel Stage 1 landed, GPU
|
**State: partial and moving (§14) — the bottom panel is now complete on
|
||||||
band pending; listview proven.** Remaining: the tree primitive (build
|
both frontends (Stage 1 #155 through Stage 2B-3), and listview is
|
||||||
it before dired and the worker tree invent two), table/inspector/diff,
|
proven.** Only the adopter default flip (Stage 3) remains on the panel
|
||||||
help unification. Wiring plus one modest model piece (the tree model).
|
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
|
### Priority 6: Productize configuration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Active work — cross-machine resume ledger
|
# 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
|
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
|
entries when their PR merges; do not let this become a second permanent
|
||||||
backlog.
|
backlog.
|
||||||
|
|
@ -378,24 +378,133 @@ The contract is now
|
||||||
cargo test --test terminal_copy_mode_acceptance --features crdt
|
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
|
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
|
all on `main`. **Stage 2B-3 — the GPU panel band, compatible protocol-v21
|
||||||
explicitly approved on 2026-07-27; revision 6 records PR #184's review
|
activation, and the negotiated `panel_capable` flip — is this lane's open
|
||||||
correction. **2B-2 — the daemon panel projection and epoch machine —
|
work**, and it completes Stage 2. Framing revision 5's three-way split of
|
||||||
landed as [PR #187](https://github.com/levineuwirth/pmacs/pull/187)**,
|
2B was explicitly approved on 2026-07-27; revision 6 records PR #184's
|
||||||
one review round of five findings on top of the implementation, 12/12
|
review correction.
|
||||||
green, 22/22 mutations biting. Its durable lessons are in
|
|
||||||
`docs/agent-handoff.md` §1; what remains below is the 2B-3 plan.
|
|
||||||
|
|
||||||
**2B-2's boundaries, restated because they are easy to overrun:** the
|
- **Branch `bottom-panel-stage2b3`, worktree `../pmacs-bp-2b3`, cut fresh
|
||||||
production `Hello` stays at v20 and `panel_capable` stays `false`. The
|
from landed `main` @ `e003b81`** (`Merge pull request #190 from
|
||||||
slice is dark/test-only capability exactly as 2B-1 was. Compatible v21
|
levineuwirth/resource-op-delete-guard-impl`) — measured with
|
||||||
activation, the GPU band, and the negotiated capability flip are all
|
`git log --oneline -1 githubsucks/main`, not carried over from a
|
||||||
2B-3's, and 2B-3 may **not** simply change the unsolicited `Hello` to
|
briefing. Not stacked on `../pmacs-bp-stage2b2`: each slice starts from
|
||||||
21.
|
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
|
- **What PR #187 shipped, dark by construction:** the semantic daemon's
|
||||||
`FrontendCellGeometry` epoch machine; one reconciled panel grid
|
`FrontendCellGeometry` epoch machine; one reconciled panel grid
|
||||||
derivation; `PanelFrame::{Present, Absent}` projection on both document
|
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
|
#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
|
- **Stage 2B-1 MERGED as #184** (`main` @ `6bee09d`, 2026-07-28; all
|
||||||
twelve checks green on the reviewed head `5539b6e`; two review rounds
|
twelve checks green on the reviewed head `5539b6e`; two review rounds
|
||||||
plus a gate-found follow-up). Branch
|
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
|
`panel_capable` is still `false` for every semantic session, so the
|
||||||
journey grade is unchanged and every shipped v20 client remains
|
journey grade is unchanged and every shipped v20 client remains
|
||||||
attachable.
|
attachable.
|
||||||
- **2B-3 inherits a hard constraint from 2B-1's review**: it owns a
|
- **2B-3 discharged the hard constraint 2B-1's review set**: the
|
||||||
*compatibility-preserving* v21 activation mechanism and may **not**
|
*compatibility-preserving* activation is the frontend counter-offer
|
||||||
simply change the unsolicited `Hello` to 21. The handshake is
|
described at the top of this lane, and the unsolicited `Hello` is
|
||||||
server-first, so that one-line change locks out every shipped v20
|
unchanged. 2B-1's real-daemon acceptance for the v20 rejection point
|
||||||
frontend before it can even send an `AttachRequest`.
|
still passes untouched.
|
||||||
- **Two review rounds, and what each cost.** Round 1: `PanelFrame`
|
- **Two review rounds, and what each cost.** Round 1: `PanelFrame`
|
||||||
needed an explicit `buffer_id`, the transport ratchet had to drive the
|
needed an explicit `buffer_id`, the transport ratchet had to drive the
|
||||||
real attach path rather than a detached codec assertion, and shared
|
real attach path rather than a detached codec assertion, and shared
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
# Agent handoff — cross-machine continuity
|
# 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`,
|
daemon panel projection and the epoch machine — lands on `6c9e765`,
|
||||||
which is the dired Stage 2 framing (#171) atop the resource-op delete
|
which is the dired Stage 2 framing (#171) atop the resource-op delete
|
||||||
guard framing (#186). Those two are framing-only: both are approved
|
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
|
#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,
|
framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160,
|
||||||
minimap blank-slab #159, bottom-panel Stage 1 #155).
|
minimap blank-slab #159, bottom-panel Stage 1 #155).
|
||||||
**Protocol schema support is now `v6..=v21`, while the production
|
**Protocol schema support is `v6..=v21`, the production server-first
|
||||||
server-first `Hello` still advertises v20.** Those are two different
|
`Hello` advertises v20, and a current session nevertheless negotiates
|
||||||
facts and #184 landed only the first: the v21 bottom-panel wire family
|
v21.** All three are true at once, and Stage 2B-3 is what made them
|
||||||
exists, is gated in both directions, and has no producer, no consumer,
|
compatible: the advertised version is a permanent **baseline** and the
|
||||||
and no capability behind it. Compatible v21 activation and the
|
session's real version is settled one message later by the frontend's
|
||||||
production advertisement move belong to Stage 2B-3. The bullets below
|
`AttachRequest` counter-offer. The bullets below describe the arcs in
|
||||||
describe the arcs in their own terms; this line is the head-of-`main`
|
their own terms; this line is the head-of-`main` anchor.
|
||||||
anchor.
|
|
||||||
- **`COHERENCE.md` is now required reading and a required framing input
|
- **`COHERENCE.md` is now required reading and a required framing input
|
||||||
— #163.** It carries the product-coherence thesis, an audited
|
— #163.** It carries the product-coherence thesis, an audited
|
||||||
scorecard, per-concern gaps, and §20's priority order, and it is the
|
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
|
unchanged, which is the additivity gate for the `read_dir` change; M4
|
||||||
121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep
|
121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep
|
||||||
3,205 across 93 suites. 15 claims bite-verified.
|
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 =
|
`ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 =
|
||||||
terminal frames/events, v20 = the GPU initial-target semantic
|
terminal frames/events, v20 = the GPU initial-target semantic
|
||||||
bootstrap family). Bottom-panel Stage 2B-1's in-review schema is v21
|
bootstrap family, v21 = the bottom-panel band family).
|
||||||
(`SUPPORTED=[6..=21]`), but its production daemon deliberately
|
**`ADVERTISED_PROTOCOL_VERSION` stays 20, permanently, and that is the
|
||||||
advertises v20: the handshake is server-first, so advertising 21
|
activation mechanism rather than a hedge** — see the Stage 2B-3 bullet.
|
||||||
would make shipped v20 GPU/TUI clients reject before
|
The rule for every future additive family: advertise the baseline,
|
||||||
`AttachRequest`. Stage 2B-3 owns compatible production activation.
|
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 —
|
- **Bottom panel Stage 1 (window placement + TUI side windows) LANDED —
|
||||||
#155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review
|
#155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review
|
||||||
rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now
|
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;
|
`bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121;
|
||||||
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
|
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
|
||||||
Stage 2 48. All 12 CI checks green at merge.
|
Stage 2 48. All 12 CI checks green at merge.
|
||||||
- **Stage 2 (the GPU panel band) is FRAMED; its first two slices have
|
- **Stage 2 (the GPU panel band) is COMPLETE: 2A #177, 2B-1 #184,
|
||||||
LANDED and its third is open as PR #187** —
|
2B-2 #187, and 2B-3 (this lane)** —
|
||||||
`docs/bottom-panel-stage2-framing.md` rev 6, four
|
`docs/bottom-panel-stage2-framing.md` rev 6, four
|
||||||
framing review rounds, no open framing items; the rev-5
|
framing review rounds, no open framing items; the rev-5
|
||||||
implementation split was explicitly approved 2026-07-27 and rev 6
|
implementation split was explicitly approved 2026-07-27 and rev 6
|
||||||
records PR #184's server-first compatibility and gate correction.
|
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 +
|
implementation slices: **2A** classified census routing +
|
||||||
per-window painter extraction (no wire change, #177), **2B-1** the
|
per-window painter extraction (no wire change, #177), **2B-1** the
|
||||||
wire (#184), **2B-2** the daemon projection and epoch machine
|
wire (#184), **2B-2** the daemon projection and epoch machine
|
||||||
(implemented but not landed in PR #187), then **2B-3** the GPU band,
|
(#187), then **2B-3** the GPU band, compatible v21 activation, and
|
||||||
compatible v21 activation, and the
|
the negotiated `panel_capable` flip. Production attachment stayed
|
||||||
negotiated `panel_capable` flip. Production attachment remains v20
|
v20 through 2B-2 and negotiates v21 from 2B-3 on. Parent acceptance
|
||||||
through 2B-2. Parent acceptance 37–55 remains authoritative.
|
37–55 remains authoritative. **Stage 3, the adopter default flip, is
|
||||||
Stage 3 is the adopter default flip. **Do not start 2B-3 until PR
|
the arc's remaining step.**
|
||||||
#187 lands.** Its branch, checkpoints, two review rounds, verification,
|
|
||||||
and exact recovery commands live in `docs/active-work.md`.
|
|
||||||
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
|
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
|
||||||
Projection class (#1–#12, #21–#22) routes through
|
Projection class (#1–#12, #21–#22) routes through
|
||||||
`primary_document_window`; focus/input (#13–#15, #23), focus chrome
|
`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
|
version is therefore an incompatible act on its own, independent of
|
||||||
whether any new message is ever sent. A real-daemon acceptance
|
whether any new message is ever sent. A real-daemon acceptance
|
||||||
emulates that exact rejection point and then requires the
|
emulates that exact rejection point and then requires the
|
||||||
attachment to reach its initial grid. **2B-3 must ship a
|
attachment to reach its initial grid. **Stage 2B-3 discharged this
|
||||||
compatibility-preserving activation mechanism; it may not simply
|
without touching the unsolicited `Hello`** — the advertisement is a
|
||||||
change the unsolicited `Hello` to 21.**
|
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**
|
- **One shared grid validator, split along a stated boundary**
|
||||||
(`pmacs-protocol/src/wire_grid.rs`). Shared: checked area, the
|
(`pmacs-protocol/src/wire_grid.rs`). Shared: checked area, the
|
||||||
visible-cell bound (262,144), cell count, cursor bounds, glyph
|
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
|
stays a per-frame `Absent`, flagged rather than hidden, because
|
||||||
making it durable needs a new "presentation permanently
|
making it durable needs a new "presentation permanently
|
||||||
unavailable" reason in `FrontendView` for a state requiring 2^64
|
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-7–10 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**
|
- **dired Stage 2 framing LANDED (document only) — #171**
|
||||||
(`docs/dired-stage2-framing.md`, revision 9; seven review rounds).
|
(`docs/dired-stage2-framing.md`, revision 9; seven review rounds).
|
||||||
**Approved as a framing; no runtime code and no implementation
|
**Approved as a framing; no runtime code and no implementation
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Bottom panel Stage 2 — the GPU panel band (framing)
|
# Bottom panel Stage 2 — the GPU panel band (framing)
|
||||||
|
|
||||||
**Revision 6 — PR #184 review correction; the underlying Stage 2
|
**Revision 7 — Stage 2B-3 review round 1 corrections; the underlying
|
||||||
framing remains APPROVED 2026-07-27. 2A (#177) and 2B-1 (#184) are both
|
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` @
|
merged, and 2B-2 is the next slice. Ground truth: canonical `main` @
|
||||||
`6bee09d`, where protocol schema support is `v6..=v21` while the
|
`6bee09d`, where protocol schema support is `v6..=v21` while the
|
||||||
production server-first handshake still deliberately advertises v20.**
|
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. 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
|
### 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
|
- **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
|
driving selection, terminal mouse reporting, and click-to-focus
|
||||||
without disturbing the document mirror), **54** (the
|
without disturbing the document mirror), **54** (the
|
||||||
`--headless-probe` run: one real daemon, real PTY, real wgpu, through
|
`--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
|
- **41, the GPU half:** the pixel→cell conversion pinned at fractional
|
||||||
widths and heights, and geometry refresh on window resize, font
|
widths and heights, and geometry refresh on window resize, font
|
||||||
change, and scale change.
|
change, and scale change.
|
||||||
|
|
@ -858,11 +918,18 @@ attach.
|
||||||
neither paints nor hit-tests until a matching `Present` arrives. This
|
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
|
is the case daemon value dedup cannot see and is why option 1 was
|
||||||
chosen.
|
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
|
probe**, not `State::mono_advance`'s document-glyph fallback: two GPU
|
||||||
frontends with identical metrics and different documents derive
|
frontends with identical metrics and different documents derive
|
||||||
identical `total.cols`, and a probe returning `None` declares zero
|
identical `total.cols`, and a probe returning `None` declares zero
|
||||||
usable geometry rather than falling back to a document sample.
|
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
|
- **A2B-4 (contrast assertion).** Installing a panel moves **all twelve
|
||||||
document-owned consumers** of §5.3 by exactly
|
document-owned consumers** of §5.3 by exactly
|
||||||
`installed_panel_height + divider_height`, **while all eight
|
`installed_panel_height + divider_height`, **while all eight
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ use pmacs_protocol::{
|
||||||
FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key,
|
FrontendEvent, FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceMessage, Key,
|
||||||
KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS,
|
KeyEvent, Modifiers, MouseKind, PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS,
|
||||||
SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message,
|
SessionBootstrapRequest, TransportError, is_supported_protocol_version, read_message,
|
||||||
write_message,
|
requested_protocol_version, write_message,
|
||||||
};
|
};
|
||||||
use winit::event_loop::EventLoopProxy;
|
use winit::event_loop::EventLoopProxy;
|
||||||
|
|
||||||
|
|
@ -355,6 +355,30 @@ fn coalesce_kind(event: &FrontendEvent) -> Option<u8> {
|
||||||
kind: MouseKind::Drag(_),
|
kind: MouseKind::Drag(_),
|
||||||
..
|
..
|
||||||
} => Some(3),
|
} => 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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -557,8 +581,16 @@ fn connect_stream_with_sink(
|
||||||
// AttachRequest — declare the capabilities a semantic frontend
|
// AttachRequest — declare the capabilities a semantic frontend
|
||||||
// needs. `multi_frontend` is included because the existing daemon
|
// needs. `multi_frontend` is included because the existing daemon
|
||||||
// gates `crdt_replica` behind it (M10.x dependency).
|
// 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 {
|
let req = AttachRequest {
|
||||||
protocol_version: hello.protocol_version,
|
protocol_version: session_protocol_version,
|
||||||
frontend_capabilities: FrontendCapabilities {
|
frontend_capabilities: FrontendCapabilities {
|
||||||
synchronized_output: false,
|
synchronized_output: false,
|
||||||
unicode_smp: true,
|
unicode_smp: true,
|
||||||
|
|
@ -678,7 +710,8 @@ fn connect_stream_with_sink(
|
||||||
outbox,
|
outbox,
|
||||||
shutdown_handle,
|
shutdown_handle,
|
||||||
frontend_id: hello.assigned_frontend_id,
|
frontend_id: hello.assigned_frontend_id,
|
||||||
server_protocol_version: hello.protocol_version,
|
session_protocol_version,
|
||||||
|
baseline_protocol_version: hello.protocol_version,
|
||||||
initial_message,
|
initial_message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -859,10 +892,25 @@ pub struct AttachClient {
|
||||||
/// `FrontendEvent` carries this so the daemon can route input back
|
/// `FrontendEvent` carries this so the daemon can route input back
|
||||||
/// to the per-session `SemanticRenderState`.
|
/// to the per-session `SemanticRenderState`.
|
||||||
frontend_id: FrontendId,
|
frontend_id: FrontendId,
|
||||||
/// The daemon's `Hello.protocol_version`. Wire variants newer
|
/// The version this session actually speaks: the frontend's
|
||||||
/// than the daemon (e.g. `Pointer`, v5) must be gated on this —
|
/// `AttachRequest` counter-offer, which the daemon adopts. Wire
|
||||||
/// an older daemon hard-errors decoding an unknown variant.
|
/// variants newer than the session (e.g. `Pointer`, v5) must be gated
|
||||||
server_protocol_version: u32,
|
/// 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.
|
/// Target snapshot retained across the pre-window readiness barrier.
|
||||||
initial_message: Option<InstanceMessage>,
|
initial_message: Option<InstanceMessage>,
|
||||||
}
|
}
|
||||||
|
|
@ -914,7 +962,7 @@ impl AttachClient {
|
||||||
|
|
||||||
/// Send a `FrontendEvent::Pointer` (session M-2): a locally
|
/// Send a `FrontendEvent::Pointer` (session M-2): a locally
|
||||||
/// hit-tested gesture in source bytes. Callers gate on
|
/// hit-tested gesture in source bytes. Callers gate on
|
||||||
/// [`Self::server_protocol_version`] `>= 5`.
|
/// [`Self::session_protocol_version`] `>= 5`.
|
||||||
pub fn send_pointer(
|
pub fn send_pointer(
|
||||||
&self,
|
&self,
|
||||||
buffer_id: BufferId,
|
buffer_id: BufferId,
|
||||||
|
|
@ -944,7 +992,7 @@ impl AttachClient {
|
||||||
|
|
||||||
/// Send a `FrontendEvent::TerminalResize` (Vterm Stage 3): the
|
/// Send a `FrontendEvent::TerminalResize` (Vterm Stage 3): the
|
||||||
/// terminal-cell geometry this frontend has on screen. Callers gate
|
/// 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
|
/// Cells, never pixels — the frontend divides its own drawable
|
||||||
/// rectangle by its own metrics, keeping the no-pixels contract the
|
/// 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
|
/// Send a `FrontendEvent::TerminalPointer` (Vterm Stage 3): a
|
||||||
/// gesture hit-tested locally to a terminal cell. Callers gate on
|
/// 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(
|
pub fn send_terminal_pointer(
|
||||||
&self,
|
&self,
|
||||||
buffer_id: BufferId,
|
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
|
/// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu
|
||||||
/// navigation hit-tested locally against the popup we drew. `index`
|
/// navigation hit-tested locally against the popup we drew. `index`
|
||||||
/// is the row the pointer is over (`None` = off the menu); `invoke`
|
/// 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`.
|
/// The version this session negotiated — the frontend's
|
||||||
pub fn server_protocol_version(&self) -> u32 {
|
/// `AttachRequest` offer, which the daemon adopts.
|
||||||
self.server_protocol_version
|
///
|
||||||
|
/// 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
|
/// 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())),
|
outbox: Arc::new((Mutex::new(outbox), Condvar::new())),
|
||||||
shutdown_handle: b,
|
shutdown_handle: b,
|
||||||
frontend_id: FrontendId::LOCAL,
|
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,
|
initial_message: None,
|
||||||
};
|
};
|
||||||
// A send against the closed outbox fails *and* shuts the socket
|
// 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 socket = temp.path().join("managed.sock");
|
||||||
let (client_stream, mut server_stream) = UnixStream::pair().expect("socket pair");
|
let (client_stream, mut server_stream) = UnixStream::pair().expect("socket pair");
|
||||||
let server = thread::spawn(move || {
|
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 {
|
let hello = Hello {
|
||||||
protocol_version: PROTOCOL_VERSION,
|
protocol_version: pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
|
||||||
assigned_frontend_id: FrontendId::LOCAL,
|
assigned_frontend_id: FrontendId::LOCAL,
|
||||||
instance_identity: InstanceIdentity {
|
instance_identity: InstanceIdentity {
|
||||||
pmacs_version: "managed-retry-test".to_owned(),
|
pmacs_version: "managed-retry-test".to_owned(),
|
||||||
|
|
@ -1546,7 +1680,16 @@ mod tests {
|
||||||
.expect("transient sequence must attach");
|
.expect("transient sequence must attach");
|
||||||
assert_eq!(attempts, 4);
|
assert_eq!(attempts, 4);
|
||||||
assert!(managed.daemon.spawned_daemon());
|
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");
|
server.join().expect("handshake server");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -212,21 +212,10 @@ impl TerminalPaintPlan {
|
||||||
/// wide-continuation topology, and the selection spans are sound.
|
/// wide-continuation topology, and the selection spans are sound.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn build(frame: &TerminalFrame, palette: TerminalPalette) -> Self {
|
pub fn build(frame: &TerminalFrame, palette: TerminalPalette) -> Self {
|
||||||
let cols = frame.size.cols as usize;
|
let mut plan = Self::build_grid(frame.size, &frame.cells, frame.cursor, palette);
|
||||||
let mut plan = Self {
|
// Selection is the one piece a panel grid does not have: it is a
|
||||||
size: frame.size,
|
// terminal-only span list on the wire, so it stays out of the
|
||||||
..Self::default()
|
// shared derivation rather than being faked as empty inside it.
|
||||||
};
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
plan.selection = frame
|
plan.selection = frame
|
||||||
.selection
|
.selection
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -236,13 +225,46 @@ impl TerminalPaintPlan {
|
||||||
end_col: span.end_col,
|
end_col: span.end_col,
|
||||||
})
|
})
|
||||||
.collect();
|
.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,
|
row: cursor.row,
|
||||||
start_col: cursor.col,
|
start_col: cursor.col,
|
||||||
end_col: cursor.col + 1,
|
end_col: cursor.col + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
plan
|
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.
|
/// The terminal cell viewport a drawable rectangle admits.
|
||||||
///
|
///
|
||||||
/// Rows and columns are `floor(extent / metric)`, clamped through the
|
/// Rows and columns are `floor(extent / metric)`, clamped through the
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,11 @@ pub use message::{
|
||||||
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
|
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
|
||||||
SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace,
|
SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace,
|
||||||
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
|
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
|
||||||
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::{
|
pub use terminal::{
|
||||||
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
|
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
|
||||||
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,
|
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,
|
||||||
|
|
|
||||||
|
|
@ -604,7 +604,14 @@ pub enum GoodbyeReason {
|
||||||
/// Frontend's `protocol_version` does not match the instance's.
|
/// Frontend's `protocol_version` does not match the instance's.
|
||||||
/// The handshake fails before any further messages.
|
/// The handshake fails before any further messages.
|
||||||
VersionMismatch {
|
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,
|
server: u32,
|
||||||
/// The version the frontend announced in its `AttachRequest`.
|
/// The version the frontend announced in its `AttachRequest`.
|
||||||
client: u32,
|
client: u32,
|
||||||
|
|
@ -1667,14 +1674,87 @@ pub const PROTOCOL_VERSION: u32 = 21;
|
||||||
|
|
||||||
/// Protocol version placed in the daemon's server-first [`Hello`].
|
/// Protocol version placed in the daemon's server-first [`Hello`].
|
||||||
///
|
///
|
||||||
/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but
|
/// **This is a compatibility *baseline*, not a ceiling, and Stage 2B-3
|
||||||
/// production attachment remains on v20 until the Stage 2B-3 capability
|
/// makes that permanent.** The handshake is server-first: the daemon
|
||||||
/// activation can preserve compatibility with existing v20 frontends.
|
/// writes [`Hello`] before the frontend has said anything at all, and a
|
||||||
/// Those frontends reject an unknown server-first version before they can
|
/// frontend rejects an unrecognized `protocol_version` *before* it can
|
||||||
/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would
|
/// send [`AttachRequest`]. Advertising [`PROTOCOL_VERSION`] here would
|
||||||
/// make the otherwise-dark protocol slice user-visible.
|
/// 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;
|
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
|
/// 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
|
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
|
||||||
/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat
|
/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,25 @@ use crate::wire_grid::{
|
||||||
MAX_WIRE_GRID_GLYPH_BYTES, WireGridError, WireGridLimits, validate_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.
|
/// Shared visible-cell ceiling for a panel grid.
|
||||||
///
|
///
|
||||||
/// Identical to the terminal bound: it is the transport-safety limit,
|
/// Identical to the terminal bound: it is the transport-safety limit,
|
||||||
|
|
|
||||||
|
|
@ -450,8 +450,15 @@ pub fn run_attach(socket_path: PathBuf) -> Result<(), AttachError> {
|
||||||
// its AttachRequest (the v0.1 daemon's strict-equality check will
|
// its AttachRequest (the v0.1 daemon's strict-equality check will
|
||||||
// accept). The frontend's runtime behavior on the wire is the
|
// accept). The frontend's runtime behavior on the wire is the
|
||||||
// intersection of features both sides support.
|
// 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 {
|
let req = AttachRequest {
|
||||||
protocol_version: hello.protocol_version,
|
protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version),
|
||||||
frontend_capabilities: build_capabilities(),
|
frontend_capabilities: build_capabilities(),
|
||||||
initial_size,
|
initial_size,
|
||||||
};
|
};
|
||||||
|
|
@ -1914,9 +1921,12 @@ fn run_one_session(
|
||||||
|
|
||||||
// T M10.5: match the server's protocol version so v1.0 frontends
|
// T M10.5: match the server's protocol version so v1.0 frontends
|
||||||
// attaching to v0.1 daemons advertise protocol_version=1. Same
|
// 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 {
|
let req = AttachRequest {
|
||||||
protocol_version: hello.protocol_version,
|
protocol_version: crate::protocol::requested_protocol_version(hello.protocol_version),
|
||||||
frontend_capabilities: build_capabilities(),
|
frontend_capabilities: build_capabilities(),
|
||||||
initial_size,
|
initial_size,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -735,11 +735,26 @@ fn per_attach_thread(
|
||||||
daemon_debug(format!("received AttachRequest from {frontend_id:?}"));
|
daemon_debug(format!("received AttachRequest from {frontend_id:?}"));
|
||||||
|
|
||||||
// T M10.5 version check.
|
// 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) {
|
if !crate::protocol::is_supported_protocol_version(req.protocol_version) {
|
||||||
let _ = write_message(
|
let _ = write_message(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
&InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch {
|
&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,
|
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)
|
u8::try_from(frontend_id.0 % (crate::overlay_color::PALETTE_LEN as u64)).unwrap_or(0)
|
||||||
};
|
};
|
||||||
|
|
||||||
let session_state =
|
// The session speaks the lower of the two ceilings. The membership
|
||||||
crate::presence::SessionState::new(req.protocol_version, negotiated_caps, color_slot);
|
// 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
|
// Hand the write-half to the dispatcher; keep a read-half for
|
||||||
// this thread's reader loop. **Reader loop starts immediately
|
// 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
|
/// 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
|
/// 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
|
/// band, which Stage 2B-3 lands — so it is panel-capable exactly when it
|
||||||
/// every semantic peer, whatever it declares. No client-asserted
|
/// negotiated a wire that can carry the band. No client-asserted
|
||||||
/// standalone boolean is trusted: the answer is derived from the daemon's
|
/// standalone boolean is trusted: the answer is derived from the daemon's
|
||||||
/// own negotiated state.
|
/// own negotiated state.
|
||||||
///
|
///
|
||||||
/// **Stage 2B-2 deliberately does not turn the version arm on.** The
|
/// **Stage 2B-3 turns the version arm on** (framing §3.5): `panel_capable`
|
||||||
/// daemon-side projection and epoch machine below are complete and
|
/// is true for an authenticated semantic session that negotiated
|
||||||
/// exercised through a test-only panel-capable view, but the production
|
/// [`PANEL_MIN_VERSION`] or later, and false for every earlier one. The
|
||||||
/// flip (`semantic_render && negotiated_protocol_version >=
|
/// gate is on *placement*, not only on transport — denying the events
|
||||||
/// PANEL_MIN_VERSION`) belongs to Stage 2B-3, together with the
|
/// while still putting a pre-panel peer's window in a side panel it cannot
|
||||||
/// compatibility-preserving activation the server-first `Hello` requires:
|
/// render would leave that window invisible, so a v6–v20 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)
|
/// [`ADVERTISED_PROTOCOL_VERSION`](pmacs_protocol::ADVERTISED_PROTOCOL_VERSION)
|
||||||
/// is still 20, so no session can negotiate 21 yet, and denying only the
|
/// baseline the daemon put in `Hello`. That distinction is the whole
|
||||||
/// events while still *placing* such a peer in a side window would leave
|
/// activation mechanism: the baseline stays where every shipped frontend
|
||||||
/// its window invisible.
|
/// 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 {
|
fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool {
|
||||||
!session_state.negotiated_capabilities.semantic_render
|
!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
|
/// 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
|
// collapses folds, a semantic one keeps raw-line reckoning until
|
||||||
// Stage 3.
|
// Stage 3.
|
||||||
// Bottom-panel arc (Q#BP13): panel capability comes from the SAME
|
// Bottom-panel arc (Q#BP13): panel capability comes from the SAME
|
||||||
// negotiated bit in this same transaction. Stage 1 ships the TUI
|
// negotiated state in this same transaction. Stage 2B-3 made the
|
||||||
// side windows only, so a semantic session is not panel-capable and
|
// semantic arm live: a semantic session that negotiated
|
||||||
// a `side` request falls back to its document target with every
|
// `PANEL_MIN_VERSION` or later can render the GPU band and is
|
||||||
// side-specific parameter discarded.
|
// 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(
|
let fresh_view = build_fresh_frontend_view(
|
||||||
editor,
|
editor,
|
||||||
!session_state.negotiated_capabilities.semantic_render,
|
!session_state.negotiated_capabilities.semantic_render,
|
||||||
|
|
@ -5570,7 +5601,7 @@ mod tests {
|
||||||
fn viewport_aligns_the_document_without_taking_focus_from_the_panel() {
|
fn viewport_aligns_the_document_without_taking_focus_from_the_panel() {
|
||||||
let (mut editor, fid, document, panel) = panel_focused_semantic_fixture();
|
let (mut editor, fid, document, panel) = panel_focused_semantic_fixture();
|
||||||
let other = {
|
let other = {
|
||||||
let mut core = editor.core.borrow_mut();
|
let core = editor.core.borrow_mut();
|
||||||
core.registry.borrow_mut().create("*other*")
|
core.registry.borrow_mut().create("*other*")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,21 +60,6 @@ use std::path::PathBuf;
|
||||||
// directly.
|
// directly.
|
||||||
pub use pmacs_protocol::*;
|
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
|
// Attachment
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,16 @@
|
||||||
|
|
||||||
//! The daemon panel projection and the epoch machine.
|
//! The daemon panel projection and the epoch machine.
|
||||||
//!
|
//!
|
||||||
//! Everything here runs through a **test-only** panel-capable semantic
|
//! Everything here runs through an explicitly panel-capable semantic
|
||||||
//! view: production negotiation still sets `panel_capable = false` for
|
//! view constructed by the fixture. When this suite was written that was
|
||||||
//! every semantic session, and the compatibility-preserving v21
|
//! a *test-only* configuration — production negotiation set
|
||||||
//! activation is Stage 2B-3's. Nothing in this slice is user-reachable.
|
//! `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:
|
//! Two disciplines the framing is explicit about:
|
||||||
//!
|
//!
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -109,9 +109,13 @@ fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame {
|
||||||
fn the_panel_stage_takes_protocol_v21() {
|
fn the_panel_stage_takes_protocol_v21() {
|
||||||
assert_eq!(PROTOCOL_VERSION, 21);
|
assert_eq!(PROTOCOL_VERSION, 21);
|
||||||
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21));
|
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21));
|
||||||
// The wire family is reserved before it is activated: the production
|
// The advertised version is a compatibility BASELINE, and Stage 2B-3
|
||||||
// server-first Hello must remain acceptable to already-shipped v20
|
// made that permanent rather than temporary: the server-first Hello
|
||||||
// clients throughout the dark protocol and daemon slices.
|
// 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_eq!(ADVERTISED_PROTOCOL_VERSION, 20);
|
||||||
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20));
|
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -579,9 +579,19 @@ mod crdt {
|
||||||
);
|
);
|
||||||
let facts = parse_report(&report);
|
let facts = parse_report(&report);
|
||||||
assert_eq!(facts.get("phase").map(String::as_str), Some("complete"));
|
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!(
|
assert_eq!(
|
||||||
facts
|
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()),
|
.and_then(|value| value.parse::<u32>().ok()),
|
||||||
Some(ADVERTISED_PROTOCOL_VERSION)
|
Some(ADVERTISED_PROTOCOL_VERSION)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -429,9 +429,32 @@ fn version_mismatch_clean_disconnect() {
|
||||||
write_message(&mut stream, &req).expect("write");
|
write_message(&mut stream, &req).expect("write");
|
||||||
|
|
||||||
// Expect Goodbye(VersionMismatch).
|
// 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) {
|
match read_message::<InstanceMessage>(&mut stream) {
|
||||||
Ok(InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client })) => {
|
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);
|
assert_eq!(client, 999);
|
||||||
}
|
}
|
||||||
other => panic!("expected VersionMismatch Goodbye, got {other:?}"),
|
other => panic!("expected VersionMismatch Goodbye, got {other:?}"),
|
||||||
|
|
|
||||||
|
|
@ -717,10 +717,25 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
|
||||||
.filter_map(|line| line.split_once('='))
|
.filter_map(|line| line.split_once('='))
|
||||||
.collect();
|
.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!(
|
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"),
|
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!(
|
assert_eq!(
|
||||||
facts.get("entered_terminal_mode").copied(),
|
facts.get("entered_terminal_mode").copied(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue