Merge pull request #184 from levineuwirth/bottom-panel-stage2b
protocol(panel): add Stage 2B-1 v21 wire layer
This commit is contained in:
commit
6bee09dc98
10
COHERENCE.md
10
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**:
|
||||
|
|
|
|||
|
|
@ -27,12 +27,11 @@ landed regardless of what a lane says.
|
|||
machine-local: `origin` may name this canonical URL, a release mirror,
|
||||
or something else, and therefore has no authority by name alone.
|
||||
- Canonical base at this snapshot:
|
||||
`githubsucks/main` @ `c2d56ff` (Journey Stage 1a #182, which
|
||||
incorporated terminal configuration + copy mode #180, atop Lean 4
|
||||
Stage 4b #181 and the previously recorded landed work; protocol v20).
|
||||
The previous snapshot named `42025e4`, and **the recovery floor
|
||||
advances with it**: the check below now requires `c2d56ff` or newer,
|
||||
so a tree at `42025e4` no longer passes. That is
|
||||
`githubsucks/main` @ `7fd646d` (Journey/GPU directory-target ratchet
|
||||
#183, atop Journey Stage 1a #182 and the previously recorded landed
|
||||
work; protocol v20). The previous snapshot named `c2d56ff`, and **the
|
||||
recovery floor advances with it**: the check below now requires
|
||||
`7fd646d` or newer, so a tree at `c2d56ff` no longer passes. That is
|
||||
deliberate — the floor moves with the base, because a check that
|
||||
accepts an older commit than the declared base passes on a tree the
|
||||
rest of this file does not describe.
|
||||
|
|
@ -71,7 +70,7 @@ git worktree list
|
|||
git status --short --branch
|
||||
```
|
||||
|
||||
The `git log` command must expose `c2d56ff` — the base named above — or a
|
||||
The `git log` command must expose `7fd646d` — the base named above — or a
|
||||
newer intentional main. Keep this threshold and the canonical-base line in
|
||||
step: a recovery check that accepts an older commit than the base it
|
||||
declares canonical will pass on a tree the rest of this file does not
|
||||
|
|
@ -171,92 +170,6 @@ If it does not, stop and repair the remote/fetch configuration.
|
|||
to recur; the next occurrence carries its own evidence under whoever's
|
||||
PR, and a Stage B framing follows then.
|
||||
|
||||
## Journey/GPU directory-target ratchet — PR #183 GATED ON PUBLIC PATH
|
||||
|
||||
- **Approved correction, not new product behavior.** GPU initial-target
|
||||
framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make
|
||||
a directory target a success; `COHERENCE.md` §2 treats that path as a
|
||||
protected journey. The stale GPU integration test still listed `"."`
|
||||
among malformed/unloadable targets after #182 landed.
|
||||
- Branch `journey-gpu-directory-ratchet`, based directly on canonical
|
||||
`main` @ `c2d56ff`. Recovery: `git fetch githubsucks && git checkout
|
||||
journey-gpu-directory-ratchet`. Everything described here is
|
||||
committed and pushed; nothing depends on the `/tmp` worktree.
|
||||
- PR #183:
|
||||
<https://github.com/levineuwirth/pmacs/pull/183>. It is intentionally
|
||||
open and unmerged pending user review.
|
||||
- **Scope is one acceptance ratchet:** remove `"."` from the four
|
||||
genuinely invalid cases and drive the public `pmacs --gpu .` root
|
||||
broker through the real managed GPU connector. The positive requires
|
||||
snapshot-first + `InitialTargetResult::Opened`, then consumes the
|
||||
post-quiescence replacement snapshot and requires dired's canonical
|
||||
header plus a known directory entry before proving the same daemon can
|
||||
open a following file target. The private display-less acceptance
|
||||
probe now reports its snapshot count and final materialized text so
|
||||
that public path is observable. No normal frontend/daemon behavior,
|
||||
protocol, framing decision, or coherence grade changes.
|
||||
- **Review round 1: three findings, all real and corrected.** The
|
||||
first test stopped at the deliberately pre-existing bootstrap
|
||||
document, so it did not pin the resolver's later dired commit. The
|
||||
Stage 2 rev-5 recovery bullet named rev 4's framing branch. And the
|
||||
durable handoff still named pre-Journey `main` even though this ledger
|
||||
had advanced. The first is now a post-quiescence transport assertion;
|
||||
the latter two are corrected in this revision. The complete matrix
|
||||
below is green on the corrected tree.
|
||||
- **Live public-path check tightened that correction further.** A user
|
||||
report that `pmacs --gpu .` differed from `pmacs .` did not reproduce:
|
||||
the live default daemon delivered both snapshots, and a traced
|
||||
windowed invocation applied both and displayed dired. It nevertheless
|
||||
exposed that the corrected acceptance still attached a raw protocol
|
||||
client rather than invoking the public root broker and real GPU
|
||||
connector. The test now covers those surfaces and passes **15/15**;
|
||||
its full matrix is green at `34b8f28`.
|
||||
- **Review round 2 found and fixed one failure-path cleanup gap at
|
||||
`dc2dc42`.** The public-path test waited directly for dired's second
|
||||
snapshot, but `ManagedProbe` only retained a spawned daemon's PID after
|
||||
a successful wait. If the exact missing-snapshot regression recurred,
|
||||
the timeout would therefore be unable to terminate that daemon. The
|
||||
test now consumes the initial ready report first, retaining lifecycle
|
||||
facts before it starts the post-quiescence assertion.
|
||||
- **Review-round-2 gates are green:** `cargo fmt --check`; strict
|
||||
workspace clippy; library **1,849 passed / 3 ignored**; CRDT library
|
||||
**2,034 passed / 4 ignored**; focused public-path test **1/1** and full
|
||||
GPU invocation suite **15/15**; M4 **121 passed / 3 ignored / 1
|
||||
filtered**; required GPU package **202/202**; `git diff --check`.
|
||||
The first in-sandbox GPU-package attempt reproduced the repository's
|
||||
documented Unix-socket restriction as three attach failures plus a
|
||||
blocked peer read; the authoritative out-of-sandbox rerun passed all
|
||||
**202** tests in under one second.
|
||||
- **Full gate matrix is green on the public-path revision at `34b8f28`:**
|
||||
- `cargo fmt --check`;
|
||||
- strict workspace clippy;
|
||||
- library **1,849 passed / 3 ignored**;
|
||||
- CRDT library **2,034 passed / 4 ignored**;
|
||||
- touched GPU initial-target suite **15/15**;
|
||||
- connected Journey/Dired/find-file/theme/bottom-panel acceptance
|
||||
suites **125/125**;
|
||||
- M4 **121 passed / 3 ignored / 1 filtered**;
|
||||
- required GPU package **202/202**;
|
||||
- `git diff --check`;
|
||||
- isolated-config, one-invocation full workspace sweep green on its
|
||||
final run.
|
||||
- **Gate diagnostics retained:** initial fresh-worktree attempts without
|
||||
the documented `pmacs-gpu` prerequisite and without out-of-sandbox
|
||||
Unix-socket permission were setup failures, not product evidence.
|
||||
During the review rerun, an in-sandbox library attempt reproduced the
|
||||
latter setup failure as `EPERM` in three attach socket tests; the
|
||||
authoritative out-of-sandbox rerun passed all **1,849** non-ignored
|
||||
tests.
|
||||
The first full-workspace attempt then exhausted the `/tmp` filesystem
|
||||
quota while linking. Moving only the disposable Cargo target to disk
|
||||
let the sweep run; its first completed pass exposed one transient
|
||||
`pmacs-gpu` font-facts unit-test red after the standalone GPU gate had
|
||||
passed. The exact test passed immediately against the identical build,
|
||||
and the required complete one-invocation rerun was green, including
|
||||
GPU **202/202**.
|
||||
- This side quest lands before bottom-panel 2B-1 opens its PR. After it
|
||||
merges, 2B-1 integrates the new `main` and reruns its full matrix.
|
||||
|
||||
## The CRDT half of the test corpus is dark in CI — NEEDS A LANE
|
||||
|
||||
- **No branch, no framing yet.** Found while gating #166, then measured
|
||||
|
|
@ -392,39 +305,128 @@ If it does not, stop and repair the remote/fetch configuration.
|
|||
never been enforced. Any CI job that compiles the `crdt` targets has to
|
||||
fix them first or it will be red on arrival.
|
||||
|
||||
## Bottom-panel lane (Arc 7) — 2B-1 GATED; waiting on ratchet side quest
|
||||
## Bottom-panel lane (Arc 7) — 2B-1 REGATED; PR #184 OPEN FOR REVIEW
|
||||
|
||||
Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing
|
||||
revision 5's three-way split of 2B was explicitly approved on
|
||||
2026-07-27. **Stage 2B-1 is implemented, integrated with canonical
|
||||
`main`, and pushed at `b9123c2`; no PR is open.** Its own omissions found
|
||||
by gating are corrected. The Journey/GPU ratchet lane above is its sole
|
||||
remaining dependency.
|
||||
2026-07-27; revision 6 records PR #184's review correction. **Stage
|
||||
2B-1 is implemented and integrated with canonical `main` @ `7fd646d`.
|
||||
Review round 2's four findings are corrected at `ab7c207`; the
|
||||
gate-found GPU/PTY probe barrier is corrected and the complete suite is
|
||||
green at `9e20175`. The follow-up fixture-specific probe correction is
|
||||
committed and proportionally regated at `9c79ce1`. PR #184 is open and
|
||||
must not merge before user review.**
|
||||
|
||||
- **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on
|
||||
`githubsucks/main` @ `c2d56ff` by merge because review had begun.
|
||||
`githubsucks/main` @ `7fd646d` by merge because review had begun.
|
||||
Recovery: `git fetch githubsucks && git checkout
|
||||
bottom-panel-stage2b`. Everything is committed and pushed; nothing
|
||||
depends on a worktree or `/tmp`.
|
||||
- **Ships only the v21 wire layer:** the four wire shapes, version bump,
|
||||
shared cell-grid validator, and version-ladder move. It has no
|
||||
producer, consumer, or capability change; `panel_capable` stays
|
||||
`false`, so this slice changes no user-visible journey grade.
|
||||
bottom-panel-stage2b`. Everything described through the integration
|
||||
and gate checkpoint is committed and pushed; nothing depends on a
|
||||
worktree or `/tmp`. PR #184:
|
||||
<https://github.com/levineuwirth/pmacs/pull/184>.
|
||||
- **Ships only the reserved v21 wire layer:** the four wire shapes,
|
||||
schema version, shared cell-grid validator, and accepted-version
|
||||
ladder move. The production daemon continues advertising v20 because
|
||||
its `Hello` is server-first; v21 activation belongs to 2B-3. This
|
||||
slice has no producer, consumer, or capability change;
|
||||
`panel_capable` stays `false`, so it changes no user-visible journey
|
||||
grade and existing v20 clients remain attachable.
|
||||
- **Review round 1 closed:** two P1s and one P2, all corrected at
|
||||
`9b364ad`: `PanelFrame` now identifies its buffer, the transport
|
||||
ratchet covers the actual attach path rather than a detached codec
|
||||
assertion, and shared grid bounds have one validator.
|
||||
- **The full gate found and corrected two further 2B-1 omissions at
|
||||
`b9123c2`:** the statusline version ladder still pinned v20/rejected
|
||||
v21, and Vterm Stage 3 pinned v20 both structurally and in its real
|
||||
headless probe. Those ratchets now expect v21 and, where applicable,
|
||||
reject v22.
|
||||
- **Green evidence except for the main-side stale ratchet this side
|
||||
quest owns:** formatting and strict Clippy; default/CRDT libraries;
|
||||
every bottom-panel, folding, GPU-font, statusline, semantic, Vterm,
|
||||
M4, and required-GPU gate; and the isolated-config full-workspace
|
||||
sweep. The exact counts and the classified M8 timing rerun live at
|
||||
`b9123c2`.
|
||||
- **Review round 2 found four issues, corrected at `ab7c207`:** the
|
||||
server-first `Hello` made the advertised v20↔v21 compatibility
|
||||
one-way; `COHERENCE.md` and `docs/agent-handoff.md` still named only
|
||||
v20 schema support; framing §9 named a nonexistent aggregate 2B
|
||||
suite instead of the three exact 2B slice suites; and the panel plus
|
||||
copied terminal "one byte over" fixtures were actually two bytes
|
||||
over. The correction keeps production advertisement at v20, adds a
|
||||
real-daemon existing-v20-client acceptance, updates all three durable
|
||||
records, names the exact slice suites, and asserts both rejecting
|
||||
fixtures are exactly `limit + 1`.
|
||||
- **The full gate exposed and corrected a contradiction in Vterm Stage
|
||||
3's headless probe at `9e20175`.** Its loop exited as soon as resize
|
||||
plus two nonuniform composites were observed, while its acceptance
|
||||
later required the PTY child's `VTERMROW` output in the final frame.
|
||||
The v20-compatible handshake made that scheduling race deterministic:
|
||||
terminal mode, five frames, and resize all succeeded, but the report
|
||||
sampled a blank frame. The probe now waits for the exact child-output
|
||||
observation its acceptance asserts. The formerly failing exact
|
||||
GPU/PTY test passes, and the full nine-test Stage 3 target passes.
|
||||
- **Follow-up review corrected the probe barrier's fixture leak at
|
||||
`9c79ce1`.** The generic runner hard-coded the producer fixture's
|
||||
`VTERMROW` breadcrumb, so the CAT input fixture could satisfy every
|
||||
assertion but never satisfy the loop exit and waited out the
|
||||
20-second safety deadline. Producer probes now name their required
|
||||
frame text while input probes finish on the latched echo. The report
|
||||
exposes `completion_observed`, and both paths assert it, so a
|
||||
deadline-driven pass cannot hide the stall again.
|
||||
- **The full gate found and corrected two 2B-1 omissions:** the
|
||||
statusline version ladder still pinned v20/rejected v21, and Vterm
|
||||
Stage 3 pinned v20 both structurally and in its real headless probe.
|
||||
Those ratchets now expect v21 and, where applicable, reject v22.
|
||||
- **Pre-integration green evidence at `b9123c2`:** formatting and strict
|
||||
workspace Clippy; library **1,849 passed + 3 ignored default** and
|
||||
**2,034 passed + 4 ignored CRDT**; bottom-panel Stage 1 / 2A / 2B-1
|
||||
**46 / 17 / 15**; folding Stage 2 **48**; GPU font **11**; statusline
|
||||
**8 CRDT**; m11_5 semantic **2 CRDT**; Vterm Stages 1 / 2 / 3
|
||||
**10 / 6 / 9 CRDT**, with Stage 3's real daemon + PTY + wgpu probe
|
||||
required and green; M4 **121 passed + 3 ignored + 1 filtered**;
|
||||
required GPU **202**; and the isolated-config, one-invocation full
|
||||
workspace sweep green on rerun. Its first pass hit the known
|
||||
completion-before-supersede race in
|
||||
`m8_1_acceptance::read_dir_supersede_cancels_in_flight_predecessor`;
|
||||
the exact pin, its full 10-test target, and the complete workspace
|
||||
rerun all passed.
|
||||
- **The former deterministic red is resolved on `main`.** PR #183
|
||||
corrected `gpu_initial_target_acceptance` through the public
|
||||
`pmacs --gpu .` path, consumed the asynchronous dired snapshot, and
|
||||
retained the managed daemon before the wait so failure cleanup remains
|
||||
effective. The code integration auto-composed.
|
||||
- **The previous complete post-integration gate was green at `c8895a8`:**
|
||||
formatting; strict workspace Clippy; library **1,849 passed + 3
|
||||
ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel
|
||||
Stage 1 / 2A / 2B-1 **46 / 17 / 15**; folding Stage 2 **48**; GPU font
|
||||
**11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU initial
|
||||
target and invocation **15 / 15 CRDT**; Vterm Stages 1 / 2 / 3
|
||||
**10 / 6 / 9 CRDT**, including the required real daemon + PTY + wgpu
|
||||
probe; M4 **121 passed + 3 ignored + 1 filtered**; required GPU
|
||||
**202/202**; the isolated-config, one-invocation full workspace sweep;
|
||||
and `git diff --check`.
|
||||
- The first required-GPU pass was **201/202** on
|
||||
`a_fraction_draws_rule_pixels_between_its_operand_rows`, a rendering
|
||||
test structurally outside this lane's protocol-only GPU diff. The
|
||||
exact test passed immediately in isolation with one test thread, and
|
||||
the mandatory complete rerun passed **202/202**. This is retained as
|
||||
classified gate evidence, not erased as a clean first pass.
|
||||
- **The corrected review-round-2 head is fully green at `9e20175`:**
|
||||
formatting; strict workspace Clippy; library **1,849 passed + 3
|
||||
ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel
|
||||
Stage 1 / 2A / 2B-1 **46 / 17 / 16**; folding Stage 2 **48**; GPU
|
||||
font **11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU
|
||||
initial target and invocation **15 / 15 CRDT**; the handshake
|
||||
consumers m5_5 / m5_7 / mode-system wiring **36 / 7 / 1 CRDT**
|
||||
(the release-only m5 perf test remains ignored by its standing
|
||||
contract); Vterm Stages 1 / 2 / 3 **10 / 6 / 9 CRDT**, including the
|
||||
required real daemon + PTY + wgpu probe; M4 **121 passed + 3 ignored
|
||||
+ 1 filtered**; required GPU **202/202**; the isolated-config,
|
||||
one-invocation full workspace sweep; and `git diff --check`.
|
||||
- An initial default-library attempt inside the restricted tool
|
||||
sandbox produced three `Operation not permitted` failures in
|
||||
socket-based attach tests. The authoritative outside-sandbox rerun
|
||||
passed all **1,849 + 3 ignored**, and the matching CRDT run passed.
|
||||
This is retained as environment classification, not presented as a
|
||||
clean first attempt.
|
||||
- **The fixture-specific follow-up is proportionally green at
|
||||
`9c79ce1`:** formatting and strict workspace Clippy; protocol
|
||||
**17/17**; bottom-panel Stage 2B-1 **16/16**; Vterm Stage 3 **9/9
|
||||
CRDT** with the real daemon + PTY + required wgpu probe in **5.72 s**;
|
||||
the formerly stalled CAT path **1/1 in 0.32 s**; required GPU
|
||||
**202/202**; and `git diff --check`. The first Stage 2B-1 and Vterm
|
||||
attempts inside the restricted tool sandbox reproduced the classified
|
||||
Unix-socket `Operation not permitted` denial; their authoritative
|
||||
outside-sandbox reruns passed.
|
||||
- **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1
|
||||
lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine
|
||||
belongs to 2B-2; the GPU band and negotiated capability flip belong
|
||||
|
|
@ -497,8 +499,9 @@ remaining dependency.
|
|||
`docs/agent-handoff.md` §1; the two round lessons are in §5.
|
||||
- Landed-docs follow-up merged as **#156** (`main` @ `d152120`,
|
||||
2026-07-25).
|
||||
- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 5**
|
||||
is commit `56301ed` on branch `githubsucks/bottom-panel-stage2b`,
|
||||
- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 6**
|
||||
is on branch `githubsucks/bottom-panel-stage2b` (revision 5 is commit
|
||||
`56301ed` there),
|
||||
worktree `../pmacs-bp-stage2b`. Revisions 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`".
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -779,11 +779,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
|
|||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.map(std::time::Duration::from_millis);
|
||||
// Normal probes stop only after their fixture-specific evidence arrives.
|
||||
// A producer fixture names the text it must paint; an input fixture uses
|
||||
// the latched echo observation. Keeping that choice outside this generic
|
||||
// runner prevents one fixture's breadcrumb from forcing another fixture
|
||||
// to sit on the 20-second safety deadline.
|
||||
let expected_frame_text = std::env::var("PMACS_GPU_PROBE_EXPECT_TEXT")
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty());
|
||||
let quiet = observe_window.is_some();
|
||||
let deadline = std::time::Instant::now()
|
||||
+ observe_window.unwrap_or_else(|| std::time::Duration::from_secs(20));
|
||||
let mut sent_input = false;
|
||||
let mut sent_resize = false;
|
||||
let mut completion_observed = false;
|
||||
while std::time::Instant::now() < deadline {
|
||||
let Ok(event) = rx.recv_timeout(std::time::Duration::from_millis(200)) else {
|
||||
continue;
|
||||
|
|
@ -857,7 +866,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
|
|||
facts.observed_resized_frame = true;
|
||||
}
|
||||
}
|
||||
if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 {
|
||||
let fixture_evidence_observed = expected_frame_text.as_deref().map_or_else(
|
||||
|| facts.input_echo_observed,
|
||||
|expected| facts.last_frame_text.contains(expected),
|
||||
);
|
||||
// Do not exit merely because resize/composition happened
|
||||
// first: that races the fixture's required PTY evidence and
|
||||
// produces a self-contradictory "successful" probe report
|
||||
// whose later acceptance assertion must reject it.
|
||||
if !quiet
|
||||
&& facts.observed_resized_frame
|
||||
&& facts.rendered_nonuniform_frames >= 2
|
||||
&& fixture_evidence_observed
|
||||
{
|
||||
completion_observed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -890,6 +912,7 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
|
|||
let _ = writeln!(out, "last_title={}", facts.last_title.unwrap_or_default());
|
||||
let _ = writeln!(out, "last_frame_text={}", facts.last_frame_text);
|
||||
let _ = writeln!(out, "input_echo_observed={}", facts.input_echo_observed);
|
||||
let _ = writeln!(out, "completion_observed={completion_observed}");
|
||||
let _ = writeln!(out, "disconnect={}", facts.disconnect.unwrap_or_default());
|
||||
if let Err(error) = std::fs::write(report, out) {
|
||||
eprintln!(
|
||||
|
|
@ -8979,6 +9002,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
|
|||
InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments",
|
||||
InstanceMessage::TerminalFrame(_) => "TerminalFrame",
|
||||
InstanceMessage::InitialTargetResult(_) => "InitialTargetResult",
|
||||
InstanceMessage::PanelFrame(_) => "PanelFrame",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
//! Bottom-panel wire types (Q#BP15, Q#BP15a, Q#BP16).
|
||||
//!
|
||||
//! A panel frame is the daemon's painted projection of one side window.
|
||||
//! It shares [`crate::wire_grid`]'s cell rules with
|
||||
//! [`crate::terminal::TerminalFrame`] but not its per-axis PTY caps: a
|
||||
//! 4K surface at a small font is legitimately wider than 512 columns,
|
||||
//! and the area bound is what keeps the encoding inside the transport
|
||||
//! budget.
|
||||
//!
|
||||
//! Presence is explicit. [`PanelFramePayload::Absent`] is authoritative
|
||||
//! and must be sent on close *and* on hide, because the receiver
|
||||
//! retains its last valid frame: silence would leave a stale band on
|
||||
//! screen indefinitely.
|
||||
|
||||
use crate::cell::{Cell, CellCoord, CellSize};
|
||||
use crate::ids::BufferId;
|
||||
use crate::wire_grid::{
|
||||
MAX_WIRE_GRID_GLYPH_BYTES, WireGridError, WireGridLimits, validate_wire_grid,
|
||||
};
|
||||
|
||||
/// Shared visible-cell ceiling for a panel grid.
|
||||
///
|
||||
/// Identical to the terminal bound: it is the transport-safety limit,
|
||||
/// not a PTY policy, so both messages answer to it.
|
||||
pub const MAX_PANEL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS;
|
||||
|
||||
/// Bounds a panel frame enforces on its cell grid.
|
||||
///
|
||||
/// The per-axis ceilings are the area bound itself rather than 512: any
|
||||
/// axis larger than the area bound is already rejected by the area
|
||||
/// check, so this expresses "no independent per-axis policy" without
|
||||
/// leaving the multiplication unchecked.
|
||||
const PANEL_GRID_LIMITS: WireGridLimits = WireGridLimits {
|
||||
max_rows: MAX_PANEL_VISIBLE_CELLS as u32,
|
||||
max_cols: MAX_PANEL_VISIBLE_CELLS as u32,
|
||||
max_visible_cells: MAX_PANEL_VISIBLE_CELLS,
|
||||
max_glyph_bytes: MAX_WIRE_GRID_GLYPH_BYTES,
|
||||
};
|
||||
|
||||
/// The daemon's painted projection of one side window.
|
||||
///
|
||||
/// `panel_epoch` is opaque and monotonic per frontend: stable across
|
||||
/// ordinary frames of one continuously present window/buffer, and
|
||||
/// changed on buffer replacement, new side-window creation, and every
|
||||
/// `Absent` → `Present` transition. That is what stops a stale
|
||||
/// `PanelPointer` from addressing a reopened panel as if it were the
|
||||
/// old one (Q#BP16).
|
||||
///
|
||||
/// `geometry_epoch` answers a *frontend* declaration and moves whenever
|
||||
/// the frontend declares new effective cell geometry — including a font
|
||||
/// or scale change that leaves [`CellSize`] identical, which is exactly
|
||||
/// the case daemon-side value dedup cannot see (Q#BP2S1).
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PanelFrame {
|
||||
/// Buffer this frame projects.
|
||||
pub buffer_id: BufferId,
|
||||
/// Presentation identity, monotonic per frontend.
|
||||
pub panel_epoch: u64,
|
||||
/// The frontend geometry declaration this frame answers.
|
||||
pub geometry_epoch: u64,
|
||||
/// Panel grid dimensions in cells.
|
||||
pub size: CellSize,
|
||||
/// Row-major cells; exactly `size.area()` entries.
|
||||
pub cells: Vec<Cell>,
|
||||
/// Panel caret, or `None` when the panel shows no cursor.
|
||||
///
|
||||
/// `paint_frame` returns the cursor separately from the cells, so a
|
||||
/// frame carrying cells alone would lose the caret.
|
||||
pub cursor: Option<CellCoord>,
|
||||
/// Whether the panel owns focus.
|
||||
///
|
||||
/// Presentation and focus-chrome routing only (Q#BP14b) — the
|
||||
/// *keys* decision is `DispatchIdle` (Q#BP14a).
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
/// Explicit panel presence.
|
||||
///
|
||||
/// `Absent` is authoritative rather than implied by silence, and is
|
||||
/// duplicate-suppressed like any other payload.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum PanelFramePayload {
|
||||
/// A panel is visible and this is its current frame.
|
||||
Present(PanelFrame),
|
||||
/// No panel is visible; clear any retained frame.
|
||||
Absent,
|
||||
}
|
||||
|
||||
/// Why a [`PanelFrame`] is not structurally valid.
|
||||
///
|
||||
/// Validation is atomic: the frame is rejected whole and the receiver
|
||||
/// retains its previous valid frame.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
|
||||
pub enum PanelFrameError {
|
||||
/// Rows or columns are zero or above the area-derived bounds.
|
||||
#[error("panel size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")]
|
||||
Size {
|
||||
/// Declared rows.
|
||||
rows: u32,
|
||||
/// Declared columns.
|
||||
cols: u32,
|
||||
/// Row bound in force.
|
||||
max_rows: u32,
|
||||
/// Column bound in force.
|
||||
max_cols: u32,
|
||||
},
|
||||
/// The checked area exceeds the shared visible-cell bound.
|
||||
#[error("panel area {area} exceeds the visible-cell bound {max}")]
|
||||
Area {
|
||||
/// Checked `rows * cols`.
|
||||
area: usize,
|
||||
/// Shared visible-cell bound.
|
||||
max: usize,
|
||||
},
|
||||
/// `cells.len()` disagrees with the declared area.
|
||||
#[error("panel frame carries {actual} cells for a {expected}-cell area")]
|
||||
CellCount {
|
||||
/// Declared area.
|
||||
expected: usize,
|
||||
/// Supplied cell count.
|
||||
actual: usize,
|
||||
},
|
||||
/// The cursor lies outside the declared grid.
|
||||
#[error("panel cursor ({row},{col}) is outside the {rows}x{cols} grid")]
|
||||
Cursor {
|
||||
/// Cursor row.
|
||||
row: u32,
|
||||
/// Cursor column.
|
||||
col: u32,
|
||||
/// Declared rows.
|
||||
rows: u32,
|
||||
/// Declared columns.
|
||||
cols: u32,
|
||||
},
|
||||
/// A cell's glyph is not a legal wire glyph.
|
||||
#[error("panel cell {index} has an invalid glyph: {reason}")]
|
||||
Glyph {
|
||||
/// Row-major cell index.
|
||||
index: usize,
|
||||
/// Why the glyph failed.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// A cell carries a frontend attachment, which panels never use.
|
||||
#[error("panel cell {index} carries an attachment")]
|
||||
Attachment {
|
||||
/// Row-major cell index.
|
||||
index: usize,
|
||||
},
|
||||
/// Aggregate glyph bytes exceed the shared budget.
|
||||
#[error("panel frame glyph bytes exceed the aggregate bound {max}")]
|
||||
GlyphBudget {
|
||||
/// Shared aggregate bound.
|
||||
max: usize,
|
||||
},
|
||||
/// An epoch is zero, which is reserved for "never declared".
|
||||
#[error("panel {field} epoch is zero, which is reserved for 'never declared'")]
|
||||
ZeroEpoch {
|
||||
/// Which epoch was zero.
|
||||
field: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl PanelFrame {
|
||||
/// Check every structural rule a panel frame must satisfy.
|
||||
///
|
||||
/// Pure: a rejected frame mutates nothing, so callers get atomic
|
||||
/// rejection for free.
|
||||
pub fn validate(&self) -> Result<(), PanelFrameError> {
|
||||
if self.panel_epoch == 0 {
|
||||
return Err(PanelFrameError::ZeroEpoch { field: "panel" });
|
||||
}
|
||||
if self.geometry_epoch == 0 {
|
||||
return Err(PanelFrameError::ZeroEpoch { field: "geometry" });
|
||||
}
|
||||
validate_wire_grid(self.size, &self.cells, self.cursor, PANEL_GRID_LIMITS)
|
||||
.map_err(panel_grid_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a shared wire-grid failure onto this message's error type.
|
||||
fn panel_grid_error(error: WireGridError) -> PanelFrameError {
|
||||
match error {
|
||||
WireGridError::Size {
|
||||
rows,
|
||||
cols,
|
||||
max_rows,
|
||||
max_cols,
|
||||
} => PanelFrameError::Size {
|
||||
rows,
|
||||
cols,
|
||||
max_rows,
|
||||
max_cols,
|
||||
},
|
||||
WireGridError::Area { area, max } => PanelFrameError::Area { area, max },
|
||||
WireGridError::CellCount { expected, actual } => {
|
||||
PanelFrameError::CellCount { expected, actual }
|
||||
}
|
||||
WireGridError::Cursor {
|
||||
row,
|
||||
col,
|
||||
rows,
|
||||
cols,
|
||||
} => PanelFrameError::Cursor {
|
||||
row,
|
||||
col,
|
||||
rows,
|
||||
cols,
|
||||
},
|
||||
WireGridError::Glyph { index, reason } => PanelFrameError::Glyph { index, reason },
|
||||
WireGridError::Attachment { index } => PanelFrameError::Attachment { index },
|
||||
WireGridError::GlyphBudget { max } => PanelFrameError::GlyphBudget { max },
|
||||
}
|
||||
}
|
||||
|
|
@ -12,13 +12,18 @@
|
|||
//! that single structural policy. A second implementation of these rules
|
||||
//! in a frontend is a bug, not a convenience.
|
||||
//!
|
||||
//! This module owns the crate's only `unicode-width` use: glyph column
|
||||
//! width and wide-continuation topology cannot be checked without it.
|
||||
//! Glyph column width and wide-continuation topology moved to
|
||||
//! [`crate::wire_grid`] in bottom-panel Stage 2B, which is now the
|
||||
//! crate's only non-test `unicode-width` use: those rules are shared
|
||||
//! with [`crate::panel::PanelFrame`]. The 512 per-axis PTY caps,
|
||||
//! metadata, selection spans, and the `at_bottom`/`scroll_offset`
|
||||
//! coupling stay here, because a panel does not inherit them.
|
||||
|
||||
use crate::cell::{Cell, CellCoord, CellSize, Glyph};
|
||||
use crate::cell::{Cell, CellCoord, CellSize};
|
||||
use crate::ids::BufferId;
|
||||
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
#[cfg(test)]
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared limits
|
||||
|
|
@ -32,10 +37,20 @@ pub const MAX_TERMINAL_COLS: u16 = 512;
|
|||
|
||||
/// Maximum visible terminal cells accepted at creation, resize, or on
|
||||
/// the wire.
|
||||
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144;
|
||||
///
|
||||
/// An alias of the shared wire-grid bound: this is transport safety, not
|
||||
/// a PTY policy, so it must not drift from the panel's.
|
||||
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS;
|
||||
|
||||
/// Maximum UTF-8 bytes retained in one terminal grapheme cluster.
|
||||
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256;
|
||||
///
|
||||
/// An **alias** of the shared wire-grid bound, not an independent value.
|
||||
/// The terminal screen truncates clusters to this constant while
|
||||
/// [`crate::wire_grid`] validates against its own; if the two were
|
||||
/// separate literals, raising one would make the producer emit clusters
|
||||
/// its own validator rejects — or, worse, accept clusters no frontend
|
||||
/// budgeted for. Keeping this a re-export means they cannot drift.
|
||||
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES;
|
||||
|
||||
/// Shared cap for terminal title and process-outcome metadata.
|
||||
pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;
|
||||
|
|
@ -51,7 +66,7 @@ pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;
|
|||
/// protocol test `maximum_legal_terminal_frame_encodes_below_the_transport_cap`
|
||||
/// measures the largest legal frame this bound admits and pins it below
|
||||
/// the unchanged 16 MiB cap.
|
||||
pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = 8 * 1024 * 1024;
|
||||
pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GLYPH_BYTES;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payload types
|
||||
|
|
@ -217,6 +232,58 @@ pub enum TerminalFrameError {
|
|||
},
|
||||
}
|
||||
|
||||
/// Bounds a terminal frame enforces on its cell grid.
|
||||
///
|
||||
/// The per-axis caps are the PTY-specific half of the split: a panel
|
||||
/// frame shares every other rule but not these, because a panel is
|
||||
/// sized by the frontend's surface rather than by a pty window size.
|
||||
const TERMINAL_GRID_LIMITS: crate::wire_grid::WireGridLimits = crate::wire_grid::WireGridLimits {
|
||||
max_rows: MAX_TERMINAL_ROWS as u32,
|
||||
max_cols: MAX_TERMINAL_COLS as u32,
|
||||
max_visible_cells: MAX_TERMINAL_VISIBLE_CELLS,
|
||||
max_glyph_bytes: MAX_TERMINAL_FRAME_GLYPH_BYTES,
|
||||
};
|
||||
|
||||
/// Map a shared wire-grid failure onto this message's error type.
|
||||
///
|
||||
/// The variants and their text are unchanged by the Stage 2B factoring:
|
||||
/// every existing terminal-frame assertion still observes exactly what
|
||||
/// it observed before.
|
||||
fn terminal_grid_error(error: crate::wire_grid::WireGridError) -> TerminalFrameError {
|
||||
use crate::wire_grid::WireGridError;
|
||||
match error {
|
||||
WireGridError::Size {
|
||||
rows,
|
||||
cols,
|
||||
max_rows,
|
||||
max_cols,
|
||||
} => TerminalFrameError::Size {
|
||||
rows,
|
||||
cols,
|
||||
max_rows,
|
||||
max_cols,
|
||||
},
|
||||
WireGridError::Area { area, max } => TerminalFrameError::Area { area, max },
|
||||
WireGridError::CellCount { expected, actual } => {
|
||||
TerminalFrameError::CellCount { expected, actual }
|
||||
}
|
||||
WireGridError::Cursor {
|
||||
row,
|
||||
col,
|
||||
rows,
|
||||
cols,
|
||||
} => TerminalFrameError::Cursor {
|
||||
row,
|
||||
col,
|
||||
rows,
|
||||
cols,
|
||||
},
|
||||
WireGridError::Glyph { index, reason } => TerminalFrameError::Glyph { index, reason },
|
||||
WireGridError::Attachment { index } => TerminalFrameError::Attachment { index },
|
||||
WireGridError::GlyphBudget { max } => TerminalFrameError::GlyphBudget { max },
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalFrame {
|
||||
/// Check every structural rule a terminal frame must satisfy.
|
||||
///
|
||||
|
|
@ -224,23 +291,13 @@ impl TerminalFrame {
|
|||
/// by a frontend after decode. It is pure: a rejected frame mutates
|
||||
/// nothing, so callers get atomic rejection for free.
|
||||
pub fn validate(&self) -> Result<(), TerminalFrameError> {
|
||||
let area = self.checked_area()?;
|
||||
if self.cells.len() != area {
|
||||
return Err(TerminalFrameError::CellCount {
|
||||
expected: area,
|
||||
actual: self.cells.len(),
|
||||
});
|
||||
}
|
||||
if let Some(cursor) = self.cursor
|
||||
&& (cursor.row >= self.size.rows || cursor.col >= self.size.cols)
|
||||
{
|
||||
return Err(TerminalFrameError::Cursor {
|
||||
row: cursor.row,
|
||||
col: cursor.col,
|
||||
rows: self.size.rows,
|
||||
cols: self.size.cols,
|
||||
});
|
||||
}
|
||||
crate::wire_grid::validate_wire_grid(
|
||||
self.size,
|
||||
&self.cells,
|
||||
self.cursor,
|
||||
TERMINAL_GRID_LIMITS,
|
||||
)
|
||||
.map_err(terminal_grid_error)?;
|
||||
if let Some(title) = &self.title {
|
||||
validate_metadata("title", title)?;
|
||||
}
|
||||
|
|
@ -249,7 +306,6 @@ impl TerminalFrame {
|
|||
TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?,
|
||||
TerminalProcessState::Running | TerminalProcessState::Exited(_) => {}
|
||||
}
|
||||
self.validate_cells()?;
|
||||
self.validate_selection()?;
|
||||
if self.at_bottom != (self.scroll_offset == 0) {
|
||||
return Err(TerminalFrameError::BottomState {
|
||||
|
|
@ -260,107 +316,6 @@ impl TerminalFrame {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Declared cell area, checked against both shared bounds.
|
||||
fn checked_area(&self) -> Result<usize, TerminalFrameError> {
|
||||
let rows = self.size.rows;
|
||||
let cols = self.size.cols;
|
||||
if rows == 0
|
||||
|| cols == 0
|
||||
|| rows > u32::from(MAX_TERMINAL_ROWS)
|
||||
|| cols > u32::from(MAX_TERMINAL_COLS)
|
||||
{
|
||||
return Err(TerminalFrameError::Size {
|
||||
rows,
|
||||
cols,
|
||||
max_rows: u32::from(MAX_TERMINAL_ROWS),
|
||||
max_cols: u32::from(MAX_TERMINAL_COLS),
|
||||
});
|
||||
}
|
||||
// Both factors are bounded above by 512, so the product cannot
|
||||
// overflow; `checked_mul` keeps that an assertion rather than an
|
||||
// assumption a later bound change could quietly break.
|
||||
let area = rows
|
||||
.checked_mul(cols)
|
||||
.and_then(|area| usize::try_from(area).ok())
|
||||
.ok_or(TerminalFrameError::Area {
|
||||
area: usize::MAX,
|
||||
max: MAX_TERMINAL_VISIBLE_CELLS,
|
||||
})?;
|
||||
if area > MAX_TERMINAL_VISIBLE_CELLS {
|
||||
return Err(TerminalFrameError::Area {
|
||||
area,
|
||||
max: MAX_TERMINAL_VISIBLE_CELLS,
|
||||
});
|
||||
}
|
||||
Ok(area)
|
||||
}
|
||||
|
||||
/// Glyph legality, wide-continuation topology, and the glyph budget.
|
||||
fn validate_cells(&self) -> Result<(), TerminalFrameError> {
|
||||
let cols = self.size.cols as usize;
|
||||
let mut glyph_bytes = 0usize;
|
||||
// Columns still owed to the preceding wide lead on this row.
|
||||
let mut pending_continuation = false;
|
||||
for (index, cell) in self.cells.iter().enumerate() {
|
||||
if cell.attachment.is_some() {
|
||||
return Err(TerminalFrameError::Attachment { index });
|
||||
}
|
||||
let col = index % cols;
|
||||
if col == 0 && pending_continuation {
|
||||
// A wide lead in the final column would have to be
|
||||
// completed on the next row, which is not a footprint a
|
||||
// terminal grid can express.
|
||||
return Err(TerminalFrameError::Glyph {
|
||||
index: index - 1,
|
||||
reason: "wide glyph has no continuation column on its row",
|
||||
});
|
||||
}
|
||||
match &cell.glyph {
|
||||
Glyph::Continuation => {
|
||||
if !pending_continuation {
|
||||
return Err(TerminalFrameError::Glyph {
|
||||
index,
|
||||
reason: "continuation without a preceding wide glyph",
|
||||
});
|
||||
}
|
||||
pending_continuation = false;
|
||||
}
|
||||
Glyph::Char(ch) => {
|
||||
if pending_continuation {
|
||||
return Err(TerminalFrameError::Glyph {
|
||||
index,
|
||||
reason: "wide glyph is not followed by its continuation",
|
||||
});
|
||||
}
|
||||
let width = char_display_width(*ch).ok_or(TerminalFrameError::Glyph {
|
||||
index,
|
||||
reason: "glyph is a control or zero-width character",
|
||||
})?;
|
||||
glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8())?;
|
||||
pending_continuation = width == 2;
|
||||
}
|
||||
Glyph::Cluster(bytes) => {
|
||||
if pending_continuation {
|
||||
return Err(TerminalFrameError::Glyph {
|
||||
index,
|
||||
reason: "wide glyph is not followed by its continuation",
|
||||
});
|
||||
}
|
||||
let width = cluster_display_width(bytes, index)?;
|
||||
glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len())?;
|
||||
pending_continuation = width == 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if pending_continuation {
|
||||
return Err(TerminalFrameError::Glyph {
|
||||
index: self.cells.len() - 1,
|
||||
reason: "wide glyph has no continuation column on its row",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One nonempty in-bounds span per row, strictly increasing by row.
|
||||
fn validate_selection(&self) -> Result<(), TerminalFrameError> {
|
||||
let mut previous_row: Option<u32> = None;
|
||||
|
|
@ -395,73 +350,6 @@ impl TerminalFrame {
|
|||
}
|
||||
}
|
||||
|
||||
/// Column width of a leading `Char` glyph, or `None` when it cannot lead.
|
||||
fn char_display_width(ch: char) -> Option<usize> {
|
||||
if ch.is_control() {
|
||||
return None;
|
||||
}
|
||||
match UnicodeWidthChar::width(ch) {
|
||||
Some(1) => Some(1),
|
||||
Some(2) => Some(2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Column width of a leading `Cluster` glyph.
|
||||
///
|
||||
/// Width is clamped into `1..=2` exactly as the terminal screen clamps it
|
||||
/// when it writes the cluster: a base plus combining marks may measure
|
||||
/// wider than two columns, and the screen occupies two. Clamping in one
|
||||
/// place and measuring in another is how a frame that renders correctly
|
||||
/// gets rejected on the wire.
|
||||
fn cluster_display_width(bytes: &[u8], index: usize) -> Result<usize, TerminalFrameError> {
|
||||
if bytes.is_empty() {
|
||||
return Err(TerminalFrameError::Glyph {
|
||||
index,
|
||||
reason: "cluster is empty",
|
||||
});
|
||||
}
|
||||
if bytes.len() > MAX_TERMINAL_GRAPHEME_BYTES {
|
||||
return Err(TerminalFrameError::Glyph {
|
||||
index,
|
||||
reason: "cluster exceeds the per-cluster byte limit",
|
||||
});
|
||||
}
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| TerminalFrameError::Glyph {
|
||||
index,
|
||||
reason: "cluster is not valid UTF-8",
|
||||
})?;
|
||||
if text.chars().any(char::is_control) {
|
||||
return Err(TerminalFrameError::Glyph {
|
||||
index,
|
||||
reason: "cluster carries a control character",
|
||||
});
|
||||
}
|
||||
let width = UnicodeWidthStr::width(text);
|
||||
if width == 0 {
|
||||
return Err(TerminalFrameError::Glyph {
|
||||
index,
|
||||
reason: "cluster occupies no columns",
|
||||
});
|
||||
}
|
||||
Ok(width.min(2))
|
||||
}
|
||||
|
||||
/// Accumulate glyph bytes under the aggregate bound with checked addition.
|
||||
fn add_glyph_bytes(total: usize, add: usize) -> Result<usize, TerminalFrameError> {
|
||||
let next = total
|
||||
.checked_add(add)
|
||||
.ok_or(TerminalFrameError::GlyphBudget {
|
||||
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
|
||||
})?;
|
||||
if next > MAX_TERMINAL_FRAME_GLYPH_BYTES {
|
||||
return Err(TerminalFrameError::GlyphBudget {
|
||||
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
|
||||
});
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Length and control-character rules shared by title and process text.
|
||||
fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFrameError> {
|
||||
if text.len() > MAX_TERMINAL_METADATA_BYTES {
|
||||
|
|
@ -482,7 +370,10 @@ fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFram
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cell::{Color, Style, UnderlineStyle};
|
||||
// `Glyph` is no longer used by this module's production code — the
|
||||
// glyph rules moved to `crate::wire_grid` — but these tests still
|
||||
// construct frames cell by cell.
|
||||
use crate::cell::{Color, Glyph, Style, UnderlineStyle};
|
||||
use crate::message::InstanceMessage;
|
||||
use crate::transport::MAX_FRAME_BYTES;
|
||||
|
||||
|
|
@ -952,14 +843,14 @@ mod tests {
|
|||
let mut over = exact.clone();
|
||||
// One more byte of glyph, nothing else changed.
|
||||
let last = over.cells.len() - 1;
|
||||
over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(3).into_boxed_slice()));
|
||||
over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(2).into_boxed_slice()));
|
||||
|
||||
(exact, over)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_legal_terminal_frame_encodes_below_the_transport_cap() {
|
||||
let (exact, _) = budget_boundary_frames();
|
||||
let (exact, over) = budget_boundary_frames();
|
||||
assert_eq!(exact.validate(), Ok(()));
|
||||
|
||||
let mut glyph_bytes = 0usize;
|
||||
|
|
@ -974,6 +865,20 @@ mod tests {
|
|||
glyph_bytes, MAX_TERMINAL_FRAME_GLYPH_BYTES,
|
||||
"the measured fixture must spend the whole aggregate budget"
|
||||
);
|
||||
let over_glyph_bytes = over
|
||||
.cells
|
||||
.iter()
|
||||
.map(|cell| match &cell.glyph {
|
||||
Glyph::Char(ch) => ch.len_utf8(),
|
||||
Glyph::Cluster(bytes) => bytes.len(),
|
||||
Glyph::Continuation => 0,
|
||||
})
|
||||
.sum::<usize>();
|
||||
assert_eq!(
|
||||
over_glyph_bytes,
|
||||
MAX_TERMINAL_FRAME_GLYPH_BYTES + 1,
|
||||
"the rejecting twin must be exactly one byte over the aggregate budget"
|
||||
);
|
||||
|
||||
let msg = InstanceMessage::TerminalFrame(exact);
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,329 @@
|
|||
//! Shared cell-grid validation for every wire message that carries a
|
||||
//! rectangular grid of [`Cell`]s.
|
||||
//!
|
||||
//! Bottom-panel Stage 2B (Q#BP15) factors this out of
|
||||
//! [`crate::terminal`], which was the only such message until
|
||||
//! [`crate::panel::PanelFrame`] arrived. The split follows the boundary
|
||||
//! the framing names:
|
||||
//!
|
||||
//! - **Shared** — the checked area, the visible-cell bound, the cell
|
||||
//! count, cursor bounds, glyph legality, wide-continuation topology,
|
||||
//! the aggregate glyph-byte budget, and the attachment rejection.
|
||||
//! - **Terminal-only** — the 512 per-axis PTY caps, title/process
|
||||
//! metadata, selection spans, and the `at_bottom == (scroll_offset ==
|
||||
//! 0)` coupling.
|
||||
//!
|
||||
//! The per-axis caps are a [`WireGridLimits`] parameter rather than a
|
||||
//! constant precisely because a panel does not inherit them: a 4K
|
||||
//! surface at a small font is legitimately wider than 512 columns, and
|
||||
//! the area bound is what keeps the encoding inside the transport
|
||||
//! budget.
|
||||
//!
|
||||
//! The attachment rejection is deliberately **shared**, not
|
||||
//! terminal-only, even though its terminal-side message reads "which
|
||||
//! terminals never use". Panels render no attachments either, so
|
||||
//! rejecting them here fails closed for both; classifying it as
|
||||
//! terminal-only would let a panel ship a cell no frontend can paint.
|
||||
|
||||
use crate::cell::{Cell, CellCoord, CellSize, Glyph};
|
||||
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
/// Aggregate glyph-byte ceiling shared by every wire grid.
|
||||
///
|
||||
/// A grid at the visible-cell bound where every cell carries a maximum
|
||||
/// cluster would exceed the transport frame limit; this keeps the
|
||||
/// encoded size bounded independently of the per-cell rule.
|
||||
pub const MAX_WIRE_GRID_GLYPH_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Per-cell grapheme-cluster byte ceiling shared by every wire grid.
|
||||
pub const MAX_WIRE_GRID_GRAPHEME_BYTES: usize = 256;
|
||||
|
||||
/// Visible-cell ceiling shared by every wire grid.
|
||||
///
|
||||
/// This is the transport-safety bound, not a per-message policy: it is
|
||||
/// what keeps `rows * cols * per-cell` inside the transport frame limit,
|
||||
/// so both the terminal and the panel answer to it even though they
|
||||
/// carry different per-axis caps.
|
||||
pub const MAX_WIRE_GRID_VISIBLE_CELLS: usize = 262_144;
|
||||
|
||||
/// Bounds a particular wire grid enforces.
|
||||
///
|
||||
/// `max_rows` / `max_cols` are per-message policy. `max_visible_cells`
|
||||
/// is the shared area bound and is what actually keeps the encoding
|
||||
/// inside the transport budget.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub struct WireGridLimits {
|
||||
/// Inclusive row ceiling.
|
||||
pub max_rows: u32,
|
||||
/// Inclusive column ceiling.
|
||||
pub max_cols: u32,
|
||||
/// Inclusive `rows * cols` ceiling.
|
||||
pub max_visible_cells: usize,
|
||||
/// Inclusive aggregate glyph-byte ceiling.
|
||||
pub max_glyph_bytes: usize,
|
||||
}
|
||||
|
||||
/// Why a wire grid is not structurally valid.
|
||||
///
|
||||
/// Callers map these onto their own message-specific error types so
|
||||
/// existing wire errors keep their exact variants and text.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum WireGridError {
|
||||
/// Rows or columns are zero or above this grid's bounds.
|
||||
Size {
|
||||
/// Declared rows.
|
||||
rows: u32,
|
||||
/// Declared columns.
|
||||
cols: u32,
|
||||
/// Row bound in force.
|
||||
max_rows: u32,
|
||||
/// Column bound in force.
|
||||
max_cols: u32,
|
||||
},
|
||||
/// The checked area exceeds the visible-cell bound.
|
||||
Area {
|
||||
/// Checked `rows * cols`.
|
||||
area: usize,
|
||||
/// Bound in force.
|
||||
max: usize,
|
||||
},
|
||||
/// `cells.len()` disagrees with the declared area.
|
||||
CellCount {
|
||||
/// Declared area.
|
||||
expected: usize,
|
||||
/// Supplied cell count.
|
||||
actual: usize,
|
||||
},
|
||||
/// The cursor lies outside the declared grid.
|
||||
Cursor {
|
||||
/// Cursor row.
|
||||
row: u32,
|
||||
/// Cursor column.
|
||||
col: u32,
|
||||
/// Declared rows.
|
||||
rows: u32,
|
||||
/// Declared columns.
|
||||
cols: u32,
|
||||
},
|
||||
/// A cell's glyph is not legal in a wire grid.
|
||||
Glyph {
|
||||
/// Row-major cell index.
|
||||
index: usize,
|
||||
/// Why the glyph failed.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// A cell carries a frontend attachment, which no wire grid uses.
|
||||
Attachment {
|
||||
/// Row-major cell index.
|
||||
index: usize,
|
||||
},
|
||||
/// Aggregate glyph bytes exceed the budget.
|
||||
GlyphBudget {
|
||||
/// Bound in force.
|
||||
max: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Declared cell area, checked against this grid's bounds.
|
||||
///
|
||||
/// Separate from [`validate_wire_grid`] because callers need the area
|
||||
/// before they have cells to check against it.
|
||||
pub fn checked_area(size: CellSize, limits: WireGridLimits) -> Result<usize, WireGridError> {
|
||||
let rows = size.rows;
|
||||
let cols = size.cols;
|
||||
if rows == 0 || cols == 0 || rows > limits.max_rows || cols > limits.max_cols {
|
||||
return Err(WireGridError::Size {
|
||||
rows,
|
||||
cols,
|
||||
max_rows: limits.max_rows,
|
||||
max_cols: limits.max_cols,
|
||||
});
|
||||
}
|
||||
// `checked_mul` rather than a bound-derived assumption: a panel's
|
||||
// axis ceilings are large enough that the product genuinely can
|
||||
// overflow, which the terminal's 512x512 could not.
|
||||
let area = rows
|
||||
.checked_mul(cols)
|
||||
.and_then(|area| usize::try_from(area).ok())
|
||||
.ok_or(WireGridError::Area {
|
||||
area: usize::MAX,
|
||||
max: limits.max_visible_cells,
|
||||
})?;
|
||||
if area > limits.max_visible_cells {
|
||||
return Err(WireGridError::Area {
|
||||
area,
|
||||
max: limits.max_visible_cells,
|
||||
});
|
||||
}
|
||||
Ok(area)
|
||||
}
|
||||
|
||||
/// Check every structural rule shared by wire grids.
|
||||
///
|
||||
/// Pure: a rejected grid mutates nothing, so callers get atomic
|
||||
/// rejection for free.
|
||||
pub fn validate_wire_grid(
|
||||
size: CellSize,
|
||||
cells: &[Cell],
|
||||
cursor: Option<CellCoord>,
|
||||
limits: WireGridLimits,
|
||||
) -> Result<(), WireGridError> {
|
||||
let area = checked_area(size, limits)?;
|
||||
if cells.len() != area {
|
||||
return Err(WireGridError::CellCount {
|
||||
expected: area,
|
||||
actual: cells.len(),
|
||||
});
|
||||
}
|
||||
if let Some(cursor) = cursor
|
||||
&& (cursor.row >= size.rows || cursor.col >= size.cols)
|
||||
{
|
||||
return Err(WireGridError::Cursor {
|
||||
row: cursor.row,
|
||||
col: cursor.col,
|
||||
rows: size.rows,
|
||||
cols: size.cols,
|
||||
});
|
||||
}
|
||||
validate_cells(size, cells, limits)
|
||||
}
|
||||
|
||||
/// Glyph legality, wide-continuation topology, and the glyph budget.
|
||||
fn validate_cells(
|
||||
size: CellSize,
|
||||
cells: &[Cell],
|
||||
limits: WireGridLimits,
|
||||
) -> Result<(), WireGridError> {
|
||||
let cols = size.cols as usize;
|
||||
let mut glyph_bytes = 0usize;
|
||||
// Columns still owed to the preceding wide lead on this row.
|
||||
let mut pending_continuation = false;
|
||||
for (index, cell) in cells.iter().enumerate() {
|
||||
if cell.attachment.is_some() {
|
||||
return Err(WireGridError::Attachment { index });
|
||||
}
|
||||
let col = index % cols;
|
||||
if col == 0 && pending_continuation {
|
||||
// A wide lead in the final column would have to be completed
|
||||
// on the next row, which is not a footprint a cell grid can
|
||||
// express.
|
||||
return Err(WireGridError::Glyph {
|
||||
index: index - 1,
|
||||
reason: "wide glyph has no continuation column on its row",
|
||||
});
|
||||
}
|
||||
match &cell.glyph {
|
||||
Glyph::Continuation => {
|
||||
if !pending_continuation {
|
||||
return Err(WireGridError::Glyph {
|
||||
index,
|
||||
reason: "continuation without a preceding wide glyph",
|
||||
});
|
||||
}
|
||||
pending_continuation = false;
|
||||
}
|
||||
Glyph::Char(ch) => {
|
||||
if pending_continuation {
|
||||
return Err(WireGridError::Glyph {
|
||||
index,
|
||||
reason: "wide glyph is not followed by its continuation",
|
||||
});
|
||||
}
|
||||
let width = char_display_width(*ch).ok_or(WireGridError::Glyph {
|
||||
index,
|
||||
reason: "glyph is a control or zero-width character",
|
||||
})?;
|
||||
glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8(), limits)?;
|
||||
pending_continuation = width == 2;
|
||||
}
|
||||
Glyph::Cluster(bytes) => {
|
||||
if pending_continuation {
|
||||
return Err(WireGridError::Glyph {
|
||||
index,
|
||||
reason: "wide glyph is not followed by its continuation",
|
||||
});
|
||||
}
|
||||
let width = cluster_display_width(bytes, index)?;
|
||||
glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len(), limits)?;
|
||||
pending_continuation = width == 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if pending_continuation {
|
||||
return Err(WireGridError::Glyph {
|
||||
index: cells.len() - 1,
|
||||
reason: "wide glyph has no continuation column on its row",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Column width of a leading `Char` glyph, or `None` when it cannot lead.
|
||||
pub(crate) fn char_display_width(ch: char) -> Option<usize> {
|
||||
if ch.is_control() {
|
||||
return None;
|
||||
}
|
||||
match UnicodeWidthChar::width(ch) {
|
||||
Some(1) => Some(1),
|
||||
Some(2) => Some(2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Column width of a leading `Cluster` glyph.
|
||||
///
|
||||
/// Width is clamped into `1..=2` exactly as the terminal screen clamps it
|
||||
/// when it writes the cluster: a base plus combining marks may measure
|
||||
/// wider than two columns, and the screen occupies two. Clamping in one
|
||||
/// place and measuring in another is how a frame that renders correctly
|
||||
/// gets rejected on the wire.
|
||||
fn cluster_display_width(bytes: &[u8], index: usize) -> Result<usize, WireGridError> {
|
||||
if bytes.is_empty() {
|
||||
return Err(WireGridError::Glyph {
|
||||
index,
|
||||
reason: "cluster is empty",
|
||||
});
|
||||
}
|
||||
if bytes.len() > MAX_WIRE_GRID_GRAPHEME_BYTES {
|
||||
return Err(WireGridError::Glyph {
|
||||
index,
|
||||
reason: "cluster exceeds the per-cluster byte limit",
|
||||
});
|
||||
}
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| WireGridError::Glyph {
|
||||
index,
|
||||
reason: "cluster is not valid UTF-8",
|
||||
})?;
|
||||
if text.chars().any(char::is_control) {
|
||||
return Err(WireGridError::Glyph {
|
||||
index,
|
||||
reason: "cluster carries a control character",
|
||||
});
|
||||
}
|
||||
let width = UnicodeWidthStr::width(text);
|
||||
if width == 0 {
|
||||
return Err(WireGridError::Glyph {
|
||||
index,
|
||||
reason: "cluster occupies no columns",
|
||||
});
|
||||
}
|
||||
Ok(width.min(2))
|
||||
}
|
||||
|
||||
/// Accumulate glyph bytes against the aggregate budget.
|
||||
fn add_glyph_bytes(
|
||||
total: usize,
|
||||
add: usize,
|
||||
limits: WireGridLimits,
|
||||
) -> Result<usize, WireGridError> {
|
||||
let next = total.checked_add(add).ok_or(WireGridError::GlyphBudget {
|
||||
max: limits.max_glyph_bytes,
|
||||
})?;
|
||||
if next > limits.max_glyph_bytes {
|
||||
return Err(WireGridError::GlyphBudget {
|
||||
max: limits.max_glyph_bytes,
|
||||
});
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,595 @@
|
|||
//! Bottom-panel Stage 2B — the v21 protocol slice.
|
||||
//!
|
||||
//! Covers parent acceptance 37 (round-trip plus the two byte pins) and
|
||||
//! the shared/terminal-only validator split of Q#BP15. The daemon
|
||||
//! projection, the epoch state machine, and the GPU band are later
|
||||
//! slices of this stage and are not exercised here.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Color, Glyph, Style, UnderlineStyle};
|
||||
use pmacs_protocol::message::{
|
||||
AttachRequest, FrontendEvent, Hello, InstanceMessage, Modifiers, MouseButton, MouseKind,
|
||||
};
|
||||
use pmacs_protocol::panel::{
|
||||
MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload,
|
||||
};
|
||||
use pmacs_protocol::terminal::{
|
||||
MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState,
|
||||
};
|
||||
use pmacs_protocol::transport::{MAX_FRAME_BYTES, read_message, write_message};
|
||||
use pmacs_protocol::wire_grid::{MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES};
|
||||
use pmacs_protocol::{
|
||||
ADVERTISED_PROTOCOL_VERSION, BufferId, FrontendId, PROTOCOL_VERSION,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
};
|
||||
|
||||
use common::daemon::{TestDaemon, build_default_caps};
|
||||
|
||||
fn cell(ch: char) -> Cell {
|
||||
Cell {
|
||||
glyph: Glyph::Char(ch),
|
||||
style: Style::default(),
|
||||
attachment: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The style whose postcard encoding is as long as a legal `Style` gets.
|
||||
fn maximal_style() -> Style {
|
||||
Style {
|
||||
fg: Color::Rgb(0xff, 0xee, 0xdd),
|
||||
bg: Color::Rgb(0x11, 0x22, 0x33),
|
||||
bold: true,
|
||||
italic: true,
|
||||
underline: UnderlineStyle::Dashed,
|
||||
reverse: true,
|
||||
underline_color: Color::Rgb(0x44, 0x55, 0x66),
|
||||
}
|
||||
}
|
||||
|
||||
fn maximal_cell(glyph: Glyph) -> Cell {
|
||||
Cell {
|
||||
glyph,
|
||||
style: maximal_style(),
|
||||
attachment: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-column cluster of exactly `len` UTF-8 bytes.
|
||||
fn cluster_of_len(len: usize) -> Vec<u8> {
|
||||
assert!((1..=MAX_WIRE_GRID_GRAPHEME_BYTES).contains(&len));
|
||||
let mut text = String::with_capacity(len);
|
||||
if len % 2 == 1 {
|
||||
text.push(' ');
|
||||
} else {
|
||||
text.push('\u{e9}');
|
||||
}
|
||||
while text.len() < len {
|
||||
text.push('\u{301}');
|
||||
}
|
||||
assert_eq!(text.len(), len);
|
||||
text.into_bytes()
|
||||
}
|
||||
|
||||
fn panel_frame(rows: u32, cols: u32) -> PanelFrame {
|
||||
PanelFrame {
|
||||
buffer_id: BufferId::from_raw(9),
|
||||
panel_epoch: 3,
|
||||
geometry_epoch: 5,
|
||||
size: CellSize::new(rows, cols),
|
||||
cells: vec![cell(' '); (rows * cols) as usize],
|
||||
cursor: Some(CellCoord::new(0, 0)),
|
||||
focused: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame {
|
||||
TerminalFrame {
|
||||
buffer_id: BufferId::from_raw(9),
|
||||
size: CellSize::new(rows, cols),
|
||||
cells: vec![cell(' '); (rows * cols) as usize],
|
||||
cursor: Some(CellCoord::new(0, 0)),
|
||||
title: None,
|
||||
screen_generation: 1,
|
||||
selection: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
at_bottom: true,
|
||||
pid: 1,
|
||||
process: TerminalProcessState::Running,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 37 — version and round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn the_panel_stage_takes_protocol_v21() {
|
||||
assert_eq!(PROTOCOL_VERSION, 21);
|
||||
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21));
|
||||
// The wire family is reserved before it is activated: the production
|
||||
// server-first Hello must remain acceptable to already-shipped v20
|
||||
// clients throughout the dark protocol and daemon slices.
|
||||
assert_eq!(ADVERTISED_PROTOCOL_VERSION, 20);
|
||||
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_daemon_keeps_an_existing_v20_client_attachable() {
|
||||
let daemon = TestDaemon::spawn();
|
||||
let mut stream = daemon.connect();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.expect("set handshake timeout");
|
||||
|
||||
// This is the rejection point in an already-shipped client: it reads the
|
||||
// daemon's unsolicited Hello before it is able to identify its own
|
||||
// supported range or send AttachRequest.
|
||||
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
|
||||
let v20_client_supported_versions = 6..=20;
|
||||
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
|
||||
assert!(
|
||||
v20_client_supported_versions.contains(&hello.protocol_version),
|
||||
"an existing v20 client would reject the server-first Hello"
|
||||
);
|
||||
|
||||
write_message(
|
||||
&mut stream,
|
||||
&AttachRequest {
|
||||
protocol_version: hello.protocol_version,
|
||||
frontend_capabilities: build_default_caps(),
|
||||
initial_size: CellSize::new(24, 80),
|
||||
},
|
||||
)
|
||||
.expect("write v20 AttachRequest");
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
read_message::<InstanceMessage>(&mut stream).expect("read initial grid"),
|
||||
InstanceMessage::CellDelta {
|
||||
full_grid: true,
|
||||
..
|
||||
}
|
||||
),
|
||||
"the daemon must establish the v20 session, not merely send an acceptable Hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_present_panel_frame_round_trips_with_both_epochs() {
|
||||
let frame = panel_frame(2, 3);
|
||||
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(frame.clone()));
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||
let InstanceMessage::PanelFrame(PanelFramePayload::Present(got)) = decoded else {
|
||||
panic!("expected a Present panel frame, got {decoded:?}");
|
||||
};
|
||||
// Both epochs must survive: they are the identities every later
|
||||
// panel event validates against, so a frame that round-trips its
|
||||
// cells but drops an epoch would silently accept stale input.
|
||||
assert_eq!(got.panel_epoch, frame.panel_epoch);
|
||||
assert_eq!(got.geometry_epoch, frame.geometry_epoch);
|
||||
assert_eq!(got.buffer_id, frame.buffer_id);
|
||||
assert_eq!(got.size, frame.size);
|
||||
assert_eq!(got.cells, frame.cells);
|
||||
assert_eq!(got.cursor, frame.cursor);
|
||||
assert_eq!(got.focused, frame.focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_panel_payload_round_trips_as_its_own_state() {
|
||||
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Absent);
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert!(matches!(
|
||||
decoded,
|
||||
InstanceMessage::PanelFrame(PanelFramePayload::Absent)
|
||||
));
|
||||
// Absent must be distinguishable from a Present frame carrying no
|
||||
// cells: it is authoritative, and conflating the two would make
|
||||
// "hide the band" indistinguishable from "paint an empty band".
|
||||
let empty_present = InstanceMessage::PanelFrame(PanelFramePayload::Present(panel_frame(1, 1)));
|
||||
assert_ne!(
|
||||
postcard::to_allocvec(&empty_present).expect("encode"),
|
||||
bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_panel_events_round_trip() {
|
||||
let fid = FrontendId(4);
|
||||
let events = vec![
|
||||
FrontendEvent::FrontendCellGeometry {
|
||||
frontend_id: fid,
|
||||
geometry_epoch: 1,
|
||||
total: CellSize::new(40, 120),
|
||||
},
|
||||
FrontendEvent::PanelResizeRows {
|
||||
frontend_id: fid,
|
||||
geometry_epoch: 2,
|
||||
panel_epoch: 7,
|
||||
rows: 12,
|
||||
},
|
||||
FrontendEvent::PanelPointer {
|
||||
frontend_id: fid,
|
||||
geometry_epoch: 2,
|
||||
panel_epoch: 7,
|
||||
buffer_id: BufferId::from_raw(21),
|
||||
coord: CellCoord::new(3, 9),
|
||||
kind: MouseKind::Down(MouseButton::Left),
|
||||
mods: Modifiers::default(),
|
||||
},
|
||||
];
|
||||
for event in events {
|
||||
let bytes = postcard::to_allocvec(&event).expect("encode");
|
||||
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert_eq!(decoded, event);
|
||||
assert_eq!(decoded.frontend_id(), fid);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_pointer_carries_buffer_id_distinctly_from_panel_epoch() {
|
||||
// The two fields close different holes and neither subsumes the
|
||||
// other: `buffer_id` catches an A->B buffer replacement, while
|
||||
// `panel_epoch` catches close/hide/reopen of the SAME buffer, which
|
||||
// a buffer id alone cannot see. So each must independently reach the
|
||||
// wire — a field silently dropped from the encoding would let one of
|
||||
// those two stale gestures through.
|
||||
let base = |buffer: u64, panel_epoch: u64| FrontendEvent::PanelPointer {
|
||||
frontend_id: FrontendId(4),
|
||||
geometry_epoch: 2,
|
||||
panel_epoch,
|
||||
buffer_id: BufferId::from_raw(buffer),
|
||||
coord: CellCoord::new(1, 1),
|
||||
kind: MouseKind::Down(MouseButton::Left),
|
||||
mods: Modifiers::default(),
|
||||
};
|
||||
let encode = |e: &FrontendEvent| postcard::to_allocvec(e).expect("encode");
|
||||
|
||||
// Same panel epoch, different buffer: must differ on the wire.
|
||||
assert_ne!(encode(&base(1, 7)), encode(&base(2, 7)));
|
||||
// Same buffer, different panel epoch: must also differ.
|
||||
assert_ne!(encode(&base(1, 7)), encode(&base(1, 8)));
|
||||
|
||||
// And both survive decode rather than being defaulted.
|
||||
let event = base(31, 7);
|
||||
let decoded: FrontendEvent = postcard::from_bytes(&encode(&event)).expect("decode");
|
||||
let FrontendEvent::PanelPointer {
|
||||
buffer_id,
|
||||
panel_epoch,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
panic!("expected a PanelPointer, got {decoded:?}");
|
||||
};
|
||||
assert_eq!(buffer_id, BufferId::from_raw(31));
|
||||
assert_eq!(panel_epoch, 7);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 37 — byte pins on the previous final variant of each extended enum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn appending_panel_frame_does_not_move_the_previous_final_instance_discriminant() {
|
||||
// `InitialTargetResult` was the final v20 variant. Its encoding must
|
||||
// be byte-identical after `PanelFrame` is appended; if the new
|
||||
// variant were inserted anywhere earlier, this leading discriminant
|
||||
// byte would shift and every v20 peer would misread the wire.
|
||||
let msg = InstanceMessage::InitialTargetResult(
|
||||
pmacs_protocol::message::InitialTargetResult::Opened {
|
||||
buffer_id: BufferId::from_raw(1),
|
||||
},
|
||||
);
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
assert_eq!(
|
||||
bytes[0], 27,
|
||||
"InitialTargetResult must stay discriminant 27; got {bytes:?}"
|
||||
);
|
||||
// And the appended variant must be the next one, not a reused slot.
|
||||
let panel = InstanceMessage::PanelFrame(PanelFramePayload::Absent);
|
||||
let panel_bytes = postcard::to_allocvec(&panel).expect("encode");
|
||||
assert_eq!(panel_bytes[0], 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() {
|
||||
// `TerminalPointer` was the final v19/v20 variant of `FrontendEvent`.
|
||||
let event = FrontendEvent::TerminalPointer {
|
||||
frontend_id: FrontendId(2),
|
||||
buffer_id: BufferId::from_raw(3),
|
||||
coord: CellCoord::new(1, 1),
|
||||
kind: MouseKind::Down(MouseButton::Left),
|
||||
mods: Modifiers::default(),
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&event).expect("encode");
|
||||
assert_eq!(
|
||||
bytes[0], 12,
|
||||
"TerminalPointer must stay discriminant 12; got {bytes:?}"
|
||||
);
|
||||
// The three appended events take the next three slots, in order.
|
||||
let fid = FrontendId(2);
|
||||
for (expected, event) in [
|
||||
(
|
||||
13u8,
|
||||
FrontendEvent::FrontendCellGeometry {
|
||||
frontend_id: fid,
|
||||
geometry_epoch: 1,
|
||||
total: CellSize::new(1, 1),
|
||||
},
|
||||
),
|
||||
(
|
||||
14,
|
||||
FrontendEvent::PanelResizeRows {
|
||||
frontend_id: fid,
|
||||
geometry_epoch: 1,
|
||||
panel_epoch: 1,
|
||||
rows: 1,
|
||||
},
|
||||
),
|
||||
(
|
||||
15,
|
||||
FrontendEvent::PanelPointer {
|
||||
frontend_id: fid,
|
||||
geometry_epoch: 1,
|
||||
panel_epoch: 1,
|
||||
buffer_id: BufferId::from_raw(1),
|
||||
coord: CellCoord::new(0, 0),
|
||||
kind: MouseKind::Down(MouseButton::Left),
|
||||
mods: Modifiers::default(),
|
||||
},
|
||||
),
|
||||
] {
|
||||
let bytes = postcard::to_allocvec(&event).expect("encode");
|
||||
assert_eq!(bytes[0], expected, "wrong discriminant for {event:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 39 — the shared/terminal-only validator split
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() {
|
||||
let wide = u32::from(MAX_TERMINAL_COLS) + 1;
|
||||
|
||||
// The panel does not inherit the PTY per-axis cap: a 4K surface at a
|
||||
// small font is legitimately this wide, and the area bound is what
|
||||
// keeps the encoding inside the transport budget.
|
||||
let panel = panel_frame(1, wide);
|
||||
assert_eq!(panel.validate(), Ok(()));
|
||||
|
||||
// The terminal keeps it, and reports the axis that failed.
|
||||
let terminal = terminal_frame(1, wide);
|
||||
assert!(matches!(
|
||||
terminal.validate(),
|
||||
Err(TerminalFrameError::Size { cols, max_cols, .. })
|
||||
if cols == wide && max_cols == u32::from(MAX_TERMINAL_COLS)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_still_answers_to_the_shared_area_bound() {
|
||||
// Removing the per-axis cap must not remove the area bound: that is
|
||||
// the check that actually bounds the encoded size.
|
||||
let huge = panel_frame(1, 1);
|
||||
let mut huge = huge;
|
||||
huge.size = CellSize::new(1024, 1024);
|
||||
huge.cells = vec![cell(' '); 1];
|
||||
assert!(matches!(huge.validate(), Err(PanelFrameError::Area { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_cell_carrying_an_attachment_is_rejected() {
|
||||
// The attachment rejection is SHARED, not terminal-only, even though
|
||||
// the terminal-side message says "which terminals never use":
|
||||
// panels render no attachments either, so a shared rejection fails
|
||||
// closed for both.
|
||||
let mut frame = panel_frame(1, 2);
|
||||
frame.cells[1].attachment = Some(pmacs_protocol::cell::Attachment::ImageCell {
|
||||
image_id: 1,
|
||||
sub_x: 0,
|
||||
sub_y: 0,
|
||||
});
|
||||
assert!(matches!(
|
||||
frame.validate(),
|
||||
Err(PanelFrameError::Attachment { index: 1 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_glyph_topology_matches_the_terminal_rules() {
|
||||
// A wide lead with no continuation column on its row is rejected the
|
||||
// same way for both messages — the topology rule is shared.
|
||||
let mut frame = panel_frame(1, 1);
|
||||
frame.cells[0] = cell('\u{4e00}');
|
||||
assert!(matches!(
|
||||
frame.validate(),
|
||||
Err(PanelFrameError::Glyph { .. })
|
||||
));
|
||||
|
||||
let mut terminal = terminal_frame(1, 1);
|
||||
terminal.cells[0] = cell('\u{4e00}');
|
||||
assert!(matches!(
|
||||
terminal.validate(),
|
||||
Err(TerminalFrameError::Glyph { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_cursor_outside_its_grid_is_rejected() {
|
||||
let mut frame = panel_frame(2, 2);
|
||||
frame.cursor = Some(CellCoord::new(2, 0));
|
||||
assert!(matches!(
|
||||
frame.validate(),
|
||||
Err(PanelFrameError::Cursor {
|
||||
row: 2,
|
||||
rows: 2,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_epoch_panel_frame_is_rejected_on_the_wire() {
|
||||
// Epoch 0 is reserved for "never declared" (Q#BP2S1), so a frame
|
||||
// carrying it could otherwise match a receiver that has declared
|
||||
// nothing yet.
|
||||
let mut frame = panel_frame(1, 1);
|
||||
frame.panel_epoch = 0;
|
||||
assert!(matches!(
|
||||
frame.validate(),
|
||||
Err(PanelFrameError::ZeroEpoch { field: "panel" })
|
||||
));
|
||||
|
||||
let mut frame = panel_frame(1, 1);
|
||||
frame.geometry_epoch = 0;
|
||||
assert!(matches!(
|
||||
frame.validate(),
|
||||
Err(PanelFrameError::ZeroEpoch { field: "geometry" })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_frames_are_unchanged_by_the_factoring() {
|
||||
// The shared validator must not have altered terminal acceptance:
|
||||
// a valid frame still validates, and each terminal-only rule still
|
||||
// reports its own variant.
|
||||
assert_eq!(terminal_frame(3, 4).validate(), Ok(()));
|
||||
|
||||
let mut bad_bottom = terminal_frame(1, 1);
|
||||
bad_bottom.at_bottom = false;
|
||||
bad_bottom.scroll_offset = 0;
|
||||
assert!(matches!(
|
||||
bad_bottom.validate(),
|
||||
Err(TerminalFrameError::BottomState { .. })
|
||||
));
|
||||
|
||||
let mut bad_meta = terminal_frame(1, 1);
|
||||
bad_meta.title = Some("\u{7}".into());
|
||||
assert!(matches!(
|
||||
bad_meta.validate(),
|
||||
Err(TerminalFrameError::Metadata { field: "title", .. })
|
||||
));
|
||||
|
||||
let mut bad_count = terminal_frame(2, 2);
|
||||
bad_count.cells.pop();
|
||||
assert!(matches!(
|
||||
bad_count.validate(),
|
||||
Err(TerminalFrameError::CellCount {
|
||||
expected: 4,
|
||||
actual: 3
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 39 — the transport-safety ratchet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The largest legal panel frame, plus the same frame one glyph byte over.
|
||||
///
|
||||
/// Deliberately shaped `1 x MAX_PANEL_VISIBLE_CELLS`: a panel carries no
|
||||
/// per-axis cap, so this is a legal panel geometry a terminal frame
|
||||
/// cannot express, and it is therefore the worst case the terminal's own
|
||||
/// ratchet never measured.
|
||||
fn panel_budget_boundary_frames() -> (PanelFrame, PanelFrame) {
|
||||
/// Shortest cluster length postcard encodes with a two-byte length
|
||||
/// prefix, which is what makes a cluster cell maximally expensive.
|
||||
const WIDE_PREFIX_LEN: usize = 128;
|
||||
let area = MAX_PANEL_VISIBLE_CELLS;
|
||||
|
||||
// Every cell owes at least one glyph byte; the rest of the budget is
|
||||
// spent on as many two-byte-prefix clusters as it affords.
|
||||
let spare = MAX_WIRE_GRID_GLYPH_BYTES - area;
|
||||
let wide_cells = spare / (WIDE_PREFIX_LEN - 1);
|
||||
let remainder = spare % (WIDE_PREFIX_LEN - 1);
|
||||
assert!(wide_cells + usize::from(remainder > 0) <= area);
|
||||
|
||||
let wide = cluster_of_len(WIDE_PREFIX_LEN).into_boxed_slice();
|
||||
let single = cluster_of_len(1).into_boxed_slice();
|
||||
let mut cells = Vec::with_capacity(area);
|
||||
for index in 0..area {
|
||||
let glyph = if index < wide_cells {
|
||||
Glyph::Cluster(wide.clone())
|
||||
} else if index == wide_cells && remainder > 0 {
|
||||
Glyph::Cluster(cluster_of_len(remainder + 1).into_boxed_slice())
|
||||
} else {
|
||||
Glyph::Cluster(single.clone())
|
||||
};
|
||||
cells.push(maximal_cell(glyph));
|
||||
}
|
||||
|
||||
let cols = u32::try_from(area).expect("area fits u32");
|
||||
let exact = PanelFrame {
|
||||
buffer_id: BufferId::from_raw(u64::MAX),
|
||||
panel_epoch: u64::MAX,
|
||||
geometry_epoch: u64::MAX,
|
||||
size: CellSize::new(1, cols),
|
||||
cells,
|
||||
cursor: Some(CellCoord::new(0, cols - 1)),
|
||||
focused: true,
|
||||
};
|
||||
|
||||
let mut over = exact.clone();
|
||||
// One more byte of glyph, nothing else changed.
|
||||
let last = over.cells.len() - 1;
|
||||
over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(2).into_boxed_slice()));
|
||||
|
||||
(exact, over)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_legal_panel_frame_encodes_below_the_transport_cap() {
|
||||
let (exact, over) = panel_budget_boundary_frames();
|
||||
assert_eq!(exact.validate(), Ok(()));
|
||||
|
||||
// The fixture must actually sit ON the boundary, or the ratchet
|
||||
// below measures something smaller than the worst case and would
|
||||
// stay green while a real maximum frame overran the transport.
|
||||
let mut glyph_bytes = 0usize;
|
||||
for cell in &exact.cells {
|
||||
glyph_bytes += match &cell.glyph {
|
||||
Glyph::Char(ch) => ch.len_utf8(),
|
||||
Glyph::Cluster(bytes) => bytes.len(),
|
||||
Glyph::Continuation => 0,
|
||||
};
|
||||
}
|
||||
assert_eq!(
|
||||
glyph_bytes, MAX_WIRE_GRID_GLYPH_BYTES,
|
||||
"the measured fixture must spend the whole aggregate budget"
|
||||
);
|
||||
let over_glyph_bytes = over
|
||||
.cells
|
||||
.iter()
|
||||
.map(|cell| match &cell.glyph {
|
||||
Glyph::Char(ch) => ch.len_utf8(),
|
||||
Glyph::Cluster(bytes) => bytes.len(),
|
||||
Glyph::Continuation => 0,
|
||||
})
|
||||
.sum::<usize>();
|
||||
assert_eq!(
|
||||
over_glyph_bytes,
|
||||
MAX_WIRE_GRID_GLYPH_BYTES + 1,
|
||||
"the rejecting twin must be exactly one byte over the aggregate budget"
|
||||
);
|
||||
|
||||
// One byte over is rejected, which is what makes `exact` maximal.
|
||||
assert!(matches!(
|
||||
over.validate(),
|
||||
Err(PanelFrameError::GlyphBudget { .. })
|
||||
));
|
||||
|
||||
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(exact));
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
assert!(
|
||||
bytes.len() < MAX_FRAME_BYTES,
|
||||
"largest legal panel frame encodes to {} bytes, at or above the \
|
||||
{MAX_FRAME_BYTES}-byte transport cap; the aggregate glyph bound no \
|
||||
longer keeps panel traffic inside the existing transport limit",
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -93,9 +93,9 @@ mod crdt {
|
|||
use pmacs::cell::CellSize;
|
||||
use pmacs::crdt::CrdtState;
|
||||
use pmacs::protocol::{
|
||||
AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget,
|
||||
InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage,
|
||||
PROTOCOL_VERSION, SessionBootstrapRequest,
|
||||
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent,
|
||||
FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities,
|
||||
InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, SessionBootstrapRequest,
|
||||
};
|
||||
use pmacs::transport::{read_message, write_message};
|
||||
|
||||
|
|
@ -244,11 +244,11 @@ mod crdt {
|
|||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.expect("set target frontend timeout");
|
||||
let hello: Hello = read_message(&mut stream).expect("target frontend Hello");
|
||||
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
|
||||
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
|
||||
write_message(
|
||||
&mut stream,
|
||||
&AttachRequest {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
protocol_version: hello.protocol_version,
|
||||
frontend_capabilities: FrontendCapabilities {
|
||||
multi_frontend: true,
|
||||
crdt_replica: true,
|
||||
|
|
@ -583,7 +583,7 @@ mod crdt {
|
|||
facts
|
||||
.get("server_protocol_version")
|
||||
.and_then(|value| value.parse::<u32>().ok()),
|
||||
Some(PROTOCOL_VERSION)
|
||||
Some(ADVERTISED_PROTOCOL_VERSION)
|
||||
);
|
||||
assert_eq!(
|
||||
facts.get("spawned_daemon").map(String::as_str),
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ use pmacs::cell::Color;
|
|||
#[cfg(feature = "crdt")]
|
||||
use pmacs::overlay_color::color_for_slot;
|
||||
use pmacs::protocol::{
|
||||
AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key,
|
||||
KeyEvent, Modifiers, PROTOCOL_VERSION,
|
||||
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason,
|
||||
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION,
|
||||
};
|
||||
use pmacs::transport::{read_message, write_message};
|
||||
|
||||
|
|
@ -52,9 +52,9 @@ use common::daemon::{
|
|||
/// Read the daemon's `Hello`, send our `AttachRequest`, return the Hello.
|
||||
fn do_handshake(stream: &mut UnixStream) -> Hello {
|
||||
let hello: Hello = read_message(stream).expect("read Hello");
|
||||
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
|
||||
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
|
||||
let req = AttachRequest {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
protocol_version: hello.protocol_version,
|
||||
frontend_capabilities: build_default_caps(),
|
||||
initial_size: CellSize::new(24, 80),
|
||||
};
|
||||
|
|
@ -418,7 +418,7 @@ fn version_mismatch_clean_disconnect() {
|
|||
|
||||
// Read Hello.
|
||||
let hello: Hello = read_message(&mut stream).expect("Hello");
|
||||
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
|
||||
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
|
||||
|
||||
// Send AttachRequest with wrong protocol version.
|
||||
let req = AttachRequest {
|
||||
|
|
@ -431,7 +431,7 @@ fn version_mismatch_clean_disconnect() {
|
|||
// Expect Goodbye(VersionMismatch).
|
||||
match read_message::<InstanceMessage>(&mut stream) {
|
||||
Ok(InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client })) => {
|
||||
assert_eq!(server, PROTOCOL_VERSION);
|
||||
assert_eq!(server, ADVERTISED_PROTOCOL_VERSION);
|
||||
assert_eq!(client, 999);
|
||||
}
|
||||
other => panic!("expected VersionMismatch Goodbye, got {other:?}"),
|
||||
|
|
@ -1145,9 +1145,9 @@ fn m10_10_production_attach_negotiates_crdt_replica() {
|
|||
|
||||
// Production handshake — NOT the test `attach_multi()` path.
|
||||
let hello: Hello = read_message(&mut stream).expect("read Hello");
|
||||
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
|
||||
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
|
||||
let req = AttachRequest {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
protocol_version: hello.protocol_version,
|
||||
frontend_capabilities: pmacs::attach::build_capabilities(),
|
||||
initial_size: CellSize::new(24, 80),
|
||||
};
|
||||
|
|
@ -1183,9 +1183,9 @@ fn m10_10_production_attach_non_crdt_build_does_not_negotiate_crdt_replica() {
|
|||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let _hello: Hello = read_message(&mut stream).expect("read Hello");
|
||||
let hello: Hello = read_message(&mut stream).expect("read Hello");
|
||||
let req = AttachRequest {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
protocol_version: hello.protocol_version,
|
||||
frontend_capabilities: pmacs::attach::build_capabilities(),
|
||||
initial_size: CellSize::new(24, 80),
|
||||
};
|
||||
|
|
@ -2171,7 +2171,7 @@ fn m10_10_f14_production_path_keystroke_flows_to_broadcast() {
|
|||
.unwrap();
|
||||
let hello_a: Hello = read_message(&mut stream_a).expect("A Hello");
|
||||
let req_a = AttachRequest {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
protocol_version: hello_a.protocol_version,
|
||||
frontend_capabilities: pmacs::attach::build_capabilities(),
|
||||
initial_size: CellSize::new(24, 80),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue