Merge pull request #184 from levineuwirth/bottom-panel-stage2b

protocol(panel): add Stage 2B-1 v21 wire layer
This commit is contained in:
Levi Neuwirth 2026-07-28 19:46:31 +00:00 committed by GitHub
commit 6bee09dc98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1929 additions and 443 deletions

View File

@ -109,7 +109,7 @@ remain open to them.
| 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 shared primitive; bottom panel landed (#155) |
| 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all |
| 16 | Semantic frontend | **Strong** | v6..=v20 negotiated protocol; degradation practiced; TUI/GPU share the model |
| 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 |
| 18 | Onboarding | **Missing** | No welcome, no tutorial; `C-h` deletes a word; `M-x` is the only door in |
| 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` exists (steps 2, 3, 5); the other five scenarios are still unwritten |
@ -1340,9 +1340,13 @@ facto privileged implementation.
**Grade: strong — the healthiest concern in this document, and most of
its asks are already practiced.**
- Versioned, negotiated protocol `SUPPORTED=[6..=20]` with deliberate
- Versioned protocol schema `SUPPORTED=[6..=21]` with deliberate
encoding-breaking bumps, both-frontends support required per bump,
and byte-pin discipline for appended variants (handoff §4).
and byte-pin discipline for appended variants (handoff §4). The v21
bottom-panel family is reserved but dark in Stage 2B-1: because
`Hello` is server-first, the production daemon still advertises v20
so shipped v20 clients remain attachable; compatible v21 activation
belongs to Stage 2B-3.
- Two genuine frontends share the conceptual model; CRDT concurrent
editing with presence across them; remote attach + reconnect.
- **Graceful per-frontend degradation is practiced, not aspirational**:

View File

@ -27,12 +27,11 @@ landed regardless of what a lane says.
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `c2d56ff` (Journey Stage 1a #182, which
incorporated terminal configuration + copy mode #180, atop Lean 4
Stage 4b #181 and the previously recorded landed work; protocol v20).
The previous snapshot named `42025e4`, and **the recovery floor
advances with it**: the check below now requires `c2d56ff` or newer,
so a tree at `42025e4` no longer passes. That is
`githubsucks/main` @ `7fd646d` (Journey/GPU directory-target ratchet
#183, atop Journey Stage 1a #182 and the previously recorded landed
work; protocol v20). The previous snapshot named `c2d56ff`, and **the
recovery floor advances with it**: the check below now requires
`7fd646d` or newer, so a tree at `c2d56ff` no longer passes. That is
deliberate — the floor moves with the base, because a check that
accepts an older commit than the declared base passes on a tree the
rest of this file does not describe.
@ -71,7 +70,7 @@ git worktree list
git status --short --branch
```
The `git log` command must expose `c2d56ff` — the base named above — or a
The `git log` command must expose `7fd646d` — the base named above — or a
newer intentional main. Keep this threshold and the canonical-base line in
step: a recovery check that accepts an older commit than the base it
declares canonical will pass on a tree the rest of this file does not
@ -171,92 +170,6 @@ If it does not, stop and repair the remote/fetch configuration.
to recur; the next occurrence carries its own evidence under whoever's
PR, and a Stage B framing follows then.
## Journey/GPU directory-target ratchet — PR #183 GATED ON PUBLIC PATH
- **Approved correction, not new product behavior.** GPU initial-target
framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make
a directory target a success; `COHERENCE.md` §2 treats that path as a
protected journey. The stale GPU integration test still listed `"."`
among malformed/unloadable targets after #182 landed.
- Branch `journey-gpu-directory-ratchet`, based directly on canonical
`main` @ `c2d56ff`. Recovery: `git fetch githubsucks && git checkout
journey-gpu-directory-ratchet`. Everything described here is
committed and pushed; nothing depends on the `/tmp` worktree.
- PR #183:
<https://github.com/levineuwirth/pmacs/pull/183>. It is intentionally
open and unmerged pending user review.
- **Scope is one acceptance ratchet:** remove `"."` from the four
genuinely invalid cases and drive the public `pmacs --gpu .` root
broker through the real managed GPU connector. The positive requires
snapshot-first + `InitialTargetResult::Opened`, then consumes the
post-quiescence replacement snapshot and requires dired's canonical
header plus a known directory entry before proving the same daemon can
open a following file target. The private display-less acceptance
probe now reports its snapshot count and final materialized text so
that public path is observable. No normal frontend/daemon behavior,
protocol, framing decision, or coherence grade changes.
- **Review round 1: three findings, all real and corrected.** The
first test stopped at the deliberately pre-existing bootstrap
document, so it did not pin the resolver's later dired commit. The
Stage 2 rev-5 recovery bullet named rev 4's framing branch. And the
durable handoff still named pre-Journey `main` even though this ledger
had advanced. The first is now a post-quiescence transport assertion;
the latter two are corrected in this revision. The complete matrix
below is green on the corrected tree.
- **Live public-path check tightened that correction further.** A user
report that `pmacs --gpu .` differed from `pmacs .` did not reproduce:
the live default daemon delivered both snapshots, and a traced
windowed invocation applied both and displayed dired. It nevertheless
exposed that the corrected acceptance still attached a raw protocol
client rather than invoking the public root broker and real GPU
connector. The test now covers those surfaces and passes **15/15**;
its full matrix is green at `34b8f28`.
- **Review round 2 found and fixed one failure-path cleanup gap at
`dc2dc42`.** The public-path test waited directly for dired's second
snapshot, but `ManagedProbe` only retained a spawned daemon's PID after
a successful wait. If the exact missing-snapshot regression recurred,
the timeout would therefore be unable to terminate that daemon. The
test now consumes the initial ready report first, retaining lifecycle
facts before it starts the post-quiescence assertion.
- **Review-round-2 gates are green:** `cargo fmt --check`; strict
workspace clippy; library **1,849 passed / 3 ignored**; CRDT library
**2,034 passed / 4 ignored**; focused public-path test **1/1** and full
GPU invocation suite **15/15**; M4 **121 passed / 3 ignored / 1
filtered**; required GPU package **202/202**; `git diff --check`.
The first in-sandbox GPU-package attempt reproduced the repository's
documented Unix-socket restriction as three attach failures plus a
blocked peer read; the authoritative out-of-sandbox rerun passed all
**202** tests in under one second.
- **Full gate matrix is green on the public-path revision at `34b8f28`:**
- `cargo fmt --check`;
- strict workspace clippy;
- library **1,849 passed / 3 ignored**;
- CRDT library **2,034 passed / 4 ignored**;
- touched GPU initial-target suite **15/15**;
- connected Journey/Dired/find-file/theme/bottom-panel acceptance
suites **125/125**;
- M4 **121 passed / 3 ignored / 1 filtered**;
- required GPU package **202/202**;
- `git diff --check`;
- isolated-config, one-invocation full workspace sweep green on its
final run.
- **Gate diagnostics retained:** initial fresh-worktree attempts without
the documented `pmacs-gpu` prerequisite and without out-of-sandbox
Unix-socket permission were setup failures, not product evidence.
During the review rerun, an in-sandbox library attempt reproduced the
latter setup failure as `EPERM` in three attach socket tests; the
authoritative out-of-sandbox rerun passed all **1,849** non-ignored
tests.
The first full-workspace attempt then exhausted the `/tmp` filesystem
quota while linking. Moving only the disposable Cargo target to disk
let the sweep run; its first completed pass exposed one transient
`pmacs-gpu` font-facts unit-test red after the standalone GPU gate had
passed. The exact test passed immediately against the identical build,
and the required complete one-invocation rerun was green, including
GPU **202/202**.
- This side quest lands before bottom-panel 2B-1 opens its PR. After it
merges, 2B-1 integrates the new `main` and reruns its full matrix.
## The CRDT half of the test corpus is dark in CI — NEEDS A LANE
- **No branch, no framing yet.** Found while gating #166, then measured
@ -392,39 +305,128 @@ If it does not, stop and repair the remote/fetch configuration.
never been enforced. Any CI job that compiles the `crdt` targets has to
fix them first or it will be red on arrival.
## Bottom-panel lane (Arc 7) — 2B-1 GATED; waiting on ratchet side quest
## Bottom-panel lane (Arc 7) — 2B-1 REGATED; PR #184 OPEN FOR REVIEW
Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing
revision 5's three-way split of 2B was explicitly approved on
2026-07-27. **Stage 2B-1 is implemented, integrated with canonical
`main`, and pushed at `b9123c2`; no PR is open.** Its own omissions found
by gating are corrected. The Journey/GPU ratchet lane above is its sole
remaining dependency.
2026-07-27; revision 6 records PR #184's review correction. **Stage
2B-1 is implemented and integrated with canonical `main` @ `7fd646d`.
Review round 2's four findings are corrected at `ab7c207`; the
gate-found GPU/PTY probe barrier is corrected and the complete suite is
green at `9e20175`. The follow-up fixture-specific probe correction is
committed and proportionally regated at `9c79ce1`. PR #184 is open and
must not merge before user review.**
- **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on
`githubsucks/main` @ `c2d56ff` by merge because review had begun.
`githubsucks/main` @ `7fd646d` by merge because review had begun.
Recovery: `git fetch githubsucks && git checkout
bottom-panel-stage2b`. Everything is committed and pushed; nothing
depends on a worktree or `/tmp`.
- **Ships only the v21 wire layer:** the four wire shapes, version bump,
shared cell-grid validator, and version-ladder move. It has no
producer, consumer, or capability change; `panel_capable` stays
`false`, so this slice changes no user-visible journey grade.
bottom-panel-stage2b`. Everything described through the integration
and gate checkpoint is committed and pushed; nothing depends on a
worktree or `/tmp`. PR #184:
<https://github.com/levineuwirth/pmacs/pull/184>.
- **Ships only the reserved v21 wire layer:** the four wire shapes,
schema version, shared cell-grid validator, and accepted-version
ladder move. The production daemon continues advertising v20 because
its `Hello` is server-first; v21 activation belongs to 2B-3. This
slice has no producer, consumer, or capability change;
`panel_capable` stays `false`, so it changes no user-visible journey
grade and existing v20 clients remain attachable.
- **Review round 1 closed:** two P1s and one P2, all corrected at
`9b364ad`: `PanelFrame` now identifies its buffer, the transport
ratchet covers the actual attach path rather than a detached codec
assertion, and shared grid bounds have one validator.
- **The full gate found and corrected two further 2B-1 omissions at
`b9123c2`:** the statusline version ladder still pinned v20/rejected
v21, and Vterm Stage 3 pinned v20 both structurally and in its real
headless probe. Those ratchets now expect v21 and, where applicable,
reject v22.
- **Green evidence except for the main-side stale ratchet this side
quest owns:** formatting and strict Clippy; default/CRDT libraries;
every bottom-panel, folding, GPU-font, statusline, semantic, Vterm,
M4, and required-GPU gate; and the isolated-config full-workspace
sweep. The exact counts and the classified M8 timing rerun live at
`b9123c2`.
- **Review round 2 found four issues, corrected at `ab7c207`:** the
server-first `Hello` made the advertised v20↔v21 compatibility
one-way; `COHERENCE.md` and `docs/agent-handoff.md` still named only
v20 schema support; framing §9 named a nonexistent aggregate 2B
suite instead of the three exact 2B slice suites; and the panel plus
copied terminal "one byte over" fixtures were actually two bytes
over. The correction keeps production advertisement at v20, adds a
real-daemon existing-v20-client acceptance, updates all three durable
records, names the exact slice suites, and asserts both rejecting
fixtures are exactly `limit + 1`.
- **The full gate exposed and corrected a contradiction in Vterm Stage
3's headless probe at `9e20175`.** Its loop exited as soon as resize
plus two nonuniform composites were observed, while its acceptance
later required the PTY child's `VTERMROW` output in the final frame.
The v20-compatible handshake made that scheduling race deterministic:
terminal mode, five frames, and resize all succeeded, but the report
sampled a blank frame. The probe now waits for the exact child-output
observation its acceptance asserts. The formerly failing exact
GPU/PTY test passes, and the full nine-test Stage 3 target passes.
- **Follow-up review corrected the probe barrier's fixture leak at
`9c79ce1`.** The generic runner hard-coded the producer fixture's
`VTERMROW` breadcrumb, so the CAT input fixture could satisfy every
assertion but never satisfy the loop exit and waited out the
20-second safety deadline. Producer probes now name their required
frame text while input probes finish on the latched echo. The report
exposes `completion_observed`, and both paths assert it, so a
deadline-driven pass cannot hide the stall again.
- **The full gate found and corrected two 2B-1 omissions:** the
statusline version ladder still pinned v20/rejected v21, and Vterm
Stage 3 pinned v20 both structurally and in its real headless probe.
Those ratchets now expect v21 and, where applicable, reject v22.
- **Pre-integration green evidence at `b9123c2`:** formatting and strict
workspace Clippy; library **1,849 passed + 3 ignored default** and
**2,034 passed + 4 ignored CRDT**; bottom-panel Stage 1 / 2A / 2B-1
**46 / 17 / 15**; folding Stage 2 **48**; GPU font **11**; statusline
**8 CRDT**; m11_5 semantic **2 CRDT**; Vterm Stages 1 / 2 / 3
**10 / 6 / 9 CRDT**, with Stage 3's real daemon + PTY + wgpu probe
required and green; M4 **121 passed + 3 ignored + 1 filtered**;
required GPU **202**; and the isolated-config, one-invocation full
workspace sweep green on rerun. Its first pass hit the known
completion-before-supersede race in
`m8_1_acceptance::read_dir_supersede_cancels_in_flight_predecessor`;
the exact pin, its full 10-test target, and the complete workspace
rerun all passed.
- **The former deterministic red is resolved on `main`.** PR #183
corrected `gpu_initial_target_acceptance` through the public
`pmacs --gpu .` path, consumed the asynchronous dired snapshot, and
retained the managed daemon before the wait so failure cleanup remains
effective. The code integration auto-composed.
- **The previous complete post-integration gate was green at `c8895a8`:**
formatting; strict workspace Clippy; library **1,849 passed + 3
ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel
Stage 1 / 2A / 2B-1 **46 / 17 / 15**; folding Stage 2 **48**; GPU font
**11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU initial
target and invocation **15 / 15 CRDT**; Vterm Stages 1 / 2 / 3
**10 / 6 / 9 CRDT**, including the required real daemon + PTY + wgpu
probe; M4 **121 passed + 3 ignored + 1 filtered**; required GPU
**202/202**; the isolated-config, one-invocation full workspace sweep;
and `git diff --check`.
- The first required-GPU pass was **201/202** on
`a_fraction_draws_rule_pixels_between_its_operand_rows`, a rendering
test structurally outside this lane's protocol-only GPU diff. The
exact test passed immediately in isolation with one test thread, and
the mandatory complete rerun passed **202/202**. This is retained as
classified gate evidence, not erased as a clean first pass.
- **The corrected review-round-2 head is fully green at `9e20175`:**
formatting; strict workspace Clippy; library **1,849 passed + 3
ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel
Stage 1 / 2A / 2B-1 **46 / 17 / 16**; folding Stage 2 **48**; GPU
font **11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU
initial target and invocation **15 / 15 CRDT**; the handshake
consumers m5_5 / m5_7 / mode-system wiring **36 / 7 / 1 CRDT**
(the release-only m5 perf test remains ignored by its standing
contract); Vterm Stages 1 / 2 / 3 **10 / 6 / 9 CRDT**, including the
required real daemon + PTY + wgpu probe; M4 **121 passed + 3 ignored
+ 1 filtered**; required GPU **202/202**; the isolated-config,
one-invocation full workspace sweep; and `git diff --check`.
- An initial default-library attempt inside the restricted tool
sandbox produced three `Operation not permitted` failures in
socket-based attach tests. The authoritative outside-sandbox rerun
passed all **1,849 + 3 ignored**, and the matching CRDT run passed.
This is retained as environment classification, not presented as a
clean first attempt.
- **The fixture-specific follow-up is proportionally green at
`9c79ce1`:** formatting and strict workspace Clippy; protocol
**17/17**; bottom-panel Stage 2B-1 **16/16**; Vterm Stage 3 **9/9
CRDT** with the real daemon + PTY + required wgpu probe in **5.72 s**;
the formerly stalled CAT path **1/1 in 0.32 s**; required GPU
**202/202**; and `git diff --check`. The first Stage 2B-1 and Vterm
attempts inside the restricted tool sandbox reproduced the classified
Unix-socket `Operation not permitted` denial; their authoritative
outside-sandbox reruns passed.
- **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1
lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine
belongs to 2B-2; the GPU band and negotiated capability flip belong
@ -497,8 +499,9 @@ remaining dependency.
`docs/agent-handoff.md` §1; the two round lessons are in §5.
- Landed-docs follow-up merged as **#156** (`main` @ `d152120`,
2026-07-25).
- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 5**
is commit `56301ed` on branch `githubsucks/bottom-panel-stage2b`,
- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 6**
is on branch `githubsucks/bottom-panel-stage2b` (revision 5 is commit
`56301ed` there),
worktree `../pmacs-bp-stage2b`. Revisions 14 remain on
`githubsucks/bottom-panel-stage2-framing` (head `4fbd47f`, four
framing commits, revision 4 at `49757e5`). Round 1 closed 2 blocking +
@ -506,7 +509,9 @@ remaining dependency.
round 2 closed 1 blocking + 2 high + 1 medium and decided both open
items; round 3 closed 1 blocking + 1 high + 1 medium. No open items
remain. Revision 5 adds no decision; it records the approved
2B-1/2B-2/2B-3 implementation split. The
2B-1/2B-2/2B-3 implementation split. Revision 6 corrects the
server-first compatibility contract, durable protocol claims, exact
acceptance-suite names, and `limit + 1` fixture. The
parent framing `docs/bottom-panel-framing.md` (rev 4) remains
authoritative, **including its acceptance criteria 3755**.
- Retained, carrying nothing unmerged: branch `bottom-panel` and worktree
@ -515,12 +520,14 @@ remaining dependency.
before the next branches:
**2A** = classified §1.3 census routing + `paint_frame` per-window
painter extraction (with the active-window auto-scroll preparation), no
protocol change; **2B-1** = protocol **v21**
protocol change; **2B-1** = reserved protocol schema **v21**, with
production advertisement held at v20,
(`InstanceMessage::PanelFrame` plus
`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
gated both directions, each extended enum byte-pinned on its own
previous final variant); **2B-2** = daemon panel projection and epoch
machine; **2B-3** = the GPU band and negotiated `panel_capable` flip.
machine; **2B-3** = compatible v21 activation, the GPU band, and the
negotiated `panel_capable` flip.
Stage 3 is the adopter default flip.
- **Correction — this entry previously mis-stated the census contract.**
It is **not** "route every consumer through `primary_document_window`".

View File

@ -1,9 +1,12 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-28, after Journey Stage 1a (#182), which made
directory startup one coherent local/daemon/GPU path and incorporated
the terminal configuration + copy mode landed-doc work (#180); following
terminal copy mode (#178) — `C-c C-t`
**Last updated: 2026-07-28, during bottom-panel Stage 2B-1 PR #184
review; the canonical landed base remains the Journey/GPU
directory-target ratchet (#183), following Journey Stage 1a (#182),
which made directory
startup one coherent local/daemon/GPU path and incorporated the terminal
configuration + copy mode landed-doc work (#180); following terminal
copy mode (#178) — `C-c C-t`
materializes a terminal's whole retained range into an ordinary buffer,
plus `Buffer::set_generated_contents`, the first genuinely immutable
generated-buffer write path — and its landed-doc pair (#168); following
@ -44,11 +47,11 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-28)
- `main` @ `c2d56ff` (Journey Stage 1a #182, incorporating terminal
configuration + copy mode landed docs #180, atop Lean 4 Stage 4b #181,
the dired Stage 1 landed docs #169 and the PTY-terminate diagnostic
#176, terminal copy
mode #178, the GPU-terminal-input landed docs #168, Lean 4 Stage 4a
- `main` @ `7fd646d` (Journey/GPU directory-target ratchet #183, atop
Journey Stage 1a #182, incorporating terminal configuration + copy
mode landed docs #180, Lean 4 Stage 4b #181, the dired Stage 1 landed
docs #169 and the PTY-terminate diagnostic #176, terminal copy mode
#178, the GPU-terminal-input landed docs #168, Lean 4 Stage 4a
#179, bottom-panel Stage 2A
#177, the bottom-panel Stage 2 framing #175, terminal configuration
Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a #167, the CRDT undo repro
@ -57,9 +60,11 @@ commands, read `docs/active-work.md` immediately after this file.
#165, the GPU terminal input fix #166, Lean 4 Stage 2 #161, the dired
framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160,
minimap blank-slab #159, bottom-panel Stage 1 #155). Protocol unchanged
at **v20** — bottom-panel Stage 2A deliberately carries no wire change;
v21 arrives with Stage 2B. The bullets below describe the arcs in their
own terms; this line is the head-of-`main` anchor.
at **v20** — bottom-panel Stage 2A deliberately carries no wire change.
The in-review Stage 2B-1 reserves the v21 schema but keeps the
server-first production `Hello` at v20; compatible activation belongs
to Stage 2B-3. The bullets below describe the arcs in their own terms;
this line is the head-of-`main` anchor.
- **`COHERENCE.md` is now required reading and a required framing input
#163.** It carries the product-coherence thesis, an audited
scorecard, per-concern gaps, and §20's priority order, and it is the
@ -394,9 +399,14 @@ commands, read `docs/active-work.md` immediately after this file.
unchanged, which is the additivity gate for the `read_dir` change; M4
121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep
3,205 across 93 suites. 15 claims bite-verified.
- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
`FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 =
the GPU initial-target semantic bootstrap family).
- Canonical `main` is protocol **v20** (`SUPPORTED=[6..=20]`; v16 =
`ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 =
terminal frames/events, v20 = the GPU initial-target semantic
bootstrap family). Bottom-panel Stage 2B-1's in-review schema is v21
(`SUPPORTED=[6..=21]`), but its production daemon deliberately
advertises v20: the handshake is server-first, so advertising 21
would make shipped v20 GPU/TUI clients reject before
`AttachRequest`. Stage 2B-3 owns compatible production activation.
- **Bottom panel Stage 1 (window placement + TUI side windows) LANDED —
#155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review
rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now
@ -462,12 +472,18 @@ commands, read `docs/active-work.md` immediately after this file.
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
Stage 2 48. All 12 CI checks green at merge.
- **Stage 2 (the GPU panel band) is FRAMED**
`docs/bottom-panel-stage2-framing.md`, four review rounds, no open
items. It takes protocol **v21** and ships as two serial slices:
**2A** classified census routing + per-window painter extraction (no
wire change), then **2B** the wire, the daemon projection, the band,
and the negotiated `panel_capable` flip. Parent acceptance 3755
remains authoritative. Stage 3 is the adopter default flip.
`docs/bottom-panel-stage2-framing.md` rev 6, four framing review
rounds, no open framing items; the rev-5 implementation split was
explicitly approved 2026-07-27 and rev 6 records PR #184's
server-first compatibility and gate correction. It reserves
protocol **v21** and ships as four serial
implementation slices: **2A** classified census routing +
per-window painter extraction (no wire change), **2B-1** the wire,
**2B-2** the daemon projection and epoch machine, then **2B-3** the
GPU band, compatible v21 activation, and negotiated
`panel_capable` flip. Production attachment remains v20 through
2B-1 and 2B-2. Parent acceptance 3755 remains authoritative.
Stage 3 is the adopter default flip.
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
Projection class (#1#12, #21#22) routes through
`primary_document_window`; focus/input (#13#15, #23), focus chrome
@ -1198,12 +1214,17 @@ buffer owns a path's recovery slot; only recover/discard release
unclaimed crash data; adopt clears the old owner's skip cache.
**Protocol** — encoding-breaking bumps are deliberate and versioned. Canonical
`main` is `[6..=20]`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
`main` is `[6..=20]`. The in-review bottom-panel 2B-1 schema extends support
to `[6..=21]`, while `ADVERTISED_PROTOCOL_VERSION` stays 20 until 2B-3
provides compatibility-preserving activation; the server-first `Hello`
cannot advertise 21 without stranding existing v20 clients before
`AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm
terminal family; v20 = semantic `SessionBootstrapRequest` plus appended
`InitialTargetResult`. New wire surface ⇒ bump + both-frontends support +
acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS
final variant — its own round-trip cannot detect a discriminant shift.
`InitialTargetResult`; v21 reserves the panel frame/event family. New wire
surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant
must be guarded by a byte pin on the PREVIOUS final variant — its own
round-trip cannot detect a discriminant shift.
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),

View File

@ -1,7 +1,13 @@
# Bottom panel Stage 2 — the GPU panel band (framing)
**Revision 4 — pre-implementation. Ground truth: canonical `main` @
`ccf29e3`, protocol v20, 2026-07-25.**
**Revision 6 — PR #184 review correction; the underlying Stage 2
framing remains APPROVED 2026-07-27. 2A is merged and 2B-1 is under
review. Ground truth: canonical `main` @ `7fd646d`, protocol v20 on
`main`; `bottom-panel-stage2b` reserves the v21 schema while its
server-first production handshake continues to advertise v20.**
Revisions 14 were pre-implementation; rev 5 recorded the three-way
slice of Stage 2B after its first slice was already built; rev 6
corrects that slice's mixed-version and gate contracts.
Stage 1 (#155, merge `e745068`) gave pmacs window placement, window
parameters, TUI side windows, the divider, and the adopter `display`
@ -26,7 +32,82 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and
## 0. Revision history
### 0.0 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all 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
protocol's handshake is server-first. An existing v20 TUI or GPU
frontend rejects a `Hello { protocol_version: 21 }` before it can
send an `AttachRequest`, so rev 5's claim that a v21 daemon and v20
peer "still negotiate 20" was impossible. 2B-1 therefore extends the
schema and accepted-version ladder to v21 while the production daemon
continues advertising v20. A real-daemon acceptance emulates the
shipped v20 rejection point and then requires the attachment to reach
its initial grid. **2B-3 owns both a compatibility-preserving
activation mechanism and the production move to v21; it may not
simply change the unsolicited `Hello` to 21.**
- **R6-2 (P2) — durable protocol claims move with the wire.**
`COHERENCE.md` and `docs/agent-handoff.md` now distinguish v21 schema
support from the still-v20 production handshake.
- **R6-3 (P2) — the gate contract names the actual decomposition.**
§9 now names 2B-1's
`bottom_panel_stage2b_protocol_acceptance` suite and the exact planned
daemon/GPU suite names for 2B-2 and 2B-3 instead of the nonexistent
`bottom_panel_stage2b_acceptance`.
- **R6-4 (P2) — "one byte over" means exactly one.** The panel and
copied terminal boundary fixtures replace a one-byte cluster with a
two-byte cluster, and each independently asserts a total of
`limit + 1`.
### 0.1 Rev 4 → rev 5 — the three-way slice of 2B (not a review round)
This revision changes no decision. It splits one approved
implementation slice into three and reallocates the acceptance
criteria across them.
- **R5-1 — why.** Rev 4 §9 scoped 2B as a single PR: v21 protocol,
daemon panel projection, GPU band, and the negotiated
`panel_capable` flip. Implementation showed that to be roughly four
thousand lines spanning `pmacs-protocol`, `src/daemon.rs`, and
`pmacs-gpu` — three review surfaces with different failure modes, in
one diff. The same argument that produced 2A/2B applies again one
level down, and it is the argument this arc has already accepted
twice (Lean 4 stages 3a/3b and 4a/4b).
- **R5-2 — the boundary rule.** A slice ends where the next thing to
build has a different *authority*: the wire format, the daemon that
produces frames, and the frontend that paints them. Each slice is
independently reviewable against a subset of the parent criteria,
and each is additive — no slice makes a previously-passing assertion
fail.
**Criteria that span a boundary are named in every slice they touch,
with their half stated**, rather than assigned wholesale to one. The
clearest case is parent 39: its shared-validation and
transport-budget halves are wire properties provable in 2B-1, while
"the previous valid frame is retained" and "a duplicate does no
work" are receiver-state properties that need the epoch machine and
land in 2B-2.
- **R5-3 — this revision is retroactive for slice 1, and that is a
process defect worth recording.** `bottom-panel-stage2b` already
carries the v21 protocol layer (three commits, one review round
closed) written before this revision existed. The workflow is
framing → approval → branch → implement; slice 1 inverted it. The
slicing decision was sound, but it was taken in code and discovered
in the branch rather than proposed in the document, which is exactly
how a stage's scope drifts without anyone deciding that it should.
Rev 5 exists to put the decision back where it belongs before slices
2 and 3 are written.
- **R5-4 — two of the three slices ship dark, deliberately.** Nothing
in 2B-1 or 2B-2 is reachable by a user: `panel_capable` stays
`false` for every negotiated semantic session until 2B-3. Rev 5
incorrectly described that posture as a production v21 negotiation
that remained compatible with v20 clients; rev 6 R6-1 supersedes
that claim. The actual dark posture keeps the server-first
production handshake on v20 while the v21 schema is reserved. This
is the same posture 2A took ("seam adoption that becomes
load-bearing in 2B"), and it means the arc must not stall between
2B-1 and 2B-3. Recorded here so a stall is visible as a decision
rather than inherited as a default.
### 0.2 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed
- **R3-1 (blocker).** Rev 3's three-boundary model was right but its
call-site table was wrong in five places, and each error was a real
@ -54,7 +135,7 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and
`cell.attachment.is_some()` rejection. It is now classified — and
**shared**, with the reasoning pinned.
### 0.1 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed
### 0.3 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed
- **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two
boundaries that must **diverge** once a panel exists. Several sites it
@ -81,7 +162,7 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and
- Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at
scale 1.0, and `TEXT_TOP` stays unscaled.
### 0.2 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed
### 0.4 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed
- **R1-1 (blocker).** Rev 1 said all 23 census reads route through
`primary_document_window`. That contradicts Q#BP14, which routes only
@ -128,9 +209,10 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and
| Byte pin `InstanceMessage::InitialTargetResult` | `pmacs-protocol/src/message.rs:1145` | Holds — still the enum's final variant |
| Byte pin `FrontendEvent::TerminalPointer` | final variant of its enum | Holds |
**Protocol is still v20** (`pmacs-protocol/src/message.rs:1568`); no
intervening PR bumped it. Q#BP9's conditional resolves: **Stage 2 is
v21**, no reservation was taken and none was needed.
**Protocol was still v20 at this re-scout**; no intervening PR had
bumped it. Q#BP9's conditional resolved: **Stage 2 reserves v21**.
Rev 6 R6-1 adds the server-first compatibility constraint discovered
during 2B-1 review.
Fifteen PRs merged between the parent's last re-scout (`47581f4`) and
this one: #149, #150, #152#155, #158#166. Nothing in the parent's
@ -322,13 +404,18 @@ undedicated (Q#BP2c). "It receives no new events" is insufficient — if
the daemon nevertheless places that frontend's window in a side panel
it cannot render, the window becomes invisible. The gate is on
placement, not only on transport. Parent acceptance 51 pins the mixed
session.
session. **The production daemon does not advertise v21 in 2B-1 or
2B-2.** Because `Hello` is server-first, 2B-3 must add or prove a
compatibility-preserving way to activate v21 before applying this rule;
merely advertising 21 would strand already-shipped v20 clients before
they can identify themselves.
## 4. Revisions to the parent framing
Only these; everything else stands.
- **Q#BP9 resolves to v21.**
- **Q#BP9 resolves to the v21 schema, with production advertisement
held at v20 until 2B-3 supplies compatible activation.**
- **Q#BP15a's epoch ownership is specified** by §3.1's table and API
split, replacing the parent's one-line "frontend-owned" statement.
- **Q#BP8's statusline criterion splits** per §3.3: one read reroutes,
@ -589,11 +676,20 @@ exactly once.
- **Section this serves:** `COHERENCE.md` §14, which records the panel
primitive as landed for Stage 1 and names "Stage 2 (GPU band)
pending its own framing" as the open item.
- **Which slice pays the coherence debt (rev 5).** The journey claim
above is Stage 2B-3's alone. 2A, 2B-1, and 2B-2 close **no** journey
divergence: with `panel_capable = false`, a GPU user still gets the
Stage 1 non-side fallback on steps 710 after all three land. Stated
explicitly so no slice's PR can claim the arc's coherence benefit
before the flip earns it — three quarters of this stage is
preparation, and only the last quarter is the improvement.
## 7. Acceptance
**Parent criteria 3755 remain authoritative and are not replaced.**
This section maps them to the two slices and adds only refinements.
This section maps them to the four slices — 2A, then 2B-1/2B-2/2B-3 —
and adds only refinements. A criterion that spans a slice boundary is
named in each slice it touches, with its half stated.
### 7.1 Stage 2A — classified census routing + painter extraction
@ -638,18 +734,83 @@ Refinements 2A adds:
scroll state**. Byte-identical cells alone would not catch a clamp
that silently moved to the wrong window.
### 7.2 Stage 2B — v21 protocol + daemon projection + GPU band
### 7.2 Stage 2B — v21 protocol, daemon projection, GPU band
Parent criteria that apply in full: **37, 38, 39, 40, 41, 45, 46, 47,
48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44, and
52** **through the actual negotiated capability flip** rather than
Stage 2B as a whole owns parent criteria **37, 38, 39, 40, 41, 45, 46,
47, 48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44,
and 52** **through the actual negotiated capability flip** rather than
through a test-only panel-capable semantic view. 52's 2B form is the
production one: a real semantic frontend with `fold_projection = false`
displaying a folded buffer in a panel shows every source line, and the
panel path never reaches `fold_map_for_window`.
Refinements 2B adds:
Per §0.0 R5-1 those land across three slices. Each slice's own gate run
is the standing suite plus §9's named acceptance suites; **only 2B-3
changes what a user sees.**
#### 7.2.1 Slice 2B-1 — the v21 wire layer
**Authority: `pmacs-protocol`.** The four wire shapes Q#BP9 names, the
version bump, and the shared cell-grid validator. No producer, no
consumer, no capability change.
- **37, in full.** `PanelFrame` round-trips including `panel_epoch` and
`geometry_epoch`, with independent byte pins on the previous final
`InstanceMessage::InitialTargetResult` and
`FrontendEvent::TerminalPointer` variants. **Both pins must be
falsified by revert**, not merely observed passing: a byte pin that
never saw the shift it exists to catch pins nothing.
- **39, the wire half only.** Shared cell/topology/glyph/area
validation; an area-bounded panel wider than 512 columns is accepted
while a terminal frame retains its 512-column PTY cap; the maximum
legal panel encoding stays below the transport limit. **The ratchet's
fixture must be shown to spend the whole aggregate glyph budget** —
otherwise it measures something smaller than the worst case and the
bound it proves is not the bound that matters. The worst case is
`1 × MAX_PANEL_VISIBLE_CELLS`, a legal panel geometry no terminal can
express, so the terminal's own ratchet has never covered it.
**39's receiver half — atomic rejection with retention of the
previous valid frame, and a duplicate doing no work — is 2B-2.**
- **The version ladder moves with the bump.** `PROTOCOL_VERSION`
becomes 21, `SUPPORTED_PROTOCOL_VERSIONS` accepts `6..=21` and
rejects 22, and any test whose *name* encodes the old number is
renamed. A ladder pin that passes across a bump was not pinning the
version. **`ADVERTISED_PROTOCOL_VERSION` remains 20 in 2B-1 and
2B-2** because the unsolicited `Hello` precedes any client version
signal. A real daemon must remain attachable by a client whose
supported range ends at 20.
- **Shared bounds are aliased, not duplicated.** Every constant the
terminal screen and the panel validator both enforce is one
definition with the other as an alias, so truncation and validation
cannot drift apart.
- **Not in this slice:** the daemon arm that drops panel events from a
grid session is exhaustiveness bookkeeping the bump forces, not
projection. It asserts only that a grid session's panel declaration
is dropped rather than trusted.
#### 7.2.2 Slice 2B-2 — the daemon panel projection and epoch machine
**Authority: `src/daemon.rs`.** Produces `PanelFrame`; derives the
grid; owns stale-event rejection. Exercised through a **test-only**
panel-capable semantic view — `panel_capable` stays `false` in
production negotiation until 2B-3.
- **38** (open → replace buffer → hidden by a tiny frame → reappear →
close, with authoritative `Absent` and a new epoch on
replacement/reappearance), **40** (first open at a non-80×24 frame
stays absent until real `FrontendCellGeometry` arrives, never
consulting the 24×80 attach placeholder), **49**, **50**, **51**,
**53**.
- **39's receiver half**, per §7.2.1.
- **41, the daemon half:** the daemon alone derives the grid; an older
retained frame neither paints nor accepts input after a new
`geometry_epoch` until a matching `Present` arrives; row-clamping
preserves the stored request; zero, non-finite, and non-positive
metric inputs fail closed to zero usable geometry. *The pixel→cell
formula and its call sites are 2B-3.*
- **42, 43, 44, 45, 52** in their projection form, through the
test-only panel-capable view. Their production re-assertion through
the real flip is 2B-3.
- **A2B-1.** The epoch state machine of §3.1 is pinned row by row,
including the lower-epoch-identical-data rejection and the
same-epoch-different-total rejection, and each row's
@ -660,7 +821,35 @@ Refinements 2B adds:
hides (a subsequent real resize must not paint a stale-geometry
panel), and a frontend that exhausts latches — a retained `Present`
whose epoch still matches cannot make the band reappear, and only a
fresh session clears the latch.
fresh session clears the latch. **A2B-1's grid-exhaustion half is
2B-2; its frontend-latch half needs a real frontend and is 2B-3.**
Both halves are named here so neither is lost at the seam.
#### 7.2.3 Slice 2B-3 — the GPU band and the capability flip
**Authority: `pmacs-gpu`, plus the compatibility-preserving negotiation
activation.** This is the only slice a user can observe, and the only
one that closes the journey divergence in §6. It must not advertise
v21 in the server-first `Hello` until an existing v20 client can still
attach.
- **46** (band + divider shrink the document text area by exactly their
pixel height; carets, hits, and scroll geometry respect the reduced
area), **47** (divider drag, `window.min-height`, `RowResize` hover,
and the stalled-writer tail-coalescing), **48** (`PanelPointer`
driving selection, terminal mouse reporting, and click-to-focus
without disturbing the document mirror), **54** (the
`--headless-probe` run: one real daemon, real PTY, real wgpu, through
a panel-hosted terminal), **55**.
- **41, the GPU half:** the pixel→cell conversion pinned at fractional
widths and heights, and geometry refresh on window resize, font
change, and scale change.
- **42, 43, 44, 45, 52 re-asserted through the production flip**, not
the test-only view. This is the point of the re-assertion: a
test-only panel-capable view can be constructed wrongly and agree
with itself, so the production negotiation path must carry the same
assertions.
- **A2B-1's frontend-latch half**, per §7.2.2.
- **A2B-2.** A font or scale change that leaves `CellSize` **identical**
still produces a new `geometry_epoch`, and the older `PanelFrame`
neither paints nor hit-tests until a matching `Present` arrives. This
@ -688,7 +877,10 @@ Refinements 2B adds:
its chrome.
- **A2B-5.** `panel_capable` is true only for a v21+ negotiated
authenticated semantic session; a v20 semantic session is never
**placed** in a side window, not merely denied the events.
**placed** in a side window, not merely denied the events. The same
acceptance must attach an actual v20 client to the production daemon
after v21 activation, so the new path cannot pass by breaking the old
handshake before placement is evaluated.
## 8. Open items
@ -704,26 +896,54 @@ fixes. It belongs to a spacing-system change of its own.
## 9. Slices, branches, and gates
Per review round 1: **two serial implementation PRs**, each a named
slice under this framing so one-feature/one-branch/one-PR holds. **2A
lands before 2B branches** — not stacked.
Per review round 1 and §0.0 R5-1: **four serial implementation PRs**,
each a named slice under this framing so one-feature/one-branch/one-PR
holds. **Each slice lands before the next branches** — none are
stacked, and each is cut from `main`.
- **Stage 2A** — classified census routing + per-window painter
extraction. Branch `bottom-panel-stage2a`. No protocol change. The
three-boundary GPU split is **2B**, not 2A: it is only observable
once a band can be installed.
- **Stage 2B** — v21 protocol, daemon panel projection, GPU band, and
the negotiated `panel_capable` flip. Branch `bottom-panel-stage2b`,
cut from `main` after 2A merges. Repeats 2A's relevant census
- **Stage 2A — MERGED as #177** (`main` @ `0a3fcd1`). Classified census
routing + per-window painter extraction. Branch
`bottom-panel-stage2a`. No protocol change. The three-boundary GPU
split is **2B-3**, not 2A: it is only observable once a band can be
installed.
- **Stage 2B-1 — the v21 wire layer.** Branch `bottom-panel-stage2b`.
The four wire shapes, the version bump, the shared cell-grid
validator, and the version-ladder move. The v21 schema is reserved
while the production daemon continues advertising v20. **No
producer, no consumer, no capability change** — `panel_capable`
stays `false`.
- **Stage 2B-2 — the daemon panel projection and epoch machine.** Cut
from `main` after 2B-1 merges. Produces `PanelFrame` and owns
stale-event rejection, exercised through a **test-only**
panel-capable semantic view. Still no production flip.
- **Stage 2B-3 — the GPU band and the negotiated flip.** Cut from
`main` after 2B-2 merges. The three-boundary text-area split, the
divider, pointer routing, the compatibility-preserving v21
activation, and `panel_capable = true` for a v21+ negotiated
authenticated semantic session. **This is the slice that changes
what a user sees**, and it repeats 2A's and 2B-2's relevant
assertions through the real capability flip.
Gates for both: the standing suite from `CLAUDE.md`, plus the **touched
**Each slice runs the full gate set below, not a subset of it.** A
slice that touches only `pmacs-protocol` still runs the GPU and vterm
suites: the shared validator and the wire enums are exactly the kind of
change whose breakage surfaces in a consumer rather than at its own
definition.
Gates for each slice: the standing suite from `CLAUDE.md`, plus the **touched
acceptance suites named explicitly** — the standing rule is to run the
suites a change touches, and "standing suite" does not name them:
- `bottom_panel_stage1_acceptance` — the substrate both slices build on.
- `bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance`
— new, one per slice.
- `bottom_panel_stage1_acceptance` — the substrate all four Stage 2
slices build on.
- `bottom_panel_stage2a_acceptance` — Stage 2A's classified census and
painter extraction.
- `bottom_panel_stage2b_protocol_acceptance` — Stage 2B-1's v21 schema,
server-first v20 compatibility, byte pins, and shared validation.
- `bottom_panel_stage2b_daemon_acceptance` — the exact suite name
reserved for Stage 2B-2's projection and epoch machine.
- `bottom_panel_stage2b_gpu_acceptance` — the exact suite name reserved
for Stage 2B-3's band, compatible activation, and capability flip.
- `statusline_segments_acceptance` — the fan-out target change (§3.3).
- `m11_5_semantic_acceptance` — the semantic census (§3.2).
- `gpu_initial_target_acceptance` — parent criterion 55.

View File

@ -779,11 +779,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(std::time::Duration::from_millis);
// Normal probes stop only after their fixture-specific evidence arrives.
// A producer fixture names the text it must paint; an input fixture uses
// the latched echo observation. Keeping that choice outside this generic
// runner prevents one fixture's breadcrumb from forcing another fixture
// to sit on the 20-second safety deadline.
let expected_frame_text = std::env::var("PMACS_GPU_PROBE_EXPECT_TEXT")
.ok()
.filter(|value| !value.is_empty());
let quiet = observe_window.is_some();
let deadline = std::time::Instant::now()
+ observe_window.unwrap_or_else(|| std::time::Duration::from_secs(20));
let mut sent_input = false;
let mut sent_resize = false;
let mut completion_observed = false;
while std::time::Instant::now() < deadline {
let Ok(event) = rx.recv_timeout(std::time::Duration::from_millis(200)) else {
continue;
@ -857,7 +866,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
facts.observed_resized_frame = true;
}
}
if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 {
let fixture_evidence_observed = expected_frame_text.as_deref().map_or_else(
|| facts.input_echo_observed,
|expected| facts.last_frame_text.contains(expected),
);
// Do not exit merely because resize/composition happened
// first: that races the fixture's required PTY evidence and
// produces a self-contradictory "successful" probe report
// whose later acceptance assertion must reject it.
if !quiet
&& facts.observed_resized_frame
&& facts.rendered_nonuniform_frames >= 2
&& fixture_evidence_observed
{
completion_observed = true;
break;
}
}
@ -890,6 +912,7 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
let _ = writeln!(out, "last_title={}", facts.last_title.unwrap_or_default());
let _ = writeln!(out, "last_frame_text={}", facts.last_frame_text);
let _ = writeln!(out, "input_echo_observed={}", facts.input_echo_observed);
let _ = writeln!(out, "completion_observed={completion_observed}");
let _ = writeln!(out, "disconnect={}", facts.disconnect.unwrap_or_default());
if let Err(error) = std::fs::write(report, out) {
eprintln!(
@ -8979,6 +9002,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments",
InstanceMessage::TerminalFrame(_) => "TerminalFrame",
InstanceMessage::InitialTargetResult(_) => "InitialTargetResult",
InstanceMessage::PanelFrame(_) => "PanelFrame",
}
}

View File

@ -40,8 +40,10 @@ pub mod cell;
pub mod crdt;
pub mod ids;
pub mod message;
pub mod panel;
pub mod terminal;
pub mod transport;
pub mod wire_grid;
/// Logical display columns between fixed buffer-text tab stops.
///
@ -55,21 +57,27 @@ pub use cell::{
pub use crdt::CrdtOp;
pub use ids::{BufferId, ByteRange, FrontendId, Position};
pub use message::{
AdornmentContent, AdornmentPlacement, AttachRequest, BUILTIN_PAIR_CHARS, BlockAdornment,
CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment,
FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InitialTarget, InitialTargetResult,
InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key,
KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES,
MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS,
MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers,
MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind,
ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest,
StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char,
is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
ADVERTISED_PROTOCOL_VERSION, AdornmentContent, AdornmentPlacement, AttachRequest,
BUILTIN_PAIR_CHARS, BlockAdornment, CompletionPopupRow, CursorState, Decoration,
DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello,
InitialTarget, InitialTargetResult, InlineAdornment, InstanceCapabilities, InstanceIdentity,
InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES,
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace,
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
negotiate_capabilities,
};
pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload};
pub use terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,
TerminalFrameError, TerminalProcessState, TerminalSelectionSpan,
};
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};
pub use wire_grid::{
MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES, WireGridError, WireGridLimits,
checked_area, validate_wire_grid,
};

View File

@ -442,6 +442,77 @@ pub enum FrontendEvent {
/// Modifiers held during the gesture.
mods: Modifiers,
},
/// Bottom panel Stage 2 (protocol v21): the frontend's authoritative
/// cell-equivalent layout capacity (Q#BP15a).
///
/// Valid **without** a side window — the daemon needs columns before
/// it can paint a first panel frame, so gating this on panel
/// presence would deadlock the first open. "Without" refers to
/// side-window presence only; the protocol and session gates still
/// apply, and the event is accepted only from an authenticated,
/// negotiated panel-capable semantic session.
///
/// Sent immediately after attach acceptance and refreshed on window
/// resize, font change, and scale change. `geometry_epoch` is
/// frontend-owned because a font or scale change can invalidate an
/// old panel frame while `total` is **identical**, which daemon-side
/// value dedup cannot detect.
FrontendCellGeometry {
/// Which frontend declared this (untrusted; checked against the
/// transport source).
frontend_id: FrontendId,
/// Monotonic frontend-owned declaration id; `0` is reserved for
/// "never declared" and is rejected on the wire.
geometry_epoch: u64,
/// Whole-cell capacity of the frontend's frame.
total: CellSize,
},
/// Bottom panel Stage 2 (protocol v21): requested fixed panel rows
/// from a divider drag (Q#BP15a).
///
/// Rows are the only size component; the epochs are identities, not
/// geometry. Accepted only for the currently visible `Present` panel
/// matching both the latest geometry declaration and the current
/// presentation epoch, then clamped by Q#BP2's interactive
/// preference.
PanelResizeRows {
/// Which frontend produced the drag (untrusted, as above).
frontend_id: FrontendId,
/// Geometry declaration this request is measured against.
geometry_epoch: u64,
/// Presentation identity this request addresses.
panel_epoch: u64,
/// Requested fixed panel rows.
rows: u32,
},
/// Bottom panel Stage 2 (protocol v21): a pointer gesture a semantic
/// frontend hit-tested to a panel CELL (Q#BP16).
///
/// Carries both epochs so a gesture aimed at a panel that has since
/// been replaced or reopened cannot be applied to its successor.
/// Unlike [`Self::Pointer`], accepting this **activates the panel**.
///
/// `buffer_id` and `panel_epoch` close different holes and neither
/// subsumes the other: `buffer_id` catches an A→B buffer
/// replacement, while `panel_epoch` catches close/hide/reopen of the
/// **same** persistent buffer — which a buffer id alone cannot
/// distinguish — without putting a `WindowId` on the wire.
PanelPointer {
/// Which frontend produced the gesture (untrusted, as above).
frontend_id: FrontendId,
/// Geometry declaration this gesture was hit-tested against.
geometry_epoch: u64,
/// Presentation identity this gesture addresses.
panel_epoch: u64,
/// Buffer the frontend believed the panel was displaying.
buffer_id: crate::BufferId,
/// Cell the pointer is over, within the declared panel grid.
coord: CellCoord,
/// Which gesture step this is.
kind: MouseKind,
/// Modifiers held during the gesture.
mods: Modifiers,
},
}
/// Gesture step for [`FrontendEvent::Pointer`]. Double-click
@ -488,7 +559,10 @@ impl FrontendEvent {
| Self::Pointer { frontend_id, .. }
| Self::MenuPointer { frontend_id, .. }
| Self::TerminalResize { frontend_id, .. }
| Self::TerminalPointer { frontend_id, .. } => *frontend_id,
| Self::TerminalPointer { frontend_id, .. }
| Self::FrontendCellGeometry { frontend_id, .. }
| Self::PanelResizeRows { frontend_id, .. }
| Self::PanelPointer { frontend_id, .. } => *frontend_id,
}
}
}
@ -1143,6 +1217,20 @@ pub enum InstanceMessage {
/// Appended after [`Self::TerminalFrame`], the final v19 variant, so no
/// legacy postcard discriminant moves.
InitialTargetResult(InitialTargetResult),
/// Bottom panel Stage 2 (protocol v21): the daemon's painted
/// projection of one side window, or its authoritative absence
/// (Q#BP15).
///
/// `Absent` is sent on close **and** on hide: the receiver retains
/// its last valid frame, so silence would leave a stale band on
/// screen indefinitely. `Absent` is duplicate-suppressed like any
/// payload, and applying it clears the last declared panel size and
/// presentation epoch before any later event can validate against
/// them.
///
/// Appended after [`Self::InitialTargetResult`], the final v20
/// variant, so no existing postcard discriminant moves.
PanelFrame(crate::panel::PanelFramePayload),
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1565,7 +1653,27 @@ pub enum ResourceBody {
/// handshake extension is read only from v20 semantic sessions; the result is
/// sent only when such a session requested a target. v6v19 handshakes and
/// message discriminants remain unchanged.
pub const PROTOCOL_VERSION: u32 = 20;
///
/// Bottom panel Stage 2 (Q#BP9): bumped 20 → 21 for
/// [`InstanceMessage::PanelFrame`] and
/// [`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`].
/// All four are appended after their enum's previous final variant, so
/// no v6v20 discriminant moves and the encoding of every existing
/// message is byte-identical. The new traffic is gated in both
/// directions: a v20 peer neither receives `PanelFrame` nor is placed in
/// a side window, because denying only the events would leave its
/// window invisible.
pub const PROTOCOL_VERSION: u32 = 21;
/// Protocol version placed in the daemon's server-first [`Hello`].
///
/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but
/// production attachment remains on v20 until the Stage 2B-3 capability
/// activation can preserve compatibility with existing v20 frontends.
/// Those frontends reject an unknown server-first version before they can
/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would
/// make the otherwise-dark protocol slice user-visible.
pub const ADVERTISED_PROTOCOL_VERSION: u32 = 20;
/// 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
@ -1643,8 +1751,15 @@ pub const PROTOCOL_VERSION: u32 = 20;
/// GPU initial target (Q#GT4): extended to `[6, ..., 20]`. v20 semantic
/// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy
/// and non-semantic sessions retain their existing handshake shape.
///
/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. Stage 2B-1
/// reserves and validates the v21 wire while production daemons continue
/// to send [`ADVERTISED_PROTOCOL_VERSION`] in their server-first
/// [`Hello`]. The later capability-activation slice owns moving production
/// negotiation to v21 without making existing v20 frontends reject the
/// handshake.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] =
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
@ -2024,7 +2139,10 @@ pub fn negotiate_capabilities(
/// frontend will use as the `FrontendId` on every event it sends.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Hello {
/// The instance's `PROTOCOL_VERSION`.
/// The protocol version this attachment should use.
///
/// This can deliberately trail [`PROTOCOL_VERSION`] while an additive
/// wire family is reserved but not yet activated in production.
pub protocol_version: u32,
/// `FrontendId` assigned to this attachment by the instance. The
/// frontend stamps this onto subsequent events. v0.1 daemons start

213
pmacs-protocol/src/panel.rs Normal file
View File

@ -0,0 +1,213 @@
//! Bottom-panel wire types (Q#BP15, Q#BP15a, Q#BP16).
//!
//! A panel frame is the daemon's painted projection of one side window.
//! It shares [`crate::wire_grid`]'s cell rules with
//! [`crate::terminal::TerminalFrame`] but not its per-axis PTY caps: a
//! 4K surface at a small font is legitimately wider than 512 columns,
//! and the area bound is what keeps the encoding inside the transport
//! budget.
//!
//! Presence is explicit. [`PanelFramePayload::Absent`] is authoritative
//! and must be sent on close *and* on hide, because the receiver
//! retains its last valid frame: silence would leave a stale band on
//! screen indefinitely.
use crate::cell::{Cell, CellCoord, CellSize};
use crate::ids::BufferId;
use crate::wire_grid::{
MAX_WIRE_GRID_GLYPH_BYTES, WireGridError, WireGridLimits, validate_wire_grid,
};
/// Shared visible-cell ceiling for a panel grid.
///
/// Identical to the terminal bound: it is the transport-safety limit,
/// not a PTY policy, so both messages answer to it.
pub const MAX_PANEL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS;
/// Bounds a panel frame enforces on its cell grid.
///
/// The per-axis ceilings are the area bound itself rather than 512: any
/// axis larger than the area bound is already rejected by the area
/// check, so this expresses "no independent per-axis policy" without
/// leaving the multiplication unchecked.
const PANEL_GRID_LIMITS: WireGridLimits = WireGridLimits {
max_rows: MAX_PANEL_VISIBLE_CELLS as u32,
max_cols: MAX_PANEL_VISIBLE_CELLS as u32,
max_visible_cells: MAX_PANEL_VISIBLE_CELLS,
max_glyph_bytes: MAX_WIRE_GRID_GLYPH_BYTES,
};
/// The daemon's painted projection of one side window.
///
/// `panel_epoch` is opaque and monotonic per frontend: stable across
/// ordinary frames of one continuously present window/buffer, and
/// changed on buffer replacement, new side-window creation, and every
/// `Absent` → `Present` transition. That is what stops a stale
/// `PanelPointer` from addressing a reopened panel as if it were the
/// old one (Q#BP16).
///
/// `geometry_epoch` answers a *frontend* declaration and moves whenever
/// the frontend declares new effective cell geometry — including a font
/// or scale change that leaves [`CellSize`] identical, which is exactly
/// the case daemon-side value dedup cannot see (Q#BP2S1).
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PanelFrame {
/// Buffer this frame projects.
pub buffer_id: BufferId,
/// Presentation identity, monotonic per frontend.
pub panel_epoch: u64,
/// The frontend geometry declaration this frame answers.
pub geometry_epoch: u64,
/// Panel grid dimensions in cells.
pub size: CellSize,
/// Row-major cells; exactly `size.area()` entries.
pub cells: Vec<Cell>,
/// Panel caret, or `None` when the panel shows no cursor.
///
/// `paint_frame` returns the cursor separately from the cells, so a
/// frame carrying cells alone would lose the caret.
pub cursor: Option<CellCoord>,
/// Whether the panel owns focus.
///
/// Presentation and focus-chrome routing only (Q#BP14b) — the
/// *keys* decision is `DispatchIdle` (Q#BP14a).
pub focused: bool,
}
/// Explicit panel presence.
///
/// `Absent` is authoritative rather than implied by silence, and is
/// duplicate-suppressed like any other payload.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum PanelFramePayload {
/// A panel is visible and this is its current frame.
Present(PanelFrame),
/// No panel is visible; clear any retained frame.
Absent,
}
/// Why a [`PanelFrame`] is not structurally valid.
///
/// Validation is atomic: the frame is rejected whole and the receiver
/// retains its previous valid frame.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum PanelFrameError {
/// Rows or columns are zero or above the area-derived bounds.
#[error("panel size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")]
Size {
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
/// Row bound in force.
max_rows: u32,
/// Column bound in force.
max_cols: u32,
},
/// The checked area exceeds the shared visible-cell bound.
#[error("panel area {area} exceeds the visible-cell bound {max}")]
Area {
/// Checked `rows * cols`.
area: usize,
/// Shared visible-cell bound.
max: usize,
},
/// `cells.len()` disagrees with the declared area.
#[error("panel frame carries {actual} cells for a {expected}-cell area")]
CellCount {
/// Declared area.
expected: usize,
/// Supplied cell count.
actual: usize,
},
/// The cursor lies outside the declared grid.
#[error("panel cursor ({row},{col}) is outside the {rows}x{cols} grid")]
Cursor {
/// Cursor row.
row: u32,
/// Cursor column.
col: u32,
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
},
/// A cell's glyph is not a legal wire glyph.
#[error("panel cell {index} has an invalid glyph: {reason}")]
Glyph {
/// Row-major cell index.
index: usize,
/// Why the glyph failed.
reason: &'static str,
},
/// A cell carries a frontend attachment, which panels never use.
#[error("panel cell {index} carries an attachment")]
Attachment {
/// Row-major cell index.
index: usize,
},
/// Aggregate glyph bytes exceed the shared budget.
#[error("panel frame glyph bytes exceed the aggregate bound {max}")]
GlyphBudget {
/// Shared aggregate bound.
max: usize,
},
/// An epoch is zero, which is reserved for "never declared".
#[error("panel {field} epoch is zero, which is reserved for 'never declared'")]
ZeroEpoch {
/// Which epoch was zero.
field: &'static str,
},
}
impl PanelFrame {
/// Check every structural rule a panel frame must satisfy.
///
/// Pure: a rejected frame mutates nothing, so callers get atomic
/// rejection for free.
pub fn validate(&self) -> Result<(), PanelFrameError> {
if self.panel_epoch == 0 {
return Err(PanelFrameError::ZeroEpoch { field: "panel" });
}
if self.geometry_epoch == 0 {
return Err(PanelFrameError::ZeroEpoch { field: "geometry" });
}
validate_wire_grid(self.size, &self.cells, self.cursor, PANEL_GRID_LIMITS)
.map_err(panel_grid_error)
}
}
/// Map a shared wire-grid failure onto this message's error type.
fn panel_grid_error(error: WireGridError) -> PanelFrameError {
match error {
WireGridError::Size {
rows,
cols,
max_rows,
max_cols,
} => PanelFrameError::Size {
rows,
cols,
max_rows,
max_cols,
},
WireGridError::Area { area, max } => PanelFrameError::Area { area, max },
WireGridError::CellCount { expected, actual } => {
PanelFrameError::CellCount { expected, actual }
}
WireGridError::Cursor {
row,
col,
rows,
cols,
} => PanelFrameError::Cursor {
row,
col,
rows,
cols,
},
WireGridError::Glyph { index, reason } => PanelFrameError::Glyph { index, reason },
WireGridError::Attachment { index } => PanelFrameError::Attachment { index },
WireGridError::GlyphBudget { max } => PanelFrameError::GlyphBudget { max },
}
}

View File

@ -12,13 +12,18 @@
//! that single structural policy. A second implementation of these rules
//! in a frontend is a bug, not a convenience.
//!
//! This module owns the crate's only `unicode-width` use: glyph column
//! width and wide-continuation topology cannot be checked without it.
//! Glyph column width and wide-continuation topology moved to
//! [`crate::wire_grid`] in bottom-panel Stage 2B, which is now the
//! crate's only non-test `unicode-width` use: those rules are shared
//! with [`crate::panel::PanelFrame`]. The 512 per-axis PTY caps,
//! metadata, selection spans, and the `at_bottom`/`scroll_offset`
//! coupling stay here, because a panel does not inherit them.
use crate::cell::{Cell, CellCoord, CellSize, Glyph};
use crate::cell::{Cell, CellCoord, CellSize};
use crate::ids::BufferId;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[cfg(test)]
use unicode_width::UnicodeWidthStr;
// ---------------------------------------------------------------------------
// Shared limits
@ -32,10 +37,20 @@ pub const MAX_TERMINAL_COLS: u16 = 512;
/// Maximum visible terminal cells accepted at creation, resize, or on
/// the wire.
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144;
///
/// An alias of the shared wire-grid bound: this is transport safety, not
/// a PTY policy, so it must not drift from the panel's.
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS;
/// Maximum UTF-8 bytes retained in one terminal grapheme cluster.
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256;
///
/// An **alias** of the shared wire-grid bound, not an independent value.
/// The terminal screen truncates clusters to this constant while
/// [`crate::wire_grid`] validates against its own; if the two were
/// separate literals, raising one would make the producer emit clusters
/// its own validator rejects — or, worse, accept clusters no frontend
/// budgeted for. Keeping this a re-export means they cannot drift.
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES;
/// Shared cap for terminal title and process-outcome metadata.
pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;
@ -51,7 +66,7 @@ pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;
/// protocol test `maximum_legal_terminal_frame_encodes_below_the_transport_cap`
/// measures the largest legal frame this bound admits and pins it below
/// the unchanged 16 MiB cap.
pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GLYPH_BYTES;
// ---------------------------------------------------------------------------
// Payload types
@ -217,6 +232,58 @@ pub enum TerminalFrameError {
},
}
/// Bounds a terminal frame enforces on its cell grid.
///
/// The per-axis caps are the PTY-specific half of the split: a panel
/// frame shares every other rule but not these, because a panel is
/// sized by the frontend's surface rather than by a pty window size.
const TERMINAL_GRID_LIMITS: crate::wire_grid::WireGridLimits = crate::wire_grid::WireGridLimits {
max_rows: MAX_TERMINAL_ROWS as u32,
max_cols: MAX_TERMINAL_COLS as u32,
max_visible_cells: MAX_TERMINAL_VISIBLE_CELLS,
max_glyph_bytes: MAX_TERMINAL_FRAME_GLYPH_BYTES,
};
/// Map a shared wire-grid failure onto this message's error type.
///
/// The variants and their text are unchanged by the Stage 2B factoring:
/// every existing terminal-frame assertion still observes exactly what
/// it observed before.
fn terminal_grid_error(error: crate::wire_grid::WireGridError) -> TerminalFrameError {
use crate::wire_grid::WireGridError;
match error {
WireGridError::Size {
rows,
cols,
max_rows,
max_cols,
} => TerminalFrameError::Size {
rows,
cols,
max_rows,
max_cols,
},
WireGridError::Area { area, max } => TerminalFrameError::Area { area, max },
WireGridError::CellCount { expected, actual } => {
TerminalFrameError::CellCount { expected, actual }
}
WireGridError::Cursor {
row,
col,
rows,
cols,
} => TerminalFrameError::Cursor {
row,
col,
rows,
cols,
},
WireGridError::Glyph { index, reason } => TerminalFrameError::Glyph { index, reason },
WireGridError::Attachment { index } => TerminalFrameError::Attachment { index },
WireGridError::GlyphBudget { max } => TerminalFrameError::GlyphBudget { max },
}
}
impl TerminalFrame {
/// Check every structural rule a terminal frame must satisfy.
///
@ -224,23 +291,13 @@ impl TerminalFrame {
/// by a frontend after decode. It is pure: a rejected frame mutates
/// nothing, so callers get atomic rejection for free.
pub fn validate(&self) -> Result<(), TerminalFrameError> {
let area = self.checked_area()?;
if self.cells.len() != area {
return Err(TerminalFrameError::CellCount {
expected: area,
actual: self.cells.len(),
});
}
if let Some(cursor) = self.cursor
&& (cursor.row >= self.size.rows || cursor.col >= self.size.cols)
{
return Err(TerminalFrameError::Cursor {
row: cursor.row,
col: cursor.col,
rows: self.size.rows,
cols: self.size.cols,
});
}
crate::wire_grid::validate_wire_grid(
self.size,
&self.cells,
self.cursor,
TERMINAL_GRID_LIMITS,
)
.map_err(terminal_grid_error)?;
if let Some(title) = &self.title {
validate_metadata("title", title)?;
}
@ -249,7 +306,6 @@ impl TerminalFrame {
TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?,
TerminalProcessState::Running | TerminalProcessState::Exited(_) => {}
}
self.validate_cells()?;
self.validate_selection()?;
if self.at_bottom != (self.scroll_offset == 0) {
return Err(TerminalFrameError::BottomState {
@ -260,107 +316,6 @@ impl TerminalFrame {
Ok(())
}
/// Declared cell area, checked against both shared bounds.
fn checked_area(&self) -> Result<usize, TerminalFrameError> {
let rows = self.size.rows;
let cols = self.size.cols;
if rows == 0
|| cols == 0
|| rows > u32::from(MAX_TERMINAL_ROWS)
|| cols > u32::from(MAX_TERMINAL_COLS)
{
return Err(TerminalFrameError::Size {
rows,
cols,
max_rows: u32::from(MAX_TERMINAL_ROWS),
max_cols: u32::from(MAX_TERMINAL_COLS),
});
}
// Both factors are bounded above by 512, so the product cannot
// overflow; `checked_mul` keeps that an assertion rather than an
// assumption a later bound change could quietly break.
let area = rows
.checked_mul(cols)
.and_then(|area| usize::try_from(area).ok())
.ok_or(TerminalFrameError::Area {
area: usize::MAX,
max: MAX_TERMINAL_VISIBLE_CELLS,
})?;
if area > MAX_TERMINAL_VISIBLE_CELLS {
return Err(TerminalFrameError::Area {
area,
max: MAX_TERMINAL_VISIBLE_CELLS,
});
}
Ok(area)
}
/// Glyph legality, wide-continuation topology, and the glyph budget.
fn validate_cells(&self) -> Result<(), TerminalFrameError> {
let cols = self.size.cols as usize;
let mut glyph_bytes = 0usize;
// Columns still owed to the preceding wide lead on this row.
let mut pending_continuation = false;
for (index, cell) in self.cells.iter().enumerate() {
if cell.attachment.is_some() {
return Err(TerminalFrameError::Attachment { index });
}
let col = index % cols;
if col == 0 && pending_continuation {
// A wide lead in the final column would have to be
// completed on the next row, which is not a footprint a
// terminal grid can express.
return Err(TerminalFrameError::Glyph {
index: index - 1,
reason: "wide glyph has no continuation column on its row",
});
}
match &cell.glyph {
Glyph::Continuation => {
if !pending_continuation {
return Err(TerminalFrameError::Glyph {
index,
reason: "continuation without a preceding wide glyph",
});
}
pending_continuation = false;
}
Glyph::Char(ch) => {
if pending_continuation {
return Err(TerminalFrameError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = char_display_width(*ch).ok_or(TerminalFrameError::Glyph {
index,
reason: "glyph is a control or zero-width character",
})?;
glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8())?;
pending_continuation = width == 2;
}
Glyph::Cluster(bytes) => {
if pending_continuation {
return Err(TerminalFrameError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = cluster_display_width(bytes, index)?;
glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len())?;
pending_continuation = width == 2;
}
}
}
if pending_continuation {
return Err(TerminalFrameError::Glyph {
index: self.cells.len() - 1,
reason: "wide glyph has no continuation column on its row",
});
}
Ok(())
}
/// One nonempty in-bounds span per row, strictly increasing by row.
fn validate_selection(&self) -> Result<(), TerminalFrameError> {
let mut previous_row: Option<u32> = None;
@ -395,73 +350,6 @@ impl TerminalFrame {
}
}
/// Column width of a leading `Char` glyph, or `None` when it cannot lead.
fn char_display_width(ch: char) -> Option<usize> {
if ch.is_control() {
return None;
}
match UnicodeWidthChar::width(ch) {
Some(1) => Some(1),
Some(2) => Some(2),
_ => None,
}
}
/// Column width of a leading `Cluster` glyph.
///
/// Width is clamped into `1..=2` exactly as the terminal screen clamps it
/// when it writes the cluster: a base plus combining marks may measure
/// wider than two columns, and the screen occupies two. Clamping in one
/// place and measuring in another is how a frame that renders correctly
/// gets rejected on the wire.
fn cluster_display_width(bytes: &[u8], index: usize) -> Result<usize, TerminalFrameError> {
if bytes.is_empty() {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster is empty",
});
}
if bytes.len() > MAX_TERMINAL_GRAPHEME_BYTES {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster exceeds the per-cluster byte limit",
});
}
let text = std::str::from_utf8(bytes).map_err(|_| TerminalFrameError::Glyph {
index,
reason: "cluster is not valid UTF-8",
})?;
if text.chars().any(char::is_control) {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster carries a control character",
});
}
let width = UnicodeWidthStr::width(text);
if width == 0 {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster occupies no columns",
});
}
Ok(width.min(2))
}
/// Accumulate glyph bytes under the aggregate bound with checked addition.
fn add_glyph_bytes(total: usize, add: usize) -> Result<usize, TerminalFrameError> {
let next = total
.checked_add(add)
.ok_or(TerminalFrameError::GlyphBudget {
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
})?;
if next > MAX_TERMINAL_FRAME_GLYPH_BYTES {
return Err(TerminalFrameError::GlyphBudget {
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
});
}
Ok(next)
}
/// Length and control-character rules shared by title and process text.
fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFrameError> {
if text.len() > MAX_TERMINAL_METADATA_BYTES {
@ -482,7 +370,10 @@ fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFram
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::{Color, Style, UnderlineStyle};
// `Glyph` is no longer used by this module's production code — the
// glyph rules moved to `crate::wire_grid` — but these tests still
// construct frames cell by cell.
use crate::cell::{Color, Glyph, Style, UnderlineStyle};
use crate::message::InstanceMessage;
use crate::transport::MAX_FRAME_BYTES;
@ -952,14 +843,14 @@ mod tests {
let mut over = exact.clone();
// One more byte of glyph, nothing else changed.
let last = over.cells.len() - 1;
over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(3).into_boxed_slice()));
over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(2).into_boxed_slice()));
(exact, over)
}
#[test]
fn maximum_legal_terminal_frame_encodes_below_the_transport_cap() {
let (exact, _) = budget_boundary_frames();
let (exact, over) = budget_boundary_frames();
assert_eq!(exact.validate(), Ok(()));
let mut glyph_bytes = 0usize;
@ -974,6 +865,20 @@ mod tests {
glyph_bytes, MAX_TERMINAL_FRAME_GLYPH_BYTES,
"the measured fixture must spend the whole aggregate budget"
);
let over_glyph_bytes = over
.cells
.iter()
.map(|cell| match &cell.glyph {
Glyph::Char(ch) => ch.len_utf8(),
Glyph::Cluster(bytes) => bytes.len(),
Glyph::Continuation => 0,
})
.sum::<usize>();
assert_eq!(
over_glyph_bytes,
MAX_TERMINAL_FRAME_GLYPH_BYTES + 1,
"the rejecting twin must be exactly one byte over the aggregate budget"
);
let msg = InstanceMessage::TerminalFrame(exact);
let bytes = postcard::to_allocvec(&msg).expect("encode");

View File

@ -0,0 +1,329 @@
//! Shared cell-grid validation for every wire message that carries a
//! rectangular grid of [`Cell`]s.
//!
//! Bottom-panel Stage 2B (Q#BP15) factors this out of
//! [`crate::terminal`], which was the only such message until
//! [`crate::panel::PanelFrame`] arrived. The split follows the boundary
//! the framing names:
//!
//! - **Shared** — the checked area, the visible-cell bound, the cell
//! count, cursor bounds, glyph legality, wide-continuation topology,
//! the aggregate glyph-byte budget, and the attachment rejection.
//! - **Terminal-only** — the 512 per-axis PTY caps, title/process
//! metadata, selection spans, and the `at_bottom == (scroll_offset ==
//! 0)` coupling.
//!
//! The per-axis caps are a [`WireGridLimits`] parameter rather than a
//! constant precisely because a panel does not inherit them: a 4K
//! surface at a small font is legitimately wider than 512 columns, and
//! the area bound is what keeps the encoding inside the transport
//! budget.
//!
//! The attachment rejection is deliberately **shared**, not
//! terminal-only, even though its terminal-side message reads "which
//! terminals never use". Panels render no attachments either, so
//! rejecting them here fails closed for both; classifying it as
//! terminal-only would let a panel ship a cell no frontend can paint.
use crate::cell::{Cell, CellCoord, CellSize, Glyph};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// Aggregate glyph-byte ceiling shared by every wire grid.
///
/// A grid at the visible-cell bound where every cell carries a maximum
/// cluster would exceed the transport frame limit; this keeps the
/// encoded size bounded independently of the per-cell rule.
pub const MAX_WIRE_GRID_GLYPH_BYTES: usize = 8 * 1024 * 1024;
/// Per-cell grapheme-cluster byte ceiling shared by every wire grid.
pub const MAX_WIRE_GRID_GRAPHEME_BYTES: usize = 256;
/// Visible-cell ceiling shared by every wire grid.
///
/// This is the transport-safety bound, not a per-message policy: it is
/// what keeps `rows * cols * per-cell` inside the transport frame limit,
/// so both the terminal and the panel answer to it even though they
/// carry different per-axis caps.
pub const MAX_WIRE_GRID_VISIBLE_CELLS: usize = 262_144;
/// Bounds a particular wire grid enforces.
///
/// `max_rows` / `max_cols` are per-message policy. `max_visible_cells`
/// is the shared area bound and is what actually keeps the encoding
/// inside the transport budget.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct WireGridLimits {
/// Inclusive row ceiling.
pub max_rows: u32,
/// Inclusive column ceiling.
pub max_cols: u32,
/// Inclusive `rows * cols` ceiling.
pub max_visible_cells: usize,
/// Inclusive aggregate glyph-byte ceiling.
pub max_glyph_bytes: usize,
}
/// Why a wire grid is not structurally valid.
///
/// Callers map these onto their own message-specific error types so
/// existing wire errors keep their exact variants and text.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum WireGridError {
/// Rows or columns are zero or above this grid's bounds.
Size {
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
/// Row bound in force.
max_rows: u32,
/// Column bound in force.
max_cols: u32,
},
/// The checked area exceeds the visible-cell bound.
Area {
/// Checked `rows * cols`.
area: usize,
/// Bound in force.
max: usize,
},
/// `cells.len()` disagrees with the declared area.
CellCount {
/// Declared area.
expected: usize,
/// Supplied cell count.
actual: usize,
},
/// The cursor lies outside the declared grid.
Cursor {
/// Cursor row.
row: u32,
/// Cursor column.
col: u32,
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
},
/// A cell's glyph is not legal in a wire grid.
Glyph {
/// Row-major cell index.
index: usize,
/// Why the glyph failed.
reason: &'static str,
},
/// A cell carries a frontend attachment, which no wire grid uses.
Attachment {
/// Row-major cell index.
index: usize,
},
/// Aggregate glyph bytes exceed the budget.
GlyphBudget {
/// Bound in force.
max: usize,
},
}
/// Declared cell area, checked against this grid's bounds.
///
/// Separate from [`validate_wire_grid`] because callers need the area
/// before they have cells to check against it.
pub fn checked_area(size: CellSize, limits: WireGridLimits) -> Result<usize, WireGridError> {
let rows = size.rows;
let cols = size.cols;
if rows == 0 || cols == 0 || rows > limits.max_rows || cols > limits.max_cols {
return Err(WireGridError::Size {
rows,
cols,
max_rows: limits.max_rows,
max_cols: limits.max_cols,
});
}
// `checked_mul` rather than a bound-derived assumption: a panel's
// axis ceilings are large enough that the product genuinely can
// overflow, which the terminal's 512x512 could not.
let area = rows
.checked_mul(cols)
.and_then(|area| usize::try_from(area).ok())
.ok_or(WireGridError::Area {
area: usize::MAX,
max: limits.max_visible_cells,
})?;
if area > limits.max_visible_cells {
return Err(WireGridError::Area {
area,
max: limits.max_visible_cells,
});
}
Ok(area)
}
/// Check every structural rule shared by wire grids.
///
/// Pure: a rejected grid mutates nothing, so callers get atomic
/// rejection for free.
pub fn validate_wire_grid(
size: CellSize,
cells: &[Cell],
cursor: Option<CellCoord>,
limits: WireGridLimits,
) -> Result<(), WireGridError> {
let area = checked_area(size, limits)?;
if cells.len() != area {
return Err(WireGridError::CellCount {
expected: area,
actual: cells.len(),
});
}
if let Some(cursor) = cursor
&& (cursor.row >= size.rows || cursor.col >= size.cols)
{
return Err(WireGridError::Cursor {
row: cursor.row,
col: cursor.col,
rows: size.rows,
cols: size.cols,
});
}
validate_cells(size, cells, limits)
}
/// Glyph legality, wide-continuation topology, and the glyph budget.
fn validate_cells(
size: CellSize,
cells: &[Cell],
limits: WireGridLimits,
) -> Result<(), WireGridError> {
let cols = size.cols as usize;
let mut glyph_bytes = 0usize;
// Columns still owed to the preceding wide lead on this row.
let mut pending_continuation = false;
for (index, cell) in cells.iter().enumerate() {
if cell.attachment.is_some() {
return Err(WireGridError::Attachment { index });
}
let col = index % cols;
if col == 0 && pending_continuation {
// A wide lead in the final column would have to be completed
// on the next row, which is not a footprint a cell grid can
// express.
return Err(WireGridError::Glyph {
index: index - 1,
reason: "wide glyph has no continuation column on its row",
});
}
match &cell.glyph {
Glyph::Continuation => {
if !pending_continuation {
return Err(WireGridError::Glyph {
index,
reason: "continuation without a preceding wide glyph",
});
}
pending_continuation = false;
}
Glyph::Char(ch) => {
if pending_continuation {
return Err(WireGridError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = char_display_width(*ch).ok_or(WireGridError::Glyph {
index,
reason: "glyph is a control or zero-width character",
})?;
glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8(), limits)?;
pending_continuation = width == 2;
}
Glyph::Cluster(bytes) => {
if pending_continuation {
return Err(WireGridError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = cluster_display_width(bytes, index)?;
glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len(), limits)?;
pending_continuation = width == 2;
}
}
}
if pending_continuation {
return Err(WireGridError::Glyph {
index: cells.len() - 1,
reason: "wide glyph has no continuation column on its row",
});
}
Ok(())
}
/// Column width of a leading `Char` glyph, or `None` when it cannot lead.
pub(crate) fn char_display_width(ch: char) -> Option<usize> {
if ch.is_control() {
return None;
}
match UnicodeWidthChar::width(ch) {
Some(1) => Some(1),
Some(2) => Some(2),
_ => None,
}
}
/// Column width of a leading `Cluster` glyph.
///
/// Width is clamped into `1..=2` exactly as the terminal screen clamps it
/// when it writes the cluster: a base plus combining marks may measure
/// wider than two columns, and the screen occupies two. Clamping in one
/// place and measuring in another is how a frame that renders correctly
/// gets rejected on the wire.
fn cluster_display_width(bytes: &[u8], index: usize) -> Result<usize, WireGridError> {
if bytes.is_empty() {
return Err(WireGridError::Glyph {
index,
reason: "cluster is empty",
});
}
if bytes.len() > MAX_WIRE_GRID_GRAPHEME_BYTES {
return Err(WireGridError::Glyph {
index,
reason: "cluster exceeds the per-cluster byte limit",
});
}
let text = std::str::from_utf8(bytes).map_err(|_| WireGridError::Glyph {
index,
reason: "cluster is not valid UTF-8",
})?;
if text.chars().any(char::is_control) {
return Err(WireGridError::Glyph {
index,
reason: "cluster carries a control character",
});
}
let width = UnicodeWidthStr::width(text);
if width == 0 {
return Err(WireGridError::Glyph {
index,
reason: "cluster occupies no columns",
});
}
Ok(width.min(2))
}
/// Accumulate glyph bytes against the aggregate budget.
fn add_glyph_bytes(
total: usize,
add: usize,
limits: WireGridLimits,
) -> Result<usize, WireGridError> {
let next = total.checked_add(add).ok_or(WireGridError::GlyphBudget {
max: limits.max_glyph_bytes,
})?;
if next > limits.max_glyph_bytes {
return Err(WireGridError::GlyphBudget {
max: limits.max_glyph_bytes,
});
}
Ok(next)
}

View File

@ -67,9 +67,9 @@ use crate::lockfile::{self, LockError, LockHandle};
use crate::presence::{PresenceSnapshot, SessionRegistry};
use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm};
use crate::protocol::{
AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget,
InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal,
MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PROTOCOL_VERSION, PointerKind,
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello,
InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage,
InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PointerKind,
SelectionSnapshot, SessionBootstrapRequest,
};
use crate::socket_path::{SocketPathError, ensure_runtime_subdir};
@ -712,7 +712,7 @@ fn per_attach_thread(
// mismatch path without changing the default.
let instance_caps_for_hello = instance_capabilities_with_env_override();
let hello = Hello {
protocol_version: PROTOCOL_VERSION,
protocol_version: ADVERTISED_PROTOCOL_VERSION,
assigned_frontend_id: frontend_id,
instance_identity: daemon_state.build_identity(),
instance_capabilities: instance_caps_for_hello.clone(),
@ -739,7 +739,7 @@ fn per_attach_thread(
let _ = write_message(
&mut stream,
&InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch {
server: PROTOCOL_VERSION,
server: ADVERTISED_PROTOCOL_VERSION,
client: req.protocol_version,
}),
);
@ -3392,12 +3392,27 @@ fn apply_event(
(grid terminals resize through the Stage 2 layout path)"
);
}
FrontendEvent::FrontendCellGeometry { .. }
| FrontendEvent::PanelResizeRows { .. }
| FrontendEvent::PanelPointer { .. } => {
// Bottom panel Stage 2 — panel declarations belong to
// negotiated panel-capable semantic sessions and are routed
// by the authenticated source in `handle_dispatcher_event`.
// A grid session has no panel band at all, so one arriving
// here is a protocol violation; drop it rather than letting
// a payload-trusted id reach a view.
eprintln!(
"pmacs daemon: panel declaration from a grid session; dropping \
(grid sessions negotiate no panel band)"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::PROTOCOL_VERSION;
#[test]
fn daemon_state_starts_frontend_id_at_two() {

View File

@ -439,6 +439,11 @@ impl Frontend {
// Q#GT4 — this pre-window semantic bootstrap result cannot
// legitimately reach the grid TUI.
| InstanceMessage::InitialTargetResult(_)
// Q#BP15 — the panel band is painted by the GPU frontend;
// the grid TUI renders its side windows through the cell
// grid and negotiates no panel capability, so this cannot
// legitimately reach here.
| InstanceMessage::PanelFrame(_)
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_twenty_for_gpu_initial_targets() {
fn protocol_version_is_twenty_one_for_the_bottom_panel_band() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1722,7 +1722,13 @@ mod tests {
// variant, see the placement pins).
// GPU initial targets bump 19→20 with a semantic-only
// SessionBootstrapRequest and appended InitialTargetResult.
assert_eq!(PROTOCOL_VERSION, 20);
// Bottom panel Stage 2 bumps 20→21 (`InstanceMessage::PanelFrame`,
// daemon-gated, plus `FrontendEvent::{FrontendCellGeometry,
// PanelResizeRows, PanelPointer}`, frontend-gated — the second
// bump that gates in BOTH directions; all four appended after
// their enum's final v20 variant, see the placement pins in
// `bottom_panel_stage2b_protocol_acceptance`).
assert_eq!(PROTOCOL_VERSION, 21);
}
#[test]
@ -1798,17 +1804,18 @@ mod tests {
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
// and v20 (semantic initial-target bootstrap) all interoperate.
for accepted in 6..=20 {
// v20 (semantic initial-target bootstrap), and v21 (the bottom
// panel band) all interoperate.
for accepted in 6..=21 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 21, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, 22, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v20 binary"
"v{rejected} must be rejected by a v21 binary"
);
}
}

View File

@ -0,0 +1,595 @@
//! Bottom-panel Stage 2B — the v21 protocol slice.
//!
//! Covers parent acceptance 37 (round-trip plus the two byte pins) and
//! the shared/terminal-only validator split of Q#BP15. The daemon
//! projection, the epoch state machine, and the GPU band are later
//! slices of this stage and are not exercised here.
mod common;
use std::time::Duration;
use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Color, Glyph, Style, UnderlineStyle};
use pmacs_protocol::message::{
AttachRequest, FrontendEvent, Hello, InstanceMessage, Modifiers, MouseButton, MouseKind,
};
use pmacs_protocol::panel::{
MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload,
};
use pmacs_protocol::terminal::{
MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState,
};
use pmacs_protocol::transport::{MAX_FRAME_BYTES, read_message, write_message};
use pmacs_protocol::wire_grid::{MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES};
use pmacs_protocol::{
ADVERTISED_PROTOCOL_VERSION, BufferId, FrontendId, PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
};
use common::daemon::{TestDaemon, build_default_caps};
fn cell(ch: char) -> Cell {
Cell {
glyph: Glyph::Char(ch),
style: Style::default(),
attachment: None,
}
}
/// The style whose postcard encoding is as long as a legal `Style` gets.
fn maximal_style() -> Style {
Style {
fg: Color::Rgb(0xff, 0xee, 0xdd),
bg: Color::Rgb(0x11, 0x22, 0x33),
bold: true,
italic: true,
underline: UnderlineStyle::Dashed,
reverse: true,
underline_color: Color::Rgb(0x44, 0x55, 0x66),
}
}
fn maximal_cell(glyph: Glyph) -> Cell {
Cell {
glyph,
style: maximal_style(),
attachment: None,
}
}
/// A single-column cluster of exactly `len` UTF-8 bytes.
fn cluster_of_len(len: usize) -> Vec<u8> {
assert!((1..=MAX_WIRE_GRID_GRAPHEME_BYTES).contains(&len));
let mut text = String::with_capacity(len);
if len % 2 == 1 {
text.push(' ');
} else {
text.push('\u{e9}');
}
while text.len() < len {
text.push('\u{301}');
}
assert_eq!(text.len(), len);
text.into_bytes()
}
fn panel_frame(rows: u32, cols: u32) -> PanelFrame {
PanelFrame {
buffer_id: BufferId::from_raw(9),
panel_epoch: 3,
geometry_epoch: 5,
size: CellSize::new(rows, cols),
cells: vec![cell(' '); (rows * cols) as usize],
cursor: Some(CellCoord::new(0, 0)),
focused: true,
}
}
fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame {
TerminalFrame {
buffer_id: BufferId::from_raw(9),
size: CellSize::new(rows, cols),
cells: vec![cell(' '); (rows * cols) as usize],
cursor: Some(CellCoord::new(0, 0)),
title: None,
screen_generation: 1,
selection: Vec::new(),
scroll_offset: 0,
at_bottom: true,
pid: 1,
process: TerminalProcessState::Running,
}
}
// ---------------------------------------------------------------------------
// 37 — version and round-trip
// ---------------------------------------------------------------------------
#[test]
fn the_panel_stage_takes_protocol_v21() {
assert_eq!(PROTOCOL_VERSION, 21);
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21));
// The wire family is reserved before it is activated: the production
// server-first Hello must remain acceptable to already-shipped v20
// clients throughout the dark protocol and daemon slices.
assert_eq!(ADVERTISED_PROTOCOL_VERSION, 20);
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20));
}
#[test]
fn a_new_daemon_keeps_an_existing_v20_client_attachable() {
let daemon = TestDaemon::spawn();
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set handshake timeout");
// This is the rejection point in an already-shipped client: it reads the
// daemon's unsolicited Hello before it is able to identify its own
// supported range or send AttachRequest.
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
let v20_client_supported_versions = 6..=20;
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
assert!(
v20_client_supported_versions.contains(&hello.protocol_version),
"an existing v20 client would reject the server-first Hello"
);
write_message(
&mut stream,
&AttachRequest {
protocol_version: hello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("write v20 AttachRequest");
assert!(
matches!(
read_message::<InstanceMessage>(&mut stream).expect("read initial grid"),
InstanceMessage::CellDelta {
full_grid: true,
..
}
),
"the daemon must establish the v20 session, not merely send an acceptable Hello"
);
}
#[test]
fn a_present_panel_frame_round_trips_with_both_epochs() {
let frame = panel_frame(2, 3);
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(frame.clone()));
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
let InstanceMessage::PanelFrame(PanelFramePayload::Present(got)) = decoded else {
panic!("expected a Present panel frame, got {decoded:?}");
};
// Both epochs must survive: they are the identities every later
// panel event validates against, so a frame that round-trips its
// cells but drops an epoch would silently accept stale input.
assert_eq!(got.panel_epoch, frame.panel_epoch);
assert_eq!(got.geometry_epoch, frame.geometry_epoch);
assert_eq!(got.buffer_id, frame.buffer_id);
assert_eq!(got.size, frame.size);
assert_eq!(got.cells, frame.cells);
assert_eq!(got.cursor, frame.cursor);
assert_eq!(got.focused, frame.focused);
}
#[test]
fn an_absent_panel_payload_round_trips_as_its_own_state() {
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Absent);
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert!(matches!(
decoded,
InstanceMessage::PanelFrame(PanelFramePayload::Absent)
));
// Absent must be distinguishable from a Present frame carrying no
// cells: it is authoritative, and conflating the two would make
// "hide the band" indistinguishable from "paint an empty band".
let empty_present = InstanceMessage::PanelFrame(PanelFramePayload::Present(panel_frame(1, 1)));
assert_ne!(
postcard::to_allocvec(&empty_present).expect("encode"),
bytes
);
}
#[test]
fn the_three_panel_events_round_trip() {
let fid = FrontendId(4);
let events = vec![
FrontendEvent::FrontendCellGeometry {
frontend_id: fid,
geometry_epoch: 1,
total: CellSize::new(40, 120),
},
FrontendEvent::PanelResizeRows {
frontend_id: fid,
geometry_epoch: 2,
panel_epoch: 7,
rows: 12,
},
FrontendEvent::PanelPointer {
frontend_id: fid,
geometry_epoch: 2,
panel_epoch: 7,
buffer_id: BufferId::from_raw(21),
coord: CellCoord::new(3, 9),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
},
];
for event in events {
let bytes = postcard::to_allocvec(&event).expect("encode");
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(decoded, event);
assert_eq!(decoded.frontend_id(), fid);
}
}
#[test]
fn panel_pointer_carries_buffer_id_distinctly_from_panel_epoch() {
// The two fields close different holes and neither subsumes the
// other: `buffer_id` catches an A->B buffer replacement, while
// `panel_epoch` catches close/hide/reopen of the SAME buffer, which
// a buffer id alone cannot see. So each must independently reach the
// wire — a field silently dropped from the encoding would let one of
// those two stale gestures through.
let base = |buffer: u64, panel_epoch: u64| FrontendEvent::PanelPointer {
frontend_id: FrontendId(4),
geometry_epoch: 2,
panel_epoch,
buffer_id: BufferId::from_raw(buffer),
coord: CellCoord::new(1, 1),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
};
let encode = |e: &FrontendEvent| postcard::to_allocvec(e).expect("encode");
// Same panel epoch, different buffer: must differ on the wire.
assert_ne!(encode(&base(1, 7)), encode(&base(2, 7)));
// Same buffer, different panel epoch: must also differ.
assert_ne!(encode(&base(1, 7)), encode(&base(1, 8)));
// And both survive decode rather than being defaulted.
let event = base(31, 7);
let decoded: FrontendEvent = postcard::from_bytes(&encode(&event)).expect("decode");
let FrontendEvent::PanelPointer {
buffer_id,
panel_epoch,
..
} = decoded
else {
panic!("expected a PanelPointer, got {decoded:?}");
};
assert_eq!(buffer_id, BufferId::from_raw(31));
assert_eq!(panel_epoch, 7);
}
// ---------------------------------------------------------------------------
// 37 — byte pins on the previous final variant of each extended enum
// ---------------------------------------------------------------------------
#[test]
fn appending_panel_frame_does_not_move_the_previous_final_instance_discriminant() {
// `InitialTargetResult` was the final v20 variant. Its encoding must
// be byte-identical after `PanelFrame` is appended; if the new
// variant were inserted anywhere earlier, this leading discriminant
// byte would shift and every v20 peer would misread the wire.
let msg = InstanceMessage::InitialTargetResult(
pmacs_protocol::message::InitialTargetResult::Opened {
buffer_id: BufferId::from_raw(1),
},
);
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes[0], 27,
"InitialTargetResult must stay discriminant 27; got {bytes:?}"
);
// And the appended variant must be the next one, not a reused slot.
let panel = InstanceMessage::PanelFrame(PanelFramePayload::Absent);
let panel_bytes = postcard::to_allocvec(&panel).expect("encode");
assert_eq!(panel_bytes[0], 28);
}
#[test]
fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() {
// `TerminalPointer` was the final v19/v20 variant of `FrontendEvent`.
let event = FrontendEvent::TerminalPointer {
frontend_id: FrontendId(2),
buffer_id: BufferId::from_raw(3),
coord: CellCoord::new(1, 1),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
};
let bytes = postcard::to_allocvec(&event).expect("encode");
assert_eq!(
bytes[0], 12,
"TerminalPointer must stay discriminant 12; got {bytes:?}"
);
// The three appended events take the next three slots, in order.
let fid = FrontendId(2);
for (expected, event) in [
(
13u8,
FrontendEvent::FrontendCellGeometry {
frontend_id: fid,
geometry_epoch: 1,
total: CellSize::new(1, 1),
},
),
(
14,
FrontendEvent::PanelResizeRows {
frontend_id: fid,
geometry_epoch: 1,
panel_epoch: 1,
rows: 1,
},
),
(
15,
FrontendEvent::PanelPointer {
frontend_id: fid,
geometry_epoch: 1,
panel_epoch: 1,
buffer_id: BufferId::from_raw(1),
coord: CellCoord::new(0, 0),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
},
),
] {
let bytes = postcard::to_allocvec(&event).expect("encode");
assert_eq!(bytes[0], expected, "wrong discriminant for {event:?}");
}
}
// ---------------------------------------------------------------------------
// 39 — the shared/terminal-only validator split
// ---------------------------------------------------------------------------
#[test]
fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() {
let wide = u32::from(MAX_TERMINAL_COLS) + 1;
// The panel does not inherit the PTY per-axis cap: a 4K surface at a
// small font is legitimately this wide, and the area bound is what
// keeps the encoding inside the transport budget.
let panel = panel_frame(1, wide);
assert_eq!(panel.validate(), Ok(()));
// The terminal keeps it, and reports the axis that failed.
let terminal = terminal_frame(1, wide);
assert!(matches!(
terminal.validate(),
Err(TerminalFrameError::Size { cols, max_cols, .. })
if cols == wide && max_cols == u32::from(MAX_TERMINAL_COLS)
));
}
#[test]
fn a_panel_still_answers_to_the_shared_area_bound() {
// Removing the per-axis cap must not remove the area bound: that is
// the check that actually bounds the encoded size.
let huge = panel_frame(1, 1);
let mut huge = huge;
huge.size = CellSize::new(1024, 1024);
huge.cells = vec![cell(' '); 1];
assert!(matches!(huge.validate(), Err(PanelFrameError::Area { .. })));
}
#[test]
fn a_panel_cell_carrying_an_attachment_is_rejected() {
// The attachment rejection is SHARED, not terminal-only, even though
// the terminal-side message says "which terminals never use":
// panels render no attachments either, so a shared rejection fails
// closed for both.
let mut frame = panel_frame(1, 2);
frame.cells[1].attachment = Some(pmacs_protocol::cell::Attachment::ImageCell {
image_id: 1,
sub_x: 0,
sub_y: 0,
});
assert!(matches!(
frame.validate(),
Err(PanelFrameError::Attachment { index: 1 })
));
}
#[test]
fn panel_glyph_topology_matches_the_terminal_rules() {
// A wide lead with no continuation column on its row is rejected the
// same way for both messages — the topology rule is shared.
let mut frame = panel_frame(1, 1);
frame.cells[0] = cell('\u{4e00}');
assert!(matches!(
frame.validate(),
Err(PanelFrameError::Glyph { .. })
));
let mut terminal = terminal_frame(1, 1);
terminal.cells[0] = cell('\u{4e00}');
assert!(matches!(
terminal.validate(),
Err(TerminalFrameError::Glyph { .. })
));
}
#[test]
fn a_panel_cursor_outside_its_grid_is_rejected() {
let mut frame = panel_frame(2, 2);
frame.cursor = Some(CellCoord::new(2, 0));
assert!(matches!(
frame.validate(),
Err(PanelFrameError::Cursor {
row: 2,
rows: 2,
..
})
));
}
#[test]
fn a_zero_epoch_panel_frame_is_rejected_on_the_wire() {
// Epoch 0 is reserved for "never declared" (Q#BP2S1), so a frame
// carrying it could otherwise match a receiver that has declared
// nothing yet.
let mut frame = panel_frame(1, 1);
frame.panel_epoch = 0;
assert!(matches!(
frame.validate(),
Err(PanelFrameError::ZeroEpoch { field: "panel" })
));
let mut frame = panel_frame(1, 1);
frame.geometry_epoch = 0;
assert!(matches!(
frame.validate(),
Err(PanelFrameError::ZeroEpoch { field: "geometry" })
));
}
#[test]
fn terminal_frames_are_unchanged_by_the_factoring() {
// The shared validator must not have altered terminal acceptance:
// a valid frame still validates, and each terminal-only rule still
// reports its own variant.
assert_eq!(terminal_frame(3, 4).validate(), Ok(()));
let mut bad_bottom = terminal_frame(1, 1);
bad_bottom.at_bottom = false;
bad_bottom.scroll_offset = 0;
assert!(matches!(
bad_bottom.validate(),
Err(TerminalFrameError::BottomState { .. })
));
let mut bad_meta = terminal_frame(1, 1);
bad_meta.title = Some("\u{7}".into());
assert!(matches!(
bad_meta.validate(),
Err(TerminalFrameError::Metadata { field: "title", .. })
));
let mut bad_count = terminal_frame(2, 2);
bad_count.cells.pop();
assert!(matches!(
bad_count.validate(),
Err(TerminalFrameError::CellCount {
expected: 4,
actual: 3
})
));
}
// ---------------------------------------------------------------------------
// 39 — the transport-safety ratchet
// ---------------------------------------------------------------------------
/// The largest legal panel frame, plus the same frame one glyph byte over.
///
/// Deliberately shaped `1 x MAX_PANEL_VISIBLE_CELLS`: a panel carries no
/// per-axis cap, so this is a legal panel geometry a terminal frame
/// cannot express, and it is therefore the worst case the terminal's own
/// ratchet never measured.
fn panel_budget_boundary_frames() -> (PanelFrame, PanelFrame) {
/// Shortest cluster length postcard encodes with a two-byte length
/// prefix, which is what makes a cluster cell maximally expensive.
const WIDE_PREFIX_LEN: usize = 128;
let area = MAX_PANEL_VISIBLE_CELLS;
// Every cell owes at least one glyph byte; the rest of the budget is
// spent on as many two-byte-prefix clusters as it affords.
let spare = MAX_WIRE_GRID_GLYPH_BYTES - area;
let wide_cells = spare / (WIDE_PREFIX_LEN - 1);
let remainder = spare % (WIDE_PREFIX_LEN - 1);
assert!(wide_cells + usize::from(remainder > 0) <= area);
let wide = cluster_of_len(WIDE_PREFIX_LEN).into_boxed_slice();
let single = cluster_of_len(1).into_boxed_slice();
let mut cells = Vec::with_capacity(area);
for index in 0..area {
let glyph = if index < wide_cells {
Glyph::Cluster(wide.clone())
} else if index == wide_cells && remainder > 0 {
Glyph::Cluster(cluster_of_len(remainder + 1).into_boxed_slice())
} else {
Glyph::Cluster(single.clone())
};
cells.push(maximal_cell(glyph));
}
let cols = u32::try_from(area).expect("area fits u32");
let exact = PanelFrame {
buffer_id: BufferId::from_raw(u64::MAX),
panel_epoch: u64::MAX,
geometry_epoch: u64::MAX,
size: CellSize::new(1, cols),
cells,
cursor: Some(CellCoord::new(0, cols - 1)),
focused: true,
};
let mut over = exact.clone();
// One more byte of glyph, nothing else changed.
let last = over.cells.len() - 1;
over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(2).into_boxed_slice()));
(exact, over)
}
#[test]
fn maximum_legal_panel_frame_encodes_below_the_transport_cap() {
let (exact, over) = panel_budget_boundary_frames();
assert_eq!(exact.validate(), Ok(()));
// The fixture must actually sit ON the boundary, or the ratchet
// below measures something smaller than the worst case and would
// stay green while a real maximum frame overran the transport.
let mut glyph_bytes = 0usize;
for cell in &exact.cells {
glyph_bytes += match &cell.glyph {
Glyph::Char(ch) => ch.len_utf8(),
Glyph::Cluster(bytes) => bytes.len(),
Glyph::Continuation => 0,
};
}
assert_eq!(
glyph_bytes, MAX_WIRE_GRID_GLYPH_BYTES,
"the measured fixture must spend the whole aggregate budget"
);
let over_glyph_bytes = over
.cells
.iter()
.map(|cell| match &cell.glyph {
Glyph::Char(ch) => ch.len_utf8(),
Glyph::Cluster(bytes) => bytes.len(),
Glyph::Continuation => 0,
})
.sum::<usize>();
assert_eq!(
over_glyph_bytes,
MAX_WIRE_GRID_GLYPH_BYTES + 1,
"the rejecting twin must be exactly one byte over the aggregate budget"
);
// One byte over is rejected, which is what makes `exact` maximal.
assert!(matches!(
over.validate(),
Err(PanelFrameError::GlyphBudget { .. })
));
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(exact));
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert!(
bytes.len() < MAX_FRAME_BYTES,
"largest legal panel frame encodes to {} bytes, at or above the \
{MAX_FRAME_BYTES}-byte transport cap; the aggregate glyph bound no \
longer keeps panel traffic inside the existing transport limit",
bytes.len()
);
}

View File

@ -26,7 +26,7 @@ use tempfile::TempDir;
#[cfg(feature = "crdt")]
use pmacs::cell::CellSize;
#[cfg(feature = "crdt")]
use pmacs::protocol::{AttachRequest, PROTOCOL_VERSION};
use pmacs::protocol::AttachRequest;
use pmacs::protocol::{FrontendCapabilities, Hello};
use pmacs::transport::read_message;
#[cfg(feature = "crdt")]
@ -294,7 +294,7 @@ pub fn attach_multi(daemon: &TestDaemon) -> (Hello, UnixStream) {
.unwrap();
let hello: Hello = read_message(&mut stream).expect("read Hello");
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: multi_frontend_caps(),
initial_size: CellSize::new(24, 80),
};

View File

@ -93,9 +93,9 @@ mod crdt {
use pmacs::cell::CellSize;
use pmacs::crdt::CrdtState;
use pmacs::protocol::{
AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget,
InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage,
PROTOCOL_VERSION, SessionBootstrapRequest,
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent,
FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities,
InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, SessionBootstrapRequest,
};
use pmacs::transport::{read_message, write_message};
@ -244,11 +244,11 @@ mod crdt {
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set target frontend timeout");
let hello: Hello = read_message(&mut stream).expect("target frontend Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
write_message(
&mut stream,
&AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: FrontendCapabilities {
multi_frontend: true,
crdt_replica: true,
@ -583,7 +583,7 @@ mod crdt {
facts
.get("server_protocol_version")
.and_then(|value| value.parse::<u32>().ok()),
Some(PROTOCOL_VERSION)
Some(ADVERTISED_PROTOCOL_VERSION)
);
assert_eq!(
facts.get("spawned_daemon").map(String::as_str),

View File

@ -37,8 +37,8 @@ use pmacs::cell::Color;
#[cfg(feature = "crdt")]
use pmacs::overlay_color::color_for_slot;
use pmacs::protocol::{
AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key,
KeyEvent, Modifiers, PROTOCOL_VERSION,
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason,
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION,
};
use pmacs::transport::{read_message, write_message};
@ -52,9 +52,9 @@ use common::daemon::{
/// Read the daemon's `Hello`, send our `AttachRequest`, return the Hello.
fn do_handshake(stream: &mut UnixStream) -> Hello {
let hello: Hello = read_message(stream).expect("read Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(24, 80),
};
@ -418,7 +418,7 @@ fn version_mismatch_clean_disconnect() {
// Read Hello.
let hello: Hello = read_message(&mut stream).expect("Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
// Send AttachRequest with wrong protocol version.
let req = AttachRequest {
@ -431,7 +431,7 @@ fn version_mismatch_clean_disconnect() {
// Expect Goodbye(VersionMismatch).
match read_message::<InstanceMessage>(&mut stream) {
Ok(InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client })) => {
assert_eq!(server, PROTOCOL_VERSION);
assert_eq!(server, ADVERTISED_PROTOCOL_VERSION);
assert_eq!(client, 999);
}
other => panic!("expected VersionMismatch Goodbye, got {other:?}"),
@ -1145,9 +1145,9 @@ fn m10_10_production_attach_negotiates_crdt_replica() {
// Production handshake — NOT the test `attach_multi()` path.
let hello: Hello = read_message(&mut stream).expect("read Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: pmacs::attach::build_capabilities(),
initial_size: CellSize::new(24, 80),
};
@ -1183,9 +1183,9 @@ fn m10_10_production_attach_non_crdt_build_does_not_negotiate_crdt_replica() {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let _hello: Hello = read_message(&mut stream).expect("read Hello");
let hello: Hello = read_message(&mut stream).expect("read Hello");
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: pmacs::attach::build_capabilities(),
initial_size: CellSize::new(24, 80),
};
@ -2171,7 +2171,7 @@ fn m10_10_f14_production_path_keystroke_flows_to_broadcast() {
.unwrap();
let hello_a: Hello = read_message(&mut stream_a).expect("A Hello");
let req_a = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello_a.protocol_version,
frontend_capabilities: pmacs::attach::build_capabilities(),
initial_size: CellSize::new(24, 80),
};

View File

@ -77,7 +77,7 @@ use nix::unistd::Pid;
use tempfile::TempDir;
use pmacs::attach::PMACS_TEST_SSH_BIN;
use pmacs::protocol::{Hello, PROTOCOL_VERSION};
use pmacs::protocol::{ADVERTISED_PROTOCOL_VERSION, Hello};
use pmacs::transport::read_message;
// ---------------------------------------------------------------------------
@ -385,7 +385,7 @@ fn daemon_attach_bridges_hello_from_existing_daemon() {
// verbatim. (No AttachRequest sent — the daemon will hold the
// attach slot until the bridge stdin closes below.)
let hello: Hello = read_message(&mut bridge_stdout).expect("read Hello via bridge");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
// Tear down: drop bridge stdin → bridge's stdin→socket copy sees
// EOF, shuts down the socket write half, the daemon notices and
@ -424,7 +424,7 @@ fn daemon_attach_auto_starts_missing_daemon() {
// bound the socket and the bridge connected.
let hello: Hello =
read_message(&mut bridge_stdout).expect("read Hello via auto-started daemon");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
// The lockfile must exist now: `acquire_lock` writes it on
// daemon startup. (Existence of the lockfile is what proves

View File

@ -72,8 +72,8 @@ use tempfile::TempDir;
use pmacs::cell::CellSize;
use pmacs::protocol::{
AttachRequest, FrontendCapabilities, FrontendEvent, Hello, InstanceMessage, Key, KeyEvent,
Modifiers, PROTOCOL_VERSION,
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, Hello,
InstanceMessage, Key, KeyEvent, Modifiers,
};
use pmacs::transport::{TransportError, read_message, write_message};
@ -158,9 +158,9 @@ fn build_default_caps() -> FrontendCapabilities {
fn do_handshake(stream: &mut UnixStream) -> Hello {
let hello: Hello = read_message(stream).expect("read Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(24, 80),
};

View File

@ -11,8 +11,8 @@ use std::time::{Duration, Instant};
use pmacs::cell::{Cell, CellSize, Glyph};
use pmacs::protocol::{
AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers,
PROTOCOL_VERSION,
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage,
Key, KeyEvent, Modifiers,
};
use pmacs::transport::{read_message, write_message};
@ -75,11 +75,11 @@ fn attach(daemon: &TestDaemon) -> (Client, Grid) {
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set daemon handshake timeout");
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
write_message(
&mut stream,
&AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(ROWS, COLS),
},

View File

@ -786,15 +786,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() {
#[test]
fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
// Vterm Stage 3 appended the terminal family as v19; GPU initial targets
// appended the semantic bootstrap family as v20. This acceptance owns the
// STATUSLINE variant's placement and gate, so it tracks the current wire
// version rather than pinning 18: the v18 floor it actually cares about is
// asserted below and in `peer_accepts_statusline_message`.
assert_eq!(PROTOCOL_VERSION, 20);
for version in 6..=20 {
// appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1
// appended the panel family as v21. This acceptance owns the STATUSLINE
// variant's placement and gate, so it tracks the current wire version
// rather than pinning 18: the v18 floor it actually cares about is asserted
// below and in `peer_accepts_statusline_message`.
assert_eq!(PROTOCOL_VERSION, 21);
for version in 6..=21 {
assert!(is_supported_protocol_version(version));
}
assert!(!is_supported_protocol_version(21));
assert!(!is_supported_protocol_version(22));
let sample = InstanceMessage::StatuslineSegments {
buffer_id: BufferId::from_raw(9),
left: vec![StatuslineSegment {

View File

@ -691,6 +691,9 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
.arg(&report)
// The chord the probe presses to run `vterm-probe.open`.
.env("PMACS_GPU_PROBE_OPEN_KEY", "t")
// This producer fixture does not consume the probe's input; wait
// instead for its own live cursor-addressed breadcrumb.
.env("PMACS_GPU_PROBE_EXPECT_TEXT", "VTERMROW")
.output()
.expect("run the headless GPU probe");
@ -717,7 +720,7 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
assert_eq!(
facts.get("server_protocol_version").copied(),
Some("20"),
"the real daemon negotiated v20 with the real client: {text}"
"the dark v21 wire slice must keep the real client on v20: {text}"
);
assert_eq!(
facts.get("entered_terminal_mode").copied(),
@ -746,6 +749,11 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
.is_some_and(|t| t.contains("VTERMROW")),
"the child's cursor-addressed output must reach the rendered frame: {text}"
);
assert_eq!(
facts.get("completion_observed").copied(),
Some("true"),
"the probe must finish on the fixture's PTY evidence, not its deadline: {text}"
);
let declarations: u32 = facts
.get("declarations")
.and_then(|v| v.parse().ok())
@ -846,7 +854,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
panic!("timed out waiting for {what}");
}
assert_eq!(PROTOCOL_VERSION, 20);
assert_eq!(PROTOCOL_VERSION, 21);
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
&[
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
@ -1311,4 +1319,10 @@ fn gpu_terminal_input_reaches_the_child_and_returns_in_a_frame() {
"the typed character must reach the child and return: {}",
report()
);
assert_eq!(
facts.get("completion_observed").map(String::as_str),
Some("true"),
"the probe must finish on the latched input echo, not its deadline: {}",
report()
);
}