diff --git a/COHERENCE.md b/COHERENCE.md index 758b3e2..e997614 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -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**: diff --git a/docs/active-work.md b/docs/active-work.md index a269a70..6a47763 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -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: - . 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: + . +- **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 1–4 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 37–55**. - 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`". diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 47f7dce..bc6f440 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -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 37–55 - 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 37–55 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), diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index 38d94ed..2384118 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -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 1–4 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 7–10 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 37–55 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. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 86132fc..3a8bb55 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -779,11 +779,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { .ok() .and_then(|value| value.parse::().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", } } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index ce2d2c5..e7e36e3 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -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, +}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 971a78e..0e4e815 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -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. v6–v19 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 v6–v20 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 diff --git a/pmacs-protocol/src/panel.rs b/pmacs-protocol/src/panel.rs new file mode 100644 index 0000000..e571ca4 --- /dev/null +++ b/pmacs-protocol/src/panel.rs @@ -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, + /// 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, + /// 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 }, + } +} diff --git a/pmacs-protocol/src/terminal.rs b/pmacs-protocol/src/terminal.rs index 8f094d7..0bcf7e0 100644 --- a/pmacs-protocol/src/terminal.rs +++ b/pmacs-protocol/src/terminal.rs @@ -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 { - 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 = 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 { - 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 { - 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 { - 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::(); + 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"); diff --git a/pmacs-protocol/src/wire_grid.rs b/pmacs-protocol/src/wire_grid.rs new file mode 100644 index 0000000..a9449df --- /dev/null +++ b/pmacs-protocol/src/wire_grid.rs @@ -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 { + 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, + 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 { + 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 { + 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 { + 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) +} diff --git a/src/daemon.rs b/src/daemon.rs index 40e9fe2..b665d07 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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() { diff --git a/src/frontend.rs b/src/frontend.rs index 8fcfb47..6b8755f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -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 diff --git a/src/protocol.rs b/src/protocol.rs index df65863..baf2709 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -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" ); } } diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs new file mode 100644 index 0000000..b066e4a --- /dev/null +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -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 { + 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::(&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::(); + 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() + ); +} diff --git a/tests/common/daemon.rs b/tests/common/daemon.rs index 6cb087a..1c8bc0d 100644 --- a/tests/common/daemon.rs +++ b/tests/common/daemon.rs @@ -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), }; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index b4e3102..2f56e8f 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -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::().ok()), - Some(PROTOCOL_VERSION) + Some(ADVERTISED_PROTOCOL_VERSION) ); assert_eq!( facts.get("spawned_daemon").map(String::as_str), diff --git a/tests/m5_5_acceptance.rs b/tests/m5_5_acceptance.rs index ee66ea8..c8c87ed 100644 --- a/tests/m5_5_acceptance.rs +++ b/tests/m5_5_acceptance.rs @@ -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::(&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), }; diff --git a/tests/m5_7_acceptance.rs b/tests/m5_7_acceptance.rs index ec0733d..ce85591 100644 --- a/tests/m5_7_acceptance.rs +++ b/tests/m5_7_acceptance.rs @@ -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 diff --git a/tests/m5_perf_acceptance.rs b/tests/m5_perf_acceptance.rs index 4d1a680..12139b3 100644 --- a/tests/m5_perf_acceptance.rs +++ b/tests/m5_perf_acceptance.rs @@ -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), }; diff --git a/tests/mode_system_wiring_acceptance.rs b/tests/mode_system_wiring_acceptance.rs index bd79b4e..16b6fe5 100644 --- a/tests/mode_system_wiring_acceptance.rs +++ b/tests/mode_system_wiring_acceptance.rs @@ -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), }, diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 120ca23..2f5cfd9 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -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 { diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index b7c2e9c..f6b1f7f 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -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() + ); }