Compare commits
No commits in common. "main" and "lsp-file-watch-d3" have entirely different histories.
main
...
lsp-file-w
|
|
@ -4,19 +4,6 @@ on:
|
|||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# Run CI on demand, on any ref.
|
||||
#
|
||||
# A CI-side red on a platform this project has no machine for --- U11
|
||||
# is macOS/`lua54` --- cannot have its merge-base control run locally.
|
||||
# Without this trigger the only way to get a contemporaneous `main`
|
||||
# run is to re-run a job from whenever `main` last happened to be
|
||||
# pushed, which on PR #243 meant a run EIGHT DAYS OLD.
|
||||
#
|
||||
# No inputs, deliberately. This runs exactly what a push runs; it is
|
||||
# not a hook for varying commands. Experiments that need that ---
|
||||
# U9's test-binary concurrency control, R7's gate conditions --- need
|
||||
# their own changes, and CI does not invoke `scripts/gate` at all.
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
|
|
|||
426
COHERENCE.md
426
COHERENCE.md
|
|
@ -98,18 +98,18 @@ remain open to them.
|
|||
| 2 | Golden product journey | **Runs end to end, thin at 11–12** | **Step 1 now works — v1.1.0 ships binaries (#211)**, so the journey no longer begins with a source build. `pmacs .` opens the directory (1a); the interface introduces itself (1b-3); a missing language server says so (1b-2, #204); a build is bound and prefilled (1b-1, #203). **Steps 11 (background-work ownership, §9) and 12 (session restore) are the remaining thin end** |
|
||||
| 3 | Zero-configuration state | **Partial** | Defaults genuinely strong; missing-tool failure is silent, not graceful |
|
||||
| 4 | Progressive disclosure | **Inverted** | The advanced level is real; the beginner level is the missing one |
|
||||
| 5 | Unified discoverability | **Partial** | Discovery Stage 1: eleven `help.*` commands (describe key/mode/hook/buffer/command/setting, where-is, list commands/keybindings/settings, apropos) over the existing registries, indexed by `M-x help`. Commands, keys, modes, hooks and settings are now reachable, and `*workers*` already was (`M-x editor.list-workers`); **packages have no comparable surface** (§13), and workers carry a required **purpose** and a statusline **activity indicator** since #232, but still lack an accountable **owner** and any **hierarchy** (§9). `Command` still has no title/category/flags, and the Rust help layer is still orphaned. M-x rows carry descriptions since #228 (protocol v23) |
|
||||
| 5 | Unified discoverability | **Partial** | Discovery Stage 1: eleven `help.*` commands (describe key/mode/hook/buffer/command/setting, where-is, list commands/keybindings/settings, apropos) over the existing registries, indexed by `M-x help`. Commands, keys, modes, hooks and settings are now reachable, and `*workers*` already was (`M-x editor.list-workers`); **packages have no comparable surface** (§13), and workers still lack owner/purpose/hierarchy and any indicator (§9). `Command` still has no title/category/flags, M-x rows are still bare names, and the Rust help layer is still orphaned |
|
||||
| 6 | Interaction islands | **Weak, and growing** | Six hardcoded key-interception shadows; no transient-keymap mechanism exists |
|
||||
| 7 | First-class workspaces | **Missing (conventions only)** | Marker walk + four independent consumers; no workspace object |
|
||||
| 8 | Execution locations | **Missing (architecture ready)** | SSH attach works; "location" is not a value anywhere |
|
||||
| 9 | Worker ownership | **Mechanism with purpose, without ownership** | Cancellation solid; **purpose required on every job and process, and a statusline activity indicator, since #232**; still **no accountable owner and no hierarchy**; four disjoint activity views |
|
||||
| 9 | Worker ownership | **Mechanism without identity** | Cancellation solid; no owner/purpose/hierarchy; four disjoint activity views |
|
||||
| 10 | Extension trust classes | **Missing (one class)** | Shared Lua state, `__index = _G`; MCP is the one out-of-process seam |
|
||||
| 11 | Config layering + provenance | **Partial (foundation only)** | Typed registry is right; 5 settings live in it; no value provenance |
|
||||
| 12 | Profiles | **Missing** | One hardcoded default keymap; not a named concept |
|
||||
| 13 | Package lifecycle UX | **Resolution without lifecycle** | Mature resolver/lockfile; init-only install; no uninstall/disable/search |
|
||||
| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive with executable call sites in **two** modules — `lsp.lua` (**4**) and **`git.lua` (1, `*git-status*`, #227)** — so it is **no longer LSP-only**; *(counted excluding comment mentions, which an earlier `grep -c` included)*; buffer-list and search re-implement it; **the bottom panel is COMPLETE — both frontends, and Stage 3 flipped the adopter default so omission means the panel**. **Tree is implemented (◐) with the LSP outline as its one adopter; the remaining consumers, including dired's `i`, have not adopted** |
|
||||
| 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; **Git integration reaches status and diff (#227, Stage 1) and no further** — §15's ground truth is authoritative, and this row previously said "no git integration at all" |
|
||||
| 16 | Semantic frontend | **Architectural: Strong · Product: Weak** | v6..=v23 schema support; production attach remains v20 during the dark panel slice; degradation practiced. **The two subgrades and the product criteria live in §16** — the row points there rather than carrying a grade of its own, so the GUI-as-a-product half cannot hide inside an architectural `Strong` |
|
||||
| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive but only **4** call sites, all LSP panels (`*lsp*` added post-audit by #204); buffer-list and search re-implement it; **the bottom panel is COMPLETE — both frontends, and Stage 3 flipped the adopter default so omission means the panel**. **Tree is implemented (◐) with the LSP outline as its one adopter; the remaining consumers, including dired's `i`, have not adopted** |
|
||||
| 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all |
|
||||
| 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced |
|
||||
| 17 | Distribution | **Partial** | **v1.1.0 ships prebuilt Linux/macOS binaries on tag** (#211) with checksums and a stated glibc floor. No channels, in-place update, rollback, signing, or package-manager distribution |
|
||||
| 18 | Onboarding | **Partial** | Journey Stage 1b-3: an unconfigured launch greets in `*scratch*` naming `M-x` and four real bindings, and `M-x help` renders a cheat sheet. Still no tutorial and `C-h` still deletes a word — deliberately, see §18 |
|
||||
| 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` carries 45 pins over steps 2, 3, 4, 5, 6 and 9 — the ratchet is real and stages add rows to it. The other five §19 scenarios (workspace lifecycle, worker ownership, config provenance, package lifecycle, extension isolation) are still unwritten |
|
||||
|
|
@ -356,200 +356,7 @@ This journey should become a release gate. New architectural work should
|
|||
be evaluated partly by whether it improves, preserves, or complicates
|
||||
the journey.
|
||||
|
||||
### 2a. Required subclaims per step (the GUI arc, Stage 0)
|
||||
|
||||
**A grade whose subclaims are unstated is not falsifiable.** The
|
||||
per-frontend table below grades each (step, frontend) cell as the
|
||||
**minimum over that step's required subclaims** — never the first word
|
||||
of a prose verdict. This section is that list, published so a reader can
|
||||
disagree with a grade by pointing at a subclaim rather than at taste.
|
||||
|
||||
Two rules decide what belongs here. **Discoverability is a subclaim
|
||||
wherever it is the step's substance**, not an annotation on it — steps
|
||||
4, 7, 8, 9 and 11 are graded on whether a capability is *findable*, because
|
||||
"present but nobody can find it" is the failure those steps exist to
|
||||
catch. And **any defect cited as the reason a cell is below `Works` must be a
|
||||
subclaim**: an annotation cannot absorb a failing one. *Narrowed from
|
||||
"anything named as a defect", which swept in limitations this section
|
||||
explicitly excludes — no package manager, no tutorial — and so
|
||||
contradicted its own exclusions.*
|
||||
|
||||
| # | Step | Required subclaims |
|
||||
|---|---|---|
|
||||
| 1 | Install | (a) a documented install path completes on a clean machine; (b) the installed binary launches |
|
||||
| 2 | Launch unconfigured | (a) starts with no user config; (b) presents a usable editing surface; (c) raises no error the user must dismiss |
|
||||
| 3 | Open real project | (a) a file opens from the CLI; (b) a file opens from inside the editor; (c) a directory opens and is browsable; (d) the project root is established |
|
||||
| 4 | Understand interface | (a) the mode line reports buffer state; (b) a help surface exists; (c) **discoverable** — the route to it is advertised in-product, not only known |
|
||||
| 5 | Edit | (a) text edits apply and render; (b) undo/redo; (c) selection, kill and yank; (d) **the full contents of an overlong line are reachable**, by wrapping or horizontal navigation |
|
||||
| 6 | Language intelligence | (a) a server attaches for a supported language; (b) diagnostics surface; (c) completion, hover and go-to-definition are available; (d) styling reflects the server; (e) **failure is visible and actionable** — both a startup failure and a *later* crash say so, rather than intelligence silently ceasing |
|
||||
| 7 | Find symbol / file | (a) open a file by path; (b) browse to a file without knowing its path; (c) find a symbol; (d) **discoverable** — the file, browse and symbol routes are **advertised in-product**, not merely bound |
|
||||
| 8 | Open terminal | (a) a terminal opens; (b) input and output round-trip; (c) it can be closed or killed; (d) **discoverable** — reachable by an advertised binding |
|
||||
| 9 | Build / test | (a) a build or test command runs **in the opened project's context**; (b) its output is captured; (c) the output is navigable; (d) **discoverable** — §2's own criteria say "Build or test command discoverable" (line above), which is what `C-c c` was bound to satisfy |
|
||||
| 10 | Inspect error | (a) errors are enumerated; (b) navigation reaches the site; (c) the site is marked in the buffer |
|
||||
| 11 | Understand what background work is running | (a) work in flight is **visible without asking**; (b) a detailed view is reachable that names each substantial background work item's **purpose and accountable owner — not merely its kind or dispatcher** — **across jobs, processes, servers and terminals**; (c) **discoverable** — reachable by an advertised binding; (d) cancellable |
|
||||
| 12 | Close + restore | (a) closing is clean and loses no data; (b) per-file state (cursor, scroll) restores; (c) the open-buffer set and window layout restore |
|
||||
|
||||
**Aggregation inside a subclaim, stated because it decided two cells.**
|
||||
A subclaim that names a conjunction — "completion, hover and
|
||||
go-to-definition"; "a startup failure *and* a later crash" — is graded
|
||||
by the same minimum rule as the step: **an absent member makes the
|
||||
subclaim `Missing`, not `Partial`.** Degradation is for a member that is
|
||||
present and qualified; absence is absence at every level. Steps 6 and 11
|
||||
are `Missing` on this rule, and an earlier draft graded both `Partial`
|
||||
by treating a conjunction as one degradable atom — which would have made
|
||||
the ordinal mean something different at the subclaim level than at the
|
||||
step level, and is the reading the framing's own Step 7 worked example
|
||||
already rejects.
|
||||
|
||||
**3(c) requires a browsable directory SURFACE, not dired.** Dired is
|
||||
today's evidence for it, not its definition — so a frontend inherits
|
||||
whatever browsing it actually has, which is precisely what the
|
||||
per-frontend columns exist to expose. A subclaim naming the
|
||||
implementation would have graded the mechanism instead of the journey.
|
||||
|
||||
**Deliberately NOT subclaims.** Performance, aesthetics, and parity with
|
||||
other editors: each is real, and each is graded elsewhere or nowhere.
|
||||
**5(d) is the boundary case and is deliberately a correctness claim**:
|
||||
unreachable text is the long-lines arc's defect, while its 100 MB timing
|
||||
witness stays a separate performance gate. "Large files stay usable" was
|
||||
in an earlier draft of this row and was performance wearing a
|
||||
correctness coat.
|
||||
Folding them in here would make every cell a judgement about taste and
|
||||
the ordinal would stop meaning anything. **Frontend-specific
|
||||
limitations are also not subclaims** — they are what the three columns
|
||||
*measure*, so encoding them here would beg the question.
|
||||
|
||||
**Step 12(c) is the worked example of why subclaims are per step and
|
||||
not per frontend.** It is a required subclaim everywhere, and Q#DS9
|
||||
makes it structurally impossible on any daemon-backed frontend — so the
|
||||
GPU cell fails it by construction rather than by omission. That is a
|
||||
finding the old single-verdict table could not express, because "Partial"
|
||||
was doing the work of both "some of this is missing" and "some of this
|
||||
cannot exist yet".
|
||||
|
||||
### 2b. The per-frontend journey table
|
||||
|
||||
Each cell is the **minimum over §2a's required subclaims**, with the
|
||||
binding subclaim named. Columns: **local TUI** (`pmacs .`), **attached
|
||||
TUI** (`pmacs --attach`), **GPU**. **The bar is GPU ≥ local TUI**; the
|
||||
attached column is evidence, separating a daemon-boundary gap from a
|
||||
frontend-local one.
|
||||
|
||||
Anchors: `COHERENCE.md` §2's ground-truth rows, the GUI arc audit at
|
||||
`4bc55e8` (`docs/gui-arc-framing.md` §2), Q#DS9
|
||||
(`docs/desktop-save-framing.md`), and `builtin/runtime/welcome.lua`.
|
||||
|
||||
**What counts as "advertised in-product", stated because an earlier
|
||||
draft of this paragraph got it wrong and mis-graded two steps.** The
|
||||
welcome advertises **both** a key table (`C-x C-f`, `C-c t`, `C-c c`,
|
||||
`C-x b`) **and prose** — its first line names `M-x` and `M-x help`
|
||||
(`welcome.lua:61`). Advertisement is therefore **transitive through the
|
||||
help graph**: a command reachable from an advertised route counts as
|
||||
advertised, which is how `help.list-keybindings` carries browse and
|
||||
symbol (step 7) and how `help.list-commands` carries
|
||||
`editor.list-workers` (step 11). Reading only the four-entry key table
|
||||
is what produced the earlier `Partial` at steps 4 and 7.
|
||||
|
||||
**A binding is a stronger claim than a route, and 11(c) asks for the
|
||||
binding.** That is why step 11 still fails while step 7 passes: both are
|
||||
reachable through the help graph, but **no binding reaches
|
||||
`editor.list-workers`**. `*workers*` is not binding-free — it carries a
|
||||
buffer-local `C-c C-k` for `workers.cancel-at-point`
|
||||
(`async.lua`; `docs/keybindings.md:282`) — and that binding lives
|
||||
*inside* the view, so it cannot help anyone find it. The absent thing is
|
||||
specifically **a binding that opens it**.
|
||||
|
||||
| # | Step | local TUI | attached TUI | GPU | binding subclaim |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Install | Works | Works | **Works** | — release builds, ships and *verifies* `pmacs-gpu` (`release.yml:149,168,195`) |
|
||||
| 2 | Launch unconfigured | Works | Works | Works | — |
|
||||
| 3 | Open real project | Works | Works | Works | 3(c) satisfied by a browsable surface, not by dired specifically |
|
||||
| 4 | Understand interface | Works | Works | Works | 4(c) satisfied — the welcome's **first line** says "M-x runs any command; **M-x help lists the keys**" (`welcome.lua:61`). An earlier draft graded this Partial by reading only the four-entry key table and ignoring the prose above it |
|
||||
| 5 | Edit | Works | Works | **Partial** | 5(a) on GPU — **no IME, no `set_ime_allowed`**, so composed/CJK input is impossible; Latin editing is fine. Local/attached satisfy 5(d) by wrap and by QoL Stage 5 horizontal scroll |
|
||||
| 6 | Language intelligence | **Missing** | **Missing** | **Missing** | 6(e) — startup failure surfaces; a **later crash does not**, and an absent member of a conjunction makes the subclaim **Missing**, not degraded (§2a's aggregation rule). `server_is_live` is consulted at five sites and every one *skips* work rather than reporting. GPU additionally degrades 6(d) — single-authority semantic styling vs the grid's `merge_styles` — but the grade is already floored by 6(e) |
|
||||
| 7 | Find symbol / file | Works | Works | Works | 7(d) satisfied **under the published "advertised in-product" wording**: the advertised `M-x help` route reaches `help.list-keybindings`, which lists **every registered binding** (`help.lua`; asserted over every sequence at `discovery_acceptance.rs:208`), so browse and symbol are advertised transitively. *If the intent is direct advertisement only, 7(d) must say so — and the framing's own worked example then forces **Missing**, never Partial* |
|
||||
| 8 | Open terminal | Works | Works | Works | 8(c) satisfied by the **global** `M-x buffer.kill-this` (`default.lua:1209`); killing a terminal buffer prunes the session and reaps the process (`vterm_stage1_acceptance.rs:336`). 8(c) never required a terminal-*specific* command. 8(d) passes on the welcome's `C-c t` |
|
||||
| 9 | Build / test | Works | Works | Works | 9(d) passes on the welcome's `C-c c` entry, not on the bare binding; 9(a) runs in the detected project's context |
|
||||
| 10 | Inspect error | Works | Works | Works | all of 10(a–c) hold. *Being gated on step 6 or 9 is a **dependency, not a subclaim**, and §2a's membership rule forbids an annotation from lowering a grade — an earlier draft let it do exactly that. Grading the dependency would require adding it as a subclaim first* |
|
||||
| 11 | Understand background work | **Missing** | **Missing** | **Missing** | 11(c) — **no binding opens `editor.list-workers`**, so a keybinding listing cannot lead anyone to it: the subclaim is **absent**, not degraded, and it floors the cell. *The view is not binding-free — `C-c C-k` runs `workers.cancel-at-point` buffer-locally inside it (`docs/keybindings.md:282`) — but a binding that only works once you are already there cannot satisfy a discoverability subclaim.* 11(b) fails too — #232 gives **purpose**, never an accountable **owner**, and the planes stay disjoint (jobs in `*workers*`, processes in `pmacs.process.list`, servers in `*lsp*`, terminals **nowhere**). 11(a) passes on the activity indicator |
|
||||
| 12 | Close + restore | **Partial** | **Missing** | **Missing** | 12(c) — local restores nothing beyond per-file state (desktop-save is opt-in); on **both daemon-backed frontends it is a structural no-op** (Q#DS9), so they fail it **by construction** |
|
||||
|
||||
**Closure condition 2 currently FAILS, with KNOWN failures at two
|
||||
steps and the exact set pending verification.** GPU is below local TUI
|
||||
at **step 5** (Partial vs Works) and **step 12** (Missing vs Partial),
|
||||
and every *graded* cell elsewhere is equal — but GPU 3(c) and 9 rest on
|
||||
inference (below), so a third failure cannot be ruled out until they are
|
||||
verified. "Exactly two" would be a stronger claim than this table's own
|
||||
evidence supports, and an earlier draft made it. GPU 6(c) is inferred
|
||||
too, but cannot change step 6, which is already floored at `Missing` by
|
||||
6(e) on all three frontends. That is the whole of
|
||||
the GPU's journey deficit as this table measures it — a narrower result
|
||||
than "the GUI feels behind", and a falsifiable one. It survived a review
|
||||
round that corrected six of the twelve rows, which is some evidence it
|
||||
is a property of the tree rather than of the grader.
|
||||
|
||||
**The journey's two worst steps are frontend-INDEPENDENT**, and that is
|
||||
the table's other finding. Steps 6 and 11 grade `Missing` in all three
|
||||
columns: language intelligence dies silently after a server crash, and
|
||||
**no binding opens** the background-work view — a route exists
|
||||
(`M-x help` → `help.list-commands` → `editor.list-workers`), and a
|
||||
buffer-local `C-c C-k` exists *within* the view, but nothing bound leads
|
||||
to it. That is why 11(c) asks for a binding rather than a route, and why
|
||||
the binding it asks for is one that **reaches** the view. Neither is GUI work,
|
||||
neither is closed by this arc, and both were previously carried as
|
||||
`Partial` — which is how they stayed off the critical path.
|
||||
|
||||
**The attached column earns its place at those two rows, and they point
|
||||
opposite ways.** Step 12 fails on attached TUI *and* GPU, so it is a
|
||||
**daemon-boundary** gap that no GPU work can close — Q#DS9 and the
|
||||
workspace object own it, which is why Stage 4b is P2-gated. Step 5
|
||||
fails on GPU *alone*, so it is **frontend-local** — Stage 1d's IME work
|
||||
closes it. A single merged TUI column would have shown two identical
|
||||
red cells and no way to tell those apart.
|
||||
|
||||
**Inference flags — and when they must be closed (user ruling,
|
||||
2026-08-11).** These do **not** block the Stage 0 docs PR. They **must**
|
||||
be verified **before this table is first enforced as a release gate**.
|
||||
**3(c) and 9 can change the deficit set**; **6(c) cannot change step 6's
|
||||
grade today** — 6(e) already floors it at `Missing` on all three
|
||||
frontends — but still needs evidentiary closure rather than standing
|
||||
indefinitely as an assumption.
|
||||
|
||||
GPU 3(c) is inferred **in both halves** —
|
||||
rendering and browsing interaction — as are GPU 9 (compile output
|
||||
renders like any other daemon buffer) and GPU 6(c) (hover reaches the
|
||||
echo area as on the grid). All must be verified before this table is
|
||||
used as a release gate.
|
||||
|
||||
*An earlier draft called 3(c)'s rendering half "verified" on
|
||||
`gpu_invocation_acceptance.rs:706`. That test runs
|
||||
`--headless-managed-probe`, which connects, receives and DECODES a
|
||||
`BufferSnapshot` into text; it never constructs GPU render state and
|
||||
never calls `render_offscreen` (`pmacs-gpu/src/main.rs:1065`). It
|
||||
witnesses delivery and decoding — which is real, and is not rendering.
|
||||
Retracted rather than softened, because "verified" was the word doing
|
||||
the damage.*
|
||||
|
||||
### Ground truth: the journey today — EVIDENCE ONLY; §2b OWNS THE GRADES
|
||||
|
||||
**The verdict column below is the SUPERSEDED single-verdict model.**
|
||||
§2b's per-frontend table is authoritative for every grade, and where the
|
||||
two disagree — steps 4, 6, 7, 10 and 11 — **§2b is correct and this
|
||||
table's verdict is historical.** It disagrees because it grades by a
|
||||
prose string rather than by the minimum over declared subclaims, which
|
||||
is the reading §2a and §2b exist to replace.
|
||||
|
||||
**Kept rather than deleted, and kept rather than re-synced.** Its value
|
||||
is the per-step *evidence* — what exists, what was measured, which PR
|
||||
changed it — which §2b's cells cite and do not restate. Re-syncing its
|
||||
verdicts would restore a second source of truth for grades and
|
||||
guarantee this drift recurs; marking it removes the second source
|
||||
instead.
|
||||
|
||||
**A historical grade may stand here. A false statement about the tree
|
||||
may not** — the two are different, and only the first is what "kept for
|
||||
its evidence" licenses. Step 8's "no close/kill command" was the latter
|
||||
and is corrected in place below.
|
||||
### Ground truth: the journey today
|
||||
|
||||
**Grade: reaches step 5; thin from step 6 on.** Was **broken at step 3**
|
||||
at audit time:
|
||||
|
|
@ -588,11 +395,11 @@ Full verdict table:
|
|||
| 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments. Journey Stage 1b-3 adds a welcome in `*scratch*` and `M-x help`; **still Partial** because `C-h` deletes a word (deliberately — §18) and there is no tutorial |
|
||||
| 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config |
|
||||
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua`). **Journey Stage 1b-2 (#204) ended the silence** for a server that fails to *start*: the status line names the command, language and errno once per `(language, root, command)`; the modeline reads `LSP:!` instead of nothing; and `M-x lsp.status` renders `*lsp*` over the `status_buffer_text()` renderer that had existed since M4.8 with no caller. **Still Partial**, for a reason unaffected by that landing: a server that starts and then *crashes* is still unsurfaced — `LspEventKind::Crashed` is pushed and no builtin subscriber handles it |
|
||||
| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` are bound and server-gated. **The "advertised nowhere" claim previously recorded here was false** under the transitive-help contract (§2b): being bound, they are named by `help.list-keybindings`, which the welcome's advertised `M-x help` reaches — which is why §2b grades this step `Works`. Corrected in place as a statement about the tree, while the historical verdict at the left stands. Still true: no workspace-symbol command, and `pmacs.index.*` has no UI |
|
||||
| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and `C-c t` is not re-advertised there. **The "no close/kill command" claim previously recorded here was false**: `M-x buffer.kill-this` is global (`builtin/commands/default.lua:1209`) and killing a terminal buffer prunes the session and reaps the owned process (`tests/vterm_stage1_acceptance.rs:336`). Corrected in place because it is a statement about the tree, not a historical grade. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
|
||||
| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
|
||||
| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and there is still **no close/kill command**, which is the remaining half of this step's discoverability gap. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
|
||||
| 9 | Build / test | **Works** | Journey Stage 1b-1 (#203): `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind (`pmacs.compile.defaults`, seeded `rust = "cargo build"`, extensible from `init.lua`) via `ProjectKind::Rust` — **not** `Cargo`, see §24. The prompt **captures** its directory rather than re-resolving at accept time, so the command it offers and the directory it runs in cannot drift while the minibuffer waits. Still defaults cwd to the detected project root and parses Rust `-->` errors. Named limitation: after `pmacs <dir>` the active buffer is dired's and pathless, so the cwd falls back to the process cwd — §8's execution-location model owns that, and the degradation stays coherent (no suggestion is offered for a directory with no detected Cargo project) |
|
||||
| 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first |
|
||||
| 11 | See background work | **Partial** | **Statusline activity indicator since #232** — in-flight count plus the oldest job's purpose, absent entirely when idle, through `ui.activity-indicator`. `*workers*` view via `M-x editor.list-workers`, **with no binding that OPENS it**; `C-c C-k` cancel-at-point works buffer-locally once inside. *Was "Works but undiscoverable — no statusline spinner/progress indicator anywhere"; #232 shipped exactly that indicator on 2026-08-09 and the row went stale the same day* |
|
||||
| 11 | See background work | **Works but undiscoverable** | `*workers*` view via `M-x editor.list-workers`; `C-c C-k` cancel-at-point. No keybinding, no statusline spinner/progress indicator anywhere (§9) |
|
||||
| 12 | Close + restore | **Partial** | Per-file cursor+scroll (saveplace), recent files, minibuffer history, autosave recovery all restore zero-config. Open-buffer set and window layout do **not**: desktop-save is opt-in (`pmacs.session.desktop_mode(true)`) *and* a documented no-op under a daemon (`src/desktop.rs:323-326`, `:353-356`, Q#DS9) |
|
||||
|
||||
A journey observation worth keeping verbatim from the audit:
|
||||
|
|
@ -692,32 +499,19 @@ level is the one missing. Audited level-by-level:
|
|||
build actions, menus, missing-tool guidance):
|
||||
|
||||
- files ✓ since #162 / #165 (`C-x C-f` opens a path, `C-x d` browses;
|
||||
**both are advertised** — `C-x C-f` directly in the welcome's key
|
||||
table, `C-x d` transitively through `M-x help` →
|
||||
`help.list-keybindings`) · buffers ✓ (`C-x b`, `*buffer-list*`) ·
|
||||
search ✓ (`C-s`/`C-r`/`C-M-s`; project.search is M-x-only) ·
|
||||
diagnostics ✓ once a server runs · terminal ✓ **and advertised**
|
||||
(`C-c t`, in the welcome) · build ✓ **and advertised** (`C-c c`, in
|
||||
the welcome; the prompt is **prefilled from the detected project
|
||||
kind**, not empty) · menus △
|
||||
neither is advertised anywhere but the keymap) · buffers ✓ (`C-x
|
||||
b`, `*buffer-list*`) · search ✓ (`C-s`/`C-r`/`C-M-s`; project.search
|
||||
is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but
|
||||
M-x-only · build ✓ but M-x-only with empty prompt · menus △
|
||||
(right-click only, 11 items) · missing-tool guidance ✗ (§1.2).
|
||||
|
||||
*Three claims in this bullet were false and are corrected together:
|
||||
"neither is advertised anywhere but the keymap", "terminal ✓ but
|
||||
M-x-only", and "build ✓ but M-x-only with empty prompt". All three
|
||||
were answered by the welcome buffer and by `C-c t`/`C-c c` — the very
|
||||
fixes the paragraph after §2's audit quote already records as
|
||||
landed. They survived because a level-by-level inventory was never
|
||||
re-run against them.*
|
||||
|
||||
**Intermediate** (should discover: palette, keybinding search, workspace
|
||||
settings, profiles, package management, task definitions,
|
||||
frontend/language settings):
|
||||
|
||||
- palette △ (`M-x` fuzzy over bare names — the rows are still bare, §5) ·
|
||||
keybinding search ✓ (`help.list-keybindings`, `help.where-is`,
|
||||
`help.describe-key`; **advertised through the welcome's `M-x help`
|
||||
route, but no DIRECT binding reaches them**) · workspace settings
|
||||
`help.describe-key`; still no key to reach them) · workspace settings
|
||||
✗ (no workspace scope, §11) · profiles ✗ (§12) · package management ✗
|
||||
in-session (§13) · task definitions ✗ · frontend customization △
|
||||
(themes, `pmacs.gpu.set_font`, statusline providers — all Lua-only) ·
|
||||
|
|
@ -858,11 +652,10 @@ the sharpest instance of §1.1.**
|
|||
function passed as `CompletionSource::Custom`.
|
||||
**What remains missing here:** a discovery surface for **packages**
|
||||
(§13) — `*workers*` already has one, reachable by
|
||||
`M-x editor.list-workers`, and §9's activity indicator now exists
|
||||
(#232) while its **ownership model remains absent** — and **no DIRECT binding reaches any of
|
||||
this**, though the family is advertised: the welcome names `M-x help`,
|
||||
which indexes it. The family stays unbound by design until the
|
||||
help-prefix decision is taken (see the prefix bullet below).
|
||||
`M-x editor.list-workers`, though §9's ownership model and activity
|
||||
indicator are still absent — and **no key reaches any of this**: the
|
||||
family is `M-x`-only by design until the help-prefix decision is taken
|
||||
(see the prefix bullet below).
|
||||
*Completion is assistance, not validation:* `resolve_accepted_value`
|
||||
returns the literal typed text when no candidate is selected, so a
|
||||
typo still reaches the handler; refusing a non-candidate is unbuilt
|
||||
|
|
@ -1184,23 +977,14 @@ so non-pool work (LSP requests, MCP) appears uniformly; one shared
|
|||
`*workers*` view (`src/workers_buffer.rs`, opened by `M-x
|
||||
editor.list-workers`, auto-refreshing, `C-c C-k` cancel-at-point).
|
||||
|
||||
**The identity layer is PARTLY built — purpose landed, ownership did not:**
|
||||
**The identity layer is absent:**
|
||||
|
||||
- `PendingJob` (`src/async_runtime.rs`) carries `{cancel, state,
|
||||
supersede_key, stream_buffer, max_batch, kind, dispatched_at,
|
||||
**purpose**}`. Since #232 the **purpose is required** — `JobSpec` has
|
||||
no `Default`, so a dispatcher that supplies none **does not compile**
|
||||
(`src/async_runtime.rs:429`), which makes presence a type obligation
|
||||
rather than a convention. **Still absent: an accountable owner, any
|
||||
workspace/buffer association, and any parent.** The one buffer link
|
||||
that exists (parse job → buffer) lives in a `SyntaxCoordinator` side
|
||||
map, invisible to the workers view.
|
||||
|
||||
The distinction that survives #232: a purpose says what a job is
|
||||
**doing**; ownership says **who asked**. Populated from static
|
||||
per-subsystem constants, an `owner` field would have been an *origin*
|
||||
and would have misattributed third-party work to a builtin — which is
|
||||
why #232 shipped none rather than a plausible one.
|
||||
- `PendingJob` (`src/async_runtime.rs:365-392`) carries `{cancel,
|
||||
state, supersede_key, stream_buffer, max_batch, kind,
|
||||
dispatched_at}`. **No owner. No purpose string. No
|
||||
workspace/buffer association. No parent.** The one buffer link that
|
||||
exists (parse job → buffer) lives in a `SyntaxCoordinator` side map,
|
||||
invisible to the workers view.
|
||||
- `JobKind` is a **closed 12-variant enum** (Sleep, ComputeSum, EmitN,
|
||||
Grep, Parse, FsReadDir, FsStat, FsRename, FsChmod, FsRemove,
|
||||
McpRequest, LspRequest). `pmacs.workers.register` funnels Lua jobs
|
||||
|
|
@ -1218,43 +1002,26 @@ editor.list-workers`, auto-refreshing, `C-c C-k` cancel-at-point).
|
|||
|---|---|---|
|
||||
| Async jobs | `*workers*` | processes, servers, terminals |
|
||||
| OS processes | `pmacs.process.list` (no buffer view exists) | **filters to `LineOriented` only — terminal PTYs are invisible**; `spawn_terminal` bypasses the public path entirely |
|
||||
| LSP servers | `*lsp*` status text | opened by **`M-x lsp.status`** (`builtin/runtime/lsp.lua:3533`), but by no binding; LSP sets `RestartPolicy::Never` on the supervisor and runs its own restart logic |
|
||||
| LSP servers | `*lsp*` status text | **no builtin command opens it**; LSP sets `RestartPolicy::Never` on the supervisor and runs its own restart logic |
|
||||
| Terminals | private id set drained after the supervisor tick | user-visible in none of the above |
|
||||
|
||||
A terminal PTY appears in **no** user-visible activity view. An LSP
|
||||
server appears in `*lsp*` (unreachable) and `list()`; its requests
|
||||
appear in `*workers*`; nothing joins them.
|
||||
- **A progress indicator now exists (#232, 2026-08-09)** — a statusline
|
||||
provider rendering the in-flight count and the **oldest** job's
|
||||
purpose, absent when idle, gated by `ui.activity-indicator`. So
|
||||
"visible asynchronous work" (§3) is **no longer false**: activity is
|
||||
visible without knowing to run `M-x editor.list-workers`, though **no
|
||||
binding opens that view** — `C-c C-k` is bound inside it, which cannot
|
||||
help anyone find it.
|
||||
|
||||
**The indicator's first act was to expose three months of invisible
|
||||
LSP file-watcher polling (issue #233)** — §9's own argument,
|
||||
demonstrated.
|
||||
- **`purpose` is now required** on every job and process (#232), through
|
||||
a single allocation funnel with no `Default`, so the compiler proves
|
||||
every caller supplies one. That is a real per-job description where
|
||||
there was none.
|
||||
- **Attribution is still missing, and that is what §9 grades.** A
|
||||
purpose says what a job is *doing*; it does not say **who asked** —
|
||||
no owner, no parent, no package. `ProcessSpec.label` remains a
|
||||
caller-supplied, unvalidated convention (`lsp:{name}`, terminal
|
||||
buffer name). **This section's grade is left untouched pending a
|
||||
re-audit**: the mechanism-without-identity finding is *partly*
|
||||
answered, and moving a grade is an audit act, not a documentation
|
||||
correction.
|
||||
- **No progress indicator exists anywhere** — no statusline spinner,
|
||||
no busy count (grep for progress/spinner/busy in `src/statusline.rs`
|
||||
is empty). "Visible asynchronous work" (§3) is currently false unless
|
||||
the user knows to run `M-x editor.list-workers`.
|
||||
- `ProcessSpec.label` is the nearest thing to attribution: caller-
|
||||
supplied, unvalidated convention (`lsp:{name}`, terminal buffer
|
||||
name).
|
||||
|
||||
The audit's conclusion, worth preserving verbatim: *because identity is
|
||||
missing, scoped cancellation has nothing to scope over and a unified
|
||||
activity view has nothing to group by — the four views exist precisely
|
||||
because there is no common key to merge them on.* **Purpose** landed with #232;
|
||||
**owner and parent** fields on the job and process specs remain the
|
||||
prerequisite, and the unified view and the ownership tree fall out of
|
||||
them.
|
||||
because there is no common key to merge them on.* Owner/purpose/parent
|
||||
fields on the job and process specs are the prerequisite; the unified
|
||||
view and the ownership tree fall out of them.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1723,73 +1490,10 @@ facto privileged implementation.
|
|||
|
||||
### Ground truth
|
||||
|
||||
**This concern carries TWO subgrades, because it asks two different
|
||||
questions and one answer was hiding the other.**
|
||||
**Grade: strong — the healthiest concern in this document, and most of
|
||||
its asks are already practiced.**
|
||||
|
||||
| subgrade | grade | what it measures |
|
||||
|---|---|---|
|
||||
| **Architectural** | **Strong** | the protocol, its versioning discipline, capability negotiation, degradation, and the absence of a privileged frontend |
|
||||
| **Product** | **Weak** | whether the graphical frontend is a workbench a user would choose — graded against §2b's per-frontend journey table and §3.1's blocker list |
|
||||
|
||||
**Why the split exists.** "Productize the semantic frontend" was graded
|
||||
`Strong` on architectural evidence alone, and that grade was true and
|
||||
was answering the wrong question: the protocol is excellent *and* the
|
||||
GUI is not yet a daily driver. A single cell could not hold both, so the
|
||||
product half was invisible — which is how a frontend the reporter would
|
||||
not choose sat inside the healthiest concern in this document.
|
||||
|
||||
**Product subgrade criteria** (each falsifiable, none aspirational):
|
||||
|
||||
1. **GPU ≥ local TUI at every journey step** under §2b. **Currently
|
||||
FAILS at two known steps** — 5 (no IME) and 12 (restore is a
|
||||
structural no-op) — with the exact set pending verification of §2b's
|
||||
inferred cells.
|
||||
2. **The daily-driver blocker list is empty.** **Currently nine open**
|
||||
(`docs/gui-arc-framing.md` §3.1): Escape quits, IME absent,
|
||||
`translate_key` holes, sub-line/horizontal scroll, no DPI, folding
|
||||
dead on the GPU, no session restore, no reconnect, the one-window
|
||||
ceiling.
|
||||
3. **Every surviving divergence is declared**, not accidental.
|
||||
**Not yet assessed** — §3.2's register is seeded empty, so this
|
||||
criterion has no finding either way and cannot lift or lower the
|
||||
grade.
|
||||
|
||||
**How the grade is derived, so it is a grading rule and not an
|
||||
adjective.** The three criteria above are *closure* conditions; on their
|
||||
own they distinguish only "closed" from "not closed", which is not a
|
||||
grade. The product subgrade reads:
|
||||
|
||||
| grade | condition |
|
||||
|---|---|
|
||||
| **Strong** | all three criteria pass |
|
||||
| **Partial** | usable, criterion 1 **passes**, and not all three criteria pass |
|
||||
| **Weak** | usable and criterion 1 **fails** at one or more steps |
|
||||
| **Missing** | no usable graphical frontend |
|
||||
|
||||
**The rule is TOTAL by construction** — every usable state is covered by
|
||||
exactly one of `Partial`/`Weak`/`Strong`, since criterion 1 either
|
||||
passes or fails and `Strong` is the all-pass case. An earlier draft
|
||||
required a non-empty blocker list for `Weak` and named blockers in
|
||||
`Partial`, which left a real state ungraded: criterion 1 passing with an
|
||||
empty blocker list but criterion 3 failing or unassessed. Criterion 1 is
|
||||
the discriminator because it is the only one that measures the GPU
|
||||
*against the TUI*; the others measure it against its own goals.
|
||||
|
||||
**Current: Weak** — criterion 1 fails at two known steps. (Nine
|
||||
blockers are open including the one-window ceiling; under the total rule
|
||||
they no longer *derive* the grade, but they are why it is not close to
|
||||
`Strong`.) The distinction that
|
||||
matters is between `Weak` and `Partial`: the GPU is not merely
|
||||
*unfinished*, it is **behind a frontend that ships in the same binary**,
|
||||
and that is what the ceiling and the two failing steps say.
|
||||
|
||||
**What moves it.** The GUI arc, and nothing else — it exists to satisfy
|
||||
exactly these three. The product subgrade is what the arc closes
|
||||
against, and it should be re-graded when the arc closes, not before.
|
||||
|
||||
**Architectural ground truth follows; it is unchanged and still strong.**
|
||||
|
||||
- Versioned protocol schema `SUPPORTED=[6..=23]` 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). The v21
|
||||
bottom-panel family landed with Stage 2B-1 (#184) and is **live in
|
||||
|
|
@ -1911,10 +1615,7 @@ across the whole command family, not an oversight.
|
|||
|
||||
Note the dependency: five of the ten onboarding steps above currently
|
||||
lead somewhere broken or invisible (find a file — the mechanism is fixed
|
||||
since #162/#165 and IS advertised — `C-x C-f` sits in the welcome's key
|
||||
table directly, and every bound command is reachable through `M-x help`;
|
||||
this clause read "advertised nowhere except the keymap" and was false at
|
||||
both levels, found while correcting step 7's row above; inspect a
|
||||
since #162/#165 but is advertised nowhere except the keymap; inspect a
|
||||
diagnostic — silent-failure risk; view workers — undiscoverable;
|
||||
setting provenance — unanswerable). Onboarding is correctly sequenced
|
||||
*after* the P1/P4 fixes, but the cheap floor — a welcome buffer in
|
||||
|
|
@ -2000,47 +1701,6 @@ documented no-op under a daemon. Journey Stage 1 closing is what moves
|
|||
this priority's own work to done — what is left is other priorities'
|
||||
work showing up in the journey.
|
||||
|
||||
### The GUI arc — placement (Q#GA5)
|
||||
|
||||
**Half A slots after Priority 1**, whose own work is complete (§20 P1),
|
||||
which is why this block sits here rather than lower down — in a
|
||||
*Recommended Priority Order*, position IS the schedule, and an earlier
|
||||
draft placed it after Priority 5 while the prose said P1.
|
||||
|
||||
**How it interleaves with P2–P5.** Half A is independent of them and
|
||||
runs alongside: it adds no workspace object, no extension-ownership
|
||||
model, no discovery surface. The interaction is at **P2 only**, through
|
||||
the gate below. P3, P4 and P5 neither block it nor are blocked by it,
|
||||
and P5's workbench-primitive adoption is *helped* by it — Stage 9's
|
||||
sidebar is a tree-primitive consumer.
|
||||
|
||||
It is the product half of §16, and it is what the §16 product subgrade
|
||||
is graded against.
|
||||
|
||||
**Reaching Stage 4b is a P2 START GATE.** Stages 4b (session save and
|
||||
restore) and 9 (project/files sidebar) are **workspace-owned**: a
|
||||
session and a sidebar root are both P2's objects, and inventing local
|
||||
conventions for them is precisely the "fifth independent root
|
||||
convention" §7 warns against. So when the arc reaches Stage 4b, **P2
|
||||
starts**, and **no later GUI stage begins** until P2 has an approved
|
||||
framing and an opened lane. Non-gated GUI work may interleave freely
|
||||
after that while the object lands.
|
||||
|
||||
**The gate is on STARTING P2, not on finishing it** — so the arc is
|
||||
never blocked on work nobody has begun, and it cannot outrun the model
|
||||
it depends on. Without the gate, every non-gated stage could finish
|
||||
first and leave P2 as a terminal closure blocker.
|
||||
|
||||
**Arc naming (Q#GA4).** This arc is **"the GUI arc"**, deliberately a
|
||||
name and not a number. The roadmap's **"Arc 8 — GPU structural parity"
|
||||
label is RETIRED** here; its scope is this arc's Half B. **"Arc 8" now
|
||||
unambiguously means the Lean 4 arc** (`docs/agent-handoff.md` §1a,
|
||||
stages 1–4b landed), which is the numbering that stays. Landed framing
|
||||
documents that say "Arc 8 adjacent" of viewport facts or splits
|
||||
(`editing-conveniences-framing.md`, `bottom-panel-framing.md`) keep
|
||||
their historical text — they are dated records, and this is the
|
||||
authoritative disambiguation for anything written from here on.
|
||||
|
||||
### Priority 2: Make workspace and location explicit
|
||||
|
||||
Otherwise project, LSP, remote, task, and persistence accumulate
|
||||
|
|
@ -2143,10 +1803,8 @@ implementation — this list is direction, not commitment):
|
|||
handle + derived `dispatch_idle`, then migrate shadows one per PR.
|
||||
4. **Extension ownership** (P3): `hook.remove`, owner-carrying
|
||||
registrations, attribution-by-default.
|
||||
5. **Worker identity** (§9): **owner and parent** on jobs and
|
||||
processes, and joining the four planes. *Purpose and the statusline
|
||||
activity indicator shipped in #232 (Stage 1); what remains is the
|
||||
ownership half.*
|
||||
5. **Worker identity** (§9): owner/purpose/parent on jobs and
|
||||
processes, join the four planes, statusline activity indicator.
|
||||
6. **Workspace entity** (P2): the object, then location values.
|
||||
7. **Config provenance + adoption** (P6).
|
||||
8. **Package lifecycle** (P7, after 4).
|
||||
|
|
|
|||
|
|
@ -2627,7 +2627,6 @@ dependencies = [
|
|||
"env_logger",
|
||||
"glyphon",
|
||||
"loro",
|
||||
"pmacs",
|
||||
"pmacs-protocol",
|
||||
"pollster",
|
||||
"sys-locale",
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ end
|
|||
|
||||
local READ_DIR_OPTS = { supersede = true, tolerant = true }
|
||||
local STAT_OPTS = { supersede = true }
|
||||
local WALK_TREE_OPTS = { supersede = true }
|
||||
|
||||
-- Two result shapes, chosen by `opts.tolerant` (dired Q#DR6):
|
||||
--
|
||||
|
|
@ -131,22 +130,6 @@ function fs.read_dir(path, opts)
|
|||
return build_handle(id)
|
||||
end
|
||||
|
||||
-- walk_tree(base [, opts]) -> handle; await -> { <entry>, ... } where
|
||||
-- each entry is the read_dir shape but `name` is a BASE-RELATIVE path
|
||||
-- ("sub/dir/file.txt") and the listing covers the whole tree as ONE
|
||||
-- job (issue #233 D3). Symlinks are recorded, never traversed; an
|
||||
-- unreadable subdirectory is skipped with its subtree; only the root
|
||||
-- failing to open fails the walk. Directory entries are included
|
||||
-- (kind "dir") --- consumers that only want files filter on kind.
|
||||
function fs.walk_tree(base, opts)
|
||||
if type(base) ~= "string" then
|
||||
error("pmacs.fs.walk_tree: base must be a string, got " .. type(base))
|
||||
end
|
||||
local key = read_opts(opts, "pmacs.fs.walk_tree", WALK_TREE_OPTS)
|
||||
local id = async_mod._dispatch_fs_walk_tree(base, key)
|
||||
return build_handle(id)
|
||||
end
|
||||
|
||||
function fs.stat(path, opts)
|
||||
if type(path) ~= "string" then
|
||||
error("pmacs.fs.stat: path must be a string, got " .. type(path))
|
||||
|
|
|
|||
|
|
@ -1910,33 +1910,22 @@ local function repull_for_attachments(sid, request_fn)
|
|||
end
|
||||
end
|
||||
|
||||
-- T M4.5 / issue #233 D3 — workspace file watching
|
||||
-- (workspace/didChangeWatchedFiles).
|
||||
-- T M4.5 — workspace file watching (workspace/didChangeWatchedFiles).
|
||||
--
|
||||
-- Servers register watchers dynamically via client/registerCapability.
|
||||
-- pmacs has no kernel file-watch, so watching is a polling
|
||||
-- snapshot-diff — but scheduled, not slept (D3 framing, approved
|
||||
-- 2026-08-11): one SCAN GROUP per (server, base) owns a retained
|
||||
-- { relpath = sig } snapshot, and a single `process.after-tick`
|
||||
-- subscription drives every group's cadence off
|
||||
-- `pmacs.editor.monotonic_ms` (autosave's Q#AS2 idiom). Waiting
|
||||
-- allocates no job and holds no pool thread; a due group runs ONE
|
||||
-- `pmacs.fs.walk_tree` job for the whole tree, diffs in Lua, and
|
||||
-- routes per-file created/changed/deleted FileEvents through each
|
||||
-- member watcher's glob and WatchKind mask, deduped into one
|
||||
-- notification. Quiet scans back the interval off to a cap; any
|
||||
-- change resets it. Groups retire when their last member leaves or
|
||||
-- their server dies, cancelling an in-flight walk cooperatively.
|
||||
-- pmacs has no kernel file-watch, so each registration runs a polling
|
||||
-- snapshot-diff coroutine: walk the base dir into a { relpath = sig }
|
||||
-- map and, every tick, diff against the previous map to emit per-file
|
||||
-- created/changed/deleted FileEvents (filtered by the glob and the
|
||||
-- WatchKind bitmask), batched into one notification. Coarser than an
|
||||
-- inotify bridge but accurate; a watcher self-cancels when the server
|
||||
-- dies or the capability is unregistered.
|
||||
|
||||
local FILE_WATCH_INTERVAL_MS = 250
|
||||
local FILE_WATCH_BACKOFF_CAP_MS = 4000
|
||||
|
||||
-- file_watchers[tostring(sid)][registrationId] = list of watch records
|
||||
-- ({ cancelled, form = "relative"|"absolute", kind_mask, match_subject,
|
||||
-- baseline_epoch, group }), one per glob watcher. `baseline_epoch`
|
||||
-- is nil until the first snapshot whose WALK STARTED after the record
|
||||
-- joined its group — the registration-epoch rule that keeps a joiner
|
||||
-- from receiving events for files that predate it.
|
||||
-- ({ cancelled = bool, form = "relative"|"absolute", _sleep = handle? }),
|
||||
-- one per glob watcher.
|
||||
local file_watchers = {}
|
||||
|
||||
-- WatchKind is a bitmask (Create=1, Change=2, Delete=4); test it
|
||||
|
|
@ -2040,328 +2029,108 @@ local function glob_matcher(glob)
|
|||
end
|
||||
end
|
||||
|
||||
-- Recursively list files under `base` → { relpath = sig }. `sig`
|
||||
-- folds size+mtime+kind so a content/metadata change flips it.
|
||||
-- Symlinks are recorded, not traversed (loop-safe). Awaits fs
|
||||
-- primitives, so call from inside an async coroutine.
|
||||
local function scan_tree(base, matches)
|
||||
local out = {}
|
||||
local function walk(dir, rel_prefix)
|
||||
local ok, entries = pcall(function()
|
||||
return pmacs.fs.read_dir(dir):await()
|
||||
end)
|
||||
if not ok or not entries then return end
|
||||
for _, e in ipairs(entries) do
|
||||
local rel = (rel_prefix == "") and e.name or (rel_prefix .. "/" .. e.name)
|
||||
if e.kind == "dir" then
|
||||
walk(dir .. "/" .. e.name, rel)
|
||||
elseif matches(rel) then
|
||||
out[rel] = table.concat({
|
||||
tostring(e.size), tostring(e.mtime),
|
||||
tostring(e.mtime_nsec), tostring(e.kind),
|
||||
}, "|")
|
||||
end
|
||||
end
|
||||
end
|
||||
walk(base, "")
|
||||
return out
|
||||
end
|
||||
|
||||
local FC_CREATED, FC_CHANGED, FC_DELETED = 1, 2, 3
|
||||
|
||||
-- Join a base-relative path under its base. The filesystem root is
|
||||
-- special-cased the way `dired`'s handler already spells it (the
|
||||
-- `(dir == "/") and "" or dir` idiom): a naive `base .. "/" .. rel`
|
||||
-- at `/` yields `//path` — the implementation-defined POSIX spelling
|
||||
-- — and `file:////path` once a URI wraps it.
|
||||
local function join_under(base, rel)
|
||||
if base == "/" then return "/" .. rel end
|
||||
return base .. "/" .. rel
|
||||
end
|
||||
|
||||
-- Build a record's match predicate over a base-RELATIVE path. Per LSP
|
||||
-- (and #234's P1 review), a plain-string glob matches the file's
|
||||
-- ABSOLUTE path while a RelativePattern's pattern is relative to its
|
||||
-- base — the record's `form` picks the subject; the walk itself is
|
||||
-- always relative.
|
||||
local function make_matcher(base, pat, form)
|
||||
local match_glob = glob_matcher(pat)
|
||||
if form == "absolute" then
|
||||
return function(rel)
|
||||
return match_glob(join_under(base, rel))
|
||||
local function start_file_watcher(sid, base, glob, kind_mask, record)
|
||||
-- Per LSP, a plain-string glob matches the file's ABSOLUTE path,
|
||||
-- while a RelativePattern's pattern is relative to its base — the
|
||||
-- record's `form` (from resolve_watcher) picks the match subject.
|
||||
-- scan_tree always walks in relative terms; only the string handed
|
||||
-- to the matcher changes.
|
||||
local match_glob = glob_matcher(glob)
|
||||
local matches = match_glob
|
||||
if record.form == "absolute" then
|
||||
matches = function(rel)
|
||||
return match_glob(base .. "/" .. rel)
|
||||
end
|
||||
end
|
||||
return match_glob
|
||||
end
|
||||
pmacs.async(function()
|
||||
local prev = scan_tree(base, matches)
|
||||
while not record.cancelled and server_is_live(sid) do
|
||||
local sh = pmacs.workers.sleep(FILE_WATCH_INTERVAL_MS)
|
||||
record._sleep = sh
|
||||
pcall(function() sh:await() end)
|
||||
record._sleep = nil
|
||||
if record.cancelled or not server_is_live(sid) then break end
|
||||
|
||||
-- The event URI for one change, as `finish_group_scan` emits it. A
|
||||
-- named function rather than an inline concat so the root-boundary
|
||||
-- witness can drive the PRODUCTION construction at base "/" — a base
|
||||
-- no fixture can walk for real.
|
||||
local function watch_change_uri(base, rel)
|
||||
return file_uri_for(join_under(base, rel))
|
||||
end
|
||||
|
||||
-- Exposed for the root-boundary witness (the `_deliver_status`
|
||||
-- pattern): the exact functions the watcher matches subjects and
|
||||
-- builds URIs with. Test-only by convention; production never reads
|
||||
-- them back.
|
||||
pmacs.lsp._watch_matcher_for_tests = make_matcher
|
||||
pmacs.lsp._watch_change_uri_for_tests = watch_change_uri
|
||||
|
||||
-- scan_groups[skey .. "\0" .. base] = one scan group per
|
||||
-- (server, base): the shared snapshot, the members it serves, and the
|
||||
-- D3 state machine — single-flight (`in_flight`), completion-advanced
|
||||
-- deadlines (`next_scan_at`), a scan `generation` that rejects stale
|
||||
-- completions, `rescan_queued` for joins landing mid-walk, the
|
||||
-- backoff `interval`, and the `failure_reported` dedup latch.
|
||||
local scan_groups = {}
|
||||
|
||||
local function group_key(skey, base)
|
||||
return skey .. "\0" .. base
|
||||
end
|
||||
|
||||
-- Retire a group: forget it and cancel any in-flight walk
|
||||
-- cooperatively. The walk's completion is then rejected by the
|
||||
-- identity/generation check in `finish_group_scan`, so a retired
|
||||
-- group's state can never be written again.
|
||||
local function retire_group(group)
|
||||
scan_groups[group.key] = nil
|
||||
if group.in_flight and group.in_flight.handle then
|
||||
pcall(function()
|
||||
group.in_flight.handle:cancel()
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- One scan's completion. `scan_members` is the membership captured at
|
||||
-- scan START — a watcher joining mid-walk is not in it and waits for
|
||||
-- its queued baseline scan. Three disjoint arms (framing round 3):
|
||||
-- stale/retired, live non-success, success.
|
||||
local function finish_group_scan(group, gen, scan_members, ok, result)
|
||||
-- Stale/retired arm: the group was retired (or superseded under the
|
||||
-- same key) while the walk was parked. Nothing here may touch a
|
||||
-- successor's state — #234's P2 recheck, applied at group scope.
|
||||
if scan_groups[group.key] ~= group then return end
|
||||
if not group.in_flight or group.in_flight.generation ~= gen then return end
|
||||
group.in_flight = nil
|
||||
local now = pmacs.editor.monotonic_ms()
|
||||
|
||||
if not ok then
|
||||
-- Live non-success arm: no snapshot, no epoch, no emit; the prior
|
||||
-- snapshot and backoff interval survive. A queued join still gets
|
||||
-- its immediate baseline attempt; otherwise reschedule normally.
|
||||
-- `Handle:await()` raises { tag = "cancelled" } (R45) for the
|
||||
-- intentional `workers.cancel-at-point` outcome, which stays
|
||||
-- quiet; a failure is reported once per distinct message until a
|
||||
-- success clears the latch.
|
||||
local tag = type(result) == "table" and result.tag or nil
|
||||
if tag ~= "cancelled" then
|
||||
local msg = type(result) == "table"
|
||||
and tostring(result.message or result.tag)
|
||||
or tostring(result)
|
||||
if group.failure_reported ~= msg then
|
||||
group.failure_reported = msg
|
||||
local report = "lsp: file watch scan failed for " .. group.base .. ": " .. msg
|
||||
if pmacs.error then pcall(pmacs.error, report) end
|
||||
pcall(pmacs.editor.set_status, report)
|
||||
local cur = scan_tree(base, matches)
|
||||
-- The seam that makes the recheck below WITNESSABLE. `scan_tree`
|
||||
-- suspends on `read_dir` once per directory, and the race is a
|
||||
-- cancel arriving during one of those suspensions --- which no
|
||||
-- arrangement of real timing can be made to happen on demand.
|
||||
-- Same reason `git.lua` exposes `_deliver_status`: the contract is
|
||||
-- about an interleaving the caller does not choose. Unset in
|
||||
-- production, so this costs one nil test per tick.
|
||||
-- `cur` is handed over so a test can cancel on THE SCAN THAT
|
||||
-- OBSERVED a given change. Cancelling on any other scan is not a
|
||||
-- witness: the loop would break at the post-sleep check on the
|
||||
-- next iteration and emit nothing anyway, so the assertion would
|
||||
-- pass with the recheck below deleted.
|
||||
if pmacs.lsp._after_scan_for_tests then
|
||||
pcall(pmacs.lsp._after_scan_for_tests, record, cur)
|
||||
end
|
||||
end
|
||||
if group.rescan_queued then
|
||||
group.rescan_queued = false
|
||||
group.next_scan_at = now
|
||||
else
|
||||
group.next_scan_at = now + group.interval
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Success arm.
|
||||
group.failure_reported = nil
|
||||
local cur = {}
|
||||
for _, e in ipairs(result) do
|
||||
if e.kind ~= "dir" then
|
||||
cur[e.name] = table.concat({
|
||||
tostring(e.size), tostring(e.mtime),
|
||||
tostring(e.mtime_nsec), tostring(e.kind),
|
||||
}, "|")
|
||||
end
|
||||
end
|
||||
|
||||
-- The seam that makes cancel-during-scan WITNESSABLE (#234's P2
|
||||
-- device, per member): the race is a cancel landing while the walk
|
||||
-- job is out, which no arrangement of real timing produces on
|
||||
-- demand. Unset in production. Handed the snapshot so a test can
|
||||
-- cancel on THE SCAN THAT OBSERVED a given change; the delivery
|
||||
-- loop below rechecks `cancelled` per member.
|
||||
if pmacs.lsp._after_scan_for_tests then
|
||||
for _, m in ipairs(scan_members) do
|
||||
pcall(pmacs.lsp._after_scan_for_tests, m, cur)
|
||||
end
|
||||
end
|
||||
|
||||
local changes = {}
|
||||
local prev = group.snapshot
|
||||
if prev then
|
||||
-- Diff once; route per member. `bit` is the WatchKind the event
|
||||
-- needs (Create=1, Change=2, Delete=4).
|
||||
local diff = {}
|
||||
for rel, sig in pairs(cur) do
|
||||
local was = prev[rel]
|
||||
if was == nil then
|
||||
diff[#diff + 1] = { rel = rel, type = FC_CREATED, bit = 1 }
|
||||
elseif was ~= sig then
|
||||
diff[#diff + 1] = { rel = rel, type = FC_CHANGED, bit = 2 }
|
||||
end
|
||||
end
|
||||
for rel in pairs(prev) do
|
||||
if cur[rel] == nil then
|
||||
diff[#diff + 1] = { rel = rel, type = FC_DELETED, bit = 4 }
|
||||
end
|
||||
end
|
||||
-- A member delivers only with a baseline (the registration-epoch
|
||||
-- rule: no events for files that predate the join) and only while
|
||||
-- uncancelled. Changes are deduped by (path, type) into the
|
||||
-- server's single notification.
|
||||
local emitted = {}
|
||||
for _, m in ipairs(scan_members) do
|
||||
if not m.cancelled and m.baseline_epoch ~= nil then
|
||||
for _, d in ipairs(diff) do
|
||||
if kind_has(m.kind_mask, d.bit) and m.match_subject(d.rel) then
|
||||
local key = d.type .. " " .. d.rel
|
||||
if not emitted[key] then
|
||||
emitted[key] = true
|
||||
changes[#changes + 1] = {
|
||||
uri = watch_change_uri(group.base, d.rel),
|
||||
type = d.type,
|
||||
}
|
||||
end
|
||||
-- RECHECKED AFTER THE SCAN, not only after the sleep (review P2).
|
||||
-- The coroutine is suspended for most of a tick with `_sleep`
|
||||
-- already cleared, so a cancel landing there sets `cancelled` and
|
||||
-- has no sleep to interrupt. Without this line the resumed scan
|
||||
-- runs on to `did_change_watched_files` below and a SUPERSEDED
|
||||
-- watcher emits one last batch under its OLD pattern. One batch is
|
||||
-- enough: it is a wrong-pattern notification the server acts on.
|
||||
if record.cancelled or not server_is_live(sid) then break end
|
||||
local changes = {}
|
||||
for rel, sig in pairs(cur) do
|
||||
local was = prev[rel]
|
||||
if was == nil then
|
||||
if kind_has(kind_mask, 1) then
|
||||
changes[#changes + 1] =
|
||||
{ uri = file_uri_for(base .. "/" .. rel), type = FC_CREATED }
|
||||
end
|
||||
elseif was ~= sig and kind_has(kind_mask, 2) then
|
||||
changes[#changes + 1] =
|
||||
{ uri = file_uri_for(base .. "/" .. rel), type = FC_CHANGED }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
group.snapshot = cur
|
||||
group.epoch = group.epoch + 1
|
||||
-- Baseline assignment: exactly the members captured at scan start
|
||||
-- with no baseline yet — for them this walk is the first one that
|
||||
-- STARTED after their join.
|
||||
for _, m in ipairs(scan_members) do
|
||||
if not m.cancelled and m.baseline_epoch == nil then
|
||||
m.baseline_epoch = group.epoch
|
||||
end
|
||||
end
|
||||
|
||||
if #changes > 0 then
|
||||
pcall(pmacs.lsp.did_change_watched_files, group.sid, changes)
|
||||
group.interval = FILE_WATCH_INTERVAL_MS
|
||||
else
|
||||
-- A quiet scan backs off toward the cap; waiting costs nothing
|
||||
-- under the after-tick cadence, so this bounds scan frequency,
|
||||
-- not sleep-job length (there are no sleeps).
|
||||
group.interval = math.min(group.interval * 2, FILE_WATCH_BACKOFF_CAP_MS)
|
||||
end
|
||||
|
||||
-- Sweep members cancelled outside cancel_watch_records (the seam,
|
||||
-- or a supersede that raced the walk); an empty group retires.
|
||||
local live = {}
|
||||
for _, m in ipairs(group.members) do
|
||||
if not m.cancelled then live[#live + 1] = m end
|
||||
end
|
||||
group.members = live
|
||||
if #group.members == 0 then
|
||||
retire_group(group)
|
||||
return
|
||||
end
|
||||
|
||||
if group.rescan_queued then
|
||||
group.rescan_queued = false
|
||||
group.next_scan_at = now
|
||||
else
|
||||
group.next_scan_at = now + group.interval
|
||||
end
|
||||
end
|
||||
|
||||
-- Start one walk for `group`. Single-flight is the caller's contract
|
||||
-- (the tick skips in-flight groups; joins queue instead) — this only
|
||||
-- stamps the generation and captures the delivery membership.
|
||||
local function start_group_scan(group)
|
||||
group.generation = group.generation + 1
|
||||
local gen = group.generation
|
||||
local scan_members = {}
|
||||
for i, m in ipairs(group.members) do
|
||||
scan_members[i] = m
|
||||
end
|
||||
local handle = pmacs.fs.walk_tree(group.base)
|
||||
group.in_flight = {
|
||||
generation = gen,
|
||||
started_at = pmacs.editor.monotonic_ms(),
|
||||
handle = handle,
|
||||
}
|
||||
pmacs.async(function()
|
||||
local ok, result = pcall(function()
|
||||
return handle:await()
|
||||
end)
|
||||
finish_group_scan(group, gen, scan_members, ok, result)
|
||||
end)
|
||||
end
|
||||
|
||||
-- The cadence (D3 framing; autosave's Q#AS2 idiom). One after-tick
|
||||
-- subscription drives every group: a clock read and a compare per
|
||||
-- frame, no job and no pool thread while waiting. Installed once and
|
||||
-- guarded rather than removed, because `pmacs.hook.remove` does not
|
||||
-- exist (the P3 gap).
|
||||
local watch_tick_installed = false
|
||||
local function ensure_watch_tick()
|
||||
if watch_tick_installed then return end
|
||||
watch_tick_installed = true
|
||||
pmacs.hook.add("process.after-tick", function()
|
||||
if next(scan_groups) == nil then return end
|
||||
local now = pmacs.editor.monotonic_ms()
|
||||
for _, group in pairs(scan_groups) do
|
||||
if not server_is_live(group.sid) then
|
||||
retire_group(group)
|
||||
elseif not group.in_flight and now >= group.next_scan_at then
|
||||
start_group_scan(group)
|
||||
for rel in pairs(prev) do
|
||||
if cur[rel] == nil and kind_has(kind_mask, 4) then
|
||||
changes[#changes + 1] =
|
||||
{ uri = file_uri_for(base .. "/" .. rel), type = FC_DELETED }
|
||||
end
|
||||
end
|
||||
if #changes > 0 then
|
||||
pcall(pmacs.lsp.did_change_watched_files, sid, changes)
|
||||
end
|
||||
prev = cur
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Join `record` to its (server, base) group, creating the group on
|
||||
-- first use. Joins WAKE the group (framing round 2): the deadline
|
||||
-- pulls to now, or — mid-walk — exactly one immediate follow-up scan
|
||||
-- is queued, so a joiner's baseline is at most the current walk's
|
||||
-- remainder plus one walk away, never a backoff cap. The
|
||||
-- join-triggered scan does not reset the backoff curve; only observed
|
||||
-- changes do.
|
||||
local function join_group(sid, skey, base, record)
|
||||
ensure_watch_tick()
|
||||
local key = group_key(skey, base)
|
||||
local group = scan_groups[key]
|
||||
if not group then
|
||||
group = {
|
||||
key = key,
|
||||
sid = sid,
|
||||
skey = skey,
|
||||
base = base,
|
||||
members = {},
|
||||
snapshot = nil,
|
||||
epoch = 0,
|
||||
generation = 0,
|
||||
in_flight = nil,
|
||||
rescan_queued = false,
|
||||
next_scan_at = 0,
|
||||
interval = FILE_WATCH_INTERVAL_MS,
|
||||
failure_reported = nil,
|
||||
}
|
||||
scan_groups[key] = group
|
||||
end
|
||||
group.members[#group.members + 1] = record
|
||||
record.group = group
|
||||
if group.in_flight then
|
||||
group.rescan_queued = true
|
||||
else
|
||||
group.next_scan_at = 0
|
||||
end
|
||||
end
|
||||
|
||||
-- The workspace directory the registering server actually serves
|
||||
-- (Q#D3-3): its spec `root_uri` — verbatim, nil when the server never
|
||||
-- asked for a root — then its `cwd`. Configured roots and custom
|
||||
-- resolvers (texlab's Q#LX2) are already folded into the spec, which
|
||||
-- is why this must NOT be a fresh `pmacs.project.detect`.
|
||||
local function server_workspace_dir(sid)
|
||||
local skey = tostring(sid)
|
||||
local ok, rows = pcall(pmacs.lsp.list)
|
||||
if not ok then return nil end
|
||||
for _, row in ipairs(rows) do
|
||||
if tostring(row.id) == skey then
|
||||
if row.root_uri then
|
||||
local p = pmacs.lsp.path_for_uri(row.root_uri)
|
||||
if p then return p end
|
||||
end
|
||||
if row.cwd then return row.cwd end
|
||||
return nil
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Resolve a GlobPattern (string | { baseUri, pattern }) to
|
||||
-- (base_dir, pattern, form). The form must travel with the pair: a
|
||||
-- RelativePattern's pattern is relative to its baseUri, and dropping
|
||||
|
|
@ -2383,52 +2152,23 @@ local function resolve_watcher(sid, gp)
|
|||
return pmacs.lsp.path_for_uri(gp.baseUri), gp.pattern or "**", "relative"
|
||||
end
|
||||
if type(gp) == "string" then
|
||||
local form = (gp:sub(1, 1) == "/") and "absolute" or "relative"
|
||||
-- Q#D3-3: the server's own workspace first. The
|
||||
-- attachment-directory guess remains only for a server with
|
||||
-- neither root_uri nor cwd — and even there it must be
|
||||
-- DETERMINISTIC (review blocker): `pairs` order is hash order, so
|
||||
-- the fallback takes the lexicographically smallest attachment
|
||||
-- directory rather than whichever record iteration yields first.
|
||||
-- REACHABLE, not defensive (review round 2 corrected round 1's
|
||||
-- "unreachable" claim here): `pmacs.lsp.spawn` accepts a spec
|
||||
-- with neither field, and for a MARKERLESS file `ensure_server`
|
||||
-- adopts such a live server because its `root_uri` and the
|
||||
-- attach's `key_uri` are both nil — after which any number of
|
||||
-- buffers in different directories can attach to it.
|
||||
local dir = server_workspace_dir(sid)
|
||||
if dir then return dir, gp, form end
|
||||
local fallback = nil
|
||||
for _, rec in pairs(attachments) do
|
||||
if rec.server == sid and rec.uri then
|
||||
local p = pmacs.lsp.path_for_uri(rec.uri)
|
||||
local d = p and p:match("^(.*)/[^/]*$")
|
||||
-- A file at the filesystem root leaves the capture empty.
|
||||
if d == "" then d = "/" end
|
||||
if d and (fallback == nil or d < fallback) then fallback = d end
|
||||
local dir = p and p:match("^(.*)/[^/]*$")
|
||||
if dir then
|
||||
return dir, gp, (gp:sub(1, 1) == "/") and "absolute" or "relative"
|
||||
end
|
||||
end
|
||||
end
|
||||
if fallback then return fallback, gp, form end
|
||||
end
|
||||
return nil, nil, nil
|
||||
end
|
||||
|
||||
-- Cancel records and leave their groups; a group losing its last
|
||||
-- member retires (its in-flight walk cancelled cooperatively).
|
||||
local function cancel_watch_records(recs)
|
||||
for _, r in ipairs(recs or {}) do
|
||||
r.cancelled = true
|
||||
local group = r.group
|
||||
if group then
|
||||
r.group = nil
|
||||
for i, m in ipairs(group.members) do
|
||||
if m == r then
|
||||
table.remove(group.members, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
if #group.members == 0 then retire_group(group) end
|
||||
end
|
||||
if r._sleep then pcall(function() r._sleep:cancel() end) end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -2437,29 +2177,20 @@ local function register_file_watchers(sid, registrations)
|
|||
file_watchers[skey] = file_watchers[skey] or {}
|
||||
for _, reg in ipairs(registrations or {}) do
|
||||
if reg.method == "workspace/didChangeWatchedFiles" then
|
||||
local outgoing = file_watchers[skey][reg.id]
|
||||
-- Re-registering a live id supersedes it (rust-analyzer does
|
||||
-- this): cancel the outgoing records first, because the table
|
||||
-- write below drops the only reference to them and an
|
||||
-- uncancelled record polls until the server dies.
|
||||
cancel_watch_records(file_watchers[skey][reg.id])
|
||||
local recs = {}
|
||||
for _, w in ipairs((reg.registerOptions or {}).watchers or {}) do
|
||||
local base, pat, form = resolve_watcher(sid, w.globPattern)
|
||||
if base and pat then
|
||||
local r = {
|
||||
cancelled = false,
|
||||
form = form,
|
||||
kind_mask = w.kind or 7,
|
||||
match_subject = make_matcher(base, pat, form),
|
||||
baseline_epoch = nil,
|
||||
}
|
||||
local r = { cancelled = false, form = form }
|
||||
recs[#recs + 1] = r
|
||||
join_group(sid, skey, base, r)
|
||||
start_file_watcher(sid, base, pat, w.kind or 7, r)
|
||||
end
|
||||
end
|
||||
-- Re-registering a live id supersedes it (rust-analyzer does
|
||||
-- this, #234's D2). The successors joined FIRST, so a same-base
|
||||
-- group stays alive across the hand-over — its in-flight walk
|
||||
-- is not torn down, and the joiners already queued their
|
||||
-- baseline. An id whose successors watch a different base still
|
||||
-- retires the old group when its last member leaves here.
|
||||
cancel_watch_records(outgoing)
|
||||
file_watchers[skey][reg.id] = recs
|
||||
end
|
||||
end
|
||||
|
|
@ -2494,7 +2225,7 @@ end
|
|||
-- family for every attached document so the matching store
|
||||
-- (`pmacs.inlay_hint` / `pmacs.semantic_tokens`) stays fresh.
|
||||
-- * `client/registerCapability` / `client/unregisterCapability` —
|
||||
-- join/leave the file-watch scan groups for any
|
||||
-- start/stop the file-watch coroutines for any
|
||||
-- `workspace/didChangeWatchedFiles` registration; reply `null`.
|
||||
--
|
||||
-- Only servers in `attachments` are drained, so a test (or package)
|
||||
|
|
|
|||
3579
docs/active-work.md
3579
docs/active-work.md
File diff suppressed because it is too large
Load Diff
|
|
@ -1,29 +1,18 @@
|
|||
# Agent handoff — cross-machine continuity
|
||||
|
||||
**Last updated: 2026-08-12.** `main` is **`d038f71`** — **#237**, GUI
|
||||
arc **Stage 1-pre**, the input seam: `App::window_event` goes from 655
|
||||
lines to four, deciding moves to a pure `route_event` and performing to
|
||||
seven `apply_*` methods. **No behaviour change and no wire change** —
|
||||
it is the seam the five behavioural slices land on, and **1a is next
|
||||
and not started**. Beneath it `f8ad3e7` — **#236**, GUI arc **Stage
|
||||
0**, the arc framing and its documentation sweep. **Both are absorbed
|
||||
into §1 at this anchor.**
|
||||
|
||||
Previously **2026-08-11 (second pass)**: `main` was
|
||||
**`122b8e8`** — **#235**, D3 of the LSP file watcher, which **closes
|
||||
issue #233 and completes that arc**: the watcher stops sleeping and
|
||||
walks once per scan, zero jobs at idle. Beneath it `b867f64` — git
|
||||
integration Stage 1 **#227** (`*git-status*` / `*git-diff*`, no wire
|
||||
change), atop `ae84d58` **#234**, the file-watcher correctness fix
|
||||
(issue #233 D1+D2). Beneath them, in first-parent order: **#231**
|
||||
destination capture, **#232** worker identity Stage 1, **#228**
|
||||
discovery Stage 2, **#230** LSP LaTeX coverage, **#229** the gate
|
||||
`--protocol` build step, **#225** per-worktree gate target dirs,
|
||||
**Last updated: 2026-08-11.** `main` is **`b867f64`** — git integration
|
||||
Stage 1 **#227** (`*git-status*` / `*git-diff*`, no wire change), atop
|
||||
`ae84d58` **#234**, the LSP file-watcher correctness fix (issue #233
|
||||
D1+D2, one review round; **D3 — the polling cost — is deliberately
|
||||
unfixed and is the ruled next lane**). Beneath them, in first-parent
|
||||
order: **#231** destination capture, **#232** worker identity Stage 1,
|
||||
**#228** discovery Stage 2, **#230** LSP LaTeX coverage, **#229** the
|
||||
gate `--protocol` build step, **#225** per-worktree gate target dirs,
|
||||
**#226** the R8 fixture fix, and **#224** the QoL docs retirement.
|
||||
**Only #227, #234 and #235 are absorbed into §1 at this anchor**; the
|
||||
eight between carry their facts in their `docs/active-work.md` lanes,
|
||||
several of whose headers still say OPEN — trust this chain over any
|
||||
lane header, per the ledger's own rule.
|
||||
**Only #227 and #234 are absorbed into §1 at this anchor**; the eight
|
||||
between carry their facts in their `docs/active-work.md` lanes, several
|
||||
of whose headers still say OPEN — trust this chain over any lane
|
||||
header, per the ledger's own rule.
|
||||
|
||||
Previously **2026-08-08**: `main` was `9a26ac8` — GPU horizontal
|
||||
scroll **#223**, which **closes the QoL arc** (§1). Beneath it the arc's
|
||||
|
|
@ -111,68 +100,8 @@ reads it the way you just did.
|
|||
For volatile branches, checkpoints, verification, and recovery
|
||||
commands, read `docs/active-work.md` immediately after this file.
|
||||
|
||||
## 1. Where the project stands (2026-08-12)
|
||||
## 1. Where the project stands (2026-08-11)
|
||||
|
||||
- **GUI arc — Stage 0 MERGED as #236, Stage 1-pre MERGED as #237
|
||||
(2026-08-12). The arc is OPEN: five Stage 1 slices remain and 1a has
|
||||
not started.** Stage 0 framed the arc
|
||||
(`docs/gui-arc-framing.md`); Stage 1 is framed for **all** its slices
|
||||
by `docs/gui-stage1-input-framing.md` (revision 11). 1-pre is the
|
||||
input seam and changes **no behaviour and no wire**:
|
||||
`App::window_event` went from 655 lines to four. Durable facts:
|
||||
- **A route carries the DECISION; only running the body gives the
|
||||
EFFECT.** A wheel route holds a delta, and whether that becomes a
|
||||
viewport update, a panel event, a terminal event or nothing at all
|
||||
depends on `State`. An approved evidence contract asked for
|
||||
"outbound events **and** local effects", and a first implementation
|
||||
argued route-classification covered both. It does not, and framing
|
||||
revision 11 retracts the argument. **When a contract names two
|
||||
things, satisfying one and arguing it implies the other is a
|
||||
narrowing wearing the costume of a mechanism.**
|
||||
- **A harness that re-implements the thing it tests witnesses its own
|
||||
copy.** The dispatch had to leave `window_event` — which cannot be
|
||||
driven, since `ActiveEventLoop` does not exist outside a live event
|
||||
loop — and become `App::dispatch_window_event` before any effect
|
||||
could be observed. That single extraction is what made the evidence
|
||||
possible, and it shrank the untestable residue to one `if`.
|
||||
- **Winit's `KeyEvent` cannot be constructed outside winit** — a
|
||||
`pub(crate) platform_specific` field — so **no
|
||||
`WindowEvent::KeyboardInput` can be built by a test**. This will
|
||||
bind every future GUI input slice. It does **not** extend to the
|
||||
pointer families: `DeviceId::dummy()` exists for exactly this
|
||||
purpose and `CursorMoved` / `MouseInput` / `MouseWheel` are
|
||||
constructible. Bound an exception by checking its edges before
|
||||
writing it down.
|
||||
- **The sentinel is the success condition; the timeout is only an
|
||||
error ceiling**, and conflating them undoes the design. Absence is
|
||||
decided by a non-coalesceable sentinel rather than a duration, so
|
||||
the harness is insensitive to core count — but an unbounded blocking
|
||||
read turns a downstream regression into a **wedged gate** instead of
|
||||
a red one, and a hang reads as slowness until the job is killed.
|
||||
- **Four defects in the tests themselves, all one shape: witnessing an
|
||||
absence the harness had manufactured.** It skipped the post-connect
|
||||
wiring `resumed` performs, so geometry was silently withheld; the
|
||||
fixture was too short to scroll; a windowless `State` has no
|
||||
attached buffer, so viewport sends were withheld; and
|
||||
`.all(|e| matches!(..))` over an **empty** transcript is vacuously
|
||||
true. **Before believing a green row, ask what the harness withheld
|
||||
that production supplies.**
|
||||
- **`cargo metadata` decides which CI job runs a member's unit
|
||||
tests, not intuition.** `workspace_default_members` is the root
|
||||
package alone, so every bare `cargo test --all-targets` skips
|
||||
`pmacs-gpu` entirely; only `gpu-render` runs it. A GPU-dependent
|
||||
assert is therefore **unconditional** rather than
|
||||
`PMACS_REQUIRE_GPU`-gated, so a missing adapter can never become a
|
||||
quiet `ok`.
|
||||
- **Deliberately unbuilt here**: every behavioural change. 1a
|
||||
(`TextInput`, **v24**, protocol-bearing) is next and serialized
|
||||
against 1e (`OpenTarget`, **v25**); **A4 deletes the idle-Escape
|
||||
local quit**, which is **pre-existing behaviour that 1-pre preserved
|
||||
and isolated rather than introduced** — the seam moved it behind an
|
||||
`EventOutcome` return, and 1a is what removes it. `EventOutcome`
|
||||
nevertheless **survives** A4:
|
||||
the native close still returns `Exit`, and **one producer is not one
|
||||
variant**.
|
||||
- **Git integration Stage 1 — MERGED as #227 (2026-08-11).**
|
||||
`*git-status*` (a `listview` panel over `git --no-optional-locks -C
|
||||
<dir> status --porcelain=v2 --branch -z`) and `*git-diff*`
|
||||
|
|
@ -220,165 +149,45 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
splitting would force every present and future consumer to spell
|
||||
both arms, and a forgotten arm silently degrades copies. The score
|
||||
byte carries the distinction where presentation needs it.
|
||||
- **LSP file watcher — ARC COMPLETE. Issue #233 CLOSED by #234 (D1+D2)
|
||||
and #235 (D3, 2026-08-11).** The watcher no longer sleeps and no
|
||||
longer walks per directory: `pmacs.fs.walk_tree` is the whole tree
|
||||
as ONE cancellable job, and a `process.after-tick` scheduler
|
||||
(autosave's Q#AS2 idiom) drives one scan group per (server, base) —
|
||||
zero jobs and zero pool threads at idle, one attributable job per
|
||||
scan, 250 ms under activity backing off to 4 s at rest. Three
|
||||
framing review rounds and three implementation review rounds; the
|
||||
framing (`docs/lsp-file-watch-d3-framing.md`) records all of them.
|
||||
Durable facts:
|
||||
- **A sleep job OCCUPIES A POOL THREAD for its whole duration**
|
||||
(`dispatch_sleep` runs `run_sleep` on the pool). N sleeping
|
||||
watchers subtract N of `available_parallelism - 1` threads. Any
|
||||
recurring wait belongs on the after-tick cadence, not in
|
||||
`workers.sleep` — autosave's comment says so and is now proven at
|
||||
scale.
|
||||
- **A loop that never runs cannot poll.** `walk_tree`'s cancellation
|
||||
is checked at entry, per directory, per 32 entries, AND at exit —
|
||||
the entry/exit checks exist because an EMPTY tree enters no loop,
|
||||
and a pre-cancelled walk returning empty SUCCESS would have been
|
||||
committed as a snapshot and diffed into a deletion storm.
|
||||
- **The group scheduler is a state machine, not a cadence**:
|
||||
single-flight per group (overlap unrepresentable), deadlines
|
||||
advanced from completion, stale completions rejected by
|
||||
generation, and a THREE-ARM completion partition — success /
|
||||
stale-or-retired / live non-success — because every job is
|
||||
user-cancellable and `Handle:await()` raises structured outcomes;
|
||||
an uncaught non-success would strand `in_flight` forever and
|
||||
silently stop watching. Joins wake the group and queue exactly one
|
||||
mid-walk follow-up; a watcher's baseline is the first snapshot
|
||||
whose WALK STARTED after its join. Retirement is deliberately
|
||||
double-enforced (unregister path + post-scan sweep) — mutation
|
||||
bites proved either copy alone is masked.
|
||||
- **The scan root is the server's** (Q#D3-3): `root_uri` → `cwd` →
|
||||
lexicographically smallest attachment directory. The fallback is
|
||||
REACHABLE — a manual `pmacs.lsp.spawn` may omit both fields and
|
||||
`ensure_server` adopts it for markerless files (nil == nil) — and
|
||||
`pairs`-order was the nondeterminism D3 existed to remove.
|
||||
- **A fixed-duration pump against pool-dependent timing is a
|
||||
core-count assumption in disguise.** PR #235's only CI red: all
|
||||
five test legs failed deterministically where sixteen local cores
|
||||
stayed green, because a 1.6 s pump assumed a held walk would
|
||||
complete while a 3-thread CI pool was still draining sleep waves.
|
||||
Drains wait on observable conditions; `taskset -c 0-3` reproduces
|
||||
the CI pool shape locally and is now the standard rehearsal for
|
||||
saturation-timed tests.
|
||||
- **A test probe must fail loudly, and a witness bound must sit
|
||||
BETWEEN the mutant and the real value.** One probe read
|
||||
`pmacs.async` for `pmacs._async` and defaulted every error to
|
||||
"absent" — a vacuously green idle witness; probes `expect` now.
|
||||
The mid-walk cancellation bound was 60 where the real value is 35
|
||||
and the poll-less mutant is 44. And `cancel_requested` on an
|
||||
active row is a REQUEST — settlement is a `cancelled` completion.
|
||||
- **A stray marker high in the tree re-roots every markerless
|
||||
fixture beneath it.** An empty `/tmp/.git` made project detection
|
||||
root tempdir fixtures at `/tmp` (7,751 entries), which the
|
||||
server-rooted watcher then faithfully watched. A
|
||||
markerless-fixture red that looks like a watcher bug may be an
|
||||
ancestor marker, on any machine.
|
||||
|
||||
**FIXED 2026-08-13: `scripts/gate` now isolates `TMPDIR`.** Each
|
||||
invocation gets a directory created fresh by `mktemp -d` under
|
||||
**`<gate-root>/tmp/`** — the shared gate root, not the per-worktree
|
||||
target, because a Unix socket path cannot exceed `sun_path` and the
|
||||
suites bind sockets inside `TMPDIR`. **The budget is the
|
||||
supported-platform floor: 103 usable bytes** — Darwin's 104-byte
|
||||
array minus its terminating NUL, not Linux's 108, because a
|
||||
Linux-derived limit passes where it is written and bind-fails on
|
||||
the macOS leg. It is exported
|
||||
once so every stage and every process they spawn inherits it, and
|
||||
reaped by the same exit trap as the ambient root. **A gate run no
|
||||
longer needs a `TMPDIR=` override.**
|
||||
|
||||
**That parent is SHARED between worktrees and `--prune` does not
|
||||
touch it** — prune only considers directories carrying an ownership
|
||||
marker. Each run removes its own leaf; the parent stays as a stable
|
||||
empty directory.
|
||||
|
||||
**Two guards, both of which cost a round to get right.** A
|
||||
byte-counted length check (`${#var}` counts CHARACTERS under UTF-8
|
||||
while `sun_path` is byte-limited) reserving the measured maximum
|
||||
suffix — `/.tmpXXXXXX/directory-target.sock`, 33 bytes — plus
|
||||
headroom. And an **ancestor-marker check**, because *a managed root
|
||||
is not inherently marker-free*: a `.git` in `$HOME` or above
|
||||
`$HOME/build` rebuilds the original defect one directory up.
|
||||
`PMACS_GATE_ALLOW_ANCESTOR_MARKER` is the documented test-only
|
||||
escape, and a row that does not set it witnesses the refusal.
|
||||
|
||||
**Why a subdirectory of `/tmp` would NOT have worked**, since that
|
||||
is the obvious cheaper fix: the hazard is an ANCESTOR marker, and a
|
||||
child of `/tmp` has exactly the same ancestors. The directory had to
|
||||
move somewhere the gate already owns.
|
||||
|
||||
*(Historical, and still the shape to recognize outside the gate —
|
||||
`cargo test` run by hand is unprotected.)* The gate isolated the
|
||||
target directory and five ambient roots but **not `TMPDIR`**, so
|
||||
`tempfile::tempdir()` landed under a `/tmp` that may carry a
|
||||
marker. It surfaced inside a gate
|
||||
run on an unrelated lane (GUI 1-pre, whose whole **executable** diff
|
||||
is inside the `pmacs-gpu` crate) as **`m4_24_bare_string_glob_stays_relative`
|
||||
and `m4_24_d3_fallback_base_is_the_smallest_attachment_dir`**, in
|
||||
both the `m4` step and the `--workspace` sweep, with every other
|
||||
target in the corpus green.
|
||||
|
||||
**Diagnose it with the discriminating pair, not a rerun** — same
|
||||
binary, same commit, one variable. **Four literal `--exact`
|
||||
invocations, one test each**, because `m4_24_` is a PREFIX matching
|
||||
**18** tests and a prefix run reports ~16/2 and 18/0 rather than the
|
||||
0/1 and 1/0 that make the pair readable:
|
||||
|
||||
```sh
|
||||
C=<marker-free dir> # outside /tmp AND outside every git worktree
|
||||
|
||||
TMPDIR=/tmp cargo test --test m4_acceptance -- \
|
||||
--exact m4_24_bare_string_glob_stays_relative # 0 passed; 1 failed
|
||||
TMPDIR=$C cargo test --test m4_acceptance -- \
|
||||
--exact m4_24_bare_string_glob_stays_relative # 1 passed; 0 failed
|
||||
|
||||
TMPDIR=/tmp cargo test --test m4_acceptance -- \
|
||||
--exact m4_24_d3_fallback_base_is_the_smallest_attachment_dir # 0 passed; 1 failed
|
||||
TMPDIR=$C cargo test --test m4_acceptance -- \
|
||||
--exact m4_24_d3_fallback_base_is_the_smallest_attachment_dir # 1 passed; 0 failed
|
||||
```
|
||||
|
||||
Each prints `running 1 test` and `171 filtered out`. **Read that
|
||||
line** — see the libtest-filter bullet below for why a filter that
|
||||
reaches nothing still prints green.
|
||||
|
||||
Check the ancestors of the temp root for `.git`, `Cargo.toml` and
|
||||
friends before believing any markerless-fixture red, and **compare
|
||||
SIGNATURES, not test names** — the contaminated legs panic at
|
||||
`m4_acceptance.rs:5668:5` and `:6615:5` with `.received = ""`,
|
||||
matching the gate red's own signature, and the clean legs panic
|
||||
nowhere. That is what makes the pair evidence rather than
|
||||
coincidence.
|
||||
|
||||
**The isolated `TMPDIR` must be outside `/tmp` AND outside every git
|
||||
worktree.** A child of `/tmp` is not isolated: `/tmp/.git` is still
|
||||
its ancestor. Verify with `git -C <dir> rev-parse --show-toplevel`
|
||||
failing, not by eye. **Do not delete a foreign marker** — isolating
|
||||
is sufficient and deletion is someone else's call. **Isolating
|
||||
`TMPDIR` inside `scripts/gate` was the standing fix; it landed
|
||||
2026-08-13** (see the FIXED note above), so a gate run is protected
|
||||
and the override is no longer needed. **A bare `cargo test` still
|
||||
is not** — that is when to check the ancestors.
|
||||
- **A libtest filter that matches nothing reports `test result: ok`.**
|
||||
`0 passed; 0 failed; N filtered out` and a zero exit code are what a
|
||||
*typo'd or mis-quoted filter* looks like, and it is indistinguishable
|
||||
from success at a glance. **This shell is zsh, which does NOT
|
||||
word-split unquoted parameter expansions**, so `NAMES="a b"; cargo
|
||||
test -- $NAMES` passes ONE argument `"a b"`, matches no test, and
|
||||
prints a green line. Seen 2026-08-11 while running a
|
||||
contaminated/clean pair: the contaminated leg "passed" and briefly
|
||||
looked like the environmental hypothesis collapsing. **Read the
|
||||
`running N tests` line, not just `test result`** — it is the only
|
||||
place the filter's actual reach is stated.
|
||||
- **Deliberately unbuilt**: kernel notification (framing option E) —
|
||||
a framed option, not residue; its trigger is the 4 s worst-case
|
||||
external-change latency mattering in practice.
|
||||
- **LSP file watcher — D1+D2 MERGED as #234 (2026-08-11); D3 is
|
||||
next.** Issue #233: with any server that dynamically registers
|
||||
`workspace/didChangeWatchedFiles`, plain-string globs never matched
|
||||
(matched **relative** where LSP says **absolute** — rust-analyzer
|
||||
saw no file change, ever; gopls saw `go.mod` but never `.go`), and
|
||||
re-registering a live id leaked the previous pollers uncancellably
|
||||
(rust-analyzer registers twice under one id: 12 pollers, 6
|
||||
unreachable). Invisible for three months until #232's activity
|
||||
indicator — §9's instrument doing exactly its job. Durable facts:
|
||||
- **The GlobPattern form travels with the pattern, and it is read
|
||||
FROM the pattern, not from the union arm.** `resolve_watcher`
|
||||
returns `(base, pattern, form)`; a leading `/` is what makes a
|
||||
string absolute. The first fix classified every string absolute —
|
||||
repairing rust-analyzer while silently breaking bare `*.txt`, a
|
||||
case that had worked since May. Review caught it (P1).
|
||||
- **A scan that completes after cancellation must not emit (P2).**
|
||||
The watcher coroutine spends most of a tick suspended in
|
||||
`read_dir` awaits with `_sleep` already cleared, so a cancel
|
||||
landing there had nothing to interrupt and the resumed scan
|
||||
emitted one stale batch under the superseded pattern. Cancellation
|
||||
and liveness are rechecked after the scan;
|
||||
`pmacs.lsp._after_scan_for_tests` (nil in production, handed the
|
||||
scan result) exists because no real timing produces that
|
||||
interleaving on demand — `git.lua`'s `_deliver_status` device
|
||||
again.
|
||||
- **F1's lesson fired twice in one lane.** The pre-existing test was
|
||||
insensitive (`**/` compiles to `.-`, which spans `/`, so it passes
|
||||
under either match subject) — and then the lane's own flat-pattern
|
||||
guard constrained the RelativePattern *object* arm while P1's
|
||||
regression lived in the *string* arm. A guard proves things about
|
||||
the arm it exercises, nothing more.
|
||||
- **All six watcher tests are mutation-verified, each bite failing
|
||||
only its own defect** — the two review fixes re-verified
|
||||
independently after review.
|
||||
- **D3 is deliberately unfixed and ruled next**: the walk still
|
||||
recurses into everything every 250 ms, six jobs per tick for
|
||||
rust-analyzer. The D3 lane in `docs/active-work.md` carries what
|
||||
was checked (no notify dependency, no ignore-list infrastructure)
|
||||
and the option space.
|
||||
- **QoL arc — CLOSED. All five stages merged (#219, #220, #221, #222,
|
||||
#223).** From one daily-driver report: terminal zoom broke TUI
|
||||
rendering and did nothing in the GUI, and a long line was unreadable
|
||||
|
|
@ -724,27 +533,18 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
|
||||
**Read this before picking anything up.** It is the only place the
|
||||
remaining work is enumerated in one view; the per-arc bullets below give
|
||||
the detail.
|
||||
|
||||
**THIS TABLE IS MIXED-ANCHOR AND IS NOT CURRENT AS A WHOLE.** The GUI
|
||||
row is written at `d038f71` (2026-08-12); **every other row dates from
|
||||
2026-08-01** and has not been re-audited since, including the original
|
||||
claim that zero PRs were open. Rows are known to have drifted — P5
|
||||
called the tree primitive "PR held" when **#217 had already merged** —
|
||||
so **check `git log --first-parent githubsucks/main` before believing
|
||||
any row is still startable**. Refreshing the rest of the board is a
|
||||
separate pass, deliberately not folded into a GUI absorption.
|
||||
the detail. Nothing here is in flight: **zero PRs are open** at this
|
||||
anchor, so every item is startable.
|
||||
|
||||
#### Arc state, against `COHERENCE.md` §20's priority order
|
||||
|
||||
| P | Arc | State | What is next |
|
||||
|---|---|---|---|
|
||||
| 1 | **Journey** | **Stage 1 COMPLETE** (1a #182/#183; 1b-1 #203, 1b-2 #204, 1b-3 #205) | Journey runs to step 10. The thin end is now steps **1** (install — that is P8), **11** (background work: visible but no ownership model, §9) and **12** (session restore: desktop-save is opt-in *and* a documented no-op under a daemon) |
|
||||
| 1.5 | **GUI arc (half A)** | **Stage 0 MERGED (#236); Stage 1-pre MERGED (#237)** | **`COHERENCE.md` §20 Q#GA5 slots half A immediately after Priority 1** and it runs alongside P2–P5, interacting only at Q#GA5's start gate — **reaching Stage 4b obliges P2 to have STARTED, and no later GUI stage begins until it has an approved framing and an opened lane. The constraint binds THIS arc, not P2**, which is free to start at any time. It is the product half of §16 and what that section's product subgrade is graded against. **Next: Stage 1a**, not started, protocol-bearing at **v24** and serialized against 1e's v25 |
|
||||
| 2 | Workspace + location | Missing; model gap | The long-lead arc. Start before a fifth subsystem grows its own root convention — four have already diverged (§7). **P2 is blocked by nothing and may start now.** Q#GA5 runs the other way: when the GUI arc reaches Stage 4b, **P2 must already have started**, and no later GUI stage begins until it has an approved framing and an opened lane. **The gate is on STARTING P2, not on finishing it** |
|
||||
| 2 | Workspace + location | Missing; model gap | The long-lead arc. Start before a fifth subsystem grows its own root convention — four have already diverged (§7) |
|
||||
| 3 | Extension ownership | Missing; prerequisite-shaped | **`pmacs.hook.remove` does not exist.** That one bug-sized gap blocks §13's disable/uninstall, §10's trust classes, and package-scoped cancellation |
|
||||
| 4 | **Discovery** | **Stage 1 MERGED (#207)** | Stage 2 candidates, in rough dependency order: richer M-x rows (**protocol change** — `MinibufferPrompt.candidates` is `Vec<String>`; `CompletionPopupRow` already proves the pattern), `Command` gaining title/category/aliases/flags/arg-schema (~147 definition sites), predicate evaluation, help-layer unification, and the help-prefix decision |
|
||||
| 5 | Workbench convergence | Partial; **Arc 7 COMPLETE** (#213) and **the tree primitive MERGED as #217** | The bottom panel is finished on both frontends and the adopter default is flipped. §14's Tree moves ✗ → ◐ with the LSP outline as its one adopter. *(This row said "PR held" until 2026-08-12; #217 is in the canonical base chain.)* **Next: adoption** — dired's `i`, then DAP's variables view, which is why it was built first |
|
||||
| 5 | Workbench convergence | Partial; **Arc 7 COMPLETE** (#213) and **the tree primitive is implemented** (PR held) | The bottom panel is finished on both frontends and the adopter default is flipped. The tree primitive has landed on a held PR: §14's Tree moves ✗ → ◐ with the LSP outline as its one adopter. **Next: adoption** — dired's `i`, then DAP's variables view, which is why it was built first |
|
||||
| 6 | Config productization | Foundation only | Value provenance, then layering, then adoption migration (**table-valued settings are the hard prerequisite** — `ConfigValue` is four scalars) |
|
||||
| 7 | Package lifecycle | Not started | Correctly sequenced after P3 |
|
||||
| 8 | **Distribution** | **Stage 1 SHIPPED (v1.1.0, #211)** | Binaries on tag, checksums, machine-checked glibc floor. **Journey step 1 now works and the "invisible until this exists" blocker is lifted.** Next is a *decision* about channels / update / signing, not a queued plan |
|
||||
|
|
@ -1353,10 +1153,6 @@ someone forgot.
|
|||
it — the reverted seam failed only the consumed-plan check, not the
|
||||
content assertion. That is what the consumed-plan check is for.
|
||||
- **Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b, 4a, 4b ALL LANDED**
|
||||
*(**"Arc 8" means THIS arc and only this one.** The roadmap's
|
||||
colliding "Arc 8 — GPU structural parity" label is retired: that scope
|
||||
is Half B of **the GUI arc**, which is a name and not a number —
|
||||
`COHERENCE.md` §20, Q#GA4.)*
|
||||
(`docs/lean4-mode-framing.md`; #160, #161, #167, #170, #179, #181). pmacs edits Lean 4: `arborium-lean` highlighting, a
|
||||
`lean4` major mode, `⟨⟩ ⦃⦄ ⟮⟯` pairs, and a `lake serve` language
|
||||
server with a Lake-aware outermost root, a lazy toolchain probe, a
|
||||
|
|
@ -2547,51 +2343,6 @@ its own step, never `&&`-chained.
|
|||
|
||||
**Run it with `scripts/gate`. Do not retype it.**
|
||||
|
||||
**NEVER start the gate — or `cargo test` — from a shell that ignores
|
||||
`SIGINT`.** A shell backgrounding a job without job control sets
|
||||
`SIGINT` (and `SIGQUIT`) to `SIG_IGN`; `nohup` adds `SIGHUP`. `SIG_IGN`
|
||||
is **inherited across `fork` and survives `exec`**, so it reaches
|
||||
`cargo`, the test binary, and everything they spawn. Any test that
|
||||
signals a child and waits for it then hangs to its own deadline and
|
||||
reports a *product* defect that is not there.
|
||||
|
||||
Concretely: `setsid nohup ./scripts/gate … &` is **forbidden**. This
|
||||
cost one lane seven red full sweeps and nine framing revisions chasing
|
||||
a GPU teardown bug that never existed
|
||||
(`docs/gpu-probe-sigint-framing.md` §4c). Long runs do not need it —
|
||||
measured, an ordinary tool-level background launch leaves `SIGINT`
|
||||
deliverable.
|
||||
|
||||
`scripts/gate` now refuses to start in that state, before any stage,
|
||||
and **there is no override**: a run under ignored `SIGINT` cannot
|
||||
produce valid evidence. If you see
|
||||
|
||||
```
|
||||
pmacs: SIGINT is ignored; run this command with SIGINT deliverable
|
||||
gate: REFUSING TO RUN --- see the diagnosis above.
|
||||
```
|
||||
|
||||
the fix is to re-run it in the foreground, not to work around the
|
||||
guard. `scripts/check-sigint-deliverable` answers the question on its
|
||||
own: exit **0** deliverable, **1** ignored, **2** undecidable.
|
||||
|
||||
**AND NEVER WRAP THE GATE IN `timeout` TO FIT AN AGENT COMMAND CAP.**
|
||||
The full 16-stage suite outruns a ten-minute cap, and a `timeout 580`
|
||||
around it kills `sweep` mid-run; the runner then records that stage as
|
||||
a **failure**, which reads in the log exactly like a real red. This
|
||||
already produced one false §5b gate result. It is the same defect as
|
||||
the one above in a different costume: a harness convenience silently
|
||||
becoming evidence.
|
||||
|
||||
The supported way to run past the cap is the **tool-level background
|
||||
launch**, which is measured `safe` by
|
||||
`scripts/check-sigint-deliverable`, so the guard admits it and the run
|
||||
is valid. Check it directly if in doubt --- the helper is one command
|
||||
and answers for whatever shell actually invoked it. Failing that, run
|
||||
the suite in labelled pieces and say in the record which piece produced
|
||||
which result. **A truncated run is not a result, and must never be
|
||||
reported as one.**
|
||||
|
||||
```
|
||||
scripts/gate [--acceptance SUITE]... [--protocol]
|
||||
```
|
||||
|
|
@ -2971,30 +2722,9 @@ nothing; and the CLOSE message must use the same variant family as the
|
|||
OPEN, or a session closed by the other family's clear leaves its surface
|
||||
on screen forever.
|
||||
|
||||
**LaTeX is served by `texlab`, and its root is NOT the repository root**
|
||||
(#230). `pmacs.lsp.config.latex` resolves the document root by an upward
|
||||
marker walk — `.texlabroot`/`texlabroot` are texlab's own markers,
|
||||
verified in its `crates/distro/src/language.rs` rather than assumed —
|
||||
and **`.git` is deliberately excluded from that walk**: a repository
|
||||
root is the wrong answer for a multi-file document, which is the whole
|
||||
reason the resolver exists. `config.latex.root` already accepted a
|
||||
resolver function, so this added no new mechanism.
|
||||
|
||||
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes — enumerated from the
|
||||
binary, because this list had gone stale and a stale mode list is how a
|
||||
test ends up covering the shape next to the defect. Capability matrix:
|
||||
`fullonly`, `rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds
|
||||
validation), `sighelp`, `prepare`, `preprefuse`, `rename`,
|
||||
`inlaybounds`, `inlayrefresh`, `semantictokensrefresh`,
|
||||
`applyeditplan`, `resourceops`, `posecho`, `defenv`, `wsconfig`,
|
||||
`rooturi`, `leanprogress`. Failure shapes: `crash`, `error`, `garbage`,
|
||||
`silent`. **File watchers (issue #233)**: `filewatch` (RelativePattern
|
||||
`**/*.txt`), `filewatchabs` (plain-string ABSOLUTE glob),
|
||||
`filewatchflat` (RelativePattern with no leading `**/`),
|
||||
`filewatchbare` (bare relative string — the P1 regression guard),
|
||||
`filewatchrereg` (same id twice, no unregister), `filewatchjoin`,
|
||||
`filewatchretire`. Use these for capability-matrix tests, not real
|
||||
servers.
|
||||
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
|
||||
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
|
||||
`sighelp`. Use these for capability-matrix tests, not real servers.
|
||||
|
||||
## 5. Hard-won ops lessons
|
||||
|
||||
|
|
@ -3415,22 +3145,6 @@ servers.
|
|||
|
||||
## 6. Named deferrals (the standing backlog, consolidated)
|
||||
|
||||
**The GUI arc — OPEN. Stage 0 MERGED as #236 and Stage 1-pre MERGED as
|
||||
#237 (2026-08-12); Stage 1a is next and not started.** See §1's arc
|
||||
bullet for what landed.
|
||||
`docs/gui-arc-framing.md` is the arc-level frame and is also Stage 0's
|
||||
own framing; Stages 1–10 each need their own before their branch. It
|
||||
opened from a daily-driver report: the TUI is daily-drivable, the GUI is
|
||||
not. **`COHERENCE.md` §16 now carries a PRODUCT subgrade (Weak) beside
|
||||
its architectural one (Strong)**, and the arc is what closes it. §20
|
||||
places Half A after Priority 1, and **reaching Stage 4b is a P2 START
|
||||
GATE** — no later GUI stage begins until P2 has an approved framing and
|
||||
an opened lane. The name is deliberate: **"Arc 8" means the Lean 4 arc
|
||||
and nothing else** (Q#GA4). Several standing-backlog items below are
|
||||
sequenced by this arc rather than deferred — §2.5 of the framing maps
|
||||
every one to a stage or leaves it here explicitly.
|
||||
|
||||
|
||||
**Fixture project-detection census — NOT STARTED, follow-on from R8.**
|
||||
`tests/m4_acceptance.rs` alone constructs state through
|
||||
`EditorState::new_with_roots` **113 times**, and an unknown number of
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,187 +0,0 @@
|
|||
# Manual CI dispatch — a contemporaneous run on any ref
|
||||
|
||||
**Status: revision 3 — AWAITING APPROVAL. Nothing implemented.**
|
||||
|
||||
Revision 3 answers review of 2: D2 could pass without the two runs ever
|
||||
overlapping, D3 accepted an aborted job as evidence the matrix ran, the
|
||||
merge-base instruction named the wrong commit and the wrong command, and
|
||||
"when it runs automatically" was still too strong.
|
||||
|
||||
Revision 2 answered review of 1, whose first change was a large
|
||||
reduction in what this claims to be worth.
|
||||
|
||||
## 1. What this fixes
|
||||
|
||||
`ci.yml` triggers on `push` to `main` and on `pull_request`. There is no
|
||||
way to run it on demand, so **a CI run on `main` can only be obtained
|
||||
from whenever `main` last happened to be pushed** — or by re-running a
|
||||
job from that stale run.
|
||||
|
||||
## 2. Why, and exactly which row needs it
|
||||
|
||||
**Revision 1 claimed "four registry rows name a control they cannot
|
||||
obtain". That was wrong, and the correction is worth stating plainly:
|
||||
CI never invokes `scripts/gate` — zero occurrences in `ci.yml`.** It
|
||||
runs `cargo test` directly.
|
||||
|
||||
So every local-gate red in the registry has a merge-base control that
|
||||
needs no CI at all. **Stated precisely, because the sloppy version of
|
||||
this sentence is itself a trap:** run **the exact failing command, with
|
||||
its flags**, at **the recorded merge base** — not at whatever `main`
|
||||
happens to be now — **in a separate worktree**, which this repository's
|
||||
shared-checkout discipline requires anyway. `git checkout main &&
|
||||
./scripts/gate` is none of those three things.
|
||||
|
||||
Measured against the registry:
|
||||
|
||||
| row | where it reds | needs this? |
|
||||
|---|---|---|
|
||||
| **U11** | **`Test (macos-latest / lua54)`** | **YES** |
|
||||
| R7 | local — originally `cargo test --workspace --features crdt` under sweep load, and **its later occurrences at `scripts/gate`'s `gpu` step**, including the two on the parse-budget lane | no |
|
||||
| U6 | local, `scripts/gate` step `04-lib-crdt` | no |
|
||||
| U9 | local, `scripts/gate` step `11-sweep` | no |
|
||||
| U10 | local, `scripts/gate` steps `13-sweep` / `15-sweep-crdt` | no |
|
||||
| U12 | local, `scripts/gate` step `04-lib-crdt` | no |
|
||||
| U13 | local, `scripts/gate` step `07-sweep` | no |
|
||||
|
||||
**One row, not four.** U11 needs it because it is macOS-specific and
|
||||
this project has no Mac: its control cannot be run locally at all. When
|
||||
U11 recurred on PR #243, the only way to get a contemporaneous
|
||||
`main`-side macOS run without touching `main` or opening a PR was to
|
||||
re-run a job from a run **eight days old**. That is the whole of the
|
||||
demonstrated need.
|
||||
|
||||
## 3. What this is, and is NOT, for the other experiments
|
||||
|
||||
**A no-input dispatch of the unchanged workflow runs exactly what a
|
||||
push runs.** It therefore:
|
||||
|
||||
- **directly enables** a contemporaneous `main`-side run of the
|
||||
existing fourteen checks — U11's case, and any future CI-side,
|
||||
platform-specific red;
|
||||
- **does NOT run `scripts/gate`**, which CI does not invoke, so it does
|
||||
nothing for R7's in-gate/out-of-gate question;
|
||||
- **does NOT vary R7's gate conditions**, which would need the gate in
|
||||
CI plus a way to change one condition per run;
|
||||
- **does NOT select U9's alternate test commands**, which would need
|
||||
workflow inputs or a branch carrying different commands.
|
||||
|
||||
Those remain separate work. **This key is infrastructure they would
|
||||
build on, not a substitute for them**, and revision 1 blurred that.
|
||||
|
||||
## 4. What lands
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
One key. No job, matrix, step, permission or timeout changes.
|
||||
|
||||
## 5. The two interactions, both checked
|
||||
|
||||
### 5a. Concurrency — the guarantee, stated at its real strength
|
||||
|
||||
```yaml
|
||||
group: ci-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
```
|
||||
|
||||
**`cancel-in-progress` is evaluated for the INCOMING run.**
|
||||
|
||||
**The defensible guarantee is narrow: once a dispatched run is
|
||||
`in_progress`, a second same-SHA dispatch will not cancel it.**
|
||||
|
||||
Revision 1 said "two dispatches cannot cancel each other", which is too
|
||||
broad. `cancel-in-progress: false` **protects a running run; it does not
|
||||
protect a PENDING one** — GitHub still replaces an existing pending run
|
||||
in the same concurrency group. And a `main` push and a dispatch at the
|
||||
same SHA **do** share `ci-<sha>`, so the two are not isolated from each
|
||||
other the way revision 1 implied.
|
||||
|
||||
A PR run and a dispatch never share a group — PR runs key on
|
||||
`pull_request.number` — so no test involving a PR exercises the
|
||||
predicate at all.
|
||||
|
||||
### 5b. No job is event-conditional
|
||||
|
||||
Measured: `ci.yml` contains exactly one `if:`, and it is
|
||||
`runner.os == 'Linux'` (line 171). `github.event_name` appears **only**
|
||||
inside the concurrency predicate (line 28). A dispatched run therefore
|
||||
expands to the same fourteen jobs a push does.
|
||||
|
||||
## 6. Acceptance
|
||||
|
||||
**The witnesses split across the merge, and they must.** GitHub offers
|
||||
`workflow_dispatch` only for a workflow **already on the default
|
||||
branch**, so no dispatch can be demonstrated before this lands. `gh
|
||||
workflow view ci.yml` shows a summary and `--yaml` is needed for the
|
||||
file — but even that reads the default branch's copy, which is why D1
|
||||
parses the working tree instead.
|
||||
|
||||
| # | when | contract | witness | mutation |
|
||||
|---|---|---|---|---|
|
||||
| D1 | pre-merge | `ci.yml` is valid YAML and declares the trigger | parse the working-tree file; assert `workflow_dispatch` is a key under `on` | drop the key → the assertion fails |
|
||||
| D2 | **post-merge** | a second same-SHA dispatch does not cancel a first that is already running | see below | `cancel-in-progress: true` unconditionally → the first ends `cancelled` |
|
||||
| D3 | **post-merge** | a dispatched run executes the full matrix for real | see below | make any job event-conditional → that job is `skipped` |
|
||||
|
||||
**D2's procedure, because the obvious version passes its own mutant.**
|
||||
"Same ref" does not mean same SHA — `main` can move between the two
|
||||
dispatches, giving them different groups and letting the mutant
|
||||
survive. And a cancelled run still reaches status `completed`, so
|
||||
status is not the discriminator. So:
|
||||
|
||||
1. dispatch `main`, record **run id A** and its `headSha`;
|
||||
2. wait until A is **`in_progress`**, not merely queued — the guarantee
|
||||
is about a running run, and a pending one is genuinely replaceable;
|
||||
3. dispatch `main` again, record **run id B** and its `headSha`;
|
||||
4. **observe a state in which A is `in_progress` AND B is `queued`.**
|
||||
Without this the witness passes vacuously: A can finish naturally
|
||||
between steps 2 and 3, and the unconditional-cancellation mutant
|
||||
then has nothing to cancel. **If that state is never observed, the
|
||||
attempt is VOID — retry it; do not report it as a pass;**
|
||||
5. **assert `headSha(A) == headSha(B)`**, or the run proves nothing —
|
||||
`main` can move between dispatches and hand the two different
|
||||
groups;
|
||||
6. assert **A's final `conclusion` is not `cancelled`**.
|
||||
|
||||
**D3's procedure, for the same reason.** A job suppressed by a
|
||||
job-level `if:` still appears as a check and reports `skipped`, which
|
||||
rolls up as success — so "all fourteen checks are listed" does not
|
||||
detect the mutant. So:
|
||||
|
||||
1. assert every one of the fourteen expected jobs **started**
|
||||
(`started_at` present) **and concluded `success` or `failure`**.
|
||||
"Not `skipped`" is not enough — it still admits `cancelled` and
|
||||
`timed_out`, neither of which is a job that ran the matrix;
|
||||
2. keep a **structural** check for step-level conditionals: the only
|
||||
`if:` in the file is `runner.os == 'Linux'`, and no expression
|
||||
references `github.event_name` or `github.event` outside the
|
||||
concurrency block.
|
||||
|
||||
**D2 and D3 are recorded as OWED at merge.** This framing does not
|
||||
pretend a pre-merge check can stand in for them.
|
||||
|
||||
## 7. Coherence impact (`COHERENCE.md` §20)
|
||||
|
||||
- **Journey steps touched: NONE.** No product behaviour changes.
|
||||
- **Interaction islands: none added.**
|
||||
- **Config registry: no entry.**
|
||||
- **Background work: none started.** A dispatched run is
|
||||
human-initiated by definition.
|
||||
|
||||
## 8. What this does NOT do
|
||||
|
||||
- **It does not make R7, U6, U9, U10, U12 or U13 more answerable.**
|
||||
Those are local-gate reds; CI does not run the gate.
|
||||
- **It does not run any control.** It makes one class of control —
|
||||
CI-side, on an arbitrary ref — obtainable without a stale rerun.
|
||||
- **It does not change what CI runs, or the automatic trigger
|
||||
conditions.** Note the narrower wording: SCHEDULING can change, because
|
||||
a dispatch sharing `ci-<sha>` may replace an existing **pending**
|
||||
`main`-push run. What is unchanged is when CI fires by itself.
|
||||
- **It does not weaken the PR-superseding saving**, which is keyed on
|
||||
`pull_request.number` and gated on the event.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,343 +0,0 @@
|
|||
# Identity-replace undo — a CRDT-version delta is not a text delta
|
||||
|
||||
**Status: revision 5 — APPROVED at revision 4 and IMPLEMENTED**
|
||||
(PR #246, branch `crdt-identity-undo`). Revision 5 is a correction pass
|
||||
answering implementation review; it changes the invariant's shape, not
|
||||
its ruling.
|
||||
|
||||
Revision 5 answers three findings against the implementation:
|
||||
|
||||
1. **the predicate conflated an empty TEXT delta with a version
|
||||
delta.** It called every empty-range/zero-insertion edit
|
||||
`version_only` and then accepted `(History, empty, None)` through a
|
||||
wildcard arm — which contradicts this framing's own "the op must
|
||||
survive". The rule is now a full enumeration over three independent
|
||||
axes (§1a), and C5 asserts all four empty-delta quadrants rather
|
||||
than two;
|
||||
2. **the public `Edit` doc was factually false**, saying forward
|
||||
`apply_edit` never produces the empty-delta shape while C2b proves
|
||||
all three forward empty forms do. The shape is now named an **empty
|
||||
text delta**, reachable on both paths, with `crdt_op` as the
|
||||
discriminator;
|
||||
3. **R7's write-up overstated what the paired gate runs exclude** — see
|
||||
`docs/ci-red-signatures.md`; the pair excludes the source tree and
|
||||
nothing else.
|
||||
|
||||
Revision 4 answered review of 3, which found one substantive gap: **C9
|
||||
guarded the census by file set and count, which a same-file substitution
|
||||
walks straight through.** C9 now asserts the exact
|
||||
`(file, impl target)` pairs, and its claim is scoped to in-tree
|
||||
implementations.
|
||||
|
||||
Revision 3 answered review of 2 by completing §4's census: it closes by
|
||||
construction, and two of its results changed the framing's own claims.
|
||||
|
||||
## 1. The decision, ANSWERED — and it is about PROVENANCE, not shape
|
||||
|
||||
**A visible TEXT delta and a CRDT-VERSION delta are INDEPENDENT
|
||||
dimensions of `Edit`.** An `Edit` may legitimately carry
|
||||
`crdt_op = Some(_)` with `range.is_empty() && inserted_len == 0`.
|
||||
|
||||
**Revision 2 stated that without qualification, and review 2 showed why
|
||||
that is too loose:** an `Edit` carries no provenance marker, so if the
|
||||
shape alone were legitimate the invariant would have nothing left to
|
||||
assert. The precise answer:
|
||||
|
||||
> **An empty TEXT delta carrying a CRDT op is legitimate when the
|
||||
> `Edit` came from `undo`/`redo`, and REQUIRED there. On the FORWARD
|
||||
> path the same shape carrying an op is a bug, and stays asserted.**
|
||||
|
||||
That is a real narrowing, not a repeal, and it is what makes C5
|
||||
testable at all.
|
||||
|
||||
### 1a. The three axes, enumerated
|
||||
|
||||
Revision 4 wrote this as one predicate with a default, and the
|
||||
implementation inherited the gap: `(History, empty delta, None)` fell
|
||||
through a wildcard and was accepted. The axes are independent — that is
|
||||
the lane's whole claim — so the rule is a full enumeration:
|
||||
|
||||
| provenance | text delta | `crdt_op` | verdict |
|
||||
|---|---|---|---|
|
||||
| forward | empty | `None` | **valid** — a syntactic no-op |
|
||||
| forward | empty | `Some` | **invalid** — the original bug |
|
||||
| forward | real | `Some` | valid |
|
||||
| forward | real | `None` | invalid |
|
||||
| history | empty | `Some` | **valid** — a version-only edit |
|
||||
| history | empty | `None` | **invalid** — the version advance is gone |
|
||||
| history | real | `Some` | valid |
|
||||
| history | real | `None` | invalid |
|
||||
|
||||
**An empty text delta is a SHAPE, not a verdict.** Both paths reach it.
|
||||
`crdt_op` is what separates them, and each direction of that separation
|
||||
is asserted.
|
||||
|
||||
**Why this answer:**
|
||||
|
||||
- The invariant it contradicts was written for `is_no_op_edit`
|
||||
(`src/buffer.rs:1836`), a **pre-check on the `EditOp`** reached only
|
||||
from `run_rope_edit_and_broadcast` (`:1256`). **`undo_crdt_mode` and
|
||||
`redo_crdt_mode` never reach it** — they diff two ropes via
|
||||
`derive_replacement_edit` (`:1440`, `:1525`) and attach the op
|
||||
`crdt.undo()` produced (`:1454`), so identical ropes yield an empty
|
||||
range describing a real operation.
|
||||
- **Forward edits reach the empty-delta shape routinely** — each of
|
||||
the three syntactically empty `EditOp` forms produces exactly it, as
|
||||
C2b asserts. What is unreachable forward is the shape **carrying an
|
||||
op**: an empty form short-circuits to `(None, None)`, and a
|
||||
real-delta form is not empty. So forward "empty range and zero
|
||||
insertion" implies `crdt_op == None`, still — which is what lets the
|
||||
invariant keep its full strength there.
|
||||
- The op must survive. Dropping it would lose a version advance the
|
||||
replicas need — which is what C3 now actually tests, and revision 1's
|
||||
C3 did not.
|
||||
- **The codebase already assumes this, in two places written for other
|
||||
reasons.** `FoldStore::translate` (`src/fold.rs:211`–`:213`) and
|
||||
`BufferStyleSpanTranslator::on_edit` (`src/overlay.rs:261`–`:263`)
|
||||
both return early on `old_len == 0 && new_len == 0`, and both say so
|
||||
in a comment — *"Buffers broadcast no-op edits; nothing moved."* This
|
||||
lane is not introducing a doctrine; it is naming one that consumers
|
||||
were already written against.
|
||||
- The public contract has room for it. `src/rope.rs:301`–`:303`
|
||||
enumerates pure insert, pure delete and replace, and **has no fourth
|
||||
case**; the `crdt_op` field doc (`src/rope.rs:316`) goes further and
|
||||
asserts the conflation outright ("`None` … for no-op edits in CRDT
|
||||
mode"). Both are updated by this lane.
|
||||
|
||||
**The empty range's LOCATION is settled by §4's census, not deferred.**
|
||||
It stays at the buffer end. No consumer is harmed there, and for the
|
||||
one consumer whose cost depends on it, the buffer end is the *cheapest*
|
||||
choice — see §4.
|
||||
|
||||
## 2. What is already known — and precisely how well
|
||||
|
||||
`src/buffer.rs:3044` carries a deterministic fixture,
|
||||
`crdt_undo_of_an_identity_replace_reports_a_no_op_edit_carrying_an_op`,
|
||||
`#[ignore]`d at `:3042` and documented from `:3005`. It reduces this
|
||||
exact case: replacing bytes with **identical bytes** is a textual no-op
|
||||
but a real CRDT delete-plus-insert, so undoing it advances the CRDT
|
||||
version while leaving text unchanged.
|
||||
|
||||
**Its evidence is narrower than revision 1 claimed, and narrower in one
|
||||
more place than revision 2 admitted:**
|
||||
|
||||
| claim | how it is established | strength |
|
||||
|---|---|---|
|
||||
| content stays correct | **asserted** in the fixture — rope and CRDT projection agree | direct |
|
||||
| the op reaches broadcast consumers | **by INSPECTION of the call sites** | reasoning, not execution |
|
||||
| the cursor does not jump | **by INSPECTION** — `EditorCore::undo` only clamps to length | reasoning, not execution |
|
||||
|
||||
**The cursor row was marked "direct" in revision 2. It is not.** The
|
||||
fixture body (`buffer.rs:3044`–`:3093`) contains **no reference to
|
||||
`EditorCore` and none to a cursor**; it exercises `Buffer` alone. The
|
||||
cursor claim is inspection of a different function than the one the
|
||||
fixture runs.
|
||||
|
||||
**Nothing here replays the op on a remote replica or witnesses
|
||||
convergence.** Revision 1 said "replicas stay converged" as though it
|
||||
were established. It is not. **That is exactly what C3 must newly
|
||||
establish**, and it is the main new evidence this lane produces.
|
||||
|
||||
The CI red that prompted the lane is a randomly sampled recurrence of
|
||||
this fixture, not a new defect.
|
||||
|
||||
## 3. Terminology, because revision 1's contradicted itself
|
||||
|
||||
**An identity replace IS a forward textual no-op**, and it *does*
|
||||
produce an operation. So "forward textual no-ops produce no operation"
|
||||
is false, and revision 1 asserted it while §2 said the opposite.
|
||||
|
||||
The correct statement names a **syntactic** category:
|
||||
|
||||
> The three **syntactically empty `EditOp` forms** — `Insert` with
|
||||
> empty bytes, `Delete` with an empty range, and `Replace` with both
|
||||
> empty — produce no CRDT operation.
|
||||
|
||||
That is what `is_no_op_edit` tests, and it stays true.
|
||||
|
||||
## 4. The consumer census — COMPLETE
|
||||
|
||||
Revision 2 listed `broadcast_on_edit` as a row reading *"every attached
|
||||
view — not enumerated, owes."* **That is a dispatcher, not a consumer,
|
||||
and review 2 was right that it cannot stand.** Here is the enumeration.
|
||||
|
||||
Both `undo_crdt_mode` (`buffer.rs:1456`) and `redo_crdt_mode`
|
||||
(`:1537`) do broadcast, so this path is real.
|
||||
|
||||
### 4a. How the census closes
|
||||
|
||||
Three measurements bound it, so it is complete by construction rather
|
||||
than by search effort — **for this tree**; see §4d on why no in-tree
|
||||
measurement can reach further:
|
||||
|
||||
1. **The `View` trait's `on_edit` default is `Ok(())`**
|
||||
(`src/view.rs:450`–`:452`). Every impl that does not override it is
|
||||
**structurally inert** — it never reads the range.
|
||||
2. **Exactly four non-test impls override `on_edit`**: `ParseView`
|
||||
(`syntax.rs:1637`), `TextView` (`text_view.rs:521`),
|
||||
`FoldStoreTranslator` (`fold.rs:274`), `BufferStyleSpanTranslator`
|
||||
(`overlay.rs:248`). The other twelve inherit the default.
|
||||
3. **Exactly four production `Buffer::attach_view` call sites exist**
|
||||
— `fold.rs:341`, `lua_bindings/mod.rs:3963`, `:4008`, `:8137`.
|
||||
Measured over the 50 occurrences of `attach_view` outside its own
|
||||
definition: **38 sit inside `#[cfg(test)]`**, and of the 12
|
||||
remaining, **8 are doc comments or a different API** (the Lua
|
||||
`pmacs.diag._attach_view` name, and `SyntaxRegistry::attach_view` at
|
||||
`lua/mod.rs:8138`, which registers a handle rather than a buffer
|
||||
view).
|
||||
|
||||
### 4b. Broadcast consumers, classified
|
||||
|
||||
| attached view | site | reads range? | verdict |
|
||||
|---|---|---|---|
|
||||
| `FoldStoreTranslator` | `fold.rs:341` | via `FoldStore::translate` | **INERT** — explicit `old_len == 0 && new_len == 0` early return at `fold.rs:211`–`:213` |
|
||||
| `BufferStyleSpanTranslator` | `lua/mod.rs:4008` | yes | **INERT** — same explicit early return, `overlay.rs:261`–`:263` |
|
||||
| `ParseView` | `lua/mod.rs:8137` | yes | **PERMITTED, justified below** |
|
||||
| `LuaInterceptView` | `lua/mod.rs:3963` | — | **INERT** — overrides `intercept_edit` only (`lua/mod.rs:2132`); inherits the `Ok(())` default |
|
||||
|
||||
**`ParseView` is the one permitted effect.** At 0→0 its splice
|
||||
(`syntax.rs:1656`) is `source.splice(n..n, [])` — the source mirror is
|
||||
**unchanged** — and it pushes one `InputEdit` with
|
||||
`start_byte == old_end_byte == new_end_byte` and all three `Point`s
|
||||
equal (`:1661`–`:1668`). **Why that is acceptable:** a degenerate
|
||||
`InputEdit` describes no change, so the incremental parse it feeds must
|
||||
produce an identical tree. **C4 asserts that rather than assuming it**,
|
||||
and also asserts the pending queue drains, since an effect that
|
||||
accumulates per undo would not be acceptable.
|
||||
|
||||
### 4c. Direct (non-broadcast) consumers
|
||||
|
||||
**Revision 2 filed `TextView` under broadcast. It is not attached to
|
||||
any buffer** — it lives on the window (`win.text_view`) and
|
||||
`EditorCore::undo` calls it directly at `editor_core.rs:2846`.
|
||||
|
||||
| consumer | reads range? | verdict |
|
||||
|---|---|---|
|
||||
| `Buffer::adjust_marks_for_edit` (def. `buffer.rs:1609`; called `:1442` undo, `:1527` redo) | yes | **INERT, arithmetically** — with `start == end` and `inserted_len == 0`, every branch is identity: `pos < start` → `pos`; `pos > end` → `pos - 0 + 0`; `pos == start` → `start` under both gravities (`:1617`–`:1629`) |
|
||||
| `EditorCore::undo` → `TextView::on_edit` (`editor_core.rs:2846`, body `text_view.rs:521`) | yes | **PERMITTED** — `rebuild_lines_from(buf, line_at_offset(range.start))`. Text is unchanged, so the rebuild is **output-identical**; the cost is the tail of the buffer from `range.start`. **The buffer-end location makes this the CHEAPEST possible rebuild** — moving the range to the edit site would rebuild strictly more |
|
||||
| `search_invalidate_for_edit` → `mark_stale` (`editor_core.rs:1974`) | **no** | **PERMITTED** — unconditional and range-independent. Search matches are marked stale on an edit that changed no text. Acceptable (correctness is preserved; a re-search is redundant, not wrong), and **moving the range would not change it** |
|
||||
| `search_invalidate_for_edit` → `translate_search_origin` (`editor_core.rs:1984`) | yes | **INERT, arithmetically** — with `start == end` and `inserted_len == 0`: `pos < start` → `pos`; `pos > end` → `pos - 0 + 0`; else `start + 0`, reachable only at `pos == start` (`:1994`–`:2000`) |
|
||||
|
||||
### 4d. The disposition
|
||||
|
||||
**Five inert, three permitted, none harmed. The range does not move,**
|
||||
and that conclusion now rests on measurement rather than on a deferral.
|
||||
|
||||
| | inert | permitted |
|
||||
|---|---|---|
|
||||
| broadcast (§4b) | `FoldStoreTranslator`, `BufferStyleSpanTranslator`, `LuaInterceptView` | `ParseView` |
|
||||
| direct (§4c) | `adjust_marks_for_edit`, `translate_search_origin` | `TextView`, `mark_stale` |
|
||||
|
||||
*(Revision 3 said four and three. Miscount, corrected.)*
|
||||
|
||||
The two permitted effects with a cost — `TextView`'s rebuild and
|
||||
`mark_stale` — are both **strictly cheaper or equal at the buffer end**
|
||||
than at the edit site, so the location the fixture called arbitrary is
|
||||
not merely harmless but weakly preferable.
|
||||
|
||||
**This census is a point-in-time measurement of THIS TREE**, valid at
|
||||
the commit the lane branches from. Both `View` (`src/view.rs:419`) and
|
||||
`Buffer::attach_view` (`src/buffer.rs:674`) are **public**, so a
|
||||
downstream crate may implement `on_edit` and attach it, and no in-tree
|
||||
measurement can enumerate that. The census, and C9 with it, are scoped
|
||||
to in-tree implementations; the public contract §5's C7 updates is what
|
||||
speaks to anyone outside. Revision 3 claimed C4 would guard it against a new
|
||||
override or attach site; **it cannot — executing three consumers says
|
||||
nothing about a fourth, and that claim is withdrawn.** C9 is the guard
|
||||
that actually holds, and it holds the one condition that matters: if
|
||||
the set of `on_edit` overrides is unchanged, then every attach site,
|
||||
new or old, attaches a view that is either the inert trait default or
|
||||
one of the four already classified.
|
||||
|
||||
## 5. Acceptance
|
||||
|
||||
| # | contract | witness | mutation |
|
||||
|---|---|---|---|
|
||||
| C1 | the fixture runs, and is not silently re-ignored | un-ignore it; **plus a structural assertion** that no `#[ignore]` attribute precedes the fixture's `fn` (via `include_str!` on the file), **plus** the run's `1 passed; 0 ignored` line recorded as gate evidence | restore `#[ignore]` → the structural assertion fires **and** the recorded line reads `0 passed; 1 ignored`. Without one of these, re-ignoring is a green suite |
|
||||
| C2a | `is_no_op_edit` classifies all three **syntactically empty forms** as no-ops | assert `is_no_op_edit` **directly** for `Insert{bytes:[]}`, `Delete{range:empty}`, `Replace{range:empty,bytes:[]}` | flip **any one** arm (`buffer.rs:1838`–`:1840`) → C2a fires. Nothing sits between the assertion and the classifier, so this mutant **cannot be masked** |
|
||||
| C2b | end-to-end, each empty form still yields `crdt_op == None` | apply each form through `apply_edit` on a CRDT buffer | **compound mutant, and it must be**: flip the arm **and delete that variant's defensive early return** — `buffer.rs:1177`–`:1182` (Insert) or `:1192`–`:1194` (Delete). See below |
|
||||
| C3 | an empty-text history op **replays convergently on a REMOTE replica**, **for both `undo` and `redo`** | seed replica B with the **forward** ops, apply the history op to B, assert **(a)** identical materialized text **and (b)** identical CRDT version/frontier; then apply a **causally dependent** op and assert both still agree | **drop the history op before replay** → text still matches, so only the version/frontier assertion catches it |
|
||||
| C4a | the history edit is **broadcast at all**, for both `undo` and `redo` | attach a counting view (the `RecorderView` shape, `buffer.rs:2218`) and assert **exactly one** `on_edit` per history op | **delete `self.broadcast_on_edit(&inverse_edit)?`** at `buffer.rs:1456` (undo) or `:1537` (redo) → the count is 0 → C4a fires |
|
||||
| C4b | the classified consumers are unchanged by the real history edit | attach `FoldStoreTranslator`, `BufferStyleSpanTranslator` and `ParseView`; run the identity-replace op; assert fold store unchanged, span vector unchanged, **parse tree identical**, and `pending_edit_count()` returns to 0 after the drain (`syntax.rs:712`, `:737`) | see the note below — **C4b claims no guard mutation**, and C4a is what makes it non-vacuous |
|
||||
| C4c | the style-span guard's own contract, pinned where it can fire | call `BufferStyleSpanTranslator::on_edit` with a **synthetic INTERIOR 0→0 `Edit`** whose position falls strictly inside an existing span, and assert the span vector is **byte-identical** — not merely equal in coverage | delete `overlay.rs:261`–`:263` → the span splits into two adjacent fragments and the vector differs → C4c fires |
|
||||
| C5 | the invariant is keyed on **provenance**, and covers **all four** empty-text-delta quadrants of §1a | preserve the `GenOp` classification (`buffer.rs:3101`, where `op` is moved before it can be classified) as an operation class; extract the shape check to take `(class, &Edit)`; then **inject** all four: `(Forward, empty, None)` accepted, `(Forward, empty, Some)` rejected, `(History, empty, Some)` accepted, `(History, empty, None)` rejected | widen the forward rule → C5 fires; accept `(History, empty, None)` → C5 fires, and **revision 4's two-assertion C5 did not**. **The proptest alone catches neither**, because no generated input reaches either row — which is why C5 is a directed injection, not a property |
|
||||
| C6 | the executed history-case set **is** `{Undo, Redo}` | after the parameterized loop, assert the collected set of cases actually run equals the literal `{Undo, Redo}`; a `match` over the case enum keeps a future variant from being added silently | drop `Redo` from the case list → the **set assertion** fires. Without it the suite simply runs one case and stays green, which is why revision 3's C6 was a zero-execution witness |
|
||||
| C7 | the public contract names the **empty text delta** and says which path produces which `crdt_op` | the `Edit` doc (from `src/rope.rs:292`) gains the empty-delta shape **as reachable on BOTH paths** — `None` forward, `Some` from history — and the `crdt_op` field doc stops asserting that no-op edits have no op | leave the doc → it contradicts the code the lane just blessed. **An earlier version of this row said forward `apply_edit` never produces the shape; C2b proves all three forward empty forms do**, so the doc it produced was false and is corrected in revision 5 |
|
||||
| C8 | **the fixture's own doc comment is corrected**, not just its attribute | rewrite `buffer.rs:3005`–`:3040`: convergence is **established by C3**, not "verified" (`:3023`–`:3026`); the buffer-end range is **ruled and weakly preferable** per §4, not "genuinely arbitrary" (`:3034`–`:3036`); "**The open question**" (`:3030`) becomes the ruling; and the `#[ignore]` reason string (`:3042`–`:3043`) goes with the attribute | leave the comment → the repository's most-read record of this defect still says the decision is open and that convergence was already checked, contradicting §1, §2 and C3 |
|
||||
| C9 | §4's census stays closed **for in-tree implementations** | walk `CARGO_MANIFEST_DIR/src` and assert the set of **`(file, impl target)` pairs** carrying a non-`#[cfg(test)]` `fn on_edit` override is exactly `{(syntax.rs, ParseView), (text_view.rs, TextView), (fold.rs, FoldStoreTranslator), (overlay.rs, BufferStyleSpanTranslator)}` — pairs, not file set and count, and by name rather than line number | **replace `ParseView`'s override with an unclassified type in the SAME file** → file set and count are both unchanged, and only the pair set catches it. Adding a fifth override anywhere under `src/` fires it too, naming the file and the type |
|
||||
|
||||
**Why C4 was rebuilt.** Revision 3's C4 claimed that deleting the fold
|
||||
or style guard would make an assertion fire. **Both mutants survive**,
|
||||
and the arithmetic says why: the history edit sits at the buffer end,
|
||||
so with `old_start == old_end == len` and `old_len == new_len == 0`,
|
||||
`BufferStyleSpanTranslator` emits a left fragment `[s, min(e, len))`
|
||||
for every span within the buffer and no right fragment — the vector is
|
||||
unchanged with or without the guard (`overlay.rs:269`–`:285`). The fold
|
||||
store's remaining arithmetic is identity for the same reason. **At this
|
||||
location the fold guard is an optimization, not a behaviour
|
||||
discriminator, and no mutation is claimed for it.** What discriminates
|
||||
is whether the broadcast happens at all (C4a) and whether the style
|
||||
guard holds where the fragmenting is reachable (C4c).
|
||||
|
||||
*(A span of zero width at exactly `len` would be dropped without the
|
||||
guard and kept with it. That is not used as a witness: whether such a
|
||||
span is constructible is unestablished, and a witness resting on a
|
||||
degenerate value is a worse instrument than the interior injection.)*
|
||||
|
||||
**C2b's mutant is compound because two of three variants mask it, and
|
||||
the asymmetry is measured.** `apply_to_crdt_then_normalize_bytes`
|
||||
returns `(None, None)` early for an empty `Insert` (`:1177`–`:1182`)
|
||||
and an empty `Delete` (`:1192`–`:1194`), so flipping those arms alone
|
||||
still yields `crdt_op == None` and the simple mutant survives. The
|
||||
empty `Replace` has **no** early return — `:1208` skips the delete,
|
||||
`:1211` skips the insert, and control falls through to the
|
||||
unconditional `Some(crdt_op)` at `:1231`–`:1236` — so there, and only
|
||||
there, the simple mutant dies. C2a exists so this asymmetry cannot hide
|
||||
a classifier regression.
|
||||
|
||||
**C3's mutation is the point of C3.** Revision 1's version asserted
|
||||
text equality alone, and an identity-replace history op leaves text
|
||||
unchanged — so **dropping the op passed it**. Version/frontier equality
|
||||
is what discriminates; the causally dependent op is corroboration on
|
||||
top.
|
||||
|
||||
**C5 is the second half of §1's answer.** §1 makes the shape legitimate
|
||||
*for history ops*; without a provenance-keyed check there is no
|
||||
remaining assertion for the forward path, and the invariant would have
|
||||
been repealed rather than narrowed.
|
||||
|
||||
**C1, C6 and C9 exist because a green suite is not evidence that a
|
||||
suite RAN.** Re-ignoring a fixture, dropping a parameter, and adding an
|
||||
unclassified consumer are all silent under ordinary assertions. Each
|
||||
gets a witness that fails on absence rather than reporting it.
|
||||
|
||||
## 6. Coherence impact (`COHERENCE.md` §20)
|
||||
|
||||
Under the resolution the census confirms — invariant narrowed to
|
||||
provenance, behaviour unchanged:
|
||||
|
||||
- **Journey steps touched: NONE.** No product behaviour changes; the
|
||||
work is a test contract, a census, and public documentation.
|
||||
- **Interaction islands: none added.**
|
||||
- **Config registry: no entry.**
|
||||
- **Background work: none started.**
|
||||
|
||||
Revision 2 made this section conditional on a census that had not run.
|
||||
**It has now run, and no consumer is harmed, so the section is
|
||||
unconditional.**
|
||||
|
||||
## 7. What this does NOT do
|
||||
|
||||
- **It does not commit the proptest regression seed.** That duplicates
|
||||
a deterministic fixture and would make a disputed assertion fail
|
||||
permanently rather than occasionally.
|
||||
- **It does not re-verify content correctness**, which §2 records as
|
||||
directly asserted. It *does* newly establish remote convergence,
|
||||
which §2 records as only inspected.
|
||||
- **It does not move the empty range**, and after §4 that is a measured
|
||||
result rather than a deferral.
|
||||
- **It does not audit `intercept_edit`**, a different stage with a
|
||||
different contract. The census covers `on_edit` and the direct
|
||||
consumers of the history `Edit`.
|
||||
- **It does not reorder the roadmap.** GUI arc 1b remains the next
|
||||
product lane.
|
||||
|
|
@ -1,30 +1,6 @@
|
|||
# `scripts/gate` — per-worktree build isolation, and one gate suite
|
||||
|
||||
**Status: revision 6 — APPROVED and IMPLEMENTED.** Revision 6 extends
|
||||
the isolation contract to `TMPDIR` (§2a below). It is a *widening of an
|
||||
existing responsibility*, not a new feature: §2 already owns "what the
|
||||
gate isolates", and `TMPDIR` was simply missing from that list — which
|
||||
is how a stray `/tmp/.git` came to redden a gate run on an unrelated
|
||||
lane.
|
||||
|
||||
**Approved after four review rounds plus a locale follow-up, all of
|
||||
which turned on evidence rather than design.** Round 1 corrected a
|
||||
propagation witness that observed no inheritance, a reserve that was
|
||||
not the maximum, and a guard that leaked what it exists to manage.
|
||||
Round 2 tightened the socket budget to the Darwin floor, *ruled* the
|
||||
nested case rather than accommodating it by loosening the reserve, and
|
||||
replaced an existence-only ancestor check with one that honours marker
|
||||
types. Round 3 made the traversal canonical, gave the length guard its
|
||||
first witnesses, and **withdrew an unsupported causal claim**. Round 4
|
||||
found four properties that were fixed or claimed but would have stayed
|
||||
green if reverted: it added the **canonical-traversal witness**, moved
|
||||
the guard rows onto the exact boundary, and covered both managed areas
|
||||
on cleanup. The follow-up required the byte-versus-character row to
|
||||
*establish* its precondition rather than name one. Each correction was
|
||||
a witness asserting something adjacent to the contract while appearing
|
||||
to assert the contract itself.
|
||||
|
||||
**Previously, revision 5. Approved at revision 4 and IMPLEMENTED; revision
|
||||
**Status: revision 5. Approved at revision 4 and IMPLEMENTED; revision
|
||||
5 records two safety defects review found in the implementation.**
|
||||
|
||||
**Neither was a design gap — both were the implementation failing to
|
||||
|
|
@ -441,147 +417,6 @@ under real parallel load, direnv is the escalation.
|
|||
|
||||
---
|
||||
|
||||
## 2a. `TMPDIR` isolation (revision 6, APPROVED)
|
||||
|
||||
**The gap.** §2 lists what a gate run isolates: the target directory and
|
||||
five ambient roots. `TMPDIR` was not on that list, so
|
||||
`tempfile::tempdir()` fixtures landed wherever the operator's `/tmp`
|
||||
pointed. That is not a hygiene preference — **project detection walks
|
||||
UPWARD**, so a marker anywhere above the temp directory re-roots every
|
||||
markerless fixture beneath it.
|
||||
|
||||
**Observed, not hypothesised.** An empty `/tmp/.git` reddened
|
||||
`m4_24_bare_string_glob_stays_relative` and
|
||||
`m4_24_d3_fallback_base_is_the_smallest_attachment_dir` *inside a gate
|
||||
run*, on a lane whose entire executable diff lived in `pmacs-gpu` — a
|
||||
crate the failing test binary does not link. Diagnosing it cost a review
|
||||
round, and the workaround was a manual `TMPDIR=` on every invocation.
|
||||
|
||||
**The contract.** Each invocation gets a directory created fresh by
|
||||
`mktemp -d` under `<gate-root>/tmp/`, exported once so every stage and
|
||||
every process they spawn inherits it, and reaped by the exit trap that
|
||||
already removes the ambient root.
|
||||
|
||||
Four decisions inside that, each of which had a cheaper wrong answer:
|
||||
|
||||
1. **Not a subdirectory of `/tmp`.** It inherits `/tmp`'s ancestors and
|
||||
therefore the marker. The directory has to sit somewhere with no
|
||||
marker above it.
|
||||
2. **Off the GATE ROOT, not the per-worktree target.** A Unix socket
|
||||
path cannot exceed `sun_path` — **103 usable bytes at the supported
|
||||
floor** (Darwin's 104-byte array minus its terminating NUL; Linux's
|
||||
is 108/107) and the suites bind sockets
|
||||
*inside* `TMPDIR`. The per-worktree target is 60 bytes and the gate
|
||||
root 36; the first implementation used the former and produced
|
||||
114-byte socket paths, failing six daemon and attach tests. **The
|
||||
parent is consequently SHARED between worktrees and is not covered
|
||||
by `--prune`**, which only considers directories carrying an
|
||||
ownership marker; each run removes its own leaf.
|
||||
3. **Created by `mktemp -d`, not `mkdir -p` on a pid.** PIDs are reused,
|
||||
so after a SIGKILL a `mkdir -p` silently *adopts* a leftover
|
||||
directory and the run inherits another run's fixtures.
|
||||
4. **Two guards, and both fail loudly at startup** rather than letting
|
||||
the symptom appear deep in a suite as a limit with no cause:
|
||||
- a **byte-counted** length check reserving the measured maximum
|
||||
suffix (`/.tmpXXXXXX/directory-target.sock`, 33 bytes) plus
|
||||
headroom — byte-counted because `sun_path` is byte-limited while
|
||||
`${#var}` counts *characters* in a shell that handles multibyte.
|
||||
**Which shell runs the script decides this**: `bash` counts
|
||||
characters under a UTF-8 locale, `dash` counts bytes under every
|
||||
locale, and `#!/bin/sh` is `bash` on Arch and macOS but `dash` on
|
||||
Debian and Ubuntu. The count must not depend on that, so the guard
|
||||
measures bytes explicitly (`printf | LC_ALL=C wc -c`) rather than
|
||||
relying on the interpreter it happens to get;
|
||||
- an **ancestor-marker check**, because **a managed root is not
|
||||
inherently marker-free**: a `.git` in `$HOME`, a marker above
|
||||
`$HOME/build`, or a contaminated `PMACS_GATE_TARGET_ROOT` rebuilds
|
||||
the original defect one directory up. Placement under a directory
|
||||
the gate owns is *necessary, not sufficient*, so the precondition
|
||||
is verified rather than assumed.
|
||||
|
||||
**THE TRAVERSAL IS CANONICAL AND MUST NOT WORD-SPLIT**, and both
|
||||
halves are contract rather than style. An unquoted `$(...)`
|
||||
expansion splits on `IFS`, so a gate root containing a **space**
|
||||
is torn into fragments and its real ancestor is never tested —
|
||||
the guard then passes on exactly the path it exists to reject.
|
||||
And `dirname` walks **lexical** ancestry while `detect_project`
|
||||
canonicalizes, so a **symlinked** root hides a marker the editor
|
||||
plainly sees; the gate and the editor must not disagree about the
|
||||
same tree. The walk resolves with `pwd -P` first and iterates a
|
||||
quoted loop, and both shapes are witnessed.
|
||||
|
||||
**MIRRORING THE NAMES IS NOT ENOUGH — the TYPES are part of the
|
||||
contract.** `match_marker` (`src/project.rs`) requires `.git` to be
|
||||
a **directory** and the seven language markers to be **files**, so
|
||||
an existence-only test rejects ancestors detection itself ignores.
|
||||
The case that matters is not exotic: **a git WORKTREE has a `.git`
|
||||
FILE**, so every worktree in this repository would have tripped an
|
||||
`[ -e ]` check while project detection walked straight past it.
|
||||
The guard tests `[ -d ]` for `.git` and `[ -f ]` for the rest.
|
||||
|
||||
**The budget is the SUPPORTED-PLATFORM FLOOR, not Linux's.** `sun_path`
|
||||
is 108 bytes on Linux but **104 on Darwin** (xnu `bsd/sys/un.h`), and
|
||||
pmacs supports macOS — CI runs a `macos-latest` leg. A Linux-derived
|
||||
limit would pass on the machine that wrote it and bind-fail on the
|
||||
other, which is the worst place to find out. **The usable PATH length is
|
||||
one less than the array**, because the stored value is NUL-terminated:
|
||||
103 on Darwin, 107 on Linux. The script takes **103**.
|
||||
|
||||
**RULING — a synthetic nested gate is given room to SATISFY the
|
||||
reserve; it is not exempted from it.** The guard is unchanged for every
|
||||
run, nested or not. What changed is the layout the behaviour suite
|
||||
hands it. The reserve exists for fixtures that bind sockets under
|
||||
`TMPDIR`. This script's own behaviour suite runs *nested* gates whose
|
||||
plans are synthetic (`true`, `false`, one `echo`) and which bind no
|
||||
socket at all, so applying the fixture reserve to them would reject a
|
||||
configuration that cannot suffer the failure it guards against — and
|
||||
the suite would fail on a setup it created rather than on the behaviour
|
||||
under test. That is not hypothetical: at a 45-byte reserve the nested
|
||||
path measured ~71 bytes and was rejected.
|
||||
|
||||
Two ways to resolve it were available, and **the layout was changed
|
||||
rather than the guard weakened**:
|
||||
|
||||
- *Rejected — exempt nested runs from the guard.* It would make the
|
||||
guard untestable in the configuration the tests exercise, and "this
|
||||
run is nested" is not something the script can know reliably.
|
||||
- **Adopted — the behaviour suite roots its gates at a SHORT base
|
||||
(`/tmp`) instead of inheriting the ambient `TMPDIR`.** A nested gate
|
||||
then sits at ~24 bytes rather than ~71 and clears the real reserve
|
||||
with room to spare. The suite is explicit that it does this for the
|
||||
socket budget, and it is free to use `/tmp` precisely because its
|
||||
plans create no markerless fixture — the same reason it may set the
|
||||
ancestor escape.
|
||||
|
||||
**The guard therefore keeps the true maximum for every run**, and the
|
||||
suite simply stops handing it a root that cannot clear it. An earlier
|
||||
draft of this section said nested gates "do not pay" the reserve, which
|
||||
is wrong and would have licensed exempting them: they pay it in full
|
||||
and now have the headroom to afford it. If a future behaviour row binds
|
||||
a socket, it must move off the short base — the reserve already covers
|
||||
it.
|
||||
|
||||
**Escape hatch, documented test-only.**
|
||||
`PMACS_GATE_ALLOW_ANCESTOR_MARKER` exists for this script's own
|
||||
behaviour tests, which run the gate under a `tempfile::tempdir()` whose
|
||||
ancestors they do not control — on a machine whose `/tmp` carries the
|
||||
very marker in question — and whose plans are synthetic, so no
|
||||
markerless fixture exists for a marker to re-root. It sits beside
|
||||
`PMACS_GATE_TARGET_ROOT` in kind and in risk. **The check is witnessed
|
||||
by a row that deliberately does not set it.**
|
||||
|
||||
**Verification.** Two witnesses beyond the refusal row: propagation
|
||||
observed in a *spawned child* (the self-test's first step reports its
|
||||
own `$TMPDIR` into its log — asserting the variable inside the script
|
||||
would only prove the script can set a variable), and cleanup after a
|
||||
run that **failed on purpose**, which is the path a leak would actually
|
||||
take.
|
||||
|
||||
**Residual, stated rather than covered.** A custom project marker
|
||||
registered at runtime is invisible to a shell script and is not
|
||||
checked. The built-in list mirrors `default_markers()` in
|
||||
`src/project.rs` and will drift if that list grows.
|
||||
|
||||
## 3. Resolved questions
|
||||
|
||||
### Q#GS1 — directory naming — **RESOLVED**
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,974 +0,0 @@
|
|||
# The GUI arc — framing
|
||||
|
||||
**Status: revision 3 — APPROVED 2026-08-10.** Approved on its design;
|
||||
the seven accuracy and process edits requested with the approval are
|
||||
folded in, and no further review round is required before the Stage 0
|
||||
branch.
|
||||
|
||||
**This document is itself the framing for Stage 0**, which is docs-only.
|
||||
Revision 3's opening previously said that *every* stage gets its own
|
||||
framing while §10 proceeded straight from this document into Stage 0 —
|
||||
the two could not both be true. The rule, stated exactly:
|
||||
|
||||
- **Stage 0 is framed by this document.** No separate framing round; it
|
||||
ships documentation only, and its scope is enumerated in §5.
|
||||
- **Stages 1 through 10 each require their own framing**, approved
|
||||
before that stage's branch, as the arc-level contract-ownership rule
|
||||
demands. This document owns ordering, gates and the arc-level bar —
|
||||
never a stage's acceptance criteria.
|
||||
|
||||
**Revision 3 answers the second review round (two blocking findings,
|
||||
seven required corrections, and nine rulings that close Q#GA4–Q#GA12).**
|
||||
|
||||
- **The closure comparison is falsifiable now** (blocking №1). Revision
|
||||
2's ordinal ordered grades but never said how to *assign* one, and
|
||||
"normalize to the head grade" silently mis-graded compound rows: a
|
||||
step reading "Works but undiscoverable" took `Works` from its first
|
||||
word while the failing half became a non-blocking annotation — even
|
||||
though **discoverability is the substance of steps 4, 7 and 11**, not
|
||||
a qualifier on them. §3.3 now defines observable criteria for each
|
||||
grade, grades a compound step by its **weakest required subclaim**,
|
||||
and separates **local TUI / attached TUI / GPU** into three columns.
|
||||
The comparison is GPU against the **local TUI** — the canonical
|
||||
`pmacs .` journey — with the attached TUI retained as evidence,
|
||||
because it is what distinguishes a semantic-wire gap from a
|
||||
frontend-local one.
|
||||
- **Half B's dependency graph was inverted, and is re-ordered**
|
||||
(blocking №2). Viewport facts cannot be designed before the
|
||||
multi-window model decides whether semantic windows are daemon
|
||||
projections or frontend-local objects, because that decision
|
||||
determines **the identity a viewport fact is *about***. And a
|
||||
framing-only stage cannot hand the sidebar geometry it consumes. Half
|
||||
B is now 6 model framing → 7 viewport/window-identity substrate
|
||||
(with smooth scroll and the scrollbar) → 8 splits **plus implemented**
|
||||
non-bottom side geometry → 9 sidebar riding Stage 8 → 10 tabs.
|
||||
- **"The GPU consumes daemon `view_top`" was backwards.** The backlog
|
||||
says the opposite in as many words — "the GPU **never** consumes
|
||||
daemon `view_top`" (`docs/side-quest-backlog.md:147`). Corrected.
|
||||
- **Stage 4b owns save *and* restore as a pair.** Q#DS9 scopes both to
|
||||
local mode and makes **both** no-ops under a daemon, enforced in Rust
|
||||
(`desktop-save-framing.md:222`). Revision 2's Q#GA10 claimed only the
|
||||
restore *trigger* remained open; snapshot ownership, save timing and
|
||||
multi-frontend arbitration remain open too.
|
||||
- **The Bell audit was wrong.** The daemon already emits
|
||||
`InstanceSignal::Bell` (`src/daemon.rs:1373`), at the audit anchor
|
||||
`4bc55e8` as well; `src/frontend.rs:349` consumes it and `pmacs-gpu`
|
||||
does not. Bell is **consumer-only** work for Stage 1c, with no
|
||||
producer question to answer.
|
||||
- **Stage 0 no longer contradicts the portability rule.** Revision 2
|
||||
put the absorption PR *before* committing this framing, which leaves
|
||||
the approved framing living only in one worktree. The approved
|
||||
framing and the Stage 0 lane are now the branch's **first commit**;
|
||||
if synchronization stays a separate PR, that PR carries the framing
|
||||
first.
|
||||
- **No orphan scorecard row.** A GUI-product grade needs criteria and
|
||||
ground truth under `COHERENCE.md` §16 — a distinct **product**
|
||||
subgrade beside the architectural one — before the scorecard can
|
||||
point at it.
|
||||
- **§2.5 stops overclaiming.** It said the arc sequences the whole GPU
|
||||
backlog while items had neither stage nor deferral. Every item is now
|
||||
mapped or explicitly left standing, in a table.
|
||||
- **Q#GA8's temporary island is withdrawn** — ruled against, so this
|
||||
arc adds **no** off-path hardcode at all (§7).
|
||||
- **Reconnect attribution covers the silent cases** (§7). EOF and crash
|
||||
may deliver no `Goodbye` at all; "the daemon's stated reason" alone
|
||||
would have been unsatisfiable exactly when it matters.
|
||||
|
||||
**One correction this round found on its own**, not raised in review:
|
||||
§3's condition 1 listed the Q#GA3 goals as "Stages 5, 6 and 10" while
|
||||
§5 marked **four** stages as Q#GA3 goals — the sidebar was missing from
|
||||
the closure sentence. Fixed, and re-checked against the renumbering.
|
||||
|
||||
**Origin.** A daily-driver report, 2026-08-09: *the TUI is a suitable
|
||||
daily driver; the GUI feels behind similar editors, enough that the
|
||||
reporter would default to a different editor before using pmacs' GUI.*
|
||||
This is the same shape that opened the QoL arc (one daily-driver
|
||||
report → an arc that closed when the report's complaints were answered
|
||||
on both frontends), at a larger scale — so it gets a standard and an
|
||||
arc-level frame before any lane, not a framing per gap.
|
||||
|
||||
**Ground truth below was established 2026-08-09** by a three-lane audit
|
||||
(GPU frontend inventory, TUI/grid inventory, documentation sweep) at
|
||||
`main` @ `4bc55e8`, with the §2.2 producer matrix re-verified against
|
||||
`src/semantic_render.rs` at the same anchor. Per `COHERENCE.md`'s
|
||||
citation convention: symbols first, `file:line` second; line numbers
|
||||
drift, symbols are authoritative.
|
||||
|
||||
Three arc-shaping questions were put to the user and ruled on
|
||||
2026-08-09; they are recorded as resolved, not open:
|
||||
|
||||
- **Q#GA1 — RESOLVED: one arc, two halves.** Half A is maturity
|
||||
(input, parity, chrome, robustness, hover); Half B is structure
|
||||
(viewport facts, splits/multi-window, side surfaces, and the
|
||||
presentation stages that depend on them). The arc closes only when
|
||||
**both** halves land (§3).
|
||||
- **Q#GA2 — RESOLVED: the closure bar is journey parity plus an empty
|
||||
blocker list**, now stated as a conjunction over the whole stage map
|
||||
(§3).
|
||||
- **Q#GA3 — RESOLVED: all four GUI-native affordances are goals, none
|
||||
is a non-goal.** Hover/signature popups, smooth scroll + scrollbar,
|
||||
and a project/files sidebar are in-scope goals. **Tabs/tabline is a
|
||||
declared goal at deliberately low priority, sequenced last** — the
|
||||
user's ruling verbatim: it is not a non-goal and "would be nice down
|
||||
the line," with care required. What the care means is Q#GA12 plus
|
||||
the anti-patterns pinned at Stage 10.
|
||||
|
||||
Coherence sections this framing serves: §2 (the journey, which Stage 0
|
||||
makes frontend-graded), §3 (the recommended default surface for the
|
||||
graphical frontend), §6 (interaction islands — see §7's accounting),
|
||||
**§7 (first-class workspaces — two stages are gated on it, §5.1)**,
|
||||
§14 (workbench primitives — the sidebar is tree-primitive adoption),
|
||||
§16 (the semantic-frontend grade this arc completes the product half
|
||||
of), §20 (priority order — placement is Q#GA5).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why an arc: the diagnosis
|
||||
|
||||
**The GPU frontend is a rendering showcase over a single buffer, not a
|
||||
workbench.** It is genuinely ahead of the TUI on rendering richness —
|
||||
a real minibuffer candidate dropdown where the grid has only an inline
|
||||
`[candidate]` suffix, a minimap, inline math, pixel-precise squiggles,
|
||||
correct grapheme shaping where the grid drops combining marks in body
|
||||
text — and behind on the three things that make an editor a daily
|
||||
driver. Those three are the arc's spine:
|
||||
|
||||
1. **The one-window ceiling.** `State` holds exactly one `buffer` and
|
||||
one `current_buffer_id`; the daemon's entire per-frontend split
|
||||
layout (`Layout::compute`, `core.views`) is invisible to a semantic
|
||||
session. The bottom panel band (`PanelBand`) is a hand-built special
|
||||
case of "a second region." Everything spatial queues behind the
|
||||
general version: splits, side windows beyond `Side::Bottom`, the
|
||||
project/files surface `COHERENCE.md` §3 names, per-window status
|
||||
bands. The July roadmap called this "the largest unscoped design
|
||||
problem" and it still is.
|
||||
2. **The GUI cannot be driven like a GUI.** `keymap_stack::Scope` has
|
||||
no frontend identity and `FrontendEvent` has no command-invocation
|
||||
variant, so a GPU-only binding cannot exist (the reason #220 shipped
|
||||
zoom as unbound commands — handoff §6's capability-aware keymap
|
||||
item). Beneath that, `translate_key` and the winit handler consume
|
||||
a narrow slice of desktop input; the rest lands in `_ => {}` (§2.3).
|
||||
3. **The monolith has no test seam for input.** `pmacs-gpu/src/main.rs`
|
||||
is ~11.7k production lines in one file; the
|
||||
`gpu-terminal-input` framing already recorded that GPU key routing
|
||||
is untestable because `App::window_event`'s logic is inline "with no
|
||||
extractable seam," and called the refactor "a real refactor
|
||||
[belonging] to its own lane." An input arc that skips the seam ships
|
||||
blind.
|
||||
|
||||
**Why the standard never caught this drifting.** `COHERENCE.md` §16
|
||||
grades the *semantic protocol* — degradation practiced, capability
|
||||
negotiation, versioning — and that grade (Strong) is earned. But **no
|
||||
scorecard row measures the GUI as a product**, and **the golden journey
|
||||
has only ever been graded on the TUI**. "Semantic frontend: Strong" and
|
||||
"I'd use a different editor before the GUI" stayed simultaneously true
|
||||
because the standard only measured the first. This is §1.1's
|
||||
substrate-without-surface at frontend scale: the substrate is the
|
||||
protocol and the daemon's facts; the missing surface is the GPU
|
||||
consumers of them (§2.2 below shows which halves exist). Stage 0
|
||||
closes the measurement gap so it cannot reopen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ground truth (audited 2026-08-09)
|
||||
|
||||
### 2.1 What the GPU has
|
||||
|
||||
Code area with syntax/LSP styling, gutter (Off/Absolute/Relative/
|
||||
Hybrid) with diagnostic signs, minimap with click/scrub, one bottom
|
||||
status band (statusline segments validated and themed), bottom panel
|
||||
band with divider drag, minibuffer with a 10-row candidate dropdown,
|
||||
in-buffer completion popup with kind glyphs, right-click context menu,
|
||||
isearch band UI, diagnostic squiggles (dedicated pipeline), selection
|
||||
and search washes, peer presence (cursors + selections), inline math,
|
||||
terminal mode, optimistic CRDT editing with unconfirmed-edit
|
||||
journaling. Mouse: click, drag, double/triple-click, wheel (line-
|
||||
quantized), edge auto-scroll, panel and divider gestures, minimap
|
||||
scrub. Clipboard both directions via `arboard`.
|
||||
|
||||
### 2.2 Wire-capability matrix: produced vs consumed
|
||||
|
||||
The GPU's live-loop catch-all is one `_ => None` (`main.rs:5211`);
|
||||
`FoldState` and `BlockAdornments` appear in `pmacs-gpu` **only** in a
|
||||
debug-name helper. But "the GPU ignores it" means different work
|
||||
depending on whether a producer exists — revision 1 conflated these,
|
||||
and three items hid producer scope. Producer column verified against
|
||||
`src/semantic_render.rs` at `4bc55e8`:
|
||||
|
||||
| Capability | Produced? | GPU consumes? | Work required |
|
||||
|---|---|---|---|
|
||||
| `FoldState` | **Yes** (`semantic_render.rs:1881`) | No | **Consumer-only** — Stage 3a |
|
||||
| `InlineAdornments` | Only `(AtOffset, Text)`, from the inlay-hint store (`semantic_render.rs:1849`) | Exactly that same subset | **No live gap today.** Other placements/content are producer *and* consumer work; not claimed by this arc |
|
||||
| `BlockAdornments` | **No** — a producer test asserts none is emitted (`semantic_render.rs:4543`) | No | Producer + consumer; Stage 3a's framing decides whether GPU folding renders from `FoldState` alone or needs placeholders |
|
||||
| `ResourceOffer` / `AdornmentContent::Resource` | **No** | No | Producer + consumer; stays deferred (§6) |
|
||||
| `InstanceSignal::Title` | **No producer found** in `src/` | No | **Not needed for a dynamic window title** — `StatusFacts` already carries the buffer name, so Stage 1c titles the window frontend-locally; a `Title` producer (e.g. terminal-set titles) is separate, unclaimed work |
|
||||
| `InstanceSignal::Bell` | **Yes** (`daemon.rs:1373`, present at `4bc55e8`) | No — `frontend.rs:349` is the grid consumer; `pmacs-gpu` has no arm | **Consumer-only** — Stage 1c. *Revision 2 recorded "no producer found" and gave Stage 1c a producer question to answer; the producer was there the whole time, and the audit had searched the semantic-render path rather than the daemon's signal path.* |
|
||||
| `Goodbye(reason)` post-handshake | Yes | Bootstrap only; live-loop reason discarded | **Consumer-only** — Stage 1c |
|
||||
| `CompletionPopup.prefix_len`/`total` | Yes (on the wire) | Stored under `#[allow(dead_code)]`, unrendered | **Consumer-only** — minibuffer/completion refinement |
|
||||
|
||||
### 2.3 Input gaps (verified in-session, not carried from docs)
|
||||
|
||||
- **Escape quits the entire application** when no intercept/popup is
|
||||
active (`main.rs:2769`; the comment says "otherwise it stays the
|
||||
local quit"). No data is lost — the daemon holds state — but it
|
||||
reads as a crash to anyone with Escape reflexes.
|
||||
- `translate_key` produces **no `ProtocolKey::F(u8)`** — F1–F12 are
|
||||
unbindable in the GUI though the protocol carries them. `BackTab`,
|
||||
`Menu` also unmapped; `Key::Dead(_) → None` (dead keys silently
|
||||
dropped); multi-codepoint `Key::Character` truncated to its first
|
||||
char.
|
||||
- **No `WindowEvent::Ime`, no `set_ime_allowed`** — CJK/compose input
|
||||
is unusable. Undocumented anywhere before this audit.
|
||||
- **No `ScaleFactorChanged` arm; `scale: 1.0` hardcoded** — HiDPI is
|
||||
wrong (also recorded as a pre-existing gap in
|
||||
`gpu-set-font-framing.md`).
|
||||
- Sub-line wheel deltas are rounded then discarded with **no residual
|
||||
accumulator** — precise-pixel trackpad scroll under ~½ line height
|
||||
does nothing. Horizontal wheel x is discarded although
|
||||
`code_scroll_left` exists; `MouseKind::ScrollLeft/Right` are never
|
||||
emitted. Ctrl+wheel is ignored.
|
||||
- No middle-click paste, no `DroppedFile`, no I-beam cursor over text,
|
||||
minibuffer dropdown not clickable (audit F-007).
|
||||
- **`FrontendEvent::FocusGained`/`FocusLost`/`Detach` are never
|
||||
sent** — no `Focused` arm; `CloseRequested` exits without `Detach`,
|
||||
so the daemon learns of departure by socket EOF.
|
||||
|
||||
### 2.4 Structure, robustness, chrome
|
||||
|
||||
- One document window forever (§1 cause 1). Daemon splits invisible.
|
||||
- No auto-reconnect; the reconnect banner is an attach-TUI-only seam
|
||||
(`Frontend::draw_status_overlay`). F-008 in
|
||||
`gpu-attach-robustness-framing.md`.
|
||||
- **Session restore is structurally never**: desktop save/restore
|
||||
early-returns in Rust under a daemon (Q#DS9), and the GPU is always
|
||||
semantic — so a GPU session can never restore. Journey step 12's
|
||||
thin end, at its thinnest on this frontend.
|
||||
- Chrome theming is half-applied: `MENU_BG`, completion popup
|
||||
background, `MINIMAP_BG`, `CARET_COLOR`, `WINDOW_BG_RGBA`, and the
|
||||
peer-presence palette are hardcoded constants; custom themes
|
||||
fracture in the GUI. (The TUI's completion popup and menu are also
|
||||
unthemed `Indexed` constants — the pair should be fixed together,
|
||||
per no-privileged-frontend.)
|
||||
- Cursor: fixed 2px bar, fixed color, no blink, no styles.
|
||||
- Word wrap regressed at #221: the GPU had cosmic-text
|
||||
`WordOrGlyph` since it existed and now gets `Wrap::Glyph`; the
|
||||
long-lines framing already names `ui.line-wrap = "word"` as the
|
||||
clean additive third value.
|
||||
- LSP styling diverges by model: the grid **merges** LSP tokens over
|
||||
tree-sitter (`LspStyleView`/`merge_styles`); the semantic wire is
|
||||
single-authority — GUI highlighting is strictly poorer in
|
||||
mixed-authority languages.
|
||||
- `HoverView` and `SignatureView` exist in the core, **built and never
|
||||
attached anywhere** (§1.1 dark matter) — relevant to Stage 5.
|
||||
|
||||
### 2.5 Already-recorded backlog: mapped or explicitly left standing
|
||||
|
||||
Revision 2 said this arc "sequences" `docs/side-quest-backlog.md`
|
||||
§"GPU frontend mechanics (non-theme)" without restating it, which
|
||||
claimed coverage it did not have — several items had no stage *and* no
|
||||
deferral, and a reader checking whether the arc covered their complaint
|
||||
had nothing to check. **Every item in that section is below. An item is
|
||||
either mapped to a stage or explicitly left in the standing backlog;
|
||||
there is no third state.** Handoff §6's capability-aware keymap item
|
||||
and the folding framings' Stage 3 obligations are absorbed **by
|
||||
reference** — each keeps its own framing.
|
||||
|
||||
| Backlog item | Disposition |
|
||||
|---|---|
|
||||
| Command/minibuffer chord forwarding | **SHIPPED** (`bc32332`) — `Char`/`Enter`/`Tab` with Ctrl or Alt reach the daemon keymap, subsuming the old per-feature allowlists |
|
||||
| **Meta/Super chords** | **Stage 2 (Q#S1-7 ruling, 2026-08-11)** — *moved from Stage 1a.* Blind forwarding would turn platform Command/Super shortcuts into daemon Meta chords **before** capability-aware and local-binding policy exists. Stage 1 keeps the deliberate, tested OS reservation (`pmacs-gpu/src/main.rs:11050`) and adds no island |
|
||||
| Rebindable local `Ctrl-V`/`Escape` | Escape half → Stage 1a; `Ctrl-V` half → Stage 2 (it is a keymap-vocabulary question, not an input-plumbing one) |
|
||||
| Middle-click paste | Stage 1b |
|
||||
| Right-click context menu | **Already shipped** (§2.1) — the backlog item is stale and Stage 0 retires the line |
|
||||
| Frontend-local provisional selection | **Standing backlog** — a selection-ownership question, not a GUI-maturity gap |
|
||||
| Minibuffer `i/total` hint (= `CompletionPopup.prefix_len`/`total`) | Stage 3d |
|
||||
| Clickable minibuffer dropdown rows (audit F-007) | Stage 3d |
|
||||
| Multibyte-exact band caret; nav highlight-wrap bug | Stage 3d |
|
||||
| Telescope-style preview pane; candidate kind/doc annotations; unify TUI inline vs GPU dropdown | **Standing backlog** — the unification is a cross-frontend convergence design, and the other two ride it |
|
||||
| Scrollbar scroll; pixel-smooth sub-line scroll | Stage 7 (the *discard* bug is Stage 1b; the smooth **model** needs Stage 7's facts) |
|
||||
| Horizontal scroll / soft-wrap | wheel → Stage 1b; wrap → Stage 3b |
|
||||
| Auto-reconnect + "reconnecting…" banner | Stage 4a |
|
||||
| `AttachRequest.initial_size` cell-grid assumption | Stage 1c, with DPI — the assumption is only visible once scale is real |
|
||||
| Capability renegotiation (relaunch daemon `--features crdt`) | **Standing backlog** — daemon lifecycle, not frontend maturity |
|
||||
| Peer caret glyph + name label; own-vs-peer cursor merge; `SelectionSnapshot` vs `Decorations::Selection`; background-kind decorations painted | **Standing backlog** — collaboration/decoration rendering; no journey step and no §3.1 blocker depends on them |
|
||||
| Inline adornment placements beyond `AtOffset` | **Standing backlog** — producer *and* consumer scope (§2.2) |
|
||||
| Glyphon full-buffer `prepare` ceiling; `Renderer` sub-struct extraction | **Standing backlog** — perf and refactor; the `main.rs` split's first slice is Stage 1-pre and claims no more |
|
||||
| Golden-PNG comparison harness | **Deferred by §8**, with its condition stated there |
|
||||
|
||||
Three gaps from §2.3/§2.4 are not in that backlog section and are
|
||||
mapped here so they cannot fall through: **`DroppedFile`** → Stage 1b;
|
||||
**cursor blink and styles** → Stage 3c (§7 registers the knob, and
|
||||
Stage 3c is what ships it — revision 2 named the configuration with no
|
||||
stage behind it); **the LSP merge-vs-single-authority divergence**
|
||||
(§2.4) → **standing backlog**, explicitly, because it is a semantic-wire
|
||||
authority question whose fix belongs to whoever owns multi-server token
|
||||
policy, not to a GUI maturity stage.
|
||||
|
||||
---
|
||||
|
||||
## 3. Closure criterion (Q#GA2)
|
||||
|
||||
**The arc closes when all three of the following hold; none alone is
|
||||
sufficient:**
|
||||
|
||||
1. **Every stage in §5 has landed** — or has been explicitly re-ruled
|
||||
by the user at the time, with the ruling and its reason recorded in
|
||||
§3.2. There is no stage outside the closure contract: revision 1's
|
||||
"Tail" is dissolved, and the Q#GA3 goals are Stages **5, 7, 9 and
|
||||
10** (hover/signature; smooth scroll + scrollbar; the project/files
|
||||
sidebar; tabs). *Revision 2's sentence said "Stages 5, 6 and 10"
|
||||
while §5 marked four stages as Q#GA3 goals — the sidebar was absent
|
||||
from the closure sentence that is supposed to enumerate them.*
|
||||
2. **The per-frontend journey table shows GPU ≥ local TUI at every
|
||||
step**, under the grading rules in §3.3.
|
||||
3. **The daily-driver blocker list (§3.1) is empty.**
|
||||
|
||||
Divergences that survive must be declared in §3.2, not accidental.
|
||||
|
||||
### 3.1 Blocker list (seed — membership is Q#GA11)
|
||||
|
||||
1. Escape quits the application (§2.3).
|
||||
2. IME absent — CJK/compose input unusable.
|
||||
3. `translate_key` holes: F-keys, BackTab, dead keys, multi-codepoint
|
||||
text.
|
||||
4. Sub-line scroll discard (trackpad feels broken); no horizontal
|
||||
wheel.
|
||||
5. No DPI/scale handling.
|
||||
6. Folding silently dead on the GPU.
|
||||
7. No session restore on the GPU, ever (Q#DS9).
|
||||
8. No reconnect after daemon restart.
|
||||
9. One-window ceiling (graded via the journey table's affected steps
|
||||
rather than as a single line — listed here so the list cannot be
|
||||
emptied while the ceiling stands).
|
||||
|
||||
### 3.2 Declared-divergence and re-ruling register
|
||||
|
||||
Divergences that survive the arc, and any stage the user re-rules out
|
||||
of the closure contract, are recorded here with a reason (the model is
|
||||
#221's honest-divergence ruling on word wrap). Seed: none — entries
|
||||
are added by stage framings or user rulings as they happen.
|
||||
|
||||
### 3.3 How a (step, frontend) cell is graded
|
||||
|
||||
`COHERENCE.md` §2's existing verdicts are compound strings ("Works but
|
||||
undiscoverable", "Partial (good once reached)") and do not order.
|
||||
Revision 2 replaced them with an ordered set but never said how a cell
|
||||
*acquires* a grade, and its normalization rule — take the head grade,
|
||||
demote the rest to annotation — is unsound in the exact case it was
|
||||
written for: **"Works but undiscoverable" would grade `Works`**, and the
|
||||
undiscoverability would become prose that cannot block closure. That
|
||||
inverts the standard, because discoverability is not a qualifier on
|
||||
journey steps 4, 7 and 11 — it *is* their substance.
|
||||
|
||||
**Three columns, not two.** Stage 0 grades each step for **local TUI**
|
||||
(`pmacs .`), **attached TUI** (`pmacs --attach`) and **GPU**
|
||||
separately.
|
||||
|
||||
**The comparison is GPU against the local TUI.** That is the canonical
|
||||
`pmacs .` journey and the frontend the daily-driver report calls
|
||||
suitable, so it is the bar the GUI must meet.
|
||||
|
||||
**The attached TUI column is retained as evidence, not as the bar — and
|
||||
what it is evidence *of* is narrower than revision 3 first claimed.**
|
||||
The attached TUI is a **grid** frontend: it handshakes
|
||||
`semantic_render: false`, and the field's own comment says it "never
|
||||
consumes the SemanticFrame family" (`src/attach.rs`, the
|
||||
`FrontendCapabilities` constructor). So a shared GPU/attached-TUI gap
|
||||
cannot mean "the semantic wire is at fault" — the attached TUI is not
|
||||
on that wire. What the three columns actually separate is:
|
||||
|
||||
- **local vs daemon-attached** behaviour (local TUI against attached
|
||||
TUI), which isolates everything the daemon boundary introduces; and
|
||||
- **attached-grid vs semantic/GPU** behaviour (attached TUI against
|
||||
GPU), which isolates what is specific to semantic rendering.
|
||||
|
||||
**Neither comparison alone establishes producer-versus-consumer
|
||||
ownership.** Reading the columns narrows where to look; **source
|
||||
tracing is what assigns the gap**, exactly as §2.2's matrix had to be
|
||||
verified against `src/semantic_render.rs` rather than inferred from
|
||||
behaviour. A single TUI column would still have merged two distinct
|
||||
diagnoses — that argument survives — but it was never going to hand out
|
||||
owners for free.
|
||||
|
||||
**The grades, by observable criteria.** Each is a test someone else can
|
||||
run and get the same answer:
|
||||
|
||||
> **Broken < Missing < Partial < Works**
|
||||
|
||||
- **Works** — every required subclaim holds with no qualifier, by a
|
||||
route the step's own discoverability subclaim admits.
|
||||
- **Partial** — every required subclaim is *satisfiable*, but at least
|
||||
one is degraded: reachable only by a route the step does not admit
|
||||
(e.g. only by typing an unlisted command), or holding only under a
|
||||
stated precondition.
|
||||
- **Missing** — a required subclaim has **no surface at all**: the
|
||||
action is unavailable and attempting it produces neither effect nor
|
||||
error.
|
||||
- **Broken** — a surface exists and using it produces a **wrong
|
||||
result**, data loss, or an application-level failure. Ranked *below*
|
||||
`Missing` deliberately: an absent feature is honest, while a present
|
||||
one that misleads costs the user work and trust.
|
||||
|
||||
**A compound step is graded by its weakest required subclaim.** Each
|
||||
step in the table declares its required subclaims explicitly; the cell's
|
||||
grade is the **minimum** over them, never the first word of a prose
|
||||
verdict.
|
||||
|
||||
**The worked examples below are HISTORICAL, and `COHERENCE.md` §2b is
|
||||
authoritative for every current grade.** They are kept because they are
|
||||
what motivated this rule, and they are marked because a framing and the
|
||||
standard it serves must not hand a reader two different grades for one
|
||||
step. They quote the **pre-Stage-0** verdict strings, not today's table:
|
||||
|
||||
- **Step 7** — the row then read "Symbol: **works but
|
||||
undiscoverable**". Subclaims *reachable* / *discoverable*: `Works` and
|
||||
`Missing` ⇒ **`Missing`**. **Stage 0 graded it `Works`** on evidence
|
||||
this example did not have: advertisement is transitive through the
|
||||
help graph, and the advertised `M-x help` route reaches
|
||||
`help.list-keybindings`, which names every registered binding.
|
||||
- **Step 11** — the row then read "**Works but undiscoverable**". Same
|
||||
shape, and here the outcome **stands**: Stage 0 also graded it
|
||||
`Missing`, because **no binding opens `editor.list-workers`** for a
|
||||
listing to name. The view does carry a buffer-local `C-c C-k`
|
||||
(`workers.cancel-at-point`), which is reachable only once you are
|
||||
already inside it.
|
||||
|
||||
The rule these examples exist to establish is untouched by either
|
||||
outcome: **the head-grade rule would have graded both `Works`**, and
|
||||
that is what makes it unsound. Step 7 moving on better evidence is the
|
||||
system working; step 11 not moving is the defect surviving contact with
|
||||
it.
|
||||
|
||||
Under revision 2's head-grade rule **both would have graded `Works`**,
|
||||
and the undiscoverability that is the entire finding would have become
|
||||
annotation text with no effect on closure. Two of the journey's twelve
|
||||
steps is not an edge case.
|
||||
|
||||
**Annotations carry only what is not a required subclaim.** They cannot
|
||||
absorb a failing subclaim; if something is load-bearing enough to
|
||||
mention as a defect, it is load-bearing enough to be a subclaim and be
|
||||
graded. Where the two frontends differ only in an annotation, the
|
||||
difference is recorded and does not block closure — that remains true,
|
||||
and is now narrow rather than a loophole.
|
||||
|
||||
**Stage 0 must publish the subclaim list per step**, not just the
|
||||
grades. A grade whose subclaims are unstated is not falsifiable, which
|
||||
is the whole objection this section answers.
|
||||
|
||||
---
|
||||
|
||||
## 4. Arc structure (Q#GA1)
|
||||
|
||||
**One arc, two halves; the name is "the GUI arc," deliberately a name
|
||||
and not a number.** The roadmap's "Arc 8 — GPU structural parity"
|
||||
label already collides (the Lean 4 framing also claims Arc 8; the
|
||||
collision is recorded in `docs/dired-framing.md`). This arc subsumes
|
||||
roadmap-Arc-8's scope as its Half B; the numeric label retires.
|
||||
|
||||
- **Half A — maturity** (Stages 0–5): the GPU behaves like a competent
|
||||
desktop application over its existing one-window model. No
|
||||
structural redesign; heavy protocol work only where §2.2 shows a
|
||||
producer already exists, or the stage's framing names the producer
|
||||
scope it adds.
|
||||
- **Half B — structure** (Stages 6–10): the multi-window model, then
|
||||
the viewport/window-identity substrate it defines, then splits and
|
||||
side geometry, then the presentation stages that depend on
|
||||
multi-window state (sidebar, tabs). The order is load-bearing — see
|
||||
the note opening Half B in §5.
|
||||
|
||||
Half A ships visible value while Half B's model framing matures; the
|
||||
arc does not close at the end of Half A (§3's condition 1 spans both
|
||||
halves), so the early wins cannot quietly become the whole arc.
|
||||
|
||||
---
|
||||
|
||||
## 5. Stage map
|
||||
|
||||
**Stages 1–10 each get their own framing before their branch; Stage 0
|
||||
is framed by this document** (see the status block). This document owns
|
||||
the ordering rationale and the arc-level bar, never stage-level
|
||||
acceptance criteria (the contract-ownership rule). **Every PR in this
|
||||
arc opens with its `docs/active-work.md` lane written at the branch's
|
||||
first commit** — the standing correction from #171/#215, missed again
|
||||
at #224 and #225, and adopted here as an arc rule rather than re-hoped.
|
||||
|
||||
### 5.1 The P2 gate (blocking №2's resolution)
|
||||
|
||||
Two stages are **workspace-owned** and carry a hard gate: they may not
|
||||
start before the P2 workspace arc has landed at least the workspace
|
||||
object they consume.
|
||||
|
||||
- **Stage 4b (session save *and* restore)**: "what a session *is*" is
|
||||
the workspace question — Q#DS9 failed precisely because a daemon
|
||||
layout had "nothing principled to attach to" (`COHERENCE.md` §7). A
|
||||
frontend-keyed convention invented here would be a new ownership
|
||||
story P2 then has to unwind; revision 1 called that v1 "plausible",
|
||||
revision 2 withdrew the recommendation, and Q#GA10 is now **ruled**
|
||||
(both surfaces preserved, the save path owned here too). The gate is
|
||||
what makes the ruling implementable: snapshot ownership and
|
||||
multi-frontend arbitration have no answer without P2's object.
|
||||
- **Stage 9 (project/files sidebar)**: a sidebar must show *something
|
||||
rooted*, and §7 warns P2 must start "before a fifth subsystem grows
|
||||
its own root convention — four have already diverged." The sidebar
|
||||
is the fifth if it picks its own root.
|
||||
|
||||
**Reaching Stage 4b is a P2 START GATE, not merely a pause** (Q#GA5
|
||||
ruling, revision 3). Revision 2 let the gated stage stall while
|
||||
everything else proceeded. **It could not have let the arc formally
|
||||
close around P2** — the gated stages are inside the closure contract
|
||||
(§3, condition 1), so closure still blocked on them. What it *would*
|
||||
have allowed is every **non-gated** stage finishing before P2 began,
|
||||
leaving P2 as a **terminal closure blocker**: an arc sitting at 100%
|
||||
of the work it could do, waiting on an arc nobody had started. The rule
|
||||
is stronger:
|
||||
|
||||
1. When the arc reaches Stage 4b, **P2 starts**. That is the trigger.
|
||||
2. **No later GUI stage starts** — gated or not — until P2 has **an
|
||||
approved framing and an opened lane**. Those two are the observable
|
||||
condition; P2 need not have *landed* anything.
|
||||
3. Once P2 has both, **non-gated GUI work may interleave** freely while
|
||||
the workspace object lands. Only the two gated stages (4b, 9) wait
|
||||
on the object itself.
|
||||
|
||||
The gate is on *starting P2*, not on P2's completion, so the arc is
|
||||
never blocked on work nobody has begun — and it cannot outrun the
|
||||
model it depends on. A gated stage never proceeds on a local
|
||||
convention; that was already true and stays true. The arc's
|
||||
`docs/active-work.md` lane records the gate state whenever it is in
|
||||
force.
|
||||
|
||||
### Half A — maturity
|
||||
|
||||
**Stage 0 — the standard sees the GUI (docs only).**
|
||||
|
||||
*The framing goes first, and that reverses revision 2's ordering.*
|
||||
Revision 2 put the absorption PR ahead of committing this document,
|
||||
which contradicts the portability rule it cites elsewhere: an approved
|
||||
framing that lives only in one worktree is one `git clean` from gone
|
||||
and does not travel to another machine. **The approved framing and this
|
||||
arc's `docs/active-work.md` lane are the Stage 0 branch's first
|
||||
commit.** If synchronization stays a separate PR, **that PR carries the
|
||||
framing first** — absorption may precede the rest of Stage 0, never the
|
||||
framing.
|
||||
|
||||
*The absorption pass*, whose scope is now enumerated rather than
|
||||
described (it grew on 2026-08-10 when six lanes merged in one session):
|
||||
|
||||
- **Five stale lanes in `docs/active-work.md`** — #224 and #225 carried
|
||||
as OPEN, #228 as OPEN and MERGE-BLOCKED, LSP LaTeX as "no PR yet"
|
||||
(merged as #230), destination capture as "PR #231 OPEN" (merged as
|
||||
`0e4c58d`). Durable facts into `docs/agent-handoff.md` first, then
|
||||
remove the **PR-specific** block.
|
||||
|
||||
**#228 is the exception, and it must not be retired "per Rule 4" as
|
||||
though Discovery were finished.** Rule 4 removes a lane when its
|
||||
**arc** is done; Discovery's is not. Two entries exist — the
|
||||
PR-specific block and the standing **Discovery lane (P4)**, which
|
||||
already says "Rewritten, not removed" for exactly this reason. Stage
|
||||
0 removes the first after re-homing its facts and **rewrites and
|
||||
coalesces the second** to *"Stage 2 merged; later discovery work
|
||||
remains"*. Still open there: **predicate evaluation**, **command
|
||||
metadata** (title/category/aliases/flags), **help unification**, and
|
||||
**the prefix decision**. Deleting that lane would drop four named
|
||||
pieces of open work on the strength of one merged stage.
|
||||
- **The authority/recovery anchor**, which points at `9a26ac8` while
|
||||
`main` has moved well past the audit anchor `4bc55e8`.
|
||||
- **`COHERENCE.md` §0 row 16 / §16's `v6..=v21` → `v6..=v23`.** The
|
||||
ceiling moved **twice**: #221 took it to v22 for `LineWrapFacts`, and
|
||||
**#228 took it to v23** for `MinibufferPromptRows`
|
||||
(`PROTOCOL_VERSION = 23`, `SUPPORTED_PROTOCOL_VERSIONS = 6..=23`,
|
||||
`pmacs-protocol/src/message.rs:1843`). *Revisions 2 and 3 both said
|
||||
v22, having read the range at the audit anchor and not re-read it
|
||||
after Discovery landed.* The same row's "production attach remains
|
||||
v20" is **still correct** — `ADVERTISED_PROTOCOL_VERSION` is 20 — and
|
||||
must not be swept along with the range.
|
||||
- **The U4 correction and the U9 residue** in
|
||||
`docs/ci-red-signatures.md`. U4's flavour field is not a matching key
|
||||
(the same selector and fragments red on both macOS flavours) and one
|
||||
of its four "occurrences" was a deliberate bite. **U9's text must be
|
||||
fixed, not merely carried**: it says a same-tree green shows the
|
||||
failure "is not the tree," which contradicts this file's own rerun
|
||||
rule — a tree can raise an intermittent failure *rate* without making
|
||||
it deterministic. The replacement claim is **"not deterministic on
|
||||
this tree; causation and rate effect unresolved."**
|
||||
- **The stale backlog line** for the right-click context menu, which
|
||||
ships (§2.5).
|
||||
- **Journey step 11's verdict**, which #232 falsified on 2026-08-10.
|
||||
`COHERENCE.md` §2 still reads "**Works but undiscoverable** … no
|
||||
keybinding, no statusline spinner/progress indicator anywhere (§9)".
|
||||
#232 shipped exactly that indicator — a statusline provider showing
|
||||
an in-flight count and the oldest job's purpose, absent when idle.
|
||||
The row needs regrading under §3.3, and §9's own grade needs
|
||||
re-reading: the mechanism-without-identity finding is partly
|
||||
answered. **Found while grading the journey for this revision, not
|
||||
in review** — which is the argument for §3.3's three columns, since
|
||||
a stale row survives precisely as long as nobody has to assign it a
|
||||
falsifiable grade.
|
||||
|
||||
*Then Stage 0 proper:* add the per-frontend journey verdicts to
|
||||
`COHERENCE.md` §2 under §3.3's grading rules, **including the subclaim
|
||||
list per step**; place the arc in §20 (Q#GA5); cross-reference from
|
||||
`docs/agent-handoff.md` §6; retire the "Arc 8" numbering (Q#GA4). No
|
||||
runtime code.
|
||||
|
||||
**No orphan scorecard row.** Revision 2 proposed adding a GUI-product
|
||||
row to the scorecard with a grade attached ("Weak — renderer ahead,
|
||||
workbench and input behind"). A scorecard row is a pointer to a graded
|
||||
concern, and there is no graded concern for the GUI *as a product*:
|
||||
`COHERENCE.md` §16's grade is architectural. So Stage 0 first
|
||||
establishes **a distinct product subgrade beside the architectural one
|
||||
in §16**, with its own criteria and audited ground truth, and *then*
|
||||
the scorecard points at it. A row whose grade rests on nothing is the
|
||||
thing §16 exists to prevent.
|
||||
|
||||
**Stage 1 — input foundation.**
|
||||
|
||||
- **1-pre: the input seam.** Extract `App::window_event`'s routing
|
||||
into testable functions — the refactor `gpu-terminal-input` already
|
||||
named as its own lane, plus the first slice of the recorded
|
||||
`main.rs` split. This is the stage's first PR because everything
|
||||
after it needs witnesses.
|
||||
- **1a keyboard correctness**: Escape stops being the local quit
|
||||
(round-trips like any key; quitting becomes a command/window
|
||||
affordance — subsumes the backlog's "rebindable local Ctrl-V/Escape"
|
||||
item on the Escape half); `translate_key` completion (`F(u8)`,
|
||||
`BackTab`, `Menu`; dead keys held for 1d rather than dropped).
|
||||
- **1b pointer/scroll correctness**: sub-line residual accumulator;
|
||||
horizontal wheel → `code_scroll_left`; middle-click paste; I-beam
|
||||
cursor over text; `DroppedFile`.
|
||||
|
||||
**Q#GA6 — RULED: land the TUI answer in this same stage; no declared
|
||||
divergence.** The TUI half is smaller than revision 2 implied, and
|
||||
the corrected trace is this: crossterm already delivers
|
||||
`MouseEventKind::ScrollLeft`/`ScrollRight`; `src/protocol.rs` and
|
||||
`pmacs-protocol` already carry them as `MouseKind::ScrollLeft`/
|
||||
`ScrollRight`; and **attached** mouse events already round-trip
|
||||
through `mouse_from_crossterm` / `mouse_to_crossterm`
|
||||
(`src/protocol.rs:712`, `:777`). Local and attached document events
|
||||
**converge on the one document handler, whose only wheel arms are
|
||||
`ScrollUp`/`ScrollDown` at `src/editor.rs:3189`** — that single site
|
||||
is where horizontal wheel is dropped.
|
||||
|
||||
*Revision 3 cited `src/editor.rs:5863-5864` and `:3407` here. Both
|
||||
are **terminal-content** paths — `:3407` matches `TerminalMouseKind`
|
||||
and drives `terminal_manager.scroll_view` — not the document window,
|
||||
so they were the wrong sites for this ruling.*
|
||||
|
||||
So the TUI answer is one handler arm on an event that already
|
||||
arrives, not new plumbing, and QoL Stage 5's reason for excluding
|
||||
explicit-scroll surfaces — keeping the frontends agreeing — is
|
||||
*served* by doing both here rather than traded against.
|
||||
- **1c session/window signals**: `Focused` → `FocusGained`/`FocusLost`;
|
||||
`CloseRequested` sends `Detach` before exit; post-handshake
|
||||
`Goodbye` reason surfaced (consumer-only, §2.2); **window title
|
||||
composed frontend-locally from `StatusFacts`** — no `Title` producer
|
||||
required (§2.2); **`Bell` as a plain consumer** — the producer exists
|
||||
and always did (§2.2), so there is no producer question and no option
|
||||
to drop it; `ScaleFactorChanged`/DPI, and with it the
|
||||
`AttachRequest.initial_size` cell-grid assumption, which is only
|
||||
observable once scale is real.
|
||||
- **1d IME — Q#GA7 RULED: the full preedit overlay, not a commit-string
|
||||
minimum.** The scope is explicitly: commit string; **caret and
|
||||
selection range within the preedit**; **cancellation**; and
|
||||
**focus-loss cleanup** so a dropped composition cannot survive as
|
||||
stale overlay text. Preedit needs a rendering surface, which is why
|
||||
this is not folded into 1a — and why the ruling has real cost, stated
|
||||
rather than discovered later.
|
||||
|
||||
**Stage 2 — capability-aware keymap resolution.**
|
||||
|
||||
**By reference, not absorption**: handoff §6 requires this to be its
|
||||
own framing round and forbids starting it as a half-lane. This arc
|
||||
sequences it here because Stage 1's seam makes its GPU consumers
|
||||
testable, and consumes it for default zoom bindings and every future
|
||||
GPU-native chord. **This arc feeds Stage 2's framing one explicit
|
||||
input question: does the capability-aware vocabulary cover pointer
|
||||
gestures (wheel-with-modifier), or keys only?** Revision 1 assumed
|
||||
the former; nothing yet guarantees it (major №4).
|
||||
|
||||
**Q#GA8 — RULED: wait for Stage 2. The temporary Ctrl+wheel zoom
|
||||
island is not created.** Zoom arrives through this stage's mechanism or
|
||||
not at all. Consequently this arc adds **no** off-path hardcode, and
|
||||
§7's island accounting is now unconditional rather than
|
||||
"at most one" — there is no removal criterion to track because there is
|
||||
nothing to remove.
|
||||
|
||||
**Stage 3 — parity consumers.**
|
||||
|
||||
- **3a folding Stage 3** — consumer-only per §2.2's matrix (the
|
||||
producer exists). The obligations are already enumerated in the
|
||||
folding framings (a `FoldState` consumer, the fold-mirror clear on
|
||||
`BufferSnapshot` (R2-4), `fold_projection` flip, optimistic-edit
|
||||
unfold (R2-3), caret/hit-test fold-awareness). Whether GPU folding
|
||||
needs `BlockAdornments` placeholders — which would add producer
|
||||
scope — is that framing's question. Its ordering precondition
|
||||
(bottom-panel Stage 2's landed band) is satisfied.
|
||||
- **3b word wrap** — `ui.line-wrap = "word"` as the declared third
|
||||
value. Nearly free on the GPU (cosmic-text `WordOrGlyph`).
|
||||
**Q#GA9 — RULED: implement the grid answer with UAX #14; no declared
|
||||
divergence.** The dependency is accepted rather than traded for a
|
||||
§3.2 entry, so both frontends wrap by the same rules.
|
||||
- **3c chrome theming** — `ThemeFacts` adoption for menu, completion
|
||||
popup, minimap, caret, window background, peer palette; the TUI's
|
||||
unthemed popup/menu pair is fixed in the same stage or declared.
|
||||
**Cursor blink and cursor styles ship here** — §7 registers the knob
|
||||
through the config registry and this is the stage behind it (§2.5).
|
||||
- **3d minibuffer/completion refinements** — `prefix_len`/`total`
|
||||
rendered (consumer-only, already on the wire per §2.2); clickable
|
||||
dropdown rows (audit F-007); multibyte-exact band caret; the nav
|
||||
highlight-wrap bug. Grouped as its own sub-stage rather than
|
||||
scattered, because §2.5 showed four backlog items landing in one
|
||||
surface.
|
||||
|
||||
**Stage 4 — robustness.**
|
||||
|
||||
- **4a auto-reconnect** + a reconnecting banner (parity with the
|
||||
attach TUI's seam; F-008). This adds a background reconnect loop and
|
||||
therefore owes §20's background-work attribution — see §7 for the
|
||||
contract its framing must satisfy (owner, lifetime, cancellation,
|
||||
failure attribution).
|
||||
- **4b session save *and* restore for semantic frontends** (Q#DS9) —
|
||||
**P2-gated, §5.1.** The stage owns **both halves as a pair**, which
|
||||
revision 2 got wrong by naming only restore. Q#DS9 scopes v1 to the
|
||||
local `editor::run` path and makes `desktop_mode(true)` auto-save
|
||||
**and** auto-restore no-ops in daemon mode, enforced in Rust by a
|
||||
`DaemonMode` marker that `save_session`/`restore_session` early-return
|
||||
on. So there is no snapshot being written under a daemon today: a
|
||||
restore path alone would have nothing to read, and shipping restore
|
||||
without save would be a stage that cannot work by construction.
|
||||
|
||||
**Q#GA10 — RULED: preserve both surfaces.** Automatic restore on the
|
||||
**first eligible attach** when armed and no explicit target was
|
||||
supplied, **plus** the existing explicit command — not one or the
|
||||
other. What remains open is more than revision 2 admitted when it
|
||||
said only the trigger shape was: **snapshot ownership** (who writes
|
||||
it, keyed how, once the frontend is not the owner), **save timing**
|
||||
(before-quit is a local-mode assumption; a daemon frontend can detach
|
||||
without quitting anything), and **multi-frontend arbitration** (two
|
||||
attached frontends with divergent layouts and one workspace key).
|
||||
All three are decided in this stage's framing, on P2's object.
|
||||
|
||||
**Stage 5 — hover/signature popups** (Q#GA3 goal). The core's
|
||||
never-attached `HoverView`/`SignatureView` are the data-model
|
||||
precedent; the GPU needs a popup surface and a wire decision (ride an
|
||||
existing family vs a new message — its framing decides; hover data
|
||||
currently flows Lua → echo/`*lsp-help*`, so there is **producer scope
|
||||
here by construction**, stated rather than hidden). Independent of
|
||||
Half B; sequenced after Stage 3 so the popup is themed from birth.
|
||||
|
||||
### Half B — structure
|
||||
|
||||
**Revision 3 reorders this half.** Revision 2 ran viewport facts (6)
|
||||
before the multi-window model (7), which is backwards twice over. A
|
||||
viewport fact is *about* something — a window — and whether a semantic
|
||||
window is a **daemon projection** or a **frontend-local object** is
|
||||
exactly what the model stage decides; designing the facts first would
|
||||
fix an identity the model then has to honour or break. Second, revision
|
||||
2's model stage was **framing-only** yet the sidebar was told to "ride
|
||||
Stage 7's geometry": a framing produces no geometry, so Stage 9
|
||||
consumed something no stage shipped. Non-bottom side geometry is now
|
||||
**implemented** in Stage 8.
|
||||
|
||||
**Stage 6 — the multi-window model framing.** The arc's center of
|
||||
gravity and the reason Half B exists: how a daemon layout projects to
|
||||
a semantic frontend (project the per-frontend layout tree vs
|
||||
frontend-local layout over multiple buffer subscriptions — the wire
|
||||
today assumes one document window per semantic session, with the panel
|
||||
band as the only exception). **Its output that everything downstream
|
||||
needs is the window-identity decision**, because that is what a
|
||||
viewport fact, a split, a side slot and a tab all refer to. Framing
|
||||
only; it ships no runtime code, and nothing downstream is told to
|
||||
consume geometry from it.
|
||||
|
||||
**Stage 7 — the viewport/window-identity substrate, and the scroll
|
||||
feel that reads it** (Q#GA3 goal). Viewport facts on the wire, carrying
|
||||
the identity Stage 6 settled — **the GPU never consumes daemon
|
||||
`view_top` today**, which the backlog names as the blocker for recenter
|
||||
and every scroll command (`docs/side-quest-backlog.md:147`; revision 2
|
||||
stated this exactly backwards). **Smooth scroll and the scrollbar live
|
||||
here**, not in a tail: a scrollbar needs authoritative extent and
|
||||
position, and pixel-smooth scrolling changes the scroll model those
|
||||
facts feed.
|
||||
|
||||
**Stage 8 — splits shipped, and side geometry with them**: rendering,
|
||||
input routing, per-window status bands, window-command parity
|
||||
(`C-x 2/3/o/0/1`), **plus implemented side-window geometry beyond
|
||||
`Side::Bottom`**. The geometry is here rather than in Stage 6 because
|
||||
it is code, and because Stage 9 consumes it.
|
||||
|
||||
**Stage 9 — the project/files sidebar** (Q#GA3 goal) —
|
||||
**P2-gated, §5.1.** Tree-primitive adoption (`COHERENCE.md` §14 names
|
||||
project files as a future tree consumer; §3 names the surface). Rides
|
||||
**Stage 8's implemented geometry** and P2's root object.
|
||||
|
||||
**Stage 10 — tabs/tabline** (Q#GA3 goal — last, low priority by
|
||||
ruling). **Q#GA12 — RULED: a deliberate deferral to this stage**,
|
||||
decided after P2 and the multi-window model exist, because both are
|
||||
what make the readings meaningful — the lineage precedents disagree
|
||||
(Emacs `tab-bar-mode` tabs are **window configurations**; tab lines and
|
||||
Doom's centaur-tabs are **buffers**), and a workspace-keyed third
|
||||
reading only becomes available once P2 has landed. Deferring is the
|
||||
ruling, not an absence of one. The constraints hold regardless and are
|
||||
pinned now: tabs present **existing objects** (whichever kind), **never
|
||||
a parallel registry** with unvalidated references — the menu-label
|
||||
mistake is the named anti-pattern — and the surface is **optional and
|
||||
off by default**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-goals and named deferrals
|
||||
|
||||
- **GUI as the default frontend.** Deliberately **not** the closure
|
||||
bar (Q#GA2 chose journey parity). It remains
|
||||
`gpu-initial-target-framing.md`'s deferral, to be *decided* — not
|
||||
assumed — when the arc closes.
|
||||
- **Git integration** (`COHERENCE.md` §15): editor-wide, not
|
||||
GUI-specific; not this arc.
|
||||
- **Settings/preferences GUI**, **native menu bar**, **multiple OS
|
||||
windows**: out of scope; nothing below depends on them.
|
||||
- **`ResourceOffer`/image rendering**: unproduced and unconsumed
|
||||
(§2.2); stays deferred unless a stage (sidebar icons, hover docs)
|
||||
pulls it in with a framing that owns both halves.
|
||||
- **Proportional code fonts, ligature/feature toggles, font wire
|
||||
transfer**: `gpu-set-font-framing.md`'s deferrals stand.
|
||||
- **Remote GPU paths, daemon service management**: unchanged.
|
||||
- **Multi-cursor**: pre-existing v0.1 non-goal, unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 7. Coherence impact (per `CLAUDE.md` / `COHERENCE.md` §20)
|
||||
|
||||
- **Journey steps touched**: **3, 4, 5, 6, 7, 8, 10, 12** — *on the GPU
|
||||
frontend*; Stage 0 makes the journey frontend-graded so the impact is
|
||||
measured per step rather than asserted. Revision 3 listed five; three
|
||||
were missing because the list was carried from revision 1's smaller
|
||||
stage map and never re-derived against the stages this arc actually
|
||||
ships. **Step 3** — `DroppedFile` (Stage 1b) is an open-a-file route.
|
||||
**Step 5** — keyboard correctness, IME, folding, wrapping and
|
||||
scrolling are all editing-surface work (Stages 1a, 1d, 3a, 3b, 7).
|
||||
**Step 6** — completion refinements and hover/signature popups
|
||||
(Stages 3d, 5).
|
||||
- **Interaction islands**: this arc adds **no dispatch shadows** — the
|
||||
count stays at six — and, after Q#GA8's ruling, **no off-path
|
||||
hardcode either**. Revision 2 reserved one temporary island for the
|
||||
Ctrl+wheel zoom intercept under a mandatory removal criterion; the
|
||||
ruling declined it, so the census is untouched by this arc and there
|
||||
is no removal criterion to track. Zoom arrives through Stage 2's
|
||||
mechanism or not at all. A stage that believes it needs a new
|
||||
*shadow*, or a new island, returns to this document first.
|
||||
- **Config registry adoption**: every user-visible knob this arc adds
|
||||
(smooth scroll, scrollbar, cursor blink, tabline toggle, IME
|
||||
behavior if any) registers through the config registry — no new raw
|
||||
Lua-table settings. The minimap's divergent tab width (4 vs the
|
||||
shared 8) stays owned by config-registry Q#CR13, referenced not
|
||||
absorbed.
|
||||
- **Background-work attribution** (moderate №8): Stage 4a's reconnect
|
||||
loop is background work and owes the §20 attribution regardless of
|
||||
§9's unsolved general model. The contract its framing must satisfy:
|
||||
**owner** — the GPU frontend process, scoped to its session, never
|
||||
the daemon; **lifetime/cancellation** — bounded backoff, canceled
|
||||
on user quit and on successful re-attach, never outliving the
|
||||
window; **failure attribution** — every terminal failure surfaces
|
||||
in-window with a reason, and the contract covers the case where the
|
||||
daemon supplies none.
|
||||
|
||||
**The reason requirement is two-sided, because the silent cases are
|
||||
the common ones.** Revision 2 required "the daemon's stated reason",
|
||||
which is unsatisfiable exactly when it matters: a daemon that
|
||||
**crashes or drops the socket delivers no `Goodbye` at all**, and the
|
||||
frontend learns of departure by EOF (§2.3 records that the GPU
|
||||
already loses its peer this way today). So: **use the daemon's reason
|
||||
when one arrives** — which is why Stage 1c's post-handshake
|
||||
`Goodbye`-reason consumer precedes this stage — **and otherwise
|
||||
surface an explicitly locally-classified transport/EOF reason**,
|
||||
labelled as locally inferred rather than reported. A banner that says
|
||||
nothing because the daemon said nothing is the failure this clause
|
||||
exists to prevent. §9's activity-indicator gap is *not* claimed by
|
||||
this arc.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verification shape
|
||||
|
||||
- **What already exists is used, not rebuilt** (major №5): the real
|
||||
offscreen `render_to_view` composition harness, the readback path,
|
||||
the smoke tests, and the required-GPU CI job are **landed**. Stages
|
||||
3c, 8 and any pixel-visible change add pixel assertions against
|
||||
that harness immediately. What is deferred from
|
||||
`gpu-golden-harness-framing.md` is only **golden-PNG comparison and
|
||||
the case gallery**; a stage adopts those if image diffing beats
|
||||
direct assertions for its witnesses, with that framing.
|
||||
- **The a37 problem is confronted, not inherited.** Real-GPU
|
||||
end-to-end tests compile only when `pmacs-gpu` is built, return
|
||||
`ok` without running otherwise, and are load-sensitive — the
|
||||
recorded footing hazard. Stage 1-pre's seam exists so input stages
|
||||
are witnessed *without* a display; stages that genuinely need a
|
||||
real frontend say so and name their witness (`PMACS_REQUIRE_GPU`
|
||||
discipline).
|
||||
- **The arc ratchet**: extend `tests/journey_acceptance.rs` with
|
||||
GPU-frontend rows where headlessly drivable; stages add rows, none
|
||||
removes them — same rule as the existing ratchet.
|
||||
|
||||
---
|
||||
|
||||
## 9. Rulings — Q#GA4 through Q#GA12, all closed
|
||||
|
||||
**Every arc-level question is ruled as of revision 3.** They are kept
|
||||
here with their answers rather than deleted, because a stage framing
|
||||
that wants to revisit one needs to see what was decided and why it is
|
||||
not open.
|
||||
|
||||
- **Q#GA4 — RULED.** The name is "the GUI arc"; the numeric **Arc 8
|
||||
label retires** at Stage 0, resolving the collision with the Lean 4
|
||||
framing's claim on the same number.
|
||||
- **Q#GA5 — RULED, with a hardening.** Half A slots after P1. Reaching
|
||||
Stage 4b is a **P2 start gate**: no later GUI stage starts until P2
|
||||
has an approved framing and an opened lane, after which non-gated
|
||||
work interleaves freely while the object lands (§5.1). Stronger than
|
||||
the recommendation carried in revision 2, which would have let every
|
||||
**non-gated** stage finish before P2 began — leaving P2 a terminal
|
||||
closure blocker rather than letting the arc close around it.
|
||||
- **Q#GA6 — RULED.** Land the TUI answer in Stage 1b; no declared
|
||||
divergence. The events already arrive and are dropped by the
|
||||
document-window handler (Stage 1b records the sites).
|
||||
- **Q#GA7 — RULED.** Full preedit overlay: commit string, caret and
|
||||
selection range, cancellation, focus-loss cleanup. Not the
|
||||
commit-string minimum.
|
||||
- **Q#GA8 — RULED.** Wait for Stage 2. **No temporary Ctrl+wheel
|
||||
island**, so this arc adds no off-path hardcode (§7).
|
||||
- **Q#GA9 — RULED.** Implement the grid answer with UAX #14. No
|
||||
declared divergence.
|
||||
- **Q#GA10 — RULED.** Preserve **both** surfaces: automatic restore on
|
||||
the first eligible attach when armed and no explicit target was
|
||||
supplied, plus the existing explicit command. Stage 4b owns the
|
||||
paired **save** path too, and three questions remain live inside that
|
||||
stage — snapshot ownership, save timing, multi-frontend arbitration.
|
||||
- **Q#GA11 — RULED.** The §3.1 blocker seed stands **unchanged at nine
|
||||
items**.
|
||||
- **Q#GA12 — RULED as a deliberate deferral** to Stage 10, taken after
|
||||
P2 and the multi-window model exist. The existing-object,
|
||||
no-parallel-registry and optional/off-by-default constraints hold
|
||||
from now, not from Stage 10.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sequencing against #227 (git Stage 1)
|
||||
|
||||
Settled with the user on 2026-08-10, and recorded here because it
|
||||
constrains when Stage 0 may start:
|
||||
|
||||
1. **Revision 3 → approval.**
|
||||
2. **The approved framing and the Stage 0 lane are committed and
|
||||
pushed** on the Stage 0 branch, as its first commit. Until that
|
||||
happens this document is not portable and nothing downstream is
|
||||
safe to rely on.
|
||||
3. **#227 is finished and merged** before Stage 0 implementation.
|
||||
4. **Stage 0 rebases and performs the absorption**, which by then
|
||||
includes **#227's own newly merged lane** alongside the five already
|
||||
enumerated.
|
||||
|
||||
The reason #227 goes first rather than riding alongside: its ref is
|
||||
**72 `main` commits behind**, and it touches `COHERENCE.md`,
|
||||
`docs/active-work.md` and `builtin/runtime/listview.lua` — the three
|
||||
files Stage 0's absorption rewrites. Carrying it across the arc would
|
||||
compound exactly the conflicts Stage 0 exists to retire.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,8 @@
|
|||
# LSP file watcher D3 — the polling cost — framing
|
||||
|
||||
**Status: revision 4 — APPROVED 2026-08-11.** The user's own review
|
||||
pass (round 3, absorbed below) closed the state machine; on the
|
||||
soundness confirmation the four rulings were adopted as proposed:
|
||||
**Q#D3-1** the honest bar — absent at idle, one attributable job per
|
||||
concurrently due group; **Q#D3-2** no exclusions by default;
|
||||
**Q#D3-3** server `root_uri` → `cwd` → attachment fallback;
|
||||
**Q#D3-4** constants, no config keys.
|
||||
**Status: revision 4 — DRAFT, review corrections absorbed; awaiting
|
||||
the user rulings Q#D3-1..4. No implementation may begin from this
|
||||
document.**
|
||||
|
||||
Continues issue #233, which stays open until this lane closes it. D1
|
||||
and D2 — matching correctness and the re-registration leak — merged as
|
||||
|
|
@ -378,7 +374,7 @@ jobs at idle**, with one `walk_tree` job per group for the few
|
|||
milliseconds each scan actually runs — at most every 250 ms under
|
||||
activity and every 4 s at rest, immediately once at registration.
|
||||
|
||||
## The rulings — adopted as proposed at approval (2026-08-11)
|
||||
## Open rulings — each blocks implementation
|
||||
|
||||
- **Q#D3-1 — the acceptance bar, stated accurately (round 2).** At
|
||||
idle the indicator is **absent** (no running job exists —
|
||||
|
|
@ -386,23 +382,22 @@ activity and every 4 s at rest, immediately once at registration.
|
|||
shows **one attributable job per concurrently due group** — `⋯N`
|
||||
when N (server, base) groups are due on the same frame, each named
|
||||
for its root; a typical single-project session has one group.
|
||||
**Adopted.** The alternative — a global scan queue guaranteeing
|
||||
`⋯1` at the cost of coupling one server's scan latency to
|
||||
another's tree size — was declined.
|
||||
- **Q#D3-2 — exclusions.** **Adopted: none by default**, with opt-in
|
||||
exclusion as a documented contract trade (option b) if a user ever
|
||||
asks.
|
||||
- **Q#D3-3 — the scan root.** **Adopted: server `root_uri` → server
|
||||
`cwd` → attached-file directory** (the fallback itself made
|
||||
deterministic in implementation review: lexicographically smallest
|
||||
attachment directory). This widens the watched tree for servers
|
||||
with a real root — a behavioural change to a path real servers
|
||||
exercise, accepted as such.
|
||||
- **Q#D3-4 — interval, cap, and backoff curve.** **Adopted:
|
||||
constants until someone asks.** The D1/D2 framing refused a knob
|
||||
for a defect; with D3 the cadence is a designed mechanism, so keys
|
||||
would be defensible — but more registry surface is coherence cost
|
||||
nobody has yet paid for a reason.
|
||||
Alternative if `⋯1` must be guaranteed: a global scan queue
|
||||
serializing walks across groups, at the cost of coupling one
|
||||
server's scan latency to another's tree size. Which bar?
|
||||
- **Q#D3-2 — exclusions.** Proposed: none by default, with opt-in
|
||||
exclusion as a documented contract trade (option b) if a user
|
||||
asks. Confirm, or rule for one of (b)/(c)/(d) above.
|
||||
- **Q#D3-3 — the scan root.** Proposed: server `root_uri` → server
|
||||
`cwd` → attached-file directory. This widens the watched tree for
|
||||
servers with a real root (today it is one attached file's
|
||||
directory, chosen by hash order) — a behavioural change to a path
|
||||
real servers exercise. Confirm the order, or rule otherwise.
|
||||
- **Q#D3-4 — interval, cap, and backoff curve: constants or config
|
||||
keys.** The D1/D2 framing refused a knob for a defect; with D3 the
|
||||
cadence becomes a designed mechanism, so keys are defensible — but
|
||||
more registry surface is coherence cost. Proposed: constants until
|
||||
someone asks.
|
||||
|
||||
## Verification sketch
|
||||
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
# Parse-budget diagnosability — reporting the measurement that failed
|
||||
|
||||
**Status: revision 2 — APPROVED 2026-08-29, IMPLEMENTED, PR #244.**
|
||||
|
||||
Revision 2 answers review of 1. Three of its four changes are
|
||||
corrections, and one is a scope reduction:
|
||||
|
||||
- **SPLIT.** Revision 1 bundled this with adding `workflow_dispatch` to
|
||||
`ci.yml`. `docs/active-work.md` already records those as "two
|
||||
follow-ups … both their own lanes", and a shared thesis is not an
|
||||
atomic feature boundary. **`workflow_dispatch` is no longer in this
|
||||
framing**; it gets its own.
|
||||
- **The census was WRONG.** Revision 1 called
|
||||
`dispatch_parse_round_trips_a_rust_source_file` the sole outlier.
|
||||
`tests/m4_acceptance.rs:244` asserts the same measurement against the
|
||||
same budget and also omits it. **Both are now in scope.**
|
||||
- **The completeness claim is WITHDRAWN**, not repaired. See §3.
|
||||
- The concurrency reasoning revision 1 got backwards belonged to the
|
||||
other change and leaves with it.
|
||||
|
||||
## 1. What this fixes
|
||||
|
||||
Two assertions compare a parse duration against a 100ms budget and
|
||||
report **nothing** about what they measured:
|
||||
|
||||
| where | assertion | message |
|
||||
|---|---|---|
|
||||
| `src/async_runtime.rs:3328` | `duration_ms < 100` | `"trivial parse should be fast"` |
|
||||
| `tests/m4_acceptance.rs:244` | `duration_ms < 100` | `"200-line parse should be quick"` |
|
||||
|
||||
When either reds, the log says the parse was slow and stops there.
|
||||
|
||||
## 2. Why now, and the evidence
|
||||
|
||||
The first has redded **twice** on `Test (macos-latest / lua54)`, and
|
||||
**both margins are unrecoverable**:
|
||||
|
||||
| occurrence | where | outcome |
|
||||
|---|---|---|
|
||||
| U11 | PR #242, run `32393462318`, job `96504773333` | red, green on rerun |
|
||||
| U11's recurrence | PR #243, run `33156571314`, job `98800645872` | red, green on rerun |
|
||||
|
||||
`src/async_runtime.rs` was **byte-identical to `main`** for both — blob
|
||||
`9310ce3fca8c5fd8ebd39a68c29ad6985e256049`. U11's own row predicted the
|
||||
cost:
|
||||
|
||||
> *A recurrence is not another instance of this row. Because the margin
|
||||
> was never captured, a second red cannot be compared with the first.*
|
||||
|
||||
That came true once. Nothing prevents a third.
|
||||
|
||||
**A 1ms overshoot and a 900ms overshoot are different failures** — one
|
||||
says a threshold is marginal, the other says something stalled — and
|
||||
today they produce identical logs.
|
||||
|
||||
## 3. Scope, and a claim this framing does NOT make
|
||||
|
||||
**In scope: the two assertions in §1.** They are the same measurement
|
||||
against the same budget, in the same subsystem, and it would be strange
|
||||
to fix one and leave the other to produce the next unreadable red.
|
||||
|
||||
**Out of scope, and deliberately: everything else.** Revision 1 claimed
|
||||
these were the only measurement-omitting assertions in the codebase.
|
||||
**That claim is withdrawn and is not replaced by a corrected one.** A
|
||||
sweep wide enough to be complete also catches `Instant::now() <
|
||||
deadline` loop guards and `eval::<bool>` turbofish, which are not
|
||||
budget assertions at all; a sweep narrow enough to be accurate proves
|
||||
nothing about completeness. **This lane is not an assertion-hygiene
|
||||
audit and should not be read as one.**
|
||||
|
||||
Several nearby budget assertions do already report their measurements —
|
||||
`composition_overhead_under_ten_percent`, `criterion_1_end_of_line_typing…`
|
||||
(`optimistic.rs:989`), `dired_open_renders_10k_entries_under_200ms`,
|
||||
`m6_2_pty_streaming_respects_byte_ceiling` (`observed {in_flight}`).
|
||||
They are cited as **precedent for the shape**, not as evidence that the
|
||||
set is exhausted.
|
||||
|
||||
## 4. What lands
|
||||
|
||||
```rust
|
||||
assert!(
|
||||
duration_ms < 100,
|
||||
"trivial parse should be fast: took {duration_ms}ms against a \
|
||||
100ms budget"
|
||||
);
|
||||
```
|
||||
|
||||
and, in `tests/m4_acceptance.rs`:
|
||||
|
||||
```rust
|
||||
assert!(
|
||||
duration_ms < 100,
|
||||
"200-line parse should be quick: took {duration_ms}ms against a \
|
||||
100ms budget"
|
||||
);
|
||||
```
|
||||
|
||||
**Both budgets stay at 100ms.** Widening is what R1 already rejected,
|
||||
and it would discard the signal these reds carry.
|
||||
|
||||
## 5. Acceptance
|
||||
|
||||
| # | contract | witness | mutation |
|
||||
|---|---|---|---|
|
||||
| D1 | the `async_runtime` assertion reports the observed ms **and** the budget | a scratch build with the comparison bound forced to `0` panics with a message containing the observed value and `100ms` | restore the bare message → the row cannot separate a 1ms overshoot from a 900ms one |
|
||||
| D2 | the `m4_acceptance` assertion does the same | same method, same row | same |
|
||||
| D3 | both budgets are still `100` | the comparison literal is unchanged in both files | widen either → R1's rejected remedy returns |
|
||||
|
||||
**D1 and D2 are asserted against a real panic message, not by reading
|
||||
the source.** A row that greps the format string would pass while the
|
||||
assertion it describes had been deleted — which is the same
|
||||
read-the-code-not-the-effect failure this project has repeatedly
|
||||
caught.
|
||||
|
||||
**How the fault is injected, and what each half proves.** Temporarily
|
||||
force **only the comparison bound** to `0` in a scratch build. The
|
||||
resulting panic then proves two things and no more: that the observed
|
||||
value is interpolated, and that the budget text reads `100ms`.
|
||||
|
||||
**It does NOT prove the exercised budget was 100ms — it was 0.** The
|
||||
scratch build compares against `0` while the message still says `100ms`,
|
||||
so that panic alone says nothing about the committed threshold. **D3
|
||||
carries that half separately**, by pinning the comparison literal in
|
||||
both files. The two rows are only a proof together, and neither
|
||||
substitutes for the other.
|
||||
|
||||
This is the **smallest deterministic fault injection** available, not
|
||||
the only conceivable one. A row that waited for a genuine 100ms
|
||||
overshoot would be exactly as intermittent as the thing it documents.
|
||||
|
||||
## 6. Coherence impact (`COHERENCE.md` §20)
|
||||
|
||||
- **Journey steps touched: NONE.** No product behaviour changes. What
|
||||
moves is **evidence quality** — whether a failing check can be
|
||||
reasoned about — the same axis the `scripts/gate` SIGINT guard sat
|
||||
on.
|
||||
- **Interaction islands: none added.**
|
||||
- **Config registry: no entry.** A test budget is not a user-tunable.
|
||||
- **Background work: none started**, and no attribution moves.
|
||||
|
||||
## 7. What this does NOT do
|
||||
|
||||
- **It does not fix the intermittence**, and makes no claim about
|
||||
cause. It makes the next occurrence *comparable*.
|
||||
- **It does not widen, relax or `#[ignore]` any budget.** Two
|
||||
intermittent reds are not evidence a threshold is wrong.
|
||||
- **It does not add `workflow_dispatch`.** That is its own lane, per
|
||||
the ledger's recorded decision.
|
||||
- **It does not run U9's discriminating control**, which remains
|
||||
unrun and is a third, separate piece of work.
|
||||
|
|
@ -1,454 +0,0 @@
|
|||
# GPU probe SIGINT lane — run manifest
|
||||
|
||||
Every physical run behind
|
||||
`docs/gpu-probe-sigint-framing.md` §4. Pushed so the evidence travels;
|
||||
the log bodies stay machine-local under
|
||||
`/home/jeans/build/pmacs-gate-targets/probe-sigint-evidence/` and are
|
||||
identified here by SHA-256 prefix and byte count.
|
||||
|
||||
## Provenance honesty
|
||||
|
||||
These runs were made **before** this manifest existed, so their
|
||||
provenance is **reconstructed, not captured**. Specifically:
|
||||
|
||||
- **Commands** are exact and complete argv. Revision 3 abbreviated
|
||||
R7–R9 as "`--test` ×N (targets 6–37)" and F5 as "`--acceptance` ×6",
|
||||
which are descriptions, not reconstructable invocations. They are
|
||||
written out in full below.
|
||||
- **Worktree** is exact.
|
||||
- **HEAD** is given as a range where the run cannot be pinned to one
|
||||
commit, and marked `~`. It is never guessed at single-commit
|
||||
precision.
|
||||
- **Cleanliness** was not recorded at the time and is therefore
|
||||
`UNKNOWN` for every pre-manifest run. It is not inferred.
|
||||
|
||||
**D0 (below) exists because of this.** No conclusion in §4 should rest
|
||||
on a `UNKNOWN`-cleanliness row once D0 has replaced it.
|
||||
|
||||
## Artifact identity — suffix is NOT byte identity
|
||||
|
||||
Two distinct things were conflated in revision 3 and are separated here.
|
||||
|
||||
**Cargo suffix** is recorded in each log and is therefore *known* per
|
||||
run. **Byte identity** is not: target directories have been overwritten
|
||||
many times since, so a hash computed today is the hash of whatever
|
||||
occupies that path now, not of what a given run executed.
|
||||
|
||||
That the two differ is demonstrated, not assumed. F1 ran in the `main`
|
||||
worktree (`pmacs-fdccc423`) and executed suffixes `-5d9105cb7047aab8`
|
||||
and `-d4dae4f01bcdef62` — **the same suffixes** as the panel-worktree
|
||||
sweeps — yet the bytes at those paths differ by worktree:
|
||||
|
||||
| worktree | `gpu_initial_target…-5d9105cb` | `gpu_invocation…-d4dae4f0` |
|
||||
|---|---|---|
|
||||
| `pmacs-fdccc423` (main) | `e057803988c34cf7` | `00f06aeb089ce38d` |
|
||||
| `pmacs-mapping-gen-…` (§5b) | `1b3cc86cbb8d6092` | `ede0c07dd9abb456` |
|
||||
|
||||
Same suffix, different source head, different bytes **today**. So
|
||||
**"workspace artifact family" was not an identity class** and is
|
||||
withdrawn as a grouping.
|
||||
|
||||
Three levels of knowledge, kept apart:
|
||||
|
||||
0. **Portability caveat.** Suffixes below are read from logs that are
|
||||
**machine-local**; this manifest is the portable record of them, and
|
||||
a reader elsewhere is trusting this transcription, not verifying it.
|
||||
Rows that were never logged say `UNKNOWN` and are not guessed.
|
||||
1. **Suffix — known**, from each log. A differing suffix means Cargo
|
||||
computed a different metadata hash, i.e. it treated the two as
|
||||
distinct compilations.
|
||||
2. **Today's bytes at a path — known**, and shown above.
|
||||
3. **The bytes a historical run executed — UNKNOWN.** Target
|
||||
directories have been overwritten repeatedly since; a hash computed
|
||||
now is the hash of the current occupant.
|
||||
|
||||
So statements of the form "R9 and the sweeps ran byte-different
|
||||
binaries" are **withdrawn everywhere**. What is established is that
|
||||
they ran **different Cargo compilations** (different suffixes), which
|
||||
is enough to void the comparison and is all that is claimed. R1 and R2
|
||||
have no preserved log and cannot claim even a suffix.
|
||||
|
||||
## Runs
|
||||
|
||||
All in worktree `pmacs-mapping-gen` unless stated. `WT=mg` is
|
||||
`/home/jeans/Repos/personal/pmacs-mapping-gen`; `WT=main` is
|
||||
`/home/jeans/Repos/personal/pmacs` at `72da24a`. All carry
|
||||
`CARGO_TARGET_DIR=/home/jeans/build/pmacs-gate-targets/pmacs-mapping-gen-8cb089c8`
|
||||
except `WT=main`, which uses `…/pmacs-fdccc423`.
|
||||
|
||||
| id | exact command (after `cargo`) | WT | HEAD | clean | Cargo suffixes executed | result | log (sha256/16, bytes) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| R1 | `test --features crdt --test gpu_invocation_acceptance ctrl_c_on_launcher_group` ×3 | mg | ~`724b785`–`5174f73` | UNKNOWN | **UNKNOWN** (no log) | green, 0.15–0.17 s | **none preserved** |
|
||||
| R2 | `test --features crdt --test gpu_invocation_acceptance` | mg | ~`724b785`–`5174f73` | UNKNOWN | **UNKNOWN** (no log) | green, 15 passed | **none preserved** |
|
||||
| R3 | `test --workspace --features crdt --no-fail-fast -- --skip basedpyright ctrl_c_on_launcher_group` | mg | ~`724b785`–`5174f73` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | green | `e09a96512035284e` 33113 |
|
||||
| R4 | `test --features crdt --lib --test gpu_invocation_acceptance --no-fail-fast` | mg | ~`724b785`–`5174f73` | UNKNOWN | `-6b4b8223` only | green, 2145 + 15 | `89050c702de22d57` 158812 |
|
||||
| R5 | `test --features crdt --no-fail-fast --test gate_script_acceptance --test gpu_invocation_acceptance` | mg | ~`5174f73`–`b72843a` | UNKNOWN | `-6b4b8223` only | green | `31b3e5249b475479` 3706 |
|
||||
| R6 | `test --features crdt --no-fail-fast --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`5174f73`–`b72843a` | UNKNOWN | `-91f51d0b` / `-6b4b8223` | green, 11+15+15 | `332693a39c73731a` 4569 |
|
||||
| R7 | `test --features crdt --no-fail-fast --lib --bins --test acceptance --test ambient_isolation_acceptance --test auto_indent_acceptance --test auto_indent_crdt_acceptance --test auto_pair_acceptance --test auto_pair_crdt_acceptance --test autosave_acceptance --test bottom_panel_stage1_acceptance --test bottom_panel_stage2a_acceptance --test bottom_panel_stage2b_daemon_acceptance --test bottom_panel_stage2b_gpu_acceptance --test bottom_panel_stage2b_protocol_acceptance --test comment_toggle_acceptance --test compile_mode_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | `gpu_invocation…-6b4b8223` **only** — R7 does not select `gpu_initial_target` | green | `9e1ebc59ed9f0dd4` 187531 |
|
||||
| R8 | `test --features crdt --no-fail-fast --test compile_mode_crdt_acceptance --test completion_popup_acceptance --test config_registry_acceptance --test cua_region_acceptance --test desktop_acceptance --test destination_capture_acceptance --test dired_acceptance --test discovery_acceptance --test discovery_stage2_acceptance --test editops_acceptance --test find_file_acceptance --test folding_acceptance --test folding_stage2_acceptance --test full_grid_resync_acceptance --test gate_script_acceptance --test git_status_stage1_acceptance --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | `-91f51d0b` / `-6b4b8223` (`half2.log:438`, `:459`) | green | `8b26ebfcf5f871b4` 28677 |
|
||||
| R9 | `test --features crdt --no-fail-fast --lib --bins --test acceptance --test ambient_isolation_acceptance --test auto_indent_acceptance --test auto_indent_crdt_acceptance --test auto_pair_acceptance --test auto_pair_crdt_acceptance --test autosave_acceptance --test bottom_panel_stage1_acceptance --test bottom_panel_stage2a_acceptance --test bottom_panel_stage2b_daemon_acceptance --test bottom_panel_stage2b_gpu_acceptance --test bottom_panel_stage2b_protocol_acceptance --test comment_toggle_acceptance --test compile_mode_acceptance --test compile_mode_crdt_acceptance --test completion_popup_acceptance --test config_registry_acceptance --test cua_region_acceptance --test desktop_acceptance --test destination_capture_acceptance --test dired_acceptance --test discovery_acceptance --test discovery_stage2_acceptance --test editops_acceptance --test find_file_acceptance --test folding_acceptance --test folding_stage2_acceptance --test full_grid_resync_acceptance --test gate_script_acceptance --test git_status_stage1_acceptance --test gpu_font_acceptance --test gpu_initial_target_acceptance --test gpu_invocation_acceptance` | mg | ~`b72843a` | UNKNOWN | **`-91f51d0b` / `-6b4b8223`** (log `:3066`, `:3087`) | green | `b31d98ee2f427eca` 214566 |
|
||||
| R10 | `test --workspace --features crdt --no-fail-fast --test gpu_initial_target_acceptance --test gpu_invocation_acceptance -- --skip basedpyright` | mg | ~`b72843a` | UNKNOWN | **`-5d9105cb` AND `-d4dae4f0`** (log `:3`, `:24`) | green | `81b48fd7a0e261dc` 3553 |
|
||||
| F1 | `build --workspace --no-default-features --features luajit,crdt && test --workspace --features crdt --no-fail-fast -- --skip basedpyright` | **main** | `72da24a` | clean (verified `git status --porcelain` empty) | `-5d9105cb` / `-d4dae4f0`; today's occupants `e0578039…` / `00f06aeb…` | **red** | `10b55b8ba8741125` 334446 |
|
||||
| F2 | `test --workspace --features crdt --no-fail-fast -- --skip basedpyright` | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`postclean.log`) | **red** | `474f88f0dad581fe` 338555 |
|
||||
| F3 | same argv as F2, with a resource sampler running | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`sweep-inst.log`) | **red** | `7b8519e7300e8bb3` 338555 |
|
||||
| F4 | same argv as F2, with a process-table sampler running | mg | ~`b72843a` | UNKNOWN | `-5d9105cb` / `-d4dae4f0` (`sweep-diag.log`) | **red** | `5ccdefc5d89eece3` 338555 |
|
||||
| F5 | stage 15 of `./scripts/gate --protocol --acceptance bottom_panel_stage1_acceptance --acceptance bottom_panel_stage2a_acceptance --acceptance bottom_panel_stage2b_daemon_acceptance --acceptance bottom_panel_stage2b_gpu_acceptance --acceptance bottom_panel_stage2b_protocol_acceptance --acceptance gui_stage1a_wire_acceptance` | mg | `5174f73` + uncommitted docs | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (3 bins: both ctrl_c copies + `m6_1_pty_mode_lifecycle`) | `20260817T172537Z-2375685/15-sweep-crdt.log`, sha `e5bdc911e366` |
|
||||
| F6 | stage 9 of `./scripts/gate --protocol` | mg | **`724b785`** (see below) | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (2 bins, both ctrl_c copies) | `20260816T063330Z-1977433/09-sweep-crdt.log`, sha `7a75d999ac4f` |
|
||||
| F7 | stage 9 of `./scripts/gate --protocol` | mg | `5174f73` (committed 08:45:41, run began 08:45:49) | UNKNOWN | `-5d9105cb` / `-d4dae4f0` | **red** (2 bins, both ctrl_c copies) | `20260816T064549Z-2144707/09-sweep-crdt.log`, sha `9d3c6ad1bfc9` |
|
||||
|
||||
Supporting, not a reduction: `9b8a01076b44bb7c` 98838
|
||||
(`proc-sample.log`) is the process-table sampler output behind the
|
||||
retracted "mechanism located" claim.
|
||||
|
||||
**The red count is 7, not 5.** Revision 3 said "F1–F5, red 5/5" while
|
||||
the framing separately cited gate `…-2144707`, a *different* physical
|
||||
run the manifest never listed. Both exist, both are red, and there is a
|
||||
third gate run too. F1–F7 above enumerate all seven, each with its own
|
||||
log digest. F5 also has an extra failing binary
|
||||
(`m6_1_pty_mode_lifecycle_started_then_exited`) that F6/F7 do not.
|
||||
|
||||
**R2 and R6 are distinct runs.** Revision 2's §4 cited `gpu3.log` for
|
||||
both; that log is R6's three-suite run only, and R2's log was never
|
||||
preserved. R1 likewise has no log. Both are marked accordingly rather
|
||||
than backfilled.
|
||||
|
||||
## The onset is datable — and this supersedes the reduction matrix
|
||||
|
||||
`sweep-crdt` has 17 log files in this target directory. Counted **per
|
||||
test copy**, not per stage:
|
||||
|
||||
| outcome | runs |
|
||||
|---|---|
|
||||
| both copies `... ok` | **13** |
|
||||
| **neither copy executed** — the stage died compiling `pmacs` (`error[E0308]: mismatched types`), log `20260815T182846Z-708693` | **1** |
|
||||
| both copies `FAILED` | **3** (`…-1977433`, `…-2144707`, `…-2375685`) |
|
||||
|
||||
Revision 4 said "14 runs, 11 green, 3 red on other tests" and that the
|
||||
earlier reds "failed on unrelated tests". **Both were wrong**: the
|
||||
count is 13 / 1 / 3, and one of the earlier non-passing runs is a
|
||||
**compile failure that never reached either copy**, not a red on
|
||||
another test. Of the genuinely red-on-other-tests sweeps, `…-2839374`
|
||||
(08-15 09:55) failed protocol and version rows and `…-830195`
|
||||
(08-15 18:37) failed `composition_overhead_under_ten_percent` and a
|
||||
v21/v20 row — those two did execute `ctrl_c`, and it passed.
|
||||
|
||||
So the failure is **not long-standing**. Last observed green:
|
||||
`20260815T185708Z`. First observed red: `20260816T063330Z`. The machine
|
||||
was not rebooted across it — the current boot began 08-14 09:30.
|
||||
|
||||
**But this is not yet a source boundary.** Reflog and commit times put
|
||||
HEAD at `7599661` during the last green (`3c06176` was committed 40 s
|
||||
*after* that run finished) and at `724b785` during the first red
|
||||
(`5174f73` was committed 08:45:41, after that run ended at 08:42:01 —
|
||||
revision 4's manifest wrongly recorded F6 at `5174f73`). Cleanliness
|
||||
was captured for **neither**, and the tree was being edited throughout.
|
||||
So the window dates a **machine/worktree-state transition**, not two
|
||||
clean source revisions.
|
||||
|
||||
One relationship is worth recording **for exactly what it shows**:
|
||||
`72da24a` is an **ancestor** of `7599661` (`git merge-base
|
||||
--is-ancestor`), yet fails today (F1) while `7599661` passed on 08-15.
|
||||
The two observations differ in commit **and** environment **and**
|
||||
time, so they are **non-comparable and support no causal conclusion of
|
||||
any kind** — not even "outcome is not determined by commit alone",
|
||||
since different commits can deterministically produce different
|
||||
outcomes. The pair does not discriminate an environmental change, a
|
||||
source/environment interaction, or a fix before `7599661` followed by a
|
||||
regression before `724b785`. And an ancestor outside the interval is
|
||||
irrelevant to whether the interval contains a regression.
|
||||
|
||||
## D1/D2 — EXECUTED 2026-08-19. The outer invocation is the variable
|
||||
|
||||
**The causal variable is the OUTER invocation of the test runner**, so
|
||||
it is recorded here as a first-class column. Earlier "exact commands"
|
||||
in this file are incomplete for that reason: they gave the inner
|
||||
`cargo` argv and omitted how the runner itself was started.
|
||||
|
||||
### Controlled arms, committed head, worktree-local target
|
||||
|
||||
Head `77b623c`, `dirty=0`, worktree
|
||||
`/home/jeans/Repos/personal/pmacs-probe-sigint`, target
|
||||
`/home/jeans/build/pmacs-gate-targets/pmacs-probe-sigint-84ed0f9e`,
|
||||
`TMPDIR=/home/jeans/build/pmacs-gate-targets/tmp/arms`.
|
||||
|
||||
Inner command, identical in both arms:
|
||||
|
||||
```
|
||||
cargo test --features crdt --no-fail-fast \
|
||||
--test gpu_invocation_acceptance --test gpu_initial_target_acceptance \
|
||||
-- ctrl_c_on_launcher_group
|
||||
```
|
||||
|
||||
The **actual evidence-producing outer invocations** were:
|
||||
|
||||
```
|
||||
# fg arm
|
||||
/home/jeans/build/pmacs-gate-targets/d0a/arms2.sh fg
|
||||
|
||||
# bg arm
|
||||
setsid nohup /home/jeans/build/pmacs-gate-targets/d0a/arms2.sh bg \
|
||||
> /dev/null 2>&1 & disown
|
||||
```
|
||||
|
||||
`arms2.sh` is machine-local, so naming it is not portable provenance by
|
||||
itself. This is the complete body that produced `arms2.tsv`; it records
|
||||
the inner `cargo` exit rather than the status of the disowned outer job:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# D1/D2 controlled arms, v2: full binary SHA-256 captured PER RUN,
|
||||
# immediately after the run, before anything can rebuild them.
|
||||
set -u
|
||||
WT=/home/jeans/Repos/personal/pmacs-probe-sigint
|
||||
TD=/home/jeans/build/pmacs-gate-targets/pmacs-probe-sigint-84ed0f9e
|
||||
TMP=/home/jeans/build/pmacs-gate-targets/tmp/arms; mkdir -p "$TMP"
|
||||
OUT=/home/jeans/build/pmacs-gate-targets/d0a
|
||||
ARM="$1"; LOG="$OUT/arm2-$ARM.log"
|
||||
HEAD=$(git -C "$WT" rev-parse HEAD); DIRTY=$(git -C "$WT" status --porcelain | wc -l)
|
||||
env TMPDIR="$TMP" CARGO_TARGET_DIR="$TD" sh -c \
|
||||
"cd $WT && cargo test --features crdt --no-fail-fast --test gpu_invocation_acceptance --test gpu_initial_target_acceptance -- ctrl_c_on_launcher_group" \
|
||||
> "$LOG" 2>&1
|
||||
EXIT=$?
|
||||
OK=$(grep -c "ctrl_c_on_launcher_group_does_not_reach_spawned_daemon \.\.\. ok" "$LOG")
|
||||
BAD=$(grep -c "ctrl_c_on_launcher_group_does_not_reach_spawned_daemon \.\.\. FAILED" "$LOG")
|
||||
SIG=$(grep -m1 -o "SigIgn=[0-9a-f]*" "$LOG" || echo "SigIgn=not-captured")
|
||||
# Full digests of exactly the binaries this run executed, taken now.
|
||||
HASHES=""
|
||||
for b in $(grep -E "Running tests/gpu_" "$LOG" | sed 's|.*deps/||; s|)||'); do
|
||||
HASHES="$HASHES $b=$(sha256sum "$TD/debug/deps/$b" | cut -d' ' -f1)"
|
||||
done
|
||||
printf '%s\tarm=%s\thead=%s\tdirty=%s\texit=%s\tok=%s\tfailed=%s\t%s\tlogsha=%s\thashes=%s\n' \
|
||||
"$(date -Iseconds)" "$ARM" "$(echo $HEAD | cut -c1-7)" "$DIRTY" "$EXIT" "$OK" "$BAD" "$SIG" \
|
||||
"$(sha256sum "$LOG" | cut -d' ' -f1)" "$HASHES" >> "$OUT/arms2.tsv"
|
||||
echo "arm2 $ARM: exit=$EXIT ok=$OK failed=$BAD $SIG"
|
||||
```
|
||||
|
||||
### Result — head `77b623c`, `dirty=0`, digests captured PER RUN
|
||||
|
||||
| arm | outer | cargo exit | ok | failed | `SigIgn` | log sha256 (full) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| fg | foreground | 0 | 2 | 0 | not captured (no failure ⇒ no dump) | `b6117619242f3dabadbe5826f59f11cef451cd09a2beb304e9a3dec1a4616465` |
|
||||
| bg | `setsid nohup … &` | 101 | 0 | 2 | `0000000000001007` | `30ac6568e345c485ddf23734a239d71bd06d4a3c2d549f1b4a8942a930527507` |
|
||||
|
||||
Binary digests, **full SHA-256, taken immediately after each run before
|
||||
anything could rebuild them** — not read later from a reused path:
|
||||
|
||||
| binary | fg arm | bg arm |
|
||||
|---|---|---|
|
||||
| `gpu_initial_target_acceptance-91f51d0b5303ff9f` | `0890b78cca22ac1e80b79845f85fb6e88def3330db15ae123a2a672d3084124c` | `0890b78cca22ac1e80b79845f85fb6e88def3330db15ae123a2a672d3084124c` |
|
||||
| `gpu_invocation_acceptance-6b4b8223dea45247` | `ef6ff1c15e11062ab53a075763814f32c1bbc9be1b146d068c60e91fa247c696` | `ef6ff1c15e11062ab53a075763814f32c1bbc9be1b146d068c60e91fa247c696` |
|
||||
|
||||
**These are byte-identical, and that claim is now carried by the
|
||||
capture rather than by inference.** The earlier arms table recorded only
|
||||
16-character prefixes at run time and its full values were read
|
||||
afterwards from reused paths — which is exactly the provenance rule
|
||||
§7/D0 states, applied against my own record. Those rows are superseded
|
||||
by the table above. The raw `arms.tsv` and `arms2.tsv` remain
|
||||
machine-local; the literal harness and full rows above are their
|
||||
portable transcription.
|
||||
|
||||
### Disposition — UNRECORDED CORROBORATION, not a controlled arm
|
||||
|
||||
This table was read ad hoc from `/proc/self/status` in the two shells
|
||||
and **its runs were not captured**: no head, no cleanliness, no log,
|
||||
no digest. It agrees with the arms above and with §4c's capture, and it
|
||||
is labelled separately for that reason — it corroborates, it does not
|
||||
evidence.
|
||||
|
||||
| context | child `SigIgn` | `SIGINT` |
|
||||
|---|---|---|
|
||||
| foreground | `0000000000001000` | bit 12 (SIGPIPE) only — deliverable |
|
||||
| `setsid nohup … &` | `0000000000000007` | SIGHUP, SIGINT, SIGQUIT — ignored |
|
||||
|
||||
The raw behavioural probe underlying the selected helper (framing §7c)
|
||||
supersedes it as the *recorded* mechanism check:
|
||||
`sh -c 'trap "exit 23" 2; kill -INT $$; exit 0'` exits **23** when
|
||||
`SIGINT` is deliverable and **0** when it is inherited as ignored.
|
||||
Verified in both contexts. It is **not itself the remedy** because it
|
||||
does not preserve `kill` failure; §7c specifies the total checked-in
|
||||
helper and its 0/1/2 consumer ABI.
|
||||
|
||||
### The first D1/D2 capture, and why it is superseded
|
||||
|
||||
The capture quoted in framing §4c came from `d12.log`, which finished
|
||||
14:10 — **five minutes before `afe3631` committed the diagnostic
|
||||
code** — and ran in the reused `d0a-B` target directory rather than
|
||||
this worktree's. Its signal facts agree with the arms above, but it is
|
||||
**not admissible provenance**: uncommitted tree, foreign target. The
|
||||
arms table replaces it, and `d12.log` is retained only as the first
|
||||
sighting.
|
||||
|
||||
### Historical foreground/background mapping — RECONSTRUCTED
|
||||
|
||||
The claim that "every reduction was foreground and every full sweep was
|
||||
backgrounded" is **reconstructed from this session's transcript, not
|
||||
captured at run time**. No run before today recorded its outer
|
||||
invocation, because none of the harnesses knew it mattered. It is
|
||||
consistent with every observation and with the two arms above, but it
|
||||
is inference, and rows R1–R10 and F1–F7 carry **no outer-invocation
|
||||
field**. That gap is the direct cause of nine revisions spent on a
|
||||
confounded matrix.
|
||||
|
||||
## D0a — EXECUTED 2026-08-19. Verdict: difference NOT captured
|
||||
|
||||
Ten runs, counterbalanced `A B B A A B B A A B`, N = 5 per endpoint,
|
||||
**zero voids, zero splits**. Endpoints checked out detached and clean
|
||||
in dedicated worktrees (`pmacs-d0a-A`, `pmacs-d0a-B`), each with its own
|
||||
target directory, each run performing the gate's `build-crdt`
|
||||
precondition then the `sweep-crdt` command. `dirty=0` verified per run.
|
||||
|
||||
| run | endpoint | HEAD | class | ctrl_c ok/failed | red bins | log |
|
||||
|---|---|---|---|---|---|---|
|
||||
| A#1 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-1.log` |
|
||||
| B#1 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-1.log` |
|
||||
| B#2 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-2.log` |
|
||||
| A#2 | A | `7599661` | **red** | 0 / 2 | 4 | `d0a/A-2.log` |
|
||||
| A#3 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-3.log` |
|
||||
| B#3 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-3.log` |
|
||||
| B#4 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-4.log` |
|
||||
| A#4 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-4.log` |
|
||||
| A#5 | A | `7599661` | **red** | 0 / 2 | 3 | `d0a/A-5.log` |
|
||||
| B#5 | B | `724b785` | **red** | 0 / 2 | 2 | `d0a/B-5.log` |
|
||||
|
||||
**Exact commands.** Every run, in full. `<WT>` is
|
||||
`/home/jeans/Repos/personal/pmacs-d0a-A` (detached at `7599661`) or
|
||||
`/home/jeans/Repos/personal/pmacs-d0a-B` (detached at `724b785`);
|
||||
`<TD>` is `/home/jeans/build/pmacs-gate-targets/d0a-A` or
|
||||
`/home/jeans/build/pmacs-gate-targets/d0a-B` correspondingly:
|
||||
|
||||
```
|
||||
env TMPDIR=/home/jeans/build/pmacs-gate-targets/tmp/d0a \
|
||||
CARGO_TARGET_DIR=<TD> \
|
||||
sh -c 'cd <WT> \
|
||||
&& cargo build --workspace --no-default-features --features luajit,crdt \
|
||||
&& cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright'
|
||||
```
|
||||
|
||||
**Per-run provenance, transcribed.** All runs: `exit=101`, `dirty=0`,
|
||||
`ok=0 failed=2`, suffixes `-5d9105cb` / `-d4dae4f0`, `/tmp` 3 G of 30 G.
|
||||
Times are local 2026-08-19. `load` is the 1/5/15 average at run start;
|
||||
`MemFree`/`MemAvail` in MB; `daemons` counts live `pmacs --daemon`:
|
||||
|
||||
| start | run | class | red bins | load | MemFree | MemAvail | daemons | log sha256/16 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 12:21:20 | A#1 | red | 3 | 2.51 2.85 3.57 | 1549 | 42069 | 72 | `e1c0fe47d55d8f5e` |
|
||||
| 12:26:55 | B#1 | red | 2 | 7.86 13.48 8.97 | 9405 | 42725 | 76 | `3105794d515e6ec3` |
|
||||
| 12:32:12 | B#2 | red | 2 | 8.04 18.28 13.23 | 9761 | 43370 | 80 | `07a662fb5ca15687` |
|
||||
| 12:35:39 | A#2 | red | 4 | 11.34 21.23 16.20 | 8933 | 43281 | 84 | `450aacb9d15244c9` |
|
||||
| 12:39:35 | A#3 | red | 3 | 13.40 26.07 20.45 | 10628 | 42989 | 88 | `19156bbbc852e2d3` |
|
||||
| 12:43:26 | B#3 | red | 2 | 8.66 22.18 20.82 | 8778 | 43588 | 92 | `6f0d768a76ed9cb0` |
|
||||
| 12:47:20 | B#4 | red | 2 | 17.96 33.10 27.07 | 10403 | 43617 | 96 | `75a043a3d5568598` |
|
||||
| 12:50:49 | A#4 | red | 3 | 10.99 27.07 26.52 | 10702 | 43335 | 100 | `c1b6d88c08764d9a` |
|
||||
| 12:54:47 | A#5 | red | 3 | 9.75 28.50 28.70 | 8494 | 43053 | 104 | `2ae21a3f53fc1a1a` |
|
||||
| 12:58:38 | B#5 | red | 2 | 11.43 26.69 28.97 | 8562 | 43122 | 108 | `82c5baa0c7e49e37` |
|
||||
|
||||
**Two defects in the previous transcription of this table, recorded
|
||||
rather than silently fixed.** Every digest had lost its leading hex
|
||||
character — A#1 read `1c0fe47d55d8f5e…` where the value is
|
||||
`e1c0fe47d55d8f5e` — because the extraction started one byte late in
|
||||
`logsha=<value>`. And `/tmp` and `MemAvailable` were captured by the
|
||||
harness but dropped from the table. A transcription that silently
|
||||
corrupts its own digests is worse than a pointer to the raw file, since
|
||||
it looks verifiable and is not.
|
||||
|
||||
**`uptime` was NOT captured, and is `UNKNOWN` for all ten runs.** §7's
|
||||
condition list names `uptime`; the harness recorded only the load
|
||||
averages from it and discarded the elapsed time. The classifications
|
||||
stand — none of them depends on it — but the condition list was **not
|
||||
fully satisfied**, and D1/D2's harness must capture it. Recorded rather
|
||||
than quietly treated as met.
|
||||
|
||||
Note the leaked-daemon count climbing 72 → 108, four per run. Recorded,
|
||||
not implicated: it rises monotonically while every run classifies the
|
||||
same. Raw logs stay machine-local at
|
||||
`/home/jeans/build/pmacs-gate-targets/d0a/`; the table above is the
|
||||
portable record.
|
||||
|
||||
**Endpoint verdicts: A uniform-red, B uniform-red.** By the approved
|
||||
table this is the *both endpoints uniform the same way* row:
|
||||
|
||||
> **the difference is not captured by those two commits** under current
|
||||
> conditions, and the question becomes what else changed across the
|
||||
> window.
|
||||
|
||||
### What this settles
|
||||
|
||||
- **The two commits do not discriminate under current conditions.**
|
||||
`7599661` passed inside `sweep-crdt` on 08-15 and now fails **5/5**
|
||||
clean. **No bisect of `7599661..724b785` is justified under current
|
||||
conditions**, and none will be run.
|
||||
- **That is the whole of the causal claim.** An earlier wording here
|
||||
said "the source hypothesis is eliminated" and that "the interval
|
||||
cannot contain the transition"; both are **withdrawn**. Uniform-red
|
||||
today is silent about what was true on 08-15 — a historical source
|
||||
regression could be **masked** by a later environmental effect, or by
|
||||
a source/environment interaction that makes both commits fail now.
|
||||
Not discriminating is not the same as not differing.
|
||||
- **The onset window is deprioritised, not excluded.** It remains a
|
||||
true observation, and it remains *possible* that source matters
|
||||
within it; what is established is only that source cannot be
|
||||
probed **by this comparison, now**.
|
||||
- **A reliable reproduction now exists.** 10/10 today, on two different
|
||||
commits, at ~4 minutes per run. **This is the most useful thing D0a
|
||||
produced**: the mechanism diagnostics D1/D2 no longer depend on a
|
||||
rare event, and can proceed immediately.
|
||||
- **A's three extra failures are recorded, not swept up**:
|
||||
`a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal`,
|
||||
`one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client`, and
|
||||
`m6_1_pty_mode_lifecycle_started_then_exited`. The v21/v20 row is
|
||||
expected to differ at that older commit; the other two are
|
||||
process/PTY-spawn rows, the same family as the target. They do not
|
||||
affect classification, which reads only the two target copies.
|
||||
|
||||
### What it does not settle
|
||||
|
||||
Nothing about the mechanism. "What else changed across the window" has
|
||||
one cheap negative result so far: **no package activity in the window**
|
||||
(`/var/log/pacman.log` shows nothing between 08-15 19:57 and 08-16
|
||||
06:33; nearest is 08-18). The `1.88` rust toolchain directory has mtime
|
||||
08-15 22:39, inside the window, but the gate builds with `1.95.0`.
|
||||
Neither is pursued further, because with a reliable reproduction in
|
||||
hand **direct measurement (D1/D2) dominates archaeology**.
|
||||
|
||||
## D0 — re-run the matrix with captured provenance
|
||||
|
||||
Before any §4 row is relied on for a conclusion, re-run the reductions
|
||||
under a harness that records, per run and at the time: exact argv and
|
||||
environment, worktree, `git rev-parse HEAD`, `git status --porcelain`
|
||||
emptiness, the artifact hashes actually executed, the result, and the
|
||||
log digest. Two constraints learned the hard way:
|
||||
|
||||
- run them **at `main`**, not on a feature branch — R1–R10 ran in the
|
||||
`panel-mapping-generation` worktree, which carries §5b changes;
|
||||
- record the **artifact hash per run at run time**, since command shape
|
||||
changes it — the reason R9's result did not mean what it appeared to
|
||||
mean — and since a hash computed later reflects only what occupies
|
||||
that path now;
|
||||
- **first, reproduce the two candidate endpoints CLEANLY** —
|
||||
`7599661` (last observed green) and `724b785` (first observed red) —
|
||||
each checked out clean, each in its **own isolated target
|
||||
directory**. A decision procedure with **no predicted outcome**, and
|
||||
**not decided by one run per endpoint**. The framing's §7 D0a holds
|
||||
the governing contract — the total run classifier
|
||||
(green / red / split / void), the void budget, the endpoint table and
|
||||
the bisect-step policy. In summary, keeping the two conclusions with
|
||||
the verdicts they actually belong to:
|
||||
|
||||
- **expected-direction clean split** (`7599661` uniform green,
|
||||
`724b785` uniform red) → a bisect of `7599661..724b785` is
|
||||
permitted;
|
||||
- **inverted clean split** (`7599661` uniform red, `724b785` uniform
|
||||
green) → the commits differ, but the observed direction contradicts
|
||||
the onset reading; record it and re-examine that reading before any
|
||||
bisect;
|
||||
- **mixed at either endpoint** → the failure is **intermittent under
|
||||
fixed source**; no bisect;
|
||||
- **both endpoints uniform the same way**, green or red → **the
|
||||
difference is not captured by those two commits** under current
|
||||
conditions, and the question becomes what else changed across the
|
||||
window.
|
||||
|
||||
Revision 8 attached "the difference is not captured" to the *mixed*
|
||||
clause. That was wrong — mixed means intermittency, not absence of a
|
||||
difference — and revision 9 corrected it in the framing but **left
|
||||
this file untouched**, because the edit's anchor silently missed.
|
||||
|
|
@ -193,12 +193,9 @@ external-change detection (verify-modtime-while-open, revert?).
|
|||
|
||||
## GPU frontend mechanics (non-theme)
|
||||
|
||||
- **Input:** ~~full command/minibuffer chord forwarding to the GUI~~
|
||||
(**shipped**, `bc32332`), **Meta/Super chords — now GUI arc Stage 2**,
|
||||
not Stage 1a (Q#S1-7, 2026-08-11: forwarding them before
|
||||
capability-aware policy exists would capture platform Command/Super
|
||||
shortcuts), rebindable local `Ctrl-V`/`Escape`, middle-click paste,
|
||||
right-click context menu, frontend-local provisional selection.
|
||||
- **Input:** full command/minibuffer chord forwarding to the GUI,
|
||||
Meta/Super chords, rebindable local `Ctrl-V`/`Escape`, middle-click
|
||||
paste, right-click context menu, frontend-local provisional selection.
|
||||
- **Minibuffer:** `i/total` hint (already on the wire), Telescope-style
|
||||
preview pane, candidate kind/doc annotations, unify TUI inline vs GPU
|
||||
dropdown, multibyte-exact band caret, the nav highlight-wrap bug.
|
||||
|
|
|
|||
|
|
@ -70,8 +70,3 @@ unicode-width = "0.2"
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
# TEST-ONLY, and never a runtime dependency: step 3's panel-wheel
|
||||
# witness has to observe the RECEIVER's effect, not the event this
|
||||
# frontend emits, because the defect it exists for is precisely
|
||||
# "the frontend emits and the receiver discards".
|
||||
pmacs = { path = ".." }
|
||||
|
|
|
|||
|
|
@ -379,28 +379,6 @@ fn coalesce_kind(event: &FrontendEvent) -> Option<u8> {
|
|||
kind: MouseKind::Drag(_),
|
||||
..
|
||||
} => Some(7),
|
||||
// §5b G13a — the v25 mapped family needs its OWN tags. Falling
|
||||
// through to `None` would make pixel-rate mapped motion lossless
|
||||
// and fill the bounded queue, which is `OUTBOX_MAX` events of
|
||||
// hover before the socket closes.
|
||||
//
|
||||
// Tail-replacement takes the whole newer event, so the surviving
|
||||
// event carries the latest coordinate AND the latest
|
||||
// `mapping_generation` together — a collapsed run can never pair
|
||||
// a new coordinate with a stale generation.
|
||||
//
|
||||
// Distinct from the legacy tags 6/7 rather than shared with
|
||||
// them. A session negotiates one family and never mixes the two,
|
||||
// so sharing would buy nothing and would let a family confusion
|
||||
// collapse silently instead of showing up as two queued events.
|
||||
FrontendEvent::PanelPointerMapped {
|
||||
kind: MouseKind::Move,
|
||||
..
|
||||
} => Some(8),
|
||||
FrontendEvent::PanelPointerMapped {
|
||||
kind: MouseKind::Drag(_),
|
||||
..
|
||||
} => Some(9),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -982,36 +960,6 @@ impl AttachClient {
|
|||
}))
|
||||
}
|
||||
|
||||
/// Send a `FrontendEvent::TextInput` (GUI arc 1a): text the user
|
||||
/// committed, by keypress or IME.
|
||||
///
|
||||
/// **The caller must gate on [`Self::session_protocol_version`]
|
||||
/// `>= TEXT_INPUT_MIN_VERSION` and fall back to `Key`** — this
|
||||
/// method does not check, because the fallback needs the untranslated
|
||||
/// key and only the caller has it. Withholding is the whole
|
||||
/// old-peer contract: a `< 24` daemon keeps the behaviour it has,
|
||||
/// including today's first-scalar truncation.
|
||||
///
|
||||
/// Rejects oversize here as well as at the daemon, so a payload
|
||||
/// that could never be accepted is not written to the socket at
|
||||
/// all; rejection rather than truncation, per `TEXT_INPUT_MAX_BYTES`.
|
||||
pub fn send_text_input(&self, text: &str) -> Result<(), TransportError> {
|
||||
if text.len() > pmacs_protocol::TEXT_INPUT_MAX_BYTES {
|
||||
return Err(TransportError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"TextInput of {} bytes exceeds the {}-byte cap",
|
||||
text.len(),
|
||||
pmacs_protocol::TEXT_INPUT_MAX_BYTES
|
||||
),
|
||||
)));
|
||||
}
|
||||
self.send_event(FrontendEvent::TextInput {
|
||||
frontend_id: self.frontend_id,
|
||||
text: text.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a `FrontendEvent::Pointer` (session M-2): a locally
|
||||
/// hit-tested gesture in source bytes. Callers gate on
|
||||
/// [`Self::session_protocol_version`] `>= 5`.
|
||||
|
|
@ -1120,36 +1068,6 @@ impl AttachClient {
|
|||
})
|
||||
}
|
||||
|
||||
/// §5b — the MAPPED gesture, echoing the generation of the frame
|
||||
/// this frontend is displaying.
|
||||
///
|
||||
/// A separate entry point rather than an `Option<u64>` on the one
|
||||
/// above: the two variants are exclusive per session, and a
|
||||
/// nullable field would let a caller send the mapped family with no
|
||||
/// generation, which is the shape the daemon refuses.
|
||||
#[allow(clippy::too_many_arguments)] // mirrors `PanelPointerMapped`'s wire shape exactly.
|
||||
pub fn send_panel_pointer_mapped(
|
||||
&self,
|
||||
geometry_epoch: u64,
|
||||
panel_epoch: u64,
|
||||
buffer_id: BufferId,
|
||||
coord: CellCoord,
|
||||
kind: MouseKind,
|
||||
mods: Modifiers,
|
||||
mapping_generation: u64,
|
||||
) -> Result<(), TransportError> {
|
||||
self.send_event(FrontendEvent::PanelPointerMapped {
|
||||
frontend_id: self.frontend_id,
|
||||
geometry_epoch,
|
||||
panel_epoch,
|
||||
buffer_id,
|
||||
coord,
|
||||
kind,
|
||||
mods,
|
||||
mapping_generation,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture
|
||||
/// hit-tested locally to a panel CELL. Callers gate on
|
||||
/// [`Self::session_protocol_version`] `>= 21`.
|
||||
|
|
@ -1251,24 +1169,6 @@ impl AttachClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// Build a real [`AttachClient`] over an already-connected stream, for
|
||||
/// the GUI 1-pre effect harness in `main.rs`.
|
||||
///
|
||||
/// The harness needs to observe the protocol messages a dispatch
|
||||
/// actually produces. Faking the client would only witness the fake, so
|
||||
/// it drives the **real** handshake, outbox, writer thread and encoder
|
||||
/// over a `socketpair`, and reads the encoded `FrontendEvent`s off the
|
||||
/// other end. This wrapper exists solely because
|
||||
/// [`connect_stream_with_sink`] is private to this module and the
|
||||
/// harness is a sibling; it adds no behaviour of its own.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn connect_stream_for_test(
|
||||
stream: UnixStream,
|
||||
sink: impl Fn(AttachEvent) -> bool + Send + 'static,
|
||||
) -> Result<AttachClient, AttachClientError> {
|
||||
connect_stream_with_sink(stream, None, sink)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1504,115 +1404,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn fe_panel_mapped(kind: MouseKind, row: u32, col: u32, generation: u64) -> FrontendEvent {
|
||||
FrontendEvent::PanelPointerMapped {
|
||||
frontend_id: FrontendId(1),
|
||||
buffer_id: BufferId::from_raw(1),
|
||||
coord: CellCoord::new(row, col),
|
||||
kind,
|
||||
mods: Modifiers::NONE,
|
||||
geometry_epoch: 1,
|
||||
panel_epoch: 1,
|
||||
mapping_generation: generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// §5b G13a/G13b — the mapped family keeps the legacy family's
|
||||
/// coalescing contract: `Move` and `Drag` collapse to the latest
|
||||
/// coordinate AND generation together; press, release and every
|
||||
/// wheel kind stay lossless and ordered.
|
||||
#[test]
|
||||
fn mapped_panel_motion_coalesces_carrying_its_latest_generation() {
|
||||
let mut ob = Outbox::new();
|
||||
ob.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 0, 0, 4));
|
||||
ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 1, 4));
|
||||
ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 2, 5));
|
||||
ob.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 0, 3, 6));
|
||||
ob.enqueue(fe_panel_mapped(MouseKind::Up(MouseButton::Left), 0, 3, 6));
|
||||
assert_eq!(
|
||||
ob.queue.len(),
|
||||
3,
|
||||
"the drag run collapsed to one; without a tag of its own the \
|
||||
mapped family is lossless and this is five"
|
||||
);
|
||||
// The whole event is replaced, so coordinate and generation
|
||||
// advance TOGETHER. A tag that replaced only the coordinate
|
||||
// would leave generation 4 on a cell measured under 6, which the
|
||||
// daemon then refuses as stale.
|
||||
assert!(
|
||||
matches!(
|
||||
&ob.queue[1],
|
||||
FrontendEvent::PanelPointerMapped {
|
||||
kind: MouseKind::Drag(MouseButton::Left),
|
||||
coord: CellCoord { row: 0, col: 3 },
|
||||
mapping_generation: 6,
|
||||
..
|
||||
}
|
||||
),
|
||||
"surviving drag: {:?}",
|
||||
&ob.queue[1]
|
||||
);
|
||||
|
||||
// G13b — wheel ticks carry scroll DISTANCE. Two ticks in one
|
||||
// generation must both survive the queue, or the panel scrolls
|
||||
// once and stops.
|
||||
let mut wheel = Outbox::new();
|
||||
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollUp, 1, 1, 7));
|
||||
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollUp, 1, 1, 7));
|
||||
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollDown, 1, 1, 7));
|
||||
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollLeft, 1, 1, 7));
|
||||
wheel.enqueue(fe_panel_mapped(MouseKind::ScrollRight, 1, 1, 7));
|
||||
assert_eq!(wheel.queue.len(), 5, "every wheel kind stays lossless");
|
||||
|
||||
// Repeated presses are what the daemon reads as a multi-click,
|
||||
// and a right press is the context gesture.
|
||||
let mut presses = Outbox::new();
|
||||
presses.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 2, 2, 8));
|
||||
presses.enqueue(fe_panel_mapped(MouseKind::Down(MouseButton::Left), 2, 2, 8));
|
||||
presses.enqueue(fe_panel_mapped(
|
||||
MouseKind::Down(MouseButton::Right),
|
||||
2,
|
||||
2,
|
||||
8,
|
||||
));
|
||||
assert_eq!(presses.queue.len(), 3, "presses stay lossless");
|
||||
|
||||
// Motion does not collapse ACROSS an intervening lossless event.
|
||||
let mut mixed = Outbox::new();
|
||||
mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 1, 9));
|
||||
mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 2, 9));
|
||||
assert_eq!(mixed.queue.len(), 1);
|
||||
mixed.enqueue(fe_panel_mapped(MouseKind::ScrollDown, 3, 2, 9));
|
||||
mixed.enqueue(fe_panel_mapped(MouseKind::Move, 3, 3, 9));
|
||||
assert_eq!(
|
||||
mixed.queue.len(),
|
||||
3,
|
||||
"the wheel tick between them must not be jumped"
|
||||
);
|
||||
|
||||
// And Move does not fold into a Drag tail: they are separate
|
||||
// gestures, and one tag for both would turn a hover into part of
|
||||
// a selection.
|
||||
let mut kinds = Outbox::new();
|
||||
kinds.enqueue(fe_panel_mapped(MouseKind::Drag(MouseButton::Left), 4, 1, 9));
|
||||
kinds.enqueue(fe_panel_mapped(MouseKind::Move, 4, 2, 9));
|
||||
assert_eq!(kinds.queue.len(), 2);
|
||||
|
||||
// The two FAMILIES do not coalesce into each other either.
|
||||
let mut families = Outbox::new();
|
||||
families.enqueue(FrontendEvent::PanelPointer {
|
||||
frontend_id: FrontendId(1),
|
||||
buffer_id: BufferId::from_raw(1),
|
||||
coord: CellCoord::new(5, 1),
|
||||
kind: MouseKind::Move,
|
||||
mods: Modifiers::NONE,
|
||||
geometry_epoch: 1,
|
||||
panel_epoch: 1,
|
||||
});
|
||||
families.enqueue(fe_panel_mapped(MouseKind::Move, 5, 2, 9));
|
||||
assert_eq!(families.queue.len(), 2);
|
||||
}
|
||||
|
||||
/// Acceptance 34: terminal move/drag runs coalesce to the latest
|
||||
/// cell, while press, release, and wheel stay lossless and ordered.
|
||||
#[test]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,82 +0,0 @@
|
|||
// columns.rs --- The display-column rule both frontends reckon in.
|
||||
|
||||
//! One definition of "a display column", shared across the wire.
|
||||
//!
|
||||
//! The TUI and the GPU both need to answer "how wide is this line?" —
|
||||
//! for the caret follow, for the minimap, and for GUI Stage 1b's B7
|
||||
//! right bound. **Two copies of that answer is a defect waiting to
|
||||
//! happen**: a bound computed one way and a follow computed the other
|
||||
//! disagree about where the document ends, and the disagreement is
|
||||
//! invisible until a tab or a wide character reaches the edge.
|
||||
//!
|
||||
//! It lives here for the same reason [`crate::scroll::follow_left`]
|
||||
//! does — the protocol crate is the one place both frontends already
|
||||
//! depend on.
|
||||
//!
|
||||
//! **Scope: SOURCE-TEXT columns.** Tab stops and Unicode terminal
|
||||
//! width. Rendered projections — inline adornments, math substitutions
|
||||
//! — can occupy a different width on screen and are deliberately not
|
||||
//! counted here.
|
||||
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
/// Advance `column` past one character.
|
||||
///
|
||||
/// A tab reaches the next [`crate::TAB_STOP_COLUMNS`] stop; every other
|
||||
/// character contributes its Unicode terminal width, so control and
|
||||
/// zero-width characters do not advance.
|
||||
#[must_use]
|
||||
pub fn advance_char(column: u32, ch: char) -> u32 {
|
||||
let width = if ch == '\t' {
|
||||
crate::TAB_STOP_COLUMNS - (column % crate::TAB_STOP_COLUMNS)
|
||||
} else {
|
||||
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
|
||||
};
|
||||
column.saturating_add(width)
|
||||
}
|
||||
|
||||
/// Display width of one line, in columns.
|
||||
#[must_use]
|
||||
pub fn line_columns(line: &str) -> u32 {
|
||||
line.chars().fold(0, advance_char)
|
||||
}
|
||||
|
||||
/// Widest line in `text`, in display columns — B7's right-bound input.
|
||||
#[must_use]
|
||||
pub fn widest_line_columns(text: &str) -> u32 {
|
||||
text.split('\n').map(line_columns).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{advance_char, line_columns, widest_line_columns};
|
||||
|
||||
#[test]
|
||||
fn a_tab_reaches_the_next_stop_rather_than_advancing_one() {
|
||||
assert_eq!(advance_char(0, '\t'), crate::TAB_STOP_COLUMNS);
|
||||
assert_eq!(advance_char(1, '\t'), crate::TAB_STOP_COLUMNS);
|
||||
assert_eq!(
|
||||
advance_char(crate::TAB_STOP_COLUMNS, '\t'),
|
||||
crate::TAB_STOP_COLUMNS * 2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_and_zero_width_characters_are_measured_not_counted() {
|
||||
assert_eq!(line_columns("ab"), 2);
|
||||
assert_eq!(line_columns("漢字"), 4, "wide characters take two columns");
|
||||
assert_eq!(line_columns("a\u{200b}b"), 2, "zero-width adds nothing");
|
||||
}
|
||||
|
||||
/// The widest line, not the last one and not the first.
|
||||
#[test]
|
||||
fn widest_line_is_the_maximum_over_all_lines() {
|
||||
assert_eq!(widest_line_columns("a\nbbbb\ncc"), 4);
|
||||
assert_eq!(widest_line_columns(""), 0);
|
||||
assert_eq!(
|
||||
widest_line_columns("\tx"),
|
||||
crate::TAB_STOP_COLUMNS + 1,
|
||||
"tabs count toward the bound"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@
|
|||
//! directly.
|
||||
|
||||
pub mod cell;
|
||||
pub mod columns;
|
||||
pub mod crdt;
|
||||
pub mod ids;
|
||||
pub mod message;
|
||||
|
|
@ -67,11 +66,11 @@ pub use message::{
|
|||
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, MinibufferRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
||||
NegotiatedCapabilities, PANEL_MAPPING_MIN_VERSION, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
||||
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
||||
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, StatuslineSegment,
|
||||
StyleSegment, StyleSpan, TEXT_INPUT_MAX_BYTES, TEXT_INPUT_MIN_VERSION, ThemeFace,
|
||||
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
|
||||
negotiate_capabilities, negotiated_session_version, requested_protocol_version,
|
||||
StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name,
|
||||
is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
|
||||
negotiated_session_version, requested_protocol_version,
|
||||
};
|
||||
pub use panel::{
|
||||
MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload,
|
||||
|
|
|
|||
|
|
@ -513,129 +513,8 @@ pub enum FrontendEvent {
|
|||
/// Modifiers held during the gesture.
|
||||
mods: Modifiers,
|
||||
},
|
||||
/// Committed text from a keypress or an IME composition — GUI arc
|
||||
/// Stage 1a, **protocol v24** ([`TEXT_INPUT_MIN_VERSION`]).
|
||||
///
|
||||
/// **APPENDED, never widened.** postcard encodes an enum variant by
|
||||
/// its positional index, so adding a field to any variant above
|
||||
/// would silently re-interpret every older peer's bytes. A new
|
||||
/// variant at the end is the only backward-compatible shape, which
|
||||
/// is also why the frozen-byte pin in the tests sits on
|
||||
/// [`FrontendEvent::PanelPointer`] — the *previous* final variant —
|
||||
/// rather than on this one: an appended variant's own round-trip
|
||||
/// cannot detect a discriminant shift beneath it.
|
||||
///
|
||||
/// **This is not [`FrontendEvent::Paste`], and the difference is
|
||||
/// behavioural rather than cosmetic.** A paste is bulk data from
|
||||
/// elsewhere; this is what the user *typed*, so a terminal receives
|
||||
/// it as **raw UTF-8 and never inside bracketed-paste markers** — a
|
||||
/// shell that sees `ESC[200~` around typed input treats it as
|
||||
/// pasted, which changes how it handles newlines and completion.
|
||||
///
|
||||
/// **One `TextInput` is ONE edit**: one undo unit, one
|
||||
/// `buffer.after-edit`, one eligible CRDT op. The multi-scalar case
|
||||
/// is the whole reason the variant exists — a two-scalar grapheme
|
||||
/// arriving as two keypresses is two undo units and, worse, can be
|
||||
/// split by an intervening remote edit.
|
||||
///
|
||||
/// `text` is capped at [`TEXT_INPUT_MAX_BYTES`]; an oversize payload
|
||||
/// is **rejected, never truncated**, because truncating a UTF-8
|
||||
/// sequence at a byte boundary silently corrupts the last character
|
||||
/// and a silently-shortened insert is worse than a refused one.
|
||||
TextInput {
|
||||
/// Which frontend produced the text. **Untrusted**, like every
|
||||
/// other `frontend_id` on this enum — the daemon uses the
|
||||
/// authenticated source, not this field.
|
||||
frontend_id: FrontendId,
|
||||
/// The committed text. Non-empty; see [`TEXT_INPUT_MAX_BYTES`].
|
||||
text: String,
|
||||
},
|
||||
/// Bottom panel §5b (protocol v25): a panel-cell gesture that also
|
||||
/// names the **inverse mapping** the frontend was looking at.
|
||||
///
|
||||
/// **APPENDED LAST, after [`Self::TextInput`].** "Beside
|
||||
/// `PanelPointer`" would be adjacent insertion, which shifts every
|
||||
/// discriminant below it; postcard encodes variants positionally, so
|
||||
/// only the end of the enum is safe.
|
||||
///
|
||||
/// **Why the mapping and not the epochs.** A cell must be inverted
|
||||
/// to a byte, and the existing ladder cannot tell whether the text
|
||||
/// under that cell still means what it meant: `buffer_id` catches an
|
||||
/// A→B replacement, `panel_epoch` catches close/reopen,
|
||||
/// `geometry_epoch` catches a declaration race, and **nothing
|
||||
/// catches a foreign edit** — which moves the mapping while every
|
||||
/// epoch holds. `mapping_generation` closes exactly that.
|
||||
///
|
||||
/// **Not a per-frame token.** A token moving with each frame would
|
||||
/// invalidate a gesture on every repaint and break drags outright,
|
||||
/// which is why [`crate::panel::PanelFrame::panel_epoch`] is stable
|
||||
/// across ordinary frames. This identifies the *mapping*: it moves
|
||||
/// with the viewport, folds, wrap and gutter geometry, buffer
|
||||
/// content and terminal topology, and holds across focus, styling,
|
||||
/// selection-only repaints and absorbed cursor motion.
|
||||
///
|
||||
/// **Gated bilaterally on [`PANEL_MAPPING_MIN_VERSION`]**, and the
|
||||
/// gate is a REFUSAL rather than a fallback: a `>= v25` session
|
||||
/// sending the bare [`Self::PanelPointer`] is refused, and a
|
||||
/// `<= v24` session sending this variant is refused too. Treating
|
||||
/// either as "handled under the other family's semantics" would
|
||||
/// leave the hole reachable by choosing a discriminant.
|
||||
PanelPointerMapped {
|
||||
/// 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,
|
||||
/// The inverse mapping the frontend was displaying.
|
||||
///
|
||||
/// **Zero is invalid** and is refused like a mismatch: it is
|
||||
/// what a default-constructed or half-initialised sender
|
||||
/// produces, so accepting it would let a peer opt out of the
|
||||
/// check by sending nothing. A live key starts at 1.
|
||||
///
|
||||
/// Appended **last within the variant**, so this mirrors
|
||||
/// [`Self::PanelPointer`]'s field order exactly and the two stay
|
||||
/// diffable by eye.
|
||||
mapping_generation: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// First protocol version carrying the **mapped panel family** —
|
||||
/// [`FrontendEvent::PanelPointerMapped`] and
|
||||
/// [`crate::panel::PanelFramePayload::PresentMapped`].
|
||||
///
|
||||
/// Read as a constant rather than compared against a literal, and
|
||||
/// **not** derived from [`PROTOCOL_VERSION`]: expressing the boundary
|
||||
/// against a moving ceiling would silently drag this feature forward
|
||||
/// on the next bump.
|
||||
pub const PANEL_MAPPING_MIN_VERSION: u32 = 25;
|
||||
|
||||
/// First protocol version carrying [`FrontendEvent::TextInput`].
|
||||
///
|
||||
/// A frontend older than this **retains its existing `Key` behaviour and
|
||||
/// its existing limitations** — it truncates multi-scalar input to the
|
||||
/// first scalar today and ignores IME, and it continues to. The promise
|
||||
/// is **no regression, not retroactive correctness**: nothing a `< 24`
|
||||
/// peer already had degrades, and the daemon simply never receives a
|
||||
/// variant such a peer cannot encode.
|
||||
pub const TEXT_INPUT_MIN_VERSION: u32 = 24;
|
||||
|
||||
/// Cap on [`FrontendEvent::TextInput::text`], in bytes of UTF-8.
|
||||
///
|
||||
/// 64 KiB is far above any keystroke or IME commit and far below a
|
||||
/// pathological paste, which has its own event. **Oversize is rejected
|
||||
/// rather than truncated** — see the variant's own documentation.
|
||||
pub const TEXT_INPUT_MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Gesture step for [`FrontendEvent::Pointer`]. Double-click
|
||||
/// detection is frontend-side (`DoubleDown` instead of a second
|
||||
/// `Down`): only the frontend knows pixel proximity and its own
|
||||
|
|
@ -683,9 +562,7 @@ impl FrontendEvent {
|
|||
| Self::TerminalPointer { frontend_id, .. }
|
||||
| Self::FrontendCellGeometry { frontend_id, .. }
|
||||
| Self::PanelResizeRows { frontend_id, .. }
|
||||
| Self::PanelPointer { frontend_id, .. }
|
||||
| Self::TextInput { frontend_id, .. }
|
||||
| Self::PanelPointerMapped { frontend_id, .. } => *frontend_id,
|
||||
| Self::PanelPointer { frontend_id, .. } => *frontend_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1963,43 +1840,7 @@ pub enum ResourceBody {
|
|||
/// encoding makes an in-place widening a wire break rather than an
|
||||
/// evolution, and gating the widened form would have left those peers
|
||||
/// with no minibuffer message at all.
|
||||
///
|
||||
/// GUI arc Stage 1a: bumped 23 → 24 for
|
||||
/// [`FrontendEvent::TextInput`] — committed text from a keypress or an
|
||||
/// IME composition, carried as one event so that a multi-scalar
|
||||
/// grapheme is one edit, one undo unit and one eligible CRDT op instead
|
||||
/// of being truncated to its first scalar. Appended after
|
||||
/// `PanelPointer`, the final v23 `FrontendEvent` variant, so no
|
||||
/// existing discriminant moves.
|
||||
///
|
||||
/// **The gate is producer-side AND receiver-side**, and for an inbound
|
||||
/// variant the receiving half is the load-bearing one. An
|
||||
/// instance→frontend variant is gated by the daemon simply not sending
|
||||
/// it, which is entirely within the daemon's control; an inbound variant
|
||||
/// cannot be, because the withholding would be the *peer's* job and a
|
||||
/// client built from this same crate can encode the discriminant
|
||||
/// whatever it negotiated. So the daemon refuses `TextInput` from a
|
||||
/// session below [`TEXT_INPUT_MIN_VERSION`] rather than trusting the
|
||||
/// producer to withhold — otherwise a v6–v23 session could drive an edit
|
||||
/// through a variant its own session never declared.
|
||||
///
|
||||
/// This is not a new shape: the v19 terminal and v21 panel families
|
||||
/// already gate their own inbound events on the authenticated session's
|
||||
/// negotiated version. What is unusual here is only that the extension
|
||||
/// is **inbound-only** — there is no outbound counterpart to withhold,
|
||||
/// so the receiver check is the whole of the daemon's half.
|
||||
///
|
||||
/// Bottom panel §5b: bumped 24 → 25 for the mapped panel family,
|
||||
/// [`FrontendEvent::PanelPointerMapped`] and
|
||||
/// [`crate::panel::PanelFramePayload::PresentMapped`]. Both variants are
|
||||
/// appended after their enum's v24 final variant. The family is gated in
|
||||
/// both directions from the authenticated session's negotiated version:
|
||||
/// v6–v24 sessions use only `PanelPointer`/`Present`, while v25 sessions
|
||||
/// use only the mapped pair; choosing the other discriminant is refused,
|
||||
/// not reinterpreted. [`crate::panel::PanelFramePayload::Absent`] remains
|
||||
/// common because hiding a band carries no mapping. The advertised
|
||||
/// baseline remains 20.
|
||||
pub const PROTOCOL_VERSION: u32 = 25;
|
||||
pub const PROTOCOL_VERSION: u32 = 23;
|
||||
|
||||
/// Protocol version placed in the daemon's server-first [`Hello`].
|
||||
///
|
||||
|
|
@ -2180,23 +2021,8 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 {
|
|||
/// keeps receiving the frozen [`InstanceMessage::MinibufferPrompt`], a
|
||||
/// `>= 23` peer receives only the rows form, and no peer ever receives
|
||||
/// both. [`ADVERTISED_PROTOCOL_VERSION`] does not move.
|
||||
///
|
||||
/// GUI arc Stage 1a: extended to `[6, ..., 24]` for
|
||||
/// [`FrontendEvent::TextInput`]. Additive, and gated producer-side AND
|
||||
/// receiver-side — see [`PROTOCOL_VERSION`] for why an inbound variant
|
||||
/// cannot rely on the producer withholding. [`ADVERTISED_PROTOCOL_VERSION`] does not move: a v23
|
||||
/// frontend negotiates v23, never sends the variant, and keeps today's
|
||||
/// first-scalar behaviour.
|
||||
///
|
||||
/// Bottom panel §5b: extended to `[6, ..., 25]` for
|
||||
/// [`FrontendEvent::PanelPointerMapped`] and
|
||||
/// [`crate::panel::PanelFramePayload::PresentMapped`]. The authenticated
|
||||
/// negotiated version selects exactly one legacy/mapped family in each
|
||||
/// direction; a peer compiled from this crate can encode either variant,
|
||||
/// so receiver-side refusal is load-bearing. `Absent` is common, and
|
||||
/// [`ADVERTISED_PROTOCOL_VERSION`] remains 20.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
|
||||
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
|
||||
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
|
||||
];
|
||||
|
||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||
|
|
|
|||
|
|
@ -103,29 +103,6 @@ pub enum PanelFramePayload {
|
|||
Present(PanelFrame),
|
||||
/// No panel is visible; clear any retained frame.
|
||||
Absent,
|
||||
/// §5b (protocol v25): a visible panel **plus the identity of the
|
||||
/// inverse mapping** it was painted from.
|
||||
///
|
||||
/// **APPENDED AFTER `Absent`, not beside `Present`.** postcard
|
||||
/// encodes variants positionally, so inserting adjacent to
|
||||
/// `Present` would shift `Absent`'s discriminant and silently
|
||||
/// re-interpret every older peer's "hide the band" as something
|
||||
/// else.
|
||||
///
|
||||
/// `Absent` is deliberately **shared by both families** — hiding a
|
||||
/// band carries no mapping, so it needs no mapped twin.
|
||||
PresentMapped {
|
||||
/// The frame, reused whole and unchanged.
|
||||
frame: PanelFrame,
|
||||
/// Identity of the inverse mapping this frame was painted from.
|
||||
///
|
||||
/// A `PanelPointerMapped` echoes it, and the daemon refuses a
|
||||
/// gesture whose echo no longer matches. **Zero is invalid**;
|
||||
/// a live key starts at 1 and is **nondecreasing**, including
|
||||
/// across `Absent` — a delayed lower frame must not roll the
|
||||
/// producer's authority backward.
|
||||
mapping_generation: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Why a [`PanelFrame`] is not structurally valid.
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
#!/bin/sh
|
||||
# Is SIGINT deliverable to this process tree?
|
||||
#
|
||||
# WHY THIS EXISTS. A shell running a command in the background without
|
||||
# job control sets SIGINT (and SIGQUIT) to SIG_IGN in the child; nohup
|
||||
# adds SIGHUP. SIG_IGN is inherited across fork AND survives exec, so
|
||||
# the disposition reaches every descendant --- cargo, a test binary, and
|
||||
# anything either of them spawns. Tests that signal a child then wait
|
||||
# for it to die will hang until their own deadline and report that as a
|
||||
# teardown defect. One lane spent nine framing revisions on exactly that
|
||||
# misreading; see docs/gpu-probe-sigint-framing.md §4c.
|
||||
#
|
||||
# INTERFACE (docs/gpu-probe-sigint-framing.md §7c) --- a validated
|
||||
# (status, token) PAIR. Callers rely on both halves and MUST NOT
|
||||
# re-derive the classification:
|
||||
#
|
||||
# 0 safe stdout: pmacs-sigint-v1:safe no diagnostic
|
||||
# 1 ignored stdout: pmacs-sigint-v1:ignored canonical diagnostic
|
||||
# 2 error stdout: pmacs-sigint-v1:error distinct diagnostic
|
||||
#
|
||||
# The token is the ONLY thing on stdout; diagnostics go to stderr. Any
|
||||
# other pair --- including a plausible status with no token --- is a
|
||||
# BOUNDARY error for the caller, mapped to 2.
|
||||
#
|
||||
# WHY A TOKEN AND NOT A STATUS ALONE. CI proved a status-only ABI
|
||||
# unsound: on macOS a shell that cannot execute this file exits 1,
|
||||
# which the old ABI read as `ignored`, so a broken guard told the
|
||||
# operator their environment ignores SIGINT. No exit status can prove
|
||||
# this script ran; a token it must have printed can.
|
||||
#
|
||||
# `error` is never folded into `ignored`. "Your environment ignores
|
||||
# SIGINT" and "the guard could not run" are different problems, and
|
||||
# conflating them fails callers for the wrong reason.
|
||||
#
|
||||
# THE PROBE. A child sends itself SIGINT. Deliverable => the trap runs
|
||||
# => 23. Ignored => the kill is a no-op => the child falls through to
|
||||
# `exit 0`. The `|| exit 24` arms matter: without them a FAILED kill
|
||||
# would also fall through to `exit 0` and be misread as `ignored`,
|
||||
# which is the one wrong answer this helper exists to avoid.
|
||||
#
|
||||
# POSIX shell only --- trap, kill, $$ --- so no /proc and no sigaction:
|
||||
# the mechanism is not Linux-specific and adds no unsafe code.
|
||||
|
||||
probe_status=0
|
||||
sh -c 'trap "exit 23" 2 || exit 24; kill -INT "$$" || exit 24; exit 0' \
|
||||
|| probe_status=$?
|
||||
|
||||
case "$probe_status" in
|
||||
23)
|
||||
echo 'pmacs-sigint-v1:safe'
|
||||
exit 0
|
||||
;;
|
||||
0)
|
||||
echo 'pmacs-sigint-v1:ignored'
|
||||
echo 'pmacs: SIGINT is ignored; run this command with SIGINT deliverable' >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo 'pmacs-sigint-v1:error'
|
||||
echo "pmacs: could not determine whether SIGINT is deliverable (probe status $probe_status)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
286
scripts/gate
286
scripts/gate
|
|
@ -287,20 +287,7 @@ emit_plan() {
|
|||
# command failed.
|
||||
# ---------------------------------------------------------------------
|
||||
emit_self_test_plan() {
|
||||
# `self-pass` reports the TMPDIR its own CHILD PROCESS sees, rather
|
||||
# than `true`. The step is still a trivially-passing command with
|
||||
# its own log, so every existing assertion about it holds --- but
|
||||
# the log now carries evidence that the isolated TMPDIR reached a
|
||||
# spawned process, which is the only thing that matters. Asserting
|
||||
# the variable inside this script would prove that this script can
|
||||
# set a variable.
|
||||
#
|
||||
# SINGLE-QUOTED INSIDE `sh -c`, so the CHILD expands `$TMPDIR`.
|
||||
# With double quotes the runner's own `eval` expands it in the
|
||||
# PARENT before `sh` ever starts, and the row then passes even when
|
||||
# the variable is assigned but never EXPORTED --- which is precisely
|
||||
# the regression it exists to catch.
|
||||
printf 'self-pass\tsh -c %s\n' "'echo gate-child-tmpdir=\$TMPDIR'"
|
||||
printf 'self-pass\ttrue\n'
|
||||
printf 'build-crdt\tfalse\n'
|
||||
printf 'self-sentinel\ttrue\n'
|
||||
}
|
||||
|
|
@ -506,116 +493,6 @@ esac
|
|||
WT=$(canon "$(worktree_root)")
|
||||
cd "$WT"
|
||||
|
||||
# R-b (framing SS7c): refuse to start when SIGINT is not deliverable.
|
||||
#
|
||||
# HERE, immediately after the worktree resolves and BEFORE any log
|
||||
# directory, ambient root or temporary dir exists: a refused run should
|
||||
# leave nothing behind to clean up or mistake for evidence.
|
||||
#
|
||||
# WHY AT ALL. A shell running a command in the background without job
|
||||
# control sets SIGINT to SIG_IGN; that survives fork AND exec, so every
|
||||
# test which signals a child and waits for it hangs to its own
|
||||
# deadline. Seven red sweeps were read as a product teardown defect
|
||||
# before this was found (framing SS4c).
|
||||
#
|
||||
# The helper owns the classification and the wording; this consumer
|
||||
# re-derives neither. It continues only on 0, and otherwise stops after
|
||||
# letting the helper's stderr through untouched.
|
||||
#
|
||||
# `|| sigint_status=$?` IS LOAD-BEARING, and two wrong shapes were tried
|
||||
# before this one:
|
||||
#
|
||||
# * a bare invocation dies under `set -e` at the helper's non-zero
|
||||
# exit, so the refusal below never prints;
|
||||
# * `if ! helper; then sigint_status=$?; fi` captures the status of
|
||||
# the NEGATED condition --- always 0 --- so the guard printed the
|
||||
# diagnosis and then ran the whole gate anyway.
|
||||
#
|
||||
# In a `cmd || assignment` list the failure is handled, so `set -e` does
|
||||
# not fire and `$?` is the helper's own status. This is the same shape
|
||||
# the helper uses internally.
|
||||
#
|
||||
# NO OVERRIDE, deliberately: a run in this state cannot produce valid
|
||||
# evidence, so a flag to proceed anyway would only manufacture red gates
|
||||
# that mean nothing.
|
||||
if [ "$MODE" != plan ] && [ "$MODE" != plannamed ] && [ "$MODE" != printdir ]; then
|
||||
# The guard owns its capture directory: it runs BEFORE the log dir,
|
||||
# ambient root and GATE_TMPDIR exist, and must still leave nothing
|
||||
# behind (A8).
|
||||
capture=$(mktemp -d "${TMPDIR:-/tmp}/pmacs-sigint.XXXXXX") || {
|
||||
echo 'gate: could not create the SIGINT guard capture directory (status=unavailable token=missing)' >&2
|
||||
echo 'gate: REFUSING TO RUN --- no stage has run.' >&2
|
||||
exit 2
|
||||
}
|
||||
cleanup_sigint_capture() { rm -rf "$capture"; }
|
||||
trap cleanup_sigint_capture EXIT HUP INT TERM
|
||||
|
||||
# `|| sigint_status=$?` IS LOAD-BEARING under `set -eu`: a bare
|
||||
# invocation dies at the helper's non-zero exit and never reaches
|
||||
# the assignment. That was the originally shipped bug.
|
||||
sigint_status=0
|
||||
"$WT/scripts/check-sigint-deliverable" \
|
||||
>"$capture/out" 2>"$capture/err" || sigint_status=$?
|
||||
|
||||
# Select an expected token only for public statuses. This MUST
|
||||
# precede any use of expected_token: `set -u` is on, and an
|
||||
# out-of-range status has no expected token.
|
||||
expected_token=
|
||||
case "$sigint_status" in
|
||||
0) expected_token=pmacs-sigint-v1:safe ;;
|
||||
1) expected_token=pmacs-sigint-v1:ignored ;;
|
||||
2) expected_token=pmacs-sigint-v1:error ;;
|
||||
esac
|
||||
|
||||
# Byte comparison against both permitted encodings. Files preserve
|
||||
# every byte including NUL; a shell variable would not, and command
|
||||
# substitution's NUL handling differs between sh and zsh.
|
||||
sigint_token_ok=0
|
||||
if [ -n "$expected_token" ]; then
|
||||
printf '%s' "$expected_token" >"$capture/want"
|
||||
printf '%s\n' "$expected_token" >"$capture/want_lf"
|
||||
if cmp -s "$capture/out" "$capture/want" ||
|
||||
cmp -s "$capture/out" "$capture/want_lf"; then
|
||||
sigint_token_ok=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -s "$capture/out" ]; then
|
||||
sigint_token_state=missing
|
||||
elif [ "$sigint_token_ok" -eq 1 ]; then
|
||||
sigint_token_state=valid
|
||||
else
|
||||
sigint_token_state=unexpected
|
||||
fi
|
||||
|
||||
case "$sigint_status:$sigint_token_ok" in
|
||||
0:1)
|
||||
# The sole continuing path. Tidy up and disarm before the
|
||||
# gate installs its own, unrelated cleanup trap.
|
||||
cleanup_sigint_capture
|
||||
trap - EXIT HUP INT TERM
|
||||
;;
|
||||
1:1 | 2:1)
|
||||
# A VALIDATED verdict: the helper's stderr IS the diagnosis
|
||||
# and is surfaced unchanged.
|
||||
cat "$capture/err" >&2
|
||||
printf 'gate: REFUSING TO RUN (status=%s token=%s) --- no stage has run.\n' \
|
||||
"$sigint_status" "$sigint_token_state" >&2
|
||||
exit "$sigint_status"
|
||||
;;
|
||||
*)
|
||||
# BOUNDARY error. The captured stderr is UNTRUSTED and is
|
||||
# deliberately not surfaced: a helper exiting 1 with no
|
||||
# token but the canonical ignored wording would otherwise
|
||||
# tell the operator their environment ignores SIGINT (A6b).
|
||||
printf 'gate: SIGINT guard boundary error (status=%s token=%s)\n' \
|
||||
"$sigint_status" "$sigint_token_state" >&2
|
||||
printf 'gate: REFUSING TO RUN --- no stage has run.\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
TARGET=$(ensure_target_dir "$WT")
|
||||
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
# $$ as well as the timestamp: two invocations in the same worktree
|
||||
|
|
@ -639,169 +516,13 @@ mkdir -p "$LOGDIR"
|
|||
# reads the environment it was handed. HOME is deliberately left alone.
|
||||
AMBIENT="$TARGET/gate-ambient/$STAMP-$$"
|
||||
mkdir -p "$AMBIENT"
|
||||
|
||||
# TMPDIR: a fresh, DISK-BACKED directory per invocation, reaped with the
|
||||
# rest.
|
||||
#
|
||||
# THE HAZARD IS AN ANCESTOR MARKER, NOT A DIRTY TEMP DIRECTORY. Project
|
||||
# detection walks upward, so a stray `/tmp/.git` re-roots every
|
||||
# markerless `tempfile::tempdir()` fixture beneath it at `/tmp` --- and
|
||||
# the tests then faithfully exercise a tree of several thousand
|
||||
# unrelated entries. Seen for real: an empty `/tmp/.git` reddened two
|
||||
# LSP file-watcher tests inside a gate run on a lane whose whole
|
||||
# executable diff was in `pmacs-gpu`, a crate the failing test binary
|
||||
# does not even link.
|
||||
#
|
||||
# So the fix is NOT "clean the temp directory" --- a fresh subdirectory
|
||||
# OF `/tmp` inherits the same ancestors and the same marker. It has to
|
||||
# live somewhere with no marker above it, which the check below
|
||||
# VERIFIES rather than assumes.
|
||||
#
|
||||
# Disk-backed matters independently. `/tmp` is commonly a tmpfs, so a
|
||||
# sweep's fixtures compete with the machine for RAM rather than for
|
||||
# disk. Measured here: `/tmp` is a 30G tmpfs that reached 24G occupied,
|
||||
# leaving ~4G of 61G free, at which point process spawning became
|
||||
# unreliable and rows failed with EMPTY output --- a symptom that reads
|
||||
# like a code defect. It was not out of space. The gate root is on the
|
||||
# same filesystem as the build artifacts, which is where the space is.
|
||||
#
|
||||
# IT HANGS OFF THE GATE ROOT, NOT THE PER-WORKTREE TARGET, AND THE NAME
|
||||
# IS SHORT ON PURPOSE --- see the SUN_LEN budget below. Note this
|
||||
# parent is SHARED between worktrees and is NOT covered by `--prune`,
|
||||
# which only considers directories carrying an ownership marker. Each
|
||||
# run removes its own leaf on exit; the parent is a stable empty
|
||||
# directory.
|
||||
#
|
||||
# `HOME` is still deliberately left alone, as above.
|
||||
GATE_TMP_PARENT="$(gate_root)/tmp"
|
||||
mkdir -p "$GATE_TMP_PARENT"
|
||||
|
||||
# FRESH BY CREATION, not by hope. `tmp/$$` with `mkdir -p` silently
|
||||
# ADOPTS a leftover directory after a SIGKILL or a power loss, because
|
||||
# PIDs are reused; the run would then inherit another run's fixtures.
|
||||
# `mktemp -d` fails rather than reuses, and the template is kept short
|
||||
# because every byte here is a byte a socket path cannot use.
|
||||
GATE_TMPDIR=$(mktemp -d "$GATE_TMP_PARENT/XXXXXX") || {
|
||||
echo "gate: could not create a fresh TMPDIR under $GATE_TMP_PARENT" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# THE TRAP IS INSTALLED BEFORE THE CHECKS BELOW, deliberately. An
|
||||
# earlier draft ran the length guard first and exited on rejection with
|
||||
# both temporary areas already created and no trap armed --- so the
|
||||
# guard leaked exactly what it exists to manage, on every rejection.
|
||||
cleanup() { rm -rf "$AMBIENT" "$GATE_TMPDIR"; }
|
||||
cleanup() { rm -rf "$AMBIENT"; }
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# --- Socket-path budget ------------------------------------------------
|
||||
#
|
||||
# A Unix socket path cannot exceed SUN_LEN (108 BYTES on Linux) and the
|
||||
# suites bind sockets INSIDE TMPDIR, so TMPDIR must leave room for the
|
||||
# longest path a fixture appends. Without this the symptom is several
|
||||
# unrelated-looking socket failures deep in a suite, naming a limit
|
||||
# rather than a cause.
|
||||
#
|
||||
# THE BUDGET IS THE SUPPORTED-PLATFORM FLOOR, NOT LINUX'S. `sun_path`
|
||||
# is 108 bytes on Linux but **104 on Darwin** (xnu `bsd/sys/un.h`), and
|
||||
# pmacs supports macOS --- CI runs a `macos-latest` leg. A
|
||||
# Linux-derived limit would pass here and bind-fail there, which is the
|
||||
# worst place to discover it. **The usable PATH length is one less than
|
||||
# the array**, because the value stored in `sun_path` is
|
||||
# NUL-terminated: 103 on Darwin, 107 on Linux. This script takes 103.
|
||||
#
|
||||
# THE RESERVE IS THE MEASURED MAXIMUM PLUS HEADROOM, not a round
|
||||
# number. The longest observed is `/.tmpXXXXXX/directory-target.sock`
|
||||
# --- 33 bytes (`tests/gpu_invocation_acceptance.rs`) --- so 48 leaves
|
||||
# ~45% headroom for a longer fixture name later.
|
||||
#
|
||||
# COUNTED IN BYTES, NOT CHARACTERS. `${#var}` counts characters under a
|
||||
# UTF-8 locale while `sun_path` is byte-limited, so a multibyte path
|
||||
# would measure short and pass a check it should fail.
|
||||
SUN_LEN_BUDGET=103
|
||||
TMPDIR_SUFFIX_RESERVE=48
|
||||
GATE_TMPDIR_BYTES=$(printf '%s' "$GATE_TMPDIR" | LC_ALL=C wc -c)
|
||||
if [ "$GATE_TMPDIR_BYTES" -gt "$((SUN_LEN_BUDGET - TMPDIR_SUFFIX_RESERVE))" ]; then
|
||||
echo "gate: TMPDIR is too long for a unix socket path:" >&2
|
||||
echo "gate: $GATE_TMPDIR" >&2
|
||||
echo "gate: $GATE_TMPDIR_BYTES bytes, but a fixture needs" \
|
||||
"$TMPDIR_SUFFIX_RESERVE of the $SUN_LEN_BUDGET usable bytes" \
|
||||
"(Darwin sun_path[104] minus its NUL --- the supported floor)" >&2
|
||||
echo "gate: shorten PMACS_GATE_TARGET_ROOT (or \$HOME) and retry." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# --- Ancestor markers --------------------------------------------------
|
||||
#
|
||||
# A MANAGED ROOT IS NOT INHERENTLY MARKER-FREE, and assuming it was
|
||||
# would rebuild the original defect one directory up: a `.git` in
|
||||
# `$HOME`, any recognized marker above `$HOME/build`, or a contaminated
|
||||
# `PMACS_GATE_TARGET_ROOT` re-roots every markerless fixture again.
|
||||
# Placement under a directory the gate owns is a NECESSARY condition,
|
||||
# not a sufficient one, so the precondition is checked rather than
|
||||
# asserted.
|
||||
#
|
||||
# The list mirrors `default_markers()` in `src/project.rs`. A custom
|
||||
# marker registered at runtime is out of reach from here, which is
|
||||
# stated rather than papered over.
|
||||
#
|
||||
# PMACS_GATE_ALLOW_ANCESTOR_MARKER exists for the behaviour tests and
|
||||
# is documented test-only, exactly like PMACS_GATE_TARGET_ROOT. They
|
||||
# run the gate under a tempdir whose ancestors they do not control ---
|
||||
# on a machine whose `/tmp` carries the very marker this checks for ---
|
||||
# and their plans are synthetic (`true` / `false` / one `echo`), so no
|
||||
# markerless fixture exists for a marker to re-root. The check itself
|
||||
# is witnessed by a test that deliberately does NOT set this and
|
||||
# asserts the refusal.
|
||||
gate_marker_refusal() {
|
||||
echo "gate: a project marker sits above the gate TMPDIR:" >&2
|
||||
echo "gate: $1" >&2
|
||||
echo "gate: TMPDIR is $GATE_TMPDIR" >&2
|
||||
echo "gate: every markerless test fixture beneath it would be" >&2
|
||||
echo "gate: re-rooted at that directory. Move the gate root" >&2
|
||||
echo "gate: (PMACS_GATE_TARGET_ROOT) somewhere without one." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
if [ -z "${PMACS_GATE_ALLOW_ANCESTOR_MARKER:-}" ]; then
|
||||
# CANONICAL, AND QUOTED. Two defects an obvious loop has:
|
||||
#
|
||||
# * `for _anc in $(...)` WORD-SPLITS on IFS, so a gate root containing
|
||||
# a space is torn into fragments and the real ancestor is never
|
||||
# tested --- the check would pass on exactly the path it should
|
||||
# reject.
|
||||
# * `dirname` walks LEXICAL ancestry. `detect_project` canonicalizes,
|
||||
# so a symlinked root can hide a marker the editor plainly sees.
|
||||
# Resolving first makes the two agree.
|
||||
_anc=$(cd "$GATE_TMPDIR" 2>/dev/null && pwd -P) || _anc="$GATE_TMPDIR"
|
||||
while :; do
|
||||
for _m in Cargo.toml .luarc.json pyproject.toml go.mod deno.json \
|
||||
deno.jsonc package.json; do
|
||||
# TYPE MATTERS, and an existence-only test is wrong in both
|
||||
# directions. `match_marker` in `src/project.rs` requires `.git`
|
||||
# to be a DIRECTORY and the seven language markers to be FILES,
|
||||
# so `[ -e ]` would reject ancestors detection itself ignores.
|
||||
if [ -f "$_anc/$_m" ]; then
|
||||
gate_marker_refusal "$_anc/$_m"
|
||||
fi
|
||||
done
|
||||
# The one directory-valued marker. A git WORKTREE has a `.git`
|
||||
# FILE, which detection ignores and this must too.
|
||||
if [ -d "$_anc/.git" ]; then
|
||||
gate_marker_refusal "$_anc/.git"
|
||||
fi
|
||||
[ "$_anc" = "/" ] && break
|
||||
_anc=$(dirname "$_anc")
|
||||
done
|
||||
fi
|
||||
|
||||
export CARGO_TARGET_DIR="$TARGET"
|
||||
export XDG_CONFIG_HOME="$AMBIENT" XDG_DATA_HOME="$AMBIENT" \
|
||||
XDG_STATE_HOME="$AMBIENT" XDG_CACHE_HOME="$AMBIENT" \
|
||||
PMACS_STATE_HOME="$AMBIENT"
|
||||
# Exported once, here, so EVERY stage and every process they spawn
|
||||
# inherits it --- the stages run as children of this shell, so there is
|
||||
# no per-stage plumbing to forget.
|
||||
export TMPDIR="$GATE_TMPDIR"
|
||||
|
||||
echo "gate: worktree $WT"
|
||||
echo "gate: target dir $TARGET"
|
||||
|
|
@ -810,9 +531,6 @@ echo "gate: logs $LOGDIR"
|
|||
# this is the directory all five ambient roots point at, and the exit
|
||||
# trap removes it, so it should be gone once the run finishes.
|
||||
echo "gate: ambient $AMBIENT"
|
||||
# Printed for the same reason as the ambient root: the isolation is
|
||||
# observable, and the exit trap removes this too.
|
||||
echo "gate: tmpdir $GATE_TMPDIR"
|
||||
[ -n "$ACCEPTANCE" ] && echo "gate: acceptance $ACCEPTANCE"
|
||||
[ "$PROTOCOL" = 1 ] && echo "gate: protocol yes (CRDT build + workspace sweep added)"
|
||||
if [ "$MODE" = selftest ]; then
|
||||
|
|
|
|||
|
|
@ -317,10 +317,6 @@ pub enum JobKind {
|
|||
Parse,
|
||||
/// `dispatch_fs_read_dir` --- directory enumeration ([T M8.1]).
|
||||
FsReadDir,
|
||||
/// `dispatch_fs_walk_tree` --- one whole-tree enumeration as a
|
||||
/// single job (issue #233 D3: the watcher's per-directory job
|
||||
/// storm collapsed into one purpose per scan).
|
||||
FsWalkTree,
|
||||
/// `dispatch_fs_stat` --- single-path metadata ([T M8.1]).
|
||||
FsStat,
|
||||
/// `dispatch_fs_rename` --- atomic rename ([T M8.1]).
|
||||
|
|
@ -359,7 +355,6 @@ impl JobKind {
|
|||
JobKind::Grep => "grep",
|
||||
JobKind::Parse => "parse",
|
||||
JobKind::FsReadDir => "fs_read_dir",
|
||||
JobKind::FsWalkTree => "fs_walk_tree",
|
||||
JobKind::FsStat => "fs_stat",
|
||||
JobKind::FsRename => "fs_rename",
|
||||
JobKind::FsChmod => "fs_chmod",
|
||||
|
|
@ -1190,29 +1185,6 @@ impl AsyncRuntime {
|
|||
id
|
||||
}
|
||||
|
||||
/// Dispatch a `walk_tree(base)` job: the whole recursive tree as
|
||||
/// ONE job, entry names base-relative (issue #233 D3). The reply
|
||||
/// reuses [`ReplyKind::ReadDir`] --- the payload shape is
|
||||
/// identical and the Lua boundary needs no second conversion;
|
||||
/// [`JobKind::FsWalkTree`] still separates the two for the
|
||||
/// `*workers*` label and the purpose string. Cancellation and
|
||||
/// tolerance are [`crate::fs::walk_tree_blocking`]'s contract.
|
||||
pub fn dispatch_fs_walk_tree(&self, base: PathBuf, supersede: Option<&str>) -> JobId {
|
||||
let (id, cancel) = self.allocate(JobSpec {
|
||||
kind: JobKind::FsWalkTree,
|
||||
supersede,
|
||||
stream: None,
|
||||
resource: None,
|
||||
purpose: format!("walk_tree {}", base.display()),
|
||||
});
|
||||
let bus = self.workers.clone();
|
||||
self.pool.dispatch(move |_pool| {
|
||||
let kind = run_fs_walk_tree(&cancel, &base);
|
||||
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
/// Dispatch a `stat(path)` job. Returns one [`FsDirEntry`] of
|
||||
/// metadata for `path`. T M8.1.
|
||||
pub fn dispatch_fs_stat(&self, path: PathBuf, supersede: Option<&str>) -> JobId {
|
||||
|
|
@ -1787,16 +1759,6 @@ fn run_fs_read_dir(
|
|||
}
|
||||
}
|
||||
|
||||
fn run_fs_walk_tree(cancel: &CancellationToken, base: &Path) -> ReplyKind {
|
||||
match crate::fs::walk_tree_blocking(base, cancel) {
|
||||
Ok(listing) => ReplyKind::ReadDir(listing),
|
||||
Err(FsError::Cancelled) => ReplyKind::Cancelled,
|
||||
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
|
||||
ReplyKind::Error(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_fs_stat(cancel: &CancellationToken, path: &Path) -> ReplyKind {
|
||||
match stat_blocking(path, cancel) {
|
||||
Ok(entry) => ReplyKind::Stat(entry),
|
||||
|
|
@ -3325,11 +3287,7 @@ mod tests {
|
|||
.expect("parse handoff must hold a bundle on Complete");
|
||||
match rt.take_result(id) {
|
||||
Some(JobOutcome::Complete(JobResult::Parse { duration_ms })) => {
|
||||
assert!(
|
||||
duration_ms < 100,
|
||||
"trivial parse should be fast: took {duration_ms}ms \
|
||||
against a 100ms budget"
|
||||
);
|
||||
assert!(duration_ms < 100, "trivial parse should be fast");
|
||||
}
|
||||
other => panic!("unexpected outcome: {other:?}"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,8 +88,6 @@ fn main() {
|
|||
let mut stdout = io::stdout().lock();
|
||||
let mut crashed_after_init = false;
|
||||
let mut open_docs: HashMap<String, String> = HashMap::new();
|
||||
// `filewatchjoin` / `filewatchretire` mid-session triggers.
|
||||
let mut didchange_count: u32 = 0;
|
||||
// `fullonly` observability: counts /full responses (rid-1, rid-2…).
|
||||
let mut full_count: u32 = 0;
|
||||
loop {
|
||||
|
|
@ -356,58 +354,6 @@ fn main() {
|
|||
});
|
||||
write_frame(&mut stdout, &req);
|
||||
}
|
||||
// Issue #233 D3: `filewatchjoin` registers one watcher at
|
||||
// initialized and a SECOND one — same base, different
|
||||
// pattern — on the first didChange it receives. The
|
||||
// didChange is the test's trigger for a mid-session join,
|
||||
// which is what the join-wakes / registration-epoch /
|
||||
// queued-baseline witnesses need and no
|
||||
// at-initialized-only mode can produce.
|
||||
("initialized", _) if mode == "filewatchjoin" => {
|
||||
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
|
||||
let req = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9310,
|
||||
"method": "client/registerCapability",
|
||||
"params": { "registrations": [{
|
||||
"id": "watch-j1",
|
||||
"method": "workspace/didChangeWatchedFiles",
|
||||
"registerOptions": { "watchers": [{
|
||||
"globPattern": {
|
||||
"baseUri": format!("file://{base}"),
|
||||
"pattern": "**/*.aaa"
|
||||
},
|
||||
"kind": 7
|
||||
}] }
|
||||
}] }
|
||||
});
|
||||
write_frame(&mut stdout, &req);
|
||||
}
|
||||
// Issue #233 D3: `filewatchretire` registers at
|
||||
// initialized, UNREGISTERS on the first didChange (the
|
||||
// group's last member leaves — retirement), and
|
||||
// re-registers on the second (a fresh group whose
|
||||
// baseline folds whatever happened while unwatched).
|
||||
("initialized", _) if mode == "filewatchretire" => {
|
||||
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
|
||||
let req = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9320,
|
||||
"method": "client/registerCapability",
|
||||
"params": { "registrations": [{
|
||||
"id": "watch-r1",
|
||||
"method": "workspace/didChangeWatchedFiles",
|
||||
"registerOptions": { "watchers": [{
|
||||
"globPattern": {
|
||||
"baseUri": format!("file://{base}"),
|
||||
"pattern": "**/*.txt"
|
||||
},
|
||||
"kind": 7
|
||||
}] }
|
||||
}] }
|
||||
});
|
||||
write_frame(&mut stdout, &req);
|
||||
}
|
||||
// Issue #233 review P1 guard: `filewatchbare` registers a
|
||||
// BARE STRING with no base and no leading `/` — `*.txt`.
|
||||
// The string arm and the `filewatchflat` arm below carry the
|
||||
|
|
@ -617,63 +563,6 @@ fn main() {
|
|||
}
|
||||
}
|
||||
("textDocument/didOpen" | "textDocument/didChange", _) => {
|
||||
if method == "textDocument/didChange" {
|
||||
didchange_count += 1;
|
||||
let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
|
||||
// `filewatchjoin` / `filewatchretire`: a didChange
|
||||
// is the test's mid-session trigger; see the
|
||||
// `initialized` arms above.
|
||||
if mode == "filewatchjoin" && didchange_count == 1 {
|
||||
let req = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9311,
|
||||
"method": "client/registerCapability",
|
||||
"params": { "registrations": [{
|
||||
"id": "watch-j2",
|
||||
"method": "workspace/didChangeWatchedFiles",
|
||||
"registerOptions": { "watchers": [{
|
||||
"globPattern": {
|
||||
"baseUri": format!("file://{base}"),
|
||||
"pattern": "**/*.bbb"
|
||||
},
|
||||
"kind": 7
|
||||
}] }
|
||||
}] }
|
||||
});
|
||||
write_frame(&mut stdout, &req);
|
||||
}
|
||||
if mode == "filewatchretire" && didchange_count == 1 {
|
||||
let req = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9321,
|
||||
"method": "client/unregisterCapability",
|
||||
"params": { "unregisterations": [{
|
||||
"id": "watch-r1",
|
||||
"method": "workspace/didChangeWatchedFiles"
|
||||
}] }
|
||||
});
|
||||
write_frame(&mut stdout, &req);
|
||||
}
|
||||
if mode == "filewatchretire" && didchange_count == 2 {
|
||||
let req = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9322,
|
||||
"method": "client/registerCapability",
|
||||
"params": { "registrations": [{
|
||||
"id": "watch-r2",
|
||||
"method": "workspace/didChangeWatchedFiles",
|
||||
"registerOptions": { "watchers": [{
|
||||
"globPattern": {
|
||||
"baseUri": format!("file://{base}"),
|
||||
"pattern": "**/*.txt"
|
||||
},
|
||||
"kind": 7
|
||||
}] }
|
||||
}] }
|
||||
});
|
||||
write_frame(&mut stdout, &req);
|
||||
}
|
||||
}
|
||||
let uri = params
|
||||
.get("textDocument")
|
||||
.and_then(|t| t.get("uri"))
|
||||
|
|
|
|||
683
src/buffer.rs
683
src/buffer.rs
|
|
@ -2962,115 +2962,6 @@ mod tests {
|
|||
]
|
||||
}
|
||||
|
||||
/// Provenance of the `Edit` a [`GenOp`] produced.
|
||||
///
|
||||
/// The `crdt_op` shape invariant is keyed on THIS, not on the
|
||||
/// `Edit`'s shape alone. An `Edit` carries no provenance
|
||||
/// marker, so the classification has to be taken from the
|
||||
/// operation *before* it is applied — see the call site, where
|
||||
/// `op` is moved into `apply_capturing`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum OperationClass {
|
||||
/// `apply_edit` — `Insert` / `Delete` / `Replace`.
|
||||
Forward,
|
||||
/// `undo` / `redo`.
|
||||
History,
|
||||
}
|
||||
|
||||
impl OperationClass {
|
||||
fn of(op: &GenOp) -> Self {
|
||||
match op {
|
||||
GenOp::Insert(..) | GenOp::Delete(..) | GenOp::Replace(..) => Self::Forward,
|
||||
GenOp::Undo | GenOp::Redo => Self::History,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `crdt_op` shape invariant, over three independent axes.
|
||||
///
|
||||
/// The axes are **provenance** (forward vs. history), the
|
||||
/// **text delta** (empty vs. real), and whether a **CRDT op** is
|
||||
/// carried. They are independent, which is the whole point of
|
||||
/// this lane, so the rule is a full enumeration rather than a
|
||||
/// default with exceptions:
|
||||
///
|
||||
/// | provenance | text delta | `crdt_op` | verdict |
|
||||
/// |---|---|---|---|
|
||||
/// | forward | empty | `None` | **valid** — a syntactic no-op |
|
||||
/// | forward | empty | `Some` | **invalid** |
|
||||
/// | forward | real | `Some` | **valid** |
|
||||
/// | forward | real | `None` | **invalid** |
|
||||
/// | history | empty | `Some` | **valid** — a version-only edit |
|
||||
/// | history | empty | `None` | **invalid** |
|
||||
/// | history | real | `Some` | **valid** |
|
||||
/// | history | real | `None` | **invalid** |
|
||||
///
|
||||
/// An **empty text delta** — `range.is_empty() && inserted_len
|
||||
/// == 0` — is a SHAPE, and forward edits reach it routinely:
|
||||
/// each of the three syntactically empty `EditOp` forms produces
|
||||
/// exactly this shape. What separates the two empty-delta cases
|
||||
/// is the op. Forward, `is_no_op_edit` short-circuits before the
|
||||
/// CRDT path exists, so there is nothing to carry. History
|
||||
/// diffs two ropes; an identity replace makes them equal, so the
|
||||
/// op IS the content of the edit and **dropping it loses the
|
||||
/// version advance**. That is why the history row demands
|
||||
/// `Some` rather than merely tolerating it.
|
||||
///
|
||||
/// A present op is separately required to carry the buffer's
|
||||
/// peer id and non-empty wire bytes.
|
||||
///
|
||||
/// Returns `Err(reason)` rather than asserting, so the same
|
||||
/// predicate serves the proptest (over generated sequences) and
|
||||
/// a directed injection. The injection is not optional: the
|
||||
/// `(forward, empty, Some)` row is **unreachable from any
|
||||
/// generated forward input**, because a forward empty form
|
||||
/// short-circuits and a forward real-delta form is not empty.
|
||||
// The enumeration IS the contract. `match_same_arms` would have
|
||||
// the three `Ok(())` rows collapsed into one alternation, which
|
||||
// is exactly the conflation this lane exists to remove: it would
|
||||
// stop the table from showing that `(forward, empty, None)` and
|
||||
// `(history, empty, Some)` are valid for OPPOSITE reasons, and a
|
||||
// future reader would have no way to see which quadrant a change
|
||||
// moved.
|
||||
#[allow(clippy::match_same_arms)]
|
||||
fn check_crdt_op_shape(
|
||||
class: OperationClass,
|
||||
edit: &Edit,
|
||||
expected_peer_id: u64,
|
||||
) -> Result<(), String> {
|
||||
if let Some(op) = edit.crdt_op.as_ref() {
|
||||
if op.peer_id != expected_peer_id {
|
||||
return Err(format!(
|
||||
"peer_id must thread from CrdtState: got {}, want {expected_peer_id}",
|
||||
op.peer_id
|
||||
));
|
||||
}
|
||||
if op.bytes.is_empty() {
|
||||
return Err("wire bytes must be non-empty".to_owned());
|
||||
}
|
||||
}
|
||||
let empty_text_delta = edit.range.is_empty() && edit.inserted_len == 0;
|
||||
match (class, empty_text_delta, edit.crdt_op.is_some()) {
|
||||
(OperationClass::Forward, true, false) => Ok(()),
|
||||
(OperationClass::Forward, true, true) => Err(
|
||||
"a FORWARD edit with an empty text delta must have crdt_op = None: the \
|
||||
three syntactically empty EditOp forms short-circuit at is_no_op_edit"
|
||||
.to_owned(),
|
||||
),
|
||||
(OperationClass::History, true, true) => Ok(()),
|
||||
(OperationClass::History, true, false) => Err(
|
||||
"a HISTORY edit with an empty text delta must have crdt_op = Some: the \
|
||||
op is the version advance, and without it the edit carries nothing"
|
||||
.to_owned(),
|
||||
),
|
||||
(_, false, true) => Ok(()),
|
||||
(_, false, false) => Err(
|
||||
"an edit with a real text delta must have crdt_op = Some in CRDT mode"
|
||||
.to_owned(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// T M10.2 Day 3 helper: applies a `GenOp` and returns the
|
||||
// resulting Edit so the proptest can assert per-op shape.
|
||||
// Each op is best-effort: out-of-range positions are clamped
|
||||
|
|
@ -3121,43 +3012,35 @@ mod tests {
|
|||
/// version while leaving the materialized text unchanged, so
|
||||
/// `undo_crdt_mode` derives an EMPTY replacement edit — and
|
||||
/// still attaches the `crdt_op` that `crdt.undo()` produced.
|
||||
/// That trips the proptest's `crdt_op` shape invariant, "a
|
||||
/// no-op edit must have `crdt_op = None`".
|
||||
///
|
||||
/// **The ruling** (`docs/crdt-identity-undo-framing.md`, and
|
||||
/// this is no longer an open question): a visible TEXT delta
|
||||
/// and a CRDT-VERSION delta are INDEPENDENT dimensions of
|
||||
/// `Edit`, so the behavior is right and the *invariant* was
|
||||
/// mis-scoped. It was written for [`is_no_op_edit`], a
|
||||
/// pre-check on the forward `EditOp` that returns before the
|
||||
/// CRDT path exists; `undo_crdt_mode` and `redo_crdt_mode`
|
||||
/// never reach it. The invariant is now keyed on **provenance**
|
||||
/// — see `check_crdt_op_shape` — and still rejects this shape
|
||||
/// on the forward path, where it remains unreachable.
|
||||
///
|
||||
/// **What is established, and by what:**
|
||||
/// **What was verified about the consequences**, so the next
|
||||
/// reader does not have to redo it:
|
||||
///
|
||||
/// * content stays correct — rope and CRDT projection agree
|
||||
/// before and after (asserted below);
|
||||
/// * replicas stay converged — established by
|
||||
/// `identity_replace_history_op_replays_convergently_on_a_remote_replica`,
|
||||
/// which seeds a second replica with the forward ops and then
|
||||
/// replays the history op, asserting the materialized text
|
||||
/// **and** the version vector. Before that witness existed
|
||||
/// this comment asserted convergence from call-site
|
||||
/// inspection alone, which cannot see a lost version advance:
|
||||
/// dropping the op leaves the text identical;
|
||||
/// * every consumer of the resulting `Edit` is classified inert
|
||||
/// or permitted — the census is §4 of the framing, and
|
||||
/// `identity_replace_history_op_leaves_classified_consumers_unchanged`
|
||||
/// executes it.
|
||||
/// * replicas stay converged — both `crdt_op` consumers
|
||||
/// (`EditorCore::queue_daemon_origin_crdt_op` and the remote-op
|
||||
/// path) read `edit.crdt_op` unconditionally and do **not**
|
||||
/// short-circuit on an empty range, so the op is broadcast;
|
||||
/// * the cursor does not jump — `EditorCore::undo` only clamps
|
||||
/// to buffer length and never seeks `edit.range.start`.
|
||||
///
|
||||
/// **The empty range's location is ruled, not arbitrary.**
|
||||
/// `derive_replacement_edit` reports it at the buffer END. No
|
||||
/// consumer is harmed there, and for the one consumer whose
|
||||
/// cost depends on it — `TextView::on_edit`, which rebuilds
|
||||
/// from `line_at_offset(range.start)` — the buffer end is the
|
||||
/// cheapest possible choice. An earlier version of this comment
|
||||
/// called it genuinely arbitrary; it is weakly preferable.
|
||||
/// **The open question** is therefore whether the *invariant* is
|
||||
/// simply mis-scoped rather than the behavior being wrong. It
|
||||
/// was written for the FORWARD `apply_edit` short-circuit, which
|
||||
/// returns before ever producing an op; CRDT-mode undo/redo
|
||||
/// never reach that path. One artifact is genuinely arbitrary
|
||||
/// either way: `derive_replacement_edit` reports the empty range
|
||||
/// at the buffer END rather than at the edit site.
|
||||
///
|
||||
/// Ignored, not deleted: it documents a real, reproducible
|
||||
/// asymmetry that nothing else on `main` records, and un-ignoring
|
||||
/// it is the first step of whichever resolution wins.
|
||||
#[test]
|
||||
#[ignore = "known pre-existing main behavior; see the doc comment \
|
||||
for the verified consequences and the open question"]
|
||||
fn crdt_undo_of_an_identity_replace_reports_a_no_op_edit_carrying_an_op() {
|
||||
let mut buffer =
|
||||
Buffer::new_with_crdt(BufferId::next(), "*identity-undo*", 1).expect("crdt");
|
||||
|
|
@ -3201,484 +3084,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// C1: the fixture above must not be silently re-ignored.
|
||||
///
|
||||
/// A restored `#[ignore]` is invisible to a green suite — the
|
||||
/// run simply reports one fewer test. This reads the source and
|
||||
/// asserts the attribute's ABSENCE, which is the only form that
|
||||
/// fails rather than quietly reporting.
|
||||
#[test]
|
||||
fn the_identity_replace_fixture_carries_no_ignore_attribute() {
|
||||
const SOURCE: &str = include_str!("buffer.rs");
|
||||
const FIXTURE: &str =
|
||||
"fn crdt_undo_of_an_identity_replace_reports_a_no_op_edit_carrying_an_op";
|
||||
let at = SOURCE
|
||||
.find(FIXTURE)
|
||||
.expect("the fixture is present by name");
|
||||
let attrs: Vec<&str> = SOURCE[..at]
|
||||
.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.skip_while(|l| l.is_empty())
|
||||
.take_while(|l| l.starts_with("#["))
|
||||
.collect();
|
||||
assert!(
|
||||
attrs.iter().any(|a| a.starts_with("#[test]")),
|
||||
"the fixture should still be a #[test]: {attrs:?}"
|
||||
);
|
||||
assert!(
|
||||
!attrs.iter().any(|a| a.starts_with("#[ignore")),
|
||||
"C1: the identity-replace fixture is ignored again — {attrs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// C2a: the classifier itself, with nothing between the
|
||||
/// assertion and it.
|
||||
///
|
||||
/// Two of the three end-to-end paths mask a classifier
|
||||
/// regression (see C2b), so this row exists to be unmaskable.
|
||||
#[test]
|
||||
fn is_no_op_edit_classifies_all_three_syntactically_empty_forms() {
|
||||
assert!(is_no_op_edit(&EditOp::Insert { pos: 0, bytes: b"" }));
|
||||
assert!(is_no_op_edit(&EditOp::Delete {
|
||||
range: Range::new(0, 0)
|
||||
}));
|
||||
assert!(is_no_op_edit(&EditOp::Replace {
|
||||
range: Range::new(0, 0),
|
||||
bytes: b"",
|
||||
}));
|
||||
// …and does not over-classify: each form with any content
|
||||
// is a real edit.
|
||||
assert!(!is_no_op_edit(&EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"x"
|
||||
}));
|
||||
assert!(!is_no_op_edit(&EditOp::Delete {
|
||||
range: Range::new(0, 1)
|
||||
}));
|
||||
assert!(!is_no_op_edit(&EditOp::Replace {
|
||||
range: Range::new(0, 1),
|
||||
bytes: b"",
|
||||
}));
|
||||
}
|
||||
|
||||
/// C2b: end to end, each syntactically empty form still
|
||||
/// produces no CRDT op.
|
||||
///
|
||||
/// **This witness is masked for two of the three forms**, which
|
||||
/// is why C2a exists. `apply_to_crdt_then_normalize_bytes`
|
||||
/// returns `(None, None)` early for an empty `Insert` and an
|
||||
/// empty `Delete`, so flipping `is_no_op_edit`'s arm for either
|
||||
/// leaves `crdt_op == None` and this test still passes. Killing
|
||||
/// it there needs a compound mutant: flip the arm AND delete
|
||||
/// that variant's defensive early return. The empty `Replace`
|
||||
/// has no such return and falls through to the unconditional
|
||||
/// `Some(crdt_op)`, so there the simple mutant does die here.
|
||||
#[test]
|
||||
fn each_syntactically_empty_form_yields_no_crdt_op_end_to_end() {
|
||||
let mut b = Buffer::new_with_crdt(BufferId::next(), "*empty-forms*", 1).expect("crdt");
|
||||
b.apply_edit(EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"seed",
|
||||
})
|
||||
.expect("seed");
|
||||
|
||||
for (label, op) in [
|
||||
("Insert{bytes:[]}", EditOp::Insert { pos: 0, bytes: b"" }),
|
||||
(
|
||||
"Delete{range:empty}",
|
||||
EditOp::Delete {
|
||||
range: Range::new(1, 1),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Replace{range:empty,bytes:[]}",
|
||||
EditOp::Replace {
|
||||
range: Range::new(1, 1),
|
||||
bytes: b"",
|
||||
},
|
||||
),
|
||||
] {
|
||||
let edit = b.apply_edit(op).expect("empty form applies");
|
||||
assert!(
|
||||
edit.crdt_op.is_none(),
|
||||
"C2b: {label} must produce no CRDT op"
|
||||
);
|
||||
assert!(
|
||||
edit.range.is_empty() && edit.inserted_len == 0,
|
||||
"C2b: {label} must be version-only in shape too"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// C5: the invariant is keyed on PROVENANCE, and covers all
|
||||
/// four empty-text-delta quadrants.
|
||||
///
|
||||
/// Two of these must be a directed injection rather than a
|
||||
/// property. `(forward, empty, Some)` is **unreachable from any
|
||||
/// generated forward input** — an empty form short-circuits
|
||||
/// before the CRDT path, and a real-delta form is not empty — so
|
||||
/// the proptest alone cannot tell a narrowed rule from a deleted
|
||||
/// one. `(history, empty, None)` is equally unreachable, because
|
||||
/// `undo_crdt_mode` and `redo_crdt_mode` always attach the op;
|
||||
/// it is asserted so that a future change which stops attaching
|
||||
/// it fails here rather than silently losing version advances.
|
||||
#[test]
|
||||
fn the_shape_invariant_covers_all_four_empty_text_delta_quadrants() {
|
||||
let with_op = |op: Option<Box<crate::rope::CrdtOp>>| Edit {
|
||||
new_rope: crate::rope::Rope::from_bytes(b"hello"),
|
||||
range: Range::new(5, 5),
|
||||
inserted_len: 0,
|
||||
crdt_op: op,
|
||||
};
|
||||
let carrying = || {
|
||||
Some(Box::new(crate::rope::CrdtOp {
|
||||
peer_id: 1,
|
||||
bytes: vec![0xAB],
|
||||
}))
|
||||
};
|
||||
|
||||
// Forward + empty delta + None: a syntactic no-op. Valid.
|
||||
assert!(
|
||||
check_crdt_op_shape(OperationClass::Forward, &with_op(None), 1).is_ok(),
|
||||
"C5: a forward syntactic no-op carries no op, and that is correct"
|
||||
);
|
||||
// Forward + empty delta + Some: the original bug.
|
||||
assert!(
|
||||
check_crdt_op_shape(OperationClass::Forward, &with_op(carrying()), 1).is_err(),
|
||||
"C5: a forward edit with an empty text delta must not carry an op"
|
||||
);
|
||||
// History + empty delta + Some: a version-only edit. Valid.
|
||||
assert!(
|
||||
check_crdt_op_shape(OperationClass::History, &with_op(carrying()), 1).is_ok(),
|
||||
"C5: the same shape from undo/redo is a legitimate version advance"
|
||||
);
|
||||
// History + empty delta + None: the version advance is gone.
|
||||
assert!(
|
||||
check_crdt_op_shape(OperationClass::History, &with_op(None), 1).is_err(),
|
||||
"C5: a history edit with an empty text delta and no op carries nothing at all"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two history operations every history witness must cover.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum HistoryCase {
|
||||
Undo,
|
||||
Redo,
|
||||
}
|
||||
|
||||
impl HistoryCase {
|
||||
const ALL: [HistoryCase; 2] = [HistoryCase::Undo, HistoryCase::Redo];
|
||||
|
||||
/// The `match` is the growth guard: a new variant fails to
|
||||
/// compile here rather than going silently unexercised.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Undo => "undo",
|
||||
Self::Redo => "redo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// C6: assert the executed case set IS `{Undo, Redo}`.
|
||||
///
|
||||
/// Narrowing a parameterized loop from two cases to one
|
||||
/// ordinarily leaves a passing test — the suite just runs less,
|
||||
/// which no assertion inside the loop can notice. The expected
|
||||
/// set is spelled out literally rather than derived from
|
||||
/// `HistoryCase::ALL`, which would make the check circular.
|
||||
fn assert_executed_both_history_cases(executed: &[HistoryCase]) {
|
||||
let mut got = executed.to_vec();
|
||||
got.sort();
|
||||
got.dedup();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![HistoryCase::Undo, HistoryCase::Redo],
|
||||
"C6: the history witnesses must execute BOTH cases"
|
||||
);
|
||||
}
|
||||
|
||||
/// A buffer holding "hello" whose last edit was an identity
|
||||
/// replace — the shape whose undo and redo are version-only.
|
||||
fn seeded_identity_replace_buffer() -> Buffer {
|
||||
let mut b =
|
||||
Buffer::new_with_crdt(BufferId::next(), "*identity-history*", 1).expect("crdt");
|
||||
b.apply_edit(EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"hello",
|
||||
})
|
||||
.expect("seed insert");
|
||||
b.apply_edit(EditOp::Replace {
|
||||
range: Range::new(1, 2),
|
||||
bytes: b"e",
|
||||
})
|
||||
.expect("identity replace");
|
||||
b
|
||||
}
|
||||
|
||||
/// Run `case`'s history operation, asserting it is version-only.
|
||||
fn take_history_edit(b: &mut Buffer, case: HistoryCase) -> Edit {
|
||||
let edit = match case {
|
||||
HistoryCase::Undo => b.undo().expect("undo"),
|
||||
HistoryCase::Redo => b.redo().expect("redo"),
|
||||
};
|
||||
assert!(
|
||||
edit.range.is_empty() && edit.inserted_len == 0,
|
||||
"{}: expected a version-only edit, got {:?}/{}",
|
||||
case.label(),
|
||||
edit.range,
|
||||
edit.inserted_len
|
||||
);
|
||||
edit
|
||||
}
|
||||
|
||||
fn assert_converged(a: &Buffer, replica: &crate::crdt::CrdtState, when: &str) {
|
||||
let doc = a.crdt_state().expect("crdt");
|
||||
assert_eq!(
|
||||
doc.materialize_string(),
|
||||
replica.materialize_string(),
|
||||
"text diverged {when}"
|
||||
);
|
||||
// The VERSION is the discriminator, and `version_scalar` is
|
||||
// documented as unusable for exactly this comparison
|
||||
// (equal scalars do not imply equal states across
|
||||
// replicas). `VersionVector`'s `PartialEq` compares logical
|
||||
// content.
|
||||
assert_eq!(
|
||||
doc.version(),
|
||||
replica.version(),
|
||||
"version vector diverged {when}"
|
||||
);
|
||||
}
|
||||
|
||||
/// C3: an empty-text history op replays convergently on a
|
||||
/// REMOTE replica, for both `undo` and `redo`.
|
||||
///
|
||||
/// The existing round-trip proptest deliberately excludes
|
||||
/// history ops, because replaying one onto a replica that never
|
||||
/// saw the forward history is ill-posed. Seeding the replica
|
||||
/// with the forward ops first is what makes this case well
|
||||
/// posed — and is the shape the wire protocol actually uses.
|
||||
///
|
||||
/// **Text equality alone does not discriminate.** Dropping the
|
||||
/// history op leaves the replica's text identical, because the
|
||||
/// op advances the version without changing bytes. The version
|
||||
/// vector is what catches it.
|
||||
#[test]
|
||||
fn identity_replace_history_op_replays_convergently_on_a_remote_replica() {
|
||||
let mut executed = Vec::new();
|
||||
for case in HistoryCase::ALL {
|
||||
let mut a = Buffer::new_with_crdt(BufferId::next(), "*replay*", 1).expect("crdt");
|
||||
let replica = crate::crdt::CrdtState::new(2).expect("replica");
|
||||
|
||||
for op in [
|
||||
EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"hello",
|
||||
},
|
||||
EditOp::Replace {
|
||||
range: Range::new(1, 2),
|
||||
bytes: b"e",
|
||||
},
|
||||
] {
|
||||
let edit = a.apply_edit(op).expect("forward edit");
|
||||
let carried = edit.crdt_op.as_ref().expect("a forward edit carries an op");
|
||||
replica
|
||||
.import_updates(&carried.bytes)
|
||||
.expect("seed the replica");
|
||||
}
|
||||
assert_converged(&a, &replica, "after seeding the forward ops");
|
||||
|
||||
// Redo needs an undo first — and that undo is itself a
|
||||
// version-only edit, so it is replayed the same way.
|
||||
if case == HistoryCase::Redo {
|
||||
let undone = take_history_edit(&mut a, HistoryCase::Undo);
|
||||
replica
|
||||
.import_updates(&undone.crdt_op.as_ref().expect("op").bytes)
|
||||
.expect("replay the preparatory undo");
|
||||
assert_converged(&a, &replica, "after the preparatory undo");
|
||||
}
|
||||
|
||||
let history = take_history_edit(&mut a, case);
|
||||
let carried = history
|
||||
.crdt_op
|
||||
.as_ref()
|
||||
.expect("the history op carries a version advance");
|
||||
replica
|
||||
.import_updates(&carried.bytes)
|
||||
.expect("replay the history op");
|
||||
assert_converged(&a, &replica, case.label());
|
||||
|
||||
// Corroboration: a causally dependent op still lands.
|
||||
let follow = a
|
||||
.apply_edit(EditOp::Insert {
|
||||
pos: a.len(),
|
||||
bytes: b"!",
|
||||
})
|
||||
.expect("dependent edit");
|
||||
replica
|
||||
.import_updates(&follow.crdt_op.as_ref().expect("op").bytes)
|
||||
.expect("replay the dependent op");
|
||||
assert_converged(&a, &replica, "after a causally dependent op");
|
||||
|
||||
executed.push(case);
|
||||
}
|
||||
assert_executed_both_history_cases(&executed);
|
||||
}
|
||||
|
||||
/// C4a: the history edit is BROADCAST at all.
|
||||
///
|
||||
/// This is what makes C4b non-vacuous. C4b asserts that the
|
||||
/// classified consumers are unchanged, and "unchanged" is also
|
||||
/// what a missing broadcast produces — so the census's whole
|
||||
/// broadcast branch rests on this count.
|
||||
#[test]
|
||||
fn identity_replace_history_op_is_broadcast_to_attached_views() {
|
||||
let mut executed = Vec::new();
|
||||
for case in HistoryCase::ALL {
|
||||
let mut b = seeded_identity_replace_buffer();
|
||||
let events = std::sync::Arc::new(Mutex::new(Vec::new()));
|
||||
b.attach_view(Box::new(RecorderView {
|
||||
events: std::sync::Arc::clone(&events),
|
||||
}));
|
||||
if case == HistoryCase::Redo {
|
||||
take_history_edit(&mut b, HistoryCase::Undo);
|
||||
}
|
||||
events.lock().unwrap().clear();
|
||||
|
||||
take_history_edit(&mut b, case);
|
||||
|
||||
let broadcasts = events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, RecorderEvent::OnEdit { .. }))
|
||||
.count();
|
||||
assert_eq!(
|
||||
broadcasts,
|
||||
1,
|
||||
"C4a: {} must broadcast exactly one on_edit",
|
||||
case.label()
|
||||
);
|
||||
executed.push(case);
|
||||
}
|
||||
assert_executed_both_history_cases(&executed);
|
||||
}
|
||||
|
||||
/// C4b: §4's classification, executed.
|
||||
///
|
||||
/// The three production views that override `on_edit` are
|
||||
/// attached to one buffer, the identity-replace history op runs,
|
||||
/// and each consumer's classified outcome is asserted: the fold
|
||||
/// store and the span vector unchanged (INERT), and `ParseView`
|
||||
/// left with an identical parse and a queue that drains
|
||||
/// (PERMITTED — one degenerate `InputEdit`, describing no
|
||||
/// change).
|
||||
#[test]
|
||||
fn identity_replace_history_op_leaves_classified_consumers_unchanged() {
|
||||
use crate::overlay::{
|
||||
BufferStyleSpan, BufferStyleSpanTranslator, SharedBufferStyleSpans,
|
||||
};
|
||||
use pmacs_protocol::ByteRange;
|
||||
|
||||
let registry = crate::syntax::SyntaxRegistry::new();
|
||||
let Some(language) = registry.language("rust") else {
|
||||
panic!("the rust grammar must load for C4b");
|
||||
};
|
||||
|
||||
let mut executed = Vec::new();
|
||||
for case in HistoryCase::ALL {
|
||||
let mut b = seeded_identity_replace_buffer();
|
||||
|
||||
let folds = crate::fold::FoldRegistry::default();
|
||||
let store = folds.store_or_attach(&mut b);
|
||||
assert!(
|
||||
store.lock().unwrap().insert(ByteRange { start: 1, end: 4 }),
|
||||
"a fold to observe"
|
||||
);
|
||||
|
||||
let spans: SharedBufferStyleSpans =
|
||||
std::sync::Arc::new(Mutex::new(vec![BufferStyleSpan {
|
||||
start: 1,
|
||||
end: 4,
|
||||
style: crate::cell::Style::default(),
|
||||
}]));
|
||||
b.attach_view(Box::new(BufferStyleSpanTranslator::new(
|
||||
std::sync::Arc::clone(&spans),
|
||||
)));
|
||||
|
||||
let parse_view = crate::syntax::ParseView::new(&b, language.clone(), "rust".into());
|
||||
let handle = parse_view.handle();
|
||||
b.attach_view(Box::new(parse_view));
|
||||
|
||||
if case == HistoryCase::Redo {
|
||||
take_history_edit(&mut b, HistoryCase::Undo);
|
||||
}
|
||||
|
||||
// Baselines, taken after any preparatory op so the
|
||||
// comparison is against the state the op under test
|
||||
// actually starts from.
|
||||
let folds_before = store.lock().unwrap().folds();
|
||||
let spans_before = spans.lock().unwrap().clone();
|
||||
let source_before = handle.source_snapshot();
|
||||
let baseline = std::sync::Arc::new(
|
||||
crate::syntax::run_parse(handle.make_request()).expect("baseline parse"),
|
||||
);
|
||||
handle.install(std::sync::Arc::clone(&baseline));
|
||||
let tree_before = baseline.root_tree().root_node().to_sexp();
|
||||
assert_eq!(
|
||||
handle.pending_edit_count(),
|
||||
0,
|
||||
"the baseline parse drains the queue"
|
||||
);
|
||||
|
||||
take_history_edit(&mut b, case);
|
||||
|
||||
assert_eq!(
|
||||
store.lock().unwrap().folds(),
|
||||
folds_before,
|
||||
"C4b: FoldStoreTranslator is INERT for {}",
|
||||
case.label()
|
||||
);
|
||||
assert_eq!(
|
||||
*spans.lock().unwrap(),
|
||||
spans_before,
|
||||
"C4b: BufferStyleSpanTranslator is INERT for {}",
|
||||
case.label()
|
||||
);
|
||||
assert_eq!(
|
||||
handle.source_snapshot(),
|
||||
source_before,
|
||||
"C4b: ParseView's source mirror is unchanged for {}",
|
||||
case.label()
|
||||
);
|
||||
// The permitted effect, bounded: exactly one degenerate
|
||||
// InputEdit is queued, and the next request drains it.
|
||||
assert_eq!(
|
||||
handle.pending_edit_count(),
|
||||
1,
|
||||
"C4b: one degenerate InputEdit for {}",
|
||||
case.label()
|
||||
);
|
||||
let after = crate::syntax::run_parse(handle.make_request()).expect("parse again");
|
||||
assert_eq!(
|
||||
handle.pending_edit_count(),
|
||||
0,
|
||||
"C4b: the queue drains for {}",
|
||||
case.label()
|
||||
);
|
||||
assert_eq!(
|
||||
after.root_tree().root_node().to_sexp(),
|
||||
tree_before,
|
||||
"C4b: the parse is identical for {}",
|
||||
case.label()
|
||||
);
|
||||
|
||||
executed.push(case);
|
||||
}
|
||||
assert_executed_both_history_cases(&executed);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
// Smaller proptest case count than the default (64) to keep
|
||||
// CI overhead modest; the per-op invariant check is the
|
||||
|
|
@ -3693,10 +3098,6 @@ mod tests {
|
|||
.expect("crdt construction");
|
||||
for op in ops {
|
||||
let op_repr = format!("{op:?}");
|
||||
// C5: classify BEFORE the move. This is the only
|
||||
// point at which forward and history are
|
||||
// distinguishable; the resulting `Edit` is not.
|
||||
let class = OperationClass::of(&op);
|
||||
let edit = apply_capturing(&mut b, op);
|
||||
// Per-op invariant check: catches drift the moment
|
||||
// it happens, with the failing op visible in the
|
||||
|
|
@ -3708,13 +3109,37 @@ mod tests {
|
|||
"invariant violated after op {}: rope={:?} crdt={:?}",
|
||||
op_repr, rope, crdt
|
||||
);
|
||||
// Day 3: crdt_op shape invariant, now keyed on
|
||||
// provenance rather than on the Edit's shape alone.
|
||||
// A history-stack-empty error returns no Edit.
|
||||
if let Some(edit) = edit
|
||||
&& let Err(why) = check_crdt_op_shape(class, &edit, 1)
|
||||
{
|
||||
prop_assert!(false, "{} ({})", why, op_repr);
|
||||
// Day 3: crdt_op shape invariant.
|
||||
// - real edits in CRDT mode populate crdt_op
|
||||
// - no-op short-circuits leave crdt_op = None
|
||||
// - history-stack-empty errors return None Edit
|
||||
if let Some(edit) = edit {
|
||||
let is_no_op_edit_result =
|
||||
edit.range.is_empty() && edit.inserted_len == 0;
|
||||
if is_no_op_edit_result {
|
||||
prop_assert!(
|
||||
edit.crdt_op.is_none(),
|
||||
"no-op edit must have crdt_op = None ({})",
|
||||
op_repr
|
||||
);
|
||||
} else {
|
||||
prop_assert!(
|
||||
edit.crdt_op.is_some(),
|
||||
"non-no-op CRDT-mode edit must have crdt_op = Some ({})",
|
||||
op_repr
|
||||
);
|
||||
let crdt_op = edit.crdt_op.as_ref().unwrap();
|
||||
prop_assert_eq!(
|
||||
crdt_op.peer_id, 1,
|
||||
"peer_id must thread from CrdtState ({})",
|
||||
op_repr
|
||||
);
|
||||
prop_assert!(
|
||||
!crdt_op.bytes.is_empty(),
|
||||
"wire bytes must be non-empty ({})",
|
||||
op_repr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3952
src/daemon.rs
3952
src/daemon.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -7,23 +7,20 @@
|
|||
//! stop. Offsets are clamped to the supplied slice and offsets inside a UTF-8
|
||||
//! code point resolve to the preceding complete-code-point boundary.
|
||||
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
/// Advance `column` past one character.
|
||||
///
|
||||
/// **Delegates to [`pmacs_protocol::columns::advance_char`]**, which is
|
||||
/// where the rule lives so both frontends cannot drift apart on what a
|
||||
/// column is. This re-export keeps the existing call sites.
|
||||
/// A tab reaches the next protocol tab stop; all other characters use their
|
||||
/// Unicode terminal width. Control and zero-width characters do not advance.
|
||||
#[must_use]
|
||||
pub fn advance_char(column: u32, ch: char) -> u32 {
|
||||
pmacs_protocol::columns::advance_char(column, ch)
|
||||
}
|
||||
|
||||
/// Widest line in `text`, in display columns — B7's right bound.
|
||||
///
|
||||
/// **Delegates to [`pmacs_protocol::columns::widest_line_columns`]**,
|
||||
/// which both frontends call.
|
||||
#[must_use]
|
||||
pub fn widest_line_columns(text: &str) -> u32 {
|
||||
pmacs_protocol::columns::widest_line_columns(text)
|
||||
let width = if ch == '\t' {
|
||||
pmacs_protocol::TAB_STOP_COLUMNS - (column % pmacs_protocol::TAB_STOP_COLUMNS)
|
||||
} else {
|
||||
UnicodeWidthChar::width(ch).unwrap_or(0) as u32
|
||||
};
|
||||
column.saturating_add(width)
|
||||
}
|
||||
|
||||
/// Display width of the valid UTF-8 prefix of `bytes`.
|
||||
|
|
|
|||
2148
src/editor.rs
2148
src/editor.rs
File diff suppressed because it is too large
Load Diff
|
|
@ -478,9 +478,8 @@ pub struct CommandBoundary {
|
|||
/// the exact facts for the one consumer contract that needs them (the
|
||||
/// pairing hook): the decoded codepoint, the requested and effective
|
||||
/// ranges, and the post-edit cursor, plus a `clean` verdict (effective
|
||||
/// triple equals the request). It is ephemeral — armed by the three
|
||||
/// self-insert producers (dispatch fallback, optimistic CRDT arm, and
|
||||
/// the single-scalar `TextInput` path of GUI arc Stage 1a) for
|
||||
/// triple equals the request). It is ephemeral — armed by the two
|
||||
/// self-insert producers (dispatch fallback, optimistic CRDT arm) for
|
||||
/// exactly one `buffer.after-edit` fan-out, consumable once via
|
||||
/// `pmacs.editor.take_typed_edit()`, and cleared when the fan-out
|
||||
/// returns. Paste, programmatic mutation, manual hook runs, and a
|
||||
|
|
@ -688,10 +687,8 @@ pub struct EditorCore {
|
|||
/// query-replace twin of `search`; drives the fifth dispatcher
|
||||
/// shadow.
|
||||
query_replace: Option<QueryReplaceSession>,
|
||||
/// In-flight typed-edit arm (auto-pairing Q#AP9): set by a
|
||||
/// self-insert producer just before the edit — the dispatch
|
||||
/// fallback invoking `buffer.self-insert`, or 1a's single-scalar
|
||||
/// `TextInput` path —
|
||||
/// In-flight typed-edit arm (auto-pairing Q#AP9): set by the
|
||||
/// dispatch fallback just before it invokes `buffer.self-insert`,
|
||||
/// completed by the insert primitives, taken back by the
|
||||
/// dispatcher via [`Self::typed_edit_finish`] in the same
|
||||
/// dispatch. Never survives a dispatch cycle.
|
||||
|
|
@ -4075,7 +4072,6 @@ impl EditorCore {
|
|||
window.cursor = 0;
|
||||
window.selection = None;
|
||||
window.view_top = 0;
|
||||
window.forget_manual_horizontal_origin();
|
||||
window.goal_col = None;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -4812,19 +4808,9 @@ impl EditorCore {
|
|||
/// Declare that `fid`'s dispatch is about to invoke
|
||||
/// `buffer.self-insert` for `codepoint`: the next insert primitive
|
||||
/// whose character matches completes the [`TypedEditRecord`].
|
||||
/// Called by the self-insert producers: the dispatch fallback, and
|
||||
/// the **single-scalar** `TextInput` path (GUI arc 1a), which must
|
||||
/// be indistinguishable from a keypress downstream. Programmatic
|
||||
/// Called by the dispatch fallback only — programmatic
|
||||
/// `pmacs.command.invoke("buffer.self-insert")` deliberately never
|
||||
/// arms, so a hook run after it observes no record; nor does a
|
||||
/// MULTI-scalar `TextInput`, which is not a keystroke.
|
||||
///
|
||||
/// **Arming is only half.** Completion happens in the insert
|
||||
/// primitives ([`Self::insert_char`] /
|
||||
/// [`Self::insert_char_over_region`]) and nowhere else, so a caller
|
||||
/// that arms and then performs a generic byte insert leaves the arm
|
||||
/// holding `None` — `this_command` looks right and auto-pairing
|
||||
/// silently stops working.
|
||||
/// arms, so a hook run after it observes no record.
|
||||
pub fn typed_edit_arm(&mut self, fid: FrontendId, codepoint: char) {
|
||||
self.typed_edit_pending = Some(TypedEditPending {
|
||||
fid,
|
||||
|
|
@ -5023,25 +5009,6 @@ impl EditorCore {
|
|||
self.insert_bytes_over_region(data)
|
||||
}
|
||||
|
||||
/// GUI arc Stage 1a / A6 — insert **multi-scalar** committed text as
|
||||
/// one edit.
|
||||
///
|
||||
/// **Single-scalar text does NOT come here**; it goes through
|
||||
/// [`Self::insert_char_over_region`], which is the only path (with
|
||||
/// [`Self::insert_char`]) that completes a [`TypedEditRecord`].
|
||||
/// Routing a single scalar here would arm provenance and never
|
||||
/// complete it — see `EditorState::dispatch_text_input`.
|
||||
///
|
||||
/// Shares [`Self::insert_bytes_over_region`] with paste, which is
|
||||
/// what makes it a single `EditOp` and therefore a single undo
|
||||
/// unit, a single `buffer.after-edit`, and a single eligible CRDT
|
||||
/// op. **It deliberately does NOT touch `clipboard_slot`**: typed
|
||||
/// text was never copied, and recording it would let the next yank
|
||||
/// resurrect something the user merely typed.
|
||||
pub fn insert_text_input(&mut self, text: &str) -> Result<(), String> {
|
||||
self.insert_bytes_over_region(text.as_bytes())
|
||||
}
|
||||
|
||||
/// Shared insert/replace for paste: `Replace` over the active
|
||||
/// region, else `Insert` at the cursor. The cursor lands just past
|
||||
/// the inserted bytes and any selection is cleared. No-op insert for
|
||||
|
|
@ -5416,7 +5383,6 @@ impl EditorCore {
|
|||
win.cursor = 0;
|
||||
win.selection = None;
|
||||
win.view_top = 0;
|
||||
win.forget_manual_horizontal_origin();
|
||||
win.goal_col = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -5652,7 +5618,6 @@ impl EditorCore {
|
|||
aw.cursor = 0;
|
||||
aw.selection = None;
|
||||
aw.view_top = 0;
|
||||
aw.forget_manual_horizontal_origin();
|
||||
aw.goal_col = None;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
323
src/fs.rs
323
src/fs.rs
|
|
@ -377,144 +377,6 @@ pub fn read_dir_blocking(
|
|||
})
|
||||
}
|
||||
|
||||
/// Recursively walk `base` into ONE flat listing whose entry names are
|
||||
/// base-RELATIVE paths (`sub/dir/file.txt`). Issue #233 D3: the LSP
|
||||
/// file-watcher scan used to be one `read_dir` job per directory per
|
||||
/// tick; this is the whole tree as one job.
|
||||
///
|
||||
/// Contract, in the D3 framing's terms:
|
||||
/// - **Symlinks are recorded, never traversed.** `lstat` kind
|
||||
/// `Symlink` regardless of target, so a link cycle cannot loop the
|
||||
/// walk. A dangling or unreadable link target leaves the entry in
|
||||
/// the listing with `symlink_target = None`, the same tolerance
|
||||
/// [`read_dir_blocking`] applies.
|
||||
/// - **Cancellation is cooperative and prompt**: the token is polled
|
||||
/// once per directory and every [`READDIR_CANCEL_POLL_EVERY`]
|
||||
/// entries within one --- the cadence [`read_dir_blocking`] set.
|
||||
/// - **The root failing to open fails the walk** (the caller's
|
||||
/// live-failure arm owns it); an unreadable SUBDIRECTORY is skipped
|
||||
/// with its whole subtree, which is the Lua `scan_tree` `pcall`
|
||||
/// behaviour this primitive replaces. A non-UTF-8 entry name is
|
||||
/// skipped the same way: such a path cannot become a watcher URI,
|
||||
/// and one weird name must not take the walk down.
|
||||
///
|
||||
/// Directory entries appear in the listing (kind `dir`) so a consumer
|
||||
/// can see structure; the watcher filters them out when building
|
||||
/// signatures, as its Lua walk always did.
|
||||
pub fn walk_tree_blocking(
|
||||
base: &Path,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<FsDirListing, FsError> {
|
||||
// Checked BEFORE opening and again before returning, not only
|
||||
// inside the entry loops: an empty tree never enters a loop, so a
|
||||
// pre-cancelled queued walk would otherwise return an empty
|
||||
// SUCCESS — which the scheduler's success arm would commit as a
|
||||
// snapshot and diff into a deletion storm, the exact outcome the
|
||||
// live non-success arm exists to prevent. (And a missing root
|
||||
// must report Cancelled, not its Io error, for the same reason.)
|
||||
if cancel.is_cancelled() {
|
||||
return Err(FsError::Cancelled);
|
||||
}
|
||||
let root = std::fs::read_dir(base).map_err(|source| FsError::Io {
|
||||
path: base.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let mut out: Vec<FsDirEntry> = Vec::new();
|
||||
// Subdirectories discovered but not yet walked, with their
|
||||
// base-relative prefixes. LIFO order --- traversal order is not
|
||||
// part of the contract; the consumer diffs a map.
|
||||
let mut pending: Vec<(std::path::PathBuf, String)> = Vec::new();
|
||||
walk_one_dir(root, "", cancel, &mut out, &mut pending)?;
|
||||
while let Some((dir, prefix)) = pending.pop() {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(FsError::Cancelled);
|
||||
}
|
||||
// Subtree skip on an unreadable directory: scan_tree's pcall.
|
||||
let Ok(iter) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
walk_one_dir(iter, &prefix, cancel, &mut out, &mut pending)?;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(FsError::Cancelled);
|
||||
}
|
||||
Ok(FsDirListing {
|
||||
entries: out,
|
||||
errors: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Test-only seam: invoked after every entry [`walk_one_dir`]
|
||||
/// records. A MID-walk cancellation is an interleaving no real
|
||||
/// timing produces on demand (the `_after_scan_for_tests`
|
||||
/// justification, at this layer): the hook lets a test flip the
|
||||
/// cancel token at an exact entry boundary and assert the walk
|
||||
/// stopped NEAR it --- which is what discriminates the internal
|
||||
/// polls from the entry/exit checks alone. `None` outside the one
|
||||
/// test that arms it.
|
||||
static WALK_ENTRY_HOOK: std::cell::RefCell<Option<Box<dyn FnMut()>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// One directory's worth of [`walk_tree_blocking`]: record every
|
||||
/// representable entry under its base-relative name and queue child
|
||||
/// directories. Only cancellation propagates as an error --- every
|
||||
/// per-entry failure is a skip, per the walk's tolerance contract.
|
||||
fn walk_one_dir(
|
||||
iter: std::fs::ReadDir,
|
||||
prefix: &str,
|
||||
cancel: &CancellationToken,
|
||||
out: &mut Vec<FsDirEntry>,
|
||||
pending: &mut Vec<(std::path::PathBuf, String)>,
|
||||
) -> Result<(), FsError> {
|
||||
for (i, entry_result) in iter.enumerate() {
|
||||
if i % READDIR_CANCEL_POLL_EVERY == 0 && cancel.is_cancelled() {
|
||||
return Err(FsError::Cancelled);
|
||||
}
|
||||
let Ok(entry) = entry_result else { continue };
|
||||
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
let rel = if prefix.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{prefix}/{name}")
|
||||
};
|
||||
let entry_path = entry.path();
|
||||
let Ok(metadata) = std::fs::symlink_metadata(&entry_path) else {
|
||||
continue;
|
||||
};
|
||||
let kind = classify(&metadata);
|
||||
if matches!(kind, FsEntryKind::Dir) {
|
||||
pending.push((entry_path.clone(), rel.clone()));
|
||||
}
|
||||
let mut symlink_target = None;
|
||||
if matches!(kind, FsEntryKind::Symlink)
|
||||
&& let Ok(target) = std::fs::read_link(&entry_path)
|
||||
{
|
||||
symlink_target = target.to_str().map(ToOwned::to_owned);
|
||||
}
|
||||
out.push(FsDirEntry {
|
||||
name: rel,
|
||||
kind,
|
||||
size: metadata.len(),
|
||||
mtime_secs: mtime_to_unix_secs(&metadata),
|
||||
mtime_nsec: mtime_to_unix_nsec(&metadata),
|
||||
mode: mode_bits(&metadata),
|
||||
symlink_target,
|
||||
});
|
||||
#[cfg(test)]
|
||||
WALK_ENTRY_HOOK.with(|h| {
|
||||
if let Some(hook) = h.borrow_mut().as_mut() {
|
||||
hook();
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Route one per-entry failure: append it to the tolerant channel, or
|
||||
/// propagate it when the caller asked for the fatal contract.
|
||||
///
|
||||
|
|
@ -1068,189 +930,4 @@ mod tests {
|
|||
other => panic!("expected NonUtf8Path, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_tree_flattens_with_relative_names() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("a.txt"), b"hello").expect("write");
|
||||
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
|
||||
std::fs::write(td.path().join("sub/b.txt"), b"xy").expect("write");
|
||||
std::fs::create_dir(td.path().join("sub/deep")).expect("mkdir");
|
||||
std::fs::write(td.path().join("sub/deep/c.txt"), b"z").expect("write");
|
||||
let listing = walk_tree_blocking(td.path(), &token()).expect("walk");
|
||||
assert!(listing.errors.is_none());
|
||||
let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
names.sort_unstable();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["a.txt", "sub", "sub/b.txt", "sub/deep", "sub/deep/c.txt"]
|
||||
);
|
||||
let c = listing
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.name == "sub/deep/c.txt")
|
||||
.unwrap();
|
||||
assert_eq!(c.kind, FsEntryKind::File);
|
||||
assert_eq!(c.size, 1);
|
||||
let sub = listing.entries.iter().find(|e| e.name == "sub").unwrap();
|
||||
assert_eq!(sub.kind, FsEntryKind::Dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_tree_records_symlink_without_traversing() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
|
||||
// A link back to the root: traversal would loop forever, so
|
||||
// completing at all is half the witness.
|
||||
symlink(td.path(), td.path().join("loop")).expect("symlink");
|
||||
let listing = walk_tree_blocking(td.path(), &token()).expect("walk");
|
||||
let link = listing.entries.iter().find(|e| e.name == "loop").unwrap();
|
||||
assert_eq!(link.kind, FsEntryKind::Symlink);
|
||||
assert!(
|
||||
!listing.entries.iter().any(|e| e.name.starts_with("loop/")),
|
||||
"a symlinked directory must be recorded, never entered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_tree_skips_unreadable_subdirectory_and_keeps_the_rest() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("a.txt"), b"x").expect("write");
|
||||
std::fs::create_dir(td.path().join("locked")).expect("mkdir");
|
||||
std::fs::write(td.path().join("locked/hidden.txt"), b"x").expect("write");
|
||||
std::fs::set_permissions(
|
||||
td.path().join("locked"),
|
||||
std::fs::Permissions::from_mode(0o000),
|
||||
)
|
||||
.expect("chmod 000");
|
||||
let result = walk_tree_blocking(td.path(), &token());
|
||||
// Restore before asserting so the tempdir can be removed even
|
||||
// if an assertion fails.
|
||||
std::fs::set_permissions(
|
||||
td.path().join("locked"),
|
||||
std::fs::Permissions::from_mode(0o755),
|
||||
)
|
||||
.expect("chmod back");
|
||||
let listing = result.expect("walk must survive an unreadable subdirectory");
|
||||
let names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert!(names.contains(&"a.txt"));
|
||||
assert!(
|
||||
names.contains(&"locked"),
|
||||
"the dir entry itself is representable"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"locked/hidden.txt"),
|
||||
"the unreadable subtree is skipped, matching scan_tree's pcall"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_tree_polls_cancellation_token() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("a.txt"), b"x").expect("write");
|
||||
let cancel = token();
|
||||
cancel.cancel();
|
||||
let err = walk_tree_blocking(td.path(), &cancel).expect_err("must observe cancel");
|
||||
assert!(matches!(err, FsError::Cancelled));
|
||||
}
|
||||
|
||||
/// Review blocker: the entry loops never run on an EMPTY tree, so
|
||||
/// without the entry/exit checks a pre-cancelled queued walk
|
||||
/// returned an empty SUCCESS — which the scheduler would commit as
|
||||
/// a snapshot and diff into a deletion storm. A missing root must
|
||||
/// likewise report Cancelled, not its Io error.
|
||||
#[test]
|
||||
fn walk_tree_pre_cancelled_is_cancelled_even_with_no_entries() {
|
||||
let empty = tempfile::tempdir().expect("tempdir");
|
||||
let cancel = token();
|
||||
cancel.cancel();
|
||||
let err = walk_tree_blocking(empty.path(), &cancel)
|
||||
.expect_err("empty tree must still observe cancel");
|
||||
assert!(matches!(err, FsError::Cancelled));
|
||||
|
||||
let err = walk_tree_blocking(&empty.path().join("absent"), &cancel)
|
||||
.expect_err("missing root must still observe cancel");
|
||||
assert!(
|
||||
matches!(err, FsError::Cancelled),
|
||||
"cancellation outranks the root error: got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Review blocker: both prior cancellation tests pre-cancelled, so
|
||||
/// deleting the INTERNAL polls (per directory, and every
|
||||
/// [`READDIR_CANCEL_POLL_EVERY`] entries) left every test green —
|
||||
/// the walk ran to completion and only the exit check fired. This
|
||||
/// cancels MID-walk through the test hook and asserts the walk
|
||||
/// stopped near the cancellation point, not at the end.
|
||||
#[test]
|
||||
fn walk_tree_observes_cancellation_mid_walk() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
for d in 0..3 {
|
||||
let dir = td.path().join(format!("d{d}"));
|
||||
std::fs::create_dir(&dir).expect("mkdir");
|
||||
for i in 0..41 {
|
||||
std::fs::write(dir.join(format!("f{i:02}.txt")), b"x").expect("write");
|
||||
}
|
||||
}
|
||||
let cancel = token();
|
||||
let seen = std::rc::Rc::new(std::cell::Cell::new(0usize));
|
||||
{
|
||||
let seen = seen.clone();
|
||||
let cancel = cancel.clone();
|
||||
WALK_ENTRY_HOOK.with(|h| {
|
||||
*h.borrow_mut() = Some(Box::new(move || {
|
||||
let n = seen.get() + 1;
|
||||
seen.set(n);
|
||||
if n == 5 {
|
||||
cancel.cancel();
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
let result = walk_tree_blocking(td.path(), &cancel);
|
||||
WALK_ENTRY_HOOK.with(|h| *h.borrow_mut() = None);
|
||||
assert!(
|
||||
matches!(result, Err(FsError::Cancelled)),
|
||||
"mid-walk cancel must surface as Cancelled"
|
||||
);
|
||||
// 126 entries total (3 dirs + 123 files). The cancel lands at
|
||||
// entry 5 (root's three dir entries, then two files), so the
|
||||
// per-entry poll stops the walk at exactly 35 = 3 + 32 (one
|
||||
// READDIR_CANCEL_POLL_EVERY stride). The bound sits BELOW 44:
|
||||
// with the per-entry poll deleted, the 41-file directory runs
|
||||
// to completion and the per-directory poll catches at 44 --- a
|
||||
// bound of 60 could not tell the two apart (review round 3).
|
||||
assert!(
|
||||
seen.get() < 40,
|
||||
"the walk must stop within one poll stride of the cancel \
|
||||
({} of 126 entries processed; expected 35)",
|
||||
seen.get()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_tree_fails_when_the_root_cannot_open() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
let err = walk_tree_blocking(&td.path().join("absent"), &token())
|
||||
.expect_err("missing root must fail the walk");
|
||||
assert!(matches!(err, FsError::Io { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_tree_matches_read_dir_on_a_flat_directory() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("a.txt"), b"hello").expect("write");
|
||||
std::fs::write(td.path().join("b.md"), b"xy").expect("write");
|
||||
let mut walked = walk_tree_blocking(td.path(), &token())
|
||||
.expect("walk")
|
||||
.entries;
|
||||
let mut listed = read_dir_fatal(td.path(), &token()).expect("read_dir");
|
||||
walked.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
listed.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
// At depth zero the relative name IS the basename, so the two
|
||||
// primitives must agree entry-for-entry --- the signature
|
||||
// parity the D3 framing requires.
|
||||
assert_eq!(walked, listed);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7364,16 +7364,6 @@ pub fn install_async(
|
|||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let rt = runtime.clone();
|
||||
async_mod.set(
|
||||
"_dispatch_fs_walk_tree",
|
||||
lua.create_function(move |_, (base, key): (String, Option<String>)| {
|
||||
Ok(rt.dispatch_fs_walk_tree(std::path::PathBuf::from(base), key.as_deref()))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let rt = runtime.clone();
|
||||
async_mod.set(
|
||||
|
|
|
|||
|
|
@ -753,52 +753,6 @@ mod tests {
|
|||
virt.render(&buf, viewport(1, 5), &mut grid);
|
||||
}
|
||||
|
||||
/// C4c: the empty/empty guard's own contract, pinned where it
|
||||
/// can actually fire.
|
||||
///
|
||||
/// **The history edit this guard was written for cannot test it.**
|
||||
/// That edit sits at the buffer END, and with
|
||||
/// `old_start == old_end == len` the loop below emits a left
|
||||
/// fragment `[s, min(e, len)) == [s, e)` for every span within the
|
||||
/// buffer and no right fragment — the vector is unchanged with or
|
||||
/// without the guard, so deleting the guard is a surviving mutant
|
||||
/// there. An INTERIOR empty edit is where the fragmenting the
|
||||
/// guard prevents is reachable: a span straddling the position
|
||||
/// splits into two adjacent fragments covering the same bytes.
|
||||
///
|
||||
/// So the assertion is on the span VECTOR, not on coverage.
|
||||
#[test]
|
||||
fn an_interior_empty_edit_does_not_fragment_a_straddled_span() {
|
||||
let buf = Buffer::from_bytes(BufferId::next(), "t", b"abcdef");
|
||||
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
|
||||
start: 1,
|
||||
end: 5,
|
||||
style: red(),
|
||||
}]));
|
||||
let before = store.lock().unwrap().clone();
|
||||
let mut translator = BufferStyleSpanTranslator::new(Arc::clone(&store));
|
||||
|
||||
// A synthetic 0→0 edit strictly inside the span. Built by hand:
|
||||
// no forward EditOp produces this shape, and the history edit
|
||||
// that does produce it lands at the buffer end.
|
||||
let interior = crate::rope::Edit {
|
||||
new_rope: buf.snapshot_rope(),
|
||||
range: crate::rope::Range::new(3, 3),
|
||||
inserted_len: 0,
|
||||
crdt_op: None,
|
||||
};
|
||||
translator
|
||||
.on_edit(&buf, &interior)
|
||||
.expect("the translator accepts the edit");
|
||||
|
||||
assert_eq!(
|
||||
*store.lock().unwrap(),
|
||||
before,
|
||||
"C4c: an interior no-op edit must leave the span vector \
|
||||
byte-identical, not split it into adjacent fragments"
|
||||
);
|
||||
}
|
||||
|
||||
fn red() -> Style {
|
||||
Style {
|
||||
fg: crate::cell::Color::Indexed(1),
|
||||
|
|
|
|||
234
src/protocol.rs
234
src/protocol.rs
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[test]
|
||||
fn protocol_version_is_twenty_five_for_the_mapped_panel_family() {
|
||||
fn protocol_version_is_twenty_three_for_minibuffer_prompt_rows() {
|
||||
// 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
|
||||
|
|
@ -1740,12 +1740,7 @@ mod tests {
|
|||
// and gating the wider form would have left them with no
|
||||
// minibuffer at all. `MinibufferPrompt` is therefore frozen and
|
||||
// pinned by literal bytes below.
|
||||
//
|
||||
// v24 is `FrontendEvent::TextInput` (GUI arc Stage 1a) and v25
|
||||
// is the mapped panel family (bottom-panel §5b) — both APPENDED
|
||||
// variants, which is why the freeze above survives them
|
||||
// untouched: nothing in `MinibufferPrompt`'s encoding moved.
|
||||
assert_eq!(PROTOCOL_VERSION, 25);
|
||||
assert_eq!(PROTOCOL_VERSION, 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1822,20 +1817,18 @@ mod tests {
|
|||
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
|
||||
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
|
||||
// v20 (semantic initial-target bootstrap), v21 (the bottom
|
||||
// panel band), v22 (`LineWrapFacts`), v23
|
||||
// (`MinibufferPromptRows`), v24 (`TextInput`, GUI arc Stage 1a)
|
||||
// and v25 (`PanelPointerMapped` + `PresentMapped`, bottom-panel
|
||||
// §5b) all interoperate.
|
||||
for accepted in 6..=PROTOCOL_VERSION {
|
||||
// panel band), v22 (`LineWrapFacts`), and v23
|
||||
// (`MinibufferPromptRows`) all interoperate.
|
||||
for accepted in 6..=23 {
|
||||
assert!(
|
||||
is_supported_protocol_version(accepted),
|
||||
"v{accepted} must be accepted"
|
||||
);
|
||||
}
|
||||
for rejected in [0, 1, 2, 3, 4, 5, PROTOCOL_VERSION + 1, u32::MAX] {
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 24, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by the current binary"
|
||||
"v{rejected} must be rejected by a v23 binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1973,204 +1966,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_pointer_encoding_is_unchanged_by_the_v24_build() {
|
||||
// GUI arc Stage 1a — the placement pin for `TextInput`, and it
|
||||
// sits on `PanelPointer` rather than on `TextInput` itself for
|
||||
// a structural reason: `TextInput` is APPENDED, so its own
|
||||
// round-trip is identical whether or not a variant was inserted
|
||||
// beneath it. Only the PREVIOUS final variant's bytes move, so
|
||||
// only they can witness the shift.
|
||||
//
|
||||
// `PanelPointer` was the last `FrontendEvent` variant at v23.
|
||||
// Inserting anything before it shifts its discriminant and
|
||||
// breaks **v21–v23 panel traffic** — `PanelPointer` arrived at
|
||||
// v21 and is gated, so that is its whole population, not every
|
||||
// peer and not every session. Stated exactly rather than
|
||||
// dramatically: an earlier version of this comment claimed
|
||||
// every v6–v23 daemon on every session, which is false and
|
||||
// would send the next reader hunting a larger blast radius than
|
||||
// exists.
|
||||
let ev = FrontendEvent::PanelPointer {
|
||||
frontend_id: FrontendId(2),
|
||||
geometry_epoch: 1,
|
||||
panel_epoch: 1,
|
||||
buffer_id: pmacs_protocol::BufferId::from_raw(4),
|
||||
coord: CellCoord { row: 0, col: 0 },
|
||||
kind: pmacs_protocol::MouseKind::Move,
|
||||
mods: Modifiers::NONE,
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&ev).expect("encode");
|
||||
assert_eq!(
|
||||
bytes,
|
||||
[15, 2, 1, 1, 4, 0, 0, 3, 0],
|
||||
"PanelPointer's v23 wire bytes changed — a variant was \
|
||||
inserted before it; append new FrontendEvent variants at \
|
||||
the end"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G0a — the **previous-final `FrontendEvent`** is now
|
||||
/// `TextInput`, and it gets its own exact pin.
|
||||
///
|
||||
/// Pins ACCUMULATE rather than move. The `PanelPointer` pin above
|
||||
/// still protects the v21–v23 population; this one protects v24's,
|
||||
/// and appending `PanelPointerMapped` is exactly the edit that would
|
||||
/// shift it if it were inserted rather than appended.
|
||||
#[test]
|
||||
fn text_input_encoding_is_unchanged_by_the_v25_build() {
|
||||
let ev = FrontendEvent::TextInput {
|
||||
frontend_id: FrontendId(2),
|
||||
text: "hi".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&ev).expect("encode"),
|
||||
[16, 2, 2, 104, 105],
|
||||
"TextInput's v24 wire bytes changed — a variant was inserted \
|
||||
before it; append new FrontendEvent variants at the end"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G0a — the **previous-final `PanelFramePayload`** is `Absent`,
|
||||
/// pinned through its real nesting inside `InstanceMessage`.
|
||||
///
|
||||
/// Nested deliberately: `PanelFramePayload` never travels alone, and
|
||||
/// a pin on the bare enum would miss a shift in the message
|
||||
/// discriminant that carries it.
|
||||
#[test]
|
||||
fn absent_panel_payload_encoding_is_unchanged_by_the_v25_build() {
|
||||
let msg = InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent);
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&msg).expect("encode"),
|
||||
[28, 1],
|
||||
"Absent's wire bytes changed — a PanelFramePayload variant \
|
||||
was inserted before it, or InstanceMessage's discriminant \
|
||||
moved; append PresentMapped AFTER Absent"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G0b — exact **encode and decode** for the mapped pointer.
|
||||
///
|
||||
/// **Every adjacent same-typed field carries a different value**, so
|
||||
/// a reordering is visible. `geometry_epoch`, `panel_epoch` and
|
||||
/// `mapping_generation` are all `u64` and all distinct (3, 7, 11);
|
||||
/// `coord.row`/`coord.col` likewise (5, 9). A round-trip alone would
|
||||
/// stay green through any swap of those — it is self-consistent by
|
||||
/// construction — which is why the exact bytes are asserted in both
|
||||
/// directions.
|
||||
#[test]
|
||||
fn panel_pointer_mapped_encodes_and_decodes_exactly() {
|
||||
const WIRE: [u8; 11] = [17, 2, 3, 7, 4, 5, 9, 0, 0, 0, 11];
|
||||
let ev = FrontendEvent::PanelPointerMapped {
|
||||
frontend_id: FrontendId(2),
|
||||
geometry_epoch: 3,
|
||||
panel_epoch: 7,
|
||||
buffer_id: pmacs_protocol::BufferId::from_raw(4),
|
||||
coord: CellCoord { row: 5, col: 9 },
|
||||
kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left),
|
||||
mods: Modifiers::NONE,
|
||||
mapping_generation: 11,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&ev).expect("encode"),
|
||||
WIRE,
|
||||
"PanelPointerMapped's v25 wire bytes changed — it must be \
|
||||
APPENDED after TextInput, with mapping_generation last"
|
||||
);
|
||||
let decoded: FrontendEvent = postcard::from_bytes(&WIRE).expect("decode");
|
||||
assert_eq!(
|
||||
decoded, ev,
|
||||
"the frozen historical bytes must decode back to the same \
|
||||
value — round-trip alone would only witness the current \
|
||||
encoder and decoder agreeing with each other"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G0b — exact **encode and decode** for the mapped frame,
|
||||
/// through its real nesting.
|
||||
///
|
||||
/// Reordering named variant fields still compiles and changes
|
||||
/// postcard's positional bytes, so the exact pin is load-bearing.
|
||||
/// The frame's own `panel_epoch` (8) and `geometry_epoch` (12) are
|
||||
/// adjacent `u64`s and are pinned distinct for the same reason as
|
||||
/// above.
|
||||
#[test]
|
||||
fn present_mapped_encodes_and_decodes_exactly() {
|
||||
const WIRE: [u8; 33] = [
|
||||
28, 2, 6, 8, 12, 1, 2, 2, 0, 1, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 32, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 1, 13,
|
||||
];
|
||||
let frame = pmacs_protocol::panel::PanelFrame {
|
||||
buffer_id: pmacs_protocol::BufferId::from_raw(6),
|
||||
panel_epoch: 8,
|
||||
geometry_epoch: 12,
|
||||
size: CellSize::new(1, 2),
|
||||
cells: vec![Cell::default(); 2],
|
||||
cursor: None,
|
||||
focused: true,
|
||||
};
|
||||
let msg =
|
||||
InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::PresentMapped {
|
||||
frame,
|
||||
mapping_generation: 13,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&msg).expect("encode"),
|
||||
WIRE,
|
||||
"PresentMapped's v25 wire bytes changed — it must be APPENDED \
|
||||
after Absent, with mapping_generation last"
|
||||
);
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&WIRE).expect("decode");
|
||||
assert_eq!(decoded, msg, "the frozen bytes must decode back");
|
||||
}
|
||||
|
||||
/// §5b G14a — the version constants move together, and the
|
||||
/// advertised baseline does not move at all.
|
||||
#[test]
|
||||
fn the_mapping_slice_takes_v25_and_the_advertised_baseline_stays_pinned() {
|
||||
assert_eq!(pmacs_protocol::PROTOCOL_VERSION, 25);
|
||||
assert_eq!(pmacs_protocol::PANEL_MAPPING_MIN_VERSION, 25);
|
||||
assert!(
|
||||
pmacs_protocol::SUPPORTED_PROTOCOL_VERSIONS.contains(&pmacs_protocol::PROTOCOL_VERSION),
|
||||
"a wire version the handshake does not support fails against \
|
||||
this daemon's own peers"
|
||||
);
|
||||
assert_eq!(
|
||||
pmacs_protocol::ADVERTISED_PROTOCOL_VERSION,
|
||||
20,
|
||||
"the advertised baseline is PERMANENT — it is not bumped to \
|
||||
chase the wire version"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G14b — the supported set's boundary, and the family boundary,
|
||||
/// are both literal.
|
||||
#[test]
|
||||
fn the_supported_set_ends_at_the_current_wire_version() {
|
||||
assert!(pmacs_protocol::is_supported_protocol_version(25));
|
||||
assert!(
|
||||
!pmacs_protocol::is_supported_protocol_version(26),
|
||||
"an unreleased version must not negotiate"
|
||||
);
|
||||
// The family boundary is stated against a LITERAL, not against
|
||||
// `PROTOCOL_VERSION`: expressing it arithmetically would drag
|
||||
// this feature forward on the next bump, so v24 sessions would
|
||||
// silently start being offered a family they never negotiated.
|
||||
//
|
||||
// Asserted as equality rather than `24 < MIN`, which clippy
|
||||
// correctly calls a compile-time tautology — it holds for every
|
||||
// value above 24 and so pins nothing.
|
||||
assert_eq!(25, pmacs_protocol::PANEL_MAPPING_MIN_VERSION);
|
||||
assert_ne!(
|
||||
pmacs_protocol::PANEL_MAPPING_MIN_VERSION,
|
||||
pmacs_protocol::TEXT_INPUT_MIN_VERSION,
|
||||
"the mapped family must not share v24's gate — that would \
|
||||
admit it on sessions that negotiated only TextInput"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_family_round_trips_and_pins_its_discriminants() {
|
||||
let bid = pmacs_protocol::BufferId::from_raw(9);
|
||||
|
|
@ -2943,20 +2738,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn m4_6_handshake_accepts_v6_peer() {
|
||||
// ANCHORED ON THE LITERAL 6, deliberately. The body used to
|
||||
// assert `is_supported_protocol_version(PROTOCOL_VERSION)` —
|
||||
// "the current wire accepts itself" — which is a different and
|
||||
// much weaker claim than the name and the M4.6 contract make:
|
||||
// **v6 is the FLOOR**, the oldest peer the handshake still
|
||||
// admits, and it must keep being accepted no matter how far the
|
||||
// ceiling moves. Written against the moving constant, the test
|
||||
// would have gone on passing after v6 was dropped from the
|
||||
// supported set, which is the only regression it exists to
|
||||
// catch. Found when the v24 bump made it fail for the unrelated
|
||||
// reason that `SUPPORTED_PROTOCOL_VERSIONS` had not been widened.
|
||||
assert!(
|
||||
is_supported_protocol_version(6),
|
||||
"v6 is the floor and must stay accepted"
|
||||
is_supported_protocol_version(PROTOCOL_VERSION),
|
||||
"the current wire version must accept itself"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
35
src/rope.rs
35
src/rope.rs
|
|
@ -301,27 +301,6 @@ impl<'a> Iterator for Chunks<'a> {
|
|||
/// A pure insert has `range.start == range.end` and `inserted_len > 0`.
|
||||
/// A pure delete has `range.start < range.end` and `inserted_len == 0`.
|
||||
/// A replace has both nonzero.
|
||||
/// An **empty text delta** has `range.start == range.end` and
|
||||
/// `inserted_len == 0` — no bytes changed at all.
|
||||
///
|
||||
/// That last shape is produced on BOTH paths, and `crdt_op` is what
|
||||
/// tells them apart:
|
||||
///
|
||||
/// * **forward** `apply_edit` reaches it whenever the `EditOp` is one of
|
||||
/// the three syntactically empty forms (an empty insert, an
|
||||
/// empty-range delete, an empty-range/empty-bytes replace). Those
|
||||
/// short-circuit before the CRDT path exists, so `crdt_op` is `None`
|
||||
/// — nothing happened;
|
||||
/// * **CRDT-mode `undo`/`redo`** reach it when the operation being
|
||||
/// inverted was itself a textual no-op (replacing bytes with
|
||||
/// identical bytes). Here `crdt_op` is `Some`, and **must be**: a CRDT
|
||||
/// VERSION delta is a separate dimension from a TEXT delta, and the
|
||||
/// op is the whole content of such an edit.
|
||||
///
|
||||
/// The history case's `range` sits at the buffer end, which is where
|
||||
/// `derive_replacement_edit` reports a no-difference diff; see
|
||||
/// `docs/crdt-identity-undo-framing.md` for the consumer census that
|
||||
/// ruled that location harmless.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Edit {
|
||||
/// The rope after the edit. `Send + Sync`; safe to hand to a worker.
|
||||
|
|
@ -333,17 +312,9 @@ pub struct Edit {
|
|||
/// T M10.2 Day 3: optional CRDT-op metadata.
|
||||
///
|
||||
/// `Some` when this Edit was produced by a CRDT-backed Buffer's
|
||||
/// edit path (`apply_edit` / `undo` / `redo`); `None` otherwise —
|
||||
/// in v0.1 mode (no CRDT), and in CRDT mode for the three
|
||||
/// syntactically empty `EditOp` forms, which `is_no_op_edit`
|
||||
/// short-circuits before the CRDT path runs.
|
||||
///
|
||||
/// An empty text delta is therefore NOT by itself a `None` signal:
|
||||
/// forward, it means the edit short-circuited and `crdt_op` is
|
||||
/// `None`; from `undo`/`redo` it means an identity operation was
|
||||
/// inverted, and `crdt_op` is `Some` — and must be, or the version
|
||||
/// advance the replicas need is lost. See the shape list on
|
||||
/// [`Edit`] above.
|
||||
/// edit path (`apply_edit` / `undo` / `redo`); `None` otherwise — both
|
||||
/// in v0.1 mode (no CRDT) and for no-op edits in CRDT mode (an
|
||||
/// empty insert at an empty range produces no CRDT op).
|
||||
///
|
||||
/// `Box` indirection: keeps Edit's None-case cost to 8 bytes
|
||||
/// (Box has a niche-optimized None) rather than the ~32 bytes
|
||||
|
|
|
|||
|
|
@ -158,42 +158,6 @@ fn minibuffer_window<T: Clone>(candidates: &[T], selected: Option<usize>) -> (Ve
|
|||
(window, selected_in_window)
|
||||
}
|
||||
|
||||
/// §5b G5 — what an accepted panel `Down` established, and what a
|
||||
/// cancellation must therefore terminate.
|
||||
///
|
||||
/// A release has to MATCH the press it ends: same button, same
|
||||
/// encoding, at a coordinate the child was actually told about. A
|
||||
/// cancellation that guessed any of those would put bytes in a child's
|
||||
/// input stream describing an event that never happened.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct AcceptedPanelGesture {
|
||||
/// The button the accepted `Down` carried.
|
||||
pub button: pmacs_protocol::MouseButton,
|
||||
/// The last coordinate known valid for this gesture — where a
|
||||
/// release is delivered when the pointer has since left content.
|
||||
pub coord: pmacs_protocol::CellCoord,
|
||||
/// The panel buffer the gesture belongs to.
|
||||
pub buffer_id: BufferId,
|
||||
/// The domain the accepted press RESOLVED INTO, and which every
|
||||
/// tail and the completion must follow (G5k).
|
||||
///
|
||||
/// This replaces a bare `reached_child` flag. The flag said whether
|
||||
/// a release was owed to a child but not how to frame it, nor which
|
||||
/// window to address, so a tail had to re-derive both from state
|
||||
/// that moves mid-gesture — Shift, the scroll position, the child's
|
||||
/// modes, and the panel's own identity. Recording the resolution
|
||||
/// is what makes the tail independent of all four.
|
||||
pub domain: crate::editor::PanelGestureDomain,
|
||||
}
|
||||
|
||||
impl AcceptedPanelGesture {
|
||||
/// Whether the press reached the child, so a release is OWED to it.
|
||||
#[must_use]
|
||||
pub fn reached_child(&self) -> bool {
|
||||
self.domain.reached_child()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns one `semantic_render` session's projection state: the last
|
||||
/// viewport the frontend declared, and the diff baseline per buffer
|
||||
/// for the `StyleSpans` and `Decorations` families.
|
||||
|
|
@ -290,15 +254,6 @@ pub struct SemanticRenderState {
|
|||
/// rows form. Also gates the per-row detail lookup: a peer that
|
||||
/// cannot carry a detail does not pay to resolve one.
|
||||
peer_knows_minibuffer_rows: bool,
|
||||
/// §5b — whether the peer negotiated `>= v25` and therefore takes
|
||||
/// the **mapped** panel family.
|
||||
///
|
||||
/// The families are exclusive in both directions: a `>= v25` peer
|
||||
/// receives `PresentMapped` and never legacy `Present`, and a
|
||||
/// `<= v24` peer the reverse. "Send whichever and let the receiver
|
||||
/// cope" would make negotiation a sender convention rather than a
|
||||
/// gate.
|
||||
peer_knows_mapped_panel: bool,
|
||||
/// Last emitted `CompletionPopup` payload per buffer (Arc 1a
|
||||
/// Q#C5), for cached-compare suppression (see
|
||||
/// [`CompletionPopupFacts`]).
|
||||
|
|
@ -422,76 +377,6 @@ pub struct SemanticRenderState {
|
|||
/// retains its last valid frame and silence would leave a stale band
|
||||
/// on screen indefinitely (Q#BP15).
|
||||
last_panel_payload: Option<PanelFramePayload>,
|
||||
/// §5b — the authoritative **cell-mapping key** for this frontend.
|
||||
///
|
||||
/// `(fingerprint, generation)`. The generation advances whenever the
|
||||
/// fingerprint changes, and **both projection and inbound
|
||||
/// validation read it through the same accessor**, so "what the
|
||||
/// frontend was shown" and "what the daemon checks" cannot drift.
|
||||
///
|
||||
/// It is deliberately **not** recomputed from the last emitted
|
||||
/// frame: a mapping mutation that has not yet been painted has still
|
||||
/// changed the inverse, and a gesture arriving in that gap must be
|
||||
/// refused. Advancing on demand at both seams is what makes
|
||||
/// "advances before the next inbound pointer, whether or not
|
||||
/// anything rendered" true rather than aspirational.
|
||||
///
|
||||
/// **Nondecreasing, and never cleared** — not even by `Absent`. A
|
||||
/// delayed lower frame must not roll the producer's authority
|
||||
/// backward, so this is a high-water mark for the session.
|
||||
/// `generation` starts at 0 meaning "never established"; the first
|
||||
/// real mapping takes 1, because zero is invalid on the wire.
|
||||
panel_mapping: Option<(crate::editor::PanelMappingSnapshot, u64)>,
|
||||
/// §5b G11a — set once the mapping generation cannot advance, and
|
||||
/// never cleared for the session.
|
||||
///
|
||||
/// Exhaustion fails **closed**: no key means no authority, so the
|
||||
/// band is published `Absent` and every inbound panel event is
|
||||
/// refused. The latch is what makes that permanent. Without it the
|
||||
/// next projection finds an UNCHANGED snapshot, takes the
|
||||
/// "unchanged" arm, and hands back the frozen ceiling — resurrecting
|
||||
/// the band with a key that can no longer distinguish anything.
|
||||
panel_mapping_exhausted: bool,
|
||||
/// §5b G5 — the panel gesture this frontend has an ACCEPTED `Down`
|
||||
/// for, if any.
|
||||
///
|
||||
/// Per frontend, never global: two frontends can hold gestures on
|
||||
/// distinct panels at once, and one global slot would make either
|
||||
/// one's authority loss cancel or erase the other's (G5p).
|
||||
///
|
||||
/// Cancellation needs this to exist at all. Without a record of
|
||||
/// what was accepted there is nothing to terminate: a release must
|
||||
/// match the press it ends, and a stale `Up` with no accepted
|
||||
/// `Down` must be inert rather than synthesising one.
|
||||
accepted_gesture: Option<AcceptedPanelGesture>,
|
||||
/// §5b/parent 48 — a release this frontend is OWED, parked until
|
||||
/// somewhere that can deliver it.
|
||||
///
|
||||
/// Two of the three cancellation sites are inside frame production
|
||||
/// — the mapping-generation advance and `publish_absent_panel` —
|
||||
/// where no target effect can run. `cancel_accepted_gesture`
|
||||
/// returned the record into those contexts and they dropped it, so
|
||||
/// the gesture ended with the child still holding its button.
|
||||
///
|
||||
/// **A SLOT, not a queue.** The latch holds at most one gesture per
|
||||
/// frontend, so at most one release can be owed, and the bound is
|
||||
/// structural rather than a cap someone had to choose. The drain
|
||||
/// runs ahead of the next panel-pointer effect, so a second
|
||||
/// cancellation cannot arrive while one is still parked.
|
||||
pending_release: Option<AcceptedPanelGesture>,
|
||||
/// §5b — how many armed gestures an authority loss has ended this
|
||||
/// session.
|
||||
///
|
||||
/// A COUNT, not a queue of records. The records are what replay
|
||||
/// consumes to deliver each release, and replay is the branch that
|
||||
/// introduces their reader — a queue landed here would grow one
|
||||
/// entry per cancelled drag for the life of the daemon with nothing
|
||||
/// ever draining it. A saturating count is bounded and still
|
||||
/// distinguishes the two ways a latch empties: an ordinary `Up`
|
||||
/// consumes it and leaves this alone, an authority loss ends it and
|
||||
/// bumps it. It also does not collapse two losses in one dispatcher
|
||||
/// burst the way a boolean flag would.
|
||||
panel_gesture_cancellations: u64,
|
||||
/// Highest presentation epoch allocated for this session; `0` means
|
||||
/// none has been. Advanced only when a frame is actually shipped, so
|
||||
/// a frame that fails validation does not burn an identity the peer
|
||||
|
|
@ -532,15 +417,6 @@ struct PanelPresentation {
|
|||
window_id: WindowId,
|
||||
buffer_id: BufferId,
|
||||
panel_epoch: u64,
|
||||
/// The geometry epoch this presentation was shipped under.
|
||||
///
|
||||
/// Retained for parent 48's authority-loss matrix: a geometry
|
||||
/// change at an UNCHANGED `CellSize` moves nothing else the
|
||||
/// producer holds — not the panel epoch, not the identity, and on a
|
||||
/// legacy peer not a mapping key either — so without this the
|
||||
/// transition is invisible and a live gesture survives a grid it no
|
||||
/// longer belongs to.
|
||||
geometry_epoch: u64,
|
||||
}
|
||||
|
||||
/// One [`SemanticRenderState::diag_line_cache`] entry: the line-start
|
||||
|
|
@ -640,15 +516,11 @@ impl SemanticRenderState {
|
|||
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
||||
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
||||
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
|
||||
s.peer_knows_mapped_panel =
|
||||
negotiated_protocol_version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION;
|
||||
s
|
||||
}
|
||||
|
||||
/// Fresh session state for frontend `frontend_id`: no viewport
|
||||
/// declared, nothing sent. Assumes a current-build peer (>= 18, and
|
||||
/// current for every later capability too, including §5b's mapped
|
||||
/// panel family);
|
||||
/// declared, nothing sent. Assumes a current-build peer (>= 18);
|
||||
/// daemon sessions with a real negotiated version use
|
||||
/// [`Self::for_peer`].
|
||||
#[must_use]
|
||||
|
|
@ -664,10 +536,6 @@ impl SemanticRenderState {
|
|||
last_menu_prompt: HashMap::new(),
|
||||
last_minibuffer: None,
|
||||
peer_knows_minibuffer_rows: true,
|
||||
// A current-build peer, like every other capability here.
|
||||
// Leaving this `false` made `new()` contradict its own doc
|
||||
// and emit the LEGACY family to an implicitly v25 peer.
|
||||
peer_knows_mapped_panel: true,
|
||||
last_completion_popup: HashMap::new(),
|
||||
last_summary: HashMap::new(),
|
||||
last_status: HashMap::new(),
|
||||
|
|
@ -707,11 +575,6 @@ impl SemanticRenderState {
|
|||
// peer already holds. Seeding the baseline keeps the first
|
||||
// frame from shipping a redundant authoritative `Absent`.
|
||||
last_panel_payload: Some(PanelFramePayload::Absent),
|
||||
panel_mapping: None,
|
||||
panel_mapping_exhausted: false,
|
||||
accepted_gesture: None,
|
||||
pending_release: None,
|
||||
panel_gesture_cancellations: 0,
|
||||
panel_epoch_used: 0,
|
||||
panel_presentation: None,
|
||||
panel_error_latched: false,
|
||||
|
|
@ -729,261 +592,11 @@ impl SemanticRenderState {
|
|||
#[must_use]
|
||||
pub fn panel_declaration(&self) -> Option<&PanelFrame> {
|
||||
match self.last_panel_payload.as_ref()? {
|
||||
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. } => {
|
||||
Some(frame)
|
||||
}
|
||||
PanelFramePayload::Present(frame) => Some(frame),
|
||||
PanelFramePayload::Absent => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel payload most recently shipped, for the family rows.
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn last_panel_payload_for_test(&self) -> Option<PanelFramePayload> {
|
||||
self.last_panel_payload.clone()
|
||||
}
|
||||
|
||||
/// §5b — how many armed gestures an authority loss has ended.
|
||||
///
|
||||
/// Counts rather than collapses: two losses in one dispatcher burst
|
||||
/// read as two. Coincident causes on ONE transition are a different
|
||||
/// matter — those take the latch once, so the count moves once.
|
||||
#[must_use]
|
||||
pub fn panel_gesture_cancellations(&self) -> u64 {
|
||||
self.panel_gesture_cancellations
|
||||
}
|
||||
|
||||
/// §5b — arm the latch, and ONLY for an accepted left press.
|
||||
///
|
||||
/// A right press opens a menu and ends there; `Move`, wheel and the
|
||||
/// other buttons begin nothing. Arming on any accepted pointer
|
||||
/// event would let one of them manufacture a delayed release at the
|
||||
/// next authority loss.
|
||||
///
|
||||
/// This is the SUBSTRATE for the framing's G5g. G5g itself — the
|
||||
/// table-driven non-gesture events followed by an authority loss,
|
||||
/// asserted to emit no synthetic release — is `panel-pointer-replay`'s
|
||||
/// per §5b's split table, because the release it denies does not
|
||||
/// exist on this branch.
|
||||
pub fn arm_accepted_gesture(&mut self, gesture: AcceptedPanelGesture) {
|
||||
// THE INVARIANT IS ASSERTED WHERE IT IS RELIED ON. A caller must
|
||||
// have ended AND paid any live gesture before arming a
|
||||
// replacement: a second accepted press while one is armed means
|
||||
// the first gesture's release never arrived, and its release has
|
||||
// to reach the target before this press does. Cancelling here
|
||||
// instead is too late by exactly one effect --- the replacement
|
||||
// has already landed.
|
||||
//
|
||||
// Checked at the point of arming rather than inside
|
||||
// cancellation, because arming is what the ordering protects.
|
||||
debug_assert!(
|
||||
self.accepted_gesture.is_none(),
|
||||
"arming over a LIVE gesture: the caller must cancel and \
|
||||
drain it first, or the replacement press overtakes the old \
|
||||
gesture's release"
|
||||
);
|
||||
debug_assert!(
|
||||
self.pending_release.is_none(),
|
||||
"arming over an OWED release: the drain must run before the \
|
||||
effect that arms"
|
||||
);
|
||||
// Defensive in release builds: ending it parks the record for a
|
||||
// later drain, which is late but not lost. Overwriting would
|
||||
// discard it outright and leave the child holding a button.
|
||||
self.cancel_accepted_gesture();
|
||||
self.accepted_gesture = Some(gesture);
|
||||
}
|
||||
|
||||
/// §5b — an ordinary accepted release CONSUMES the latch, WITHOUT
|
||||
/// counting as a cancellation.
|
||||
///
|
||||
/// Otherwise a later invalidation finds a gesture it believes is
|
||||
/// still live and synthesises a duplicate release for a button
|
||||
/// already up. Substrate for the framing's G5c/G5d, whose duplicate-
|
||||
/// release assertions are replay's.
|
||||
pub fn consume_accepted_gesture(&mut self) -> Option<AcceptedPanelGesture> {
|
||||
self.accepted_gesture.take()
|
||||
}
|
||||
|
||||
/// §5b G5a — cancellation: end the live gesture, if any, and return
|
||||
/// its record so the caller can terminate it.
|
||||
///
|
||||
/// Returning the record rather than acting is deliberate. The
|
||||
/// EFFECTS — clearing an empty selection, clearing the click chain,
|
||||
/// delivering the child's release — live in panel replay, which is
|
||||
/// `panel-pointer-replay`'s. This is the trigger and the state; the
|
||||
/// two meet at that rebase.
|
||||
///
|
||||
/// Idempotent on an empty latch: no gesture, no count. That is what
|
||||
/// lets a duplicate `Absent` and a coincident advance both call it
|
||||
/// without inventing a second cancellation.
|
||||
pub fn cancel_accepted_gesture(&mut self) -> Option<AcceptedPanelGesture> {
|
||||
let cancelled = self.accepted_gesture.take();
|
||||
if let Some(record) = cancelled {
|
||||
self.panel_gesture_cancellations = self.panel_gesture_cancellations.saturating_add(1);
|
||||
// PARKED, not just returned. Returning was the whole bug:
|
||||
// the callers inside frame production cannot deliver a
|
||||
// release, so the record went out of scope and the gesture
|
||||
// ended with nothing terminated.
|
||||
//
|
||||
// Overwriting a still-parked release would lose one, so it
|
||||
// is an invariant violation rather than a silent drop. It is
|
||||
// a BACKSTOP: the ordering — drain before the next effect —
|
||||
// is what actually prevents it.
|
||||
debug_assert!(
|
||||
self.pending_release.is_none(),
|
||||
"a release was still owed when another gesture was \
|
||||
cancelled; the drain must run before any subsequent \
|
||||
panel-pointer effect"
|
||||
);
|
||||
self.pending_release = Some(record);
|
||||
}
|
||||
cancelled
|
||||
}
|
||||
|
||||
/// Take the release this frontend is owed, if any.
|
||||
///
|
||||
/// The RETURN of `cancel_accepted_gesture` is for inspection; THIS
|
||||
/// is the delivery path.
|
||||
pub fn take_pending_release(&mut self) -> Option<AcceptedPanelGesture> {
|
||||
self.pending_release.take()
|
||||
}
|
||||
|
||||
/// Whether a release is still owed, for assertions.
|
||||
#[must_use]
|
||||
pub fn has_pending_release(&self) -> bool {
|
||||
self.pending_release.is_some()
|
||||
}
|
||||
|
||||
/// The live gesture record, if any — parent 48 Q#BP-R4's
|
||||
/// "live record" test, and the source of the recorded completion.
|
||||
#[must_use]
|
||||
pub fn accepted_gesture(&self) -> Option<&AcceptedPanelGesture> {
|
||||
self.accepted_gesture.as_ref()
|
||||
}
|
||||
|
||||
/// Q#BP-R4: an accepted `Drag` continues the gesture and moves its
|
||||
/// LAST VALID CONTENT CELL, which is where a release that later
|
||||
/// lands on chrome gets delivered (R-c2).
|
||||
///
|
||||
/// Inert with nothing armed. A drag with no accepted press is a
|
||||
/// stale tail and must not create a record by writing to one.
|
||||
pub fn note_gesture_content_cell(&mut self, coord: pmacs_protocol::CellCoord) {
|
||||
if let Some(gesture) = self.accepted_gesture.as_mut() {
|
||||
gesture.coord = coord;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a gesture is currently accepted, for assertions.
|
||||
#[must_use]
|
||||
pub fn has_accepted_gesture(&self) -> bool {
|
||||
self.accepted_gesture.is_some()
|
||||
}
|
||||
|
||||
/// Advance-if-changed, then read: the authoritative mapping key.
|
||||
///
|
||||
/// **The single seam §5b requires.** Projection stamps the frame
|
||||
/// with what this returns, and inbound validation compares against
|
||||
/// what this returns; there is no second derivation to disagree
|
||||
/// with.
|
||||
///
|
||||
/// `fingerprint` is `None` when no panel is presentable. That does
|
||||
/// **not** reset the key — the high-water mark survives `Absent`,
|
||||
/// so a frame delayed across a hide cannot come back with a lower
|
||||
/// generation and be believed.
|
||||
pub fn panel_mapping_generation(
|
||||
&mut self,
|
||||
snapshot: Option<crate::editor::PanelMappingSnapshot>,
|
||||
) -> Option<u64> {
|
||||
if self.panel_mapping_exhausted {
|
||||
return None;
|
||||
}
|
||||
let snapshot = snapshot?;
|
||||
// Matched by value, not by reference: the changed arm CANCELS,
|
||||
// and cancelling needs `&mut self`.
|
||||
let seen = self
|
||||
.panel_mapping
|
||||
.as_ref()
|
||||
.map(|(seen, generation)| (*seen == snapshot, *generation));
|
||||
let next = match seen {
|
||||
// Compared STRUCTURALLY above. A hash would make this
|
||||
// probabilistic, and a collision here silently accepts a
|
||||
// stale gesture — the exact failure the key exists for.
|
||||
Some((true, generation)) => generation,
|
||||
Some((false, generation)) => {
|
||||
// §5b G5a — the key advancing IS the cancellation
|
||||
// trigger, raised here rather than waiting for another
|
||||
// pointer event. Reactive cancellation loses a race: if
|
||||
// the successor frame reaches the frontend before the
|
||||
// physical `Up`, the producer clears its latch and the
|
||||
// cancelling event never arrives.
|
||||
self.cancel_accepted_gesture();
|
||||
let Some(next) = generation.checked_add(1) else {
|
||||
// §5b G11a — fail CLOSED, and return BEFORE the
|
||||
// store below. A saturating add would freeze the key
|
||||
// at the ceiling while the mapping kept moving
|
||||
// underneath it, which is precisely the stale-gesture
|
||||
// hole the key exists to close, with the check still
|
||||
// looking like it passes.
|
||||
//
|
||||
// Returning BEFORE the store below is a second,
|
||||
// redundant guard against the same zombie band:
|
||||
// recording `(snapshot, MAX)` here would make the
|
||||
// next read take the unchanged arm and hand back the
|
||||
// ceiling. Measured, the two are ALTERNATIVES —
|
||||
// either alone keeps the band down, and only
|
||||
// removing both resurrects it. The latch is kept as
|
||||
// the primary because it has a job the ordering
|
||||
// does not: it is what makes `peek` and the
|
||||
// authoritative read agree that this session has no
|
||||
// key, rather than reporting the ceiling it stopped
|
||||
// at.
|
||||
self.panel_mapping_exhausted = true;
|
||||
return None;
|
||||
};
|
||||
next
|
||||
}
|
||||
// First establishment takes 1, never 0: zero is the wire's
|
||||
// "uninitialised" value and is refused on sight.
|
||||
None => 1,
|
||||
};
|
||||
self.panel_mapping = Some((snapshot, next));
|
||||
Some(next)
|
||||
}
|
||||
|
||||
/// §5b G11a — whether this session's key is exhausted.
|
||||
#[must_use]
|
||||
pub fn panel_mapping_generation_exhausted(&self) -> bool {
|
||||
self.panel_mapping_exhausted
|
||||
}
|
||||
|
||||
/// Place the key one advance below the ceiling, so exhaustion is
|
||||
/// reachable in a test without 2^64 mutations.
|
||||
#[doc(hidden)]
|
||||
pub fn seed_panel_mapping_generation_for_test(
|
||||
&mut self,
|
||||
snapshot: crate::editor::PanelMappingSnapshot,
|
||||
generation: u64,
|
||||
) {
|
||||
self.panel_mapping = Some((snapshot, generation));
|
||||
}
|
||||
|
||||
/// The current key without advancing it, for assertions and for
|
||||
/// callers that must not have a side effect.
|
||||
///
|
||||
/// `None` once exhausted, matching the authoritative read. The
|
||||
/// stored pair still holds the ceiling it stopped at, and reporting
|
||||
/// that would name a key no frame carries and no gesture may echo.
|
||||
#[must_use]
|
||||
pub fn panel_mapping_generation_peek(&self) -> Option<u64> {
|
||||
if self.panel_mapping_exhausted {
|
||||
return None;
|
||||
}
|
||||
self.panel_mapping
|
||||
.as_ref()
|
||||
.map(|(_, generation)| *generation)
|
||||
}
|
||||
|
||||
/// Whether the last shipped declaration is a `Present` whose epochs
|
||||
/// both match an inbound panel event **and** which still describes
|
||||
/// the side window that is live now (Q#BP16 steps 2–4).
|
||||
|
|
@ -1729,31 +1342,6 @@ impl SemanticRenderState {
|
|||
return;
|
||||
};
|
||||
let identity = (projection.window_id, projection.buffer_id);
|
||||
|
||||
// Parent 48 G5b — AUTHORITY LOSS. A live gesture belongs to the
|
||||
// presentation it was pressed on. Three of the matrix's five
|
||||
// transitions are visible right here, and each is a separate
|
||||
// cause with one shared consequence:
|
||||
//
|
||||
// * the side WINDOW was replaced (panel-epoch change),
|
||||
// * its BUFFER was replaced,
|
||||
// * the GEOMETRY epoch moved, including at an unchanged size.
|
||||
//
|
||||
// §5b left these armed deliberately — inert while nothing
|
||||
// consumed the latch — and they became defects the moment this
|
||||
// lane gave cancellation an effect. Taking the latch parks the
|
||||
// release; the drain pays it.
|
||||
//
|
||||
// G5m: coincident causes take the latch ONCE. `cancel_accepted_gesture`
|
||||
// is idempotent on an empty latch, so a transition that trips
|
||||
// two of these conditions still emits one release.
|
||||
if let Some(presentation) = self.panel_presentation
|
||||
&& ((presentation.window_id, presentation.buffer_id) != identity
|
||||
|| presentation.geometry_epoch != geometry.geometry_epoch)
|
||||
{
|
||||
self.cancel_accepted_gesture();
|
||||
}
|
||||
|
||||
let panel_epoch = match self.panel_presentation {
|
||||
Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => {
|
||||
Some(presentation.panel_epoch)
|
||||
|
|
@ -1786,7 +1374,7 @@ impl SemanticRenderState {
|
|||
self.publish_absent_panel(out);
|
||||
return;
|
||||
};
|
||||
let frame = PanelFrame {
|
||||
let payload = PanelFramePayload::Present(PanelFrame {
|
||||
buffer_id: projection.buffer_id,
|
||||
panel_epoch,
|
||||
geometry_epoch: geometry.geometry_epoch,
|
||||
|
|
@ -1794,28 +1382,7 @@ impl SemanticRenderState {
|
|||
cells: projection.cells,
|
||||
cursor: projection.cursor,
|
||||
focused: projection.focused,
|
||||
};
|
||||
// §5b — ORDER MATTERS. The projection is prepared above, THEN
|
||||
// the key is captured, THEN the payload is built. A terminal
|
||||
// projection registers the view whose scroll anchor the key
|
||||
// reads, so capturing earlier would stamp a frame with a key
|
||||
// derived from an unregistered anchor.
|
||||
let payload = if self.peer_knows_mapped_panel {
|
||||
let snapshot = state.panel_mapping_snapshot(self.frontend_id);
|
||||
let Some(mapping_generation) = self.panel_mapping_generation(snapshot) else {
|
||||
// No presentable mapping means nothing to stamp. Falling
|
||||
// back to the legacy variant here would hand a v25 peer
|
||||
// the family it did not negotiate.
|
||||
self.publish_absent_panel(out);
|
||||
return;
|
||||
};
|
||||
PanelFramePayload::PresentMapped {
|
||||
frame,
|
||||
mapping_generation,
|
||||
}
|
||||
} else {
|
||||
PanelFramePayload::Present(frame)
|
||||
};
|
||||
});
|
||||
// Complete-payload comparison FIRST, like the terminal pass: only
|
||||
// validated payloads are ever stored, so a payload equal to the
|
||||
// baseline has already passed and re-running the per-cell width
|
||||
|
|
@ -1824,10 +1391,8 @@ impl SemanticRenderState {
|
|||
self.panel_error_latched = false;
|
||||
return;
|
||||
}
|
||||
let (PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. }) =
|
||||
&payload
|
||||
else {
|
||||
unreachable!("a Present payload was constructed immediately above");
|
||||
let PanelFramePayload::Present(frame) = &payload else {
|
||||
unreachable!("the Present payload was constructed immediately above");
|
||||
};
|
||||
match frame.validate() {
|
||||
Ok(()) => {
|
||||
|
|
@ -1837,7 +1402,6 @@ impl SemanticRenderState {
|
|||
window_id: projection.window_id,
|
||||
buffer_id: projection.buffer_id,
|
||||
panel_epoch,
|
||||
geometry_epoch: geometry.geometry_epoch,
|
||||
});
|
||||
self.last_panel_payload = Some(payload.clone());
|
||||
out.push(InstanceMessage::PanelFrame(payload));
|
||||
|
|
@ -1869,20 +1433,6 @@ impl SemanticRenderState {
|
|||
/// deliberately survives: it is answered by the frontend, not by the
|
||||
/// panel's presence.
|
||||
fn publish_absent_panel(&mut self, out: &mut Vec<InstanceMessage>) {
|
||||
// §5b — `Absent` is a loss of gesture authority: it clears
|
||||
// `panel_presentation` two lines below, so every later inbound
|
||||
// event for that gesture is refused and its release can never
|
||||
// arrive. Cancelled even on a DUPLICATE `Absent`, which does no
|
||||
// wire work but must still leave no live gesture behind — the
|
||||
// clears below are idempotent for the same reason.
|
||||
//
|
||||
// `Absent` is one of G5b's five transitions. THE OTHER FOUR ARE
|
||||
// NOW WIRED TOO, on this branch: window replacement, buffer
|
||||
// replacement and a geometry-epoch change are handled where the
|
||||
// declaration is built above, and detach in the dispatcher's
|
||||
// teardown. This comment used to say they were left armed — true
|
||||
// of §5b, false here since the authority-loss matrix landed.
|
||||
self.cancel_accepted_gesture();
|
||||
self.panel_presentation = None;
|
||||
// `Absent` also clears the peer's retained mode line. A later
|
||||
// `Present` under `NoMessage` therefore has nothing it can
|
||||
|
|
|
|||
|
|
@ -114,15 +114,6 @@ pub struct ScreenProjection {
|
|||
pub title: Option<String>,
|
||||
/// Screen generation represented by this projection.
|
||||
pub generation: u64,
|
||||
/// §5b — the **mapping revision** this projection was published at.
|
||||
///
|
||||
/// Carried here, not read live, because the two diverge while
|
||||
/// synchronized output is held: `projection_ref` keeps returning the
|
||||
/// last PUBLISHED cells while the live screen races ahead, so
|
||||
/// stamping a frame with the live revision would give displayed
|
||||
/// cells authority they were never painted under — a frontend would
|
||||
/// then echo a generation that matches nothing it can see.
|
||||
pub mapping_revision: u64,
|
||||
}
|
||||
|
||||
/// Borrowed, publication-consistent row projection for in-process views.
|
||||
|
|
@ -137,8 +128,6 @@ pub(crate) struct BorrowedScreenProjection<'a> {
|
|||
pub cursor: Option<CellCoord>,
|
||||
pub title: Option<&'a str>,
|
||||
pub generation: u64,
|
||||
/// §5b — the mapping revision this projection was published at.
|
||||
pub mapping_revision: u64,
|
||||
}
|
||||
|
||||
impl BorrowedScreenProjection<'_> {
|
||||
|
|
@ -158,7 +147,6 @@ impl ScreenProjection {
|
|||
cursor: self.cursor,
|
||||
title: self.title.as_deref(),
|
||||
generation: self.generation,
|
||||
mapping_revision: self.mapping_revision,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -204,9 +192,6 @@ pub struct TerminalScreen {
|
|||
tab_stops: BTreeSet<usize>,
|
||||
title: Option<String>,
|
||||
generation: u64,
|
||||
/// §5b — see [`Screen::mapping_revision`]. Separate from
|
||||
/// `generation`, which advances for style and title too.
|
||||
mapping_revision: u64,
|
||||
published: ScreenProjection,
|
||||
sync_started: Option<Instant>,
|
||||
next_line_id: u64,
|
||||
|
|
@ -236,7 +221,6 @@ impl TerminalScreen {
|
|||
cursor: Some(CellCoord::new(0, 0)),
|
||||
title: None,
|
||||
generation: 0,
|
||||
mapping_revision: 0,
|
||||
};
|
||||
Ok(Self {
|
||||
size,
|
||||
|
|
@ -256,7 +240,6 @@ impl TerminalScreen {
|
|||
tab_stops: default_tab_stops(size.cols as usize),
|
||||
title: None,
|
||||
generation: 0,
|
||||
mapping_revision: 0,
|
||||
published,
|
||||
sync_started: None,
|
||||
next_line_id,
|
||||
|
|
@ -286,24 +269,24 @@ impl TerminalScreen {
|
|||
}
|
||||
AnsiEvent::SetStyle(style) => {
|
||||
self.style = style;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::CarriageReturn => {
|
||||
self.cursor.col = 0;
|
||||
self.cursor.pending_wrap = false;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::Backspace => {
|
||||
self.cursor.col = self.cursor.col.saturating_sub(1);
|
||||
self.cursor.pending_wrap = false;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::Bell => {
|
||||
self.bell_count = self.bell_count.saturating_add(1);
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::LineFeed | AnsiEvent::Index => {
|
||||
|
|
@ -325,17 +308,17 @@ impl TerminalScreen {
|
|||
}
|
||||
AnsiEvent::SetTabStop => {
|
||||
self.tab_stops.insert(self.cursor.col);
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::ClearTabStop => {
|
||||
self.tab_stops.remove(&self.cursor.col);
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::ClearAllTabStops => {
|
||||
self.tab_stops.clear();
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::CursorUp(n) => {
|
||||
|
|
@ -449,23 +432,23 @@ impl TerminalScreen {
|
|||
CharacterSetSlot::G0 => self.g0 = charset,
|
||||
CharacterSetSlot::G1 => self.g1 = charset,
|
||||
}
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::ShiftOut => {
|
||||
self.use_g1 = true;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::ShiftIn => {
|
||||
self.use_g1 = false;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::DeviceRequest(request) => Some(self.device_reply(request)),
|
||||
AnsiEvent::SetTitle(title) => {
|
||||
self.title = Some(sanitize_title(&title));
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
None
|
||||
}
|
||||
AnsiEvent::EraseToEol => {
|
||||
|
|
@ -612,29 +595,12 @@ impl TerminalScreen {
|
|||
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
|
||||
title: self.title.as_deref(),
|
||||
generation: self.generation,
|
||||
mapping_revision: self.mapping_revision,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn modes(&self) -> TerminalModes {
|
||||
self.modes
|
||||
}
|
||||
|
||||
/// Turn SGR mouse reporting on or off, for parent 48 G5k.
|
||||
///
|
||||
/// The domain matrix needs a child that reports and then STOPS
|
||||
/// reporting mid-gesture. Driving that through the ANSI parser
|
||||
/// would make the row depend on escape-sequence handling it is not
|
||||
/// testing; this sets the two modes the adapter actually reads.
|
||||
#[doc(hidden)]
|
||||
pub fn set_mouse_reporting_for_test(&mut self, enabled: bool) {
|
||||
self.modes.mouse_sgr = enabled;
|
||||
self.modes.mouse_tracking = if enabled {
|
||||
MouseTrackingMode::Button
|
||||
} else {
|
||||
MouseTrackingMode::Off
|
||||
};
|
||||
}
|
||||
/// Return whether the published active screen is alternate.
|
||||
#[must_use]
|
||||
pub fn alternate_active(&self) -> bool {
|
||||
|
|
@ -688,7 +654,7 @@ impl TerminalScreen {
|
|||
self.g0 = saved.g0;
|
||||
self.g1 = saved.g1;
|
||||
self.use_g1 = saved.use_g1;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
}
|
||||
|
||||
fn write_text(&mut self, text: &str) {
|
||||
|
|
@ -842,24 +808,6 @@ impl TerminalScreen {
|
|||
if self.modes.insert {
|
||||
self.insert_characters(width as u32);
|
||||
}
|
||||
// §5b — did the GLYPH change, or only the pen? Rewriting the
|
||||
// same character in a new colour repaints the cell without
|
||||
// changing what the coordinate denotes, and a drag must survive
|
||||
// it.
|
||||
//
|
||||
// Sampled BEFORE `clear_wide_at`, which blanks the cell when it
|
||||
// is part of a wide pair — sampling after would compare the new
|
||||
// glyph against a default and call every rewrite a change.
|
||||
let glyph_changed = {
|
||||
let row = self.cursor.row;
|
||||
let col = self.cursor.col;
|
||||
let cells = &self.active().rows[row].cells;
|
||||
cells[col].glyph != Glyph::Char(ch)
|
||||
|| (width == 2
|
||||
&& cells
|
||||
.get(col + 1)
|
||||
.is_none_or(|next| next.glyph != Glyph::Continuation))
|
||||
};
|
||||
self.clear_wide_at(self.cursor.row, self.cursor.col);
|
||||
if width == 2 {
|
||||
self.clear_wide_at(self.cursor.row, self.cursor.col + 1);
|
||||
|
|
@ -880,11 +828,7 @@ impl TerminalScreen {
|
|||
self.cursor.pending_wrap = false;
|
||||
}
|
||||
self.last_grapheme = Some((row, col));
|
||||
if glyph_changed {
|
||||
self.changed();
|
||||
} else {
|
||||
self.display_only_changed();
|
||||
}
|
||||
self.changed();
|
||||
}
|
||||
|
||||
fn soft_wrap(&mut self) {
|
||||
|
|
@ -938,7 +882,7 @@ impl TerminalScreen {
|
|||
.copied()
|
||||
.unwrap_or(cols - 1);
|
||||
self.cursor.pending_wrap = false;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
}
|
||||
|
||||
fn move_vertical(&mut self, delta: i64) {
|
||||
|
|
@ -955,7 +899,7 @@ impl TerminalScreen {
|
|||
};
|
||||
self.cursor.row = moved.clamp(lo, hi);
|
||||
self.cursor.pending_wrap = false;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
}
|
||||
|
||||
fn move_horizontal(&mut self, delta: i64) {
|
||||
|
|
@ -967,13 +911,13 @@ impl TerminalScreen {
|
|||
};
|
||||
self.cursor.col = moved.min(self.size.cols as usize - 1);
|
||||
self.cursor.pending_wrap = false;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
}
|
||||
|
||||
fn set_col(&mut self, col: u32) {
|
||||
self.cursor.col = col.saturating_sub(1).min(self.size.cols - 1) as usize;
|
||||
self.cursor.pending_wrap = false;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
}
|
||||
fn set_row(&mut self, row: u32) {
|
||||
let base = if self.modes.origin {
|
||||
|
|
@ -988,7 +932,7 @@ impl TerminalScreen {
|
|||
};
|
||||
self.cursor.row = (base + row.saturating_sub(1) as usize).min(hi);
|
||||
self.cursor.pending_wrap = false;
|
||||
self.display_only_changed();
|
||||
self.changed();
|
||||
}
|
||||
fn set_position(&mut self, row: u32, col: u32) {
|
||||
self.set_row(row);
|
||||
|
|
@ -1522,32 +1466,6 @@ impl TerminalScreen {
|
|||
|
||||
fn changed(&mut self) {
|
||||
self.generation = self.generation.saturating_add(1);
|
||||
// §5b: by DEFAULT a change also moves the mapping. Anything not
|
||||
// explicitly classified as display-only is treated as content,
|
||||
// which fails in the safe direction — over-cancelling a gesture
|
||||
// is a nuisance, under-cancelling one lets a stale coordinate
|
||||
// reach a child.
|
||||
self.mapping_revision = self.mapping_revision.saturating_add(1);
|
||||
}
|
||||
|
||||
/// A change that repaints but **cannot move what a coordinate
|
||||
/// denotes** (§5b's stable controls).
|
||||
///
|
||||
/// Style, title, bell, tab stops and pure cursor motion all land
|
||||
/// here. The existing `generation` still advances — the screen does
|
||||
/// look different — but `mapping_revision` does not, so a drag
|
||||
/// survives them. Keying the panel's mapping on `generation` was
|
||||
/// rejected for exactly this reason: it moves for all of these.
|
||||
fn display_only_changed(&mut self) {
|
||||
self.generation = self.generation.saturating_add(1);
|
||||
}
|
||||
|
||||
/// §5b — identity of what a terminal coordinate DENOTES.
|
||||
///
|
||||
/// Advances with content and topology, and holds across the display
|
||||
/// changes above.
|
||||
pub fn mapping_revision(&self) -> u64 {
|
||||
self.mapping_revision
|
||||
}
|
||||
fn current_snapshot(&self) -> ScreenSnapshot {
|
||||
ScreenSnapshot {
|
||||
|
|
@ -1577,20 +1495,8 @@ impl TerminalScreen {
|
|||
.then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)),
|
||||
title: self.title.clone(),
|
||||
generation: self.generation,
|
||||
mapping_revision: self.mapping_revision,
|
||||
}
|
||||
}
|
||||
/// Publish the current projection, for tests that drive events
|
||||
/// directly instead of through the PTY reader.
|
||||
///
|
||||
/// `#[doc(hidden)]` rather than `#[cfg(test)]`: the rows that need
|
||||
/// it are integration tests, which link the library WITHOUT
|
||||
/// `cfg(test)` and so cannot see a gated item.
|
||||
#[doc(hidden)]
|
||||
pub fn publish_for_test(&mut self) {
|
||||
self.publish();
|
||||
}
|
||||
|
||||
fn publish(&mut self) {
|
||||
self.published = self.current_projection();
|
||||
}
|
||||
|
|
@ -2004,67 +1910,6 @@ mod tests {
|
|||
assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 0)));
|
||||
}
|
||||
|
||||
/// §5b G3 — the terminal **stable controls**.
|
||||
///
|
||||
/// These are exactly the events that make `generation` unusable as a
|
||||
/// mapping key: each one advances it. `mapping_revision` must hold
|
||||
/// across all of them, or a drag over a panel terminal dies the
|
||||
/// moment the child recolours a character or rings the bell.
|
||||
#[test]
|
||||
fn display_only_events_advance_the_generation_but_not_the_mapping() {
|
||||
for (name, event) in [
|
||||
("style", AnsiEvent::SetStyle(Style::default())),
|
||||
("title", AnsiEvent::SetTitle("t".to_owned())),
|
||||
("bell", AnsiEvent::Bell),
|
||||
("tab stop", AnsiEvent::SetTabStop),
|
||||
("clear tab stops", AnsiEvent::ClearAllTabStops),
|
||||
("carriage return", AnsiEvent::CarriageReturn),
|
||||
] {
|
||||
let mut s = screen(2, 16);
|
||||
let before_generation = s.snapshot().generation;
|
||||
let before_mapping = s.mapping_revision();
|
||||
|
||||
s.apply_event(event);
|
||||
|
||||
assert!(
|
||||
s.snapshot().generation > before_generation,
|
||||
"{name} repaints, so the display generation must advance \
|
||||
— otherwise this row proves nothing about the split"
|
||||
);
|
||||
assert_eq!(
|
||||
s.mapping_revision(),
|
||||
before_mapping,
|
||||
"{name} cannot change what a coordinate denotes, so the \
|
||||
MAPPING revision must hold"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// §5b G2 — content and topology **do** move the mapping revision.
|
||||
///
|
||||
/// The positive half. Without it, a `mapping_revision` that never
|
||||
/// advanced at all would pass every stable control above.
|
||||
#[test]
|
||||
fn content_events_advance_the_mapping_revision() {
|
||||
for (name, event) in [
|
||||
("text", AnsiEvent::Text("hi".to_owned())),
|
||||
("line feed", AnsiEvent::LineFeed),
|
||||
(
|
||||
"erase display",
|
||||
AnsiEvent::EraseDisplay(crate::ansi::EraseMode::ToEnd),
|
||||
),
|
||||
("scroll up", AnsiEvent::ScrollUp(1)),
|
||||
] {
|
||||
let mut s = screen(2, 16);
|
||||
let before = s.mapping_revision();
|
||||
s.apply_event(event);
|
||||
assert!(
|
||||
s.mapping_revision() > before,
|
||||
"{name} changes what a coordinate denotes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_only_adds_default_tab_stops_in_new_columns() {
|
||||
let mut s = screen(2, 16);
|
||||
|
|
|
|||
|
|
@ -247,21 +247,10 @@ pub(super) struct EscapeCache {
|
|||
pub(super) reported_invalid: Option<String>,
|
||||
}
|
||||
|
||||
/// What each child was sent, in order, while the G5k tap is armed.
|
||||
type ChildSendLog = Vec<(BufferId, Vec<u8>)>;
|
||||
|
||||
/// Owns the one-buffer/one-process/one-screen terminal registry.
|
||||
#[derive(Default)]
|
||||
pub struct TerminalManager {
|
||||
pub(super) sessions: HashMap<BufferId, TerminalSession>,
|
||||
/// An OPT-IN tap on child input, for parent 48 G5k's witnesses.
|
||||
///
|
||||
/// The gesture-domain rows have to read what the child actually
|
||||
/// received --- a release delivered in the recorded encoding, and
|
||||
/// exactly one of it --- and no other seam exposes that. Off by
|
||||
/// default, so production pays one `is_some` check per send and
|
||||
/// never accumulates.
|
||||
send_tap: RefCell<Option<ChildSendLog>>,
|
||||
/// Total escape-key parses performed (Q#TC4c observability).
|
||||
escape_parses: u64,
|
||||
process_to_buffer: HashMap<ProcessId, BufferId>,
|
||||
|
|
@ -575,35 +564,11 @@ impl TerminalManager {
|
|||
.sessions
|
||||
.get(&buffer_id)
|
||||
.ok_or(TerminalError::NotTerminal(buffer_id))?;
|
||||
if let Some(tap) = self.send_tap.borrow_mut().as_mut() {
|
||||
tap.push((buffer_id, bytes.to_vec()));
|
||||
}
|
||||
supervisor
|
||||
.write_stdin(session.process_id, bytes)
|
||||
.map_err(TerminalError::Process)
|
||||
}
|
||||
|
||||
/// Begin recording child input for G5k's witnesses.
|
||||
#[doc(hidden)]
|
||||
pub fn start_send_tap_for_test(&self) {
|
||||
*self.send_tap.borrow_mut() = Some(Vec::new());
|
||||
}
|
||||
|
||||
/// Take everything sent to children since the tap was started.
|
||||
///
|
||||
/// Returns the sends in ORDER, because "one release, not two" and
|
||||
/// "the old gesture's release before the new gesture's press" are
|
||||
/// both ordering claims that a set cannot express.
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn take_send_tap_for_test(&self) -> ChildSendLog {
|
||||
self.send_tap
|
||||
.borrow_mut()
|
||||
.as_mut()
|
||||
.map(std::mem::take)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve this terminal's effective escape chord, parsing at most
|
||||
/// once per `(terminal, config epoch)` (Q#TC4c).
|
||||
///
|
||||
|
|
|
|||
|
|
@ -249,60 +249,6 @@ impl TerminalManager {
|
|||
self.screen_size(key.buffer_id)
|
||||
}
|
||||
|
||||
/// Apply one parsed event to a session's screen, for tests.
|
||||
///
|
||||
/// Terminal output normally arrives on the PTY reader thread, which
|
||||
/// no daemon-level test can drive deterministically. §5b's terminal
|
||||
/// rows must nevertheless be witnessed **across the seam** — the
|
||||
/// screen counter and the daemon's key are separately provable, and
|
||||
/// a `view_mapping_identity` returning a constant would leave both
|
||||
/// green — so this exists to join them.
|
||||
///
|
||||
/// `#[doc(hidden)]` rather than `#[cfg(test)]`, because the rows
|
||||
/// that need it are integration tests and those link the library
|
||||
/// without `cfg(test)`.
|
||||
#[doc(hidden)]
|
||||
pub fn apply_event_for_test(
|
||||
&mut self,
|
||||
buffer_id: BufferId,
|
||||
event: crate::ansi::AnsiEvent,
|
||||
) -> bool {
|
||||
match self.sessions.get_mut(&buffer_id) {
|
||||
Some(session) => {
|
||||
session.screen.apply_event(event);
|
||||
session.screen.publish_for_test();
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// §5b — the terminal's **mapping revision** plus its per-view
|
||||
/// scroll anchor: together, the identity of what a coordinate in
|
||||
/// this view denotes.
|
||||
///
|
||||
/// The anchor is part of it because the same coordinate names a
|
||||
/// different retained row once the view scrolls, even with the
|
||||
/// child's screen untouched.
|
||||
#[must_use]
|
||||
pub fn view_mapping_identity(
|
||||
&self,
|
||||
key: TerminalViewKey,
|
||||
) -> Option<(u64, Option<LogicalCellAnchor>)> {
|
||||
let session = self.sessions.get(&key.buffer_id)?;
|
||||
// `top` IS the anchor: `None` means following the live tail,
|
||||
// which is itself a distinct state from any pinned row.
|
||||
let anchor = self.views.get(&key).and_then(|view| view.top);
|
||||
// The PUBLISHED revision, not the live one. While synchronized
|
||||
// output is held, `projection_ref` keeps returning the last
|
||||
// published cells while the screen races ahead — reading
|
||||
// `screen.mapping_revision()` there would stamp displayed cells
|
||||
// with authority they were never painted under, and the frontend
|
||||
// would echo a generation matching nothing it can see.
|
||||
let published = session.screen.projection_ref().mapping_revision;
|
||||
Some((published, anchor))
|
||||
}
|
||||
|
||||
/// The shared screen's current size, read from the borrowed
|
||||
/// projection.
|
||||
///
|
||||
|
|
@ -501,22 +447,6 @@ impl TerminalManager {
|
|||
moved || was_dragging
|
||||
}
|
||||
|
||||
/// Whether a view is mid-drag, for parent 48 Q#BP-R4's completion
|
||||
/// witnesses.
|
||||
///
|
||||
/// A local terminal gesture is "finished" exactly when
|
||||
/// `finish_selection` takes `drag`, so this is the observable that
|
||||
/// separates a delivered completion from a latch that merely
|
||||
/// emptied — which is the distinction the framing requires those
|
||||
/// rows to assert.
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn view_is_dragging_for_test(&self, key: TerminalViewKey) -> bool {
|
||||
self.views
|
||||
.get(&key)
|
||||
.is_some_and(|state| state.drag.is_some())
|
||||
}
|
||||
|
||||
/// Clear one view's terminal selection without changing its scroll anchor.
|
||||
pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool {
|
||||
let Some(state) = self.views.get_mut(&key) else {
|
||||
|
|
@ -525,14 +455,6 @@ impl TerminalManager {
|
|||
state.selection.take().is_some() || state.drag.take().is_some()
|
||||
}
|
||||
|
||||
/// Turn SGR mouse reporting on or off for one session (G5k).
|
||||
#[doc(hidden)]
|
||||
pub fn set_mouse_reporting_for_test(&mut self, buffer_id: BufferId, enabled: bool) {
|
||||
if let Some(session) = self.sessions.get_mut(&buffer_id) {
|
||||
session.screen.set_mouse_reporting_for_test(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Current child input modes for one session.
|
||||
#[must_use]
|
||||
pub fn modes_for_view(&self, key: TerminalViewKey) -> Option<TerminalModes> {
|
||||
|
|
@ -1043,7 +965,6 @@ mod tests {
|
|||
cursor: None,
|
||||
title: Some("shell".into()),
|
||||
generation: 7,
|
||||
mapping_revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1324,7 +1245,6 @@ mod tests {
|
|||
cursor: None,
|
||||
title: None,
|
||||
generation: 2,
|
||||
mapping_revision: 0,
|
||||
};
|
||||
let mut state = TerminalViewState {
|
||||
top: Some(LogicalCellAnchor {
|
||||
|
|
|
|||
115
src/view.rs
115
src/view.rs
|
|
@ -597,119 +597,4 @@ mod tests {
|
|||
assert_eq!(vp.row_offset_of(0, 3), None, "hidden lines have no row");
|
||||
assert_eq!(vp.row_offset_of(0, 5), Some(2), "rows below shift up");
|
||||
}
|
||||
|
||||
/// C9: §4 of `docs/crdt-identity-undo-framing.md` enumerates every
|
||||
/// in-tree `on_edit` override so that the consumers of a
|
||||
/// version-only history `Edit` are a closed set. This asserts the
|
||||
/// set is still what the census measured.
|
||||
///
|
||||
/// **It asserts PAIRS, not a file set and a count.** Replacing
|
||||
/// `ParseView`'s override with an unclassified type in the same
|
||||
/// file leaves both the file set and the total unchanged, and only
|
||||
/// the pair set catches it.
|
||||
///
|
||||
/// **Its reach is in-tree, and that is a real limit.** [`View`] and
|
||||
/// `Buffer::attach_view` are both public, so a downstream crate may
|
||||
/// implement `on_edit` and attach it; no in-tree measurement can
|
||||
/// enumerate that. What speaks to those implementors is the
|
||||
/// documented contract on `Edit` itself.
|
||||
///
|
||||
/// It also guards only the census's CLOSURE condition — that the
|
||||
/// override set is unchanged — not the classifications inside it.
|
||||
/// Those are executed by
|
||||
/// `identity_replace_history_op_leaves_classified_consumers_unchanged`.
|
||||
#[test]
|
||||
fn every_in_tree_on_edit_override_is_one_the_census_classified() {
|
||||
/// `(file, impl target)`, as measured by the census.
|
||||
const CLASSIFIED: [(&str, &str); 4] = [
|
||||
("fold.rs", "FoldStoreTranslator"),
|
||||
("overlay.rs", "BufferStyleSpanTranslator"),
|
||||
("syntax.rs", "ParseView"),
|
||||
("text_view.rs", "TextView"),
|
||||
];
|
||||
|
||||
fn rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).expect("src is readable") {
|
||||
let path = entry.expect("dir entry").path();
|
||||
if path.is_dir() {
|
||||
rs_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
rs_files(
|
||||
&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"),
|
||||
&mut files,
|
||||
);
|
||||
files.sort();
|
||||
|
||||
let mut found: Vec<(String, String)> = Vec::new();
|
||||
for path in &files {
|
||||
let name = path
|
||||
.file_name()
|
||||
.expect("file name")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let text = std::fs::read_to_string(path).expect("source is readable");
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
// The test module boundary: the first `#[cfg(test)]` that
|
||||
// introduces a `mod`. Overrides below it are test fixtures
|
||||
// and are out of scope, as the census says.
|
||||
let boundary = lines.iter().enumerate().find_map(|(i, l)| {
|
||||
(l.trim() == "#[cfg(test)]"
|
||||
&& lines
|
||||
.get(i + 1)
|
||||
.is_some_and(|n| n.trim_start().starts_with("mod ")))
|
||||
.then_some(i)
|
||||
});
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if boundary.is_some_and(|b| i > b) {
|
||||
break;
|
||||
}
|
||||
if !line.contains("fn on_edit") {
|
||||
continue;
|
||||
}
|
||||
// Walk back to the enclosing `impl … for <Type>`. A hit
|
||||
// on a trait declaration first means this is the
|
||||
// trait's own default, which is not an override.
|
||||
for j in (0..=i).rev() {
|
||||
let l = lines[j].trim_start();
|
||||
if let Some(rest) = l.strip_prefix("impl")
|
||||
&& let Some(after) = rest.split(" for ").nth(1)
|
||||
{
|
||||
let target = after
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('{')
|
||||
.rsplit("::")
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
found.push((name.clone(), target));
|
||||
break;
|
||||
}
|
||||
if l.starts_with("trait ") || l.starts_with("pub trait ") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
|
||||
let expected: Vec<(String, String)> = CLASSIFIED
|
||||
.iter()
|
||||
.map(|(f, t)| ((*f).to_owned(), (*t).to_owned()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
found, expected,
|
||||
"C9: the in-tree `on_edit` override set no longer matches the \
|
||||
census in docs/crdt-identity-undo-framing.md §4. Every \
|
||||
override is a consumer of the version-only history `Edit` \
|
||||
and needs classifying there."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,24 +384,6 @@ pub struct Window {
|
|||
/// Always `0` while this window's buffer wraps; see
|
||||
/// [`LayoutCtx::effective_left`](crate::view::LayoutCtx::effective_left).
|
||||
pub view_left: u32,
|
||||
/// GUI Stage 1b, lifetime clause 2 — **the user's horizontal origin
|
||||
/// outranks the caret's**.
|
||||
///
|
||||
/// Set when a deliberate horizontal scroll *effectively* moves
|
||||
/// [`Self::view_left`]; while set, the caret-following pass
|
||||
/// re-clamps the origin but does not drag it back. Without it a
|
||||
/// sideways wheel is undone by the very next paint, because
|
||||
/// `horizontal_follow` runs on every frame and knows only the
|
||||
/// caret.
|
||||
///
|
||||
/// Cleared by a genuine cursor move (clause 4), by wrap, and by
|
||||
/// buffer replacement (clause 5).
|
||||
pub manual_left_authority: bool,
|
||||
/// The cursor as it stood when [`Self::manual_left_authority`] was
|
||||
/// armed, so clause 4 can tell a *genuine* cursor change from the
|
||||
/// follow merely running again. Meaningless while the latch is
|
||||
/// clear.
|
||||
pub manual_left_cursor: Position,
|
||||
/// Sticky display column for vertical motion.
|
||||
pub goal_col: Option<u32>,
|
||||
/// Number of text rows that fit in this window's viewport at last
|
||||
|
|
@ -457,8 +439,6 @@ impl Window {
|
|||
selection: None,
|
||||
view_top: 0,
|
||||
view_left: 0,
|
||||
manual_left_authority: false,
|
||||
manual_left_cursor: 0,
|
||||
goal_col: None,
|
||||
last_visible_rows: 0,
|
||||
last_content_cols: 0,
|
||||
|
|
@ -487,42 +467,6 @@ impl Window {
|
|||
}
|
||||
}
|
||||
|
||||
/// Forget a manual horizontal origin because this window is
|
||||
/// adopting a **different buffer** (lifetime clause 5).
|
||||
///
|
||||
/// The origin is a fact about the document being shown, not about
|
||||
/// the window. Carried into a successor it renders the new buffer
|
||||
/// scrolled sideways with nothing about that buffer to explain it,
|
||||
/// until some later cursor motion repairs it by accident. The GPU
|
||||
/// learned this once already — `code_scroll_left` has its own line
|
||||
/// in that frontend's replacement reset, added after exactly this
|
||||
/// symptom — and the TUI's four replacement paths had neither the
|
||||
/// origin reset nor the latch clear.
|
||||
///
|
||||
/// One helper rather than a copy per site, so a new replacement
|
||||
/// path gets the rule by calling it; each **call site** stays
|
||||
/// individually removable, which is what keeps its own row honest.
|
||||
///
|
||||
/// **The census, taken by grepping every write of a window's
|
||||
/// `buffer_id` rather than by recalling which paths exist** — an
|
||||
/// earlier version of this doc said "three" and was wrong, because
|
||||
/// it listed the paths someone had thought of. Four production
|
||||
/// sites rebind a live window to a different buffer:
|
||||
/// `EditorCore::switch_active_buffer_for`,
|
||||
/// `EditorCore::install_buffer_in_window`,
|
||||
/// `EditorCore::kill_buffer`'s fallback rebind, and the daemon's
|
||||
/// `align_primary_document_window`. Each has its own row
|
||||
/// (L8b–L8e).
|
||||
///
|
||||
/// `EditorCore::from_bytes` also assigns `buffer_id`, and is
|
||||
/// **deliberately not on that list**: it builds a fresh core whose
|
||||
/// window has no prior origin to inherit. Named here so the next
|
||||
/// census does not have to re-decide it.
|
||||
pub fn forget_manual_horizontal_origin(&mut self) {
|
||||
self.view_left = 0;
|
||||
self.manual_left_authority = false;
|
||||
}
|
||||
|
||||
/// Width in cells this window's line-number gutter occupies, or `0`
|
||||
/// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`;
|
||||
/// the renderer caps this against the window width and applies it as a
|
||||
|
|
|
|||
|
|
@ -117,16 +117,9 @@ impl Session {
|
|||
first
|
||||
}
|
||||
|
||||
/// The frame from whichever Present family this session negotiated.
|
||||
///
|
||||
/// Family-agnostic on purpose: these rows are about the PROJECTION,
|
||||
/// and which wrapper carries it is §5b's own concern, pinned by the
|
||||
/// G6/G7/G8 rows rather than incidentally by thirty others.
|
||||
fn present(&mut self) -> PanelFrame {
|
||||
match self.frame() {
|
||||
Some(
|
||||
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
|
||||
) => frame,
|
||||
Some(PanelFramePayload::Present(frame)) => frame,
|
||||
other => panic!("expected a Present panel payload, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -529,10 +522,7 @@ fn acc41_degenerate_geometry_fails_closed_to_zero_usable_grid() {
|
|||
session.declare(1, ROWS, COLS);
|
||||
open_panel(&session, "*panel*", 4);
|
||||
assert!(
|
||||
matches!(
|
||||
session.frame(),
|
||||
Some(PanelFramePayload::Present(_) | PanelFramePayload::PresentMapped { .. })
|
||||
),
|
||||
matches!(session.frame(), Some(PanelFramePayload::Present(_))),
|
||||
"{label}: fixture precondition — a band was visible first"
|
||||
);
|
||||
|
||||
|
|
@ -908,9 +898,7 @@ fn acc45_one_statusline_invocation_serves_the_document_and_the_panel() {
|
|||
let panel_rows = messages
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
InstanceMessage::PanelFrame(
|
||||
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
|
||||
) => Some(rows_of(frame)),
|
||||
InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(rows_of(frame)),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a panel frame");
|
||||
|
|
@ -1339,9 +1327,7 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() {
|
|||
session.state.core.borrow_mut().focus_window(FID, panel);
|
||||
|
||||
let frame = match session.frame() {
|
||||
Some(
|
||||
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
|
||||
) => frame,
|
||||
Some(PanelFramePayload::Present(frame)) => frame,
|
||||
other => panic!(
|
||||
"a legally wide panel must still present its terminal; got {other:?} \
|
||||
— and the durable state says hidden={}",
|
||||
|
|
@ -1376,728 +1362,3 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() {
|
|||
// write into their real data root.
|
||||
#[path = "common/iso.rs"]
|
||||
mod iso;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §5b G1–G4 — the authoritative cell-mapping key
|
||||
//
|
||||
// The key is derived from a FINGERPRINT of the inverse mapping's inputs,
|
||||
// so the changing/stable split is structural: an input that is hashed
|
||||
// moves the key by construction, and one that is not cannot. These rows
|
||||
// pin each input individually, because a single "it changed" row cannot
|
||||
// show WHICH input moved it — and a key that silently ignored, say,
|
||||
// `view_left` would pass every row that only scrolls vertically.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Edit the panel's buffer from OUTSIDE any gesture — the "foreign
|
||||
/// edit" the ladder cannot see. Done in Rust rather than Lua because it
|
||||
/// must be a plain content mutation with no view, cursor or command
|
||||
/// state attached to it.
|
||||
fn foreign_edit(session: &Session, text: &str) {
|
||||
let core = session.state.core.borrow();
|
||||
let side = core.side_window_for(FID).expect("a side window");
|
||||
let buffer_id = core.windows[&side].buffer_id;
|
||||
let registry = core.registry.clone();
|
||||
let mut reg = registry.borrow_mut();
|
||||
let buffer = reg.get_mut(buffer_id).expect("the panel's buffer");
|
||||
buffer
|
||||
.set_generated_contents(text.as_bytes())
|
||||
.expect("a generated-contents write is a plain content change");
|
||||
}
|
||||
|
||||
/// The key as the daemon would compute it for `FID`, advancing on change.
|
||||
fn mapping_generation(session: &mut Session) -> Option<u64> {
|
||||
let snapshot = session.state.panel_mapping_snapshot(FID);
|
||||
session.render.panel_mapping_generation(snapshot)
|
||||
}
|
||||
|
||||
/// §5b G1 — a **foreign** edit before the next render moves the key.
|
||||
///
|
||||
/// This is the case the whole slice exists for: the epoch ladder cannot
|
||||
/// see it. No buffer is replaced, no panel reopens, no geometry is
|
||||
/// re-declared — every epoch holds — and yet the byte under a cell has
|
||||
/// changed. It is also why the key must be derived on demand rather than
|
||||
/// from the last emitted frame: nothing has rendered here.
|
||||
#[test]
|
||||
fn g1_a_foreign_edit_moves_the_mapping_key_before_anything_renders() {
|
||||
let mut session = Session::new();
|
||||
open_panel(&session, "g1", 4);
|
||||
session.declare(1, 24, 80);
|
||||
let _ = session.present();
|
||||
|
||||
let before = mapping_generation(&mut session).expect("a presentable panel has a key");
|
||||
assert!(
|
||||
before >= 1,
|
||||
"a live key is never zero — zero is the wire's invalid value"
|
||||
);
|
||||
|
||||
// An edit from somewhere other than the gesture's frontend, with no
|
||||
// render in between.
|
||||
foreign_edit(&session, "foreign edit\n");
|
||||
|
||||
let after = mapping_generation(&mut session).expect("still presentable");
|
||||
assert!(
|
||||
after > before,
|
||||
"a foreign edit changes which byte a cell means, and no epoch \
|
||||
moves with it — this is the hole the ladder cannot close"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G15 — **TUI structural control**: the local panel click, drag
|
||||
/// and wheel paths keep their effects with NO mapping generation
|
||||
/// anywhere.
|
||||
///
|
||||
/// The TUI hit-tests the daemon's own state directly. It receives no
|
||||
/// `PanelFrame`, so it has no generation to echo and there is nothing
|
||||
/// for a freshness check to compare. If the check ever migrates from
|
||||
/// the authenticated semantic boundary into shared replay, local input
|
||||
/// stops working entirely — and it would stop silently, because
|
||||
/// refusing a gesture looks exactly like a gesture that did nothing.
|
||||
///
|
||||
/// The control is the FIXTURE: this session has no
|
||||
/// `SemanticRenderState` at all. Every effect below is therefore
|
||||
/// reached without a producer in existence, which is the strongest form
|
||||
/// of "no generation was consulted" — not an assertion about a value,
|
||||
/// but the absence of anything that could hold one.
|
||||
#[test]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "one control over three input kinds: splitting it would \
|
||||
give each leg its own fixture, and the shared fixture — a \
|
||||
session with no SemanticRenderState at all — is the control"
|
||||
)]
|
||||
fn g15_local_tui_panel_input_keeps_its_effects_with_no_generation() {
|
||||
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind as TuiMouseKind};
|
||||
|
||||
let state = EditorState::new_with_roots(&crate::iso::roots());
|
||||
exec(&state, "pmacs.lsp.config = {}");
|
||||
state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS));
|
||||
exec(
|
||||
&state,
|
||||
"PANEL_BUF = pmacs.buffer.create(\"*g15*\")
|
||||
pmacs.buffer.set_generated_contents(PANEL_BUF, \
|
||||
\"alpha alpha\\nbravo bravo\\ncharlie\\ndelta\\necho\\nfox\\ngolf\\nhotel\\n\")
|
||||
pmacs.window.display(PANEL_BUF, { side = \"bottom\", height = 4 })",
|
||||
);
|
||||
let mut state = state;
|
||||
let panel = state
|
||||
.core
|
||||
.borrow()
|
||||
.side_window_for(FrontendId::LOCAL)
|
||||
.expect("a panel");
|
||||
let document = state
|
||||
.core
|
||||
.borrow()
|
||||
.non_side_target(FrontendId::LOCAL)
|
||||
.expect("a document");
|
||||
|
||||
// Paint one frame first, exactly as the TUI does before a user can
|
||||
// click anything. The window's text view is built from the buffer at
|
||||
// paint time, so input dispatched against an unpainted window maps
|
||||
// every row to offset zero and the row proves nothing.
|
||||
{
|
||||
let mut cells = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize];
|
||||
let mut grid = pmacs::cell::CellGrid {
|
||||
cells: &mut cells,
|
||||
stride: COLS,
|
||||
size: CellSize::new(ROWS, COLS),
|
||||
};
|
||||
pmacs::editor::paint_frame(
|
||||
&state,
|
||||
FrontendId::LOCAL,
|
||||
&HashMap::new(),
|
||||
&mut grid,
|
||||
CellSize::new(ROWS, COLS),
|
||||
);
|
||||
}
|
||||
|
||||
// Where the panel actually is, from the same layout the TUI paints.
|
||||
let panel_rect = {
|
||||
let core = state.core.borrow();
|
||||
let view = core.views.get(&FrontendId::LOCAL).expect("LOCAL view");
|
||||
let area = pmacs::window::Rect::new(0, 0, ROWS - 1, COLS);
|
||||
let fixed = core.panel_fixed_rows(FrontendId::LOCAL, area.size.rows);
|
||||
view.layout.compute(area, &fixed)[&panel]
|
||||
};
|
||||
let row = u16::try_from(panel_rect.origin.row + 1).expect("row fits");
|
||||
// Past the gutter, and inside a line that is long enough for the
|
||||
// drag below to stay within it: clamping at the line end would make
|
||||
// press and drag land on the same byte and prove nothing.
|
||||
let gutter = state.core.borrow().windows[&panel].gutter_width();
|
||||
let col = u16::try_from(panel_rect.origin.col + gutter + 1).expect("col fits");
|
||||
let click = |kind| MouseEvent {
|
||||
kind,
|
||||
column: col,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
};
|
||||
|
||||
// Precondition, scoped to THIS view: the panel is not already
|
||||
// focused, or every assertion below would pass without input.
|
||||
assert_eq!(
|
||||
state.core.borrow().views[&FrontendId::LOCAL].active,
|
||||
document,
|
||||
"precondition: the document is focused"
|
||||
);
|
||||
|
||||
// CLICK — focuses the panel and positions the cursor.
|
||||
state.dispatch_mouse(
|
||||
FrontendId::LOCAL,
|
||||
click(TuiMouseKind::Down(MouseButton::Left)),
|
||||
CellSize::new(ROWS, COLS),
|
||||
);
|
||||
assert_eq!(
|
||||
state.core.borrow().views[&FrontendId::LOCAL].active,
|
||||
panel,
|
||||
"a local press focuses the panel — no token, no refusal"
|
||||
);
|
||||
let pressed_cursor = state.core.borrow().windows[&panel].cursor;
|
||||
assert_ne!(
|
||||
pressed_cursor, 0,
|
||||
"the press positioned the cursor inside the text, not at the \
|
||||
buffer start — a row that pressed into an unpainted window \
|
||||
would read zero here and never notice"
|
||||
);
|
||||
|
||||
// DRAG — extends a selection inside the panel.
|
||||
state.dispatch_mouse(
|
||||
FrontendId::LOCAL,
|
||||
MouseEvent {
|
||||
kind: TuiMouseKind::Drag(MouseButton::Left),
|
||||
column: col + 7,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
CellSize::new(ROWS, COLS),
|
||||
);
|
||||
assert!(
|
||||
state.core.borrow().windows[&panel].selection.is_some(),
|
||||
"a local drag selects inside the panel"
|
||||
);
|
||||
assert_ne!(
|
||||
state.core.borrow().windows[&panel].cursor,
|
||||
pressed_cursor,
|
||||
"and the drag moved the cursor, so the selection is a real range"
|
||||
);
|
||||
|
||||
state.dispatch_mouse(
|
||||
FrontendId::LOCAL,
|
||||
MouseEvent {
|
||||
kind: TuiMouseKind::Up(MouseButton::Left),
|
||||
column: col + 7,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
CellSize::new(ROWS, COLS),
|
||||
);
|
||||
|
||||
// WHEEL — two ticks, both of which must land. The mapped family's
|
||||
// wheel exemption exists so this stays true over the wire; here
|
||||
// there is no wire, and it must be true for the same reason.
|
||||
let before = state.core.borrow().windows[&panel].view_top;
|
||||
for _ in 0..2 {
|
||||
state.dispatch_mouse(
|
||||
FrontendId::LOCAL,
|
||||
click(TuiMouseKind::ScrollDown),
|
||||
CellSize::new(ROWS, COLS),
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
state.core.borrow().windows[&panel].view_top,
|
||||
before,
|
||||
"a local wheel scrolls the panel"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G9a — a generation change is EMITTED even when the picture is
|
||||
/// byte-identical.
|
||||
///
|
||||
/// The panel shows four rows; an edit further down the buffer moves the
|
||||
/// key without changing a single visible cell. Suppressing that frame
|
||||
/// because the cells match would leave the frontend echoing a
|
||||
/// generation the daemon has already retired, and every gesture it then
|
||||
/// sends is refused — the panel goes quietly dead while looking
|
||||
/// perfectly correct.
|
||||
#[test]
|
||||
fn g9a_a_generation_change_ships_even_when_the_cells_are_identical() {
|
||||
let mut session = Session::new();
|
||||
open_panel(&session, "g9a", 4);
|
||||
session.declare(1, 24, 80);
|
||||
|
||||
// Enough lines that the visible four cannot see the edit below.
|
||||
let mut lines = String::new();
|
||||
for line in 0..20 {
|
||||
use std::fmt::Write as _;
|
||||
writeln!(lines, "line {line}").expect("writing to a String never fails");
|
||||
}
|
||||
foreign_edit(&session, &lines);
|
||||
let baseline = session.present();
|
||||
let before = mapping_generation(&mut session).expect("a live key");
|
||||
|
||||
// Change line 15 only. Rows 0..4 are untouched.
|
||||
let edited = lines.replace("line 15\n", "LINE 15 CHANGED\n");
|
||||
assert_ne!(edited, lines, "fixture: the edit must actually apply");
|
||||
foreign_edit(&session, &edited);
|
||||
|
||||
let payload = session
|
||||
.frame()
|
||||
.expect("a generation change is not silence: the frame must ship");
|
||||
let (frame, shipped) = match payload {
|
||||
PanelFramePayload::PresentMapped {
|
||||
frame,
|
||||
mapping_generation,
|
||||
} => (frame, mapping_generation),
|
||||
other => panic!("a v25 producer ships the mapped family, got {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
frame.cells, baseline.cells,
|
||||
"fixture: the visible cells really are identical — if they \
|
||||
differ, this row is testing an ordinary repaint instead"
|
||||
);
|
||||
assert!(
|
||||
shipped > before,
|
||||
"and the key moved with the edit below the fold"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G3 — the **stable** inputs, one row each.
|
||||
///
|
||||
/// Every entry here is something that repaints a panel without changing
|
||||
/// which byte a cell denotes. A drag provokes selection repaints on every
|
||||
/// motion, so a key that moved with them would cancel the gesture it
|
||||
/// exists to protect after a single step.
|
||||
#[test]
|
||||
fn g3_repaints_that_cannot_move_a_byte_leave_the_key_alone() {
|
||||
let mut session = Session::new();
|
||||
open_panel(&session, "g3", 4);
|
||||
session.declare(1, 24, 80);
|
||||
let _ = session.present();
|
||||
|
||||
let baseline = mapping_generation(&mut session).expect("a key");
|
||||
|
||||
// Re-reading with nothing changed at all.
|
||||
assert_eq!(
|
||||
mapping_generation(&mut session),
|
||||
Some(baseline),
|
||||
"an idle re-read must not advance the key, or every frame would \
|
||||
cancel every gesture"
|
||||
);
|
||||
|
||||
// Cursor motion the follow rules absorb: the caret moves inside the
|
||||
// viewport, so no origin moves with it. Set directly, so nothing but
|
||||
// the cursor changes.
|
||||
{
|
||||
let mut core = session.state.core.borrow_mut();
|
||||
let side = core.side_window_for(FID).expect("a side window");
|
||||
let window = core.windows.get_mut(&side).expect("the side window");
|
||||
window.cursor = 0;
|
||||
}
|
||||
assert_eq!(
|
||||
mapping_generation(&mut session),
|
||||
Some(baseline),
|
||||
"cursor motion that moves no origin is not a mapping change"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G4a — a **selection-only** repaint preserves the key.
|
||||
///
|
||||
/// Split from G3 because it is the one the lifecycle depends on: G4b —
|
||||
/// that an in-flight drag then continues through real replay — is owed by
|
||||
/// the rebased replay lane, which is the only branch where replay exists.
|
||||
#[test]
|
||||
fn g4a_a_selection_only_repaint_preserves_the_mapping_key() {
|
||||
let mut session = Session::new();
|
||||
open_panel(&session, "g4a", 4);
|
||||
session.declare(1, 24, 80);
|
||||
let _ = session.present();
|
||||
|
||||
let baseline = mapping_generation(&mut session).expect("a key");
|
||||
foreign_edit(&session, "alpha beta\n");
|
||||
let after_edit = mapping_generation(&mut session).expect("a key");
|
||||
assert!(after_edit > baseline, "the edit itself is a mapping change");
|
||||
|
||||
// Now a selection, with no content or viewport change.
|
||||
{
|
||||
let mut core = session.state.core.borrow_mut();
|
||||
let side = core.side_window_for(FID).expect("a side window");
|
||||
let window = core.windows.get_mut(&side).expect("the side window");
|
||||
window.selection = Some(pmacs::window::Selection { anchor: 0 });
|
||||
window.cursor = 5;
|
||||
}
|
||||
assert_eq!(
|
||||
mapping_generation(&mut session),
|
||||
Some(after_edit),
|
||||
"a selection changes what is HIGHLIGHTED, never what a cell \
|
||||
denotes — and a drag repaints the selection on every motion"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b — the key is a **high-water mark** and survives `Absent`.
|
||||
///
|
||||
/// Hiding the band clears input authority, but it must not reset the
|
||||
/// generation: a frame delayed across the hide would otherwise return
|
||||
/// with a lower value and be believed.
|
||||
#[test]
|
||||
fn the_mapping_key_never_moves_backward_across_a_hidden_panel() {
|
||||
let mut session = Session::new();
|
||||
open_panel(&session, "hw", 4);
|
||||
session.declare(1, 24, 80);
|
||||
let _ = session.present();
|
||||
|
||||
let before = mapping_generation(&mut session).expect("a key");
|
||||
foreign_edit(&session, "one\n");
|
||||
let peak = mapping_generation(&mut session).expect("a key");
|
||||
assert!(peak > before);
|
||||
|
||||
// Hide it: no fingerprint, so no advance — and no reset either.
|
||||
{
|
||||
let mut core = session.state.core.borrow_mut();
|
||||
core.views
|
||||
.get_mut(&FID)
|
||||
.expect("the frontend's view")
|
||||
.panel_hidden = true;
|
||||
}
|
||||
assert_eq!(
|
||||
mapping_generation(&mut session),
|
||||
None,
|
||||
"no presentable panel means no key to stamp, which is not the \
|
||||
same as a key of zero"
|
||||
);
|
||||
assert_eq!(
|
||||
session.render.panel_mapping_generation_peek(),
|
||||
Some(peak),
|
||||
"the high-water mark SURVIVES the hide — clearing it would let a \
|
||||
delayed frame roll the producer's authority backward"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G2 — **every changing input, one leg each.**
|
||||
///
|
||||
/// Enumerated rather than asserted in aggregate, and mutation testing is
|
||||
/// what forced it: with only the content-edit row present, dropping
|
||||
/// `view_left` from the key and collapsing the grid to `rows * cols`
|
||||
/// both stayed GREEN. A single "the key moved" row cannot show *which*
|
||||
/// input moved it, and a key that ignores horizontal scrolling passes
|
||||
/// every row that only scrolls vertically.
|
||||
#[test]
|
||||
fn g2_each_input_of_the_inverse_mapping_moves_the_key_on_its_own() {
|
||||
// Each leg names one input and touches only that input.
|
||||
type Leg = (&'static str, fn(&Session));
|
||||
|
||||
let legs: &[Leg] = &[
|
||||
("view_top", |session| {
|
||||
let mut core = session.state.core.borrow_mut();
|
||||
let side = core.side_window_for(FID).expect("side");
|
||||
core.windows.get_mut(&side).expect("win").view_top += 1;
|
||||
}),
|
||||
("view_left — GUI arc 1b makes this real", |session| {
|
||||
let mut core = session.state.core.borrow_mut();
|
||||
let side = core.side_window_for(FID).expect("side");
|
||||
core.windows.get_mut(&side).expect("win").view_left += 1;
|
||||
}),
|
||||
("wrap mode", |session| {
|
||||
let mut core = session.state.core.borrow_mut();
|
||||
let side = core.side_window_for(FID).expect("side");
|
||||
let window = core.windows.get_mut(&side).expect("win");
|
||||
window.last_wrap = match window.last_wrap {
|
||||
pmacs::view::WrapMode::Wrap => pmacs::view::WrapMode::Truncate,
|
||||
pmacs::view::WrapMode::Truncate => pmacs::view::WrapMode::Wrap,
|
||||
};
|
||||
}),
|
||||
(
|
||||
"content columns — the gutter is subtracted here",
|
||||
|session| {
|
||||
let mut core = session.state.core.borrow_mut();
|
||||
let side = core.side_window_for(FID).expect("side");
|
||||
core.windows.get_mut(&side).expect("win").last_content_cols += 1;
|
||||
},
|
||||
),
|
||||
("fold PROJECTION POLICY, owned by the view", |session| {
|
||||
let mut core = session.state.core.borrow_mut();
|
||||
let view = core.views.get_mut(&FID).expect("view");
|
||||
view.fold_projection = !view.fold_projection;
|
||||
}),
|
||||
("fold CONTENT, owned by the buffer", |session| {
|
||||
let core = session.state.core.borrow();
|
||||
let side = core.side_window_for(FID).expect("side");
|
||||
let buffer_id = core.windows[&side].buffer_id;
|
||||
let registry = core.registry.clone();
|
||||
let mut reg = registry.borrow_mut();
|
||||
let buffer = reg.get_mut(buffer_id).expect("buffer");
|
||||
let store = core.fold_registry.store_or_attach(buffer);
|
||||
store
|
||||
.lock()
|
||||
.expect("fold store mutex")
|
||||
.insert(pmacs_protocol::ByteRange { start: 0, end: 1 });
|
||||
}),
|
||||
];
|
||||
|
||||
for (name, mutate) in legs {
|
||||
let mut session = Session::new();
|
||||
open_panel(&session, "g2", 4);
|
||||
session.declare(1, 24, 80);
|
||||
let _ = session.present();
|
||||
|
||||
let before = mapping_generation(&mut session).expect("a key");
|
||||
mutate(&session);
|
||||
let after = mapping_generation(&mut session).expect("a key");
|
||||
assert!(
|
||||
after > before,
|
||||
"changing {name} changes which byte a cell means, so the key \
|
||||
must move; it did not ({before} → {after})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// §5b G2 — grid **rows** and **columns** are independent inputs,
|
||||
/// proven by a **transposition**.
|
||||
///
|
||||
/// Revision-16's version changed one dimension at a time and could not
|
||||
/// discriminate: `last_content_cols` co-varies with a column change, so
|
||||
/// collapsing the key to `rows * cols` stayed green. I recorded that as
|
||||
/// unwitnessable and claimed no production path reached a
|
||||
/// same-area transition. **That was wrong** — resize plus redeclare
|
||||
/// gets there: 4×80 → 8×40 holds the product at 320 while swapping the
|
||||
/// dimensions, and `last_content_cols` is not refreshed until the next
|
||||
/// render, so the two grid fields are isolated.
|
||||
#[test]
|
||||
fn g2_a_transposed_grid_moves_the_key_at_an_unchanged_area() {
|
||||
let mut session = Session::new();
|
||||
open_panel(&session, "g2t", 4);
|
||||
session.declare(1, 24, 80);
|
||||
let _ = session.present();
|
||||
|
||||
let before = mapping_generation(&mut session).expect("a key");
|
||||
|
||||
// 4×80 → 8×40. Same area, different shape, and no render between.
|
||||
assert!(
|
||||
session.state.apply_panel_resize_rows(FID, 8),
|
||||
"the resize must be accepted, or the transposition never happens"
|
||||
);
|
||||
session.declare(2, 24, 40);
|
||||
|
||||
let after = mapping_generation(&mut session).expect("a key");
|
||||
assert!(
|
||||
after > before,
|
||||
"a transposed grid inverts differently at the same area — a key \
|
||||
hashing rows*cols would not notice ({before} → {after})"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5b G3 — **focus** is a stable input.
|
||||
///
|
||||
/// Missing from the first version of G3, which covered only idle and
|
||||
/// cursor. Focus in particular is the one a naive
|
||||
/// implementation gets wrong, because the panel frame carries a
|
||||
/// `focused` flag and it is tempting to fold the whole frame into the
|
||||
/// key.
|
||||
#[test]
|
||||
fn g3_focus_is_a_stable_input() {
|
||||
let mut session = Session::new();
|
||||
open_panel(&session, "g3b", 4);
|
||||
session.declare(1, 24, 80);
|
||||
let _ = session.present();
|
||||
|
||||
let baseline = mapping_generation(&mut session).expect("a key");
|
||||
|
||||
// Focus. The band's `focused` flag flips; no byte moves.
|
||||
{
|
||||
let mut core = session.state.core.borrow_mut();
|
||||
let side = core.side_window_for(FID).expect("side");
|
||||
core.focus_window(FID, side);
|
||||
}
|
||||
assert_eq!(
|
||||
mapping_generation(&mut session),
|
||||
Some(baseline),
|
||||
"focus decides CHROME, never which byte a cell denotes — and a \
|
||||
click focuses the panel mid-gesture"
|
||||
);
|
||||
|
||||
// Styling is pinned STRUCTURALLY rather than by driving a theme
|
||||
// change here: `PanelMappingSnapshot` has no style field at all, so
|
||||
// there is nothing a recolour could touch. The terminal side — where
|
||||
// a convenient style-bumping counter DOES exist and had to be
|
||||
// rejected — is pinned in `screen.rs`'s own tests, at the level the
|
||||
// classification lives.
|
||||
}
|
||||
|
||||
/// §5b — the snapshot **selects the right domain by target kind**.
|
||||
///
|
||||
/// Added because mutation testing found the branch unwitnessed: routing
|
||||
/// terminal panels through the DOCUMENT arm — keying them on the
|
||||
/// buffer's revision, which §5b explicitly rejects — left all thirty-five
|
||||
/// other rows green. The daemon-level half of the terminal contract is
|
||||
/// that the branch is taken at all; `screen.rs` owns the half that says
|
||||
/// the revision it reads classifies events correctly.
|
||||
#[test]
|
||||
fn the_mapping_snapshot_picks_the_terminal_domain_for_a_terminal_panel() {
|
||||
// Document panel → the document domain.
|
||||
{
|
||||
let mut session = Session::new();
|
||||
open_panel(&session, "doc", 4);
|
||||
session.declare(1, 24, 80);
|
||||
let _ = session.present();
|
||||
let snapshot = session
|
||||
.state
|
||||
.panel_mapping_snapshot(FID)
|
||||
.expect("a presentable document panel");
|
||||
assert!(
|
||||
matches!(
|
||||
snapshot.content(),
|
||||
pmacs::editor::PanelMappingContent::Document { .. }
|
||||
),
|
||||
"a document panel is keyed on its buffer's content revision"
|
||||
);
|
||||
}
|
||||
|
||||
// Terminal panel → the terminal domain.
|
||||
{
|
||||
let mut session = Session::new();
|
||||
session.declare(1, 24, 80);
|
||||
exec(
|
||||
&session.state,
|
||||
"TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \
|
||||
args = { \"-c\", \"sleep 30\" }, display = \"panel\" }",
|
||||
);
|
||||
let _ = session.frame();
|
||||
let snapshot = session
|
||||
.state
|
||||
.panel_mapping_snapshot(FID)
|
||||
.expect("a presentable terminal panel");
|
||||
assert!(
|
||||
matches!(
|
||||
snapshot.content(),
|
||||
pmacs::editor::PanelMappingContent::Terminal { .. }
|
||||
),
|
||||
"a terminal panel is keyed on the SCREEN's mapping revision \
|
||||
and scroll anchor — its buffer revision tracks something \
|
||||
else entirely and would both miss real changes and fire on \
|
||||
non-changes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// §5b — the terminal key **across the seam**.
|
||||
///
|
||||
/// `screen.rs` proves the counter classifies events correctly, and the
|
||||
/// row above proves the snapshot picks the terminal domain. Neither
|
||||
/// notices a `view_mapping_identity` that returns a CONSTANT: the
|
||||
/// classification is right, the branch is taken, and the daemon's key
|
||||
/// still never moves. These rows join the two halves.
|
||||
#[test]
|
||||
fn g2_g3_a_terminal_panels_key_tracks_its_screen_and_anchor() {
|
||||
use pmacs::ansi::AnsiEvent;
|
||||
|
||||
let mut session = Session::new();
|
||||
session.declare(1, 24, 80);
|
||||
exec(
|
||||
&session.state,
|
||||
"TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \
|
||||
args = { \"-c\", \"sleep 30\" }, display = \"panel\" }",
|
||||
);
|
||||
let _ = session.frame();
|
||||
|
||||
let buffer_id = {
|
||||
let core = session.state.core.borrow();
|
||||
let side = core.side_window_for(FID).expect("side");
|
||||
core.windows[&side].buffer_id
|
||||
};
|
||||
let feed = |session: &Session, event: AnsiEvent| {
|
||||
assert!(
|
||||
session
|
||||
.state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.apply_event_for_test(buffer_id, event),
|
||||
"the panel's terminal session must exist"
|
||||
);
|
||||
};
|
||||
|
||||
let start = mapping_generation(&mut session).expect("a terminal panel has a key");
|
||||
|
||||
// CHANGING: a glyph appears where none was.
|
||||
feed(&session, AnsiEvent::Text("A".to_owned()));
|
||||
let after_glyph = mapping_generation(&mut session).expect("a key");
|
||||
assert!(
|
||||
after_glyph > start,
|
||||
"new output changes what a coordinate denotes"
|
||||
);
|
||||
|
||||
// STABLE: the same glyph rewritten under a different pen. This is
|
||||
// the row that forced `write_character` to compare glyphs — a
|
||||
// blanket bump made a recolour cancel the drag.
|
||||
feed(&session, AnsiEvent::CursorPosition { row: 1, col: 1 });
|
||||
feed(
|
||||
&session,
|
||||
AnsiEvent::SetStyle(pmacs_protocol::Style::default()),
|
||||
);
|
||||
feed(&session, AnsiEvent::Text("A".to_owned()));
|
||||
assert_eq!(
|
||||
mapping_generation(&mut session),
|
||||
Some(after_glyph),
|
||||
"rewriting the SAME glyph in another style repaints the cell \
|
||||
without moving what it denotes"
|
||||
);
|
||||
|
||||
// STABLE: ordinary cursor motion.
|
||||
feed(&session, AnsiEvent::CursorPosition { row: 2, col: 3 });
|
||||
assert_eq!(
|
||||
mapping_generation(&mut session),
|
||||
Some(after_glyph),
|
||||
"moving the caret denotes nothing new — and these paths were \
|
||||
advancing the revision until §5b's terminal correction"
|
||||
);
|
||||
|
||||
// CHANGING: the view's **scroll anchor** moves, with the child's
|
||||
// screen untouched. The same coordinate then names a different
|
||||
// retained row.
|
||||
//
|
||||
// This is the leg that separates the anchor from the screen
|
||||
// revision: the baseline is taken AFTER the history exists, so a
|
||||
// constant anchor leaves it unchanged and the assertion below fails
|
||||
// even though `mapping_revision` is perfectly live.
|
||||
let key = {
|
||||
let core = session.state.core.borrow();
|
||||
let side = core.side_window_for(FID).expect("side");
|
||||
pmacs::terminal::view::TerminalViewKey::new(FID, side, buffer_id)
|
||||
};
|
||||
let viewport = {
|
||||
let core = session.state.core.borrow();
|
||||
let grid = core.panel_grid_size(FID).expect("a presentable panel");
|
||||
pmacs_protocol::CellSize::new(grid.rows.saturating_sub(1), grid.cols)
|
||||
};
|
||||
for _ in 0..40 {
|
||||
feed(&session, AnsiEvent::LineFeed);
|
||||
}
|
||||
let anchor_of = |session: &Session| {
|
||||
session
|
||||
.state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.view_mapping_identity(key)
|
||||
.expect("the panel's terminal view")
|
||||
.1
|
||||
};
|
||||
|
||||
let before_scroll = mapping_generation(&mut session).expect("a key");
|
||||
let anchor_before = anchor_of(&session);
|
||||
assert!(
|
||||
session
|
||||
.state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.scroll_view(key, viewport, 3),
|
||||
"the scroll must actually move, or this leg proves nothing"
|
||||
);
|
||||
|
||||
assert_ne!(
|
||||
anchor_of(&session),
|
||||
anchor_before,
|
||||
"scrolling pins the view to a retained row, so the anchor moves"
|
||||
);
|
||||
let after_scroll = mapping_generation(&mut session).expect("a key");
|
||||
assert!(
|
||||
after_scroll > before_scroll,
|
||||
"the anchor is part of the mapping — the same coordinate now \
|
||||
names a different retained row, with the screen untouched"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,13 +182,8 @@ fn press_and_await_panel(session: &mut Session) -> bool {
|
|||
}),
|
||||
)
|
||||
.expect("write panel-open key");
|
||||
// §5b — whichever Present family this session negotiated. These
|
||||
// rows are about the band ARRIVING; which wrapper carries it is
|
||||
// pinned by the G6/G7/G8 rows, not incidentally here.
|
||||
drain_until(&mut session.stream, "panel", |message| match message {
|
||||
InstanceMessage::PanelFrame(
|
||||
PanelFramePayload::Present(frame) | PanelFramePayload::PresentMapped { frame, .. },
|
||||
) => Some(frame.size),
|
||||
InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(frame.size),
|
||||
_ => None,
|
||||
})
|
||||
.is_some()
|
||||
|
|
@ -256,8 +251,8 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
|
|||
);
|
||||
assert!(
|
||||
press_and_await_panel(&mut current),
|
||||
"a current-wire semantic session must be panel-capable and \
|
||||
receive a Present-family panel frame"
|
||||
"a v21-negotiated semantic session must be panel-capable and receive \
|
||||
a Present panel frame"
|
||||
);
|
||||
|
||||
// Half 3 — a semantic session that echoed the baseline is NOT
|
||||
|
|
@ -342,11 +337,9 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
|
|||
#[test]
|
||||
fn the_baseline_stays_and_the_counter_offer_activates() {
|
||||
// A deliberate tripwire: bumping the wire must be a conscious edit
|
||||
// here, not a silent one. v25 is the mapped panel family
|
||||
// (`PanelPointerMapped` + `PresentMapped`, bottom-panel §5b); v24 is
|
||||
// `TextInput` (GUI arc Stage 1a); v23 was `MinibufferPromptRows`
|
||||
// (Discovery Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
|
||||
assert_eq!(PROTOCOL_VERSION, 25);
|
||||
// here, not a silent one. v23 is `MinibufferPromptRows` (Discovery
|
||||
// Stage 2); v22 was `LineWrapFacts` (long-lines Stage 3).
|
||||
assert_eq!(PROTOCOL_VERSION, 23);
|
||||
assert_eq!(
|
||||
ADVERTISED_PROTOCOL_VERSION, 20,
|
||||
"moving this is the incompatible act the mechanism exists to avoid"
|
||||
|
|
|
|||
|
|
@ -25,4 +25,3 @@
|
|||
pub mod daemon;
|
||||
pub mod iso;
|
||||
pub mod pty;
|
||||
pub mod sigint_conformance;
|
||||
|
|
|
|||
|
|
@ -1,213 +0,0 @@
|
|||
//! Shared SIGINT-guard conformance vectors
|
||||
//! (`docs/gpu-probe-sigint-framing.md` §7c).
|
||||
//!
|
||||
//! # Why these live here rather than in each suite
|
||||
//!
|
||||
//! The contract is that the **shell** consumer (`scripts/gate`) and the
|
||||
//! **Rust** consumer (R-d's `sigint_diagnosis`) agree on every case.
|
||||
//! Two independently written copies of the list can drift while both
|
||||
//! still report "45 cases" — the same-length-different-content
|
||||
//! divergence this matrix exists to rule out. One generator, two
|
||||
//! consumers.
|
||||
|
||||
/// What a consumer must do with a given `(status, stdout)` pair.
|
||||
///
|
||||
/// `ValidatedError` and `Boundary` **both exit 2**, so a test comparing
|
||||
/// only exit codes cannot separate them — and a validator that accepted
|
||||
/// *every* status 2 would pass. They are told apart by whether the
|
||||
/// child's stderr is surfaced: a validated verdict speaks with the
|
||||
/// helper's voice; a boundary failure must not.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Safe,
|
||||
ValidatedIgnored,
|
||||
ValidatedError,
|
||||
Boundary,
|
||||
}
|
||||
|
||||
pub const TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe";
|
||||
pub const TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored";
|
||||
pub const TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error";
|
||||
|
||||
/// Emitted on stderr by the **branch-discriminating** cases — the
|
||||
/// cross-product rows and X1 — so a consumer's output can be searched
|
||||
/// for it: present ⇒ the child's stderr was surfaced.
|
||||
///
|
||||
/// **X3 and X4 deliberately carry their own payloads instead**, which
|
||||
/// is what makes them distinct inputs rather than duplicates of
|
||||
/// `1/E/empty` and `0/V/safe/bare`. An earlier revision gave every case
|
||||
/// this same sentinel and so shipped 45 entries over 43 distinct
|
||||
/// inputs; [`shared_cases`] now asserts uniqueness so that cannot
|
||||
/// recur silently.
|
||||
pub const SENTINEL: &str = "PMACS-CONFORMANCE-SENTINEL";
|
||||
|
||||
/// The canonical wording the helper uses for `ignored`. X3 emits it
|
||||
/// **without** a valid token, so a consumer that surfaced untrusted
|
||||
/// stderr would repeat it — the defect A6b forbids.
|
||||
pub const CANONICAL_IGNORED: &str =
|
||||
"pmacs: SIGINT is ignored; run this command with SIGINT deliverable";
|
||||
|
||||
pub struct Case {
|
||||
pub name: String,
|
||||
pub status: i32,
|
||||
pub stdout: Vec<u8>,
|
||||
/// Exact stderr this stub emits. Most cases use [`SENTINEL`]; X3
|
||||
/// and X4 carry their own payloads, which is what makes them
|
||||
/// distinct inputs rather than duplicates of other rows.
|
||||
pub stderr: String,
|
||||
pub expect: Outcome,
|
||||
}
|
||||
|
||||
/// A `/bin/sh` stub reproducing one case exactly: its stdout bytes, its
|
||||
/// own stderr payload, and its status.
|
||||
#[must_use]
|
||||
pub fn stub_script(case: &Case) -> String {
|
||||
let octal = case.stdout.iter().fold(String::new(), |mut acc, b| {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(acc, "\\{b:03o}");
|
||||
acc
|
||||
});
|
||||
format!(
|
||||
"#!/bin/sh\nprintf '{octal}'\necho '{}' >&2\nexit {}\n",
|
||||
case.stderr, case.status
|
||||
)
|
||||
}
|
||||
|
||||
/// The shared set: ten classes × encodings × three statuses, plus
|
||||
/// X1/X3/X4. Only the diagonal validates.
|
||||
///
|
||||
/// X2 — a spawn error with no status — is deliberately absent: the
|
||||
/// shell boundary cannot represent it, because an `exec` failure there
|
||||
/// becomes a status. Rust exercises it separately.
|
||||
#[must_use]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "the bulk is the generated vector list; splitting it would \
|
||||
separate a case from the outcome it encodes, which is the \
|
||||
one thing this file exists to keep together"
|
||||
)]
|
||||
pub fn shared_cases() -> Vec<Case> {
|
||||
let toks: [(&str, &[u8]); 3] = [
|
||||
("safe", TOKEN_SAFE),
|
||||
("ignored", TOKEN_IGNORED),
|
||||
("error", TOKEN_ERROR),
|
||||
];
|
||||
let mut out = Vec::new();
|
||||
for (idx, (name, correct)) in toks.iter().enumerate() {
|
||||
let status = i32::try_from(idx).expect("0..=2");
|
||||
let diagonal = match status {
|
||||
0 => Outcome::Safe,
|
||||
1 => Outcome::ValidatedIgnored,
|
||||
_ => Outcome::ValidatedError,
|
||||
};
|
||||
let mut lf = correct.to_vec();
|
||||
lf.push(b'\n');
|
||||
out.push(Case {
|
||||
name: format!("{status}/V/{name}/bare"),
|
||||
status,
|
||||
stdout: correct.to_vec(),
|
||||
stderr: SENTINEL.to_owned(),
|
||||
expect: diagonal,
|
||||
});
|
||||
out.push(Case {
|
||||
name: format!("{status}/V/{name}/lf"),
|
||||
status,
|
||||
stdout: lf,
|
||||
stderr: SENTINEL.to_owned(),
|
||||
expect: diagonal,
|
||||
});
|
||||
for (other, bytes) in &toks {
|
||||
if other == name {
|
||||
continue;
|
||||
}
|
||||
let mut olf = bytes.to_vec();
|
||||
olf.push(b'\n');
|
||||
out.push(Case {
|
||||
name: format!("{status}/M/{other}/bare"),
|
||||
status,
|
||||
stdout: bytes.to_vec(),
|
||||
stderr: SENTINEL.to_owned(),
|
||||
expect: Outcome::Boundary,
|
||||
});
|
||||
out.push(Case {
|
||||
name: format!("{status}/M/{other}/lf"),
|
||||
status,
|
||||
stdout: olf,
|
||||
stderr: SENTINEL.to_owned(),
|
||||
expect: Outcome::Boundary,
|
||||
});
|
||||
}
|
||||
let mut leading = vec![b'\n'];
|
||||
leading.extend_from_slice(correct);
|
||||
let mut extra = correct.to_vec();
|
||||
extra.extend_from_slice(b"\n\n");
|
||||
let mut spaces = b" ".to_vec();
|
||||
spaces.extend_from_slice(correct);
|
||||
spaces.push(b' ');
|
||||
let mut crlf = correct.to_vec();
|
||||
crlf.extend_from_slice(b"\r\n");
|
||||
let mut doubled = correct.to_vec();
|
||||
doubled.extend_from_slice(correct);
|
||||
let mut nul = correct.to_vec();
|
||||
nul.push(0);
|
||||
for (cls, bytes) in [
|
||||
("E/empty", Vec::new()),
|
||||
("U/unknown", b"pmacs-sigint-v2:safe".to_vec()),
|
||||
("L/leading-lf", leading),
|
||||
("X/extra-lf", extra),
|
||||
("S/spaces", spaces),
|
||||
("C/crlf", crlf),
|
||||
("D/doubled", doubled),
|
||||
("N/nul", nul),
|
||||
] {
|
||||
out.push(Case {
|
||||
name: format!("{status}/{cls}"),
|
||||
status,
|
||||
stdout: bytes,
|
||||
stderr: SENTINEL.to_owned(),
|
||||
expect: Outcome::Boundary,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.push(Case {
|
||||
name: "X1/status-126".to_owned(),
|
||||
status: 126,
|
||||
stdout: TOKEN_SAFE.to_vec(),
|
||||
stderr: SENTINEL.to_owned(),
|
||||
expect: Outcome::Boundary,
|
||||
});
|
||||
out.push(Case {
|
||||
name: "X3/ignored-text-no-token".to_owned(),
|
||||
status: 1,
|
||||
stdout: Vec::new(),
|
||||
// The canonical ignored wording WITHOUT a token: a consumer
|
||||
// that surfaced untrusted stderr would repeat it.
|
||||
stderr: CANONICAL_IGNORED.to_owned(),
|
||||
expect: Outcome::Boundary,
|
||||
});
|
||||
out.push(Case {
|
||||
name: "X4/stderr-noise".to_owned(),
|
||||
status: 0,
|
||||
stdout: TOKEN_SAFE.to_vec(),
|
||||
// Noise on stderr must not affect classification --- and this
|
||||
// payload is what distinguishes X4 from 0/V/safe/bare.
|
||||
stderr: "unrelated chatter on stderr".to_owned(),
|
||||
expect: Outcome::Safe,
|
||||
});
|
||||
// The set must be 45 DISTINCT inputs, not merely 45 entries. A
|
||||
// previous revision gave every case the same stderr, which silently
|
||||
// collapsed X3 into `1/E/empty` and X4 into `0/V/safe/bare` — 45
|
||||
// entries, 43 inputs, and two framing-specified cases quietly not
|
||||
// exercised. Asserted here rather than in each suite so no consumer
|
||||
// can forget it.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for case in &out {
|
||||
assert!(
|
||||
seen.insert((case.status, case.stdout.clone(), case.stderr.clone())),
|
||||
"duplicate conformance input at {}: (status, stdout, stderr) already present",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
assert_eq!(seen.len(), out.len(), "every case must be a distinct input");
|
||||
out
|
||||
}
|
||||
|
|
@ -67,32 +67,25 @@ use common::daemon::{TestDaemon, build_default_caps};
|
|||
/// server-first, so moving it locks out every already-shipped frontend
|
||||
/// before it can counter-offer. An additive family never needs it.
|
||||
#[test]
|
||||
fn the_wire_is_v25_and_the_advertised_baseline_is_unmoved() {
|
||||
fn the_wire_is_v23_and_the_advertised_baseline_is_unmoved() {
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 25,
|
||||
"v25 is the mapped panel family (bottom-panel §5b); v24 was \
|
||||
TextInput (GUI arc Stage 1a); v23 was MinibufferPromptRows"
|
||||
PROTOCOL_VERSION, 23,
|
||||
"v23 is MinibufferPromptRows (Discovery Stage 2)"
|
||||
);
|
||||
assert_eq!(
|
||||
ADVERTISED_PROTOCOL_VERSION, 20,
|
||||
"moving this is the incompatible act the counter-offer mechanism exists to avoid"
|
||||
);
|
||||
// The whole v12..=22 population this lane is compatible with is
|
||||
// still supported, AND the current wire is in the set. The loop
|
||||
// must run to `PROTOCOL_VERSION`, not to a literal: stopping at 23
|
||||
// let a supported range that ended at 23 pass this test while
|
||||
// `PROTOCOL_VERSION` was already 24 — the accepted half proved
|
||||
// nothing about the version the constant names.
|
||||
for version in 6..=PROTOCOL_VERSION {
|
||||
// still supported, and the set ends at the new wire — a widened set
|
||||
// is a failure rather than a silent pass.
|
||||
for version in 6..=23 {
|
||||
assert!(
|
||||
is_supported_protocol_version(version),
|
||||
"v{version} must still be supported"
|
||||
);
|
||||
}
|
||||
// The ceiling: the supported set ENDS at the current wire, which
|
||||
// is what makes an accidentally-widened set a failure rather than
|
||||
// a silent pass. Probes one PAST the top, so it moves with it.
|
||||
assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1));
|
||||
assert!(!is_supported_protocol_version(24));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -619,7 +612,7 @@ fn one_daemon_serves_a_v23_rows_session_and_a_frozen_v22_session() {
|
|||
// rather than after the interesting half has already passed.
|
||||
let (mut legacy, _legacy_fid) = attach_semantic(&daemon, 22);
|
||||
let (mut current, current_fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
|
||||
assert_eq!(PROTOCOL_VERSION, 25);
|
||||
assert_eq!(PROTOCOL_VERSION, 23);
|
||||
|
||||
// Open the real `M-x` through the real key path, then narrow to the
|
||||
// probe command by typing it — the candidate window is ten rows out
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
#![cfg(unix)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
|
|
@ -173,177 +171,6 @@ mod crdt {
|
|||
let _ = kill(Pid::from_raw(pid.cast_signed()), signal);
|
||||
}
|
||||
|
||||
/// R-d (framing §7c): refuse to run this test when `SIGINT` is not
|
||||
/// deliverable, and say so.
|
||||
///
|
||||
/// The test signals a process group and requires the launcher to
|
||||
/// exit. If `SIGINT` is inherited as `SIG_IGN` --- which a shell
|
||||
/// running a command in the background without job control sets,
|
||||
/// and which survives `fork` and `exec` --- the signal is a no-op
|
||||
/// and the launcher waits out the deadline. Without this the
|
||||
/// failure reads "child did not exit within 5s", which names a
|
||||
/// teardown defect that is not there; that misreading cost nine
|
||||
/// framing revisions (§4c).
|
||||
///
|
||||
/// Both consumers use the **same checked-in helper**, but that alone
|
||||
/// no longer makes them agree: each validates the
|
||||
/// `(status, token)` pair independently, in a different language.
|
||||
/// Revision 12's "they can never disagree" is withdrawn, and the
|
||||
/// shared matrix in `tests/common/sigint_conformance.rs` replaces
|
||||
/// it — both validators run the same vectors.
|
||||
///
|
||||
/// This consumer proceeds only on a validated `(0, safe)` pair. On a
|
||||
/// **boundary** failure the helper's stderr is untrusted and is
|
||||
/// withheld; only a validated verdict speaks with the helper's
|
||||
/// voice.
|
||||
const SIGINT_TOKEN_SAFE: &[u8] = b"pmacs-sigint-v1:safe";
|
||||
const SIGINT_TOKEN_IGNORED: &[u8] = b"pmacs-sigint-v1:ignored";
|
||||
const SIGINT_TOKEN_ERROR: &[u8] = b"pmacs-sigint-v1:error";
|
||||
|
||||
/// Does `stdout` carry exactly `token`, in one of the two permitted
|
||||
/// encodings? Grammar is `TOKEN | TOKEN LF` **as bytes** — no
|
||||
/// trimming, so a leading newline, a second newline, surrounding
|
||||
/// spaces, CRLF, a doubled token or a trailing NUL all fail.
|
||||
fn sigint_token_matches(stdout: &[u8], token: &[u8]) -> bool {
|
||||
stdout == token
|
||||
|| (stdout.len() == token.len() + 1
|
||||
&& stdout.starts_with(token)
|
||||
&& stdout[token.len()] == b'\n')
|
||||
}
|
||||
|
||||
/// The diagnosis for one helper invocation, validating the
|
||||
/// `(status, token)` pair rather than the status alone.
|
||||
///
|
||||
/// A status arriving without its token did not come from this
|
||||
/// helper — not hypothetical: on macOS a shell that cannot execute
|
||||
/// the helper exits 1, which a status-only ABI read as `ignored`
|
||||
/// (§4d). Rust compares `Command::output()` bytes directly; only
|
||||
/// the shell consumer needs capture files.
|
||||
fn sigint_diagnosis(helper: &Path) -> Result<(), String> {
|
||||
let out = match Command::new(helper).output() {
|
||||
// Rust's boundary differs from the shell's: a spawn error
|
||||
// has NO status, where a shell turns the same failure into
|
||||
// one. Conformance X2, Rust-only.
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"precondition undecidable --- SIGINT guard boundary error \
|
||||
(status=unavailable token=missing): could not execute {}: {error}",
|
||||
helper.display()
|
||||
));
|
||||
}
|
||||
Ok(out) => out,
|
||||
};
|
||||
let expected: &[u8] = match out.status.code() {
|
||||
Some(0) => SIGINT_TOKEN_SAFE,
|
||||
Some(1) => SIGINT_TOKEN_IGNORED,
|
||||
Some(2) => SIGINT_TOKEN_ERROR,
|
||||
_ => b"",
|
||||
};
|
||||
let token_ok = !expected.is_empty() && sigint_token_matches(&out.stdout, expected);
|
||||
let token_state = if out.stdout.is_empty() {
|
||||
"missing"
|
||||
} else if token_ok {
|
||||
"valid"
|
||||
} else {
|
||||
"unexpected"
|
||||
};
|
||||
let status = out
|
||||
.status
|
||||
.code()
|
||||
.map_or_else(|| "signal".to_owned(), |c| c.to_string());
|
||||
match (out.status.code(), token_ok) {
|
||||
(Some(0), true) => Ok(()),
|
||||
// A VALIDATED verdict: the helper's stderr is the diagnosis.
|
||||
(Some(1 | 2), true) => Err(format!(
|
||||
"precondition failed --- this is NOT a teardown defect. \
|
||||
(status={status} token={token_state})\n{}",
|
||||
String::from_utf8_lossy(&out.stderr).trim_end()
|
||||
)),
|
||||
// BOUNDARY: the child's stderr is UNTRUSTED and is not shown,
|
||||
// or a helper exiting 1 with no token but the canonical
|
||||
// ignored wording would still mislead the reader (A6b).
|
||||
_ => Err(format!(
|
||||
"precondition undecidable --- SIGINT guard boundary error \
|
||||
(status={status} token={token_state}). The helper's own \
|
||||
output is not trusted here and is not shown."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn sigint_helper_path() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/check-sigint-deliverable")
|
||||
}
|
||||
|
||||
fn require_sigint_deliverable() {
|
||||
if let Err(diagnosis) = sigint_diagnosis(&sigint_helper_path()) {
|
||||
panic!("{diagnosis}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A6/A6b/A6c, R-d consumer: the shared vectors, asserting the exact
|
||||
/// branch.
|
||||
///
|
||||
/// `ValidatedError` and `Boundary` both produce `Err`, so comparing
|
||||
/// `is_ok()` alone would let a validator that accepts every status 2
|
||||
/// pass. The branch-discriminating cases emit a sentinel on stderr;
|
||||
/// a validated verdict surfaces it, while X3/X4 carry dedicated
|
||||
/// payloads and a boundary failure must withhold untrusted stderr.
|
||||
#[test]
|
||||
fn rd_precondition_validates_the_whole_conformance_set() {
|
||||
// `super::` and NOT `crate::`: this file is ALSO compiled as a
|
||||
// nested module of `gpu_initial_target_acceptance.rs`, where
|
||||
// `crate::` is the outer test crate and has no `common`.
|
||||
use super::common::sigint_conformance::{
|
||||
CANONICAL_IGNORED, Outcome, SENTINEL, shared_cases, stub_script,
|
||||
};
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let cases = shared_cases();
|
||||
assert_eq!(cases.len(), 45, "the shared set is 45 cases");
|
||||
|
||||
for case in cases {
|
||||
let path = dir.path().join(case.name.replace('/', "_"));
|
||||
fs::write(&path, stub_script(&case)).expect("write stub");
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
let got = sigint_diagnosis(&path);
|
||||
let name = &case.name;
|
||||
match case.expect {
|
||||
Outcome::Safe => assert!(got.is_ok(), "case {name}: {got:?}"),
|
||||
Outcome::ValidatedIgnored | Outcome::ValidatedError => {
|
||||
let message = got.expect_err("a validated refusal");
|
||||
assert!(
|
||||
message.contains(SENTINEL),
|
||||
"case {name}: a validated verdict surfaces the helper's stderr: {message}"
|
||||
);
|
||||
assert!(message.contains("token=valid"), "case {name}: {message}");
|
||||
}
|
||||
Outcome::Boundary => {
|
||||
let message = got.expect_err("a boundary refusal");
|
||||
assert!(message.contains("boundary error"), "case {name}: {message}");
|
||||
assert!(
|
||||
!message.contains(CANONICAL_IGNORED),
|
||||
"case {name}: never repeats the canonical ignored wording \
|
||||
--- X3 emits exactly that with no token: {message}"
|
||||
);
|
||||
assert!(
|
||||
!message.contains(SENTINEL),
|
||||
"case {name}: a boundary failure must NOT surface the child's \
|
||||
stderr --- that is what separates it from a validated error, \
|
||||
which shares its outcome type: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// X2 --- Rust only: a shell exec failure becomes a status, so the
|
||||
// shell consumer cannot present a status-less spawn error.
|
||||
let message = sigint_diagnosis(&dir.path().join("absent")).expect_err("must not validate");
|
||||
assert!(
|
||||
message.contains("status=unavailable") && !message.contains(SENTINEL),
|
||||
"X2: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
fn wait_for_exit(child: &mut Child, timeout: Duration) -> std::process::ExitStatus {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
|
|
@ -1256,7 +1083,6 @@ mod crdt {
|
|||
|
||||
#[test]
|
||||
fn ctrl_c_on_launcher_group_does_not_reach_spawned_daemon() {
|
||||
require_sigint_deliverable();
|
||||
let temp = secure_tempdir();
|
||||
let socket = temp.path().join("signal.sock");
|
||||
let report = temp.path().join("signal-report");
|
||||
|
|
|
|||
|
|
@ -1,423 +0,0 @@
|
|||
//! GUI arc Stage 1a acceptance — `TextInput` at protocol v24.
|
||||
//!
|
||||
//! Framing: `docs/gui-stage1-input-framing.md` §5 (Q#S1-9 precedence)
|
||||
//! and §6's A1–A9.
|
||||
//!
|
||||
//! **These rows drive the real dispatch, not the classifier.** 1a's
|
||||
//! first review found A7 and A8 unreachable from the production
|
||||
//! producer while `text_input_payload` was perfectly correct: the
|
||||
//! intercept branch returned before classification, and a modal prompt
|
||||
//! or a focused terminal is exactly what makes intercept true. A test
|
||||
//! that exercises the pure function would have stayed green through
|
||||
//! that, so the rows here go through `dispatch_text_input` and, for the
|
||||
//! producer-side ones, through the real classifier at the real call
|
||||
//! site.
|
||||
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::FrontendId;
|
||||
|
||||
fn exec(s: &EditorState, src: &str) {
|
||||
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||
}
|
||||
|
||||
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||
}
|
||||
|
||||
fn editor_with(body: &str) -> EditorState {
|
||||
let s = EditorState::new_with_roots(&crate::iso::roots());
|
||||
if !body.is_empty() {
|
||||
exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})"));
|
||||
}
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
s
|
||||
}
|
||||
|
||||
fn buffer_text(s: &EditorState) -> String {
|
||||
let b: mlua::String = eval(
|
||||
s,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
String::from_utf8_lossy(&b.as_bytes()).into_owned()
|
||||
}
|
||||
|
||||
const FID: FrontendId = FrontendId::LOCAL;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// A6 — one commit is one edit, one undo unit, one hook fan-out
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// A6 — a multi-scalar commit is **one** edit and **one** undo unit.
|
||||
///
|
||||
/// This is the failure 1a exists to fix: as separate keypresses the same
|
||||
/// grapheme is two edits, so one undo leaves half of it behind.
|
||||
#[test]
|
||||
fn a6_a_multi_scalar_commit_is_one_edit_and_one_undo_unit() {
|
||||
let mut s = editor_with("");
|
||||
exec(
|
||||
&s,
|
||||
"_G.edits = 0
|
||||
pmacs.hook.add('buffer.after-edit', function() _G.edits = _G.edits + 1 end)",
|
||||
);
|
||||
|
||||
// A composed grapheme: base plus combining acute. Two scalars, one
|
||||
// thing the user meant to type.
|
||||
s.dispatch_text_input(FID, "e\u{301}");
|
||||
assert_eq!(buffer_text(&s), "e\u{301}");
|
||||
|
||||
let edits: i64 = eval(&s, "return _G.edits");
|
||||
assert_eq!(edits, 1, "one commit must fire ONE buffer.after-edit");
|
||||
|
||||
exec(&s, "pmacs.command.invoke('buffer.undo')");
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"",
|
||||
"one undo must remove the whole commit, not its last scalar"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// §5 provenance — the single/multi split
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// §5 — a SINGLE-scalar commit is indistinguishable from a keypress, so
|
||||
/// it must produce a real, consumable `TypedEditRecord`.
|
||||
///
|
||||
/// **Asserting `this_command` is not enough**, and that is the whole
|
||||
/// point of this row: review round 2 found the code rotating the command
|
||||
/// correctly while never completing the record, because arming and
|
||||
/// completing are different steps and only the insert primitives
|
||||
/// complete. `this_command` looked right and auto-pairing was broken.
|
||||
/// So this consumes the record through the same seam `pair.lua` uses.
|
||||
#[test]
|
||||
fn single_scalar_text_input_produces_a_consumable_typed_edit_record() {
|
||||
let mut s = editor_with("");
|
||||
exec(&s, "pmacs.pair._capture_records = true");
|
||||
|
||||
s.dispatch_text_input(FID, "(");
|
||||
|
||||
let (cp, ch, clean, il): (i64, String, bool, i64) = eval(
|
||||
&s,
|
||||
"local r = pmacs.pair._last_record
|
||||
return r.codepoint, r.char, r.clean, r.inserted_len",
|
||||
);
|
||||
assert_eq!(cp, 40, "exact codepoint for '('");
|
||||
assert_eq!(ch, "(");
|
||||
assert!(clean, "no intercept ran, so the effective triple is clean");
|
||||
assert_eq!(il, 1);
|
||||
|
||||
let this_command: String = eval(&s, "return pmacs.editor.this_command() or ''");
|
||||
assert_eq!(
|
||||
this_command, "buffer.self-insert",
|
||||
"and the command rotates, which is the half that already worked"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5 — a MULTI-scalar commit is **not** a keystroke: it creates no
|
||||
/// typed provenance and breaks the command chain.
|
||||
#[test]
|
||||
fn multi_scalar_text_input_creates_no_typed_provenance() {
|
||||
let mut s = editor_with("");
|
||||
exec(&s, "pmacs.pair._capture_records = true");
|
||||
|
||||
s.dispatch_text_input(FID, "e\u{301}");
|
||||
|
||||
let no_record: bool = eval(&s, "return pmacs.pair._last_record == nil");
|
||||
assert!(
|
||||
no_record,
|
||||
"a multi-scalar commit must not forge a typed-edit record"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5 — a MULTI-scalar commit **breaks the command chain**, as a paste
|
||||
/// does.
|
||||
///
|
||||
/// **The chain is PRIMED first, and that is what makes the row
|
||||
/// discriminating.** Starting from a fresh editor the chain is already
|
||||
/// empty, so an assertion that it is empty afterwards passes whether or
|
||||
/// not `break_command_chain` is called — the first version of this row
|
||||
/// did exactly that and would have survived deleting the call.
|
||||
#[test]
|
||||
fn multi_scalar_text_input_breaks_a_live_command_chain() {
|
||||
let mut s = editor_with("");
|
||||
|
||||
// Prime it with 1a's OWN single-scalar path, which rotates to
|
||||
// `buffer.self-insert`. A programmatic
|
||||
// `pmacs.command.invoke('buffer.self-insert')` cannot prime it:
|
||||
// rotation belongs to the dispatcher, and invoking the command
|
||||
// directly deliberately never rotates or arms.
|
||||
s.dispatch_text_input(FID, "x");
|
||||
let primed: String = eval(&s, "return pmacs.editor.this_command() or ''");
|
||||
assert_eq!(
|
||||
primed, "buffer.self-insert",
|
||||
"precondition: the chain is live before the commit"
|
||||
);
|
||||
|
||||
s.dispatch_text_input(FID, "e\u{301}");
|
||||
|
||||
let after: Option<String> = eval(&s, "return pmacs.editor.this_command()");
|
||||
assert_eq!(
|
||||
after, None,
|
||||
"a multi-scalar commit is not a command and must clear the chain"
|
||||
);
|
||||
}
|
||||
|
||||
/// The pairing consumer, end to end: a single-scalar `(` must auto-pair
|
||||
/// exactly as a typed `(` does. This is the behaviour the missing record
|
||||
/// silently disabled, stated in the terms a user would notice.
|
||||
#[test]
|
||||
fn single_scalar_text_input_auto_pairs_like_a_keypress() {
|
||||
let mut s = editor_with("");
|
||||
s.dispatch_text_input(FID, "(");
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"()",
|
||||
"auto-pairing consumes the typed-edit record; without one the \
|
||||
closer is never inserted"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// A7 — prompts consume scalars IN ORDER
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// A7 — a prompt accumulates the scalars in order.
|
||||
///
|
||||
/// Order is the contract: a reversed or set-wise delivery would still
|
||||
/// "consume" the text and would produce a different query.
|
||||
#[test]
|
||||
fn a7_a_prompt_consumes_scalars_in_order() {
|
||||
let mut s = editor_with("");
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.minibuffer.read({ prompt = 'x: ', on_accept = function() end })",
|
||||
);
|
||||
assert!(s.core.borrow().minibuffer.is_active(), "prompt is up");
|
||||
|
||||
s.dispatch_text_input(FID, "abc");
|
||||
|
||||
let content: String = eval(&s, "return pmacs.minibuffer.contents() or ''");
|
||||
assert_eq!(content, "abc", "in order, not reversed or reordered");
|
||||
assert_eq!(
|
||||
buffer_text(&s),
|
||||
"",
|
||||
"and the buffer underneath is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// A9 — the cap rejects rather than truncates
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// A9 — a payload at the cap is accepted whole. The complement of the
|
||||
/// rejection row: a cap that refused its own boundary value would be
|
||||
/// off by one in the direction nobody notices until a long IME commit
|
||||
/// vanishes.
|
||||
///
|
||||
/// **The rejection half is witnessed where it is enforced** — at the
|
||||
/// daemon boundary (`daemon.rs`, gated before any insert) and at the
|
||||
/// producer (`AttachClient::send_text_input`, whose unit test lives
|
||||
/// beside it in `pmacs-gpu`). Neither is reachable from an
|
||||
/// `EditorState`, so asserting the constant here instead would be a row
|
||||
/// that cannot fail for the right reason.
|
||||
#[test]
|
||||
fn a9_a_payload_at_the_cap_is_inserted_whole() {
|
||||
let mut s = editor_with("");
|
||||
let at_cap = "a".repeat(pmacs_protocol::TEXT_INPUT_MAX_BYTES);
|
||||
s.dispatch_text_input(FID, &at_cap);
|
||||
assert_eq!(
|
||||
buffer_text(&s).len(),
|
||||
pmacs_protocol::TEXT_INPUT_MAX_BYTES,
|
||||
"the boundary value is legal and must land intact"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// A8 — a terminal receives RAW UTF-8, never bracketed paste
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// A8, delivered rather than merely routed: the child process receives
|
||||
/// the exact UTF-8 bytes, **while bracketed-paste mode is ENABLED**, and
|
||||
/// no `ESC[200~` / `ESC[201~` markers.
|
||||
///
|
||||
/// **The enabled mode is the whole precondition.** With bracketed paste
|
||||
/// off, "no markers" is true of every code path including a paste, so
|
||||
/// the assertion would pass against the behaviour it exists to forbid.
|
||||
/// The row therefore waits for the child's own `ESC[?2004h` to be
|
||||
/// parsed, asserts the mode really is on, and only then types.
|
||||
///
|
||||
/// The contrast at the end is what makes it a discriminator: through the
|
||||
/// same terminal in the same mode, a PASTE does get the markers. One
|
||||
/// path bracketed and the other not, observed at the PTY.
|
||||
#[test]
|
||||
fn a8_a_terminal_receives_raw_utf8_with_bracketed_paste_enabled() {
|
||||
use pmacs::terminal::TerminalSpec;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let sink = dir.path().join("received");
|
||||
let sink_disp = sink.display().to_string();
|
||||
|
||||
let mut s = EditorState::new_with_roots(&crate::iso::roots());
|
||||
|
||||
// The child turns bracketed paste ON, then copies its stdin to a
|
||||
// file so the test can read exactly what arrived on the PTY.
|
||||
let script = format!("printf '\\033[?2004h'; exec cat > {sink_disp}");
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec!["-c".into(), script];
|
||||
spec.rows = 24;
|
||||
spec.cols = 80;
|
||||
let buffer_id = s
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.open(
|
||||
spec,
|
||||
&mut s.core.borrow_mut(),
|
||||
&mut s.process_supervisor.borrow_mut(),
|
||||
)
|
||||
.expect("open terminal");
|
||||
|
||||
// Point this frontend's view at the terminal buffer, the way a
|
||||
// daemon-side buffer switch does. Without it `active_terminal_key`
|
||||
// returns `None`, the terminal branch is never taken, and the row
|
||||
// fails for a setup reason rather than a behavioural one — which is
|
||||
// exactly how it first failed.
|
||||
let window_id = attach_terminal_view(&s, FID, buffer_id);
|
||||
let key = pmacs::terminal::TerminalViewKey::new(FID, window_id, buffer_id);
|
||||
// Precondition, asserted rather than assumed: this frontend's
|
||||
// ACTIVE window shows the terminal buffer. A row that silently
|
||||
// failed this would be testing the document path and reporting it
|
||||
// as a terminal result.
|
||||
{
|
||||
let core = s.core.borrow();
|
||||
let view = core.views.get(&FID).expect("view registered");
|
||||
let active = core.windows.get(&view.active).expect("active window");
|
||||
assert_eq!(active.buffer_id, buffer_id);
|
||||
assert!(
|
||||
s.terminal_manager.borrow().is_terminal(buffer_id),
|
||||
"and the manager agrees it is a terminal"
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for the child's mode-set to be parsed — a condition, not a
|
||||
// sleep, so a slow machine waits longer rather than failing.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
s.tick_processes();
|
||||
let on = s
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.modes_for_view(key)
|
||||
.is_some_and(|m| m.bracketed_paste);
|
||||
if on {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"child never enabled bracketed paste; the precondition this \
|
||||
row depends on was never established"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
// Multi-byte and multi-scalar, so a byte-level mistake shows up.
|
||||
let typed = "h\u{e9}llo\u{301}";
|
||||
s.dispatch_text_input(FID, typed);
|
||||
|
||||
// Waits for AT LEAST the full payload, then asserts exact equality.
|
||||
// Sound against a split write for a reason the contrast below does
|
||||
// not share: the gate is a lower bound on length, so a partial
|
||||
// delivery keeps waiting rather than being mistaken for a wrong
|
||||
// answer — and the equality can still fail for the real reason,
|
||||
// which a wait-for-exact-content loop could not.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let got = loop {
|
||||
s.tick_processes();
|
||||
let got = std::fs::read(&sink).unwrap_or_default();
|
||||
if got.len() >= typed.len() {
|
||||
break got;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the child never received the typed text; got {got:?}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&got),
|
||||
typed,
|
||||
"the PTY must receive the exact UTF-8 that was typed"
|
||||
);
|
||||
let text = String::from_utf8_lossy(&got).into_owned();
|
||||
assert!(
|
||||
!text.contains("\u{1b}[200~") && !text.contains("\u{1b}[201~"),
|
||||
"typed text must NOT be bracketed: {text:?}"
|
||||
);
|
||||
|
||||
// The contrast, through the same terminal in the same mode: a paste
|
||||
// IS bracketed. Without this the row above could pass because the
|
||||
// mode was somehow inert rather than because the code is right.
|
||||
assert!(
|
||||
s.dispatch_paste(FID, b"pasted"),
|
||||
"the terminal claims the paste"
|
||||
);
|
||||
//
|
||||
// **Wait for the COMPLETE sequence, not the opening marker.** PTY
|
||||
// delivery and the child's writes can split anywhere, so breaking
|
||||
// as soon as `ESC[200~` appears and then requiring the payload and
|
||||
// the closer is a race that fails on correct code — the closer may
|
||||
// simply not have arrived yet. Polling for the whole string makes a
|
||||
// partial write indistinguishable from "not yet", which is what it
|
||||
// is. Same rule the vterm suite follows when it waits for `row19`
|
||||
// rather than for a prefix of it.
|
||||
let want = "\u{1b}[200~pasted\u{1b}[201~";
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
s.tick_processes();
|
||||
let all = String::from_utf8_lossy(&std::fs::read(&sink).unwrap_or_default()).into_owned();
|
||||
if all.contains(want) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"a paste through the same terminal must be bracketed on both \
|
||||
sides; waited for {want:?}, saw {all:?}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a frontend view whose active window shows `buffer_id`.
|
||||
fn attach_terminal_view(
|
||||
state: &EditorState,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: pmacs::buffer::BufferId,
|
||||
) -> pmacs::window::WindowId {
|
||||
use pmacs::window::{FrontendView, Layout, Window, WindowId};
|
||||
let mut core = state.core.borrow_mut();
|
||||
let text_view = {
|
||||
let registry = core.registry.clone();
|
||||
let registry = registry.borrow();
|
||||
let buffer = registry.get(buffer_id).expect("buffer present");
|
||||
pmacs::text_view::TextView::new(buffer)
|
||||
};
|
||||
let window_id = WindowId::next();
|
||||
core.windows
|
||||
.insert(window_id, Window::new(window_id, buffer_id, text_view));
|
||||
core.register_frontend_view(
|
||||
frontend_id,
|
||||
FrontendView {
|
||||
layout: Layout::single(window_id),
|
||||
active: window_id,
|
||||
fold_projection: true,
|
||||
panel_capable: true,
|
||||
frame_geometry: None,
|
||||
panel_hidden: false,
|
||||
},
|
||||
);
|
||||
window_id
|
||||
}
|
||||
|
||||
#[path = "common/iso.rs"]
|
||||
mod iso;
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
//! GUI arc Stage 1a — the `TextInput` wire gate, against a real daemon.
|
||||
//!
|
||||
//! Separate from `gui_stage1a_acceptance` because these rows need a
|
||||
//! live daemon and a negotiated session; that suite is in-process.
|
||||
//!
|
||||
//! **The claim under test is a REFUSAL**, which is the hardest kind to
|
||||
//! witness honestly: "nothing happened" is also what a broken test,
|
||||
//! a dead daemon or a dropped connection look like. Every row here
|
||||
//! therefore pairs the refusal with a positive control on the same
|
||||
//! session — something that *does* take effect — so silence can only
|
||||
//! mean the gate fired.
|
||||
|
||||
#![cfg(feature = "crdt")]
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pmacs_protocol::{
|
||||
ADVERTISED_PROTOCOL_VERSION, AttachRequest, CellSize, FrontendCapabilities, FrontendEvent,
|
||||
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, SessionBootstrapRequest,
|
||||
TEXT_INPUT_MIN_VERSION, read_message, write_message,
|
||||
};
|
||||
|
||||
#[path = "common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
fn semantic_caps() -> FrontendCapabilities {
|
||||
FrontendCapabilities {
|
||||
synchronized_output: false,
|
||||
unicode_smp: true,
|
||||
true_color: true,
|
||||
mouse: false,
|
||||
bracketed_paste: false,
|
||||
terminal_kind: Some("stage1a".into()),
|
||||
multi_frontend: true,
|
||||
crdt_replica: true,
|
||||
semantic_render: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a semantic session that counter-offers `offer`.
|
||||
fn attach_semantic(
|
||||
daemon: &common::daemon::TestDaemon,
|
||||
offer: u32,
|
||||
) -> (UnixStream, pmacs_protocol::FrontendId) {
|
||||
let mut stream = daemon.connect();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.expect("set read timeout");
|
||||
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
|
||||
assert_eq!(
|
||||
hello.protocol_version, ADVERTISED_PROTOCOL_VERSION,
|
||||
"the server-first Hello stays at the compatibility baseline"
|
||||
);
|
||||
let fid = hello.assigned_frontend_id;
|
||||
write_message(
|
||||
&mut stream,
|
||||
&AttachRequest {
|
||||
protocol_version: offer,
|
||||
frontend_capabilities: semantic_caps(),
|
||||
initial_size: CellSize::new(24, 80),
|
||||
},
|
||||
)
|
||||
.expect("write AttachRequest");
|
||||
write_message(&mut stream, &SessionBootstrapRequest::default()).expect("write bootstrap");
|
||||
(stream, fid)
|
||||
}
|
||||
|
||||
fn pump<T>(
|
||||
stream: &mut UnixStream,
|
||||
what: &str,
|
||||
mut want: impl FnMut(&InstanceMessage) -> Option<T>,
|
||||
) -> T {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < deadline {
|
||||
match read_message::<InstanceMessage>(stream) {
|
||||
Ok(msg) => {
|
||||
if let Some(found) = want(&msg) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
Err(error) => panic!("{what}: read stopped: {error}"),
|
||||
}
|
||||
}
|
||||
panic!("timed out waiting for {what}");
|
||||
}
|
||||
|
||||
fn send_text_input(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, text: &str) {
|
||||
write_message(
|
||||
stream,
|
||||
&FrontendEvent::TextInput {
|
||||
frontend_id: fid,
|
||||
text: text.to_owned(),
|
||||
},
|
||||
)
|
||||
.expect("write TextInput");
|
||||
}
|
||||
|
||||
fn send_key(stream: &mut UnixStream, fid: pmacs_protocol::FrontendId, key: Key) {
|
||||
write_message(
|
||||
stream,
|
||||
&FrontendEvent::Key(KeyEvent {
|
||||
frontend_id: fid,
|
||||
key,
|
||||
mods: Modifiers::NONE,
|
||||
timestamp_ns: 0,
|
||||
}),
|
||||
)
|
||||
.expect("write key");
|
||||
}
|
||||
|
||||
/// **The discriminating witness for the inbound gate.** A session that
|
||||
/// negotiated v23 can still ENCODE `TextInput` — it is built from this
|
||||
/// same crate — so the daemon must refuse it on the authenticated
|
||||
/// session's negotiated version rather than trusting the producer to
|
||||
/// withhold.
|
||||
///
|
||||
/// The positive control is what makes the refusal legible: the SAME
|
||||
/// session then sends an ordinary `Key`, and that must take effect. So
|
||||
/// the session is alive, the stream is synchronized and the daemon is
|
||||
/// listening — silence about the `TextInput` is the gate, not the
|
||||
/// plumbing.
|
||||
#[test]
|
||||
fn a_v23_session_cannot_drive_an_edit_through_text_input() {
|
||||
assert_eq!(
|
||||
TEXT_INPUT_MIN_VERSION, 24,
|
||||
"this row is written against the v24 floor"
|
||||
);
|
||||
let daemon = common::daemon::TestDaemon::spawn();
|
||||
let (mut stream, fid) = attach_semantic(&daemon, TEXT_INPUT_MIN_VERSION - 1);
|
||||
|
||||
let buffer_id = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
|
||||
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// Refused: encoded by a peer that never declared v24.
|
||||
send_text_input(&mut stream, fid, "REFUSED");
|
||||
|
||||
// The positive control, on the same session and after it.
|
||||
send_key(&mut stream, fid, Key::Char('k'));
|
||||
|
||||
// The first edit that reaches this session must be the CONTROL's,
|
||||
// never the refused text. Ordering carries the proof: the daemon
|
||||
// processes a session's events in order, so the control's edit
|
||||
// arriving with no preceding `REFUSED` edit means the TextInput was
|
||||
// dropped rather than merely slow.
|
||||
let op = pump(&mut stream, "the control's edit", |msg| match msg {
|
||||
InstanceMessage::CrdtOp {
|
||||
buffer_id: b, op, ..
|
||||
} if *b == buffer_id => Some(op.bytes.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let text = String::from_utf8_lossy(&op).into_owned();
|
||||
assert!(
|
||||
!text.contains("REFUSED"),
|
||||
"a v23 session must not be able to insert through TextInput; got {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The complement, so the row above cannot pass because `TextInput` is
|
||||
/// broken outright: the SAME traffic on a v24 session **does** edit.
|
||||
#[test]
|
||||
fn a_v24_session_can_drive_an_edit_through_text_input() {
|
||||
let daemon = common::daemon::TestDaemon::spawn();
|
||||
let (mut stream, fid) = attach_semantic(&daemon, PROTOCOL_VERSION);
|
||||
|
||||
let buffer_id = pump(&mut stream, "first BufferSnapshot", |msg| match msg {
|
||||
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(*buffer_id),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
send_text_input(&mut stream, fid, "ACCEPTED");
|
||||
|
||||
let op = pump(&mut stream, "the TextInput edit", |msg| match msg {
|
||||
InstanceMessage::CrdtOp {
|
||||
buffer_id: b, op, ..
|
||||
} if *b == buffer_id => Some(op.bytes.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let text = String::from_utf8_lossy(&op).into_owned();
|
||||
assert!(
|
||||
text.contains("ACCEPTED"),
|
||||
"a v24 session must be able to insert through TextInput; got {text:?}"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -804,11 +804,11 @@ fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
|
|||
// three lines on purpose. The ceiling assertion is the load-bearing
|
||||
// one — it says the supported set ENDS here, which is what makes an
|
||||
// accidentally-widened set a failure rather than a silent pass.
|
||||
assert_eq!(PROTOCOL_VERSION, 25);
|
||||
for version in 6..=PROTOCOL_VERSION {
|
||||
assert_eq!(PROTOCOL_VERSION, 23);
|
||||
for version in 6..=23 {
|
||||
assert!(is_supported_protocol_version(version));
|
||||
}
|
||||
assert!(!is_supported_protocol_version(PROTOCOL_VERSION + 1));
|
||||
assert!(!is_supported_protocol_version(24));
|
||||
let sample = InstanceMessage::StatuslineSegments {
|
||||
buffer_id: BufferId::from_raw(9),
|
||||
left: vec![StatuslineSegment {
|
||||
|
|
|
|||
|
|
@ -888,11 +888,10 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
|
|||
panic!("timed out waiting for {what}");
|
||||
}
|
||||
|
||||
// Tripwire: a wire bump must be a conscious edit here. v25 is the
|
||||
// mapped panel family (bottom-panel §5b); v24 is `TextInput` (GUI
|
||||
// arc Stage 1a); v23 was `MinibufferPromptRows` (Discovery Stage 2);
|
||||
// v22 was `LineWrapFacts` (long-lines Stage 3).
|
||||
assert_eq!(PROTOCOL_VERSION, 25);
|
||||
// Tripwire: a wire bump must be a conscious edit here. v23 is
|
||||
// `MinibufferPromptRows` (Discovery Stage 2); v22 was
|
||||
// `LineWrapFacts` (long-lines Stage 3).
|
||||
assert_eq!(PROTOCOL_VERSION, 23);
|
||||
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
|
||||
&[
|
||||
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue