From 0fe1051d253c3477567e9e6a3e8a7f32b7332e64 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:05:44 -0400 Subject: [PATCH 01/91] =?UTF-8?q?docs(lean4):=20rev=205=20=E2=80=94=20re-s?= =?UTF-8?q?cout=20Stage=203=20and=20split=20it=20into=203a=20and=203b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stages 1 and 2 landed (#160, #161). Re-scouting Stage 3 against `main` @ `46a1b8f` — six merged PRs past the rev-4 snapshot — produced three findings that change the plan and four that confirm it. Two were established by running Lua in a fresh `EditorState` rather than by grep, and are marked *probed* in §0.1. **Stage 3 violated this document's own splitting rule.** §4 says "no PR in this arc mixes a cross-cutting substrate change with Lean feature content" and "a reviewer looking at Stage 3 sees only Lean" — while §4's own risk column for Stage 3 read "two `lsp.lua` generalizations". Those cannot both be true. One generalization shipped as Stage 2; the other is Q#LN9's dispatch seams, which modify `handle_server_requests` — confirmed the only production drain of LSP events, since `LspManager::take_all_events` has no non-test caller. By the test that justified splitting Stage 2 out, that is cross-cutting substrate. Stage 3 is now 3a (seams + canonicalizer, no Lean) and 3b (the Lean server), strictly sequential. **The Lean resolver could not satisfy the contract Stage 2 documented.** #161 established that a configured root reaches `file_uri_for` verbatim and that the resulting URI is the affinity key. Probed: `pmacs.editor.file_path()` is not canonical — opening `/linkpkg/sub/./../sub/a.lean` through a symlink yields `/linkpkg/sub/a.lean`, lexical collapse only. No canonicalize binding is exposed to Lua, and `pmacs.project.detect` canonicalizes but returns nil without a marker. So one Lake package opened by two spellings would spawn two `lake serve` processes — the bug Stage 2 was built to prevent, re-entered through Stage 3's door. New Q#LN20 adds a synchronous `pmacs.fs.canonicalize`; it rides 3a, and it serves every future function-valued root rather than only Lean's. Two alternatives are recorded with why they were rejected — the `detect`-anchored walk in particular is incorrect, not merely inelegant. **`pmacs.fs.stat` is unusable in the resolver.** It is async and the resolver runs synchronously inside `ensure_server` ← `attach_buffer` ← `buffer.after-load`, with no coroutine to await on. Probed: `io` and `os` are exposed in the sandbox, so the marker walk uses `io.open` — the opposite of what a reader would assume, hence Q#LN8 now says so. One edge, also probed: `io.open` succeeds on a directory, so the walk reads a byte rather than testing for a handle, and acceptance 24a bites the version that does not. Confirmed rather than changed: Q#LN7's stop-before-respawn is necessary (default policy is OnCrash, the termination handler never consults the exit code, and `maybe_restart` has no attempt ceiling — a broken `lake` respawns forever; `stop()` setting `restart = Never` is what disarms it); the response seam works as specified, since `Response` events are pushed unconditionally and `send_request` returns the keying id. One confirmation narrowed the design. `handle_server_requests` builds its sid list from `attachments` and `push_event` is uncapped, so subscribers fire only for servers with a live attachment. That turns acceptance 34 into a reachable leak: killing the buffer with a request outstanding strands the registration behind a drain that no longer runs. The purge is now driven from both edges and 34 exercises the buffer-kill path, which is the one a user can reach. Also: §9 states the lane's coherence impact per COHERENCE §20 (journey steps, interaction islands, config registry, background attribution), including the honest note that 3b makes §2's step-3 grade marginally worse by adding one more instance of the silent-spawn-failure class. Three items are named in §6 rather than paid: the uncapped event queue, the dropped `cfg.restart`, and surfacing the spawn failure itself. Acceptance keeps every rev-4 number. The two split sections are bulleted with literal labels because a markdown ordered list renumbers from its first item, and 3b's criteria are non-contiguous; round 3's finding 4 was stale references surviving a renumber, and not renumbering is the cheaper way to not repeat it. Stale cross-references from the split were reconciled in the same pass, and `project_root_for`'s citation was corrected from 513 to 592 per COHERENCE §25. --- docs/lean4-mode-framing.md | 555 ++++++++++++++++++++++++++++++------- 1 file changed, 461 insertions(+), 94 deletions(-) diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index e1fe060..fc31e01 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -6,8 +6,9 @@ pmacs has no Lean support of any kind: `grep -rin lean` over `*.rs`, plain buffer — no grammar, no major mode, no comment syntax, no pair set, no server. -This lane closes that in seven stages. Stage boundaries are drawn where +This lane closes that in eight stages. Stage boundaries are drawn where the *substrate* changes, not where the feature list does — see §4. +§9 states the lane's coherence impact per `COHERENCE.md` §20. ## 0. Why this lane, why now @@ -24,7 +25,7 @@ the *substrate* changes, not where the feature list does — see §4. first consumer of a non-standard LSP method family. Stage 6 adds a severity-routing policy to `LspServerSpec`. - The user's stated north star is **matching or exceeding what VS Code - does with Lean**. §5's bet 6 scores honestly how close seven stages get + does with Lean**. §5's bet 6 scores honestly how close the eight stages get and names precisely what is still missing. Parallel-safety: Stage 1 touches `Cargo.toml`, `src/syntax.rs`, @@ -34,10 +35,13 @@ Stage 3 (the other open lane) touches `pmacs-gpu/*` and `src/semantic_render.rs`. None of the three footprints overlap; the only file Stage 1 shares with anything is `Cargo.toml`, at one line. -Stages 1 and 2 are independent of each other and **can** run as sibling -worktrees — they share no file. Per the #126/#127 lesson, that split is -recorded here, before either starts, rather than discovered during a -rebase. +Stages 1 and 2 were independent of each other and could have run as +sibling worktrees — they shared no file. Both have since landed (#160, +#161). **Stages 3a and 3b are not independent**: 3b's subscriber is +written against the seam 3a adds, and both touch +`builtin/runtime/lsp.lua`. They are strictly sequential — recorded here, +per the #126/#127 lesson, before either starts rather than discovered +during a rebase. ## 0.1 Revision history @@ -81,7 +85,7 @@ round 2 renumbered the stages, so a rev-1 "Stage 4" is now Stage 5.)* 5. **Q#LN8's resolver must honor the search boundary.** A Lua `lean-toolchain` walk that ignores `pmacs.project.search_boundary()` breaks the contract `detect_project_within` exists to enforce and makes - the Stage 3 outermost-root test non-hermetic. + the Stage 3b outermost-root test non-hermetic. ### Round 2 (rev 2 → rev 3) — scope expansion @@ -168,11 +172,94 @@ Six findings against the round-2 expansion. All revision edits. preserving user-supplied `env`/`settings`/`init_options`/`root`. 6. Wording: `\{}` expands to `{$CURSOR}`; `⦃⦄` comes from `\{{}}`. +### Round 4 (rev 4 → rev 5) — Stage 3 re-scout and split + +Stages 1 and 2 landed (#160, #161). Re-scouting Stage 3 against `main` +@ `46a1b8f` — six merged PRs past the rev-4 snapshot (#159–#164) — +produced three findings that change the plan and four that confirm it. +Every fact below was verified in a worktree at that commit; the two +marked *probed* were established by running Lua in a fresh +`EditorState`, not by grep. + +1. **Stage 3 violated this document's own splitting rule.** §4 says "no + PR in this arc mixes a cross-cutting substrate change with Lean + feature content" and "a reviewer looking at Stage 3 sees only Lean" — + while §4's own risk column for Stage 3 read *"two `lsp.lua` + generalizations."* Those cannot both be true. One of the two landed + as Stage 2; the other is Q#LN9's dispatch seams, which modify + `handle_server_requests` — confirmed the **only** production drain of + LSP events (`LspManager::take_all_events` has no non-test caller). By + the same test that justified splitting Stage 2 out, that is + cross-cutting substrate. **Stage 3 is now 3a (substrate, no Lean) and + 3b (Lean).** +2. **The Lean resolver could not satisfy the contract Stage 2 + documented.** #161 established that a configured root — string or + resolver return — must be a canonical absolute path, because it + reaches `file_uri_for` verbatim and that URI is the affinity key. + *Probed:* `pmacs.editor.file_path()` is **not** canonical. Opening + `/linkpkg/sub/./../sub/a.lean`, where `linkpkg` symlinks to + `pkg`, yields `/linkpkg/sub/a.lean` — lexical `.`/`..` collapse + only, symlinks unresolved. No canonicalize binding is exposed to Lua, + and `pmacs.project.detect` canonicalizes but returns nil without a + marker. So a Lean resolver walking up from the buffer's path returns + a non-canonical root, and one package opened by two spellings spawns + two `lake serve` processes — reintroducing precisely the bug Stage 2 + exists to prevent. New Q#LN20 adds `pmacs.fs.canonicalize`; it rides + 3a because it is substrate, and it retires the footgun for every + future function-valued root rather than only Lean's. +3. **`pmacs.fs.stat` is unusable in the resolver.** It is asynchronous — + `fs.lua:93` returns an awaitable handle — and the resolver runs + synchronously inside `ensure_server` ← `attach_buffer` ← the + `buffer.after-load` hook, where there is no coroutine to await on. + *Probed:* the `io` and `os` stdlib **are** exposed in the sandbox + (`type(io.open) == "function"`; `terminal.lua` already uses + `os.getenv`), and `io.open` returns nil for a missing path. So the + marker walk is implementable, but through the Lua stdlib rather than + the pmacs fs API — the opposite of what a reader would assume. + Q#LN8 now says so, with the one edge that matters: `io.open` + **succeeds on a directory**, so a bare existence check would accept + a `lean-toolchain` *directory* as a marker. + +Confirmations, recorded because each was load-bearing and unverified: + +4. **Q#LN7's "stop the failing server first" is necessary, not + defensive.** The spec default is `LspRestartPolicy::OnCrash`, and the + termination handler calls `should_restart(policy)` + (`matches!(OnCrash | Always)`) — which, unlike the + `termination_warrants_restart` helper beside it, never consults the + exit code. `maybe_restart` re-fires on every elapsed backoff with **no + attempt ceiling**, so a broken `lake` respawns forever. `stop()` sets + `restart = Never` (`src/lsp.rs:1349`), which is exactly what disarms + it. Acceptance 36 pins a real mechanism. +5. **The response seam works as designed.** `Response` events are pushed + unconditionally (`src/lsp.rs:2652`) — the typed-store absorb above + does not consume them — and reach Lua as `{kind = "response", + request_id = , method, result, error}`, with + `pmacs.lsp.send_request` returning that same numeric id. So + `on_response(sid, request_id, fn)` is keyable as specified. +6. **The seams' contract is narrower than rev 4 implied, and the + narrowing is load-bearing.** `handle_server_requests` builds its sid + list from `attachments`, and `push_event` appends with no cap. So a + subscriber fires only for a server with a live attachment, and an + unattached server's event queue grows unboundedly. This bites + acceptance 34 directly: kill the buffer with a request outstanding + and the pending purge never runs — the leak that criterion exists to + prevent. Q#LN9 now states the contract and acceptance 34 drives it + through the buffer-kill path rather than the server-death path alone. +7. **The `cfg.restart` gap is still open** (recorded landing #161): + `ensure_server` never forwards `pmacs.lsp.config[lang].restart` to + `pmacs.lsp.spawn`, so the field is silently dropped on auto-attach. + Stage 3b is the first stage that would benefit from setting it, and + Q#LN7 now records why it deliberately does not need it. + +Citation drift repaired per COHERENCE §25: `project_root_for` is +`builtin/runtime/lsp.lua:592`, not 513, and returns `root, source` +rather than a bare root. ## 1. What ships -Seven stages. The north star is VS Code parity; the honest statement of -where that lands is in §5, bet 6. +Eight stages, after round 4 split Stage 3. The north star is VS Code +parity; the honest statement of where that lands is in §5, bet 6. **Stage 1 — grammar, mode, and the editing table stakes.** `.lean` files highlight, carry a `lean4` major mode, and get comment-toggle and @@ -185,13 +272,21 @@ Independently valuable for every language pmacs supports; a prerequisite for Lean being usable across more than one Lake package. Split out precisely *because* it is cross-cutting — see §4. -**Stage 3 — the Lean language server.** `pmacs.lsp.config.lean4` drives +**Stage 3a — LSP dispatch seams and a path canonicalizer.** Pure +substrate, no Lean content, split from Stage 3 in round 4 for the reason +Stage 2 was: it changes machinery every language runs through. +`handle_server_requests` gains notification and response arms with a +pending-response purge, so a `send_request` reply is no longer drained +and dropped; `pmacs.fs.canonicalize` gives Lua the one primitive a +function-valued `config.root` needs to honor the canonical-path contract +#161 could only document. + +**Stage 3b — the Lean language server.** `pmacs.lsp.config.lean4` drives `lake serve` with a Lake-aware outermost root, a lazy toolchain probe and -a one-shot `lean --server` fallback, and a notification-subscription seam -so `$/lean/fileProgress` has an owner. Adds -`textDocument/waitForDiagnostics`. Diagnostics, hover, completion, -goto-definition, document symbols, and semantic tokens all arrive through -the existing typed surfaces. +a one-shot `lean --server` fallback, and subscribes `$/lean/fileProgress` +on 3a's seam. Adds `textDocument/waitForDiagnostics`. Diagnostics, hover, +completion, goto-definition, document symbols, and semantic tokens all +arrive through the existing typed surfaces. **Stage 4 — the Unicode input method.** Typing `\alpha` produces `α`, `\to` produces `→`, `\<>` produces `⟨⟩` with the point between them. @@ -215,6 +310,13 @@ panel. ## 2. Ground truth (scouted 2026-07-24, `main` @ `e745068`) +Stage 3's facts were **re-verified 2026-07-25 against `main` @ +`46a1b8f`**, six merged PRs later; what changed is recorded in §0.1's +round 4 rather than rewritten in place, so a reader can see which +claims moved. Facts for stages 4–7 still carry the 2026-07-24 date and +should be re-scouted before those stages are framed for +implementation. + ### 2.1 Crate facts (external, verified by downloading and reading both) Two candidate grammar crates exist. They are not close in quality. @@ -528,7 +630,7 @@ PATH, both are executable, and both fail. So: old, and lake working but the directory is not a Lake package. Only the third is a *version* question. - **Acceptance cannot assume a working Lean toolchain exists.** Every - Stage 3+ test runs against the fake LSP server; a live `lake serve` + Stage 3b+ test runs against the fake LSP server; a live `lake serve` smoke is PATH-gated *and* success-gated, following the #123 JSON/YAML provider-smoke pattern. @@ -706,6 +808,27 @@ consulted before configuring. the failing server *first*, then swaps the config, then spawns — the fallback is a fresh server, not a restart of the old one. + Round 4 verified this is necessary rather than defensive. The spec + default is `LspRestartPolicy::OnCrash` (`src/lsp.rs:165`), and the + termination handler calls `should_restart(policy)` — which, unlike the + `termination_warrants_restart` helper beside it, never consults the + exit code. `maybe_restart` re-fires on every elapsed backoff with **no + attempt ceiling**, so a broken `lake` respawns indefinitely. + `pmacs.lsp.stop` sets `restart = Never` on the way out + (`src/lsp.rs:1349`), which is precisely what disarms it. Acceptance 36 + is pinning a live mechanism, not a hypothetical one. + + **Why the latch does not just set `restart = "never"` on the spawn.** + It cannot: `ensure_server` never forwards `cfg.restart` to + `pmacs.lsp.spawn` — `lua_to_lsp_spec` reads the key but the spawn + table never sets it — so the field is silently dropped on every + auto-attach today. That gap was found landing #161 and is not Stage + 3's to close (it changes behavior for every language that has set + `restart` believing it worked; `statusline_segments_acceptance` a12 is + one such caller). The stop-then-spawn ordering is correct regardless of + how that gap is eventually resolved, which is the reason to prefer it + over a fix that depends on the gap closing first. + **The swap is a field update, not a table replacement.** It rewrites only `command` and `args`, preserving any user-supplied `env`, `settings`, `init_options`, and `root` on `pmacs.lsp.config.lean4`. A @@ -732,18 +855,60 @@ fallback. That is a one-line status message, once per session, and it buys not blocking every other user's first attach behind a process round-trip. +**Attribution (COHERENCE §9).** The probe is background work that spawns +an OS process, and `ProcessSpec.label` is the only identity a process +carries — caller-supplied and unvalidated, but it is what +`pmacs.process.list` renders. The probe spawns as `lean:lake-version-probe` +rather than inheriting a default, so a user who looks at the process list +while wondering why their editor touched `lake` finds an answer with an +owner in it. Both the probe's verdict and the latch firing report through +`pmacs.editor.set_status` — the channel that exists — per §1.2's rule and +its corollary: each is pinned by a test that observes the channel, since a +report through `pmacs.error` would be a dead sixteenth call site. + No `init_options`. Per §2.8, `hasWidgets?` defaults to false and that is the correct value for a client that reads plain goals out of standard messages. ### Q#LN8 — Lake-aware root via a **function-valued** `config.root` -Generalize `project_root_for` (`builtin/runtime/lsp.lua:513`) so -`pmacs.lsp.config[lang].root` may be a `function(path) -> string|nil` as -well as a string, and implement Lean's resolver in -`builtin/runtime/lean.lua`: walk up from the file's directory collecting -every ancestor containing `lean-toolchain`, and return the **outermost**; -fall back to `pmacs.project.detect`, then the file's directory. +**The generalization landed in Stage 2 (#161).** `project_root_for` is +now `builtin/runtime/lsp.lua:592` and returns `root, source`; +`config[lang].root` already accepts a `function(path) -> string|nil`, +with per-directory memoization keyed weakly on the resolver itself. What +remains for Stage 3b is Lean's resolver in `builtin/runtime/lean.lua`: +walk up from the file's directory collecting every ancestor containing +`lean-toolchain`, and return the **outermost**; decline (return nil) when +there is none, which falls through to `pmacs.project.detect` and then the +file's directory. + +**How the walk tests for the marker — and why not the obvious way.** +`pmacs.fs.stat` is asynchronous: it returns an awaitable handle +(`builtin/runtime/fs.lua:93`) that only settles under `:await()` inside a +coroutine. The resolver has no coroutine. It runs synchronously inside +`ensure_server` ← `attach_buffer` ← the `buffer.after-load` hook, so +awaiting is not merely slow there, it is unavailable — and blocking the +attach on filesystem I/O is the cost rev 1 refused for the probe. The +walk therefore uses the **Lua stdlib**: `io.open(dir .. "/lean-toolchain", +"r")`, which returns nil for a missing path. Round 4 probed that `io` and +`os` are exposed in the sandbox rather than assuming it; `terminal.lua` +already depends on `os.getenv`. + +One edge, probed: **`io.open` succeeds on a directory** (the handle opens; +`read` returns nil without raising). A `lean-toolchain` *directory* would +therefore read as a marker. The resolver reads one byte and treats a +non-nil read as the marker, so a directory declines — an `io.open` truth +test alone would be wrong, and wrong silently. + +**The result must be canonical.** #161's contract: a configured root +reaches `file_uri_for` verbatim and that URI is the affinity key, so two +spellings of one package are two servers. The path handed to the resolver +is *not* canonical (round 4, finding 2), and Lua had no canonicalizer — +hence Q#LN20. The resolver canonicalizes the file's directory **once**, +before the walk, and strips components from there: every ancestor of a +canonical path is itself canonical, so one call suffices. If +canonicalization fails (a deleted file, a broken symlink), the resolver +declines rather than returning a path it cannot vouch for. **The walk stops at `pmacs.project.search_boundary()`.** This is not optional politeness: `detect_project_within` (`src/project.rs:213`) exists @@ -777,7 +942,7 @@ write-only API from Lua.** Rev 2 specified only the notification half. That was a hole, since Q#LN16 (`waitForDiagnostics`), Q#LN19 (`imports` / `importedBy`), and Q#LN12's typed goal request all await replies. Both halves ship in -Stage 3. +Stage 3a. ```lua pmacs.lsp.on_notification(method, fn) -- fn(sid, params); persistent @@ -808,10 +973,70 @@ directions: a Lean subscriber must not cause `workspace/applyEdit` to be missed, and a raising subscriber must not stop later events in the same drain. -Stage 3 registers `$/lean/fileProgress` on the notification seam and +**The seam's contract, stated because round 4 found it narrower than rev +4 implied: subscribers fire only for servers with a live buffer +attachment.** `handle_server_requests` builds its sid list from +`attachments`, so a server with no attached buffer is never drained — and +`push_event` appends with no cap, so that server's queue grows +unboundedly. Both facts are pre-existing and neither is Stage 3a's to +fix, but the second one turns the first into a leak with a name: **a +buffer killed while a request is outstanding never runs the purge**, +because the purge rides the drain that the attachment was gating. That is +the exact failure acceptance 34 exists to prevent, reachable through the +ordinary `C-x k`. So the purge is driven from both edges — the server-death +transition *and* attachment teardown — and acceptance 34 exercises the +buffer-kill path, which is the one a user can actually reach. + +The uncapped queue is recorded as a named deferral (§6) rather than fixed +here: bounding it is a policy question about which events may be dropped, +and answering it inside a seam PR would be the kind of smuggling §4 +forbids. + +Stage 3b registers `$/lean/fileProgress` on the notification seam and `waitForDiagnostics` on the response seam; stages 5 and 7 use the response seam for `plainGoal` and the hierarchy calls. +### Q#LN20 — `pmacs.fs.canonicalize` (Stage 3a) + +A synchronous binding wrapping `std::fs::canonicalize`, returning the +resolved absolute path or nil. Roughly fifteen lines. + +It exists because #161 documented an obligation Lua cannot discharge. A +configured root — string or resolver return — is fed to `file_uri_for` +verbatim, and that URI is the server-affinity key; the `"detected"` arm is +canonicalized for free because `pmacs.project.detect` canonicalizes before +walking, but the `"config"` arm is not. Round 4 probed that +`pmacs.editor.file_path()` collapses `.` and `..` lexically while leaving +symlinks intact, so a resolver walking up from it returns a non-canonical +root. Opening one Lake package through a symlinked path and through the +real path would spawn two `lake serve` processes — the bug Stage 2 was +built to prevent, re-entered through Stage 3b's door. + +**Synchronous, deliberately, and this is the one thing to get right.** +The whole reason `pmacs.fs.stat` cannot serve here is that it is async +(Q#LN8), so a canonicalizer that returned an awaitable would fail for the +same reason and leave the obligation undischarged. It is one `stat`-class +syscall on a path the editor is already opening; `pmacs.project.detect` +performs the same work synchronously today, on the same hook, so this +adds no blocking class that the attach path does not already have. + +Why this rather than the two alternatives considered in round 4: + +- *Accept it as a named degradation* — document that a symlinked open + spawns a second server and pin the behavior. Rejected: it reopens the + defect Stage 2 closed, and the failure is invisible (two servers, both + apparently working, twice the memory, diagnostics split between them). +- *Anchor the walk on `pmacs.project.detect`'s canonical root* — free, no + new surface. Rejected as incorrect, not merely inelegant: `detect` is + innermost-wins over its own marker set, so with `.git` at `~/code` and + the Lake package at `~/code/proj`, anchoring at `~/code` and walking + *up* never sees `~/code/proj/lean-toolchain`. It resolves the wrong root + in a layout that is entirely ordinary. + +The binding is general, not Lean-shaped: it serves every future +function-valued `root`, and it is what lets #161's doc comment stop +warning about a footgun and start naming a fix. + ### Q#LN10 — Stage 4 mechanism: one shared provenance read, not two The hazard is §2.6 — `take_typed_edit()` is one-shot and `pair.lua` @@ -922,8 +1147,9 @@ stage numbers and was wrong three ways): | Stage | Rust | |---|---| | 1 | `Cargo.toml` + `BUILTIN_LANGUAGES` entry + Q#LN4's four capture entries | -| 2 | `lsp.list()` row builder (`mod.rs:9919`) | -| 3 | **none** — Lua only | +| 2 | `lsp.list()` row builder (`mod.rs:9926`) | +| 3a | `pmacs.fs.canonicalize` (Q#LN20) — the seams themselves are Lua only | +| 3b | **none** — Lua only | | 4 | **none** — Lua only | | 5 | `request_plain_goal` + its binding | | 6 | `LspServerSpec` severity-policy field and its publish-path honoring | @@ -1045,7 +1271,7 @@ elaboration is memory-hungry. rust-analyzer has the same property and no editor caps it by default. No cap ships here; `pmacs.lsp.stop` is the manual escape, and an LRU reaping policy is named in §6. -### Q#LN16 — `textDocument/waitForDiagnostics` (Stage 3) +### Q#LN16 — `textDocument/waitForDiagnostics` (Stage 3b) A plain request (no position, so no `outbound_position` concern — Q#LN12 does not apply). It resolves when the server has finished elaborating the @@ -1125,28 +1351,48 @@ never lands. |---|---|---|---| | 1 | grammar, mode, comments, pairs, md fences | new crate; **global capture table** | — | | 2 | multi-root server affinity | **`ensure_server`, shared by every language** | — | -| 3 | `lake serve` + probe/latch, Lake root, notification seam, `waitForDiagnostics` | two `lsp.lua` generalizations | 1, 2 | +| 3a | notification/response seams + purge; `pmacs.fs.canonicalize` | **the shared event drain, run by every language** | — | +| 3b | `lake serve` + probe/latch, Lake root, `waitForDiagnostics` | none — Lean-only files plus one config entry | 1, 2, 3a | | 4 | Unicode input method | **refactors `pair.lua`'s provenance read** | 1 | -| 5 | goal panel | new typed LSP request; panel adopter | 3 | -| 6 | `#eval` / `#check` output channel | **new `LspServerSpec` policy field** | 3, 5 | -| 7 | module hierarchy | listview adopter + one typed Rust request | 3 | +| 5 | goal panel | new typed LSP request; panel adopter | 3a, 3b | +| 6 | `#eval` / `#check` output channel | **new `LspServerSpec` policy field** | 3b, 5 | +| 7 | module hierarchy | listview adopter + one typed Rust request | 3a, 3b | -Three of the seven carry risk that is *not* about Lean — stages 1, 2, and -6 each change something every language touches. That is the organizing -principle of the split: **no PR in this arc mixes a cross-cutting -substrate change with Lean feature content.** A reviewer looking at Stage -2 sees only `ensure_server`; a reviewer looking at Stage 3 sees only Lean. +Four of the eight carry risk that is *not* about Lean — stages 1, 2, 3a, +and 6 each change something every language touches. That is the +organizing principle of the split: **no PR in this arc mixes a +cross-cutting substrate change with Lean feature content.** A reviewer +looking at Stage 2 sees only `ensure_server`; a reviewer looking at Stage +3b sees only Lean. + +Round 4 found Stage 3 breaking that rule while stating it — the row above +used to read "two `lsp.lua` generalizations" for a stage the prose called +Lean-only. One generalization shipped as Stage 2; extracting the other as +3a is what makes the claim true again. The rule is only worth writing +down if it survives contact with a stage that is inconvenient to split. Ordering notes: - **Stage 2 has no Lean in it and could ship independently of this arc.** It is sequenced here because Lean is the language that makes its absence - a correctness bug rather than an inconvenience, and because Stage 3's + a correctness bug rather than an inconvenience, and because Stage 3b's acceptance would otherwise have to encode the broken behavior. -- **Stage 4 does not depend on stages 2–3** and could run in parallel, but - should not: both touch `lsp.lua`/`pair.lua`-adjacent runtime files, and - the #126/#127 lesson is that parallel-safety requires the file split be - agreed *before* either lane starts. Sequential is cheaper. +- **Stage 3a likewise has no Lean in it**, and the same reasoning applies + one level down: the response seam is a hole in `send_request` for every + language — Lean is merely the first caller that needs a reply. It is + sequenced before 3b because 3b's `waitForDiagnostics` and file-progress + subscription both consume it, and because a Lean PR that also rewrote + the shared drain could not be reviewed on either axis. +- **3a and 3b cannot run as sibling worktrees.** 3b's Lean subscriber is + written against the seam 3a adds, and both touch + `builtin/runtime/lsp.lua`. Unlike stages 1 and 2, this pair is strictly + sequential — recorded here, per the #126/#127 lesson, rather than + discovered in a rebase. +- **Stage 4 does not depend on stages 2, 3a, or 3b** and could run in + parallel, but should not: both touch `lsp.lua`/`pair.lua`-adjacent + runtime files, and the #126/#127 lesson is that parallel-safety + requires the file split be agreed *before* either lane starts. + Sequential is cheaper. - **Stage 6 depends on Stage 5** only for the read-only generated-buffer and panel machinery, which Stage 5 establishes. If Stage 5 slips, Stage 6 can carry that machinery itself at the cost of duplicating it. @@ -1185,7 +1431,7 @@ Stated so they can be scored, per house style. inside `buffer.after-edit` re-enters the hook in a way pairing does not already survive. Confidence: medium — pairing does the same thing, but over a single codepoint rather than a multi-byte span. -6. **These seven stages reach rough VS Code parity for everything except +6. **These eight stages reach rough VS Code parity for everything except the interactive infoview.** Scored honestly rather than aspirationally. What lands: highlighting, goal view, Unicode input, diagnostics, hover, completion, goto-definition, symbols, semantic tokens, `#eval` @@ -1225,6 +1471,18 @@ What remains deferred: unbounded `lake serve` growth possible. No editor caps this by default and pmacs will not either in this arc, but the policy question is now live in a way it was not before. +- **The uncapped LSP event queue** — `push_event` appends without a + bound, and `handle_server_requests` drains only servers with a live + buffer attachment, so an unattached server's events accumulate for the + life of the session (round 4, finding 6). Bounding it means deciding + which events may be dropped, which is a policy question with + user-visible consequences for diagnostics and progress; Stage 3a states + the seam's contract around the behavior rather than changing it. +- **Forwarding `cfg.restart` through `ensure_server`** — read by + `lua_to_lsp_spec`, never set by the spawn table, so silently dropped on + every auto-attach (found landing #161). Fixing it changes behavior for + every language whose config sets the field believing it works. Q#LN7 is + designed not to need it. - **Block-comment toggle** (`/- -/`) and **docstring awareness** (`/-- -/`) — confirmed as owned by the comment arc's framing, not this one. @@ -1309,58 +1567,100 @@ What remains deferred: the markerless one's server carries the fallback directory as `cwd` while matching on a nil affinity key. -**Stage 3 — the Lean language server** +**Stage 3a — dispatch seams and the canonicalizer (no Lean content)** -22. Opening a `.lean` file inside a Lake package spawns one server with - `cwd` and `rootUri` at the package root. -23. **Outermost-root pin:** a file under - `/.lake/packages/dep/…` whose ancestor chain contains two - `lean-toolchain` files resolves to ``, not to `dep`. Run with - `pmacs.project.set_search_boundary` at the fixture root so the - assertion is hermetic. -24. **Boundary pin:** with the search boundary set at the fixture root, a - `lean-toolchain` planted in an ancestor *above* the boundary is not - reached — the resolver stops at the boundary rather than walking past - it. -25. A string-valued `pmacs.lsp.config.lean4.root` still works — the Q#LN8 - generalization is strictly additive. -26. `didOpen` carries `languageId = "lean4"`. -27. **Fallback-latch pin (Q#LN7):** a `lake` stub that exits non-zero — - reproducing §2.9's shimmed-elan state — causes exactly **one** restart - against `lean --server`, and a second failure surfaces an error rather - than looping. The latch does not re-arm within the session. -28. **Probe pin:** a `lake` stub reporting version 3.0.0 triggers the - fallback; one reporting 3.1.0 does not. A stub that never exits does - not block the attach — the optimistic `lake serve` spawn proceeds. -29. A `$/lean/fileProgress` notification delivered through the fake server - reaches a registered `on_notification` subscriber. -30. **Dispatch-integrity pin:** with a Lean subscriber registered, a - `workspace/applyEdit` request in the same drain is still handled — no - event is stolen. -31. A subscriber that raises does not prevent later events in the same - drain from being processed. -32. **Response-seam pin (Q#LN9).** A `send_request` reply reaches its - registered `on_response` one-shot, and the one-shot is **removed - before** invocation — a raising handler is not re-entered. Bites - against rev 2, where no Lua consumed `ev.kind == "response"` at all - and the reply was dropped. -33. **Response dispatch-integrity pin.** With a response subscriber - registered, `workspace/applyEdit` in the same drain is still handled; - a raising response handler does not stop later events in that drain. - Mirrors the notification-side pins above. -34. **Pending-purge pin.** A server that dies with a response outstanding - invokes the pending one-shot with an error and clears it — the - registration does not leak and the awaiting caller does not hang. -35. **Config-preservation pin (Q#LN7).** After the fallback latch fires, - user-supplied `env` / `settings` / `init_options` / `root` on - `pmacs.lsp.config.lean4` survive; only `command` and `args` change. -36. **No-respawn-loop pin.** The latch stops the failing server before - spawning the fallback, so `RestartPolicy` does not respawn the broken - command underneath it. -37. `textDocument/waitForDiagnostics` resolves through the response seam - (Q#LN16). **PATH-and-success-gated live smoke:** if `lake serve` - starts successfully a real elaboration completes and diagnostics - arrive; skipped otherwise, never failed. +Driven against `pmacs_fake_lsp` through an already-shipped language, for +the same reason Stage 2's suite was: the drain is shared by every +language, and a suite that reaches it only through Lean would understate +the blast radius. + +- **29.** A notification delivered through the fake server reaches a registered + `on_notification` subscriber. +- **30.** **Dispatch-integrity pin:** with a subscriber registered, a + `workspace/applyEdit` request in the same drain is still handled — no + event is stolen. +- **31.** A subscriber that raises does not prevent later events in the same + drain from being processed. +- **32.** **Response-seam pin (Q#LN9).** A `send_request` reply reaches its + registered `on_response` one-shot, and the one-shot is **removed + before** invocation — a raising handler is not re-entered. Bites + against rev 2, where no Lua consumed `ev.kind == "response"` at all + and the reply was dropped. +- **33.** **Response dispatch-integrity pin.** With a response subscriber + registered, `workspace/applyEdit` in the same drain is still handled; + a raising response handler does not stop later events in that drain. + Mirrors the notification-side pins above. +- **34.** **Pending-purge pin, both edges.** A server that dies with a response + outstanding invokes the pending one-shot with an error and clears it. + **And** — the case round 4 found reachable and rev 4 missed — killing + the *buffer* with a request outstanding does the same, rather than + stranding the registration behind a drain that no longer runs for + that server. The second half must be shown to fail against a + purge wired only to the server-death transition; otherwise this + criterion is satisfied by the implementation that leaks. +- **34a.** **Canonicalizer pin (Q#LN20).** `pmacs.fs.canonicalize` resolves a + symlinked and dot-segmented path to the same string as the real path, + and returns nil for a nonexistent one. Fixture builds the symlink + rather than assuming one exists. +- **34b.** **Affinity-through-canonicalization pin.** With a function-valued + `root` that canonicalizes, the same project opened by its real path + and through a symlink reuses **one** server. Falsified by a resolver + that returns the path verbatim, which yields two — this is the + regression Q#LN20 exists to prevent, so it is asserted at the + affinity layer, not just at the binding. + +**Stage 3b — the Lean language server** + +- **22.** Opening a `.lean` file inside a Lake package spawns one server with + `cwd` and `rootUri` at the package root. +- **23.** **Outermost-root pin:** a file under + `/.lake/packages/dep/…` whose ancestor chain contains two + `lean-toolchain` files resolves to ``, not to `dep`. Run with + `pmacs.project.set_search_boundary` at the fixture root so the + assertion is hermetic. +- **24.** **Boundary pin:** with the search boundary set at the fixture root, a + `lean-toolchain` planted in an ancestor *above* the boundary is not + reached — the resolver stops at the boundary rather than walking past + it. +- **24a.** **Marker-is-a-file pin (Q#LN8).** A `lean-toolchain` + *directory* does not mark a root. Bites against the bare `io.open` + truth test, which round 4 probed succeeds on directories — the shape + that would pass every other criterion here while being wrong. +- **25.** A string-valued `pmacs.lsp.config.lean4.root` still works — the Q#LN8 + generalization is strictly additive. +- **26.** `didOpen` carries `languageId = "lean4"`. +- **27.** **Fallback-latch pin (Q#LN7):** a `lake` stub that exits non-zero — + reproducing §2.9's shimmed-elan state — causes exactly **one** restart + against `lean --server`, and a second failure surfaces an error rather + than looping. The latch does not re-arm within the session. +- **28.** **Probe pin:** a `lake` stub reporting version 3.0.0 triggers the + fallback; one reporting 3.1.0 does not. A stub that never exits does + not block the attach — the optimistic `lake serve` spawn proceeds. +- **35.** **Config-preservation pin (Q#LN7).** After the fallback latch fires, + user-supplied `env` / `settings` / `init_options` / `root` on + `pmacs.lsp.config.lean4` survive; only `command` and `args` change. +- **36.** **No-respawn-loop pin.** The latch stops the failing server before + spawning the fallback, so `RestartPolicy` does not respawn the broken + command underneath it. +- **36a.** **Attribution pin (COHERENCE §9/§1.2).** The probe process + appears in `pmacs.process.list` under a Lean-owned label, and the + latch firing leaves a status-line trace. Both assert through the + channel a user can actually observe; a report added through + `pmacs.error` alone must fail this. +- **37.** `textDocument/waitForDiagnostics` resolves through the response seam + (Q#LN16). **PATH-and-success-gated live smoke:** if `lake serve` + starts successfully a real elaboration completes and diagnostics + arrive; skipped otherwise, never failed. + +These two sections are bulleted with explicit labels rather than +numbered, because the split leaves each stage's criteria non-contiguous +(3b runs 22–28 then 35–37) and a markdown ordered list renumbers from +its first item regardless of what is written. Keeping the labels literal +means **every rev-4 number still denotes what it denoted in rev 4** — +"acceptance 34", "acceptance 27" — and the four criteria added in this +revision take letter suffixes rather than displacing anything. Round 3's +finding 4 was stale cross-references surviving a renumber; not +renumbering is the cheaper way to not repeat it. **Stage 4 — the Unicode input method** @@ -1447,7 +1747,7 @@ What remains deferred: - **#146 (HTML+CSS)** — the global capture table, and the requirement to pin retro-paint in both directions. Q#LN4 is that lesson applied. - **#123 (JSON/YAML)** — declarative `pmacs.lsp.config` entries with a - fake-server delivery proof plus PATH-gated live smokes. Stage 3 follows + fake-server delivery proof plus PATH-gated live smokes. Stage 3b follows it, with the extra success-gate §2.9 forces. - **#110 (auto-pairing)** — `take_typed_edit()` provenance, the fail-closed discipline on transformed source edits, and Q#AP1's optimistic-classifier @@ -1465,3 +1765,70 @@ What remains deferred: which Q#LN17 registers into. - **#94/#95 (LSP panels)** — `pmacs.listview.open` and the references/outline panel shape that Stage 7 reuses wholesale. + +## 9. Coherence impact (COHERENCE §20) + +Required of every framing since #163. Stated for stages 3a and 3b, the +work this revision authorizes; the earlier stages predate the rule and +are not retrofitted here. + +**Sections served.** §1.2 (the silence asymmetry) primarily, and §7 +(first-class workspaces) indirectly — per-root affinity is the workspace +concern arriving one language at a time. §9 (worker identity) is touched +but not advanced. + +**Golden journey (§2).** No step is touched. Neither stage changes what +happens between launching pmacs and editing a file; Lean is not on the +journey's critical path, and 3a is invisible to a user who has no Lean +installed. Stage 3b does make §2's step-3 grade slightly *worse* in one +narrow way, and it is honest to say so: a preconfigured-but-missing +`lake` is one more instance of the silent-spawn-failure class, on a +toolchain many users will not have. Q#LN7's status-line reports on the +probe verdict and the latch cover the Lean-specific paths, but they do +not fix the general failure — that remains Priority 1 work with its own +framing, as §1.2's frequency note already records. + +**Interaction islands (§6).** None added. Stage 3b introduces no keymap, +no modal surface, and no dispatch shadow. Its one user-facing command +(`M-x lean-wait-for-diagnostics`, Q#LN16) registers through the ordinary +command table and is reachable from `M-x` like everything else. + +**Config registry (§11).** Neither stage adds a `pmacs.config` option. +`pmacs.lsp.config.lean4` joins the existing declarative server table +alongside sixteen other languages — deliberately *not* the typed registry, +because moving one language's entry there while the other sixteen stay +put would fragment the surface rather than unify it. Migrating +`pmacs.lsp.config` wholesale is a config-arc concern; this lane must not +create a precedent that makes it harder. Stage 4's `lean.abbrev` gate is +where this arc does enter the registry, and Q#LN10 already commits to the +`editing.auto-pair` shape. + +**Background-work attribution (§9).** Three pieces of background work, +each with a named owner and an observable trace: + +| Work | Identity | Trace | +|---|---|---| +| `lake --version` probe | `ProcessSpec.label = "lean:lake-version-probe"`, visible in `pmacs.process.list` | status line on a verdict that triggers fallback | +| the fallback latch | the server it stops/spawns is already in `pmacs.lsp.list()` | status line on firing | +| root resolution | none — synchronous, inside the attach | status line on resolver failure (shipped #161) | + +This is attribution within the identity layer §9 says is absent, not a +fix for its absence: the probe carries a label because +`ProcessSpec.label` is the only field available, and §9's own ground +truth calls that "caller-supplied, unvalidated convention." Owner/purpose +/parent fields remain unbuilt, and nothing here joins the four activity +planes. What this lane commits to is not *worsening* the ratio — every +background action it adds is nameable in some user-visible view on the +day it ships. + +**Debt this revision retires.** Q#LN20 closes the gap #161 could only +document: a configured root reaching `file_uri_for` uncanonicalized. That +was coherence debt of exactly §1.3's compounding kind — a correct +substrate with a footgun the next caller was expected to disarm by +reading a comment. + +**Debt this revision names rather than pays.** Three, all in §6: the +uncapped event queue, the dropped `cfg.restart`, and — unchanged from +#161 — surfacing the spawn failure itself. Each is a behavior change for +languages other than Lean, and §4's rule is what keeps them out of a Lean +PR. From a516a46359286c4c6503d9ba43b37e051eb1b1e3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:17:51 -0400 Subject: [PATCH 02/91] =?UTF-8?q?docs(lean4):=20rev=205=20round=201=20?= =?UTF-8?q?=E2=80=94=20fix=20the=20empty-marker=20case,=20sweep=20citation?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both revision edits. **Q#LN8's marker test was wrong in the other direction.** Rev 5 fixed the directory case by reading a byte and requiring a non-nil read — but an **empty** `lean-toolchain` reads nil at EOF too, so that rule declines a marker that exists, silently, falling through to `pmacs.project.detect`. Marker semantics here are `lean4-mode`'s `locate-dominating-file` semantics: existence, not content, and a `lean-toolchain` can legitimately be empty. The discriminator is `read`'s second return, probed on LuaJIT 2.1: | Path | `io.open` | `f:read(1)` | Verdict | |---|---|---|---| | file with content | handle | `"l"`, no error | marker | | empty file | handle | `nil`, no error | marker | | directory | handle | `nil`, `"Is a directory"` | decline | | missing | `nil` | — | decline | So `local data, err = f:read(1)`, declining only on a non-nil `err`. The rule needs no per-platform re-probe: both directory behaviors are declines, since a platform whose `fopen` refuses a directory fails at `io.open` and one that opens it fails at `read`. There is no platform on which a directory both opens and yields a byte. Acceptance gains **24b** (an empty `lean-toolchain` marks a root) beside 24a, with the obligation that each be shown to fail against the implementation satisfying only the other. A suite carrying just one is satisfied by a resolver silently wrong for the other case — which is precisely how rev 5's first answer got written. **Citation sweep.** Round 4 stated the `project_root_for` correction in §0.1 without editing the citation in §2.5; the correction and the fix are different acts, and noting one is not doing the other. Review caught a second stale citation (`handle_server_requests` at :1448), which prompted a sweep of every `file:line` from §2.4 onward. Four more were stale. All six: `project_root_for` 513 → 592, `ensure_server` 527 → 610, `handle_server_requests` 1448 → 1549, `take_typed_edit` 12798 → 12827, `pair.lua` 213 → 229, `compile.lua` 264 → 266. Six others were verified good and left alone, listed in §0.1 so the next sweep knows what has already been checked. Q#LN15's present-tense "the change is small and spans two files" now reads as past tense with its PR number, since that stage landed. Its pre-#161 line numbers stay as written — historical record, not navigation. --- docs/lean4-mode-framing.md | 72 +++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index fc31e01..317d74f 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -252,9 +252,21 @@ Confirmations, recorded because each was load-bearing and unverified: Stage 3b is the first stage that would benefit from setting it, and Q#LN7 now records why it deliberately does not need it. -Citation drift repaired per COHERENCE §25: `project_root_for` is -`builtin/runtime/lsp.lua:592`, not 513, and returns `root, source` -rather than a bare root. +Citation drift repaired per COHERENCE §25. Round 4's first pass stated +the `project_root_for` correction in this section without editing the +citation in §2.5 — the correction and the fix are different acts, and +noting one is not doing the other. Review caught a second stale citation +(`handle_server_requests`), which prompted a full sweep of every +`file:line` from §2.4 onward; it found four more. All six: +`project_root_for` 513 → **592** (and it now returns `root, source` +rather than a bare root), `ensure_server` 527 → **610**, +`handle_server_requests` 1448 → **1549**, `take_typed_edit` +12798 → **12827**, `pair.lua` 213 → **229**, and `compile.lua` +264 → **266**. Verified good and left alone: `listview.lua:138`, +`src/lsp.rs:264`, `src/diag.rs:50`, `src/process.rs:193`, +`src/project.rs:145`, and the `mod.rs` binding-block citations. The pre-#161 line numbers inside Q#LN15 are +left as written: that stage has landed and its citations are historical +record, not navigation. ## 1. What ships @@ -465,7 +477,7 @@ and pin it.* params }` and `Response { id, result, error, method }` variants. Unknown server methods are delivered, not dropped. - **But `events_take` has exactly one consumer**: `handle_server_requests` - at `builtin/runtime/lsp.lua:1448`, driven off `pmacs._async.tick`. It + at `builtin/runtime/lsp.lua:1549`, driven off `pmacs._async.tick`. It `take`s — a drain. Its `if/elseif` chain handles five `request` methods and `initialized`, and **ignores every `notification` and every `response`**. A second module calling `events_take` would steal events @@ -481,7 +493,7 @@ and pin it.* ### 2.5 Project-root detection -`project_root_for` (`builtin/runtime/lsp.lua:513`) resolves: +`project_root_for` (`builtin/runtime/lsp.lua:592`) resolves: `pmacs.lsp.config[language].root` → `pmacs.project.detect` → the file's own directory. Two gaps for Lean: @@ -509,7 +521,7 @@ directory. Two gaps for Lean: then reports import errors for the whole file. Third, and the reason Stage 2 exists: `ensure_server` -(`builtin/runtime/lsp.lua:527`) reuses any live server with a matching +(`builtin/runtime/lsp.lua:610`) reuses any live server with a matching `language_id` regardless of the new file's project, so **the first `.lean` file opened fixes the root for every later `.lean` file.** For most languages that is an inconvenience; for Lean, where `lake serve` is bound @@ -526,10 +538,10 @@ changes loose-file behavior for every language. `builtin/runtime/pair.lua` is the whole precedent for "react to a typed character": subscribe to `buffer.after-edit`, gate on -`ed.this_command() == "buffer.self-insert"` (`pair.lua:213`), then take the +`ed.this_command() == "buffer.self-insert"` (`pair.lua:229`), then take the exact provenance record. -`pmacs.editor.take_typed_edit()` (`src/lua_bindings/mod.rs:12798`) returns +`pmacs.editor.take_typed_edit()` (`src/lua_bindings/mod.rs:12827`) returns `{ buffer, window, codepoint, char, requested_start, requested_end, effective_start, effective_end, inserted_len, post_cursor, clean }` — or nil. Its doc comment is explicit: @@ -558,7 +570,7 @@ cross-peer-degraded**. Lean's `⟨⟩` is outside that set. shows the adopter shape, gated on `spec.display == "panel"`. `pmacs.window.params()` and `pmacs.window.quit()` complete the surface. - Read-only generated buffers use the listview idiom, documented at - `builtin/runtime/compile.lua:264`: an erroring `pmacs.buffer.add_intercept` + `builtin/runtime/compile.lua:266`: an erroring `pmacs.buffer.add_intercept` for user edits, with module writes passing `{ bypass_intercept = true }`. - **Note for whoever picks this up on another machine:** the ledgers are stale about this. `docs/active-work.md:57` still heads the lane "Stage 1 @@ -896,9 +908,36 @@ already depends on `os.getenv`. One edge, probed: **`io.open` succeeds on a directory** (the handle opens; `read` returns nil without raising). A `lean-toolchain` *directory* would -therefore read as a marker. The resolver reads one byte and treats a -non-nil read as the marker, so a directory declines — an `io.open` truth -test alone would be wrong, and wrong silently. +therefore read as a marker under an `io.open` truth test — wrong, and +wrong silently. + +The fix is **not** "read a byte and require it to be non-nil", which was +this section's first answer and is wrong in the other direction: an +**empty** `lean-toolchain` file also reads nil at EOF, so that rule +declines a marker that exists. Marker semantics here are `lean4-mode`'s +`locate-dominating-file` semantics — *existence*, not content — and a +`lean-toolchain` can legitimately be empty. The discriminator is +`read`'s **second** return, probed on LuaJIT 2.1: + +| Path | `io.open` | `f:read(1)` | Verdict | +|---|---|---|---| +| file with content | handle | `"l"`, no error | marker | +| **empty file** | handle | `nil`, **no error** | **marker** | +| directory | handle | `nil`, `"Is a directory"` | decline | +| missing | `nil` | — | decline | + +So: `local data, err = f:read(1)` and decline only on a non-nil `err`. +The rule is robust across platforms without needing to be re-probed on +each, because both directory behaviors are declines — a platform whose +`fopen` refuses a directory outright fails at `io.open`, and one that +opens it fails at `read`. There is no platform on which a directory both +opens and yields a byte. + +Acceptance 24a and 24b pin the two halves, and each must be shown to +fail against the implementation that satisfies only the other — +otherwise "handles directories" is satisfiable by the version that +breaks empty files, which is exactly how this section's first answer got +written. **The result must be canonical.** #161's contract: a configured root reaches `file_uri_for` verbatim and that URI is the affinity key, so two @@ -1204,7 +1243,7 @@ rough edge but a correctness failure: `lake serve` is bound to one Lake package, so the second package a user opens gets a server that cannot resolve its imports. -The change is small and spans two files: +The change was small and spanned two files (Stage 2, landed as #161): - **`src/lua_bindings/mod.rs:9919`** — the `lsp.list()` row builder sets `id`/`label`/`language_id`/`command`/`state`/`attempt`. Add `root_uri` @@ -1626,6 +1665,13 @@ the blast radius. *directory* does not mark a root. Bites against the bare `io.open` truth test, which round 4 probed succeeds on directories — the shape that would pass every other criterion here while being wrong. +- **24b.** **Empty-marker pin (Q#LN8).** An **empty** `lean-toolchain` + file *does* mark a root — marker semantics are existence, not content. + Bites against the read-a-byte-and-require-non-nil rule, which declines + it at EOF. 24a and 24b must each be shown to fail against the + implementation that satisfies only the other; a suite carrying just + one of them is satisfied by a resolver that is silently wrong for the + other case. - **25.** A string-valued `pmacs.lsp.config.lean4.root` still works — the Q#LN8 generalization is strictly additive. - **26.** `didOpen` carries `languageId = "lean4"`. From 12236b265da4eebb1bf973965920b56cc5b2c3b5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:37:48 -0400 Subject: [PATCH 03/91] feat(lsp): notification/response dispatch seams and fs.canonicalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arc 8 Stage 3a (framing Q#LN9, Q#LN20). No Lean content: this changes the event drain every LSP language runs through, and is split from the Lean server work for the reason Stage 2 was. **The seams.** `handle_server_requests` handled five `request` methods and `initialized`, dropping every `notification` and `response` on the floor. Dropping responses made `pmacs.lsp.send_request` a write-only API from Lua — the reply was drained and discarded, so nothing outside Rust's typed stores could consume one. Two new arms route to `pmacs.lsp.on_notification(method, fn)` (persistent, method-keyed) and `pmacs.lsp.on_response(sid, request_id, fn)` (one-shot). Both extend the existing loop rather than opening a second `events_take` caller, which would steal events from it. A one-shot is removed **before** invocation, so a raising handler cannot be re-entered. Every subscriber is `pcall`ed and a raise reports through `pmacs.editor.set_status` per COHERENCE §1.2 — not `pmacs.error`, which is defined nowhere in production. The notification list's length is captured before the walk so a subscriber registering another cannot extend the list being iterated. **The purge is driven off `pmacs.lsp.list()`, not off a death event.** The framing said acceptance 34's second edge was a killed buffer. That was wrong, and scouting the implementation is what caught it: pmacs fires exactly five hooks (`buffer.after-edit`, `buffer.after-load`, `buffer.after-switch`, `frontend.detached`, `process.after-tick`) and there is no buffer-kill hook at all, so `lsp.lua` never tears an attachment down and the drain keeps reaching that server. No leak there. The real leak is a different path with the same root cause. The drain builds its sid list from `attachments`, and `attach_buffer` drops a sid from that table the moment `server_is_live` reports false — rebuilding against a fresh server. So the `crashed` / `stopped` event that should trigger the purge is precisely the one most likely to go undrained. A purge wired to that event leaks exactly when it matters. `pmacs.lsp.list()` enumerates the manager directly and is unaffected by attachment bookkeeping, so the purge polls it after each drain: a sid that is absent, terminal, or running a **new generation** settles its pending one-shots with an error. The generation check uses the `attempt` field, because a crash-then-restart reuses the sid — without it a one-shot would sit waiting on a reply the dead generation owed. **`pmacs.fs.canonicalize`** (Q#LN20) is the one synchronous function on `pmacs.fs`, and synchronous is the point: its consumer is a function-valued `config.root` called from `ensure_server` <- `attach_buffer` <- `buffer.after-load`, where there is no coroutine and `pmacs.fs.stat`'s awaitable handle is unusable. It is installed from `install_async` rather than `install_project` purely for load order — `make_workspace` runs after `fs.lua` is evaluated, so a canonicalizer placed there reads nil. Acceptance: `tests/lsp_dispatch_seams_acceptance.rs`, 14 tests, driven against `pmacs_fake_lsp` through rust so nothing needs a toolchain. Dispatch integrity is exercised at real co-occurrence — the fake server writes `workspace/applyEdit` and the `executeCommand` reply back to back, so both land in one `events_take` batch. 34b asserts affinity survives a symlinked open and is paired with its own falsification: the same resolver minus the canonicalize call spawns two servers, so the positive case cannot be vacuous. --- builtin/runtime/fs.lua | 24 + builtin/runtime/lsp.lua | 162 ++++++ src/lua_bindings/mod.rs | 39 ++ tests/lsp_dispatch_seams_acceptance.rs | 667 +++++++++++++++++++++++++ 4 files changed, 892 insertions(+) create mode 100644 tests/lsp_dispatch_seams_acceptance.rs diff --git a/builtin/runtime/fs.lua b/builtin/runtime/fs.lua index 02ca064..77fee1f 100644 --- a/builtin/runtime/fs.lua +++ b/builtin/runtime/fs.lua @@ -284,4 +284,28 @@ function fs.watch(path, callback, opts) return watch end +-- pmacs.fs.canonicalize(path) -> string | nil +-- +-- Arc 8 Stage 3a (framing Q#LN20). The **only synchronous** function on +-- this module, and deliberately so: its consumer is a function-valued +-- `pmacs.lsp.config[lang].root`, invoked from `ensure_server` <- +-- `attach_buffer` <- the `buffer.after-load` hook, where there is no +-- coroutine and therefore nothing to `:await()` on. Every other +-- primitive here returns a Handle; this one cannot, or it would be +-- unusable at the one call site that needs it — the same trap +-- `pmacs.fs.stat` falls into for that caller. +-- +-- Resolves symlinks and `.` / `..`, returning an absolute path, or nil +-- if the path does not exist or cannot be resolved. Nil is a normal +-- answer, not an error: callers routinely ask about paths that may have +-- been deleted. +-- +-- Why it exists: a configured LSP root reaches `file_uri_for` verbatim +-- and that URI is the server-affinity key (PR #161), so one project +-- opened through a symlink and through its real path would otherwise +-- spawn two servers. `pmacs.editor.file_path()` collapses `.` and `..` +-- lexically but leaves symlinks intact, so the resolver cannot get a +-- canonical path any other way. +fs.canonicalize = pmacs._fs.canonicalize + pmacs.fs = fs diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 6021134..eac5753 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1546,6 +1546,160 @@ end -- itself is unaffected. Server ids are snapshotted before the loop -- because `apply_workspace_edit` → `find_or_open` can attach a new -- buffer mid-iteration (mutating `attachments`). +-- Server-originated notification / response seams (framing Q#LN9) ------- +-- +-- Before this, `handle_server_requests` handled five `request` methods +-- and `initialized`, and dropped every `notification` and `response` on +-- the floor. Dropping responses made `pmacs.lsp.send_request` a +-- write-only API from Lua: the reply was drained and discarded, so +-- nothing outside Rust's typed stores could ever consume one. +-- +-- Both seams route through the *existing* drain. A second +-- `events_take` caller would steal events from this one — `take_events` +-- removes the queue — so any new consumer must extend this loop rather +-- than open its own. +-- +-- method -> array of subscriber fns. Persistent; `pmacs.hook` has no +-- `remove` and neither does this, deliberately matching it. +local notification_subs = {} +-- tostring(sid) -> { [request_id] = { fn = fn, attempt = n } }. One-shot. +local pending_responses = {} + +local function report_subscriber_error(what, err) + local msg = string.format("LSP: %s subscriber failed: %s", what, + tostring(err)) + -- COHERENCE §1.2: a pcall around background wiring must report, not + -- discard. `pmacs.editor.set_status` is the channel that exists; + -- `pmacs.error` is referenced by fifteen call sites and defined + -- nowhere in production, so it rides along rather than standing alone. + pcall(pmacs.editor.set_status, msg) + if pmacs.error then pcall(pmacs.error, msg) end +end + +-- Current spawn attempt for `sid`, or nil if the manager has forgotten +-- it. A restart reuses the sid but bumps the attempt, which is how a +-- pending one-shot tells "my server is still here" from "my server died +-- and a new generation took its id". +local function server_attempt(sid) + local skey = tostring(sid) + for _, info in ipairs(pmacs.lsp.list()) do + if tostring(info.id) == skey then + return info.attempt or 0, info.state and info.state.kind + end + end + return nil, nil +end + +-- fn(sid, params); persistent, fires for every server. +function pmacs.lsp.on_notification(method, fn) + if type(method) ~= "string" or type(fn) ~= "function" then + error("pmacs.lsp.on_notification(method, fn): want string, function") + end + local subs = notification_subs[method] + if not subs then + subs = {} + notification_subs[method] = subs + end + subs[#subs + 1] = fn +end + +-- fn(result, err); ONE-SHOT, keyed to the exact request. +-- `request_id` is what `pmacs.lsp.send_request` returned. +function pmacs.lsp.on_response(sid, request_id, fn) + if not sid or type(request_id) ~= "number" or type(fn) ~= "function" then + error("pmacs.lsp.on_response(sid, request_id, fn): want sid, number, function") + end + local skey = tostring(sid) + local pend = pending_responses[skey] + if not pend then + pend = {} + pending_responses[skey] = pend + end + -- The attempt is captured at registration so a restart under the same + -- sid purges this entry rather than leaving it waiting on a reply the + -- dead generation was going to send. + pend[request_id] = { fn = fn, attempt = server_attempt(sid) or 0 } +end + +local function dispatch_notification(sid, ev) + local subs = notification_subs[ev.method] + if not subs then return end + -- Length captured up front: a subscriber that registers another one + -- must not be able to extend the list being walked. + local n = #subs + for i = 1, n do + local ok, err = pcall(subs[i], sid, ev.params) + if not ok then + report_subscriber_error("notification " .. tostring(ev.method), err) + end + end +end + +local function deliver_response(sid, ev) + local skey = tostring(sid) + local pend = pending_responses[skey] + if not pend then return end + local entry = pend[ev.request_id] + if not entry then return end + -- Removed BEFORE invocation: a handler that raises must not be + -- re-entered by a later event carrying the same id. + pend[ev.request_id] = nil + if next(pend) == nil then pending_responses[skey] = nil end + local ok, err = pcall(entry.fn, ev.result, ev.error) + if not ok then + report_subscriber_error("response " .. tostring(ev.method), err) + end +end + +-- Settle every one-shot whose server can no longer answer it. +-- +-- Deliberately driven off `pmacs.lsp.list()` and NOT off a death event +-- observed in the drain, because the drain cannot be relied on to reach +-- the server in question: `handle_server_requests` builds its sid list +-- from `attachments`, and a sid leaves that table whenever +-- `attach_buffer` finds it dead and rebuilds the attachment against a +-- fresh server. So the very event that should trigger the purge — +-- `crashed` / `stopped` — is the one most likely to go undrained. A +-- one-shot settled only by the drain would leak exactly when it matters. +-- +-- `pmacs.lsp.list()` enumerates the manager directly and is unaffected +-- by attachment bookkeeping, which is what makes it the right authority. +local function purge_dead_pending() + if next(pending_responses) == nil then return end + local ok, rows = pcall(pmacs.lsp.list) + -- A failed enumeration is not evidence that every server died; leaving + -- the registrations alone is the safe read of "we don't know". + if not ok or not rows then return end + local alive = {} + for _, info in ipairs(rows) do + local kind = info.state and info.state.kind + if kind ~= "crashed" and kind ~= "stopped" then + alive[tostring(info.id)] = info.attempt or 0 + end + end + for skey, pend in pairs(pending_responses) do + local attempt = alive[skey] + local dead = {} + for rid, entry in pairs(pend) do + -- Absent or terminal, or the same sid running a NEW generation: + -- in every case the request this entry awaits is unanswerable. + if attempt == nil or attempt ~= entry.attempt then + dead[#dead + 1] = rid + end + end + for _, rid in ipairs(dead) do + local entry = pend[rid] + pend[rid] = nil + local ok_h, err = pcall(entry.fn, nil, + { message = "server gone before response" }) + if not ok_h then + report_subscriber_error("response purge", err) + end + end + if next(pend) == nil then pending_responses[skey] = nil end + end +end + local function handle_server_requests() local sids, seen = {}, {} for _, rec in pairs(attachments) do @@ -1598,6 +1752,10 @@ local function handle_server_requests() -- LSP spells the field "unregisterations". pcall(unregister_file_watchers, sid, ev.params and ev.params.unregisterations) + elseif ev.kind == "notification" then + dispatch_notification(sid, ev) + elseif ev.kind == "response" then + deliver_response(sid, ev) elseif ev.kind == "initialized" then -- Buffers attach before the server finishes initializing, so -- the pulls in `attach_buffer` are no-ops for the FIRST file @@ -1620,6 +1778,10 @@ if pmacs._async and pmacs._async.tick then pmacs._async.tick = function(...) local ret = _prior_async_tick(...) pcall(handle_server_requests) + -- After the drain, so a response delivered this tick settles its + -- one-shot normally rather than being purged as "server gone" in the + -- same pass when the server died right after answering. + pcall(purge_dead_pending) pcall(flush_due_did_changes) return ret end diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 3a92520..369b810 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -6519,6 +6519,45 @@ pub fn install_async( ) -> mlua::Result<()> { lua.set_app_data(runtime.clone()); let pmacs: Table = lua.globals().get("pmacs")?; + + // Arc 8 Stage 3a (framing Q#LN20): the one *synchronous* filesystem + // primitive Lua has. `pmacs.fs` is otherwise an async, handle- + // returning surface built in `builtin/runtime/fs.lua`, so this + // arrives through a private table that file re-exports rather than + // joining the `_dispatch_fs_*` family it would not belong to. + // + // Installed here, alongside those dispatchers, purely for load + // order: `make_async_runtime` runs before `fs.lua` is evaluated, + // whereas `install_project` — the other plausible home — runs after + // it, so a canonicalizer placed there is nil when `fs.lua` reads it. + // + // Synchronous on purpose, and that is the whole point. The consumer + // is a function-valued `pmacs.lsp.config[lang].root`, which + // `project_root_for` calls from `ensure_server` <- `attach_buffer` + // <- the `buffer.after-load` hook — no coroutine, nothing to await + // on. An awaitable canonicalizer would be unusable there for exactly + // the reason `pmacs.fs.stat` already is, leaving #161's + // canonical-root obligation undischarged. The cost is one syscall on + // a path the editor is already opening; `pmacs.project.detect` + // canonicalizes synchronously on the same hook today. + { + let fs_priv = lua.create_table()?; + fs_priv.set( + "canonicalize", + lua.create_function(|_, path: String| { + // nil rather than an error for a path that cannot be + // resolved: asking about a deleted file or a broken + // symlink is ordinary, and raising would surface through + // `resolve_root_fn`'s pcall as a config bug, which it is + // not. + Ok(std::fs::canonicalize(&path) + .ok() + .map(|p| p.display().to_string())) + })?, + )?; + pmacs.set("_fs", fs_priv)?; + } + let async_mod = lua.create_table()?; { diff --git a/tests/lsp_dispatch_seams_acceptance.rs b/tests/lsp_dispatch_seams_acceptance.rs new file mode 100644 index 0000000..d240ba4 --- /dev/null +++ b/tests/lsp_dispatch_seams_acceptance.rs @@ -0,0 +1,667 @@ +//! Arc 8 Stage 3a acceptance — LSP notification/response dispatch seams +//! and `pmacs.fs.canonicalize`. +//! +//! `docs/lean4-mode-framing.md` Q#LN9 and Q#LN20, acceptance 29–34 plus +//! 34a/34b. +//! +//! This suite deliberately contains **no Lean content**. +//! `handle_server_requests` (`builtin/runtime/lsp.lua`) is the single +//! LSP event drain for every language in pmacs, so the change is +//! exercised through an already-shipped language driven against +//! `pmacs_fake_lsp`. A suite that reached the drain only through Lean +//! would understate the blast radius — the same reasoning that shaped +//! Stage 2's suite. +//! +//! Every fixture calls `pmacs.project.set_search_boundary` at its own +//! tempdir root, so a stray marker above the temp directory cannot make +//! a "markerless" case silently detected. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use pmacs::editor::EditorState; + +fn exec(state: &EditorState, source: &str) { + state.lua_host.lua().load(source.to_owned()).exec().unwrap(); +} + +fn eval(state: &EditorState, source: &str) -> T { + state.lua_host.lua().load(source.to_owned()).eval().unwrap() +} + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +/// A fresh editor with the shipped language configs cleared, so the only +/// server any test can spawn is the fake one it configures itself. +fn editor() -> EditorState { + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + state +} + +fn lua_str(path: &Path) -> String { + path.display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(dir.path()).unwrap(); + Self { _dir: dir, root } + } + + fn write(&self, rel: &str, contents: &str) -> PathBuf { + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, contents).unwrap(); + path + } + + fn dir(&self, rel: &str) -> PathBuf { + self.root.join(rel) + } + + fn bind(&self, state: &EditorState) { + exec( + state, + &format!( + "pmacs.project.set_search_boundary(\"{}\")", + lua_str(&self.root) + ), + ); + } +} + +fn configure(state: &EditorState, language: &str) { + exec( + state, + &format!( + "pmacs.lsp.config.{language} = {{ command = \"{}\" }}", + fake_lsp_path() + ), + ); +} + +fn open(state: &EditorState, path: &Path) { + exec( + state, + &format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)), + ); +} + +/// `tick_async` is what drives the drain: `handle_server_requests` is +/// wrapped onto `pmacs._async.tick`, so a settle loop without it moves +/// the LSP state machine while never delivering a single event to Lua. +fn settle(state: &mut EditorState) { + for _ in 0..8 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// A rust project with one file, an attached fake server, and the +/// probes below installed. Returns the opened file's path. +fn attached_rust(state: &mut EditorState, fx: &Fixture) -> PathBuf { + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let file = fx.write("proj/src/main.rs", "fn main() {}\nlet x = 1;\n"); + fx.bind(state); + configure(state, "rust"); + open(state, &file); + settle(state); + file +} + +/// The sid of the single live server, as a Lua expression fragment. +const THE_SID: &str = "pmacs.lsp.list()[1].id"; + +// --------------------------------------------------------------------------- +// Acceptance 29 — a notification reaches a registered subscriber. +// --------------------------------------------------------------------------- + +#[test] +fn acc29_notification_reaches_a_registered_subscriber() { + let fx = Fixture::new(); + let mut state = editor(); + // Registered BEFORE the open, so the didOpen-triggered `pmacs/echo` + // is in the first drain. + exec( + &state, + r#" + _G.seen = {} + pmacs.lsp.on_notification("pmacs/echo", function(sid, params) + _G.seen[#_G.seen + 1] = tostring(params and params.uri) + end) + "#, + ); + attached_rust(&mut state, &fx); + + let n: i64 = eval(&state, "return #_G.seen"); + assert!( + n >= 1, + "expected at least one pmacs/echo notification, got {n}" + ); + let first: String = eval(&state, "return _G.seen[1]"); + assert!( + first.starts_with("file://") && first.ends_with("main.rs"), + "subscriber got the document uri; saw {first:?}" + ); +} + +#[test] +fn acc29_subscriber_for_an_unsent_method_does_not_fire() { + let fx = Fixture::new(); + let mut state = editor(); + exec( + &state, + r#" + _G.hits = 0 + pmacs.lsp.on_notification("pmacs/never", function() _G.hits = _G.hits + 1 end) + "#, + ); + attached_rust(&mut state, &fx); + + // Non-vacuity for acc29: the seam is method-keyed, not a firehose. + // Without this, a subscriber invoked for every notification would + // pass the test above while being wrong. + let hits: i64 = eval(&state, "return _G.hits"); + assert_eq!(hits, 0, "a subscriber must only fire for its own method"); +} + +// --------------------------------------------------------------------------- +// Acceptance 30 + 33 — dispatch integrity: with subscribers registered, +// a `workspace/applyEdit` request in the same drain is still handled. +// +// The fake server writes the applyEdit request and the executeCommand +// response back to back, so both land in one `events_take` batch. That +// co-occurrence is the point: a seam that consumed the batch, or that +// returned early, would starve the `request` arms that share it. +// --------------------------------------------------------------------------- + +fn drive_apply_edit(state: &mut EditorState, file: &Path) { + exec( + state, + &format!( + r#" + local sid = {THE_SID} + local uri = "file://{}" + _G.rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{ + command = "pmacs.fake.applyEdit", + arguments = {{ uri }}, + }}) + _G.response_hits = 0 + pmacs.lsp.on_response(sid, _G.rid, function(result, err) + _G.response_hits = _G.response_hits + 1 + end) + "#, + lua_str(file) + ), + ); + settle(state); +} + +fn buffer_text(state: &EditorState) -> String { + eval( + state, + "local b = pmacs.window.buffer() return b:slice(0, b:len())", + ) +} + +#[test] +fn acc30_apply_edit_still_handled_with_a_notification_subscriber() { + let fx = Fixture::new(); + let mut state = editor(); + exec( + &state, + r#" + _G.notes = 0 + pmacs.lsp.on_notification("pmacs/echo", function() _G.notes = _G.notes + 1 end) + "#, + ); + let file = attached_rust(&mut state, &fx); + assert!( + eval::(&state, "return _G.notes") >= 1, + "precondition: the notification subscriber is actually firing" + ); + + drive_apply_edit(&mut state, &file); + + assert!( + buffer_text(&state).contains("ED2"), + "workspace/applyEdit must still be applied with a subscriber \ + registered; buffer was {:?}", + buffer_text(&state) + ); +} + +#[test] +fn acc33_apply_edit_still_handled_with_a_response_subscriber() { + let fx = Fixture::new(); + let mut state = editor(); + let file = attached_rust(&mut state, &fx); + drive_apply_edit(&mut state, &file); + + // Both halves in one drain: the response was delivered to its + // one-shot AND the server-originated request was serviced. + assert_eq!( + eval::(&state, "return _G.response_hits"), + 1, + "the executeCommand response reaches its one-shot" + ); + assert!( + buffer_text(&state).contains("ED2"), + "workspace/applyEdit must still be applied with a response \ + subscriber registered; buffer was {:?}", + buffer_text(&state) + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 31 — a raising subscriber does not stop later events in the +// same drain (and does not stop the `request` arms either). +// --------------------------------------------------------------------------- + +#[test] +fn acc31_raising_notification_subscriber_does_not_stop_the_drain() { + let fx = Fixture::new(); + let mut state = editor(); + exec( + &state, + r#" + _G.second_hits = 0 + pmacs.lsp.on_notification("pmacs/echo", function() + error("subscriber blew up") + end) + pmacs.lsp.on_notification("pmacs/echo", function() + _G.second_hits = _G.second_hits + 1 + end) + "#, + ); + let file = attached_rust(&mut state, &fx); + + assert!( + eval::(&state, "return _G.second_hits") >= 1, + "a raising subscriber must not starve the ones after it" + ); + + // And the shared `request` arms still run in a later drain. + drive_apply_edit(&mut state, &file); + assert!( + buffer_text(&state).contains("ED2"), + "a raising subscriber must not stop workspace/applyEdit" + ); +} + +#[test] +fn acc33_raising_response_handler_does_not_stop_the_drain() { + let fx = Fixture::new(); + let mut state = editor(); + let file = attached_rust(&mut state, &fx); + exec( + &state, + &format!( + r#" + local sid = {THE_SID} + _G.notes_after = 0 + pmacs.lsp.on_notification("pmacs/echo", function() + _G.notes_after = _G.notes_after + 1 + end) + local rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{ + command = "pmacs.fake.applyEdit", + arguments = {{ "file://{}" }}, + }}) + pmacs.lsp.on_response(sid, rid, function() error("handler blew up") end) + "#, + lua_str(&file) + ), + ); + settle(&mut state); + + assert!( + buffer_text(&state).contains("ED2"), + "a raising response handler must not stop workspace/applyEdit in \ + the same drain" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 32 — the one-shot is removed BEFORE invocation. +// +// Observed rather than asserted structurally: the handler raises, and +// the server is then stopped. If removal happened only on a clean +// return — or not at all — the purge below would invoke the same handler +// a second time with an error. The count is what pins it. +// --------------------------------------------------------------------------- + +#[test] +fn acc32_response_one_shot_is_removed_before_invocation() { + let fx = Fixture::new(); + let mut state = editor(); + attached_rust(&mut state, &fx); + exec( + &state, + &format!( + r#" + local sid = {THE_SID} + _G.calls = 0 + local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 1 }}) + pmacs.lsp.on_response(sid, rid, function(result, err) + _G.calls = _G.calls + 1 + error("handler raises after being removed") + end) + "# + ), + ); + settle(&mut state); + assert_eq!( + eval::(&state, "return _G.calls"), + 1, + "the one-shot fires exactly once for its reply" + ); + + exec(&state, &format!("pmacs.lsp.stop({THE_SID})")); + settle(&mut state); + assert_eq!( + eval::(&state, "return _G.calls"), + 1, + "a delivered one-shot must not be re-invoked by the purge — it \ + was removed before the raising handler ran, not after" + ); +} + +#[test] +fn acc32_response_carries_the_servers_result() { + let fx = Fixture::new(); + let mut state = editor(); + attached_rust(&mut state, &fx); + exec( + &state, + &format!( + r#" + local sid = {THE_SID} + _G.echoed = nil + _G.saw_err = "unset" + local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 42 }}) + pmacs.lsp.on_response(sid, rid, function(result, err) + _G.echoed = result and result.echo and result.echo.v + _G.saw_err = tostring(err) + end) + "# + ), + ); + settle(&mut state); + + // Non-vacuity: without this the seam could "fire" with nil payloads + // and every count-based assertion above would still pass. + assert_eq!( + eval::(&state, "return _G.echoed or -1"), + 42, + "the handler receives the server's result payload" + ); + assert_eq!( + eval::(&state, "return _G.saw_err"), + "nil", + "a successful reply passes nil for err" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 34 — the pending purge, driven off `pmacs.lsp.list()` and +// NOT off a death event seen in the drain. +// +// The second test is the load-bearing one. `handle_server_requests` +// builds its sid list from `attachments`, so a server that is in no +// attachment is never drained — and its `stopped` event is therefore +// never seen. A purge wired to that event leaks exactly there. +// --------------------------------------------------------------------------- + +#[test] +fn acc34_purge_settles_a_pending_one_shot_when_the_server_dies() { + let fx = Fixture::new(); + let mut state = editor(); + attached_rust(&mut state, &fx); + exec( + &state, + &format!( + r#" + local sid = {THE_SID} + _G.err_msg = "never called" + -- A method the fake server answers only after a delay would + -- be ideal; instead the server is stopped in the same breath, + -- so the reply can never arrive. + local rid = pmacs.lsp.send_request(sid, "test/slow", {{}}) + pmacs.lsp.on_response(sid, rid, function(result, err) + _G.err_msg = tostring(err and err.message) + end) + pmacs.lsp.stop(sid) + "# + ), + ); + settle(&mut state); + + let msg: String = eval(&state, "return _G.err_msg"); + assert!( + msg.contains("server gone") || msg == "nil", + "a pending one-shot must be settled, not left waiting; saw {msg:?}" + ); + assert_ne!( + msg, "never called", + "the one-shot was never settled — it leaked" + ); +} + +#[test] +fn acc34_purge_reaches_a_server_that_is_in_no_attachment() { + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + // Spawned directly, never attached to a buffer. `attachments` is + // empty, so `handle_server_requests` never visits this sid and its + // `stopped` event is never drained. + exec( + &state, + &format!( + r#" + _G.settled = "never called" + local sid = pmacs.lsp.spawn({{ + label = "orphan", + language_id = "rust", + command = "{}", + args = {{}}, + }}) + _G.orphan = sid + "#, + fake_lsp_path() + ), + ); + settle(&mut state); + + let attached: i64 = eval( + &state, + "local n = 0 for _ in pairs(_G) do n = n + 1 end return n", + ); + assert!(attached > 0, "lua globals are readable"); + + exec( + &state, + r#" + local rid = pmacs.lsp.send_request(_G.orphan, "test/slow", {}) + pmacs.lsp.on_response(_G.orphan, rid, function(result, err) + _G.settled = tostring(err and err.message) + end) + pmacs.lsp.stop(_G.orphan) + "#, + ); + settle(&mut state); + + let settled: String = eval(&state, "return _G.settled"); + assert_ne!( + settled, "never called", + "the purge must not depend on the drain reaching this server — \ + it is in no attachment, so the drain never does" + ); + assert!( + settled.contains("server gone"), + "settled with the purge's error; saw {settled:?}" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 34a — `pmacs.fs.canonicalize` (Q#LN20). +// --------------------------------------------------------------------------- + +#[test] +fn acc34a_canonicalize_resolves_symlinks_and_dot_segments() { + let fx = Fixture::new(); + fx.write("pkg/sub/a.txt", "x\n"); + // Built here rather than assumed: the whole point is the symlink. + std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap(); + let state = editor(); + + let noncanon = format!("{}/sub/./../sub/a.txt", fx.dir("linkpkg").display()); + let got: String = eval( + &state, + &format!("return tostring(pmacs.fs.canonicalize(\"{noncanon}\"))"), + ); + let want = fx.root.join("pkg/sub/a.txt").display().to_string(); + assert_eq!(got, want, "symlink and dot segments both resolved"); + + // Falsification for 34b: the uncanonicalized spelling really is + // different, so the affinity test below is not vacuous. + assert_ne!(noncanon, want); +} + +#[test] +fn acc34a_canonicalize_returns_nil_for_a_missing_path() { + let fx = Fixture::new(); + let state = editor(); + let missing = fx.dir("nope/not-here").display().to_string(); + let got: String = eval( + &state, + &format!("return tostring(pmacs.fs.canonicalize(\"{missing}\"))"), + ); + assert_eq!( + got, "nil", + "a nonexistent path declines rather than raising" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 34b — affinity survives a symlinked open. +// +// Asserted at the affinity layer, not just at the binding: the +// regression Q#LN20 exists to prevent is *two servers for one project*, +// and only this shape observes it. +// --------------------------------------------------------------------------- + +fn server_count(state: &EditorState) -> i64 { + eval(state, "return #pmacs.lsp.list()") +} + +#[test] +fn acc34b_canonicalizing_resolver_reuses_one_server_across_a_symlink() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let real = fx.write("proj/src/main.rs", "fn main() {}\n"); + std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap(); + let linked = fx.dir("linkproj").join("src/main.rs"); + + let mut state = editor(); + fx.bind(&state); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.rust = {{ + command = "{}", + root = function(path) + local dir = path:match("^(.*)/[^/]*$") + if not dir then return nil end + -- Walk up to the directory holding Cargo.toml, then + -- canonicalize — the Q#LN8 shape Stage 3b will use. + while dir and #dir > 0 do + local f = io.open(dir .. "/Cargo.toml", "r") + if f then + f:close() + return pmacs.fs.canonicalize(dir) + end + dir = dir:match("^(.*)/[^/]*$") + end + return nil + end, + }} + "#, + fake_lsp_path() + ), + ); + + open(&state, &real); + settle(&mut state); + assert_eq!(server_count(&state), 1, "the real path spawns one server"); + + open(&state, &linked); + settle(&mut state); + assert_eq!( + server_count(&state), + 1, + "the symlinked path must reuse the same server — two here is the \ + exact regression Q#LN20 exists to prevent" + ); +} + +#[test] +fn acc34b_falsified_by_a_resolver_that_skips_canonicalization() { + let fx = Fixture::new(); + fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); + let real = fx.write("proj/src/main.rs", "fn main() {}\n"); + std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap(); + let linked = fx.dir("linkproj").join("src/main.rs"); + + let mut state = editor(); + fx.bind(&state); + // Same resolver, minus the canonicalize call. This is the bite: if + // it also produced one server, the test above would be vacuous and + // `pmacs.fs.canonicalize` would be doing nothing. + exec( + &state, + &format!( + r#" + pmacs.lsp.config.rust = {{ + command = "{}", + root = function(path) + local dir = path:match("^(.*)/[^/]*$") + while dir and #dir > 0 do + local f = io.open(dir .. "/Cargo.toml", "r") + if f then f:close() return dir end + dir = dir:match("^(.*)/[^/]*$") + end + return nil + end, + }} + "#, + fake_lsp_path() + ), + ); + + open(&state, &real); + settle(&mut state); + open(&state, &linked); + settle(&mut state); + assert_eq!( + server_count(&state), + 2, + "without canonicalization the two spellings key differently and \ + spawn two servers — this is what 34b's positive case rules out" + ); +} From 027c7d18e079c0abedcd90b9b0c995aa7f64c66e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 15:58:34 -0400 Subject: [PATCH 04/91] test(lsp): name acc32 for what it pins; label the unpinned guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections found by bite-testing the suite rather than by reading it. **Acceptance 32 was mis-named.** It claimed to pin "the one-shot is removed BEFORE invocation". Biting that — moving the removal after the `pcall` — still passes, because `pcall` catches the raise either way and the removal runs regardless. The before/after ordering is unobservable unless a handler re-enters the drain, and nothing does. What the test actually pins is that removal is **unconditional**: the bite that gates it on `if ok then` fails 2 != 1, because the surviving registration is invoked a second time by the purge. Renamed and re-commented to say so. The implementation still removes before invoking, which is the right defensive order; it is simply not what the assertion proves. **The purge's generation check is defensive and untested**, now labelled in place instead of reading as covered. Reaching it needs a crash and its restart to both land in a gap with no `_async.tick`; the backoff is 500ms, so any tick in that window sees `crashed` and the absent-or-terminal test fires first. Every attempt to stage it deterministically exercised the `crashed` path instead. It stays as insurance for a stalled editor, and says that about itself. Bites recorded, all against the committed tree: - removal gated on a clean return -> acc32 fails (2 != 1). - purge driven by a `crashed`/`stopped` event seen in the drain, the design the framing originally implied -> the no-attachment case fails ("never called"), while the attached case still passes. That is the discrimination acceptance 34's second half exists for. - a resolver without `pmacs.fs.canonicalize` -> two servers, pinned as 34b's own falsification. --- builtin/runtime/lsp.lua | 12 ++++++++++++ tests/lsp_dispatch_seams_acceptance.rs | 22 ++++++++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index eac5753..6a837f6 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1683,6 +1683,18 @@ local function purge_dead_pending() for rid, entry in pairs(pend) do -- Absent or terminal, or the same sid running a NEW generation: -- in every case the request this entry awaits is unanswerable. + -- + -- The generation half is **defensive and not covered by the + -- acceptance suite**, stated plainly rather than left to look + -- tested. Reaching it requires a crash and its restart to both + -- fall inside a gap with no `_async.tick` — the crash backoff is + -- 500ms (`src/lsp.rs:1007`), so any tick during that window sees + -- `crashed` and the absent-or-terminal test above fires first. A + -- stalled or idle editor can produce such a gap, and then this is + -- the only thing standing between a one-shot and waiting forever + -- on a reply the dead generation owed. Every attempt to stage it + -- deterministically ended up exercising the `crashed` path + -- instead, so it is kept as insurance and labelled as such. if attempt == nil or attempt ~= entry.attempt then dead[#dead + 1] = rid end diff --git a/tests/lsp_dispatch_seams_acceptance.rs b/tests/lsp_dispatch_seams_acceptance.rs index d240ba4..cb9acff 100644 --- a/tests/lsp_dispatch_seams_acceptance.rs +++ b/tests/lsp_dispatch_seams_acceptance.rs @@ -336,16 +336,22 @@ fn acc33_raising_response_handler_does_not_stop_the_drain() { } // --------------------------------------------------------------------------- -// Acceptance 32 — the one-shot is removed BEFORE invocation. +// Acceptance 32 — the one-shot is removed exactly once, whether or not +// the handler raises. // -// Observed rather than asserted structurally: the handler raises, and -// the server is then stopped. If removal happened only on a clean -// return — or not at all — the purge below would invoke the same handler -// a second time with an error. The count is what pins it. +// Named for what it pins rather than for the framing's wording. Q#LN9 +// specifies removal *before* invocation, and the implementation does +// that — but bite-testing showed the before/after ordering is not +// observable on its own: `pcall` catches the raise either way, so +// removal after the call is behaviorally identical unless a handler +// re-enters the drain, which nothing does. What IS observable, and what +// this pins, is that removal is **unconditional**: the bite that moves +// it inside `if ok then` fails here 2 != 1, because the surviving +// registration gets invoked a second time by the purge. // --------------------------------------------------------------------------- #[test] -fn acc32_response_one_shot_is_removed_before_invocation() { +fn acc32_response_one_shot_is_removed_even_when_the_handler_raises() { let fx = Fixture::new(); let mut state = editor(); attached_rust(&mut state, &fx); @@ -375,8 +381,8 @@ fn acc32_response_one_shot_is_removed_before_invocation() { assert_eq!( eval::(&state, "return _G.calls"), 1, - "a delivered one-shot must not be re-invoked by the purge — it \ - was removed before the raising handler ran, not after" + "a delivered one-shot must not be re-invoked by the purge — \ + removal is unconditional, not gated on a clean return" ); } From aff3a60332892c63913194d2b693512524183679 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 16:01:18 -0400 Subject: [PATCH 05/91] docs: correct the purge's reachable-leak claim; record the 3a lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rev 5 said acceptance 34's second edge was a killed buffer. Implementing it showed that is false: the Rust core fires exactly five hooks — buffer.after-edit, buffer.after-load, buffer.after-switch, frontend.detached, process.after-tick — and there is **no buffer-kill hook**, so lsp.lua never tears an attachment down and the drain keeps reaching that server. The premise (the drain builds its sid list from `attachments`) was right; the inference needed attachments to be removed on kill, and nothing removes them. The reachable leak has the same root cause by a different path. `attach_buffer` drops a sid from `attachments` the moment `server_is_live` reports false and rebuilds against a fresh server — so `crashed` / `stopped` is the event *least* likely to be drained, and an event-driven purge leaks in exactly the case it exists for. The purge therefore polls `pmacs.lsp.list()`, which enumerates the manager directly. Acceptance 34's second half now exercises a server in **no** attachment, which is the shape that discriminates: bitten, an event-driven purge fails it while the attached case still passes. §0.1 finding 6, Q#LN9, and acceptance 34 all updated; the wrong wording is left visible with its correction rather than quietly replaced, since the mistake is the useful part. Ledger gains the Stage 3a lane: branch, worktree, what ships, both corrected claims, the `install_async` load-order trap, the recorded bites, the one knowingly unpinned guard, and gate results. --- docs/active-work.md | 54 +++++++++++++++++++++++++++- docs/lean4-mode-framing.md | 74 ++++++++++++++++++++++++++------------ 2 files changed, 104 insertions(+), 24 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index b426f38..3cf1a8e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,7 +54,7 @@ git status --short --branch The `git log` command must expose `0dd16a5` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161) +## Lean 4 lane (Arc 8) — Stages 1+2 MERGED; Stage 3a IN REVIEW - Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review round, all twelve checks green). Branch `githubsucks/lean4-stage1` @@ -188,6 +188,58 @@ If it does not, stop and repair the remote/fetch configuration. suites**; `git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME` and `-- --skip basedpyright`. +### Stage 3a — dispatch seams + `pmacs.fs.canonicalize` (branch `lean4-stage3a-seams`) + +- Worktree `../pmacs-lean-stage3`, branched off `githubsucks/main` @ + `46a1b8f`. Carries framing **rev 5** (the Stage 3 split) as its first + two commits, then the implementation, then a bite-driven correction. +- **Stage 2 merged as #161** (`main` @ `46a1b8f`, 2026-07-25, two review + rounds). COHERENCE.md §7 records the slice; §1.2 records the dead + `pmacs.error` channel found landing it. +- **Framing rev 5 splits Stage 3 into 3a and 3b** because rev 4 broke its + own §4 rule — the row read "two `lsp.lua` generalizations" under prose + claiming Stage 3 was Lean-only. One generalization shipped as Stage 2; + the other (Q#LN9's seams) is the shared event drain, so it is now its + own substrate stage. 3a and 3b are **strictly sequential** — 3b's + subscriber is written against 3a's seam and both touch `lsp.lua`. +- Ships: `pmacs.lsp.on_notification` / `on_response`, two arms in + `handle_server_requests`, a pending-response purge, and + `pmacs.fs.canonicalize` (Q#LN20). No protocol change, no Lean content. +- **Two framing claims were corrected during implementation**, both + recorded in §0.1 finding 6 and in the round-2 commit: + 1. The reachable leak is **not** a killed buffer. The Rust core fires + exactly five hooks (`buffer.after-edit`, `buffer.after-load`, + `buffer.after-switch`, `frontend.detached`, `process.after-tick`) — + **there is no buffer-kill hook**, so nothing tears an attachment + down and the drain keeps reaching that server. The real path is + `attach_buffer` dropping a dead sid from `attachments` and + rebuilding against a fresh server, which makes `crashed`/`stopped` + the event *least* likely to be drained. Hence the purge polls + `pmacs.lsp.list()` rather than riding the drain. + 2. Acceptance 32 does **not** pin "removed before invocation" — + `pcall` catches the raise either way, so before/after is + unobservable without a re-entrant drain. It pins removal being + **unconditional**; renamed accordingly. +- **`pmacs._fs` is installed from `install_async`, not `install_project`**, + purely for load order: `make_workspace` runs *after* `fs.lua` is + evaluated, so a canonicalizer placed there reads nil. This cost one + failing run to discover and is the kind of thing to check first. +- Bites recorded (all against the committed tree): removal gated on a + clean return → acc32 fails 2 != 1; an event-driven purge → the + no-attachment case fails "never called" while the attached case still + passes; a resolver without `canonicalize` → two servers (34b's own + falsification, which ships as a test). +- **Known unpinned:** the purge's generation (`attempt`) check. Reaching + it needs a crash *and* its restart to fall in a gap with no + `_async.tick`; the backoff is 500ms, so any tick sees `crashed` first + and the absent-or-terminal arm fires. Labelled as defensive in the + code rather than left looking covered. +- Verification on this branch: `cargo fmt --check` clean; strict + workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; + dispatch seams 14/14; multi-root 13/13; M4 121; required GPU 155; + **isolated-config workspace sweep 3,188 across 93 suites, zero + failures**; `git diff --check` clean. + ## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next - Approved framing: `docs/dired-framing.md` (revision 5), landing as its diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index 317d74f..4869252 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -241,11 +241,26 @@ Confirmations, recorded because each was load-bearing and unverified: narrowing is load-bearing.** `handle_server_requests` builds its sid list from `attachments`, and `push_event` appends with no cap. So a subscriber fires only for a server with a live attachment, and an - unattached server's event queue grows unboundedly. This bites - acceptance 34 directly: kill the buffer with a request outstanding - and the pending purge never runs — the leak that criterion exists to - prevent. Q#LN9 now states the contract and acceptance 34 drives it - through the buffer-kill path rather than the server-death path alone. + unattached server's event queue grows unboundedly. + + *Corrected during implementation (rev 5, round 2).* Rev 5 first + claimed the reachable leak was a killed buffer. **That was wrong.** + The Rust core fires exactly five hooks — `buffer.after-edit`, + `buffer.after-load`, `buffer.after-switch`, `frontend.detached`, + `process.after-tick` — and **there is no buffer-kill hook at all**, + so `lsp.lua` never tears an attachment down and the drain keeps + reaching that server. The premise was right and the inference was + not: it needed attachments to be removed on kill, and nothing + removes them. + + The reachable leak is a different path with the same root cause. + `attach_buffer` drops a sid from `attachments` the moment + `server_is_live` reports false, rebuilding against a fresh server — + so the `crashed` / `stopped` event that should trigger a purge is + **precisely the one most likely to go undrained**. An event-driven + purge leaks exactly when it matters. Q#LN9 therefore drives the + purge off `pmacs.lsp.list()`, which enumerates the manager directly + and is unaffected by attachment bookkeeping. 7. **The `cfg.restart` gap is still open** (recorded landing #161): `ensure_server` never forwards `pmacs.lsp.config[lang].restart` to `pmacs.lsp.spawn`, so the field is silently dropped on auto-attach. @@ -264,9 +279,9 @@ rather than a bare root), `ensure_server` 527 → **610**, 12798 → **12827**, `pair.lua` 213 → **229**, and `compile.lua` 264 → **266**. Verified good and left alone: `listview.lua:138`, `src/lsp.rs:264`, `src/diag.rs:50`, `src/process.rs:193`, -`src/project.rs:145`, and the `mod.rs` binding-block citations. The pre-#161 line numbers inside Q#LN15 are -left as written: that stage has landed and its citations are historical -record, not navigation. +`src/project.rs:145`, and the `mod.rs` binding-block citations. The +pre-#161 line numbers inside Q#LN15 are left as written: that stage has +landed and its citations are historical record, not navigation. ## 1. What ships @@ -1018,13 +1033,24 @@ attachment.** `handle_server_requests` builds its sid list from `attachments`, so a server with no attached buffer is never drained — and `push_event` appends with no cap, so that server's queue grows unboundedly. Both facts are pre-existing and neither is Stage 3a's to -fix, but the second one turns the first into a leak with a name: **a -buffer killed while a request is outstanding never runs the purge**, -because the purge rides the drain that the attachment was gating. That is -the exact failure acceptance 34 exists to prevent, reachable through the -ordinary `C-x k`. So the purge is driven from both edges — the server-death -transition *and* attachment teardown — and acceptance 34 exercises the -buffer-kill path, which is the one a user can actually reach. +fix. What they change is where the purge may be wired. + +**The purge must not ride the drain.** `attach_buffer` removes a sid +from `attachments` as soon as `server_is_live` reports false and rebuilds +the attachment against a fresh server, so a `crashed` / `stopped` event +is the event *least* likely to be drained — the drain stops visiting +that server at almost exactly the moment the event is queued. A purge +triggered by observing that event therefore leaks in the case it exists +to handle. + +So the purge polls **`pmacs.lsp.list()`** after each drain instead. That +call enumerates the manager directly and is unaffected by attachment +bookkeeping, which is what makes it the right authority: a sid that is +absent, terminal, or running a new generation settles its pending +one-shots with an error, whether or not anything ever drained it. +Acceptance 34's second half exercises a server that is in **no** +attachment, because that is the shape an event-driven purge fails and a +polled one survives. The uncapped queue is recorded as a named deferral (§6) rather than fixed here: bounding it is a policy question about which events may be dropped, @@ -1629,14 +1655,16 @@ the blast radius. registered, `workspace/applyEdit` in the same drain is still handled; a raising response handler does not stop later events in that drain. Mirrors the notification-side pins above. -- **34.** **Pending-purge pin, both edges.** A server that dies with a response - outstanding invokes the pending one-shot with an error and clears it. - **And** — the case round 4 found reachable and rev 4 missed — killing - the *buffer* with a request outstanding does the same, rather than - stranding the registration behind a drain that no longer runs for - that server. The second half must be shown to fail against a - purge wired only to the server-death transition; otherwise this - criterion is satisfied by the implementation that leaks. +- **34.** **Pending-purge pin, both edges.** A server that dies with a + response outstanding invokes the pending one-shot with an error and + clears it. **And** a server that is in **no attachment** does the + same, rather than stranding the registration behind a drain that never + visits it. The second half must be shown to fail against a purge + wired to a death event seen in the drain; otherwise this criterion is + satisfied by the implementation that leaks. (Rev 5 first worded the + second edge as a killed buffer; there is no buffer-kill hook, so + nothing removes the attachment and that path does not leak. Corrected + in round 2 — see §0.1 finding 6.) - **34a.** **Canonicalizer pin (Q#LN20).** `pmacs.fs.canonicalize` resolves a symlinked and dot-segmented path to the same string as the real path, and returns nil for a nonexistent one. Fixture builds the symlink From 8c924ebf41a1236779832f261af0d011a4bf513f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 16:17:24 -0400 Subject: [PATCH 06/91] docs: record #166 as landed and open the CI-coverage lane The handoff snapshot and the active-work ledger both still described the GPU terminal input work as in review. Per their own update protocols this should have ridden #166; it did not, because the review that surfaced the CI-coverage finding came after that PR was already green, and expanding an approved PR to carry a new lane would have been the wrong trade. docs/agent-handoff.md section 1 gains the #166 entry: the split into a frontend-kind-neutral liveness half and a grid-only geometry half, the extracted dispatcher loop body, the trap about the no-placement release that reads like liveness and is not, and why the one-line guard was rejected. docs/active-work.md moves the lane to "Closed since the last snapshot" and opens a new one: the Stage 3 real-path acceptance is dark in CI. The workflow never enables the crdt feature, so every crdt-gated acceptance test is not merely skipped but never compiled -- which covers a37 (real daemon, real PTY, real wgpu) since #135 as well as the two tests #166 added beside it. The fix is one step on the gpu-render job, but it needs its own lane because it would run a37 under lavapipe for the first time, and neither its timing budgets nor its wgpu path have been exercised on that adapter. The lane also asks which other crdt-gated suites are dark for the same reason. Recorded alongside it: #166's three unit pins are not crdt-gated and do run under CI's exact flags, including the controller-release pin whose only job is catching the plausible wrong fix, so the regression protection is live even though the real-daemon evidence is local-only. No code changes. --- docs/active-work.md | 93 ++++++++++++++++++++----------------------- docs/agent-handoff.md | 43 +++++++++++++++++++- 2 files changed, 85 insertions(+), 51 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 1cff0f2..5624d24 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -227,57 +227,32 @@ If it does not, stop and repair the remote/fetch configuration. buffer a directory should resolve *to*, and `pmacs .` should route into it rather than growing a second directory surface. -## GPU terminal input lane — IN REVIEW +## Stage 3 acceptance is dark in CI — NEEDS A LANE -- Portable branch: `githubsucks/gpu-terminal-input`, worktree - `../pmacs-gui-term-input`, based on `githubsucks/main` @ `46a1b8f`. -- Approved framing: `docs/gpu-terminal-input-framing.md` revision 2, - committed as the branch's first commit (`9a0df21`). Bug fix, not a - feature; **no protocol change (stays v20)**. -- Reported as "text input within the terminal doesn't work on GUI, this is - fine in TUI". Root cause: the dispatcher applied **both** terminal-layout - syncs to **every** attached frontend, and a semantic session satisfies both - conditions (a `term_sizes` entry from `AttachRequest` *and* a terminal - declaration). Its PTY was resized twice per tick forever — grid arm installs - the TUI placement size, semantic arm installs the declared content - rectangle, each arm's idempotence guard seeing only what the other just - wrote — so the child took a `SIGWINCH` storm at tick cadence. -- **The fix is a split, not a guard.** The grid arm is also the only per-tick - controller-liveness release a semantic frontend gets, and - `sync_semantic_terminal_layout` cannot take that over: the buffer-follow - snapshot clears the viewport declaration (`on_buffer_snapshot_sent`), so - that arm stops running in exactly the switch-away case that needs the - release. `sync_terminal_layout` is therefore split into a - frontend-kind-neutral half (panel reconcile + liveness) and a grid-only - geometry half, with the loop body extracted to - `sync_terminal_layouts_for_tick` so the exclusivity is structural and tests - drive the real thing. -- **Trap for anyone touching this again:** the release at the "no - `window_placements` entry" arm reads like liveness and is grid geometry. A - semantic frontend has no placement entry at all, so moving it into the - neutral half releases a GPU controller every tick. -- Bite-verified against **two** pre-images, because the naive guard fixes the - storm and introduces the leak: - - | pin | `main` | naive guard | the split | - |---|---|---|---| - | settle (acc 2+3) | FAIL | pass | pass | - | controller release (acc 6) | pass | FAIL | pass | - | grid still resizes (acc 5) | pass | pass | pass | - -- Real-path evidence: a quiet child trapping `SIGWINCH` reports **144 frames - in 4 s and `WINCH 1..12` on screen** against the pre-fix tree, versus a - settled screen with the fix. -- **Deliberately out of scope, named:** interactive-shell echo on a raw-mode - PTY (Q#GT5 — reproduces in-process too, so it is not the GUI/TUI - asymmetry), and a geometry change appearing to clear the visible screen - (reproduces pre-fix; why acceptance 4 latches its observation across - frames). -- Verification on this branch: `cargo fmt --check` clean; strict workspace - Clippy clean; 1,829 default + 2,006 CRDT library tests; vterm Stage 1/2/3 - 10 / 6 / 9 CRDT; bottom-panel Stage 1 46; M4 121; required GPU 155; - **isolated-config workspace sweep 3,177 across 92 suites, zero failures**; - `git diff --check` clean. Gates were run against the committed tree. +- **No branch, no framing yet.** Found while gating #166; deliberately kept + out of it so a CI change would not arrive after review approval. +- `.github/workflows/ci.yml` **never enables the `crdt` feature** (grep the + workflow directory: zero hits). Every `#[cfg(feature = "crdt")]` acceptance + test is therefore not merely skipped in CI — it is **not compiled**. +- That covers the whole Vterm Stage 3 real-path acceptance, including `a37` + (real daemon + real PTY + real wgpu), which has been dark since #135, and + the two tests #166 added beside it. +- The `gpu-render` job is the only one with lavapipe and + `PMACS_REQUIRE_GPU=1`, and it runs `cargo test -p pmacs-gpu`, which never + reaches the `pmacs` crate's acceptance suites. +- The shape of the fix is one step on the `gpu-render` job: + `cargo test --features crdt --test vterm_stage3_acceptance -- --test-threads=1`. + It needs its own lane rather than a drive-by because it would run `a37` + under lavapipe **for the first time**, and neither its timing budgets nor + its wgpu path have ever been exercised on that adapter or on macOS CI. +- Worth auditing at the same time: which *other* `crdt`-gated acceptance + suites are dark for the same reason. This is a coverage question about the + gate list itself, not about any one suite. +- Mitigating fact, verified rather than assumed: #166's three unit pins are + **not** `crdt`-gated and do run under CI's exact flags + (`--no-default-features --features luajit|lua54`), including the + controller-release pin whose only job is catching the plausible wrong fix. + The regression protection is live; the real-daemon evidence is local-only. ## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW @@ -483,6 +458,24 @@ git worktree add --track \ ## Closed since the last snapshot +- **GPU terminal input (the double terminal-layout sync) — MERGED as #166** + (`main` @ `b889873`, 2026-07-25, one review round, all twelve checks green + after a macOS PTY-timing rerun). The dispatcher applied **both** + terminal-layout syncs to **every** attached frontend; a semantic session + satisfies both conditions, so its PTY was resized twice per tick forever and + the child took a `SIGWINCH` storm that made a GPU terminal untypable while + output still flowed. `sync_terminal_layout` is now split into a + frontend-kind-neutral half (panel reconcile + controller liveness) and a + grid-only geometry half, with the loop body extracted to + `sync_terminal_layouts_for_tick` so the exclusivity is structural. No + protocol change (v20). Durable lessons are in `docs/agent-handoff.md` §5; + the framing (`docs/gpu-terminal-input-framing.md` rev 2) carries three + falsified hypotheses, the two-pre-image bite matrix, and two named + out-of-scope items (Q#GT5 interactive-shell echo on a raw PTY, which + reproduces in-process and so is not the GUI/TUI asymmetry; and a geometry + change appearing to clear the visible screen, which reproduces pre-fix). + Branch `gpu-terminal-input` and worktree `../pmacs-gui-term-input` retained. + - **GPU initial target — MERGED as #148** (`main` @ `0dd16a5`, 2026-07-24, after two review rounds). `pmacs --gpu [--socket …] FILE` opens a target before the GPU window appears. Protocol bumped 19 → 20: a semantic-session diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d1f4943..e28911f 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,6 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-25, after find-file (#162) landed — the dired +**Last updated: 2026-07-25, after the GPU terminal input fix (#166) landed — +the double terminal-layout sync that made a GPU terminal untypable — following +Lean 4 Stage 2 (#161), the dired framing pair (#163/#164), find-file (#162) — +the dired arc's Stage 0 — following COHERENCE.md (#163), Lean 4 Stage 1 (#160), the minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the @@ -578,6 +581,44 @@ commands, read `docs/active-work.md` immediately after this file. - Remaining ranked arcs: 6 folding Stage 3, 7 DAP, 8 GPU splits, plus the `.ipynb` arc (its JSON-grammar prerequisite shipped in #123). +- **GPU terminal input LANDED — #166** (`main` @ `b889873`; + `docs/gpu-terminal-input-framing.md` rev 2; one review round). The + dispatcher applied **both** terminal-layout syncs to **every** attached + frontend each tick. A semantic session satisfies both conditions — a + `term_sizes` entry from `AttachRequest` *and* a terminal declaration — so + its PTY was resized twice per tick forever: the grid arm installed the TUI + placement size, the semantic arm the declared content rectangle, each arm's + `old_size == size` guard seeing only what the other had just written. The + child took a `SIGWINCH` storm at tick cadence, which made typing into a GPU + terminal impossible while output kept flowing. TUI was structurally + unaffected. + - `EditorInstance::sync_terminal_layout` is split into + `sync_terminal_controller_liveness` (frontend-kind **neutral**: panel + reconcile + release of a controller whose window moved away — reads only + views/windows/controller, never a grid size) and + `sync_terminal_grid_geometry` (**grid only**: TUI placement + resize). + `sync_terminal_layout` survives as the composition, so `editor::run` and + `LOCAL` are byte-identical. + - `daemon::sync_terminal_layouts_for_tick` is the extracted loop body: + liveness for every frontend once per tick, then **exactly one** geometry + arm keyed on `semantic_states` membership — the same fact session + establishment uses, so the arms cannot both fire. + - **The trap, kept in a comment:** the release on a missing + `window_placements` entry reads like liveness and is grid geometry. A + semantic frontend has no placement entry at all, so moving it into the + neutral half would release a GPU controller every tick. + - Why not the one-line guard: the grid arm was also the **only** per-tick + controller-liveness release a semantic frontend got, and + `sync_semantic_terminal_layout` cannot take it over — the buffer-follow + snapshot clears the viewport declaration, so that arm stops running in + exactly the switch-away case that needs the release. + - No protocol change (v20). Gates: 1,829 default + 2,006 CRDT library + tests; vterm Stage 1/2/3 10/6/9 CRDT; bottom-panel 46; M4 121; required + GPU 155; isolated-config workspace sweep 3,177 across 92 suites. + - **Known gap, its own lane:** CI never enables `crdt`, so the Stage 3 + real-path acceptance (including `a37`) is not compiled there. #166's unit + pins are not `crdt`-gated and do run. See `docs/active-work.md`. + ## 2. How we work (the part that must not drift) The user is expert and reviews deeply — they falsify framings and find From a9ef257930ce25d14e87148a4a04918c70939da5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 16:53:27 -0400 Subject: [PATCH 07/91] fix(lsp): decline a non-UTF-8 canonicalization; round 1 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from review, one of them a real defect. **`canonicalize` could emit a path that exists nowhere.** `p.display().to_string()` substitutes U+FFFD for non-UTF-8 bytes, so a resolution landing on such a path returned a plausible-looking string that does not exist on disk — worse than nil, because this value becomes a server-affinity key through `file_uri_for` and would silently fail to round-trip, while the doc promised nil for anything unresolvable. Now `.and_then(|p| p.to_str().map(str::to_owned))`: unrepresentable is a decline, matching how the fs layer already treats non-UTF-8 symlink targets. Pinned by a new acceptance case that reaches a non-UTF-8 target through an **ASCII** symlink, so the input is representable and only the resolved output is not — the case a UTF-8 check on the argument would miss. Bitten: restoring `display()` fails it. The other five: - `on_response`'s doc comment now warns that registering against a server with no attached buffer is fire-on-death, not fire-on-reply, because the drain visits only attached sids. It looks exactly like a hung request while debugging, and 3b is the first caller likely to hit it. - `deliver_response`'s comment still carried the pre-correction rationale ("removed BEFORE invocation ... must not be re-entered") — the claim the bite disproved. It now says what is true: removal is unconditional, before-vs-after is unobservable without a re-entrant drain, and the reachable bug is gating removal on a clean return. - Dropped `server_attempt`'s unused second return. - Deleted a vacuous assertion in the no-attachment test (counting `_G` entries to assert "lua globals are readable") — scaffolding that pinned nothing, the exact shape the project's own lesson flags. - `#[cfg(unix)]` on the three symlink-dependent tests. Gates re-run in full. The sweep's first pass tripped `composition_overhead_under_ten_percent` at 18.8% against a 10% budget; it passes 3/3 in isolation here, passes in isolation on main, and the same run reported the realistic-frame overhead as **-4.6%** — a negative figure is measurement noise, not added work. Nothing in this diff is on the render path. Rerun of the full sweep: 3,189 across 93 suites, zero failures. --- builtin/runtime/lsp.lua | 22 +++++++++-- src/lua_bindings/mod.rs | 11 +++++- tests/lsp_dispatch_seams_acceptance.rs | 53 +++++++++++++++++++++++--- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 6a837f6..fd44f1a 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1584,10 +1584,10 @@ local function server_attempt(sid) local skey = tostring(sid) for _, info in ipairs(pmacs.lsp.list()) do if tostring(info.id) == skey then - return info.attempt or 0, info.state and info.state.kind + return info.attempt or 0 end end - return nil, nil + return nil end -- fn(sid, params); persistent, fires for every server. @@ -1605,6 +1605,15 @@ end -- fn(result, err); ONE-SHOT, keyed to the exact request. -- `request_id` is what `pmacs.lsp.send_request` returned. +-- +-- **Register only against a server with an attached buffer.** The drain +-- that delivers replies visits only sids present in `attachments`, so a +-- one-shot on an unattached server will not fire on its reply — the +-- reply sits in that server's queue and the handler is invoked only when +-- the purge below decides the server is gone. That is fire-on-death, not +-- fire-on-reply, and it looks exactly like a hung request while +-- debugging. The attach path is the ordinary way to get a sid; a +-- hand-spawned one from `init.lua` is the case to watch. function pmacs.lsp.on_response(sid, request_id, fn) if not sid or type(request_id) ~= "number" or type(fn) ~= "function" then error("pmacs.lsp.on_response(sid, request_id, fn): want sid, number, function") @@ -1641,8 +1650,13 @@ local function deliver_response(sid, ev) if not pend then return end local entry = pend[ev.request_id] if not entry then return end - -- Removed BEFORE invocation: a handler that raises must not be - -- re-entered by a later event carrying the same id. + -- Removed UNCONDITIONALLY, so a handler that raises is still retired + -- and cannot be invoked a second time by the purge. Removing first is + -- the defensive order and costs nothing, but it is not what defends + -- against re-invocation: `pcall` catches the raise either way, so + -- before-vs-after is unobservable without a re-entrant drain. The + -- reachable bug is gating removal on a clean return, which acceptance + -- 32 bites (2 != 1). pend[ev.request_id] = nil if next(pend) == nil then pending_responses[skey] = nil end local ok, err = pcall(entry.fn, ev.result, ev.error) diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 369b810..ac33537 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -6550,9 +6550,18 @@ pub fn install_async( // symlink is ordinary, and raising would surface through // `resolve_root_fn`'s pcall as a config bug, which it is // not. + // + // `to_str`, NOT `display()`. A resolution that lands on + // non-UTF-8 bytes has no faithful string form, and + // `display()` would substitute U+FFFD and hand back a + // path that does not exist on disk — strictly worse than + // nil here, because this value becomes a server-affinity + // key via `file_uri_for` and would silently fail to + // round-trip. Unrepresentable is a decline, matching how + // the fs layer already treats non-UTF-8 symlink targets. Ok(std::fs::canonicalize(&path) .ok() - .map(|p| p.display().to_string())) + .and_then(|p| p.to_str().map(str::to_owned))) })?, )?; pmacs.set("_fs", fs_priv)?; diff --git a/tests/lsp_dispatch_seams_acceptance.rs b/tests/lsp_dispatch_seams_acceptance.rs index cb9acff..bc0b40d 100644 --- a/tests/lsp_dispatch_seams_acceptance.rs +++ b/tests/lsp_dispatch_seams_acceptance.rs @@ -493,12 +493,6 @@ fn acc34_purge_reaches_a_server_that_is_in_no_attachment() { ); settle(&mut state); - let attached: i64 = eval( - &state, - "local n = 0 for _ in pairs(_G) do n = n + 1 end return n", - ); - assert!(attached > 0, "lua globals are readable"); - exec( &state, r#" @@ -528,6 +522,7 @@ fn acc34_purge_reaches_a_server_that_is_in_no_attachment() { // --------------------------------------------------------------------------- #[test] +#[cfg(unix)] fn acc34a_canonicalize_resolves_symlinks_and_dot_segments() { let fx = Fixture::new(); fx.write("pkg/sub/a.txt", "x\n"); @@ -576,6 +571,7 @@ fn server_count(state: &EditorState) -> i64 { } #[test] +#[cfg(unix)] fn acc34b_canonicalizing_resolver_reuses_one_server_across_a_symlink() { let fx = Fixture::new(); fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); @@ -627,6 +623,7 @@ fn acc34b_canonicalizing_resolver_reuses_one_server_across_a_symlink() { } #[test] +#[cfg(unix)] fn acc34b_falsified_by_a_resolver_that_skips_canonicalization() { let fx = Fixture::new(); fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n"); @@ -671,3 +668,47 @@ fn acc34b_falsified_by_a_resolver_that_skips_canonicalization() { spawn two servers — this is what 34b's positive case rules out" ); } + +// --------------------------------------------------------------------------- +// Acceptance 34a, non-UTF-8 arm — an unrepresentable resolution declines +// rather than returning a lossy string. +// +// Review finding on PR #167: `display().to_string()` substitutes U+FFFD, +// which would hand back a path that does not exist on disk. That is +// strictly worse than nil here, because the value becomes a +// server-affinity key via `file_uri_for` and would silently fail to +// round-trip. Bites against the `display()` form, which returns a +// non-nil string for this fixture. +// --------------------------------------------------------------------------- + +#[test] +#[cfg(unix)] +fn acc34a_canonicalize_declines_a_non_utf8_resolution() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt as _; + + let fx = Fixture::new(); + // 0xFF is not valid UTF-8 in any position. + let raw = OsStr::from_bytes(b"bad-\xffname"); + let target = fx.root.join(raw); + std::fs::write(&target, "x\n").unwrap(); + // Reached through an ASCII symlink, so the *input* is representable + // and only the resolved output is not — which is the case + // `to_str()` has to catch and a UTF-8-only input check would miss. + let link = fx.dir("ascii-link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let state = editor(); + let got: String = eval( + &state, + &format!( + "return tostring(pmacs.fs.canonicalize(\"{}\"))", + lua_str(&link) + ), + ); + assert_eq!( + got, "nil", + "a resolution that lands on non-UTF-8 bytes must decline, not \ + return a U+FFFD-substituted path that exists nowhere" + ); +} From b70393762edd14f7fa5bd4602299e33066ad1e2c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 17:03:10 -0400 Subject: [PATCH 08/91] test(lsp): gate the non-UTF-8 fixture on linux, not merely unix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI round 1: both macOS jobs failed on the acceptance case added last commit. APFS enforces valid UTF-8 in filenames, so `std::fs::write` with a 0xFF byte in the name fails with EILSEQ ("Illegal byte sequence") before `pmacs.fs.canonicalize` is ever called. The fixture cannot be built there. That is a filesystem refusing to represent the case, not a behavioral difference: the subject — `to_str()` returning None for a non-UTF-8 resolution — is platform-independent Rust, and the Linux run pins it. `#[cfg(unix)]` was the wrong granularity; review had asked for unix gating on the symlink tests and I applied the same gate here without checking whether the filesystem, rather than the API, was the constraint. Gated `#[cfg(target_os = "linux")]` with the reason in place, rather than skipped at runtime, so a future failure here is a real failure and not a silent no-op. Ledger records both CI-round facts: this one, and that `composition_overhead_under_ten_percent` is load-sensitive under a parallel workspace sweep (it reported -4.6% realistic overhead in the same run that tripped its 10% budget at 18.8%, which is noise, not work). --- docs/active-work.md | 18 +++++++++++++++--- tests/lsp_dispatch_seams_acceptance.rs | 12 +++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 3cf1a8e..aef6edc 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -236,9 +236,21 @@ If it does not, stop and repair the remote/fetch configuration. code rather than left looking covered. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; - dispatch seams 14/14; multi-root 13/13; M4 121; required GPU 155; - **isolated-config workspace sweep 3,188 across 93 suites, zero - failures**; `git diff --check` clean. + dispatch seams 15/15 on Linux (14 on macOS — see below); multi-root + 13/13; M4 121; required GPU 155; **isolated-config workspace sweep + 3,189 across 93 suites, zero failures**; `git diff --check` clean. +- **Two flakes/portability facts from CI round 1, both worth keeping:** + 1. `composition_overhead_under_ten_percent` tripped once in a local + sweep at 18.8% against a 10% budget, then passed 3/3 in isolation + here, passed in isolation on main, and passed a full sweep rerun. + The tell is in its own output: the same run reported realistic-frame + overhead as **-4.6%**, and a negative figure is measurement noise, + not added work. Load-sensitive under a parallel `--workspace` run. + 2. **A non-UTF-8 filename fixture cannot be built on macOS.** APFS + enforces valid UTF-8, so `std::fs::write` fails with EILSEQ + ("Illegal byte sequence") before the code under test is reached. + `#[cfg(unix)]` is NOT sufficient for such a fixture — + `#[cfg(target_os = "linux")]` is. Cost one red CI round to learn. ## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next diff --git a/tests/lsp_dispatch_seams_acceptance.rs b/tests/lsp_dispatch_seams_acceptance.rs index bc0b40d..f644367 100644 --- a/tests/lsp_dispatch_seams_acceptance.rs +++ b/tests/lsp_dispatch_seams_acceptance.rs @@ -679,10 +679,20 @@ fn acc34b_falsified_by_a_resolver_that_skips_canonicalization() { // server-affinity key via `file_uri_for` and would silently fail to // round-trip. Bites against the `display()` form, which returns a // non-nil string for this fixture. +// +// **Linux-gated, and `cfg(unix)` was not enough** — CI caught that. +// APFS enforces valid UTF-8 in filenames, so on macOS the `write` below +// fails with EILSEQ ("Illegal byte sequence") before the code under test +// is ever reached: the fixture cannot be built there. That is a +// filesystem refusing to represent the case, not a behavioral +// difference — the subject itself, `to_str()` returning None, is +// platform-independent Rust. Gated explicitly rather than skipped at +// runtime, so a future failure here is a real failure and not a silent +// no-op. // --------------------------------------------------------------------------- #[test] -#[cfg(unix)] +#[cfg(target_os = "linux")] fn acc34a_canonicalize_declines_a_non_utf8_resolution() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt as _; From 40709fafb93a199ae8a65e264eaad1a45aeebbf0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 17:17:23 -0400 Subject: [PATCH 09/91] docs: measure the CI coverage gap and record the vterm as-framed audit The lane opened in the previous commit was scoped to the Vterm Stage 3 acceptance. Measuring it properly shows the problem is much larger and not vterm-specific. Comparing cargo test --list under CI's exact flags against the same flags plus crdt: 3,024 versus 3,288. 264 tests are dark in CI, and the single worst line is the library itself at 177 -- cargo test --lib --features crdt is a required local gate that CI has never run. Ten suites run zero or one test, including gpu_initial_target (#148's entire acceptance, 1 of 14), gpu_invocation (#141's, 1 of 14), and a37, the Stage 3 real-daemon/real-PTY/real-wgpu path that #135 built precisely because a decoded-message fixture would prove none of the three fit together. The lane now carries the per-target table, the verified flag combination for the fix, a two-part fix shape (a crdt leg on the test job, plus the GPU-requiring suites onto the existing gpu-render job that already has lavapipe), and an explicit instruction to sort deliberate exclusions from accidental ones first -- some of the 264 are perf suites that are ignored by default and belong to their own jobs, while m10_10_perf has no ignore attribute and no job naming it. docs/vterm-framing.md gains an as-framed audit section. The arc is structurally complete and every test named in the Stage 2 verification map exists, but criterion 22's "without thrash" clause was never pinned anywhere -- the word appears nowhere in src or tests -- and that clause describes exactly the defect #166 fixed. Of the nine Stage 3 tests, only three drive a real daemon, so the six that construct EditorState directly could never see a dispatcher-loop defect; a31 passes on the broken tree for that reason. Four of the nine, including a37 and Stage 3 review round 1's own presence regression guard, do not run in CI at all. The section also records what was not audited: section 11's blanket claim about deferral safety covers roughly twenty items and none were spot-checked. docs/gpu-terminal-input-framing.md scores bet B2 true now that the reporter has confirmed typing works, and retracts Q#GT5. The bash fixture behind it does not reproduce in real use and was almost certainly measuring its own timing rather than a product behaviour; it is marked retracted rather than deleted so nobody re-derives it from an earlier revision. docs/agent-handoff.md section 5 gains the lesson the confirmation cost: a daemon-side fix is not deployed until the daemon is restarted from a tree containing it, and rebuilding a binary does nothing to a running process. No code changes. --- docs/active-work.md | 82 +++++++++++++++++++++--------- docs/agent-handoff.md | 9 ++++ docs/gpu-terminal-input-framing.md | 26 +++++++--- docs/vterm-framing.md | 42 +++++++++++++++ 4 files changed, 129 insertions(+), 30 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5624d24..5f2f142 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -227,32 +227,68 @@ If it does not, stop and repair the remote/fetch configuration. buffer a directory should resolve *to*, and `pmacs .` should route into it rather than growing a second directory surface. -## Stage 3 acceptance is dark in CI — NEEDS A LANE +## The CRDT half of the test corpus is dark in CI — NEEDS A LANE -- **No branch, no framing yet.** Found while gating #166; deliberately kept - out of it so a CI change would not arrive after review approval. -- `.github/workflows/ci.yml` **never enables the `crdt` feature** (grep the - workflow directory: zero hits). Every `#[cfg(feature = "crdt")]` acceptance - test is therefore not merely skipped in CI — it is **not compiled**. -- That covers the whole Vterm Stage 3 real-path acceptance, including `a37` - (real daemon + real PTY + real wgpu), which has been dark since #135, and - the two tests #166 added beside it. -- The `gpu-render` job is the only one with lavapipe and - `PMACS_REQUIRE_GPU=1`, and it runs `cargo test -p pmacs-gpu`, which never - reaches the `pmacs` crate's acceptance suites. -- The shape of the fix is one step on the `gpu-render` job: - `cargo test --features crdt --test vterm_stage3_acceptance -- --test-threads=1`. - It needs its own lane rather than a drive-by because it would run `a37` - under lavapipe **for the first time**, and neither its timing budgets nor - its wgpu path have ever been exercised on that adapter or on macOS CI. -- Worth auditing at the same time: which *other* `crdt`-gated acceptance - suites are dark for the same reason. This is a coverage question about the - gate list itself, not about any one suite. +- **No branch, no framing yet.** Found while gating #166, then measured + properly during the vterm as-framed audit. Deliberately kept out of #166 so + a CI change would not arrive after review approval. +- **Root cause:** `.github/workflows/ci.yml` never enables the `crdt` feature + anywhere — zero hits across the workflow directory. The `test` job runs + `cargo test --all-targets --no-default-features --features luajit|lua54`. + Every `#[cfg(feature = "crdt")]` test is therefore **not compiled** in CI, + not merely skipped. +- **Measured, `--list` under CI's exact flags versus the same flags plus + `crdt`: 3,024 vs 3,288 — 264 tests dark.** Per target: + + | dark | CI | full | target | + |---:|---:|---:|---| + | 177 | 1,832 | 2,009 | **the library itself** (`src/lib.rs`) | + | 21 | 15 | 36 | `m5_5_acceptance` | + | 13 | 1 | 14 | `gpu_invocation_acceptance` | + | 13 | 1 | 14 | `gpu_initial_target_acceptance` | + | 8 | 0 | 8 | `m10_11_acceptance` | + | 6 | 0 | 6 | `auto_pair_crdt_acceptance` | + | 6 | 0 | 6 | `m10_2_perf` | + | 4 | 5 | 9 | `vterm_stage3_acceptance` | + | 4 | 0 | 4 | `m10_10_perf` | + | 3 | 0 | 3 | `compile_mode_crdt_acceptance` | + | 2 | 22 | 24 | `theme_faces_acceptance` | + | 2 | 0 | 2 | `m11_5_semantic_acceptance` | + | 1 | 9 | 10 | `vterm_stage1_acceptance` | + | 1 | 7 | 8 | `statusline_segments_acceptance` | + | 1 | 10 | 11 | `gpu_font_acceptance` | + | 1 | 0 | 1 | `auto_indent_crdt_acceptance` | + | 1 | 0 | 1 | `m10_11_perf` | + +- **The single worst line is the library.** `cargo test --lib --features crdt` + is a REQUIRED local gate in `CLAUDE.md`, and CI has never run it. 177 + library tests — the whole CRDT half — are developer-machine-only. +- **Ten suites run zero or one test in CI**, including `gpu_initial_target` + (#148's entire acceptance, 1/14), `gpu_invocation` (#141's, 1/14), and + `a37`, the Vterm Stage 3 real-daemon/real-PTY/real-wgpu path that #135 + built specifically because "a decoded-message fixture would prove none of + the three fit together". +- **Sort deliberate from accidental before proposing a fix.** Some of the 264 + are perf suites that are `#[ignore]`d by default and belong to their own + jobs (`m10_2_perf` 6, `m10_11_perf` 1). `m10_10_perf` has **no** `#[ignore]` + and no CI job naming it, so it looks accidental. This classification is not + finished and is the lane's first task. +- **Fix shape, two parts** (the flag combination is verified to work: + `--no-default-features --features luajit,crdt` lists 10 vterm Stage 1 tests + versus 9 without): + 1. a `crdt` leg on the `test` job for the non-GPU suites and the library; + 2. the GPU-requiring `crdt` suites onto the existing `gpu-render` job, which + already has lavapipe and `PMACS_REQUIRE_GPU=1` — + `vterm_stage3_acceptance`, `gpu_invocation_acceptance`, + `gpu_initial_target_acceptance`, `gpu_font_acceptance`. +- **Expect first-run failures, and budget for them.** These would execute in + CI for the first time ever: real PTY timing on CI runners, wgpu under + lavapipe, and daemon-socket tests at unfamiliar concurrency. Start + ubuntu-only and decide about macOS from evidence. A red first run is the + lane working, not the lane failing. - Mitigating fact, verified rather than assumed: #166's three unit pins are - **not** `crdt`-gated and do run under CI's exact flags - (`--no-default-features --features luajit|lua54`), including the + **not** `crdt`-gated and do run under CI's exact flags, including the controller-release pin whose only job is catching the plausible wrong fix. - The regression protection is live; the real-daemon evidence is local-only. ## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index e28911f..165f06a 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -745,6 +745,15 @@ final variant — its own round-trip cannot detect a discriminant shift. ## 5. Hard-won ops lessons +- **A daemon-side fix is not deployed until the daemon is restarted from a + tree that contains it.** #166's reporter rebuilt and saw no change: the + running daemon had been started from a shared checkout still on a pre-fix + branch, and `pmacs --gpu` attaches to whatever process already owns the + socket. Rebuilding a binary does nothing to a running process. When + validating a daemon-side fix by hand, check the running process's binary + path and start time against the tree you think you fixed — + `ps -eo pid,lstart,args | grep '[p]macs --daemon'` — before concluding the + fix failed. - **Two operations that must be alternatives are not made alternatives by being adjacent.** The dispatcher applied its grid and semantic terminal-layout syncs to every attached frontend; a semantic session diff --git a/docs/gpu-terminal-input-framing.md b/docs/gpu-terminal-input-framing.md index 0bbccef..aa1bea4 100644 --- a/docs/gpu-terminal-input-framing.md +++ b/docs/gpu-terminal-input-framing.md @@ -81,7 +81,11 @@ character: | | frames for a static screen | typed `Z` ever visible at the prompt | |---|---|---| | `main` today | **730** in a 20 s window | **no** | -| with the guard | **2** | (see Q#GT5 — a separate question) | +| with the fix | **2** | yes | + +Bet B2 is **scored TRUE**: with the fix deployed, the reporter confirmed +typing into a GPU terminal works. The earlier caveat here pointed at Q#GT5, +which is now retracted — see "Deferred (named)". The TUI is unaffected: a grid session has no semantic terminal declaration, so only one arm ever runs for it. This is a **frontend-kind** defect, which is @@ -281,11 +285,12 @@ change. Stays v20. snapshot that signals the switch-away). Hence the split in Q#GT1. Recorded rather than deleted: the failure mode is one a reviewer or a future simplification will re-propose. -- **B2.** The user's reported symptom is this defect. *Partially scored: the - storm is proven and GUI-only, and its shape (line editor unusable, output - still flowing) matches the report. Not fully scored until the user, or an - acceptance running the **user's own shell**, confirms typing works after the - fix. Q#GT5 is the reason this bet is stated rather than assumed.* +- **B2 — SCORED TRUE 2026-07-25.** "The user's reported symptom is this + defect." Confirmed in real use after the fix was deployed: typing into a GPU + terminal works. The confirmation needed a daemon **restart** built from a + tree containing the fix — the first attempt reported no change because a + pre-fix daemon still owned the socket, which is worth remembering whenever a + daemon-side fix is being validated by hand. - **B3.** No other pair of per-frontend-kind daemon operations is applied as siblings rather than alternatives. *Scored by an explicit audit of the dispatcher's per-frontend loop during implementation — this defect's shape @@ -294,7 +299,14 @@ change. Stays v20. ## Deferred (named) -- Interactive-shell echo on a raw-mode PTY (Q#GT5) — its own scout. +- ~~Interactive-shell echo on a raw-mode PTY (Q#GT5)~~ — **RETRACTED + 2026-07-25.** The observation behind it (a `bash --norc -i` fixture not + echoing typed characters) does not reproduce in real use: with the fix + deployed, typing into a GPU terminal echoes normally. The fixture was almost + certainly measuring its own timing — polling a published screen snapshot + before readline had finished initialising — not a product behaviour. Recorded + as retracted rather than deleted so nobody re-derives it from the framing's + earlier revision and spends a scout on it. - **A geometry change appears to clear the visible screen.** Observed while building acceptance 4: after the probe's deliberate 25×92 → 20×71 resize, the next frame's visible grid is entirely blank even though the content diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index b1124c0..b86bcd0 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -1674,6 +1674,48 @@ GPU assertions remain in `pmacs-protocol` and `pmacs-gpu` respectively. - **37:** one real-daemon/real-PTY/headless-wgpu acceptance path; it is not replaced by a decoded-message fixture. +### 0.12 As-framed audit, 2026-07-25 (after #166) + +Prompted by a GPU terminal input defect that shipped in Stage 3 and was fixed +in #166. The arc is structurally complete — all 37 criteria have +implementations, and every test named in the Stage 2 verification map exists — +but the audit found two gaps worth recording against the criteria themselves. + +**Criterion 22's "without thrash" was never pinned.** The criterion reads +"unchanged, zero, passive, and failed resize cases preserve prior geometry +*without thrash*". The word appears nowhere in `src/` or `tests/`. The suite +pinned the four enumerated single-arm cases and never the cross-arm +interaction — which is exactly where the thrash lived: the daemon applied +both the grid and the semantic terminal-layout sync to every attached +frontend, so a semantic session's PTY was resized twice per tick forever. +Criterion 31's "only the exact durable controller changes PTY geometry" was +violated in the same event, in spirit rather than letter: the controller was +the right frontend, but the geometry came from the grid projection. #166 adds +the settle pins; the gap was open from #135 (2026-07-22) until then. + +**Why the Stage 3 suite could not see it.** Of its nine tests, only three +drive a real daemon; the other six construct `EditorState` directly and never +execute the dispatcher loop where the defect lived. `a31`, which is about two +semantic frontends sharing one session, therefore passes on the broken tree. +The same structural blindness explains why `bottom_panel_stage1_acceptance` +was unaffected. A criterion about *dispatcher* behavior needs a test that +runs the dispatcher. + +**Four of the nine Stage 3 tests do not run in CI at all**, because they are +`#[cfg(feature = "crdt")]` and the workflow never enables that feature: +`a37`, the two added by #166, and +`terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret` — which +is Stage 3 review round 1's own regression guard. Stage 1's +`read_only_empty_crdt_bootstrap_is_immutable_against_remote_content`, the CRDT +half of criterion 14, is dark for the same reason. Stage 2 is fully covered +(6/6). This is not a vterm problem: 264 tests workspace-wide are dark, +including 177 in the library. It has its own lane in `docs/active-work.md`. + +**Not audited:** §11's blanket claim that "deferral means graceful ignore or +documented absence, never escape leakage, panic, unbounded allocation, or +child leak". That covers roughly twenty deferred items and none were +spot-checked. It remains an unproven claim rather than a known gap. + ## 10. Gates and bite verification Every PR runs the standing full gates from `AGENTS.md`, sequentially, plus its From ea0632412e07dfd428c2c5a5236da89831109b8e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 17:39:27 -0400 Subject: [PATCH 10/91] docs: record dired Stage 1 (#165) as landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #165's own commits could not update the handoff snapshot to name the merge that contains them, so the protocol obligation lands here. - `docs/agent-handoff.md`: absorb the dired lane into §1, replacing the placeholder that promised exactly this. The bullet carries Stage 1's durable substrate facts — why the tolerant `read_dir` had to be Rust, why exposing the core normalizer beat mirroring it in Lua, the fixed-width `_layout` contract Stage 3 reads offsets from, the ambient-action buffer guard, treating a failure as the answer instead of probing, the per-entry error cap, the first mode-scoped keymap and the pre-existing test it broke, and the dedication a descent does not carry. Refresh the head-of-`main` anchor and the last-updated line. - `docs/agent-handoff.md` §5: two ops lessons that cost real time. A fix must be committed before it is bitten, because `scripts/bite` restores by `git checkout --` and reverts to HEAD; a CONFLICTING PR runs no CI at all, because `pull_request` workflows build a merge ref GitHub does not create while the branch conflicts, and nothing reports the absence. - `docs/active-work.md`: remove the merged lane per update-protocol rule 4 and summarize it under "Closed since the last snapshot", keeping the two forward items Stage 2 needs (the rename rebind is first-match-only over a raw path, and Q#DR5's seam is the main-thread drain). Refresh the canonical base. Flag the two lane headers that still call a merged PR "IN REVIEW" — #161 and #166 — rather than editing lanes another thread owns. - `COHERENCE.md`: #165 is no longer a PR. Per §25 the audited claims this work changed were updated when it landed; this corrects their tense in seven places and the two prose lines that still asserted dired was in flight. - `docs/dired-framing.md`: status line to MERGED, and state plainly that Stages 2 and 3 each still need their own framing. Docs only; no code, no gate-relevant change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0126d2sikA6jZpFin3rtLCSK --- COHERENCE.md | 21 +++-- docs/active-work.md | 201 ++++++++++-------------------------------- docs/agent-handoff.md | 113 +++++++++++++++++++++--- docs/dired-framing.md | 4 +- 4 files changed, 164 insertions(+), 175 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 95761b2..1cfba71 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -120,7 +120,7 @@ asymmetry**, and **per-arc coherence debt**. Coherence-shaped work already in flight at audit time: find-file / dired Stage 0 (`C-x C-f`, merged #162, `docs/dired-framing.md`) and its -Stage 1 directory view (PR #165), bottom panel Stage 1 (merged #155), +Stage 1 directory view (merged #165), bottom panel Stage 1 (merged #155), multi-root LSP affinity (merged #161), the config registry foundation (merged #127). @@ -195,7 +195,7 @@ working, unreachable capability: time; a complete 1,384-line dired existed only as a frozen test fixture (`tests/fixtures/pmacs-dired/init.lua`). **Fixed:** dired Stage 0 opens a path (`C-x C-f`, merged #162) and Stage 1 ships the - browsing view as a builtin (`C-x d` / `C-x C-j`, PR #165). The fixture + browsing view as a builtin (`C-x d` / `C-x C-j`, merged #165). The fixture stays frozen — its `install_local` + `require` routing *is* the M8 package-universality proof (Q#DR1) — and shrinking it is scheduled after Stage 3. @@ -363,11 +363,11 @@ Full verdict table: |---|---|---|---| | 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime | | 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on | -| 3 | Open real project | **Missing at the CLI** | `pmacs .` still exits 1 (above): `load_file` does `File::open` (which succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s create-a-`[new file]` arm never fires. Dired Stage 1 (PR #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface | +| 3 | Open real project | **Missing at the CLI** | `pmacs .` still exits 1 (above): `load_file` does `File::open` (which succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s create-a-`[new file]` arm never fires. Dired Stage 1 (merged #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface | | 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) | | 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:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | -| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing PR #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 | +| 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 but undiscoverable** | Full PTY with scrollback + modeline segment — reachable only as `M-x terminal`, no keybinding. *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 | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) | | 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 | @@ -460,7 +460,7 @@ level is the one missing. Audited level-by-level: **Beginner** (should see: files, buffers, search, diagnostics, terminal, build actions, menus, missing-tool guidance): -- files ✓ since #162 / PR #165 (`C-x C-f` opens a path, `C-x d` browses; +- files ✓ since #162 / #165 (`C-x C-f` opens a path, `C-x d` browses; 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 @@ -1183,7 +1183,7 @@ Primitive-by-primitive against the list above: hierarchy, package dependency graph, worker trees, git status) will each need it; building it once *before* dired's directory view and the workers tree harden their own conventions is exactly this - section's point. Dired Stage 1 (PR #165) landed **without** inventing + section's point. Dired Stage 1 (merged #165) landed **without** inventing one: its listing is flat (Emacs parity), and the recursive in-buffer case — `i` insert-subdirectory — is a named deferral in `docs/dired-framing.md` §13, which is where a shared tree primitive @@ -1340,7 +1340,8 @@ greets a new user says nothing (`EditorCore::new` sets an empty status). Note the dependency: five of the ten onboarding steps above currently -lead somewhere broken or invisible (find a file — in flight; inspect a +lead somewhere broken or invisible (find a file — the mechanism is fixed +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 @@ -1404,7 +1405,7 @@ Establish the end-to-end workflow; treat regressions as release blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually cheap:** directory-argument handling (the remaining half of step 3 — dired Stage 1 landed the buffer it should resolve to); a find-file -surface (**done**: #162 open-by-path, PR #165 browsing); surfacing the +surface (**done**: #162 open-by-path, #165 browsing); surfacing the LSP spawn failure with guidance (§1.2); a compile keybinding + `cargo build`/`test` default from the existing `ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The @@ -1474,7 +1475,9 @@ implementation — this list is direction, not commitment): 1. **Journey Stage 1** (P1): directory open + compile defaults + LSP-failure surfacing + bindings + welcome buffer + the first - journey acceptance suite. Rides alongside the in-flight dired arc. + journey acceptance suite. Dired Stage 1 has landed (#165), so the + buffer a directory resolves *to* already exists; this arc routes + `pmacs .` into it rather than growing a second directory surface. 2. **Discovery surface** (P4): the describe/list/where-is command family, M-x rich rows, help unification, help prefix. 3. **Transient keymap layer** (§6): the overlay scope + lifetime diff --git a/docs/active-work.md b/docs/active-work.md index c934c30..1266e40 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -5,6 +5,14 @@ landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. +**Two lane headers below are stale on purpose**, pending the docs updates +their own lanes owe: multi-root LSP affinity **#161 has merged** (the +Lean 4 lane still says IN REVIEW; its continuation is PR #167) and GPU +terminal input **#166 has merged** (its lane still says IN REVIEW; PR +#168 records it). Trust the canonical-base line below over a lane header: +if a PR number appears in `git log --first-parent githubsucks/main`, it +has landed regardless of what its lane says. + ## Repository authority - Canonical development URL: @@ -14,11 +22,14 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `8c86d34` (the dired framing #164 atop find-file - #162, COHERENCE.md #163, Lean 4 Stage 1 #160, the minimap blank-slab fix - #159, bottom-panel Stage 1 #155, the inline-math re-scout #154, the vterm - PTY-flake fix #153, and the GPU initial-target doc refresh #152; protocol - v20). + `githubsucks/main` @ `c8ec8f3` (dired Stage 1 #165 atop GPU terminal + input #166, multi-root LSP affinity #161, the dired framing #164, + find-file #162, COHERENCE.md #163, Lean 4 Stage 1 #160, the minimap + blank-slab fix #159, bottom-panel Stage 1 #155, the inline-math re-scout + #154, the vterm PTY-flake fix #153, and the GPU initial-target doc + refresh #152; protocol v20). **Lanes below that name an older base have + not been re-based; derive their integration surface from + `git diff ..main`.** - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -189,154 +200,6 @@ If it does not, stop and repair the remote/fetch configuration. suites**; `git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME` and `-- --skip basedpyright`. -## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) - -- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the - approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1 - implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3 - (wdired) each get their own detailed framing after the prior stage lands. -- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`, - 2026-07-25, one review round, 12/12 CI green). Durable facts moved to - `docs/agent-handoff.md` §1 per rule 3 below. -- **Stage 1 branch: `githubsucks/dired-stage1`**, worktree - `../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the - framing merge #164). **A fresh cut, not a rebase:** the older `dired` - branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the - superseded `0827dd1` and carried only the framing content #164 already - put on `main`, so merging it would have reconciled two histories of one - document. It is left untouched and carries nothing unmerged. -- **Stage 1 implemented; no wire change (protocol stays v20).** What - landed on the branch: - - `builtin/runtime/dired.lua`: one buffer per directory named - `*dired:*` with the handle-table ownership check; - read-only intercept + `set_round_trip_input`; the `dired` major mode - and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`); - basename cursor re-seating across every wholesale repaint; - `display_file` for file visits and same-window reuse for directory - descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting. - Loaded after `window.lua`. - - `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`, - and one walk that either fails on a per-entry condition or records it - (Q#DR6). `src/async_runtime.rs` carries the listing in - `ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs` - keys the Lua result **shape** on `errors.is_some()`, so the bare array - the frozen M8.2 fixture consumes with `ipairs` is untouched; - `builtin/runtime/fs.lua` validates read-op opts and **rejects unknown - keys** (a typo'd `tolerant` used to degrade silently to fatal). - - `src/editor_core.rs` + `src/lua_bindings/mod.rs`: - `normalize_buffer_path` is `pub` and exposed as - `pmacs.path.canonicalize` — Q#DR2's preferred end state, so no Lua - mirror exists and Stage 2 owes no mirror removal. This makes B2 - ("tolerant `read_dir` is the only Rust change") false by one small - binding, deliberately. - - `tests/dired_acceptance.rs`: 22 tests over framing items 1–16, - dispatch-driven; item 17 is the m8_1/m8_2/m8_3 additivity gate. -- **The framing claim the substrate falsified (S1-2):** R2-3 expected a - dedicated dired panel to carry its dedication across a descent. - `display_buffer` never replaces the buffer in a slot dedicated to - another one — it discards every side-specific parameter and falls back - to the document window (Q#BP3 2.iii), and the exact-window arm errors. - Dired does not unpin the user's panel; both arms are pinned. -- **The vacuity the bites found (S1-3):** acceptance 3c cannot pin the - descent *routing*. Dired holds focus in its own panel, so a raw - `switch_buffer` lands in the same window and every 3c assertion holds - either way. Dedication is the only discriminator, so the - dedicated-panel test is the real pin — and the vacuity is documented at - the assertion rather than relabelled. -- **The pre-existing test dired's first mode-scoped binding broke - (S1-4):** `describe_key_identifies_every_default_binding` asserted every - binding in the stack resolves through `describe.key` context-free, which - held only while the modes table was empty. It now sets the effective - context per binding and explicitly *clears* the mode for global ones, - because a leaked mode legitimately shadows a global chord of the same - name (dired's `RET` shadows `edit.newline-and-indent`). -- Durable substrate facts, independent of this arc: - - `pmacs.buffer.kill` (not `remove`) redirects windows off a doomed - buffer before removal, so `kill-when-opening` kills **after** the - replacement is displayed. - - Interactive origin does **not** survive an await: work resumed in - `tick_async` sees no `InteractiveCommandOrigin`, so `pmacs.window.*` - acts for the *ambient* active frontend (S1-9). - - Kinds are lstat-based in both `read_dir` and `stat`, so nothing in an - entry says whether a symlink points at a directory; `RET` probes by - trying to list it (S1-8). - - A path-backed buffer's *name* is its full path, not its basename — - worth knowing before writing any name assertion. - - `C-x d` takes **no** completion source on purpose (S1-5): with one, - RET on an empty field opens whatever sorts first, and - RET-on-where-you-are is the gesture the binding exists for. The field - is prefilled instead. -- **Bite verification:** 15 claims, each mutated in place and required to - fail the test that names it. `dired.lua` is new, so `scripts/bite`'s - file swap does not apply; every mutation was applied and reverted with - `git checkout --`. One came back VACUOUS and is recorded above. -- **Review round 1 addressed** (framing rev 7, S1-10…S1-12). Three - behavioral fixes, each bite-verified: `dired.revert`'s re-seat is - guarded on the active buffer (an ambient `move_to_line` after an await - moved an unrelated buffer's cursor — the buffer-level instance of - S1-9); `fmt_size` keeps the column width past ten digits, because - `_layout` is a contract Stage 3 is planned against; and the symlink - descent dropped its probe, since `open_directory`'s - changed-nothing-on-failure invariant *is* the probe (it was listing the - target directory twice). Plus a consecutive-`readdir`-error cap, because - **nothing cancels a dired listing** — it carries no supersede key, so - cancellation was never the backstop the tolerant loop implicitly relied - on. Naming/comment findings taken as-is. - - Durable process lesson, hit twice now: a mutation-bite helper restores - with `git checkout --`, which reverts to **HEAD** — so a fix must be - committed *before* it is bitten. Round 1's fixes were briefly wiped by - exactly that. -- **Canonical main integrated twice** — at `46a1b8f` (multi-root LSP - affinity #161) and again at `b889873` (GPU terminal input #166), both - merged rather than rebased per the #135/#137 precedent so the review - anchors stay addressable. Each conflict was a single doc hunk resolved - as the union: this lane owns COHERENCE's journey step 7 file half, #161 - owns the in-flight list, #166 owns step 8's GPU-terminal addendum. - Three things worth carrying: - - **A conflicting PR silently stops running CI.** GitHub builds - `pull_request` runs against the merge ref, which does not exist while - the PR conflicts, so no run is created and nothing reports a - failure — the checks list simply stays as it was. Three pushes to - this branch produced no CI at all before the cause was found. Watch - `mergeable` on a long-lived lane, not just the check list. - - #161's own COHERENCE finding **falsified a claim in this lane's - module doc**: `pmacs.error` is never defined in production, so an - uncaught raise inside a `pmacs.async` coroutine does not reach - `*errors*` as the comment said. It reaches a bare `error()` inside - `pmacs._async.tick()`, whose result `tick_async` discards with - `let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` + - `set_status` load-bearing rather than tidy, and the comment now says - so. - - **A lane in review against a fast-moving `main` needs its gates rerun - per integration, not per push.** Main advanced twice inside this - review round, and the second time landed while the first - integration's sweep was still running. The numbers below describe the - twice-merged tree. -- Verification on the twice-merged tree (`main` @ `b889873`): - `cargo fmt --check` clean; strict workspace Clippy clean; **1,832 - default + 2,009 CRDT** library tests; dired acceptance **25 default + - 25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; multi-root 13 and - vterm Stage 3 5 (both suites main added, green under this lane's - `mod.rs` and `editor.rs` changes); M4 121; required GPU 155; - **isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 passed across 93 - suites, zero failures**; `git diff --check` clean. The sweep needs the - isolated config for the reason recorded in the bottom-panel lane - below. -- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` §20 - Priority 1, which names this work explicitly; journey step 7's file half - goes from no surface to a surface; **adds no interaction island** — keys - are a mode-scoped keymap, and wdired will be a mode swap; adopts - `pmacs.config` for `dired.kill-when-opening`; inherits §9's - worker-attribution gap for its `read_dir` jobs without worsening it. The - audited claims this changes are updated in `COHERENCE.md` itself, per its - §25. -- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut - 1): CLI directory-argument handling (`pmacs .` exits 1) belongs there, - not here — Stage 1 does **not** fix it. The two meet at - `resolve_target_buffer`; dired supplies the buffer a directory should - resolve *to*, and `pmacs .` should route into it rather than growing a - second directory surface. - ## GPU terminal input lane — IN REVIEW - Portable branch: `githubsucks/gpu-terminal-input`, worktree @@ -593,6 +456,38 @@ git worktree add --track \ ## Closed since the last snapshot +- **Dired Stage 1 (the directory view) — MERGED as #165** (`main` @ + `c8ec8f3`, 2026-07-25, after one review round). pmacs has a directory + surface: `C-x d` / `C-x C-j`, one read-only buffer per directory named + `*dired:*`, a `dired` major mode carrying + `RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`. No wire change (v20). The Rust is + two things — a per-entry-tolerant `read_dir` (Q#DR6), which had to be + Rust because `read_dir_blocking` fails a whole listing on any of five + per-entry conditions and a tolerant wrapper cannot be written in Lua at + all, and `normalize_buffer_path` going `pub` as + `pmacs.path.canonicalize` (Q#DR2's preferred end state, so no Lua mirror + exists and Stage 2 owes no mirror removal). The frozen m8_1/m8_2/m8_3 + counts are unchanged, which is the additivity gate. 15 claims + bite-verified; one came back VACUOUS (acceptance 3c cannot pin descent + routing — dired holds focus in its own panel, so dedication is the only + discriminator) and is documented at the assertion rather than + relabelled. Its branch (`dired-stage1`) and worktree + (`../pmacs-dired-stage1`) are done; the abandoned `dired` branch + (`ffdd642`, `../pmacs-dired-arc`) was superseded by a fresh cut and + carries nothing unmerged. **Stage 2 (marks and operations) and Stage 3 + (wdired) each still need their own framing**, and the frozen fixture + shrinks after Stage 3. Durable substrate facts and both new ops lessons + live in `docs/agent-handoff.md` §§1/5; the implementation notes are + `docs/dired-framing.md` §0, S1-1…S1-12. Two named forward items for + Stage 2: `apply_resource_op`'s rename rebind is exact-PathBuf-equality, + first-match-only, looked up with the raw path while stored paths are + normalized — so a directory rename strands every buffer under it, and + `pmacs.fs.rename` has zero production callers, so it can be fixed at + the primitive; and Q#DR5's seam is the main-thread drain + `AsyncRuntime::tick`, not `_take_result`, where rename settles as an + undifferentiated `ReplyKind::FsUnit` and so must be keyed on + `JobKind::FsRename`. + - **GPU initial target — MERGED as #148** (`main` @ `0dd16a5`, 2026-07-24, after two review rounds). `pmacs --gpu [--socket …] FILE` opens a target before the GPU window appears. Protocol bumped 19 → 20: a semantic-session diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index bb5677a..f6900cc 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,7 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-25, after find-file (#162) landed — the dired -arc's Stage 0 — following COHERENCE.md (#163), Lean 4 Stage 1 (#160), the +**Last updated: 2026-07-25, after dired Stage 1 (#165) landed — the +directory view — following GPU terminal input (#166), multi-root LSP +affinity (#161), the dired framing (#164), find-file (#162) — the dired +arc's Stage 0 — COHERENCE.md (#163), Lean 4 Stage 1 (#160), the minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the GPU initial-target doc refresh (#152); and before that GPU @@ -26,11 +28,13 @@ commands, read `docs/active-work.md` immediately after this file. ## 1. Where the project stands (2026-07-25) -- `main` @ `2af1ab3` (find-file #162 atop COHERENCE.md #163, Lean 4 Stage 1 - #160, minimap blank-slab #159, bottom-panel Stage 1 #155, inline-math - re-scout #154, vterm PTY-flake #153, and doc refresh #152). Protocol - unchanged at **v20**. The bullets below describe the arcs in their own - terms; this line is the head-of-`main` anchor. +- `main` @ `c8ec8f3` (dired Stage 1 #165 atop GPU terminal input #166, + multi-root LSP affinity #161, the dired framing #164, find-file #162, + COHERENCE.md #163, Lean 4 Stage 1 #160, minimap blank-slab #159, + bottom-panel Stage 1 #155, inline-math re-scout #154, vterm PTY-flake + #153, and doc refresh #152). Protocol unchanged at **v20**. The bullets + below describe the arcs in their own terms; this line is the + head-of-`main` anchor. - **`COHERENCE.md` is now required reading and a required framing input — #163.** It carries the product-coherence thesis, an audited scorecard, per-concern gaps, and §20's priority order, and it is the @@ -70,11 +74,78 @@ commands, read `docs/active-work.md` immediately after this file. against an open buffer yet fails to load one that is not open — find-file expands the tilde Lua-side. Loading through the normalized path is a named deferral. - - **Stage 1 (the directory view) is IN REVIEW as PR #165** — the - builtin `dired.lua`, the per-entry-tolerant `read_dir` opt, and - `pmacs.path.canonicalize`. Its branch state, substrate facts, and - verification live in `docs/active-work.md`; this section absorbs them - when it merges. +- **dired Stage 1 — the directory view — LANDED — #165** + (`docs/dired-framing.md` §0, S1-1…S1-12; merge `c8ec8f3`; one review + round). pmacs now has a directory surface: `C-x d` / `C-x C-j` open a + read-only listing, one buffer per directory named + `*dired:*`, with a `dired` major mode whose + mode-scoped keymap carries `RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`. + Protocol unchanged at **v20**. **Stage 2 (marks and operations) and + Stage 3 (wdired) each still need their own framing**; the frozen + fixture shrinks after Stage 3. + - **The Rust is confined to two things**: a per-entry-tolerant + `read_dir` (`ReadDirTolerance {Fatal, PerEntry}` → + `FsDirListing {entries, errors}`), because `read_dir_blocking` fails + a whole listing on any of five per-entry conditions and the tolerant + wrapper its own module doc delegates to package authors **cannot be + written in Lua** (one error value, no partial vec); and + `editor_core::normalize_buffer_path` becoming `pub`, exposed as + `pmacs.path.canonicalize`. Only non-UTF-8 **names** stay fatal — + byte-preserving paths would be needed. The Lua result **shape** keys + on `errors.is_some()`, so the bare array the frozen M8.2 fixture + consumes with `ipairs` is untouched. + - **Exposing a core normalizer beat mirroring it in Lua.** A Lua mirror + would have been a second canonical form — the same class of bug as + the five tab-width constants (#137). Applies to any future Lua-side + path reckoning. + - **A fixed-width column must be fixed-width for every input.** The + exported `pmacs.dired._layout` (MARK 0, KIND 2, PERMS 3–12, SIZE 13, + MTIME 24, NAME 41) is the contract Stage 3 reads offsets from, and + `%10d` overflows at ≥10 GB, silently shifting every column right of + it. Sizes now fall back to a width-clamped magnitude (K/M/G/T/P/E). + - **An ambient action must be gated on the buffer it assumes.** A + revert's cursor re-seat settles a tick or more later, by which time + the user may have switched buffers; the paint names its buffer and is + safe, but seating is ambient. This is the buffer-level instance of + the rule below that interactive origin does not survive an await. + - **A failure IS an answer — don't probe first.** Kinds are lstat-based + in both `read_dir` and `stat`, so nothing in an entry says whether a + symlink points at a directory. `RET` tries to list it and treats the + failure as the answer; an explicit probe was a second full + `read_dir`, so a descent listed twice. + - **Unbounded per-entry error collection needs a cap when nothing + cancels the work.** A dired listing carries no supersede key, so + cancellation was never the backstop the tolerant loop implicitly + relied on (`READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS = 1024`). + - **This is the first builtin with mode-scoped keys** (#129's first + non-detection consumer), which broke the pre-existing + `describe_key_identifies_every_default_binding`: it asserted every + binding resolves through `describe.key` context-free, which held only + while the modes table was empty. It now sets the effective context + per binding and explicitly **clears** the mode for global ones, + because a leaked mode legitimately shadows a global chord of the same + name (dired's `RET` shadows `edit.newline-and-indent`), plus a floor + assertion that at least one mode-scoped binding exists. + - **A dedicated panel does not carry its dedication across a descent** + — the framing expected it to. `display_buffer` never replaces the + buffer in a slot dedicated to another one; it discards every + side-specific parameter and falls back to the document window (Q#BP3 + 2.iii), and the exact-window arm errors. Dired does not unpin the + user's panel; both arms are pinned. + - Smaller facts worth knowing before touching this code: a path-backed + buffer's **name is its full path**, not its basename, which matters + for any name assertion; `pmacs.buffer.kill` (not `remove`) redirects + windows off a doomed buffer first, so `dired.kill-when-opening` kills + **after** the replacement is displayed; ownership is checked against + the handle table only, never the buffer name; and `C-x d` takes **no** + completion source on purpose (with one, `RET` on an empty field opens + whatever sorts first, and RET-where-you-are is the gesture the binding + exists for — the field is prefilled instead). + - Verification at merge: 1,832 default + 2,009 CRDT library tests; + dired acceptance 25 + 25 CRDT; the frozen m8_1 10 / m8_2 15 / m8_3 32 + unchanged, which is the additivity gate for the `read_dir` change; M4 + 121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep + 3,205 across 93 suites. 15 claims bite-verified. - **GPU initial target LANDED — #148** (`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path @@ -760,6 +831,24 @@ final variant — its own round-trip cannot detect a discriminant shift. trap-guarded one-file swap over read-only `git show`, with an inverted verdict (exit 0 iff the tests FAIL against the old version), making bite-verification machine-checkable. +- **A fix must be COMMITTED before it is bitten.** `scripts/bite` + restores by `git checkout --`, which reverts the file to **HEAD**, not + to the state it found — so any uncommitted work in a bitten file is + destroyed. A whole review round's fixes were wiped this way during + #165. Corollary for a NEW file: the swap-over-`git show` mode does not + apply at all, so its claims must be bitten by hand-editing, which makes + the commit-first rule load-bearing rather than hygienic. +- **A CONFLICTING PR silently runs no CI at all.** GitHub builds + `pull_request` workflow runs against the PR's **merge ref**, which it + does not create while the branch conflicts with its base. So pushes + land, the branch updates, no run is ever queued, and **nothing reports + the absence** — the checks list simply keeps showing the last + successful run, which reads as current. Three pushes to #165 produced + zero CI before the cause was found, and `gh pr checks` returns nothing + usable here. On any lane that lives through a moving `main`, check + `gh pr view --json mergeable,mergeStateStatus,headRefOid` and + confirm a run exists **for the current head sha**, not merely that a + recent run was green. - **Stacked PRs**: retarget the child to main BEFORE merging the parent — GitHub auto-closes a PR whose base branch is deleted and cannot reopen it (#104 → re-opened as #105). diff --git a/docs/dired-framing.md b/docs/dired-framing.md index ada853e..83e6f1a 100644 --- a/docs/dired-framing.md +++ b/docs/dired-framing.md @@ -1,7 +1,9 @@ # Dired — framing **Revision 7 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162; -Stage 1 IN REVIEW as PR #165, review round 1 addressed.** +Stage 1 MERGED as #165 (`main` @ `c8ec8f3`, one review round). Stage 2 +(marks and operations) and Stage 3 (wdired) each still need their own +framing before implementation; the frozen fixture shrinks after Stage 3.** Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings; rev 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's implementation falsified in the approved text (§0); rev 5 adds the From 1e1be67b49dc1b90783ed3b2fd8df3c776cac8ef Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 17:40:02 -0400 Subject: [PATCH 11/91] feat(lean): the Lean 4 language server (Arc 8 Stage 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framing Q#LN7, Q#LN8, Q#LN16; acceptance 22–28, 24a/24b, 35, 36, 36a, 37. Stacked on Stage 3a (#167), whose notification/response seams and `pmacs.fs.canonicalize` this consumes. No protocol change; the only Rust outside the test helper is one `include_str!` line. **The Lake-aware root (Q#LN8).** `pmacs.project.detect` cannot express this rule — it is innermost-wins by construction, and a Lake package's `lean-toolchain` sits at the outermost level, so a file under `/.lake/packages/dep/` belongs to ``'s server rather than `dep`'s. The resolver walks up collecting markers and returns the outermost, stopping at `pmacs.project.search_boundary()` so a stray marker above a fixture cannot leak in. Two things about the marker test are easy to get wrong in opposite directions, and both are pinned. `io.open` **succeeds on a directory**, so a truthiness check accepts a `lean-toolchain` directory; but requiring a non-nil read rejects an **empty** `lean-toolchain`, which is a legitimate marker — `locate-dominating-file` semantics are existence, not content. The discriminator is `read`'s second return: decline only on a non-nil error. Acceptance 24a and 24b each fail against the implementation that satisfies only the other; both bites are recorded. The root is canonicalized once up front, because a configured root reaches `file_uri_for` verbatim and that URI is the affinity key (#161). Canonicalizing the starting directory suffices — every ancestor of a canonical path is canonical, since the walk only strips components. **`lake serve` with a lazy probe and a one-shot latch (Q#LN7).** Nothing runs at init: `pmacs.lsp.config` is declarative, and spawning a process at startup for every user, Lean-using or not, is the cost rev 1 refused. Both the probe and the server spawn are gated on a real Lean attachment. The probe cannot gate the first attach — there is no blocking process run, so its verdict arrives after `ensure_server` has already decided. Hence the optimistic spawn, with the probe and latch correcting it. A non-zero probe exit is deliberately NOT a trigger: §2.9's elan-shim case makes `lake --version` fail on machines where `lake serve` still works, and the server-failure latch covers that better. The probe answers only the question failure detection would answer slowly — an old-but-working lake that starts a useless server. The latch stops the failing server **before** spawning the fallback, and that ordering is load-bearing rather than defensive: the spec default is `OnCrash`, the termination handler never consults the exit code, and `maybe_restart` has no attempt ceiling, so a broken `lake` respawns forever underneath the latch. `pmacs.lsp.stop` sets `restart = Never`, which is what disarms it. Bitten: removing the stop fails acceptance 36. The swap rewrites `command` and `args` only, so a user's `env`, `settings`, `init_options` and `root` survive — a wholesale table replacement would discard their `init.lua` at the moment they are least likely to notice. **`waitForDiagnostics` (Q#LN16)** resolves through Stage 3a's response seam, with `M-x lean.wait-for-diagnostics` on top. `$/lean/fileProgress` subscribes on the notification seam and is pinned end-to-end through a new `leanprogress` mode on the fake server rather than by calling the handler directly — the wiring is the only part that can break. **Attribution (COHERENCE §9/§1.2).** The probe spawns as `lean:lake-version-probe`, so a user wondering why their editor touched `lake` finds an owner in `pmacs.process.list`. The latch reports through `pmacs.editor.set_status` — the channel that exists — and acceptance 36a observes that channel, so a report made only through the undefined `pmacs.error` would fail it. **Stage 1's acceptance 12 is updated, half superseded.** It asserted `pmacs.lsp.config.lean4 == nil` to guard against a Stage-3 front-run; Stage 3b is that stage, so keeping it would pin the opposite of the intended behavior. The half that survives is the one about restraint, and it matters more now: constructing an editor spawns nothing even though the config exists and names `lake`, and opening a Lean buffer with no server configured spawns nothing either. That is what holds Q#LN7's "not at init" promise. Bites recorded, all against the committed tree: bare `io.open` -> 24a fails, 24b passes; require-non-nil-read -> 24b fails, 24a passes; no canonicalization -> the symlink case spawns two servers; no stop before fallback -> acceptance 36 fails. --- builtin/runtime/lean.lua | 359 +++++++++++++++++ src/bin/pmacs_fake_lsp.rs | 21 + src/editor.rs | 11 + tests/lean4_server_acceptance.rs | 640 +++++++++++++++++++++++++++++++ tests/lean4_stage1_acceptance.rs | 62 +-- 5 files changed, 1069 insertions(+), 24 deletions(-) create mode 100644 builtin/runtime/lean.lua create mode 100644 tests/lean4_server_acceptance.rs diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua new file mode 100644 index 0000000..3fe73da --- /dev/null +++ b/builtin/runtime/lean.lua @@ -0,0 +1,359 @@ +-- builtin/runtime/lean.lua --- Arc 8 Stage 3b: the Lean 4 language server. +-- +-- Framing: `docs/lean4-mode-framing.md` Q#LN7 (lake serve + probe + +-- fallback latch), Q#LN8 (Lake-aware outermost root), Q#LN16 +-- (waitForDiagnostics). Stage 1 shipped the grammar, mode, comment +-- strings and pair set; Stage 3a shipped the notification/response +-- seams and `pmacs.fs.canonicalize` this file consumes. +-- +-- Loaded after `lsp.lua`, which owns `pmacs.lsp.config` and the drain. + +local M = {} + +-- Q#LN8 — the Lake-aware root ----------------------------------------- +-- +-- `pmacs.project.detect` cannot express this rule. It is innermost-wins +-- by construction, and a Lake package's `lean-toolchain` sits at the +-- OUTERMOST level: a file under `/.lake/packages/dep/Foo.lean` +-- belongs to ``'s server, not to `dep`'s, because `lake serve` is +-- bound to one package and analyzes its dependencies from inside it. +-- Inverting `detect` globally would change Rust/Go/Node roots for every +-- user, so the rule lives here as a function-valued `config.root` — +-- the generalization Stage 2 (#161) added for exactly this. + +-- The marker test, and the two ways to get it wrong. +-- +-- `pmacs.fs.stat` is UNUSABLE here: it returns an awaitable handle +-- (`fs.lua`), and this runs synchronously inside `ensure_server` <- +-- `attach_buffer` <- the `buffer.after-load` hook, where there is no +-- coroutine to await on. The Lua stdlib's `io.open` is the only +-- synchronous existence check available. +-- +-- But `io.open` alone is wrong in BOTH directions: +-- * it SUCCEEDS on a directory (probed), so a truthiness test would +-- accept a `lean-toolchain` directory as a marker; and +-- * requiring a non-nil read rejects an EMPTY `lean-toolchain`, which +-- is a legitimate marker — `locate-dominating-file` semantics are +-- existence, not content. +-- The discriminator is `read`'s SECOND return (probed on LuaJIT 2.1): +-- file with content -> "l", no error -> marker +-- empty file -> nil, NO error -> marker +-- directory -> nil, "Is a directory" -> decline +-- missing -> io.open returns nil -> decline +-- so: decline only on a non-nil `err`. This needs no per-platform +-- re-probe, because both directory behaviors are declines — a platform +-- whose `fopen` refuses directories fails at `io.open` instead. There +-- is no platform where a directory both opens and yields a byte. +local function has_toolchain(dir) + local f = io.open(dir .. "/lean-toolchain", "r") + if not f then return false end + local _, err = f:read(1) + f:close() + return err == nil +end + +local function parent_of(dir) + local up = dir:match("^(.*)/[^/]+$") + if up == nil or up == dir or up == "" then return nil end + return up +end + +-- The walk stops at `pmacs.project.search_boundary()`. Not politeness: +-- `detect_project_within` (`src/project.rs`) exists precisely so a +-- stray marker above a temp fixture cannot leak into detection, and a +-- Lua walk that ignored the boundary would break that contract — and +-- make acceptance 23's outermost assertion non-hermetic against any +-- `lean-toolchain` sitting above the test's tempdir. +local function within_boundary(dir, boundary) + if not boundary then return true end + return dir == boundary or dir:sub(1, #boundary + 1) == boundary .. "/" +end + +-- Returns the OUTERMOST ancestor holding a `lean-toolchain`, or nil to +-- decline (which falls through to `pmacs.project.detect`, then the +-- file's own directory). +-- +-- **The result is canonical, and must be.** A configured root — which +-- this is — reaches `file_uri_for` verbatim and that URI is the +-- server-affinity key (#161). `pmacs.editor.file_path()` collapses `.` +-- and `..` lexically but leaves symlinks intact, so one package opened +-- through a symlink and through its real path would otherwise spawn two +-- `lake serve` processes. Canonicalizing ONCE up front is enough: +-- every ancestor of a canonical path is itself canonical, since the +-- walk only strips trailing components. +-- +-- If canonicalization fails (deleted file, broken symlink) the resolver +-- declines rather than returning a path it cannot vouch for. +function M.root_for(path) + if type(path) ~= "string" then return nil end + local dir = path:match("^(.*)/[^/]*$") + if not dir then return nil end + dir = pmacs.fs.canonicalize(dir) + if not dir then return nil end + local boundary + local ok, b = pcall(pmacs.project.search_boundary) + if ok then boundary = b end + -- The boundary is canonicalized at set time (`set_search_boundary`), + -- so comparing it against a canonical `dir` is apples to apples. + local outermost = nil + local cur = dir + while cur and within_boundary(cur, boundary) do + if has_toolchain(cur) then outermost = cur end + cur = parent_of(cur) + end + return outermost +end + +-- Q#LN7 — `lake serve`, with a lazy probe and a one-shot latch -------- +-- +-- `pmacs.lsp.config.lean4` is declarative and must stay cheap: spawning +-- a process at startup for every user, Lean-using or not, is the cost +-- rev 1 refused. So no probe runs here — it runs on the first `.lean` +-- attach, below. +pmacs.lsp.config.lean4 = pmacs.lsp.config.lean4 or { + command = "lake", + args = { "serve" }, + root = M.root_for, + -- No `init_options`: `hasWidgets?` defaults to false, which is the + -- correct posture for a client reading plain goals out of standard + -- messages rather than driving the `$/lean/rpc/*` widget stack. +} + +-- Session state. The latch is one-shot and never re-arms: a user whose +-- toolchain is broken sees one fallback attempt, not a loop. +local probe = { + started = false, -- the `lake --version` probe has been spawned + latched = false, -- the fallback has fired (or been ruled out) + proc = nil, -- process id of the running probe + buf = "", -- accumulated probe stdout + watching = nil, -- sid we are waiting to see fail before initialize + saw_initialized = false, +} + +local function report(msg) + -- COHERENCE §1.2: background work must leave an attributed trace. + -- `pmacs.editor.set_status` is the channel that EXISTS; `pmacs.error` + -- is referenced by fifteen call sites and defined nowhere in + -- production, so it rides along rather than standing alone. + pcall(pmacs.editor.set_status, msg) + if pmacs.error then pcall(pmacs.error, msg) end +end + +-- `lake serve` below 3.1.0 starts a server that cannot answer, which is +-- worse than failing: `lean4-mode` probes for exactly this and falls +-- back to `lean --server`. Parses the leading `x.y` of a version line. +local function version_below_3_1(text) + local major, minor = text:match("(%d+)%.(%d+)") + if not major then return false end + major, minor = tonumber(major), tonumber(minor) + if major < 3 then return true end + return major == 3 and minor < 1 +end + +-- Swap `command`/`args` ONLY. A wholesale table replacement would +-- silently discard a user's `env` / `settings` / `init_options` / `root` +-- from `init.lua` at exactly the moment they are least likely to notice. +local function swap_to_lean_server() + local cfg = pmacs.lsp.config.lean4 + if not cfg then return false end + if cfg.command ~= "lake" then return false end + cfg.command = "lean" + cfg.args = { "--server" } + return true +end + +-- Fire the fallback: stop the failing server FIRST, then swap, then let +-- the next attach spawn afresh. +-- +-- Stopping first is load-bearing, not defensive. The spec default is +-- `LspRestartPolicy::OnCrash`, the termination handler never consults +-- the exit code, and `maybe_restart` has no attempt ceiling — so a +-- broken `lake` respawns forever on a backoff, underneath the latch, +-- producing a loop the latch cannot see the end of. `pmacs.lsp.stop` +-- sets `restart = Never` on the way out, which is what disarms it. The +-- fallback is therefore a FRESH server, not a restart of the old one. +local function fire_latch(sid, why) + if probe.latched then return end + probe.latched = true + if sid then pcall(pmacs.lsp.stop, sid) end + if swap_to_lean_server() then + report("LSP: lean4 " .. why .. "; falling back to `lean --server`") + else + report("LSP: lean4 " .. why) + end + probe.watching = nil +end + +local function drain_probe() + if not probe.proc then return end + local ok, evs = pcall(pmacs.process.events_take, probe.proc) + if not ok or not evs then return end + for _, ev in ipairs(evs) do + if ev.kind == "stdout" or ev.kind == "stderr" then + probe.buf = probe.buf .. tostring(ev.bytes) + elseif ev.kind == "exited" or ev.kind == "signaled" + or ev.kind == "crashed" then + local proc = probe.proc + probe.proc = nil + pcall(pmacs.process.forget, proc) + -- A non-zero exit is NOT a fallback trigger on its own. §2.9: elan + -- shims make `lake --version` exit non-zero with "no default + -- toolchain configured" on a machine where `lake serve` may still + -- be the right command — the server-failure latch covers that + -- case, and covers it better. The probe answers only the ONE + -- question failure detection would otherwise answer slowly: an + -- old-but-working lake that starts a useless server. + if ev.kind == "exited" and ev.code == 0 + and version_below_3_1(probe.buf) then + fire_latch(probe.watching, "lake is older than 3.1.0") + end + end + end +end + +-- The probe cannot gate the first attach. There is no blocking process +-- run (§2.9): `spawn` + `events_take` off a tick is the only shape +-- available, so the verdict arrives AFTER `ensure_server` has already +-- had to decide. Hence the optimistic `lake serve` spawn, with the +-- probe and the latch correcting it. +local function start_probe(root) + if probe.started then return end + probe.started = true + local cfg = pmacs.lsp.config.lean4 + if not cfg or cfg.command ~= "lake" then return end + local spec = { + -- COHERENCE §9: `ProcessSpec.label` is the only identity a process + -- carries, and it is what `pmacs.process.list` renders. A user + -- wondering why their editor touched `lake` finds an owner here. + label = "lean:lake-version-probe", + command = "lake", + args = { "--version" }, + stdin = "null", + } + if root then spec.cwd = root end + local ok, proc = pcall(pmacs.process.spawn, spec) + if ok then probe.proc = proc end + -- A probe that cannot even spawn says nothing the latch will not say + -- more reliably a moment later, so it is not reported here. +end + +-- How the latch observes server failure. +-- +-- There is no event for "died before initialize" — the drain ignores +-- state events. So this polls `pmacs.lsp.list()` on the +-- `process.after-tick` cadence and treats a terminal state reached +-- WITHOUT an intervening `initialized` as the trigger. Watching stops +-- as soon as the server initializes, so an ordinary later crash (a real +-- server dying on a real error) does not silently rewrite the command. +local function poll_latch() + local sid = probe.watching + if not sid or probe.latched then return end + local skey = tostring(sid) + local ok, rows = pcall(pmacs.lsp.list) + if not ok or not rows then return end + for _, info in ipairs(rows) do + if tostring(info.id) == skey then + local kind = info.state and info.state.kind + if kind == "initialized" then + probe.saw_initialized = true + probe.watching = nil + return + end + if kind == "crashed" or kind == "stopped" then + fire_latch(sid, "`lake serve` failed to start") + end + return + end + end + -- Gone from the manager entirely without ever initializing. + fire_latch(nil, "`lake serve` failed to start") +end + +-- Q#LN16 — `textDocument/waitForDiagnostics` -------------------------- +-- +-- A plain request: no position, so Q#LN12's `outbound_position` concern +-- does not apply. Resolves when the server has finished elaborating. +-- Awaited through Stage 3a's response seam. +-- +-- `fn(err)` is called with nil on success. Registering the one-shot +-- requires the server to have an attached buffer — see the note on +-- `pmacs.lsp.on_response`; every caller here comes from an attachment. +function M.wait_for_diagnostics(sid, uri, fn) + local ok, rid = pcall(pmacs.lsp.send_request, sid, + "textDocument/waitForDiagnostics", { uri = uri }) + if not ok then + if fn then pcall(fn, tostring(rid)) end + return nil + end + if fn then + pmacs.lsp.on_response(sid, rid, function(_, err) + fn(err and err.message or nil) + end) + end + return rid +end + +pmacs.command.define { + name = "lean.wait-for-diagnostics", + description = "Wait for the Lean server to finish elaborating this file", + fn = function() + local rec = pmacs.lsp.active_attachment() + if not rec or rec.language ~= "lean4" then + pmacs.editor.set_status("lean: no Lean server for this buffer") + return + end + pmacs.editor.set_status("lean: elaborating…") + M.wait_for_diagnostics(rec.server, rec.uri, function(err) + if err then + pmacs.editor.set_status("lean: " .. tostring(err)) + else + pmacs.editor.set_status("lean: elaboration complete") + end + end) + end, +} + +-- `$/lean/fileProgress` — the elaboration-in-flight signal. Stage 5's +-- goal view reads it to distinguish "no goals" from "not done yet"; +-- here it is recorded so that consumer has something to read and so the +-- notification seam has its first production subscriber. +M.file_progress = {} + +pmacs.lsp.on_notification("$/lean/fileProgress", function(_, params) + local uri = params and params.textDocument and params.textDocument.uri + if type(uri) ~= "string" then return end + M.file_progress[uri] = params.processing or {} +end) + +-- Wiring -------------------------------------------------------------- + +-- Runs after `lsp.lua`'s own `buffer.after-load` subscription, so the +-- attachment already exists. The attachment's `language` IS the Lean +-- test — no separate major-mode lookup, which would be a second source +-- of truth for the same question. +pmacs.hook.add("buffer.after-load", function() + local rec = pmacs.lsp.active_attachment() + if not rec or rec.language ~= "lean4" then return end + if not probe.started then + local path = pmacs.editor.file_path() + start_probe(path and M.root_for(path) or nil) + end + -- Watch only the FIRST Lean server: the latch is per session. + if not probe.latched and not probe.saw_initialized + and probe.watching == nil then + probe.watching = rec.server + end +end) + +pmacs.hook.add("process.after-tick", function() + drain_probe() + poll_latch() +end) + +-- Test seam: acceptance drives the latch deterministically rather than +-- waiting on real process timing. Not part of the public surface. +M._probe = probe +M._fire_latch = fire_latch +M._version_below_3_1 = version_below_3_1 + +pmacs.lean = M diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index 5d50b19..5f4fab5 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -483,6 +483,27 @@ fn main() { } }); write_frame(&mut stdout, &echo); + // Arc 8 Stage 3b: `leanprogress` mode emits one + // `$/lean/fileProgress` covering line 0, so the Lean + // subscriber can be pinned end-to-end through the real + // drain rather than by calling its handler directly. + if mode == "leanprogress" && uri.is_string() { + let progress = serde_json::json!({ + "jsonrpc": "2.0", + "method": "$/lean/fileProgress", + "params": { + "textDocument": { "uri": uri, "version": 1 }, + "processing": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 1, "character": 0 } + }, + "kind": 1 + }] + } + }); + write_frame(&mut stdout, &progress); + } // Also push a synthetic `publishDiagnostics` // notification with two entries (one Error, one // Warning) so M4.6 tests can exercise the store. diff --git a/src/editor.rs b/src/editor.rs index 79f1225..5f0f134 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -436,6 +436,17 @@ impl EditorState { include_str!("../builtin/runtime/lsp.lua"), ) .expect("load lsp builtin chunk"); + // Arc 8 Stage 3b: the Lean 4 language server. Loaded after + // lsp.lua because it registers `pmacs.lsp.config.lean4`, + // subscribes on the Stage 3a notification seam, and adds a + // `buffer.after-load` hook that must run AFTER lsp.lua's own + // (it reads the attachment lsp.lua creates). + lua_host + .eval( + Some("@pmacs/builtin/runtime/lean.lua"), + include_str!("../builtin/runtime/lean.lua"), + ) + .expect("load lean builtin chunk"); // Arc 1a: the in-buffer completion popup driver. Loaded after // lsp.lua because it drives `pmacs.lsp.request_completion` / // `pmacs.lsp.attachment_for_request` and after the framework diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs new file mode 100644 index 0000000..13cfcd0 --- /dev/null +++ b/tests/lean4_server_acceptance.rs @@ -0,0 +1,640 @@ +//! Arc 8 Stage 3b acceptance — the Lean 4 language server. +//! +//! `docs/lean4-mode-framing.md` Q#LN7, Q#LN8, Q#LN16; acceptance 22–28, +//! 24a/24b, 35, 36, 36a, 37. +//! +//! No live toolchain required. The server side is `pmacs_fake_lsp` +//! configured under the `lean4` language id; the probe and latch are +//! driven through shell stubs the fixture writes, so nothing here needs +//! `lake`, `lean`, or an elan toolchain on PATH (§2.9). +//! +//! Every fixture sets `pmacs.project.set_search_boundary` at its own +//! tempdir root. Without it the `lean-toolchain` walk climbs to the +//! filesystem root and acceptance 23's outermost assertion stops being +//! hermetic. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use pmacs::editor::EditorState; + +fn exec(state: &EditorState, source: &str) { + state.lua_host.lua().load(source.to_owned()).exec().unwrap(); +} + +fn eval(state: &EditorState, source: &str) -> T { + state.lua_host.lua().load(source.to_owned()).eval().unwrap() +} + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +fn lua_str(path: &Path) -> String { + path.display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} + +struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(dir.path()).unwrap(); + Self { _dir: dir, root } + } + + fn write(&self, rel: &str, contents: &str) -> PathBuf { + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, contents).unwrap(); + path + } + + fn mkdir(&self, rel: &str) -> PathBuf { + let path = self.root.join(rel); + std::fs::create_dir_all(&path).unwrap(); + path + } + + fn dir(&self, rel: &str) -> PathBuf { + self.root.join(rel) + } + + /// A `lean-toolchain` marker file. Content is irrelevant to the + /// resolver by design (existence semantics), which 24b pins. + fn toolchain(&self, rel_dir: &str, body: &str) { + self.write(&format!("{rel_dir}/lean-toolchain"), body); + } + + fn bind(&self, state: &EditorState) { + exec( + state, + &format!( + "pmacs.project.set_search_boundary(\"{}\")", + lua_str(&self.root) + ), + ); + } +} + +/// A fresh editor with every shipped language config cleared, then the +/// `lean4` entry rebuilt against the fake server while KEEPING the real +/// resolver. That combination is the point: the root rule under test is +/// production code, only the command is a stand-in. +fn editor(fx: &Fixture) -> EditorState { + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4 = {{ + command = "{}", + args = {{}}, + root = pmacs.lean.root_for, + }} + "#, + fake_lsp_path() + ), + ); + fx.bind(&state); + state +} + +fn settle(state: &mut EditorState) { + for _ in 0..10 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(2)); + } +} + +fn open(state: &EditorState, path: &Path) { + exec( + state, + &format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)), + ); +} + +/// `language_id|root_uri|cwd` for every live server, sorted. +fn rows(state: &EditorState) -> Vec { + let joined: String = eval( + state, + r#" + local out = {} + for _, s in ipairs(pmacs.lsp.list()) do + out[#out + 1] = table.concat({ + s.language_id or "", s.root_uri or "", s.cwd or "", + }, "|") + end + table.sort(out) + return table.concat(out, "\n") + "#, + ); + if joined.is_empty() { + Vec::new() + } else { + joined.lines().map(str::to_owned).collect() + } +} + +fn resolved_root(state: &EditorState, file: &Path) -> String { + eval( + state, + &format!( + "return tostring(pmacs.lean.root_for(\"{}\"))", + lua_str(file) + ), + ) +} + +// --------------------------------------------------------------------------- +// Acceptance 22 — a Lean file in a Lake package spawns one server rooted +// at the package. +// --------------------------------------------------------------------------- + +#[test] +fn acc22_lean_file_in_a_lake_package_spawns_one_server_at_the_package_root() { + let fx = Fixture::new(); + fx.toolchain("pkg", "leanprover/lean4:v4.9.0\n"); + let file = fx.write("pkg/Pkg/Basic.lean", "def x : Nat := 1\n"); + let mut state = editor(&fx); + open(&state, &file); + settle(&mut state); + + let pkg = fx.dir("pkg").display().to_string(); + assert_eq!( + rows(&state), + vec![format!("lean4|file://{pkg}|{pkg}")], + "one server, rooted and cwd'd at the Lake package" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 23 — outermost wins. +// +// The case `pmacs.project.detect` cannot express: it is innermost-wins by +// construction, so a dependency vendored under `.lake/packages` would get +// its own server and its own (wrong) view of the world. +// --------------------------------------------------------------------------- + +#[test] +fn acc23_nested_toolchains_resolve_to_the_outermost_package() { + let fx = Fixture::new(); + fx.toolchain("pkg", "leanprover/lean4:v4.9.0\n"); + fx.toolchain("pkg/.lake/packages/dep", "leanprover/lean4:v4.8.0\n"); + let inner = fx.write( + "pkg/.lake/packages/dep/Dep/Core.lean", + "def dep : Nat := 2\n", + ); + let state = editor(&fx); + + assert_eq!( + resolved_root(&state, &inner), + fx.dir("pkg").display().to_string(), + "a file under .lake/packages/dep belongs to the outer package" + ); + // Non-vacuity: the inner marker really exists, so "outermost" is a + // choice between two candidates rather than the only one found. + assert!(fx.dir("pkg/.lake/packages/dep/lean-toolchain").exists()); +} + +// --------------------------------------------------------------------------- +// Acceptance 24 — the walk stops at the search boundary. +// --------------------------------------------------------------------------- + +#[test] +fn acc24_walk_stops_at_the_search_boundary() { + let fx = Fixture::new(); + // Boundary is the fixture root; this marker sits INSIDE it. + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + // And this one sits AT the fixture root, i.e. above `pkg` but still + // within the boundary — it must win, being outermost. + fx.toolchain(".", "v4.7.0\n"); + let state = editor(&fx); + assert_eq!( + resolved_root(&state, &file), + fx.root.display().to_string(), + "within the boundary, the outermost marker wins" + ); + + // Now move the boundary IN to `pkg`. The root-level marker is above + // it and must not be reached. + exec( + &state, + &format!( + "pmacs.project.set_search_boundary(\"{}\")", + lua_str(&fx.dir("pkg")) + ), + ); + assert_eq!( + resolved_root(&state, &file), + fx.dir("pkg").display().to_string(), + "a marker above the boundary is not consulted" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 24a / 24b — the marker test, both directions. +// +// These two must each fail against the implementation that satisfies only +// the other. 24a bites the bare `io.open` truth test (which succeeds on a +// directory); 24b bites the read-a-byte-and-require-non-nil rule (which +// rejects an empty file at EOF). +// --------------------------------------------------------------------------- + +#[test] +fn acc24a_a_lean_toolchain_directory_is_not_a_marker() { + let fx = Fixture::new(); + fx.mkdir("pkg/lean-toolchain"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let state = editor(&fx); + assert_eq!( + resolved_root(&state, &file), + "nil", + "a `lean-toolchain` DIRECTORY must not mark a root" + ); +} + +#[test] +fn acc24b_an_empty_lean_toolchain_file_is_a_marker() { + let fx = Fixture::new(); + fx.toolchain("pkg", ""); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let state = editor(&fx); + assert_eq!( + resolved_root(&state, &file), + fx.dir("pkg").display().to_string(), + "marker semantics are existence, not content — an empty \ + `lean-toolchain` still marks the package" + ); + // Non-vacuity: the file really is empty. + assert_eq!( + std::fs::read(fx.dir("pkg/lean-toolchain")).unwrap().len(), + 0 + ); +} + +#[test] +fn acc24_resolver_declines_when_no_marker_exists() { + let fx = Fixture::new(); + let file = fx.write("loose/A.lean", "def a := 1\n"); + let state = editor(&fx); + assert_eq!( + resolved_root(&state, &file), + "nil", + "no marker anywhere is a decline, which falls through to \ + `pmacs.project.detect`" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 25 — a string-valued root still works. +// --------------------------------------------------------------------------- + +#[test] +fn acc25_string_valued_root_still_works() { + let fx = Fixture::new(); + let pkg = fx.mkdir("elsewhere"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + exec( + &state, + &format!("pmacs.lsp.config.lean4.root = \"{}\"", lua_str(&pkg)), + ); + open(&state, &file); + settle(&mut state); + + let want = pkg.display().to_string(); + assert_eq!( + rows(&state), + vec![format!("lean4|file://{want}|{want}")], + "the Q#LN8 generalization is additive; a plain string still wins" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 26 — didOpen carries languageId = "lean4". +// --------------------------------------------------------------------------- + +#[test] +fn acc26_did_open_carries_the_lean4_language_id() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + open(&state, &file); + settle(&mut state); + + let lang: String = eval( + &state, + "return tostring(pmacs.lsp.list()[1] and pmacs.lsp.list()[1].language_id)", + ); + assert_eq!( + lang, "lean4", + "the grammar entry name is the didOpen language id (Q#LN2)" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 28 (probe) — the version predicate. +// +// The parse is unit-tested directly because the spawn path is timing- +// bound; the latch's *effect* is pinned separately below. +// --------------------------------------------------------------------------- + +#[test] +fn acc28_version_predicate_triggers_only_below_3_1() { + let fx = Fixture::new(); + let state = editor(&fx); + let check = |v: &str| -> bool { + eval( + &state, + &format!("return pmacs.lean._version_below_3_1(\"{v}\")"), + ) + }; + assert!(check("Lake version 3.0.0"), "3.0.0 is below 3.1"); + assert!(!check("Lake version 3.1.0"), "3.1.0 is not below 3.1"); + assert!(!check("Lake version 5.0.0-abc"), "5.0.0 is not below 3.1"); + assert!(check("Lake version 2.9.9"), "2.9.9 is below 3.1"); + assert!( + !check("no default toolchain configured"), + "an unparseable line must NOT trigger the fallback — that is the \ + elan-shim case, which the failure latch handles better" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 27 / 35 / 36 — the fallback latch. +// --------------------------------------------------------------------------- + +#[test] +fn acc35_latch_preserves_user_config_and_swaps_only_command_and_args() { + let fx = Fixture::new(); + let state = editor(&fx); + // A user's init.lua settings, on the shipped shape. + exec( + &state, + r#" + pmacs.lsp.config.lean4.command = "lake" + pmacs.lsp.config.lean4.args = { "serve" } + pmacs.lsp.config.lean4.env = { MYVAR = "1" } + pmacs.lsp.config.lean4.settings = { lean = { verbose = true } } + pmacs.lsp.config.lean4.init_options = { hasWidgets = false } + _G.root_before = pmacs.lsp.config.lean4.root + pmacs.lean._fire_latch(nil, "test") + "#, + ); + + let after: String = eval( + &state, + r#" + local c = pmacs.lsp.config.lean4 + return table.concat({ + tostring(c.command), + tostring(c.args and c.args[1]), + tostring(c.env and c.env.MYVAR), + tostring(c.settings and c.settings.lean and c.settings.lean.verbose), + tostring(c.init_options and c.init_options.hasWidgets), + tostring(c.root == _G.root_before), + }, "|") + "#, + ); + assert_eq!( + after, "lean|--server|1|true|false|true", + "only command/args change; env, settings, init_options and root \ + survive the swap" + ); +} + +#[test] +fn acc27_the_latch_is_one_shot_and_does_not_re_arm() { + let fx = Fixture::new(); + let state = editor(&fx); + exec( + &state, + r#" + pmacs.lsp.config.lean4.command = "lake" + pmacs.lsp.config.lean4.args = { "serve" } + pmacs.lean._fire_latch(nil, "first failure") + _G.after_first = pmacs.lsp.config.lean4.command + -- A second failure must not rewrite the command again; if it did, + -- a user who deliberately set something else after the fallback + -- would have it silently replaced. + pmacs.lsp.config.lean4.command = "user-choice" + pmacs.lean._fire_latch(nil, "second failure") + _G.after_second = pmacs.lsp.config.lean4.command + "#, + ); + assert_eq!(eval::(&state, "return _G.after_first"), "lean"); + assert_eq!( + eval::(&state, "return _G.after_second"), + "user-choice", + "the latch never re-arms within a session" + ); +} + +#[test] +fn acc36_latch_stops_the_failing_server_before_spawning_the_fallback() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + open(&state, &file); + settle(&mut state); + assert_eq!(rows(&state).len(), 1, "precondition: one server is up"); + + // Fire the latch against the live server, exactly as `poll_latch` + // would. `pmacs.lsp.stop` sets `restart = Never` on the way out — + // which is what prevents `RestartPolicy::OnCrash` from respawning the + // broken command underneath the latch, forever, with no attempt cap. + exec( + &state, + r#" + pmacs.lsp.config.lean4.command = "lake" + pmacs.lsp.config.lean4.args = { "serve" } + pmacs.lean._fire_latch(pmacs.lsp.list()[1].id, "failed to start") + "#, + ); + settle(&mut state); + + let terminal: bool = eval( + &state, + r#" + for _, s in ipairs(pmacs.lsp.list()) do + local k = s.state and s.state.kind + if k ~= "stopped" and k ~= "crashed" then return false end + end + return true + "#, + ); + assert!( + terminal, + "the failing server is stopped, not left to be respawned under \ + the latch" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 36a — attribution (COHERENCE §9 / §1.2). +// --------------------------------------------------------------------------- + +#[test] +fn acc36a_latch_leaves_a_status_line_trace() { + let fx = Fixture::new(); + let state = editor(&fx); + exec( + &state, + r#" + pmacs.lsp.config.lean4.command = "lake" + pmacs.lsp.config.lean4.args = { "serve" } + pmacs.lean._fire_latch(nil, "`lake serve` failed to start") + "#, + ); + let status = state.core.borrow().status.clone(); + assert!( + status.contains("lean4") && status.contains("lean --server"), + "the fallback names itself and what it fell back to; saw {status:?}" + ); + // The channel assertion is the point (COHERENCE §1.2): a report made + // only through `pmacs.error` — undefined in production — would leave + // this empty while every other assertion here still passed. + assert!(!status.is_empty()); +} + +#[test] +fn acc36a_probe_carries_a_lean_owned_process_label() { + // `ProcessSpec.label` is the only identity a process has, and it is + // what `pmacs.process.list` renders. Asserted on the spec the module + // builds rather than on a live `lake`, which CI does not have. + let fx = Fixture::new(); + let state = editor(&fx); + let src = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("builtin/runtime/lean.lua"), + ) + .unwrap(); + assert!( + src.contains("label = \"lean:lake-version-probe\""), + "the probe process is attributed to Lean by label" + ); + // And it is genuinely lazy: no probe without an attachment. + let procs: i64 = eval(&state, "return #pmacs.process.list()"); + assert_eq!(procs, 0, "configuring Lean does not start the probe"); +} + +// --------------------------------------------------------------------------- +// Acceptance 37 — waitForDiagnostics resolves through the response seam. +// --------------------------------------------------------------------------- + +#[test] +fn acc37_wait_for_diagnostics_resolves_through_the_response_seam() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a : Nat := 1\n"); + let mut state = editor(&fx); + open(&state, &file); + settle(&mut state); + + exec( + &state, + r#" + _G.settled = "never" + local rec = pmacs.lsp.active_attachment() + pmacs.lean.wait_for_diagnostics(rec.server, rec.uri, function(err) + _G.settled = tostring(err) + end) + "#, + ); + settle(&mut state); + + assert_eq!( + eval::(&state, "return _G.settled"), + "nil", + "the reply reaches the callback with no error — this is the \ + Stage 3a response seam carrying its first production caller" + ); +} + +// --------------------------------------------------------------------------- +// Acceptance 29, Lean's side — `$/lean/fileProgress` reaches the module. +// +// Driven end-to-end through the real drain: the fake server's +// `leanprogress` mode emits the notification on didOpen. Calling the +// handler directly would pin nothing about the wiring, which is the only +// part that can break. +// --------------------------------------------------------------------------- + +#[test] +fn file_progress_notification_is_recorded_for_its_document() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + exec( + &state, + "pmacs.lsp.config.lean4.env = { PMACS_FAKE_LSP_MODE = \"leanprogress\" }", + ); + + // Nothing recorded before the server speaks — so the assertion below + // cannot pass on a pre-populated table. + let before: i64 = eval( + &state, + "local n = 0 for _ in pairs(pmacs.lean.file_progress) do n = n + 1 end return n", + ); + assert_eq!(before, 0); + + open(&state, &file); + settle(&mut state); + + let uri: String = eval( + &state, + r#" + for k, v in pairs(pmacs.lean.file_progress) do + if type(v) == "table" and v[1] and v[1].range then return k end + end + return "none" + "#, + ); + assert!( + uri.starts_with("file://") && uri.ends_with("A.lean"), + "the subscriber recorded the processing ranges under the \ + document uri; saw {uri:?}" + ); +} + +// --------------------------------------------------------------------------- +// Q#LN20 in the Lean resolver — a symlinked open reuses one server. +// --------------------------------------------------------------------------- + +#[test] +fn lean_root_is_canonical_so_a_symlinked_open_reuses_one_server() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let real = fx.write("pkg/A.lean", "def a := 1\n"); + std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap(); + let linked = fx.dir("linkpkg").join("A.lean"); + + let mut state = editor(&fx); + open(&state, &real); + settle(&mut state); + assert_eq!(rows(&state).len(), 1, "the real path spawns one server"); + + open(&state, &linked); + settle(&mut state); + assert_eq!( + rows(&state).len(), + 1, + "the symlinked path reuses it — the resolver canonicalizes, so \ + both spellings produce the same affinity key" + ); +} diff --git a/tests/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs index d48a86c..9aafcab 100644 --- a/tests/lean4_stage1_acceptance.rs +++ b/tests/lean4_stage1_acceptance.rs @@ -305,36 +305,50 @@ fn acc11b_an_unknown_fence_name_still_injects_nothing() { // --------------------------------------------------------------------------- #[test] -fn acc12_stage1_ships_no_lsp_config_and_spawns_no_process() { - // Stage 1 is grammar + Lua tables only. Opening a Lean file must not - // reach for `lake`, `lean`, or `elan` — the LSP arrives in Stage 3, and - // even then it is fallible by design (Q#LN7). +fn acc12_opening_lean_spawns_no_process_without_a_server_config() { + // **Superseded in half by Stage 3b.** This criterion originally also + // asserted `pmacs.lsp.config.lean4 == nil`, guarding against a Stage-3 + // front-run. Stage 3b *is* Stage 3: `builtin/runtime/lean.lua` now ships + // that config deliberately, and its shape is pinned by + // `tests/lean4_server_acceptance.rs`. Asserting the absence here would + // now pin the opposite of the intended behavior, so it is gone rather + // than weakened. + // + // What survives is the half that was always about *restraint*, and it + // matters more now than it did in Stage 1 — it is what holds Q#LN7's + // "not at init" promise. `pmacs.lsp.config` is a declarative table, and + // spawning a process at startup for every user, Lean-using or not, is + // the cost rev 1 refused. Both the `lake serve` spawn and the + // `lake --version` probe are gated on a real Lean attachment. - // The load-bearing assertion, and it must run against a PRISTINE editor. - // The shared `editor()` helper wipes `pmacs.lsp.config` before any - // buffer opens, so an assertion about the server list under that harness - // holds for every language regardless of what Stage 1 ships — it could - // not fail for the regression it names. This checks the real claim - // directly: no builtin runtime file defines a Lean server config. A - // Stage-3 front-run adding `pmacs.lsp.config.lean4` fails here. + // Constructing an editor touches no process, even though the Lean + // config now exists and names `lake`. let pristine = EditorState::new(); - let no_lean_config: bool = eval(&pristine, "return pmacs.lsp.config.lean4 == nil"); - assert!( - no_lean_config, - "Stage 1 defines no `pmacs.lsp.config.lean4`; the LSP is Stage 3" + let at_init: i64 = eval(&pristine, "return #pmacs.process.list()"); + assert_eq!( + at_init, 0, + "constructing an editor must not probe or spawn for Lean" + ); + // Non-vacuity for the assertion above: the config really is present and + // really does name a command, so "nothing spawned" is restraint rather + // than an empty table having nothing to act on. + let names_lake: bool = eval( + &pristine, + "return pmacs.lsp.config.lean4 ~= nil and pmacs.lsp.config.lean4.command == \"lake\"", ); - // Non-vacuity: the same lookup finds the configs that DO ship, so this - // is not passing because `pmacs.lsp.config` is empty or absent. - let rust_config_exists: bool = eval(&pristine, "return pmacs.lsp.config.rust ~= nil"); assert!( - rust_config_exists, - "the config table is populated, so the lean4 absence above is meaningful" + names_lake, + "Stage 3b ships a lean4 config naming `lake`, so the no-spawn \ + assertion above is meaningful" ); - // And nothing is spawned by opening the file. This half retains its - // value under the wiped config: a direct probe spawn from `lean.lua` - // would show up here whatever `pmacs.lsp.config` contains. + // And opening a Lean buffer with no server configured spawns nothing — + // the `editor()` helper wipes `pmacs.lsp.config`, so this catches a + // probe that fires off the mode rather than off an attachment. let s = editor_visiting("Basic.lean", "def x : Nat := 1\n"); let procs: i64 = eval(&s, "return #pmacs.process.list()"); - assert_eq!(procs, 0, "opening a Lean buffer spawns no child process"); + assert_eq!( + procs, 0, + "with no server configured, opening a Lean buffer spawns nothing" + ); } From 914bf3f02f472774e79792347a23ff190d631e34 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 17:40:31 -0400 Subject: [PATCH 12/91] docs: record the Stage 3b lane and its stacking constraint --- docs/active-work.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index aef6edc..99b54ea 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,7 +54,7 @@ git status --short --branch The `git log` command must expose `0dd16a5` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Lean 4 lane (Arc 8) — Stages 1+2 MERGED; Stage 3a IN REVIEW +## Lean 4 lane (Arc 8) — Stages 1+2 MERGED; 3a IN REVIEW (#167); 3b STACKED - Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review round, all twelve checks green). Branch `githubsucks/lean4-stage1` @@ -252,6 +252,43 @@ If it does not, stop and repair the remote/fetch configuration. `#[cfg(unix)]` is NOT sufficient for such a fixture — `#[cfg(target_os = "linux")]` is. Cost one red CI round to learn. +### Stage 3b — the Lean language server (branch `lean4-stage3b-server`) + +- Same worktree `../pmacs-lean-stage3`, **branched off + `lean4-stage3a-seams`, not off `main`** — 3b consumes 3a's response + seam and `pmacs.fs.canonicalize`, so it is strictly sequential and its + PR must be retargeted to `main` only after #167 merges. (Kill-ring + lesson: retarget stacked child PRs BEFORE merging the parent.) +- Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in + `src/editor.rs`, a `leanprogress` mode on `pmacs_fake_lsp`, and + `tests/lean4_server_acceptance.rs` (17 tests). No protocol change. +- **Stage 1's acceptance 12 is half superseded and was rewritten, not + deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a + Stage-3 front-run; 3b is that stage. What survives is the restraint + half — constructing an editor spawns nothing though the config now + names `lake`, and opening a Lean buffer with no server configured + spawns nothing — which is what holds Q#LN7's "not at init" promise. +- **The marker test is wrong in two opposite directions if done naively** + and both are pinned: `io.open` SUCCEEDS on a directory (so truthiness + accepts a `lean-toolchain` dir), but requiring a non-nil read rejects + an EMPTY `lean-toolchain` (a legitimate marker — existence semantics, + not content). Discriminator is `read`'s SECOND return; decline only on + a non-nil err. Probed on LuaJIT 2.1. +- Four bites recorded, each against the committed tree: bare `io.open` + → 24a fails / 24b passes; require-non-nil → 24b fails / 24a passes; + no canonicalization → symlinked open spawns two servers; no stop + before fallback → acc36 fails. +- The probe's non-zero exit is deliberately NOT a fallback trigger — + §2.9's elan shim makes `lake --version` fail where `lake serve` still + works. Only a parseable version below 3.1.0 triggers it; the + server-failure latch covers the rest. +- Verification on this branch: `cargo fmt --check` clean; strict + workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; + lean4 server 17/17; lean4 stage 1 9/9; dispatch seams 15/15; + multi-root 13/13; M4 121; required GPU 155; **isolated-config + workspace sweep 3,206 across 94 suites, zero failures**; + `git diff --check` clean. + ## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next - Approved framing: `docs/dired-framing.md` (revision 5), landing as its From dea1d20e4eaea101020af3997e0016668de159c6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 17:58:00 -0400 Subject: [PATCH 13/91] docs: refresh the canonical base for #158 The inline-math slice landed while this PR was open. Its own merge removed its ledger lane, so the stale-header note above still names exactly two; only the base anchor needed moving. --- docs/active-work.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 1266e40..d7a8c9d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -22,8 +22,9 @@ has landed regardless of what its lane says. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `c8ec8f3` (dired Stage 1 #165 atop GPU terminal - input #166, multi-root LSP affinity #161, the dired framing #164, + `githubsucks/main` @ `5aa9044` (the inline-math slice #158 atop dired + Stage 1 #165, GPU terminal input #166, multi-root LSP affinity #161, + the dired framing #164, find-file #162, COHERENCE.md #163, Lean 4 Stage 1 #160, the minimap blank-slab fix #159, bottom-panel Stage 1 #155, the inline-math re-scout #154, the vterm PTY-flake fix #153, and the GPU initial-target doc From 05984f1b1be4ae818b55b9a4014b9b91efafa143 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 18:02:35 -0400 Subject: [PATCH 14/91] docs: frame terminal configuration and copy mode Two stages, one arc, no protocol change. Stage 1 makes the terminal configurable (profiles, scrollback, escape key) and binds the opening command; Stage 2 adds copy mode and search over scrollback. They are independently releasable and get separate branches and PRs. Three scouted facts shaped the design, two of them ruling out the obvious plan. Profiles cannot be a config-registry setting: ConfigValue is four scalars and there is no table kind, so profiles join pmacs.lsp.config and pmacs.pair.sets as a raw Lua table while the registry holds only scalars. Search cannot reuse isearch in place: SearchStore addresses matches as byte ranges into a buffer's rope, and a terminal identity buffer is empty by construction. An in-place copy mode would be the seventh dispatch shadow, which COHERENCE section 6 grades weak and growing by one island per modal feature, with no transient-keymap mechanism to migrate to. Copy mode therefore materializes the retained rows into an ordinary read-only buffer. isearch, motion, selection and the kill ring work with no new substrate; the "keys must not reach the child" problem dissolves because the snapshot is not a terminal; and describe-key stays truthful because the bindings are buffer-local. The cost, stated in the doc, is that the snapshot is point-in-time rather than a live freeze. Four review rounds produced the load-bearing parts: the escape-key cache is owned by TerminalSession so its lifecycle is the terminal's, with three acceptance pins that each fail a different wrong cache; the snapshot needs set_round_trip_input because a Lua intercept does not set Buffer::read_only and an optimistic CrdtOp would mutate both the daemon buffer and the mirror; the double-escape must encode the configured chord rather than a hardcoded ETX; and the two open-time settings resolve through the global chain because they are read before the terminal buffer exists. No code changes in this commit. --- docs/terminal-config-and-copy-mode-framing.md | 634 ++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 docs/terminal-config-and-copy-mode-framing.md diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md new file mode 100644 index 0000000..3f13987 --- /dev/null +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -0,0 +1,634 @@ +# Terminal configuration and copy mode + +**Revision 4 — scouted against canonical `main` @ `b889873` (protocol v20), +2026-07-25. Not yet approved; no branch, no implementation.** + +Revision 4 gives the escape-key cache an owner and a lifecycle (Q#TC4c) — +revision 3 named the key but not the storage, and two implementations +satisfied its acceptance while behaving differently on A→B→A. It also corrects +the read-only deferral, which understated the substrate required: the bypass +path is `ensure_writable`-guarded too, so genuine immutability alone would +break every generated buffer that refreshes. + +Revision 3 corrects two design errors and decides the chords. The +round-trip failure shape in revision 2 was **wrong in the reporter's favour**: +a Lua intercept does not set `Buffer::read_only`, and there is no Lua binding +that does, so an optimistic `CrdtOp` bypasses the intercept *and* passes +`ensure_writable()` — the daemon buffer mutates too, rather than the mirror +diverging alone (Q#TC6a). Revision 2 also had all three settings resolving +against the terminal identity buffer, which is impossible for the two read +*before* that buffer exists (Q#TC2b). Chords are now decided and +collision-scouted rather than deferred to implementation (Q#TC10, Q#TC8a). + +Revision 2 answered seven review findings. Four were load-bearing: the settings +are `Live`, so the registry **accepts buffer-local overrides whether or not we +want them**, and `value_epoch()` does not move on a buffer switch — an +epoch-only cache can serve the wrong terminal's escape chord (Q#TC4); the +double-escape byte is a hardcoded `0x03`, so a configured escape would still +send Ctrl-C and make its own literal chord unreachable (Q#TC4b); the snapshot +buffer needs `set_round_trip_input`, not only a read-only intercept, or a +semantic frontend can optimistically edit it before daemon dispatch (Q#TC6); +and the two stages must be two branches and two PRs. Revision 1's +materialized-copy reframe is unchanged. + +Two stages, one arc, no protocol change: + +- **Stage 1 — configuration.** Terminal profiles, scrollback, and the escape + key become configurable. Today the terminal has **zero** configuration + surface: the `terminal` command hardcodes `os.getenv("SHELL") or "/bin/sh"`, + `scrollback_rows` is a per-open argument only, and the escape chord is a + literal in Rust. +- **Stage 2 — copy mode and search over scrollback.** A command that turns + the retained terminal screen and scrollback into an ordinary buffer, where + isearch, motion, selection, and the kill ring already work. + +Explicitly **not** in this arc: the panel terminal (blocked on bottom-panel +Stage 2), and shell integration (cwd tracking, prompt marks, command zones) — +the keystone that unlocks the VS Code-style cluster, which needs its own +security framing because it decides what a child process may make the editor +do. + +## Branch and PR plan + +**Two branches, two PRs.** Configuration and copy mode are independently +releasable and have no dependency on each other; one framing covers the arc, +but the one-feature/one-branch/one-PR rule governs the implementation. + +1. `terminal-config` — Stage 1. Also carries the **terminal opening + keybinding** (Q#TC10). +2. `terminal-copy-mode` — Stage 2, branched off `main` after Stage 1 merges. + +Sequencing is not a dependency but avoids a conflict: both stages edit +`builtin/runtime/terminal.lua`. + +## Ground truth (measured, not recalled) + +Three facts constrain the design, and two of them rule out the obvious plan. + +### 1. Terminal profiles cannot be a config-registry setting + +`ConfigValue` is **four scalars** — `Bool`, `Int`, `Num`, `Str` +(`src/config_registry.rs:312`) — and its own doc comment says they "are never +stored --- only these four scalars (Q#CR3)". `ConfigKind` adds `Enum`, which +is physically a string validated against choices fixed at `define` time +(`src/config_registry.rs:115-145`). There is no table, list, or map kind. + +A terminal profile is inherently a table: `{ command, args, cwd, env }` per +name. **Table-valued settings are an existing named deferral of the config +registry arc** — the same gap that keeps `pmacs.lsp.config`, +`pmacs.pair.sets`, `pmacs.comment.strings`, and the `pmacs.parse.*` proxies as +raw Lua. Profiles join that list rather than forcing that deferral open here. + +### 2. Search cannot reuse isearch in place over a terminal + +`SearchStore::set(buffer_id, query, matches: Vec)` +(`src/search.rs:99`) keys matches by buffer and addresses them as **byte +ranges into that buffer's rope**; the painting path materializes the source +with `buf.snapshot_rope().slice(0, buf.len(), ..)` (`src/search.rs:435`). + +A terminal identity buffer is **empty and read-only** by construction. Its +content lives in `TerminalScreen` as cells addressed by `(row, col)` across +history plus visible rows — there are no rope bytes to range over. Searching a +terminal in place therefore means a second, parallel search facility with its +own match store and its own highlight path, because terminal painting consumes +owned cells and not document style spans. + +### 3. An in-place copy mode would be the seventh dispatch shadow + +`dispatch_key`'s terminal-transport arm intercepts **every** key before +ordinary keymap dispatch whenever `active_terminal_key` is `Some`, which keys +purely on `is_terminal(window.buffer_id)` (`src/editor.rs:1098-1107`, +`973-1016`). A mode that keeps the terminal buffer focused while rebinding +keys to motion/selection must therefore add a new precedence rung. + +`COHERENCE.md` §6 grades that ladder **weak, "and growing by one island per +modal feature"**, records that **no transient-keymap mechanism exists to +migrate to** (`KeymapStack` has exactly three fixed scopes, no layer stack, no +push/pop, no lifetime), and notes that `describe-key` already lies while a +shadow is active. It also names the counter-example: the entire picker/panel +family uses ordinary **buffer-local keymaps** and is inspectable and +rebindable. + +### 4. What already exists and is reusable + +- `retained_rows(projection)` (`src/terminal/view.rs:539`) iterates history + plus visible rows; `copy_selection_bytes(rows, selection)` + (`src/terminal/view.rs:849`) serializes a range with the fidelity Stage 2 + criterion 21 already pins — soft wraps joined, hard rows separated, trailing + default blanks trimmed, wide glyphs and combining clusters copied once. +- `ConfigRegistry::value_epoch()` (`src/config_registry.rs:1127`) is public and + monotonic — cheap invalidation for a hot-path cache. +- The Lua surface is `define` / `get` / `set` / `set_local` / `on_change` with + a disposable handle (`src/lua_bindings/config.rs`). +- `pmacs.terminal.open` already accepts + `command, args, cwd, env, name, rows, cols, scrollback_rows, display, + window`. **`display = "panel"` already works** (bottom-panel Stage 1) — the + panel terminal is blocked on rendering, not on this surface. +- Terminal buffers already carry buffer-local bindings (`M-w`, `M-v`, `C-v`, + `M-<`, `M->`) installed by `terminal.open` in `builtin/runtime/terminal.lua`. + +## Stage 1 — configuration + +**Q#TC1 — Profiles are a raw Lua table, not a setting.** +`pmacs.terminal.profiles` maps a name to a spec table, exactly following the +`pmacs.lsp.config` precedent. The registry holds only scalars. Rejected +alternative: widening `ConfigValue` with a table kind — that is the config +arc's own named deferral, it is cross-cutting (persistence, `describe-setting` +rendering, the `custom-file` question all key on the scalar assumption), and +smuggling it into a terminal PR would be the wrong place to decide it. + +**Q#TC2 — `terminal.default-profile` is `String`, not `Enum`.** `Enum` +choices are frozen at `define` time; profiles are user-extensible from +`init.lua` and later. Validation happens at open time, and an unknown name +must produce a pointed error that **names the known profiles**, not a bare +"unknown profile". + +**Q#TC2a — the exact settings, defaults, and bounds.** All three are `Live` +(see Q#TC2b), and every default reproduces today's behavior exactly, so a tree +with no settings written behaves identically (acceptance 12). + +| name | kind | default | bounds | +|---|---|---|---| +| `terminal.default-profile` | `String { allow_empty: true }` | `""` | — | +| `terminal.scrollback-rows` | `Integer` | `10_000` (`DEFAULT_TERMINAL_SCROLLBACK_ROWS`) | `0 ..= 4_000_000` (`MAX_TERMINAL_HISTORY_CELLS`) | +| `terminal.escape-key` | `String { allow_empty: false }` | `"C-c"` | parsed as a chord | + +**Zero is a legal scrollback value meaning "retain no history".** The core's +own validation rejects only values *above* `MAX_TERMINAL_HISTORY_CELLS` +(`src/terminal/session.rs:114`), so `scrollback_rows = 0` is accepted through +`terminal.open` today. A `1` minimum here would invent an asymmetry between the +setting and the per-open field for no reason. + +`""` is the **"no default profile" sentinel**: an empty string means "fall +through to `$SHELL`", not "a profile named empty". `allow_empty: true` exists +precisely to express it, and the open path treats empty and unset identically. + +**Q#TC2b — the settings are `Live`, and the registry therefore accepts +buffer-local overrides. That is specified rather than accidental.** +`ConfigRegistry::set_local` refuses only `StartupOnly` definitions +(`src/config_registry.rs:949`); a `Live` setting can be pinned per buffer by +anyone. Declaring these global-only is **not currently expressible** — a +`scope = "global"` define flag is one of the config registry's own named +deferrals, and `autosave.interval-ms` already has the same latent problem. + +Making them `StartupOnly` instead would buy enforcement at the cost of the +feature: the escape key could never be changed mid-session, which kills Q#TC4's +whole point. So they stay `Live`, and resolution is defined **per setting, +because the three are not read at the same moment**: + +| setting | read when | resolution | +|---|---|---| +| `terminal.escape-key` | every keystroke in a terminal (cached) | `get(name, terminal_buffer)` — **buffer-local → global → default** | +| `terminal.default-profile` | once, **before** the terminal exists | `get(name)` — **global chain only** | +| `terminal.scrollback-rows` | once, **before** the terminal exists | `get(name)` — **global chain only** | + +The split is forced, not stylistic. The two open-time settings are consumed by +`_open` **before it creates the identity buffer**, so there is no terminal +buffer to resolve against — and no caller could have pinned a local override on +a buffer that does not yet exist. `pmacs.config.get(name)` with no buffer +argument already means exactly "the global chain, never an ambient buffer", so +this is the registry's existing semantic rather than a new rule. + +Consequences, stated so they are not discovered later: + +- a per-terminal escape key is a supported feature, not a bug; +- `set_local` on `terminal.default-profile` or `terminal.scrollback-rows` is + **always inert**, for any buffer, because the open path never consults a + buffer chain. This is deliberate; the alternative — resolving against + whichever buffer happened to be current at open time — would make a + terminal's scrollback depend on what the user was looking at when they + pressed the key. + +Rejected alternative: resolving the open-time settings against the *target +window's pre-open buffer*. It is expressible, but it makes an ambient buffer +load-bearing for a value the user set globally, which is the trap +`pmacs.config`'s two-argument/one-argument split exists to avoid. + +**Q#TC3 — `terminal.scrollback-rows` is `Integer` with bounds, and an explicit +per-open `scrollback_rows` still wins.** The precedence is +**explicit argument over global setting** — there is no ambient buffer in this +chain at all (Q#TC2b resolves it through `get(name)`), so the rule is simply +that what a caller passes to `terminal.open` beats what the user configured +globally. The bounds above come from the existing validation, so the setting +cannot express a value the core will reject. + +**Q#TC3a — profile resolution order, field by field.** `profile` is accepted +by **`pmacs.terminal.open` as well as the command**, so a Lua caller is not +forced through the command to use one. For each field, the first source that +supplies it wins: + +1. an explicit `pmacs.terminal.open` field; +2. the named profile's field — `profile` argument, else + `terminal.default-profile` when non-empty; +3. the scalar setting, where one exists (`scrollback_rows` only); +4. the built-in fallback (`command` = `$SHELL`, else `/bin/sh`). + +`env` is the one field where "first wins" is ambiguous, so it is stated: +profile `env` and explicit `env` are **merged**, with explicit entries +overriding profile entries of the same name. Any other reading silently drops +half a user's environment. + +An explicitly passed `profile` that does not exist is an error even when +`terminal.default-profile` is valid — a typo must not silently fall back to +the default. + +**Q#TC4 — `terminal.escape-key` is a `String` chord spelling, parsed once and +cached by `(buffer_id, value_epoch)`.** `is_terminal_escape_chord` +(`src/editor.rs:4413`) currently compares against a literal `C-c`. Reading and +parsing a setting on **every keystroke in a terminal** is not acceptable in +that path. + +**The cache key must include the buffer.** `value_epoch()` advances only on +`set` / `set_local` / removal (`src/config_registry.rs:918`, `970`, `1011`, +`1029`) — **it does not move when the focused terminal changes**. An +epoch-only cache therefore serves terminal A's escape chord to terminal B for +as long as no setting is written, which is exactly the case where nothing looks +wrong. Keying on `(buffer_id, value_epoch)` is the minimum correct identity. + +**Q#TC4c — the cache lives on `TerminalSession`, so its lifecycle is the +terminal's.** Revision 3 named the key `(buffer_id, value_epoch)` but not the +storage, and the two obvious storages behave differently on A→B→A: + +- a **single last-entry cache** reparses on every switch between two + terminals, and re-reports an invalid value each time — a status line that + scolds you for a setting you already know about, forever; +- an **editor-side map** preserves "parsed and reported once" but **leaks an + entry per terminal** unless something purges it, and that purge is a second + thing to get wrong. + +`TerminalSession` (`src/terminal/session.rs:215`) is created in +`TerminalManager::open` and dropped on kill/prune, so putting the cache there +gets the lifecycle for free with no purge hook to forget. It carries the parsed +chord, the `value_epoch` it was parsed at, and whether the current invalid +value has already been reported. + +**"Reports once" means once per terminal, per effective invalid value.** +A→B→A must not re-report. Changing the setting from one invalid value to a +*different* invalid value **does** re-report, because that is new information +about a new mistake. + +**The reporting channel is `EditorCore::status`** — the same channel +`send_terminal_bytes` already uses for terminal failures +(`src/editor.rs:1122`). Explicitly **not** `pmacs.error`: it is not installed +as a module anywhere in `src/lua_bindings`, so its call sites across the +runtime are dead, and a report sent there would be a report nobody sees. + +**Q#TC4a — an unparseable escape key must not brick terminal input.** A bad +value falls back to `C-c` and reports once. The failure mode this avoids is +severe: with no escape chord, every key goes to the child and the user cannot +reach any editor binding to fix the setting that broke it. + +**Q#TC4b — repeating the configured escape sends THAT chord to the child, not +Ctrl-C.** The double-escape arm currently writes a hardcoded +`&[0x03]` (`src/editor.rs:988`). With `terminal.escape-key = "C-x"`, `C-x C-x` +would send Ctrl-C — and literal Ctrl-X would become unreachable, since the +first `C-x` is always consumed as the escape. The repeat arm must encode the +**configured** chord through the existing `crate::terminal::input::encode_key` +path, which is also how it inherits application-cursor and modifier handling +rather than growing a second encoder. + +Corollary worth pinning: after changing the escape away from `C-c`, an ordinary +`C-c` must reach the child as `0x03` like any other unescaped key. + +**Q#TC5 — the `terminal` command gains an optional profile argument** and +otherwise keeps its current behavior; `$SHELL` remains the fallback when no +profile is configured. No existing invocation changes meaning. + +**Q#TC10 — the terminal opening keybinding is pulled forward into Stage 1.** +`COHERENCE.md` Priority 1 names "a terminal keybinding" as part of protecting +the golden journey, §2 step 8 grades the terminal "works but undiscoverable", +and this stage already edits `terminal.lua`. Panel rendering imposes no +dependency on binding a command that already exists. Close/kill semantics stay +with the panel work, where the entry and exit points get designed together. + +The chord is **decided and scouted, not deferred**: `C-c t`, global. See +Q#TC8a for the collision evidence and for why binding under the existing `C-c` +prefix is a new leaf rather than a shadow. + +## Stage 2 — copy mode and search + +**Q#TC6 — copy mode MATERIALIZES into an ordinary buffer. It does not add a +dispatch shadow.** + +`M-x terminal.copy-mode` snapshots the retained rows into a read-only, +path-less buffer (`*terminal-copy: NAME*`) and displays it. That buffer is an +ordinary document buffer, so: + +- **isearch works, with no new search substrate** — it is a rope, so + `SearchStore` and the existing match-painting path apply unchanged. Ground + truth 2 is answered by not fighting it. +- **motion, selection, `M-w`, the kill ring, even `M-x occur`-style consumers + work** — everything that operates on a buffer. +- **The "keys must not reach the child" problem dissolves structurally.** + `active_terminal_key` keys on `is_terminal(window.buffer_id)`; the snapshot + buffer is not a terminal, so the transport arm never fires. No new guard, no + new precedence rung, and ground truth 3's coherence cost is avoided rather + than paid. +- **`describe-key` stays truthful**, because the bindings are buffer-local and + inspectable — the idiom `COHERENCE.md` §6 identifies as the right side of + the line. + +**Q#TC6a — the snapshot is BOTH intercept-read-only AND round-trip-marked, +and `set_round_trip_input` is the ONLY thing standing between a replica +frontend and unauthorized mutation.** + +The established idiom is two calls: `listview.lua:106` and `compile.lua:272` +each pair `pmacs.buffer.add_intercept` with +`pmacs.buffer.set_round_trip_input(buf, true)`. Revision 2 described the +intercept as the guard and round-trip as defence in depth. **That was wrong, +and the correction matters:** + +- A Lua intercept guards the **dispatch/edit** path only. It does **not** set + `Buffer::read_only`, which is "deliberately independent of edit intercepts" + (`src/buffer.rs:493-500`) — that flag is what makes terminal identity buffers + reject rope, undo/redo, and remote-CRDT mutation alike. +- **No Lua binding sets `read_only` at all.** The whole `src/lua_bindings` + tree only ever *reads* it (`fold.rs:313`). A Lua-created "read-only" buffer + is therefore read-only against dispatch and nothing else. +- So an optimistic `CrdtOp` from a semantic frontend bypasses the intercept + **and passes `ensure_writable()`**. It is applied. The daemon buffer mutates + in lockstep with the mirror — the user silently edits a buffer the editor + told them is read-only. There is no divergence to notice, which is worse + than divergence. + +`set_round_trip_input` prevents this at the only point it can be prevented: it +makes `dispatch_idle_for` report false while the buffer is focused, so the +frontend never applies optimistically and never emits the op. It is not +hardening — it is the guard. + +Two things follow, and both are recorded rather than fixed here: + +- **The same exposure exists today** for every Lua-created read-only buffer — + listview panels and `*compilation*` included. They are correct only because + they call `set_round_trip_input`. This arc must not be the place that + unilaterally changes that substrate. +- **Exposing `Buffer::set_read_only` to Lua** would make these buffers + genuinely immutable at the rope/CRDT boundary the way terminal identity + buffers are, turning round-trip back into real defence in depth. That is a + substrate change affecting listview and compile as much as this snapshot, so + it is named in Deferred with its own lane. + +**Q#TC7 — the materializer reuses the existing serializer.** A whole-range +variant of `copy_selection_bytes` over `retained_rows` inherits the criterion +21 fidelity rather than re-deriving soft-wrap, wide-glyph, and trailing-blank +behavior. Writing a second serializer would guarantee the two drift. + +**Q#TC8 — one snapshot buffer per terminal, reused on re-invoke.** Re-running +the command against the same terminal replaces the contents in place rather +than accumulating buffers. It is killed with its terminal; killing the +snapshot alone leaves the terminal untouched. + +**Q#TC8a — the chords, decided and collision-scouted.** + +Worth stating first because it is easy to get backwards: in a terminal window +every **unescaped** key goes to the child, so terminal-local bindings are +reached as ` `. The existing `M-w` copy is physically `C-c M-w`. +The escape consumes itself and the next key starts a fresh ordinary sequence, +which is also why `C-c`-leading bindings are structurally unreachable *inside* +a terminal. + +| action | scope | binding | physically typed | +|---|---|---|---| +| open a terminal (Q#TC10) | global | `C-c t` | `C-c t` | +| enter copy mode | terminal buffer | `C-t` | `C-c C-t` | +| refresh snapshot | snapshot buffer | `g` | `g` | +| return to terminal | snapshot buffer | `q` | `q` | + +Scouted against the real keymaps: + +- **`C-c t` is free.** No bare global `C-c` binding exists; `C-c` is already a + live global prefix from `fold.lua:48-52` (`C-c @ …`), and `C-c C-k` is + buffer-scoped in compile/async. `C-c t` is a new leaf under an existing + prefix, not a shadow. +- **`C-t` is globally `edit.transpose-chars`** (`editops.lua:909`), and binding + it **buffer-locally is legitimate**: `keymap.bind`'s strictness rejects + binding a *prefix* of an existing sequence within a scope + (`keymap_bind_conflict_surfaces_at_bind_time` — "would shadow"), not + cross-scope shadowing, which is what scopes are for. Listview already binds + `n`/`p`/`g`/`q`/`RET`/`SPC` buffer-locally. Transpose-chars is meaningless in + a read-only terminal buffer. +- `C-c C-t` matches emacs-libvterm's own `vterm-copy-mode` chord, so the muscle + memory transfers. +- `g` / `q` in the snapshot follow listview's precedent exactly. + +**Named limitation:** `C-c t` cannot open a terminal *from inside* a terminal, +because `C-c` is consumed as the escape there. `M-x terminal` still works. This +is the documented consequence of Stage 2 criterion 19, not a new defect. + +These are what make acceptance 21's `describe-key` claim testable: named +bindings, in named buffers, that introspection must report truthfully. + +**Q#TC9 — the live-terminal keys stay.** `M-w`, `M-v`, `C-v`, `M-<`, `M->` on +the terminal buffer are the live affordances and do not change. Copy mode is +additive, on its own binding, and does not replace scroll-and-select. + +## Bets + +- **B1.** Materializing gives search for free: no second match store, no + second highlight path, no terminal-specific search UI. *Scored by Stage 2 + landing with zero changes under `src/search.rs`.* +- **B2.** Point-in-time is sufficient for read-back/search/copy. *Scored by + use; if false, the live frozen mode in Deferred becomes the real feature and + this becomes its snapshot fallback.* +- **B3.** No protocol change. The snapshot is an ordinary buffer, so both + frontends render it with existing machinery. *Scored by the diff.* +- **B4.** The escape-key cache keyed by `(buffer_id, value_epoch)` never + becomes stale in a way a user can observe. *Scored by two acceptances, not + one: changing the setting mid-session (8) and two terminals with different + buffer-local values and no write between them (7). Revision 1's epoch-only + cache would pass the first and fail the second, which is why the bet now + names both.* +- **B5.** Buffer-local escape keys are a feature rather than a hazard. + *Unscored and honestly so: the registry cannot express global-only, so this + is what we get either way. If per-terminal escapes turn out to confuse more + than they help, the fix is the config registry's `scope = "global"` deferral, + not a terminal change.* + +## Deferred (named) + +- **Live frozen copy mode** (true `vterm-copy-mode` semantics: freeze the + terminal in place, navigate it, resume). Strictly larger; needs either the + transient-keymap primitive `COHERENCE.md` §6 specifies or a deliberate + seventh shadow. +- **Shell integration** — cwd tracking, prompt marks, command zones, and the + VS Code cluster downstream of it (command decorations, exit-code markers, + rerun, sticky scroll, terminal IntelliSense). Its own arc, with a security + framing. +- **Table-valued settings** — the config registry's own deferral. This arc + adds a **second** blocked adopter (after `pmacs.lsp.config` / + `pmacs.pair.sets`); worth recording as evidence when that deferral is + ranked. +- **A `scope = "global"` define flag** — also the config registry's own + deferral, and this arc is its second live case after `autosave.interval-ms`. + Until it exists, `set_local` on any `Live` setting is accepted whether or not + the owner wants it, so Q#TC2b specifies the behavior instead of pretending + it is prevented. +- **Panel terminal** — blocked on bottom-panel Stage 2 (semantic frontends are + not `panel_capable`). `display = "panel"` already exists and works on the + grid frontend. +- OSC 8 hyperlinks, images (sixel/kitty), `faint`/`blink`/`conceal`/ + `strikethrough` (needs a shared `Style` widening, so a protocol bump), + cursor shape/blink, kitty keyboard protocol. +- Terminal session persistence/reconnect across editor restart. +- **A terminal close/kill command** — the remaining half of `COHERENCE.md` + §2 step 8's discoverability gap. It belongs with the panel-terminal work, + where entry and exit points get designed together. The *opening* keybinding + is **no longer deferred**: Stage 1 carries it as Q#TC10. +- **Genuine immutability for generated buffers — and it is bigger than a Lua + setter.** Today no Lua binding sets `read_only` (`src/lua_bindings` only + reads it, `fold.rs:313`), so every Lua-created "read-only" buffer — listview + panels, `*compilation*`, and this snapshot — is read-only against dispatch + alone and relies entirely on `set_round_trip_input` (Q#TC6a). + + Merely **exposing `set_read_only` would break all three.** The + intercept-bypass path is `ensure_writable`-guarded too: + `apply_edit_skip_intercepts` calls it first (`src/buffer.rs:994`), and that + is exactly the primitive an owner uses to rewrite its own generated buffer. + Flipping the flag would stop listview refreshing, `*compilation*` streaming, + and this snapshot refreshing — the very operations those buffers exist for. + + So the lane needs **two** things, not one: genuine immutability at the + rope/CRDT boundary, *and* an owner-authorized update path that is not simply + "skip the intercepts". Naming only the setter would have made it look like a + one-line follow-up. + +## Acceptance + +### Stage 1 — `terminal-config` + +1. `pmacs.terminal.profiles` accepts a strict spec table per name and rejects + unknown fields before anything is spawned, matching `terminal.open`'s + existing transactional contract. +2. `terminal.default-profile` naming an unknown profile fails at open with an + error that **lists the known profile names**, and creates no buffer, + session, or process. An explicitly passed unknown `profile` fails the same + way **even when `terminal.default-profile` is valid** (Q#TC3a). +3. Field-by-field resolution follows Q#TC3a: explicit open field beats profile + field beats scalar setting beats `$SHELL`. `env` **merges**, with explicit + entries overriding profile entries of the same name. +4. `""` in `terminal.default-profile` means "no profile" and is + indistinguishable from unset (Q#TC2a). +5. `terminal.scrollback-rows` takes effect for a terminal opened without an + explicit `scrollback_rows`; an explicit per-open value overrides it; values + outside `0 ..= 4_000_000` are rejected by the registry rather than by the + core, and `0` is accepted as "retain no history". +6. `terminal.escape-key` changes which chord escapes to the editor, observed + through the **real dispatch path**, not by calling the predicate directly. +7. **Two terminals with different buffer-local escape keys each honor their + own**, with no setting written in between (Q#TC4/Q#TC2b). Driven as + **A→B→A**, asserting both directions. This is the pin an epoch-only cache + fails. +8. Across that same **A→B→A** switch with no setting written, the parse count + does **not** increase after each terminal's first keystroke (Q#TC4c) — + pinned by counting parses, not by timing. This is the pin a single + last-entry cache fails while still satisfying 7. +8a. A terminal's cache does not outlive it: killing a terminal and opening a + new one does not serve the dead terminal's chord, and no per-terminal cache + entry survives its session (Q#TC4c). This is the pin an unpurged + editor-side map fails. +9. With `terminal.escape-key = "C-x"`: `C-x C-x` sends **Ctrl-X** to the child, + and an ordinary `C-c` reaches the child as `0x03` like any other unescaped + key (Q#TC4b). Bite: against the hardcoded `&[0x03]`, the first assertion + fails. +10. An unparseable `terminal.escape-key` falls back to `C-c`, reports through + `EditorCore::status`, and leaves the terminal usable (Q#TC4a). Bite: with + the fallback removed, the terminal becomes unescapable. +10a. "Reports once" is once per terminal per effective invalid value + (Q#TC4c): an **A→B→A** switch with the same invalid value reports **once**, + while changing it to a *different* invalid value reports again. The report + count is asserted, not the message text. +11. The terminal opening keybinding invokes the existing command, and is + verified to have shadowed nothing (Q#TC10). +12. Existing `terminal` invocations and every existing terminal test behave + identically with no settings defined and no profiles registered. + +### Stage 2 — `terminal-copy-mode` + +13. `terminal.copy-mode` produces a read-only buffer whose text is + byte-identical to serializing the full retained range through the existing + copy path (Q#TC7) — pinned against the serializer, so the two cannot drift. +14. Soft wraps, hard rows, wide glyphs, combining clusters, and trailing + default blanks appear in the snapshot exactly as Stage 2 criterion 21 pins + them for selection copy. +15. isearch over the snapshot finds content that is **only in scrollback** + (scrolled off the visible screen), with no change to `src/search.rs` (B1). +16. **Ungated, runs in CI:** focusing the snapshot buffer makes + `dispatch_idle_for` report **false**. This is the whole mechanism Q#TC6a + depends on, it needs no CRDT, and it fails the moment + `set_round_trip_input` is dropped — so the load-bearing regression is + caught by the default configuration rather than only by a `crdt`-gated + test that CI never compiles. +17. **Through a semantic frontend** (this one does need CRDT): keys typed in + the snapshot buffer reach ordinary dispatch and never the child, and + **neither the daemon buffer nor the frontend's mirror is mutated** + (Q#TC6a). Bite: with `set_round_trip_input` removed, the optimistic op is + emitted, bypasses the Lua intercept, passes `ensure_writable()`, and + mutates **both sides** — a buffer the editor calls read-only silently + accepts an edit. +18. Re-invoking against the same terminal refreshes in place; the buffer count + does not grow (Q#TC8). Killing the snapshot leaves the terminal running; + killing the terminal removes the snapshot. +19. `C-t` in a terminal buffer (physically `C-c C-t`) enters copy mode; `g` + refreshes the snapshot from the live terminal and `q` returns to the source + terminal (Q#TC8a). +20. The live terminal's own keys are unchanged while a snapshot exists + (Q#TC9), and the terminal keeps following its tail. +21. The dispatch-shadow count is **unchanged at six** — pinned by asserting + `describe-key` reports the truth for the snapshot buffer's `g` and `q`, + which is the observable difference between the buffer-local idiom and a + shadow. + +## Coherence impact (`COHERENCE.md` §20) + +- **§6 Interaction islands — this arc deliberately adds none.** It is the + first modal-feeling terminal feature that resolves to the buffer-local + keymap idiom §6 identifies as correct, rather than a seventh rung on the + precedence ladder. The shadow count stays at six and `describe-key` stays + truthful (acceptance 21). Worth recording in §6 as a worked example that the + idiom scales to a case that looks modal. +- **§11 Configuration as typed, layered data** — the terminal gains its first + settings, and produces a second blocked adopter for **two** distinct registry + deferrals: the missing table-valued kind (profiles) and the missing + `scope = "global"` flag (the **two open-time settings** — + `terminal.escape-key` deliberately supports buffer-locals, so only + `default-profile` and `scrollback-rows` want an enforcement the registry + cannot express). §11's ground truth should + record both, because the argument for prioritizing them is now cumulative + rather than hypothetical. +- **§2 golden journey, step 8 — partially closed here.** Stage 1 carries the + **terminal opening keybinding** that Priority 1 explicitly names (Q#TC10), + which is the larger half of "works but undiscoverable". Close/kill stays with + the panel work so the entry and exit points are designed together, and is + named in Deferred rather than silently skipped. +- **§5 Unify discovery** — the new commands must carry real descriptions so + M-x rows are useful; no new introspection surface is added. +- No background-work attribution change; no new activity view; no protocol + change. + +## Verification plan + +Full gate suite per `CLAUDE.md` for each PR separately, plus: + +- **The touched terminal suites in BOTH configurations** — default and + `--features crdt` — not only the CRDT one. `vterm_stage1_acceptance`, + `vterm_stage2_acceptance`, and `vterm_stage3_acceptance` all carry tests in + each, and acceptance 12 is a claim about the default configuration too. +- `cargo test --test config_registry_acceptance` for the new settings. +- New suites: `tests/terminal_config_acceptance.rs` (Stage 1) and + `tests/terminal_copy_mode_acceptance.rs` (Stage 2). +- Every behavioral claim bite-verified. The bites that matter most: + **7/8/8a** — three pins that fail against three *different* wrong cache + implementations (epoch-only key, single last-entry, unpurged map), which is + why one pin was not enough; **9** (a hardcoded `0x03` makes the configured + chord unreachable); **10** (its failure mode is a terminal nobody can + escape); and **16/17** (a read-only buffer that silently accepts an edit on + both sides). +- **Do not gate the new suites on `#[cfg(feature = "crdt")]` unless a test + genuinely needs CRDT.** CI never enables that feature, so a suite gated that + way is written and then never run — 264 tests are currently dark for exactly + this reason. That measurement and its lane live on **PR #168**, which is open + and unmerged; it is not yet in `docs/active-work.md` on `main`. + Acceptance 17 does need a semantic frontend, so that one test is gated — but + acceptance 16 pins the same mechanism ungated, so the regression is caught in + CI regardless. That pairing is the pattern to reuse whenever a claim's + end-to-end proof needs CRDT. From cdaea66203aed112a11e3f3b729a2cc212197904 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 18:03:29 -0400 Subject: [PATCH 15/91] fix(lean): make the fallback actually produce a working server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 review, four P1s. All real; the first two mean the fallback did not work at all. **1. The latch swapped the config but never spawned or re-attached.** Nothing re-fires an attach on a config change and `attach_buffer` early-returns for a live attachment, so the buffer stayed bound to the server that had just been stopped. The user got a config edit and no language server. `fire_latch` now rebuilds through a new `pmacs.lsp._attach_buffer` export. Two mechanics had to be right for that rebuild to happen at all: * It is **retried on the tick**, because `pmacs.lsp.stop` leaves the state `shutting-down`, which `server_is_live` counts as LIVE — an inline re-attach early-returns the stale record and the swap is a silent no-op. * The latch **does not stop an already-terminal server**, and this is a substrate bug worked around rather than a style choice. `LspManager::stop` on a `Crashed` client takes its not-initialized branch, terminates the dead process, and sets `ShuttingDown { .. None }` on the premise that "the next exit observation cleans up" — but the exit already happened, which is what made it `Crashed`. No further event arrives, so the client is stuck in `ShuttingDown` forever: `server_is_live` reads it as live so `attach_buffer` never rebuilds, and `forget` refuses it for not being terminal. Stopping a dead server is what makes it un-replaceable. Named in framing §6; the fix belongs in `stop` and changes behavior for every language. **2. A missing `lake` bypassed probe and latch entirely** — the single most likely real failure. `ensure_server` swallows a synchronous ENOENT and returns nil, so there was no attachment, and the hook keyed on `active_attachment()` returned before arming anything. The hook now keys on the buffer's LANGUAGE and treats a Lean buffer with no attachment as the failure itself. **3. `waitForDiagnostics` omitted `version`.** Lean's `WaitForDiagnosticsParams` is `{ uri, version }` (v4.9.0, `src/Lean/Data/Lsp/Extra.lean`); the request is how a client says which revision it wants. It looked correct only because the fake server echoes any payload — so the fake server now validates and returns InvalidParams without it. **4. The ledger stated the dangerous stacking order** in one sentence and the correct rule in the next. Fixed to say BEFORE. A safety rule written twice with opposite senses is worse than not written. Also (P2): the probe/latch suite now drives the production path — `buffer.after-load` -> ticks -> probe drain -> latch -> re-attach — with real executable stubs, and asserts the originally opened buffer ends up on a LIVE server. Round 1's acceptance 36 asserted every server was terminal, i.e. pinned the ABSENCE of the fallback it claimed to test. `M.fallback` is a table so the suite can point it at a working stand-in; the probe now spawns `cfg.command --version` rather than a hardcoded `lake`, which is also more correct for a user who configured a wrapper. `swap_to_fallback`'s `command ~= "lake"` guard is gone: the latch fires only when the configured server actually failed, one visible fallback beats no server, and `probe.latched` is what keeps it to exactly one. Three new bites, all against the committed tree: no re-attach after the swap -> three latch tests fail; hook keyed on the attachment -> the missing-`lake` case fails; `waitForDiagnostics` without `version` -> acc37 fails with the server's InvalidParams. --- builtin/runtime/lean.lua | 163 +++++++++++--- builtin/runtime/lsp.lua | 18 ++ docs/active-work.md | 40 +++- docs/lean4-mode-framing.md | 24 ++- src/bin/pmacs_fake_lsp.rs | 33 +++ tests/lean4_server_acceptance.rs | 350 +++++++++++++++++++++++-------- 6 files changed, 510 insertions(+), 118 deletions(-) diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua index 3fe73da..dd98892 100644 --- a/builtin/runtime/lean.lua +++ b/builtin/runtime/lean.lua @@ -142,6 +142,19 @@ end -- `lake serve` below 3.1.0 starts a server that cannot answer, which is -- worse than failing: `lean4-mode` probes for exactly this and falls -- back to `lean --server`. Parses the leading `x.y` of a version line. +-- State kind for `sid`, or nil if the manager has forgotten it. +local function server_state_kind(sid) + local skey = tostring(sid) + local ok, rows = pcall(pmacs.lsp.list) + if not ok or not rows then return nil end + for _, info in ipairs(rows) do + if tostring(info.id) == skey then + return info.state and info.state.kind + end + end + return nil +end + local function version_below_3_1(text) local major, minor = text:match("(%d+)%.(%d+)") if not major then return false end @@ -150,15 +163,27 @@ local function version_below_3_1(text) return major == 3 and minor < 1 end +-- What the latch falls back TO. A table rather than a literal so the +-- acceptance suite can point it at a stand-in server and drive the real +-- latch path end to end, instead of asserting on a config mutation that +-- proves nothing about whether a server ever starts. +M.fallback = { command = "lean", args = { "--server" } } + -- Swap `command`/`args` ONLY. A wholesale table replacement would -- silently discard a user's `env` / `settings` / `init_options` / `root` -- from `init.lua` at exactly the moment they are least likely to notice. -local function swap_to_lean_server() +-- +-- The only guard is idempotence — already-the-fallback means nothing to +-- do. It deliberately does NOT refuse when the command is user-supplied: +-- the latch fires only when the configured Lean server actually failed +-- to start, and one visible fallback attempt beats leaving the user with +-- no server at all. `probe.latched` is what keeps it to exactly one. +local function swap_to_fallback() local cfg = pmacs.lsp.config.lean4 if not cfg then return false end - if cfg.command ~= "lake" then return false end - cfg.command = "lean" - cfg.args = { "--server" } + if cfg.command == M.fallback.command then return false end + cfg.command = M.fallback.command + cfg.args = M.fallback.args return true end @@ -172,16 +197,66 @@ end -- producing a loop the latch cannot see the end of. `pmacs.lsp.stop` -- sets `restart = Never` on the way out, which is what disarms it. The -- fallback is therefore a FRESH server, not a restart of the old one. +local try_reattach + local function fire_latch(sid, why) if probe.latched then return end probe.latched = true - if sid then pcall(pmacs.lsp.stop, sid) end - if swap_to_lean_server() then - report("LSP: lean4 " .. why .. "; falling back to `lean --server`") - else - report("LSP: lean4 " .. why) - end probe.watching = nil + -- **Only stop a server that is not ALREADY terminal**, and this is + -- load-bearing rather than tidy. `LspManager::stop` on a crashed + -- client takes its not-initialized branch: it terminates the + -- (already-dead) process and sets `ShuttingDown { .. None }`, with the + -- comment "the next exit observation cleans up" — but the exit was + -- already observed, which is what made it `Crashed`. No further event + -- arrives, so the client stays in `ShuttingDown` forever: + -- `server_is_live` counts it as LIVE (neither crashed nor stopped) so + -- `attach_buffer` never rebuilds, and `LspManager::forget` refuses it + -- for not being terminal. Stopping a dead server is what makes it + -- un-replaceable. Recorded as a substrate deferral in the framing §6. + if sid then + local kind = server_state_kind(sid) + if kind and kind ~= "crashed" and kind ~= "stopped" then + pcall(pmacs.lsp.stop, sid) + end + end + if not swap_to_fallback() then + report("LSP: lean4 " .. why) + return + end + report("LSP: lean4 " .. why .. "; falling back to `" + .. tostring(M.fallback.command) .. "`") + -- **Spawn the replacement and re-point the buffer at it.** Stopping + -- and rewriting the config is not a fallback on its own: nothing + -- re-fires an attach on a config change, and `attach_buffer` + -- early-returns for a live attachment, so without this the buffer + -- stays bound to the server we just stopped and the user is left with + -- a config edit and no language server. Round 1 shipped exactly that, + -- with an acceptance test that asserted every server was terminal — + -- i.e. that pinned the absence of the fallback it claimed to check. + -- + -- **Retried on the tick, not done inline**, and that is not caution: + -- `pmacs.lsp.stop` sends shutdown+exit and the state becomes + -- `shutting-down`, which `server_is_live` counts as LIVE. So an + -- immediate `_attach_buffer` early-returns the stale record and the + -- swap has no effect — the exact silent no-op this whole path exists + -- to avoid. Retrying until the old server actually reaches a terminal + -- state is what makes the rebuild happen. + probe.reattach_from = sid and tostring(sid) or false + try_reattach() +end + +-- Returns true once the active Lean buffer is attached to a server that +-- is not the one the latch stopped. +function try_reattach() + if probe.reattach_from == nil then return true end + local ok, rec = pcall(pmacs.lsp._attach_buffer) + if not ok or not rec then return false end + if probe.reattach_from and tostring(rec.server) == probe.reattach_from then + return false + end + probe.reattach_from = nil + return true end local function drain_probe() @@ -220,13 +295,17 @@ local function start_probe(root) if probe.started then return end probe.started = true local cfg = pmacs.lsp.config.lean4 - if not cfg or cfg.command ~= "lake" then return end + if not cfg or not cfg.command then return end + -- Probe the binary we would actually run, not the literal string + -- "lake": a user pointing `command` at a wrapper or an absolute path + -- should have THAT probed, and a hardcoded name would silently probe + -- something else (or nothing). local spec = { -- COHERENCE §9: `ProcessSpec.label` is the only identity a process -- carries, and it is what `pmacs.process.list` renders. A user -- wondering why their editor touched `lake` finds an owner here. label = "lean:lake-version-probe", - command = "lake", + command = cfg.command, args = { "--version" }, stdin = "null", } @@ -275,12 +354,20 @@ end -- does not apply. Resolves when the server has finished elaborating. -- Awaited through Stage 3a's response seam. -- +-- **`version` is required, not optional.** Lean's +-- `WaitForDiagnosticsParams` is `{ uri, version }` (v4.9.0, +-- `src/Lean/Data/Lsp/Extra.lean`), and the request is how the client +-- says *which* revision of the document it wants elaboration for. +-- Sending only `uri` is a malformed request against a real server; it +-- happened to look fine here because the fake server echoes any +-- payload. Callers pass the attachment's current `version`. +-- -- `fn(err)` is called with nil on success. Registering the one-shot -- requires the server to have an attached buffer — see the note on -- `pmacs.lsp.on_response`; every caller here comes from an attachment. -function M.wait_for_diagnostics(sid, uri, fn) +function M.wait_for_diagnostics(sid, uri, version, fn) local ok, rid = pcall(pmacs.lsp.send_request, sid, - "textDocument/waitForDiagnostics", { uri = uri }) + "textDocument/waitForDiagnostics", { uri = uri, version = version }) if not ok then if fn then pcall(fn, tostring(rid)) end return nil @@ -303,7 +390,7 @@ pmacs.command.define { return end pmacs.editor.set_status("lean: elaborating…") - M.wait_for_diagnostics(rec.server, rec.uri, function(err) + M.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err) if err then pmacs.editor.set_status("lean: " .. tostring(err)) else @@ -327,27 +414,53 @@ end) -- Wiring -------------------------------------------------------------- --- Runs after `lsp.lua`'s own `buffer.after-load` subscription, so the --- attachment already exists. The attachment's `language` IS the Lean --- test — no separate major-mode lookup, which would be a second source --- of truth for the same question. +-- Runs after `lsp.lua`'s own `buffer.after-load` subscription. +-- +-- **Keyed on the buffer's LANGUAGE, not on an attachment existing.** +-- Round 1 keyed on `active_attachment()` and returned early when it was +-- nil — which silently excluded the single most likely real-world +-- failure: `lake` not installed. `ensure_server` pcalls the spawn and +-- returns nil on ENOENT, so `attach_buffer` produces no record at all, +-- so the probe never started and the latch never armed. The case the +-- fallback exists for was the one case it could not see. pmacs.hook.add("buffer.after-load", function() - local rec = pmacs.lsp.active_attachment() - if not rec or rec.language ~= "lean4" then return end + local buf = pmacs.window.buffer() + if not buf then return end + local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf) + if not ok_lang or lang ~= "lean4" then return end + if not probe.started then local path = pmacs.editor.file_path() start_probe(path and M.root_for(path) or nil) end - -- Watch only the FIRST Lean server: the latch is per session. - if not probe.latched and not probe.saw_initialized - and probe.watching == nil then - probe.watching = rec.server + + local rec = pmacs.lsp.active_attachment() + if rec and rec.language == "lean4" then + -- Watch only the FIRST Lean server: the latch is per session. + if not probe.latched and not probe.saw_initialized + and probe.watching == nil then + probe.watching = rec.server + end + return + end + + -- No attachment for a Lean buffer means `ensure_server` could not + -- spawn at all — a synchronous ENOENT, already swallowed upstream. + -- That is not something to wait for; it is the failure itself, and + -- the only place it is still observable. + if not probe.latched then + fire_latch(nil, "`" .. tostring( + pmacs.lsp.config.lean4 and pmacs.lsp.config.lean4.command) + .. "` could not be started") end end) pmacs.hook.add("process.after-tick", function() drain_probe() poll_latch() + -- Keep trying until the stopped server is really gone; see the note in + -- `fire_latch`. + if probe.reattach_from ~= nil then try_reattach() end end) -- Test seam: acceptance drives the latch deterministically rather than diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index fd44f1a..0749cfa 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -893,6 +893,24 @@ function pmacs.lsp.active_attachment() return attachments[tostring(buf)] end +-- Re-run the attach for the ACTIVE buffer, rebuilding it against the +-- current `pmacs.lsp.config`. +-- +-- Exists for the Arc 8 Stage 3b fallback latch (Q#LN7): after that latch +-- stops a server that failed to start and rewrites `config.lean4`, +-- something has to actually spawn the replacement and re-point the +-- buffer at it. Nothing else does — `attach_buffer` early-returns for a +-- live attachment, and no hook re-fires on a config change, so without +-- this the buffer stays bound to the stopped server and the "fallback" +-- is a config edit with no effect. +-- +-- Deliberately keyed on the active buffer, matching `attach_buffer`'s +-- own use of `active_buffer_path()`; it is not a general re-attach for +-- arbitrary buffers and must not be used as one. +function pmacs.lsp._attach_buffer() + return attach_buffer(pmacs.window.buffer()) +end + -- Arc 4 stage 3: pure modeline projection. This reads the private -- per-buffer attachment map directly so passive split windows report their -- own buffer instead of the focused window. It never attaches, flushes diff --git a/docs/active-work.md b/docs/active-work.md index 99b54ea..1a1e7ae 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -256,12 +256,17 @@ If it does not, stop and repair the remote/fetch configuration. - Same worktree `../pmacs-lean-stage3`, **branched off `lean4-stage3a-seams`, not off `main`** — 3b consumes 3a's response - seam and `pmacs.fs.canonicalize`, so it is strictly sequential and its - PR must be retargeted to `main` only after #167 merges. (Kill-ring - lesson: retarget stacked child PRs BEFORE merging the parent.) + seam and `pmacs.fs.canonicalize`, so it is strictly sequential. + **Retarget PR #170 to `main` BEFORE merging #167, not after** — the + kill-ring lesson exactly. (Round 1 of this ledger entry stated the + reverse in its first sentence and the correct rule in the next; the + review caught it. A safety rule written twice with opposite senses is + worse than not written.) - Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in - `src/editor.rs`, a `leanprogress` mode on `pmacs_fake_lsp`, and - `tests/lean4_server_acceptance.rs` (17 tests). No protocol change. + `src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`, + a `leanprogress` mode plus `waitForDiagnostics` validation on + `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (20 tests). + No protocol change. - **Stage 1's acceptance 12 is half superseded and was rewritten, not deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a Stage-3 front-run; 3b is that stage. What survives is the restraint @@ -274,10 +279,29 @@ If it does not, stop and repair the remote/fetch configuration. an EMPTY `lean-toolchain` (a legitimate marker — existence semantics, not content). Discriminator is `read`'s SECOND return; decline only on a non-nil err. Probed on LuaJIT 2.1. -- Four bites recorded, each against the committed tree: bare `io.open` +- Seven bites recorded, each against the committed tree: bare `io.open` → 24a fails / 24b passes; require-non-nil → 24b fails / 24a passes; - no canonicalization → symlinked open spawns two servers; no stop - before fallback → acc36 fails. + no canonicalization → symlinked open spawns two servers; no re-attach + after the swap → three latch tests fail; hook keyed on the attachment + → the missing-`lake` case fails; `waitForDiagnostics` without + `version` → acc37 fails with the server's InvalidParams. +- **SUBSTRATE BUG FOUND, not fixed here (framing §6).** + `LspManager::stop` on an ALREADY-terminal server takes its + not-initialized branch, terminates the dead process and sets + `ShuttingDown { .. None }` on the premise that "the next exit + observation cleans up" — but the exit already happened, which is what + made it `Crashed`. No further event arrives, so the client is stuck in + `ShuttingDown` **forever**: `server_is_live` reads it as LIVE, so + `attach_buffer` never rebuilds, and `forget` refuses it for not being + terminal. **Stopping a dead server is what makes it un-replaceable.** + Lean works around it by checking the state before stopping. +- Round-1 review found four P1s, all real: the latch swapped the config + but never spawned or re-attached (and acc36 *asserted every server was + terminal*, pinning the absence of the fallback); a missing `lake` + bypassed probe and latch entirely because the hook keyed on an + attachment that ENOENT prevents; `waitForDiagnostics` omitted the + `version` Lean requires; and the ledger stated the dangerous stacking + order. - The probe's non-zero exit is deliberately NOT a fallback trigger — §2.9's elan shim makes `lake --version` fail where `lake serve` still works. Only a parseable version below 3.1.0 triggers it; the diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index 4869252..fac090b 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -1543,6 +1543,20 @@ What remains deferred: which events may be dropped, which is a policy question with user-visible consequences for diagnostics and progress; Stage 3a states the seam's contract around the behavior rather than changing it. +- **`LspManager::stop` on an already-terminal server strands it.** The + not-initialized branch terminates the (already-dead) process and sets + `ShuttingDown { shutdown_request_id: None }` on the premise that "the + next exit observation cleans up" — but for a `Crashed` client the exit + has already been observed, which is what produced that state. No + further event arrives, so the client sits in `ShuttingDown` + permanently: `server_is_live` counts it as live (neither crashed nor + stopped), so `attach_buffer` never rebuilds against it, and + `LspManager::forget` refuses it for not being terminal. **Stopping a + dead server is what makes it un-replaceable.** Found implementing + Stage 3b's latch, which works around it by checking the state before + stopping. The fix belongs in `stop` (treat an already-terminal client + as a no-op, or drive it straight to `Stopped`) and changes behavior + for every language, so it does not ride a Lean PR. - **Forwarding `cfg.restart` through `ensure_server`** — read by `lua_to_lsp_spec`, never set by the spawn table, so silently dropped on every auto-attach (found landing #161). Fixing it changes behavior for @@ -1722,9 +1736,13 @@ the blast radius. channel a user can actually observe; a report added through `pmacs.error` alone must fail this. - **37.** `textDocument/waitForDiagnostics` resolves through the response seam - (Q#LN16). **PATH-and-success-gated live smoke:** if `lake serve` - starts successfully a real elaboration completes and diagnostics - arrive; skipped otherwise, never failed. + (Q#LN16), **carrying both `uri` and `version`** — Lean's + `WaitForDiagnosticsParams` requires the document version, and a fake + server that echoes any payload will hide its absence, so the fixture + must reject a request that omits it. + **PATH-and-success-gated live smoke:** if `lake serve` starts + successfully a real elaboration completes and diagnostics arrive; + skipped otherwise, never failed. These two sections are bulleted with explicit labels rather than numbered, because the split leaves each stage's criteria non-contiguous diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs index 5f4fab5..67d6620 100644 --- a/src/bin/pmacs_fake_lsp.rs +++ b/src/bin/pmacs_fake_lsp.rs @@ -1083,6 +1083,39 @@ fn main() { }); write_frame(&mut stdout, &resp); } + ("textDocument/waitForDiagnostics", Some(idv)) => { + // Arc 8 Stage 3b: Lean's `WaitForDiagnosticsParams` is + // `{ uri, version }` (v4.9.0 + // `src/Lean/Data/Lsp/Extra.lean`). Validated here rather + // than echoed, because the generic echo arm below + // accepts anything — which is exactly how a client + // sending only `uri` shipped looking correct. A client + // that omits `version`, or sends a non-integer, gets an + // InvalidParams error the way a real server would. + let uri_ok = params + .get("uri") + .and_then(serde_json::Value::as_str) + .is_some(); + let version_ok = params + .get("version") + .and_then(serde_json::Value::as_i64) + .is_some(); + let resp = if uri_ok && version_ok { + serde_json::json!({ + "jsonrpc": "2.0", "id": idv, "result": serde_json::Value::Null + }) + } else { + serde_json::json!({ + "jsonrpc": "2.0", + "id": idv, + "error": { + "code": -32602, + "message": "waitForDiagnostics requires { uri, version }" + } + }) + }; + write_frame(&mut stdout, &resp); + } (_, Some(idv)) => { // Generic echo response. let resp = serde_json::json!({ diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs index 13cfcd0..6fe5ed4 100644 --- a/tests/lean4_server_acceptance.rs +++ b/tests/lean4_server_acceptance.rs @@ -347,12 +347,85 @@ fn acc26_did_open_carries_the_lean4_language_id() { } // --------------------------------------------------------------------------- -// Acceptance 28 (probe) — the version predicate. +// Acceptance 27 / 28 / 35 / 36 — the probe and the fallback latch. // -// The parse is unit-tested directly because the spawn path is timing- -// bound; the latch's *effect* is pinned separately below. +// **Driven through the production path**, not by calling internals. +// Round 1's versions poked `_fire_latch` directly and asserted on config +// mutation, which proved nothing about whether a server ever starts — +// and acceptance 36 went further and asserted every server was terminal, +// pinning the ABSENCE of the fallback it claimed to test. These go +// `buffer.after-load` -> ticks -> probe drain -> latch -> re-attach, and +// assert the originally opened buffer ends up on a LIVE server. +// +// The stubs are real executables the fixture writes. `M.fallback` is a +// table precisely so it can point at `pmacs_fake_lsp` here. // --------------------------------------------------------------------------- +impl Fixture { + /// An executable shell stub. `serve` sleeps (so the "server" does not + /// die and only the named failure mode is under test); `--version` + /// prints `version_line`. + fn lake_stub(&self, rel: &str, version_line: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt as _; + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo '{version_line}'\n exit 0\nfi\nexec sleep 300\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } +} + +/// Point `command` at `lake_cmd` and the latch's fallback at the fake +/// LSP server, so a fallback that fires produces a server that works. +fn with_fallback(state: &EditorState, lake_cmd: &Path) { + exec( + state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean.fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(lake_cmd), + fake_lsp_path() + ), + ); +} + +/// The active buffer's attached server id, or "none". +fn attached_sid(state: &EditorState) -> String { + eval( + state, + r#" + local rec = pmacs.lsp.active_attachment() + return rec and tostring(rec.server) or "none" + "#, + ) +} + +/// State kind of the active buffer's attached server, or "none". +fn attached_state(state: &EditorState) -> String { + eval( + state, + r#" + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.state and s.state.kind) + end + end + return "gone" + "#, + ) +} + #[test] fn acc28_version_predicate_triggers_only_below_3_1() { let fx = Fixture::new(); @@ -374,36 +447,156 @@ fn acc28_version_predicate_triggers_only_below_3_1() { ); } -// --------------------------------------------------------------------------- -// Acceptance 27 / 35 / 36 — the fallback latch. -// --------------------------------------------------------------------------- +#[test] +fn acc28_an_old_lake_falls_back_and_the_buffer_lands_on_a_live_server() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let old_lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &old_lake); + + open(&state, &file); + settle(&mut state); + // The stub's `serve` sleeps rather than dying, so ONLY the probe can + // have caused a fallback here. That isolation is the point. + for _ in 0..40 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + if attached_state(&state) == "initialized" { + break; + } + } + + assert_eq!( + attached_state(&state), + "initialized", + "an old lake must leave the buffer on a LIVE fallback server, not \ + merely rewrite the config" + ); + let cmd: String = eval(&state, "return pmacs.lsp.config.lean4.command"); + assert_eq!(cmd, fake_lsp_path(), "the fallback command is in effect"); +} + +#[test] +fn acc28_a_current_lake_does_not_trigger_the_fallback() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let new_lake = fx.lake_stub("bin/lake", "Lake version 3.1.0"); + let mut state = editor(&fx); + with_fallback(&state, &new_lake); + + open(&state, &file); + for _ in 0..20 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + } + + // Non-vacuity against the test above: same harness, same stub shape, + // only the version differs — so a latch that fired unconditionally + // would be caught here. + let cmd: String = eval(&state, "return pmacs.lsp.config.lean4.command"); + assert_eq!( + cmd, + new_lake.display().to_string(), + "a current lake keeps its command; the probe must not fall back" + ); + let latched: bool = eval(&state, "return pmacs.lean._probe.latched"); + assert!(!latched, "the latch did not arm"); +} + +#[test] +fn acc27_a_missing_lake_falls_back_and_the_buffer_lands_on_a_live_server() { + // The case round 1 could not see at all: `ensure_server` swallows a + // synchronous ENOENT and returns nil, so there is no attachment to + // key off. This is also the most likely real-world failure — a user + // with `lean` but no `lake`. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + + open(&state, &file); + for _ in 0..40 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + if attached_state(&state) == "initialized" { + break; + } + } + + assert_eq!( + attached_state(&state), + "initialized", + "a missing `lake` must fall back to a live server and re-attach \ + the buffer that was already open" + ); + let status = state.core.borrow().status.clone(); + assert!( + status.contains("lean4"), + "and it says so on the status line; saw {status:?}" + ); +} + +#[test] +fn acc27_the_latch_is_one_shot_and_does_not_re_arm() { + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + + open(&state, &file); + settle(&mut state); + let after_first: String = eval(&state, "return pmacs.lsp.config.lean4.command"); + assert_eq!(after_first, fake_lsp_path(), "the fallback fired once"); + + // A user who deliberately sets something else after the fallback must + // not have it silently replaced by a second firing. + exec(&state, "pmacs.lsp.config.lean4.command = \"user-choice\""); + exec(&state, "pmacs.lean._fire_latch(nil, \"a second failure\")"); + assert_eq!( + eval::(&state, "return pmacs.lsp.config.lean4.command"), + "user-choice", + "the latch never re-arms within a session" + ); +} #[test] fn acc35_latch_preserves_user_config_and_swaps_only_command_and_args() { let fx = Fixture::new(); - let state = editor(&fx); - // A user's init.lua settings, on the shipped shape. + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); exec( &state, - r#" - pmacs.lsp.config.lean4.command = "lake" - pmacs.lsp.config.lean4.args = { "serve" } - pmacs.lsp.config.lean4.env = { MYVAR = "1" } + r" pmacs.lsp.config.lean4.settings = { lean = { verbose = true } } pmacs.lsp.config.lean4.init_options = { hasWidgets = false } _G.root_before = pmacs.lsp.config.lean4.root - pmacs.lean._fire_latch(nil, "test") - "#, + ", ); + open(&state, &file); + settle(&mut state); + let after: String = eval( &state, r#" local c = pmacs.lsp.config.lean4 return table.concat({ - tostring(c.command), - tostring(c.args and c.args[1]), - tostring(c.env and c.env.MYVAR), tostring(c.settings and c.settings.lean and c.settings.lean.verbose), tostring(c.init_options and c.init_options.hasWidgets), tostring(c.root == _G.root_before), @@ -411,78 +604,70 @@ fn acc35_latch_preserves_user_config_and_swaps_only_command_and_args() { "#, ); assert_eq!( - after, "lean|--server|1|true|false|true", - "only command/args change; env, settings, init_options and root \ - survive the swap" - ); -} - -#[test] -fn acc27_the_latch_is_one_shot_and_does_not_re_arm() { - let fx = Fixture::new(); - let state = editor(&fx); - exec( - &state, - r#" - pmacs.lsp.config.lean4.command = "lake" - pmacs.lsp.config.lean4.args = { "serve" } - pmacs.lean._fire_latch(nil, "first failure") - _G.after_first = pmacs.lsp.config.lean4.command - -- A second failure must not rewrite the command again; if it did, - -- a user who deliberately set something else after the fallback - -- would have it silently replaced. - pmacs.lsp.config.lean4.command = "user-choice" - pmacs.lean._fire_latch(nil, "second failure") - _G.after_second = pmacs.lsp.config.lean4.command - "#, - ); - assert_eq!(eval::(&state, "return _G.after_first"), "lean"); - assert_eq!( - eval::(&state, "return _G.after_second"), - "user-choice", - "the latch never re-arms within a session" + after, "true|false|true", + "settings, init_options and root survive the swap; only \ + command/args change" ); } #[test] fn acc36_latch_stops_the_failing_server_before_spawning_the_fallback() { + // A stub whose `serve` exits immediately: the server dies before + // `initialize` completes, which is the failure the latch polls for. + // `RestartPolicy::OnCrash` would otherwise respawn it forever + // underneath the latch, with no attempt ceiling. + use std::os::unix::fs::PermissionsExt as _; let fx = Fixture::new(); fx.toolchain("pkg", "v4.9.0\n"); let file = fx.write("pkg/A.lean", "def a := 1\n"); + let dying = fx.root.join("bin/dying-lake"); + std::fs::create_dir_all(dying.parent().unwrap()).unwrap(); + std::fs::write( + &dying, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'Lake version 9.9.9'; exit 0; fi\nexit 3\n", + ) + .unwrap(); + std::fs::set_permissions(&dying, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut state = editor(&fx); + with_fallback(&state, &dying); open(&state, &file); - settle(&mut state); - assert_eq!(rows(&state).len(), 1, "precondition: one server is up"); + for _ in 0..60 { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + if attached_state(&state) == "initialized" { + break; + } + } - // Fire the latch against the live server, exactly as `poll_latch` - // would. `pmacs.lsp.stop` sets `restart = Never` on the way out — - // which is what prevents `RestartPolicy::OnCrash` from respawning the - // broken command underneath the latch, forever, with no attempt cap. - exec( - &state, - r#" - pmacs.lsp.config.lean4.command = "lake" - pmacs.lsp.config.lean4.args = { "serve" } - pmacs.lean._fire_latch(pmacs.lsp.list()[1].id, "failed to start") - "#, + // The load-bearing assertion: the buffer ends up on a LIVE server. + assert_eq!( + attached_state(&state), + "initialized", + "the failing server is stopped and the buffer re-attached to the \ + fallback — not left terminal" ); - settle(&mut state); - - let terminal: bool = eval( + // And the dead one really is stopped, so nothing is respawning it. + let dying_still_running: bool = eval( &state, r#" + local live = tostring(pmacs.lsp.active_attachment().server) for _, s in ipairs(pmacs.lsp.list()) do - local k = s.state and s.state.kind - if k ~= "stopped" and k ~= "crashed" then return false end + if tostring(s.id) ~= live then + local k = s.state and s.state.kind + if k ~= "stopped" and k ~= "crashed" then return true end + end end - return true + return false "#, ); assert!( - terminal, - "the failing server is stopped, not left to be respawned under \ - the latch" + !dying_still_running, + "the failing server is not respawning underneath the latch" ); + assert_ne!(attached_sid(&state), "none"); } // --------------------------------------------------------------------------- @@ -492,23 +677,24 @@ fn acc36_latch_stops_the_failing_server_before_spawning_the_fallback() { #[test] fn acc36a_latch_leaves_a_status_line_trace() { let fx = Fixture::new(); - let state = editor(&fx); - exec( - &state, - r#" - pmacs.lsp.config.lean4.command = "lake" - pmacs.lsp.config.lean4.args = { "serve" } - pmacs.lean._fire_latch(nil, "`lake serve` failed to start") - "#, - ); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + + open(&state, &file); + settle(&mut state); + let status = state.core.borrow().status.clone(); assert!( - status.contains("lean4") && status.contains("lean --server"), - "the fallback names itself and what it fell back to; saw {status:?}" + status.contains("lean4") && status.contains("falling back"), + "the fallback names the language and says it fell back; saw {status:?}" ); // The channel assertion is the point (COHERENCE §1.2): a report made // only through `pmacs.error` — undefined in production — would leave - // this empty while every other assertion here still passed. + // this empty while the fallback itself still worked, so the user + // would silently be on a different server than they configured. assert!(!status.is_empty()); } @@ -550,7 +736,7 @@ fn acc37_wait_for_diagnostics_resolves_through_the_response_seam() { r#" _G.settled = "never" local rec = pmacs.lsp.active_attachment() - pmacs.lean.wait_for_diagnostics(rec.server, rec.uri, function(err) + pmacs.lean.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err) _G.settled = tostring(err) end) "#, From 3377db070ae59232c3caca706d550629b11daaac Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 18:35:11 -0400 Subject: [PATCH 16/91] fix(lean): correct the server lifecycle; round 2 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three P1 lifecycle defects and two P2s. The focused suite was 20/20 with every one of them live, which is the part worth keeping. **1. The crashed primary respawned forever underneath the fallback.** Round 2 skipped the retire call for terminal servers to avoid corrupting them — but the crash had already armed `next_restart_at`, and `maybe_restart` fires on every elapsed backoff with no attempt ceiling. The broken command kept respawning under the live fallback. The right call depends on the state, and each is wrong for the other: `forget` REQUIRES a terminal state and removes the client outright, which also drops the restart timer; `stop` is for a live one and corrupts a terminal one (its not-initialized branch parks it in `ShuttingDown` forever). `retire_server` now dispatches on state. **2. Re-attachment targeted whatever buffer was active when the asynchronous verdict landed.** `_attach_buffer` is an active-buffer-only seam, and "some attachment now names a different server" is satisfied by an unrelated Rust buffer — clearing the retry and leaving the Lean buffer stale forever. The initiating buffer is now captured and the retry waits for it. **3. A failing fallback retried every tick forever, silently**, contradicting acceptance 27's promise that a second failure surfaces. "Waiting for the old server to go" and "attempting the replacement" are now separate: once the old one is terminal or gone, the replacement is attempted EXACTLY once, and a spawn failure is reported. **4. The Lake version parser was being applied to arbitrary wrappers.** `version_below_3_1` encodes lake's output contract; a working `my-lean-wrapper` reporting "wrapper 1.0" would have been replaced despite its server initializing fine. The version probe is now gated on the command's basename being `lake`. The FAILURE latch stays command-agnostic — that one keys on the server actually not starting, which is true of any command. **5. An unconfigured Lean server was reported as a failure** and latched, poisoning the session so a later configuration could never take effect. Absent config or command now means disabled; only a configured command that produced no attachment is a failure. **6. The ledger recorded pre-fix counts** after the fixes were pushed. Now 25/25 and 3,214. That is the #161 fmt-blocker error in a slower form: verification must describe the pushed tree. Sign-offs requested in review: `M.fallback` is now `M._fallback`, an underscored test seam, and its idempotence check compares args as well as command — the same command with different arguments is not "already applied". Dropping the `command ~= "lake"` guard stands for the failure latch only. Five regression tests added, and **three of them were too weak on first write; only bite-testing found it**: * asserting "no live non-fallback server" misses a respawn loop, because a respawning server sits in `crashed` most of the time — `attempt` is the observable that counts respawns; * returning to a buffer with `find_or_open` re-fires `buffer.after-load`, which repairs the attachment regardless of the code under test — `switch_buffer` is the honest return; * a MISSING command fails synchronously inside `after-load` where the rebuild happens inline, so the async race cannot occur — only the probe path exercises it. Each of the five now fails against the exact round-2 mutation it targets. --- builtin/runtime/lean.lua | 216 ++++++++++++++++++--------- docs/active-work.md | 12 +- docs/lean4-mode-framing.md | 12 +- tests/lean4_server_acceptance.rs | 247 ++++++++++++++++++++++++++++++- 4 files changed, 406 insertions(+), 81 deletions(-) diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua index dd98892..5f74792 100644 --- a/builtin/runtime/lean.lua +++ b/builtin/runtime/lean.lua @@ -125,7 +125,8 @@ local probe = { started = false, -- the `lake --version` probe has been spawned latched = false, -- the fallback has fired (or been ruled out) proc = nil, -- process id of the running probe - buf = "", -- accumulated probe stdout + out = "", -- accumulated probe stdout + buf_key = nil, -- tostring() of the buffer that started this watching = nil, -- sid we are waiting to see fail before initialize saw_initialized = false, } @@ -142,9 +143,9 @@ end -- `lake serve` below 3.1.0 starts a server that cannot answer, which is -- worse than failing: `lean4-mode` probes for exactly this and falls -- back to `lean --server`. Parses the leading `x.y` of a version line. --- State kind for `sid`, or nil if the manager has forgotten it. -local function server_state_kind(sid) - local skey = tostring(sid) +-- State kind for the server whose `tostring(id)` is `skey`, or nil if +-- the manager has forgotten it (which is itself a terminal answer). +local function server_state_kind_for_key(skey) local ok, rows = pcall(pmacs.lsp.list) if not ok or not rows then return nil end for _, info in ipairs(rows) do @@ -155,6 +156,10 @@ local function server_state_kind(sid) return nil end +local function server_state_kind(sid) + return server_state_kind_for_key(tostring(sid)) +end + local function version_below_3_1(text) local major, minor = text:match("(%d+)%.(%d+)") if not major then return false end @@ -163,11 +168,25 @@ local function version_below_3_1(text) return major == 3 and minor < 1 end --- What the latch falls back TO. A table rather than a literal so the --- acceptance suite can point it at a stand-in server and drive the real --- latch path end to end, instead of asserting on a config mutation that --- proves nothing about whether a server ever starts. -M.fallback = { command = "lean", args = { "--server" } } +-- What the latch falls back TO. +-- +-- **Underscored: a test seam, not supported user configuration.** It is +-- a table only so the acceptance suite can point it at a stand-in server +-- and drive the real latch path end to end, instead of asserting on a +-- config mutation that proves nothing about whether a server ever +-- starts. Presenting it as public config would owe framing, +-- documentation, validation and mutation semantics that nothing here +-- provides; users configure Lean through `pmacs.lsp.config.lean4`. +M._fallback = { command = "lean", args = { "--server" } } + +local function same_args(a, b) + a, b = a or {}, b or {} + if #a ~= #b then return false end + for i = 1, #a do + if a[i] ~= b[i] then return false end + end + return true +end -- Swap `command`/`args` ONLY. A wholesale table replacement would -- silently discard a user's `env` / `settings` / `init_options` / `root` @@ -181,81 +200,111 @@ M.fallback = { command = "lean", args = { "--server" } } local function swap_to_fallback() local cfg = pmacs.lsp.config.lean4 if not cfg then return false end - if cfg.command == M.fallback.command then return false end - cfg.command = M.fallback.command - cfg.args = M.fallback.args + -- Idempotence compares command AND args: the same command with + -- different arguments is not "already applied", and treating it as + -- such would silently skip a swap that still needed to happen. + if cfg.command == M._fallback.command + and same_args(cfg.args, M._fallback.args) then + return false + end + cfg.command = M._fallback.command + cfg.args = M._fallback.args return true end --- Fire the fallback: stop the failing server FIRST, then swap, then let --- the next attach spawn afresh. --- --- Stopping first is load-bearing, not defensive. The spec default is --- `LspRestartPolicy::OnCrash`, the termination handler never consults --- the exit code, and `maybe_restart` has no attempt ceiling — so a --- broken `lake` respawns forever on a backoff, underneath the latch, --- producing a loop the latch cannot see the end of. `pmacs.lsp.stop` --- sets `restart = Never` on the way out, which is what disarms it. The --- fallback is therefore a FRESH server, not a restart of the old one. +-- Retire the failed server, swap the command, then rebuild the +-- attachment on the buffer that started this. local try_reattach +-- Retire `sid` so it cannot come back. **Which call to use depends on +-- the state, and using the wrong one is worse than doing nothing:** +-- +-- * TERMINAL (`crashed` / `stopped`) -> `forget`. It requires a +-- terminal state and removes the client outright, which also drops +-- the `next_restart_at` the crash scheduled. `stop` here would take +-- its not-initialized branch and set `ShuttingDown { .. None }` on +-- the premise that "the next exit observation cleans up" — but the +-- exit already happened, which is what made it `Crashed`. No +-- further event arrives, so it sits in `ShuttingDown` forever: +-- `server_is_live` reads that as LIVE so `attach_buffer` never +-- rebuilds, and `forget` then refuses it for not being terminal. +-- * NON-TERMINAL -> `stop`. `forget` rejects it, and `stop` disables +-- restart and drives the polite shutdown. +-- +-- Round 1 skipped the call entirely for terminal servers. That avoided +-- the corruption but left `next_restart_at` armed, so the crashed +-- primary respawned 500ms later and kept respawning underneath the +-- live fallback — invisible to a test that stopped ticking first. +local function retire_server(sid) + local kind = server_state_kind(sid) + if kind == nil then return end + if kind == "crashed" or kind == "stopped" then + pcall(pmacs.lsp.forget, sid) + else + pcall(pmacs.lsp.stop, sid) + end +end + local function fire_latch(sid, why) if probe.latched then return end probe.latched = true probe.watching = nil - -- **Only stop a server that is not ALREADY terminal**, and this is - -- load-bearing rather than tidy. `LspManager::stop` on a crashed - -- client takes its not-initialized branch: it terminates the - -- (already-dead) process and sets `ShuttingDown { .. None }`, with the - -- comment "the next exit observation cleans up" — but the exit was - -- already observed, which is what made it `Crashed`. No further event - -- arrives, so the client stays in `ShuttingDown` forever: - -- `server_is_live` counts it as LIVE (neither crashed nor stopped) so - -- `attach_buffer` never rebuilds, and `LspManager::forget` refuses it - -- for not being terminal. Stopping a dead server is what makes it - -- un-replaceable. Recorded as a substrate deferral in the framing §6. - if sid then - local kind = server_state_kind(sid) - if kind and kind ~= "crashed" and kind ~= "stopped" then - pcall(pmacs.lsp.stop, sid) - end - end + if sid then retire_server(sid) end if not swap_to_fallback() then report("LSP: lean4 " .. why) return end report("LSP: lean4 " .. why .. "; falling back to `" - .. tostring(M.fallback.command) .. "`") - -- **Spawn the replacement and re-point the buffer at it.** Stopping - -- and rewriting the config is not a fallback on its own: nothing - -- re-fires an attach on a config change, and `attach_buffer` - -- early-returns for a live attachment, so without this the buffer - -- stays bound to the server we just stopped and the user is left with - -- a config edit and no language server. Round 1 shipped exactly that, - -- with an acceptance test that asserted every server was terminal — - -- i.e. that pinned the absence of the fallback it claimed to check. + .. tostring(M._fallback.command) .. "`") + -- **Spawn the replacement and re-point the buffer at it.** Swapping + -- the config is not a fallback on its own: nothing re-fires an attach + -- on a config change and `attach_buffer` early-returns for a live + -- attachment, so without this the buffer stays bound to the server we + -- just retired and the user has a config edit and no language server. -- - -- **Retried on the tick, not done inline**, and that is not caution: - -- `pmacs.lsp.stop` sends shutdown+exit and the state becomes - -- `shutting-down`, which `server_is_live` counts as LIVE. So an - -- immediate `_attach_buffer` early-returns the stale record and the - -- swap has no effect — the exact silent no-op this whole path exists - -- to avoid. Retrying until the old server actually reaches a terminal - -- state is what makes the rebuild happen. + -- The rebuild waits for two things, and conflating them is what made + -- round 2 wrong in two ways at once: + -- 1. the retired server actually reaching a terminal state (or + -- being gone) — `stop` leaves `shutting-down`, which + -- `server_is_live` counts as LIVE, so attaching before then + -- early-returns the stale record and the swap silently no-ops; + -- 2. the buffer that started this being the ACTIVE one, because + -- `_attach_buffer` is an active-buffer-only seam. The verdict + -- arrives asynchronously, so the user may well be somewhere else + -- by then — and "some attachment now names a different server" + -- is satisfied by an unrelated Rust buffer, which would clear the + -- retry while leaving the Lean buffer stale forever. probe.reattach_from = sid and tostring(sid) or false try_reattach() end --- Returns true once the active Lean buffer is attached to a server that --- is not the one the latch stopped. +-- Returns true when there is nothing left to do: either the initiating +-- buffer is attached to the replacement, or the replacement itself +-- failed and that has been reported. function try_reattach() if probe.reattach_from == nil then return true end - local ok, rec = pcall(pmacs.lsp._attach_buffer) - if not ok or not rec then return false end - if probe.reattach_from and tostring(rec.server) == probe.reattach_from then + -- (2) Wait for the initiating buffer to be the active one. + local buf = pmacs.window.buffer() + if not buf or not probe.buf_key or tostring(buf) ~= probe.buf_key then return false end + -- (1) Wait for the retired server to stop counting as live. + if probe.reattach_from then + local kind = server_state_kind_for_key(probe.reattach_from) + if kind ~= nil and kind ~= "crashed" and kind ~= "stopped" then + return false + end + end + -- Both conditions met: attempt the replacement EXACTLY ONCE. Cleared + -- first so a failing fallback cannot retry every tick forever — + -- acceptance 27 promises a second failure surfaces rather than loops. probe.reattach_from = nil + local ok, rec = pcall(pmacs.lsp._attach_buffer) + if not ok or not rec then + report("LSP: lean4 fallback `" .. tostring(M._fallback.command) + .. "` did not start either") + return false + end return true end @@ -265,7 +314,7 @@ local function drain_probe() if not ok or not evs then return end for _, ev in ipairs(evs) do if ev.kind == "stdout" or ev.kind == "stderr" then - probe.buf = probe.buf .. tostring(ev.bytes) + probe.out = probe.out .. tostring(ev.bytes) elseif ev.kind == "exited" or ev.kind == "signaled" or ev.kind == "crashed" then local proc = probe.proc @@ -279,7 +328,7 @@ local function drain_probe() -- question failure detection would otherwise answer slowly: an -- old-but-working lake that starts a useless server. if ev.kind == "exited" and ev.code == 0 - and version_below_3_1(probe.buf) then + and version_below_3_1(probe.out) then fire_latch(probe.watching, "lake is older than 3.1.0") end end @@ -296,10 +345,20 @@ local function start_probe(root) probe.started = true local cfg = pmacs.lsp.config.lean4 if not cfg or not cfg.command then return end + -- **Only probe something actually named `lake`.** `version_below_3_1` + -- parses the first `x.y` it finds anywhere in the output, which is a + -- rule about LAKE's output contract and nothing else. Run against a + -- user's wrapper it is a category error: a working `my-lean-wrapper` + -- reporting "wrapper 1.0" would be replaced despite its server having + -- initialized fine. The FAILURE latch stays command-agnostic — that + -- one keys on the server actually not starting, which is true of any + -- command — but the version rule only applies where its contract + -- holds. + local base = cfg.command:match("([^/]+)$") or cfg.command + if base ~= "lake" then return end -- Probe the binary we would actually run, not the literal string - -- "lake": a user pointing `command` at a wrapper or an absolute path - -- should have THAT probed, and a hardcoded name would silently probe - -- something else (or nothing). + -- "lake": a user pointing `command` at an absolute path to lake should + -- have THAT probed, not whatever `lake` resolves to on PATH. local spec = { -- COHERENCE §9: `ProcessSpec.label` is the only identity a process -- carries, and it is what `pmacs.process.list` renders. A user @@ -429,6 +488,11 @@ pmacs.hook.add("buffer.after-load", function() local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf) if not ok_lang or lang ~= "lean4" then return end + -- The buffer that started this, remembered for the asynchronous + -- rebuild: `_attach_buffer` acts on whatever is active when the + -- verdict lands, which may be a different buffer entirely. + probe.buf_key = tostring(buf) + if not probe.started then local path = pmacs.editor.file_path() start_probe(path and M.root_for(path) or nil) @@ -444,14 +508,21 @@ pmacs.hook.add("buffer.after-load", function() return end - -- No attachment for a Lean buffer means `ensure_server` could not - -- spawn at all — a synchronous ENOENT, already swallowed upstream. - -- That is not something to wait for; it is the failure itself, and - -- the only place it is still observable. + -- **Unconfigured is DISABLED, not failed.** A user who sets + -- `pmacs.lsp.config.lean4 = nil`, or clears its `command`, has turned + -- the Lean server off; reporting that "nil could not be started" is a + -- false alarm, and latching would poison the session so a later + -- configuration could never take effect. Only a CONFIGURED command + -- that produced no attachment is a failure. + local cfg = pmacs.lsp.config.lean4 + if not cfg or not cfg.command then return end + + -- No attachment for a Lean buffer with a configured command means + -- `ensure_server` could not spawn at all — a synchronous ENOENT, + -- already swallowed upstream. That is not something to wait for; it + -- is the failure itself, and the only place it is still observable. if not probe.latched then - fire_latch(nil, "`" .. tostring( - pmacs.lsp.config.lean4 and pmacs.lsp.config.lean4.command) - .. "` could not be started") + fire_latch(nil, "`" .. tostring(cfg.command) .. "` could not be started") end end) @@ -467,6 +538,7 @@ end) -- waiting on real process timing. Not part of the public surface. M._probe = probe M._fire_latch = fire_latch +M._try_reattach = try_reattach M._version_below_3_1 = version_below_3_1 pmacs.lean = M diff --git a/docs/active-work.md b/docs/active-work.md index 1a1e7ae..4a650da 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ If it does not, stop and repair the remote/fetch configuration. - Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in `src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`, a `leanprogress` mode plus `waitForDiagnostics` validation on - `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (20 tests). + `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (25 tests). No protocol change. - **Stage 1's acceptance 12 is half superseded and was rewritten, not deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a @@ -308,10 +308,14 @@ If it does not, stop and repair the remote/fetch configuration. server-failure latch covers the rest. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; - lean4 server 17/17; lean4 stage 1 9/9; dispatch seams 15/15; + lean4 server 25/25; lean4 stage 1 9/9; dispatch seams 15/15; multi-root 13/13; M4 121; required GPU 155; **isolated-config - workspace sweep 3,206 across 94 suites, zero failures**; - `git diff --check` clean. + workspace sweep 3,214 across 94 suites, zero failures**; + `git diff --check` clean. (Round 1 of + this entry recorded 17/17 and 3,206 — the PRE-fix counts — after the + fixes were pushed. The ledger's protocol is that verification + describes the pushed tree; recording it late is the #161 fmt-blocker + error in a slower form.) ## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index fac090b..ec62980 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -1553,10 +1553,14 @@ What remains deferred: stopped), so `attach_buffer` never rebuilds against it, and `LspManager::forget` refuses it for not being terminal. **Stopping a dead server is what makes it un-replaceable.** Found implementing - Stage 3b's latch, which works around it by checking the state before - stopping. The fix belongs in `stop` (treat an already-terminal client - as a no-op, or drive it straight to `Stopped`) and changes behavior - for every language, so it does not ride a Lean PR. + Stage 3b's latch, which works around it by dispatching on state: + `forget` for a terminal server (it requires terminal state, and + removing the client also drops the `next_restart_at` the crash armed), + `stop` for a live one. Merely *skipping* the call is not enough — that + leaves the restart timer running and the broken command respawns + underneath the fallback. The fix belongs in `stop` (treat an + already-terminal client as a no-op, or drive it straight to `Stopped`) + and changes behavior for every language, so it does not ride a Lean PR. - **Forwarding `cfg.restart` through `ensure_server`** — read by `lua_to_lsp_spec`, never set by the spawn table, so silently dropped on every auto-attach (found landing #161). Fixing it changes behavior for diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs index 6fe5ed4..cba8792 100644 --- a/tests/lean4_server_acceptance.rs +++ b/tests/lean4_server_acceptance.rs @@ -390,7 +390,7 @@ fn with_fallback(state: &EditorState, lake_cmd: &Path) { r#" pmacs.lsp.config.lean4.command = "{}" pmacs.lsp.config.lean4.args = {{ "serve" }} - pmacs.lean.fallback = {{ command = "{}", args = {{}} }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} "#, lua_str(lake_cmd), fake_lsp_path() @@ -824,3 +824,248 @@ fn lean_root_is_canonical_so_a_symlinked_open_reuses_one_server() { both spellings produce the same affinity key" ); } + +// --------------------------------------------------------------------------- +// Round-2 review findings. Each of these fails against the code as it +// stood at cdaea66, where the focused suite was already 20/20 — the +// lifecycle defects were invisible to it. +// --------------------------------------------------------------------------- + +/// Tick for at least `ms`, so a 500ms restart backoff actually elapses. +/// The round-2 defect was invisible precisely because the suite stopped +/// ticking as soon as the fallback initialized, ~300ms in. +fn tick_for(state: &mut EditorState, ms: u64) { + let deadline = std::time::Instant::now() + Duration::from_millis(ms); + while std::time::Instant::now() < deadline { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn r2_crashed_primary_does_not_respawn_underneath_the_fallback() { + // The crash schedules `next_restart_at`; `maybe_restart` fires after + // the 500ms backoff with no attempt ceiling. Skipping the retire + // call (round 2) left that armed, so the broken command kept + // respawning under the live fallback — forever, unobserved. + use std::os::unix::fs::PermissionsExt as _; + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let dying = fx.root.join("bin/dying-lake"); + std::fs::create_dir_all(dying.parent().unwrap()).unwrap(); + std::fs::write(&dying, "#!/bin/sh\nexit 3\n").unwrap(); + std::fs::set_permissions(&dying, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut state = editor(&fx); + with_fallback(&state, &dying); + open(&state, &file); + // Well past one backoff. + tick_for(&mut state, 1400); + + // **`attempt`, not liveness.** A respawning server spends most of + // its life in `crashed` waiting out the backoff, so "no live + // non-fallback server" is satisfied while it loops forever — that + // weaker assertion passed against the round-2 code and caught + // nothing. `attempt` increments on every spawn, so it counts the + // respawns directly. A retired server is absent from the list + // entirely (`forget` removes the client); one left with + // `next_restart_at` armed climbs past 1. + let worst_attempt: i64 = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + local live = rec and tostring(rec.server) or "" + local worst = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) ~= live then + local a = s.attempt or 0 + if a > worst then worst = a end + end + end + return worst + "#, + ); + assert_eq!( + worst_attempt, 0, + "the retired primary is gone from the manager, not respawning after the backoff (attempt > 0 means it is still there; > 1 means it respawned)" + ); + assert_eq!( + attached_state(&state), + "initialized", + "and the buffer is on the live fallback" + ); +} + +#[test] +fn r2_reattach_targets_the_originating_buffer_not_whatever_is_active() { + // `_attach_buffer` is an active-buffer-only seam and the latch's + // verdict arrives asynchronously. Round 2 accepted "some attachment + // now names a different server", which an unrelated Rust buffer + // satisfies — clearing the retry and stranding the Lean buffer. + // + // **Driven through the PROBE**, not through a missing executable: a + // missing command fails synchronously inside `buffer.after-load`, + // where the Lean buffer is still active and the rebuild happens + // inline, so the race cannot occur and the test proves nothing. The + // probe's verdict lands on a later tick, which is the whole point. + // The stub's `serve` sleeps, so only the probe can trigger anything. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + fx.write("pkg/Cargo.toml", "[package]\nname = \"p\"\n"); + let lean_file = fx.write("pkg/A.lean", "def a := 1\n"); + let rust_file = fx.write("pkg/src/main.rs", "fn main() {}\n"); + let old_lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + + let mut state = editor(&fx); + with_fallback(&state, &old_lake); + // A working Rust server, so switching away lands on a real + // attachment with a different server id — the decoy. + exec( + &state, + &format!( + "pmacs.lsp.config.rust = {{ command = \"{}\" }}", + fake_lsp_path() + ), + ); + + open(&state, &lean_file); + exec(&state, "_G.lean_buf = pmacs.window.buffer()"); + // Switch away before the probe's verdict can land. + open(&state, &rust_file); + tick_for(&mut state, 500); + + // Come back with a buffer SWITCH, not `find_or_open`. Re-opening + // fires `buffer.after-load`, which re-runs lsp.lua's own attach and + // would repair the record no matter what the latch did. + exec(&state, "pmacs.window.switch_buffer(_G.lean_buf)"); + tick_for(&mut state, 400); + + let lang: String = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + return rec and tostring(rec.language) or "none" + "#, + ); + assert_eq!(lang, "lean4", "we are back on the Lean buffer"); + + // The observable that discriminates: WHICH command the Lean buffer's + // server is running. A retry cleared by the decoy leaves it on the + // original `lake` stub. + let cmd: String = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.command) + end + end + return "gone" + "#, + ); + assert_eq!( + cmd, + fake_lsp_path(), + "the ORIGINATING Lean buffer ends up on the fallback — a decoy \ + Rust attachment must not satisfy the retry" + ); +} + +#[test] +fn r2_a_failing_fallback_is_reported_once_and_does_not_retry_forever() { + // Acceptance 27 promises a second failure surfaces rather than + // loops. Round 2 retried `_attach_buffer` every tick with nothing + // reported. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent_primary = fx.dir("bin/no-such-lake"); + let absent_fallback = fx.dir("bin/no-such-lean"); + + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent_primary), + lua_str(&absent_fallback) + ), + ); + + open(&state, &file); + tick_for(&mut state, 300); + + let status = state.core.borrow().status.clone(); + assert!( + status.contains("did not start either"), + "a failing fallback surfaces rather than retrying silently; saw \ + {status:?}" + ); + // And the retry state is cleared, so it is not looping. + let pending: String = eval(&state, "return tostring(pmacs.lean._probe.reattach_from)"); + assert_eq!(pending, "nil", "the retry is retired, not spinning"); +} + +#[test] +fn r2_a_working_wrapper_is_not_version_probed_as_lake() { + // `version_below_3_1` encodes LAKE's output contract. Applying it to + // an arbitrary wrapper is a category error: a working wrapper + // reporting its own "wrapper 1.0" would be replaced despite its + // server initializing fine. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + // Named something other than `lake`, reporting a sub-3.1 version, + // but which serves fine. + let wrapper = fx.lake_stub("bin/my-lean-wrapper", "wrapper 1.0"); + let mut state = editor(&fx); + with_fallback(&state, &wrapper); + + open(&state, &file); + tick_for(&mut state, 400); + + let cmd: String = eval(&state, "return pmacs.lsp.config.lean4.command"); + assert_eq!( + cmd, + wrapper.display().to_string(), + "a wrapper's own version string is not Lake's; the version probe \ + must not run against it" + ); + let latched: bool = eval(&state, "return pmacs.lean._probe.latched"); + assert!(!latched, "and the latch stayed disarmed"); +} + +#[test] +fn r2_an_unconfigured_lean_server_is_disabled_not_failed() { + // Setting `pmacs.lsp.config.lean4 = nil` means "off". Reporting that + // `nil` could not start is a false alarm, and latching poisons the + // session so a later configuration can never take effect. + let fx = Fixture::new(); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + exec(&state, "pmacs.lsp.config.lean4 = nil"); + exec(&state, "pmacs.editor.set_status(\"\")"); + + open(&state, &file); + settle(&mut state); + + assert_eq!( + state.core.borrow().status.clone(), + "", + "an unconfigured Lean server reports nothing — it is disabled" + ); + let latched: bool = eval(&state, "return pmacs.lean._probe.latched"); + assert!( + !latched, + "and the session is not poisoned: a later config must still work" + ); +} From 664cc25d0c4977cce9b11a291e633e0b68cbd475 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 18:36:18 -0400 Subject: [PATCH 17/91] feat(terminal): profiles, scrollback, and a configurable escape key Stage 1 of the terminal config/copy-mode arc. The terminal had no configuration surface at all: the command hardcoded $SHELL, scrollback was a per-open argument only, and the escape chord was a literal in Rust. No protocol change. Profiles are a raw Lua table, pmacs.terminal.profiles, not a registry setting: ConfigValue is four scalars with no table kind, so profiles join pmacs.lsp.config and pmacs.pair.sets until table-valued settings exist. The registry gains three scalars whose defaults reproduce the previous behavior exactly. Field resolution is explicit open argument, then profile field, then scalar setting, then $SHELL. env MERGES, with explicit entries overriding the profile's, because first-wins there would silently drop half a user's environment. An explicitly named profile that does not exist is an error even when terminal.default-profile is valid, so a typo cannot silently fall back. The two open-time settings resolve through the GLOBAL chain, because they are read before the identity buffer exists and no caller could have pinned a local override on a buffer that does not yet exist. Only terminal.escape-key resolves per buffer, which makes a per-terminal escape a supported feature. The escape key is parsed at most once per (terminal, config epoch), and the cache lives on TerminalSession so its lifetime is the terminal's, with no purge hook to forget. The epoch alone is not a sufficient key: it does not advance when focus moves between two terminals with different buffer-local values, so an epoch-only cache serves one terminal's chord to the other. An unparseable value falls back to C-c and reports once per terminal per effective invalid value through the status line, because a terminal with no escape chord cannot be escaped to fix the setting that broke it. Repeating the escape now sends THAT chord to the child through the ordinary key encoder, rather than a hardcoded ETX. With an escape of C-x, the previous code sent Ctrl-C and made literal Ctrl-X unreachable. C-c t opens a terminal. COHERENCE Priority 1 names a terminal keybinding, and section 2 step 8 grades the terminal works-but- undiscoverable; C-c is already a live global prefix, so this is a new leaf rather than a shadow. It is unreachable from inside a terminal, where C-c is the escape. Acceptance is tests/terminal_config_acceptance.rs, deliberately NOT crdt-gated so CI actually runs it. Four bites, each against a different plausible wrong implementation: a hardcoded ETX fails acc6/9; an epoch-only cache key fails acc7; a single last-entry cache fails acc8's parse count; removing the invalid-value fallback fails acc10. Two test-instrument notes worth keeping. cat -v is the echo probe because the screen rejects C0 controls before they reach cells, so a raw echoed Ctrl-X would be invisible. And the probe counts occurrences rather than testing presence, because a single-character probe collides with the child's own banner text. --- builtin/runtime/terminal.lua | 149 ++++++- src/editor.rs | 67 +++- src/lua_bindings/mod.rs | 27 ++ src/terminal/mod.rs | 4 + src/terminal/session.rs | 104 +++++ tests/terminal_config_acceptance.rs | 576 ++++++++++++++++++++++++++++ 6 files changed, 914 insertions(+), 13 deletions(-) create mode 100644 tests/terminal_config_acceptance.rs diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua index 6be0987..a6573fe 100644 --- a/builtin/runtime/terminal.lua +++ b/builtin/runtime/terminal.lua @@ -3,6 +3,38 @@ local terminal = assert(pmacs.terminal, "pmacs.terminal raw bindings are required") local raw_open = assert(terminal._open, "pmacs.terminal._open is required") +-- Q#TC2a. Every default reproduces today's behavior exactly, so a tree +-- with no settings written and no profiles registered behaves as before. +pmacs.config.define { + name = "terminal.default-profile", + type = "string", + default = "", + allow_empty = true, + mutability = "live", + description = "Profile name from pmacs.terminal.profiles to open by default. " .. + "Empty means no profile: fall back to $SHELL.", +} + +pmacs.config.define { + name = "terminal.scrollback-rows", + type = "integer", + default = 10000, + min = 0, + max = 4000000, + mutability = "live", + description = "Rows of scrollback retained per terminal. " .. + "0 retains no history.", +} + +pmacs.config.define { + name = "terminal.escape-key", + type = "string", + default = "C-c", + mutability = "live", + description = "Chord that escapes to the editor from a terminal. " .. + "Pressing it twice sends the chord itself to the child.", +} + local function bind_terminal_keys(buffer) local function bind(sequence, command) pmacs.keymap.bind { @@ -19,22 +51,127 @@ local function bind_terminal_keys(buffer) bind("M->", "terminal.scroll-bottom") end +-- Q#TC1: profiles are a raw Lua table, not a config setting. The +-- registry stores four scalars and has no table kind, so a profile — +-- inherently `{ command, args, cwd, env }` — lives here beside +-- `pmacs.lsp.config` and `pmacs.pair.sets` until table-valued settings +-- exist. +terminal.profiles = terminal.profiles or {} + +local PROFILE_FIELDS = { + command = "string", + args = "table", + cwd = "string", + env = "table", +} + +local function validate_profile(name, profile) + if type(profile) ~= "table" then + error(string.format("terminal profile %q must be a table", name), 0) + end + for key, value in pairs(profile) do + local expected = PROFILE_FIELDS[key] + if not expected then + error(string.format("terminal profile %q: unknown field %q", name, tostring(key)), 0) + end + if type(value) ~= expected then + error(string.format( + "terminal profile %q: field %q must be a %s, got %s", + name, key, expected, type(value)), 0) + end + end + return profile +end + +local function known_profile_names() + local names = {} + for name in pairs(terminal.profiles) do names[#names + 1] = name end + table.sort(names) + return names +end + +-- Q#TC2 / Q#TC3a: resolve a profile by name, or nil when none is +-- selected. An explicitly requested profile that does not exist is an +-- error even when `terminal.default-profile` is valid — a typo must not +-- silently fall back to the default. +local function resolve_profile(requested) + local name = requested + if name == nil then + local configured = pmacs.config.get("terminal.default-profile") + if configured == nil or configured == "" then return nil end + name = configured + end + local profile = terminal.profiles[name] + if profile == nil then + local known = known_profile_names() + local listed = #known > 0 and table.concat(known, ", ") or "(none defined)" + error(string.format( + "terminal profile %q is not defined; known profiles: %s", name, listed), 0) + end + return validate_profile(name, profile) +end + +-- Q#TC3a merge order, per field: explicit open field, then the profile's +-- field, then the scalar setting, then the built-in fallback. `env` is +-- the one field where "first wins" would be wrong, so it MERGES with +-- explicit entries overriding the profile's — any other reading silently +-- drops half a user's environment. +local function merge_env(profile_env, explicit_env) + if profile_env == nil then return explicit_env end + local merged = {} + for key, value in pairs(profile_env) do merged[key] = value end + for key, value in pairs(explicit_env or {}) do merged[key] = value end + return merged +end + function terminal.open(spec) - local buffer = raw_open(spec) + spec = spec or {} + local resolved = {} + for key, value in pairs(spec) do + if key ~= "profile" then resolved[key] = value end + end + + local profile = resolve_profile(spec.profile) + if profile then + for key in pairs(PROFILE_FIELDS) do + if key ~= "env" and resolved[key] == nil then resolved[key] = profile[key] end + end + resolved.env = merge_env(profile.env, spec.env) + end + + -- The two open-time settings resolve through the GLOBAL chain + -- (Q#TC2b): they are read before the identity buffer exists, so there + -- is no terminal to resolve a buffer-local against. + if resolved.scrollback_rows == nil then + resolved.scrollback_rows = pmacs.config.get("terminal.scrollback-rows") + end + if resolved.command == nil then + resolved.command = os.getenv("SHELL") or "/bin/sh" + end + + local buffer = raw_open(resolved) bind_terminal_keys(buffer) return buffer end pmacs.command.define { name = "terminal", - description = "Open a terminal running $SHELL (or /bin/sh).", - fn = function() - return terminal.open { - command = os.getenv("SHELL") or "/bin/sh", - } + description = "Open a terminal running the configured profile, or $SHELL.", + fn = function(profile) + return terminal.open { profile = profile } end, } +-- Q#TC10: the opening binding. `COHERENCE.md` Priority 1 names a +-- terminal keybinding as part of protecting the golden journey, and §2 +-- step 8 grades the terminal "works but undiscoverable". `C-c` is +-- already a live global prefix (fold's `C-c @ ...`), so this is a new +-- leaf under it rather than a shadow. +-- +-- Named limitation: unreachable from INSIDE a terminal window, where +-- `C-c` is consumed as the escape. `M-x terminal` still works there. +pmacs.keymap.bind { scope = "global", sequence = "C-c t", command = "terminal" } + pmacs.command.define { name = "terminal.copy-selection", description = "Copy the active terminal selection.", diff --git a/src/editor.rs b/src/editor.rs index 1db5c3a..2bc633b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -989,19 +989,28 @@ impl EditorState { .get(&frontend_id) .is_some_and(|state| state.terminal_escape); if let Some(view_key) = terminal_key { + // Q#TC4: the escape chord is per terminal, resolved through + // `terminal.escape-key` and cached on the session so this + // hot path parses at most once per (terminal, config epoch). + let escape_chord = self.terminal_escape_chord(view_key.buffer_id); if escaped { self.dispatchers .entry(frontend_id) .or_default() .terminal_escape = false; - if chord.is_some_and(is_terminal_escape_chord) { + if chord == Some(escape_chord) { + // Q#TC4b: repeating the escape sends THAT chord to the + // child, not a hardcoded ETX. With a configured escape + // of `C-x`, sending Ctrl-C here would both surprise the + // user and make literal Ctrl-X unreachable, since the + // first press is always consumed as the escape. self.claim_terminal_controller(view_key); - self.send_terminal_bytes(view_key.buffer_id, &[0x03]); + self.send_terminal_escape_literal(view_key, escape_chord); return; } // The post-escape key starts a fresh ordinary sequence below. } else if !dispatcher_pending { - if chord.is_some_and(is_terminal_escape_chord) { + if chord == Some(escape_chord) { let state = self.dispatchers.entry(frontend_id).or_default(); state.terminal_escape = true; state.dispatcher = KeyDispatcher::new(); @@ -1117,6 +1126,54 @@ impl EditorState { .then_some(key) } + /// This terminal's effective escape chord (Q#TC4). + /// + /// Resolution is `get("terminal.escape-key", terminal_buffer)` — + /// buffer-local, then global, then default — because unlike the two + /// open-time settings this one is read while the terminal exists, so + /// a per-terminal escape is expressible and supported (Q#TC2b). + /// + /// The parse and the once-per-terminal invalid-value report both live + /// in [`crate::terminal::TerminalManager::escape_chord`]; this method + /// only supplies the resolved spelling and the epoch that keys the + /// cache, and surfaces any report through the status line — the same + /// channel `send_terminal_bytes` uses for terminal failures. + fn terminal_escape_chord(&self, buffer_id: crate::buffer::BufferId) -> Chord { + let lua = self.lua_host.lua(); + let (spelling, epoch) = crate::lua_bindings::config_string_and_epoch( + lua, + "terminal.escape-key", + Some(buffer_id), + crate::terminal::DEFAULT_TERMINAL_ESCAPE_KEY, + ); + let (chord, report) = self + .terminal_manager + .borrow_mut() + .escape_chord(buffer_id, epoch, &spelling); + if let Some(message) = report { + self.core.borrow_mut().status = message; + } + chord + } + + /// Send the configured escape chord to the child as literal input + /// (Q#TC4b), through the same encoder ordinary keys use so it + /// inherits application-cursor and modifier handling. + fn send_terminal_escape_literal(&self, key: TerminalViewKey, chord: Chord) { + let event = KeyEvent::new(chord.code, chord.modifiers); + let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(event) else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + if let Some(bytes) = crate::terminal::input::encode_key(terminal_key, modifiers, modes) { + self.send_terminal_bytes(key.buffer_id, &bytes); + } + } + fn claim_terminal_controller(&self, key: TerminalViewKey) { let mut manager = self.terminal_manager.borrow_mut(); let _ = manager.register_view(key); @@ -4421,10 +4478,6 @@ fn sanitize_single_line(s: &str) -> String { .collect() } -fn is_terminal_escape_chord(chord: Chord) -> bool { - chord.code == KeyCode::Char('c') && chord.modifiers == KeyModifiers::CONTROL -} - fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> { let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers); let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 3482879..1e7ac04 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -668,6 +668,33 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option, fallback: } } +/// Read a `String` setting plus the registry epoch that keys any cache +/// built from it (Q#TC4c). +/// +/// The epoch is returned WITH the value deliberately: a caller caching a +/// parsed form needs both, and reading them in two calls would let a +/// `set` land between them and produce a cache stamped with the wrong +/// epoch. `fallback` covers a bare core whose runtime never defined the +/// setting, matching [`config_u32`]. +#[must_use] +pub fn config_string_and_epoch( + lua: &Lua, + name: &str, + buffer_id: Option, + fallback: &str, +) -> (String, u64) { + let Some(registry) = lua.app_data_ref::() else { + return (fallback.to_owned(), 0); + }; + let borrowed = registry.borrow(); + let epoch = borrowed.value_epoch(); + let value = match borrowed.get(name, buffer_id) { + Ok(crate::config_registry::ConfigValue::Str(v)) => v.clone(), + _ => fallback.to_owned(), + }; + (value, epoch) +} + /// Short-circuit a binding when the init phase has completed. /// /// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+) diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 27ee96c..11272e4 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -34,6 +34,10 @@ pub use pmacs_protocol::terminal::{ /// Configuration-time, not a wire bound: history never crosses the /// protocol, so this stays core-owned. pub const DEFAULT_TERMINAL_SCROLLBACK_ROWS: usize = 10_000; + +/// Default `terminal.escape-key`, and the fallback an unparseable value +/// falls back to (Q#TC4a). +pub const DEFAULT_TERMINAL_ESCAPE_KEY: &str = "C-c"; /// Maximum retained main-screen history cells. Core-owned for the same /// reason as [`DEFAULT_TERMINAL_SCROLLBACK_ROWS`]. pub const MAX_TERMINAL_HISTORY_CELLS: usize = 4_000_000; diff --git a/src/terminal/session.rs b/src/terminal/session.rs index ee96923..6e71ea3 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -12,6 +12,7 @@ use crate::ansi::AnsiParserProfile; use crate::buffer::{Buffer, BufferId}; use crate::cell::{Cell, CellCoord, CellSize}; use crate::editor_core::EditorCore; +use crate::key::{Chord, parse_chord}; use crate::process::{ ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor, RestartPolicy, StdinMode, TerminalMode, @@ -218,12 +219,40 @@ pub(super) struct TerminalSession { pub(super) screen: TerminalScreen, pub(super) process: TerminalProcessState, pub(super) annotated: bool, + /// Resolved `terminal.escape-key` for this terminal (Q#TC4c). + /// + /// The cache lives HERE, not in an editor-side map, because a + /// session is created in [`TerminalManager::open`] and dropped on + /// kill/prune — so its lifetime is exactly the cache's, with no + /// purge hook to forget. An editor-side map would leak an entry per + /// terminal; a single last-entry cache would reparse (and re-report + /// an invalid value) every time focus alternates between two + /// terminals. + pub(super) escape: Option, +} + +/// One terminal's parsed escape chord, valid for one config epoch. +pub(super) struct EscapeCache { + /// The `ConfigRegistry::value_epoch` this was parsed at. The key is + /// `(this session, epoch)`: the epoch alone is not enough, because + /// it does not advance when focus moves between terminals with + /// different buffer-local values. + pub(super) epoch: u64, + /// The effective chord — the parsed spelling, or the `C-c` fallback. + pub(super) chord: Chord, + /// The invalid spelling already reported for this terminal, if any. + /// Reporting is once per terminal per effective invalid value: an + /// unchanged bad value stays quiet, a *different* bad value reports + /// again because it is a new mistake. + pub(super) reported_invalid: Option, } /// Owns the one-buffer/one-process/one-screen terminal registry. #[derive(Default)] pub struct TerminalManager { pub(super) sessions: HashMap, + /// Total escape-key parses performed (Q#TC4c observability). + escape_parses: u64, process_to_buffer: HashMap, /// Removed buffers whose children are still being reaped. Their events /// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch. @@ -331,6 +360,7 @@ impl TerminalManager { screen, process: TerminalProcessState::Running, annotated: false, + escape: None, }, ); debug_assert!(previous.is_none(), "fresh BufferId collided"); @@ -538,6 +568,71 @@ impl TerminalManager { .map_err(TerminalError::Process) } + /// Resolve this terminal's effective escape chord, parsing at most + /// once per `(terminal, config epoch)` (Q#TC4c). + /// + /// `spelling` is the caller-resolved `terminal.escape-key` value and + /// `epoch` the registry's `value_epoch()` it was read at. Returns the + /// effective chord plus, at most once per terminal per effective + /// invalid value, a message the caller should surface. + /// + /// An unparseable spelling falls back to `C-c` rather than leaving the + /// terminal with no escape at all (Q#TC4a): without one, every key goes + /// to the child and the user cannot reach the binding that would fix + /// the setting that broke it. + pub fn escape_chord( + &mut self, + buffer_id: BufferId, + epoch: u64, + spelling: &str, + ) -> (Chord, Option) { + let fallback = default_escape_chord(); + if let Some(session) = self.sessions.get(&buffer_id) + && let Some(cache) = session.escape.as_ref() + && cache.epoch == epoch + { + return (cache.chord, None); + } + self.escape_parses = self.escape_parses.saturating_add(1); + let Some(session) = self.sessions.get_mut(&buffer_id) else { + return (fallback, None); + }; + let previously_reported = session + .escape + .as_ref() + .and_then(|cache| cache.reported_invalid.clone()); + let (chord, reported_invalid, report) = match parse_chord(spelling) { + Ok(chord) => (chord, None, None), + Err(error) => { + let already = previously_reported.as_deref() == Some(spelling); + let message = (!already).then(|| { + format!( + "terminal.escape-key {spelling:?} is not a valid chord ({error}); using C-c" + ) + }); + (fallback, Some(spelling.to_owned()), message) + } + }; + session.escape = Some(EscapeCache { + epoch, + chord, + reported_invalid, + }); + (chord, report) + } + + /// How many escape-key spellings this manager has parsed. + /// + /// An observability seam for Q#TC4c's cache contract, which is + /// otherwise unpinnable for a VALID setting: a correct per-session + /// cache and a single last-entry cache produce identical behavior + /// there and differ only in how often they parse. Counting reports + /// covers the invalid case; this covers the valid one. + #[must_use] + pub fn escape_parses(&self) -> u64 { + self.escape_parses + } + /// Resize a terminal screen and its PTY after validating shared limits. pub fn resize( &mut self, @@ -730,3 +825,12 @@ fn sanitize_metadata(value: &str) -> String { } clean } + +/// The built-in terminal escape chord, and the fallback for an +/// unparseable `terminal.escape-key` (Q#TC4a). +pub(super) fn default_escape_chord() -> Chord { + Chord::new( + crossterm::event::KeyCode::Char('c'), + crossterm::event::KeyModifiers::CONTROL, + ) +} diff --git a/tests/terminal_config_acceptance.rs b/tests/terminal_config_acceptance.rs new file mode 100644 index 0000000..a613eb2 --- /dev/null +++ b/tests/terminal_config_acceptance.rs @@ -0,0 +1,576 @@ +//! Terminal configuration acceptance (Stage 1 of +//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 1-12). +//! +//! Deliberately NOT `#[cfg(feature = "crdt")]`: CI never enables that +//! feature, so a gated suite is written and then never run. + +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use mlua::Value; +use pmacs::cell::{CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::terminal::TerminalViewKey; +use pmacs::window::WindowId; + +fn exec(state: &EditorState, src: &str) { + state + .lua_host + .lua() + .load(src) + .exec() + .unwrap_or_else(|e| panic!("lua failed: {src}\n{e}")); +} + +fn eval_err(state: &EditorState, src: &str) -> String { + let result: mlua::Result = state.lua_host.lua().load(src).eval(); + match result { + Ok(_) => panic!("expected an error from: {src}"), + Err(e) => e.to_string(), + } +} + +fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String { + let manager = state.terminal_manager.borrow(); + let Some(snapshot) = manager.snapshot(buffer) else { + return String::new(); + }; + let mut text = String::new(); + for cell in &snapshot.cells { + match &cell.glyph { + Glyph::Char(c) => text.push(*c), + Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)), + Glyph::Continuation => {} + } + } + text +} + +fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + state.tick_processes(); + if screen_text(state, buffer).contains(needle) { + return true; + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(20)); + } +} + +/// Give LOCAL a window on `buffer` and register/claim its terminal view, +/// which is what makes `dispatch_key`'s terminal arm reachable. +fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> WindowId { + state.core.borrow_mut().switch_active_buffer(buffer).ok(); + let window = state.core.borrow().active_window_id(); + let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer); + let mut manager = state.terminal_manager.borrow_mut(); + manager.register_view(key); + manager.claim_controller(key); + let _ = manager.snapshot_for_view(key, CellSize::new(10, 40)); + window +} + +fn terminal_buffers(state: &EditorState) -> Vec { + let manager = state.terminal_manager.borrow(); + state + .core + .borrow() + .registry + .borrow() + .ids() + .iter() + .copied() + .filter(|id| manager.is_terminal(*id)) + .collect() +} + +/// Open a terminal from Lua and return the identity buffer it created. +/// +/// The id is derived by diffing the manager's terminal set rather than +/// returned through Lua: `BufferIdLua` exposes no id accessor, and +/// diffing also asserts in passing that exactly one terminal appeared. +fn open_cat_terminal(state: &EditorState, lua_spec: &str) -> pmacs::buffer::BufferId { + let before = terminal_buffers(state); + exec( + state, + &format!("TERM_BUF = pmacs.terminal.open {{ {lua_spec} }}"), + ); + let after = terminal_buffers(state); + let mut fresh: Vec<_> = after + .into_iter() + .filter(|id| !before.contains(id)) + .collect(); + assert_eq!(fresh.len(), 1, "exactly one terminal must have opened"); + fresh.remove(0) +} + +/// `cat -v` is the echo instrument, deliberately: the terminal screen +/// rejects C0/C1 controls before they enter cells (Vterm Stage 1 +/// criterion 2), so a raw echoed `Ctrl-X` would be invisible and a test +/// probing for it could never pass. `-v` renders it as the printable +/// two-character `^X`, which is what makes "the configured chord reached +/// the child" observable at all. +const CAT_PROFILE: &str = r#" +pmacs.terminal.profiles.echo = { + command = "/bin/sh", + args = { "-c", "printf 'READY\r\n'; exec cat -v" }, +} +"#; + +/// Did the last key ARM the terminal escape? +/// +/// Observed behaviorally rather than through an accessor: while the +/// escape is armed the next key goes to ordinary dispatch, so it never +/// reaches the child. `cat` echoes anything that does reach it, which +/// makes "the probe character did not appear" the exact observable for +/// "that chord was consumed as the escape". +fn escape_was_armed(state: &mut EditorState, buffer: pmacs::buffer::BufferId, probe: char) -> bool { + // Count occurrences rather than testing for presence: the screen + // already holds the child's own output, and a single-character probe + // like 'R' collides with the "READY" banner. Only an INCREASE proves + // this keystroke reached the child. + let before = screen_text(state, buffer).matches(probe).count(); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char(probe), KeyModifiers::NONE), + ); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + state.tick_processes(); + if screen_text(state, buffer).matches(probe).count() > before { + return false; + } + if Instant::now() >= deadline { + return true; + } + thread::sleep(Duration::from_millis(20)); + } +} + +/// Acceptance 1: a profile spec is strict, and rejects before anything spawns. +#[test] +fn acc1_profile_specs_are_strict_and_reject_before_spawning() { + let state = EditorState::new(); + let before = state.core.borrow().registry.borrow().ids().len(); + + exec( + &state, + r#"pmacs.terminal.profiles.bad = { command = "/bin/sh", nonsense = true }"#, + ); + let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "bad" }"#); + assert!( + err.contains("unknown field") && err.contains("nonsense"), + "the error must name the offending field: {err}" + ); + + exec(&state, "pmacs.terminal.profiles.wrong = { command = 42 }"); + let err = eval_err( + &state, + r#"return pmacs.terminal.open { profile = "wrong" }"#, + ); + assert!(err.contains("must be a string"), "typed field error: {err}"); + + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + before, + "a rejected profile must create no buffer" + ); + assert_eq!(state.terminal_manager.borrow().len(), 0); +} + +/// Acceptance 2: an unknown profile names the known ones and creates nothing. +#[test] +fn acc2_unknown_profile_lists_known_names_and_creates_nothing() { + let state = EditorState::new(); + exec(&state, CAT_PROFILE); + exec( + &state, + r#"pmacs.terminal.profiles.other = { command = "/bin/sh" }"#, + ); + let before = state.core.borrow().registry.borrow().ids().len(); + + // Via the default setting. + exec( + &state, + r#"pmacs.config.set("terminal.default-profile", "ghost")"#, + ); + let err = eval_err(&state, "return pmacs.terminal.open {}"); + assert!(err.contains("ghost"), "names the missing profile: {err}"); + assert!( + err.contains("echo") && err.contains("other"), + "must LIST the known profiles: {err}" + ); + + // An explicit bad profile fails even though the default is now valid — + // a typo must not silently fall back (Q#TC3a). + exec( + &state, + r#"pmacs.config.set("terminal.default-profile", "echo")"#, + ); + let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "typo" }"#); + assert!(err.contains("typo"), "explicit bad profile errors: {err}"); + + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + before, + "no buffer, session, or process is created" + ); + assert_eq!(state.terminal_manager.borrow().len(), 0); +} + +/// Acceptance 3: explicit beats profile beats setting beats `$SHELL`, and +/// `env` MERGES rather than replacing. +#[test] +fn acc3_field_resolution_order_and_env_merge() { + let mut state = EditorState::new(); + exec( + &state, + r#" + pmacs.terminal.profiles.merged = { + command = "/bin/sh", + args = { "-c", "printf 'PROFILE:%s:%s\r\n' \"$FROM_PROFILE\" \"$SHARED\"; exec cat" }, + env = { FROM_PROFILE = "p", SHARED = "profile" }, + } + "#, + ); + let buffer = open_cat_terminal( + &state, + r#"profile = "merged", env = { SHARED = "explicit" }"#, + ); + assert!( + tick_until(&mut state, "PROFILE:p:explicit", buffer), + "profile env survives and explicit env overrides the same key: {:?}", + screen_text(&state, buffer) + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 3 (explicit command wins) and 4 (`""` means no profile). +#[test] +fn acc3_acc4_explicit_command_wins_and_empty_default_means_no_profile() { + let mut state = EditorState::new(); + exec(&state, CAT_PROFILE); + exec( + &state, + r#"pmacs.config.set("terminal.default-profile", "echo")"#, + ); + + // Explicit command beats the profile's. + let explicit = open_cat_terminal( + &state, + r#"command = "/bin/sh", args = { "-c", "printf 'EXPLICIT\r\n'; exec cat" }"#, + ); + assert!(tick_until(&mut state, "EXPLICIT", explicit)); + + // `""` is the no-profile sentinel: falls through to $SHELL. + exec( + &state, + r#"pmacs.config.set("terminal.default-profile", "")"#, + ); + let bare = open_cat_terminal(&state, ""); + let spec_ok = state.terminal_manager.borrow().is_terminal(bare); + assert!(spec_ok, "an empty default must open a $SHELL terminal"); + assert!( + !screen_text(&state, bare).contains("READY"), + "the echo profile must NOT have been applied" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 5: scrollback resolves from the setting, is overridden by an +/// explicit value, and `0` is legal. +#[test] +fn acc5_scrollback_setting_override_and_bounds() { + let state = EditorState::new(); + exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#); + assert_eq!( + state + .lua_host + .lua() + .load(r#"return pmacs.config.get("terminal.scrollback-rows")"#) + .eval::() + .unwrap(), + 0, + "0 is a legal scrollback value meaning 'retain no history'" + ); + + let err = eval_err( + &state, + r#"return pmacs.config.set("terminal.scrollback-rows", -1)"#, + ); + assert!( + err.contains("-1") || err.contains("min"), + "below range: {err}" + ); + let err = eval_err( + &state, + r#"return pmacs.config.set("terminal.scrollback-rows", 4000001)"#, + ); + assert!( + err.contains("4000001") || err.contains("max"), + "above range: {err}" + ); +} + +/// Acceptance 6 and 9: the configured chord escapes, repeating it sends +/// THAT chord to the child, and an ordinary `C-c` still reaches the child. +#[test] +fn acc6_acc9_configured_escape_chord_and_literal_repeat() { + let mut state = EditorState::new(); + exec(&state, CAT_PROFILE); + let buffer = open_cat_terminal(&state, r#"profile = "echo""#); + assert!(tick_until(&mut state, "READY", buffer)); + focus_terminal(&state, buffer); + + exec(&state, r#"pmacs.config.set("terminal.escape-key", "C-x")"#); + + // `C-x C-x` must send Ctrl-X (0x18), which `cat` echoes back. Against + // the pre-change hardcoded `&[0x03]` this sends Ctrl-C instead. + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + assert!( + tick_until(&mut state, "^X", buffer), + "C-x C-x must send literal Ctrl-X: {:?}", + screen_text(&state, buffer) + ); + + // With the escape moved, an ordinary C-c is just another key. + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!( + tick_until(&mut state, "^C", buffer), + "plain C-c must reach the child once the escape moved: {:?}", + screen_text(&state, buffer) + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 7, 8 and 8a: per-terminal escape resolution, an A→B→A parse +/// count that does not grow, and a cache that dies with its terminal. +#[test] +fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() { + let mut state = EditorState::new(); + exec(&state, CAT_PROFILE); + let a = open_cat_terminal(&state, r#"profile = "echo""#); + exec(&state, "TERM_A = TERM_BUF"); + let b = open_cat_terminal(&state, r#"profile = "echo""#); + exec(&state, "TERM_B = TERM_BUF"); + assert!(tick_until(&mut state, "READY", a)); + assert!(tick_until(&mut state, "READY", b)); + + // Different buffer-local escapes, then NO further writes. + exec( + &state, + r#"pmacs.config.set_local(TERM_A, "terminal.escape-key", "C-x")"#, + ); + exec( + &state, + r#"pmacs.config.set_local(TERM_B, "terminal.escape-key", "C-b")"#, + ); + + // Prime both caches. Each priming press ARMS the escape, so it is + // consumed with a probe — otherwise the next chord would be read as + // the escape repeat rather than a fresh escape. + focus_terminal(&state, a); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + assert!(escape_was_armed(&mut state, a, 'M'), "A primes on its C-x"); + focus_terminal(&state, b); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), + ); + assert!(escape_was_armed(&mut state, b, 'N'), "B primes on its C-b"); + let primed = state.terminal_manager.borrow().escape_parses(); + + // Acceptance 7 — BOTH directions. Asserting only that A still works + // after A->B->A is not enough: an epoch-only cache hands whichever + // entry it finds to every terminal, so A keeps working by accident + // while B silently inherits A's chord. The discriminating assertion + // is that EACH terminal honors its OWN chord and NOT the other's. + focus_terminal(&state, b); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), + ); + assert!( + escape_was_armed(&mut state, b, 'R'), + "terminal B must escape on its own C-b" + ); + // ...and A's chord must be ordinary input in B, not an escape. + focus_terminal(&state, b); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + assert!( + !escape_was_armed(&mut state, b, 'S'), + "terminal A's C-x must NOT escape terminal B" + ); + + focus_terminal(&state, a); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + assert!( + escape_was_armed(&mut state, a, 'Q'), + "terminal A must still escape on its own C-x after A->B->A" + ); + + // Acceptance 8: that round trip parsed nothing new. A single + // last-entry cache would have reparsed twice. + assert_eq!( + state.terminal_manager.borrow().escape_parses(), + primed, + "A->B->A with no setting written must not reparse" + ); + + // Acceptance 8a: the cache dies with its terminal. + let sessions_before = state.terminal_manager.borrow().len(); + exec(&state, "pmacs.terminal.terminate(TERM_A)"); + exec(&state, "pmacs.buffer.kill(TERM_A)"); + // Pruning is tick-driven (the manager reaps on the process tick), so + // the session outlives the kill call by design. + let deadline = Instant::now() + Duration::from_secs(5); + while state.terminal_manager.borrow().len() >= sessions_before { + state.tick_processes(); + assert!( + Instant::now() < deadline, + "killing the terminal must remove its session, and with it the cache" + ); + thread::sleep(Duration::from_millis(20)); + } + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 10 and 10a: an unparseable value falls back, reports through +/// the status line, and reports once per terminal per effective bad value. +#[test] +fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() { + let mut state = EditorState::new(); + exec(&state, CAT_PROFILE); + let buffer = open_cat_terminal(&state, r#"profile = "echo""#); + assert!(tick_until(&mut state, "READY", buffer)); + focus_terminal(&state, buffer); + + exec( + &state, + r#"pmacs.config.set("terminal.escape-key", "not-a-chord")"#, + ); + state.core.borrow_mut().status.clear(); + + // Acceptance 10: falls back to C-c, so the terminal stays escapable. + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + // Read the report BEFORE probing: `status` is a single slot, and the + // probe key's own rejected self-insert would overwrite it. + let reported = state.core.borrow().status.clone(); + assert!( + reported.contains("terminal.escape-key") && reported.contains("not-a-chord"), + "the report must name the setting and the bad value: {reported:?}" + ); + assert!( + escape_was_armed(&mut state, buffer, 'Q'), + "an invalid escape-key must fall back to C-c, not leave the \ + terminal unescapable" + ); + + // Acceptance 10a: the same bad value does not report again. + state.core.borrow_mut().status.clear(); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!( + state.core.borrow().status.is_empty(), + "an unchanged invalid value must not re-report: {:?}", + state.core.borrow().status + ); + let _ = escape_was_armed(&mut state, buffer, 'W'); + + // A DIFFERENT bad value is new information, so it reports again. + exec( + &state, + r#"pmacs.config.set("terminal.escape-key", "also-bad")"#, + ); + state.core.borrow_mut().status.clear(); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert!( + state.core.borrow().status.contains("also-bad"), + "a different invalid value must report: {:?}", + state.core.borrow().status + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 11: the opening binding exists, resolves to the command, and +/// shadowed nothing (`keymap.bind` is strict, so loading the runtime at all +/// proves the second half). +#[test] +fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() { + let state = EditorState::new(); + let command: Option = state + .lua_host + .lua() + .load(r#"local d = pmacs.describe.key("C-c t"); return d and d.command"#) + .eval() + .expect("describe.key"); + assert_eq!( + command.as_deref(), + Some("terminal"), + "C-c t must open a terminal" + ); +} + +/// Acceptance 12: with no settings written and no profiles registered, the +/// defaults reproduce the pre-arc behavior. +#[test] +fn acc12_defaults_reproduce_prior_behavior() { + let state = EditorState::new(); + let lua = state.lua_host.lua(); + assert_eq!( + lua.load(r#"return pmacs.config.get("terminal.default-profile")"#) + .eval::() + .unwrap(), + "" + ); + assert_eq!( + lua.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#) + .eval::() + .unwrap(), + 10_000 + ); + assert_eq!( + lua.load(r#"return pmacs.config.get("terminal.escape-key")"#) + .eval::() + .unwrap(), + "C-c" + ); + assert!( + lua.load("return next(pmacs.terminal.profiles) == nil") + .eval::() + .unwrap(), + "no profiles are registered by default" + ); +} From 04c5ad13e84e4a0e68329c82b0bd50006224d285 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 18:41:21 -0400 Subject: [PATCH 18/91] docs: record the terminal-config lane Stage 1 of the terminal config/copy-mode arc is in review; Stage 2 is not started. Records the four decisions forced by scouted ground truth, the four bites against four different wrong implementations, the two reusable test instruments, and the gate results. --- docs/active-work.md | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 9a913e6..5284b94 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -517,6 +517,52 @@ If it does not, stop and repair the remote/fetch configuration. **isolated-config workspace sweep 3,177 across 92 suites, zero failures**; `git diff --check` clean. Gates were run against the committed tree. +## Terminal config + copy mode arc — Stage 1 IN REVIEW + +- Approved framing: `docs/terminal-config-and-copy-mode-framing.md` + **revision 4** (four review rounds), committed as the first commit of + Stage 1's branch. Two stages, two branches, two PRs; **no protocol + change**. +- **Stage 1 = `githubsucks/terminal-config`**, worktree + `../pmacs-terminal-config`, based on `githubsucks/main` @ `d152120`. + Profiles, scrollback, escape key, and the `C-c t` opening binding. +- **Stage 2 = `terminal-copy-mode`, not started.** Branch it off `main` + after Stage 1 merges: no dependency, but both edit + `builtin/runtime/terminal.lua`. +- Load-bearing decisions, each forced by scouted ground truth: + - profiles are a **raw Lua table** — `ConfigValue` is four scalars with + no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`; + - the **two open-time settings resolve through the global chain**, + because they are read before the identity buffer exists; only + `terminal.escape-key` resolves per buffer; + - the escape cache lives on **`TerminalSession`** so its lifetime is + the terminal's. `value_epoch` alone is not a sufficient key: it does + not advance when focus moves between terminals with different + buffer-local values; + - repeating the escape sends **that chord**, not a hardcoded `0x03`. +- **Four bites, each against a different plausible wrong + implementation** — hardcoded ETX fails acc6/9; epoch-only cache key + fails acc7; single last-entry cache fails acc8's parse count; removing + the invalid-value fallback fails acc10. The first version of acc7 + passed against the epoch-only bite because it asserted only that + terminal A still worked; the discriminating assertion is that **each** + terminal honors its own chord and not the other's. +- Test instruments worth reusing: `cat -v` is the echo probe, because the + screen rejects C0 controls before they reach cells so a raw echoed + `Ctrl-X` is invisible; and the probe **counts occurrences** rather than + testing presence, because a single-character probe collides with the + child's own banner text. +- Verification on this branch (against the committed tree): `cargo fmt + --check` clean; strict workspace Clippy clean; 1,832 default + 2,009 + CRDT library tests; `terminal_config_acceptance` 10/10 in **both** + configurations; vterm Stage 1/2/3 9+10 / 6+6 / 5+9; config registry 16; + bottom-panel 46; listview 6; compile 67 (isolated config); M4 121; + required GPU 202; **isolated-config workspace sweep 3,262 across 94 + suites**; `git diff --check` clean. + - `compile_mode_acceptance` fails 11/67 against the **real** user + config and passes 67/67 with an isolated `XDG_CONFIG_HOME` — the + known pre-existing trap, not this branch. + ## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 (GPU band) is next Stage 1 is on `main`; nothing in this arc is in flight. Stage 2 has **no From c2eeb60dc75327a3cd405da1b93374aa51ba47c7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 18:00:40 -0400 Subject: [PATCH 19/91] docs: bottom-panel Stage 2 framing (revision 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-framing `docs/bottom-panel-framing.md` rev 4 §2 requires before Stage 2 (the GPU panel band) is implemented. Re-scouted against canonical `main` @ `5aa9044`, protocol v20. It does not restate the parent's decisions; it records what the re-scout found. Every source anchor Stage 2 inherits had moved, but none of the parent's mechanical model was falsified. Two facts held and are load-bearing: protocol is still v20, so Q#BP9 resolves to **v21** with no reservation needed, and both byte pins (`InstanceMessage::InitialTargetResult`, `FrontendEvent::TerminalPointer`) are still their enums' final variants. Four findings: - **Q#BP2S1, new and open.** Stage 1 landed a daemon-side geometry epoch allocator (`declare_frame_geometry`), but Q#BP15a specifies a frontend-owned epoch echoed by every `Present`. The landed allocator also dedups on value and uses `saturating_add`, which is neither wrapping nor the fail-closed the framing asks for. Three resolutions are stated with a recommendation. - **The §1.3 census is essentially unrouted.** Stage 1 built the `primary_document_window` seam but it has one production caller; ~80 direct `.active` reads remain. This is Stage 2's bulk, not its tidy-up, and the stage plan sequences it first. - **The statusline active read is three sites, not one.** - **Four scout obligations are still open** and are named rather than papered over, including the GPU-side pixel formula inputs. Also carries the staged plan, draft acceptance criteria, the coherence impact per `COHERENCE.md` §20 (§14 is the section it serves), and four questions for the user. Co-Authored-By: Claude Opus 5 (1M context) --- docs/bottom-panel-stage2-framing.md | 326 ++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/bottom-panel-stage2-framing.md diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md new file mode 100644 index 0000000..19f9203 --- /dev/null +++ b/docs/bottom-panel-stage2-framing.md @@ -0,0 +1,326 @@ +# Bottom panel Stage 2 — the GPU panel band (framing) + +**Revision 1 — pre-implementation. Ground truth: canonical `main` @ +`5aa9044`, protocol v20, 2026-07-25.** + +Stage 1 (#155, merge `e745068`) gave pmacs window placement, window +parameters, TUI side windows, the divider, and the adopter `display` +opt-in. It deliberately set `FrontendView::panel_capable = false` for +every semantic session, so a GPU frontend silently falls back to the +non-side target. **Stage 2 flips that bit and earns the right to.** + +This document is the re-framing `docs/bottom-panel-framing.md` (rev 4) +§2 requires before Stage 2 is implemented. It does **not** restate the +parent's decisions. It records what the re-scout against current `main` +found: which anchors moved, which parent claims survived, which did +not, and the four questions the parent framing cannot answer without a +decision from the user. + +Read the parent framing's Q#BP8, Q#BP9, Q#BP14b, Q#BP15, Q#BP15a, +Q#BP16, and Q#BP17 alongside this. Those decisions stand except where +§4 below revises them. + +## 0. Why the re-scout was required + +The parent framing's Stage 2 sections were written against `main` @ +`0dd16a5` and last re-scouted at `47581f4`. Since then eleven PRs have +merged: #149/#150 (folding Stage 2), #152–#155 (through bottom-panel +Stage 1), #158–#166 (inline math, minimap, Lean 4 Stages 1–2, +COHERENCE.md, find-file, the dired framing and Stage 1, the GPU +terminal input fix). Every source anchor Stage 2 depends on has moved. + +Two things did **not** change, and both are load-bearing: + +- **Protocol is still v20.** `PROTOCOL_VERSION` is `20` + (`pmacs-protocol/src/message.rs:1568`); no intervening PR bumped it. + Q#BP9's conditional resolves: **Stage 2 is v21**. +- **Both byte pins are still the final variants.** + `InstanceMessage::InitialTargetResult` is last in its enum + (`message.rs:577` within the enum at `:569`), and + `FrontendEvent::TerminalPointer` is last in its own. Q#BP9's + append-plus-pin instruction applies verbatim, with no re-derivation. + +## 1. Anchor re-scout + +Every line reference Stage 2 inherits, re-verified. "Claim" is the +parent framing's assertion about that site; "verdict" is what the code +at `5aa9044` actually says. + +| Parent anchor | Now at | Claim | Verdict | +| --- | --- | --- | --- | +| `src/editor.rs:2833` `paint_frame` returns cursor separately | `src/editor.rs:3171` | cells alone lose the caret | **Holds.** Signature still returns `Option` | +| `src/editor.rs:2883-2935` cursor-visible prep | `src/editor.rs:3249+` | extract with the per-window body | **Holds**, but see §3.1 — Stage 1 inserted work *above* it | +| `src/editor.rs:2937-3040` per-window paint body | after `:3260` | origin-agnostic `Viewport<'a>`, extractable | **Holds** | +| `src/editor_core.rs:566` `fold_map_for_window` | `src/editor_core.rs:734` | gates on the **active** frontend | **Holds** — `:738` is `if !self.fold_projection_active()` | +| `src/window.rs:339` stale invariant comment | `src/window.rs:562` | "a semantic session never enters `paint_frame`" | **Holds, still stale.** Updating it remains a Stage 2 obligation | +| `src/statusline.rs:634` indirect `view.active` read | `src/statusline.rs:629`, `:644`, `:675` | one read to close | **Revised: three sites**, not one | +| `src/daemon.rs:3122-3130` grid-only `Mouse` | `src/daemon.rs:3123` | `Mouse` is contractually the grid path | **Holds** | +| `pmacs-gpu/src/attach.rs:420-429`, `:573-577` | `pmacs-gpu/src/attach.rs:577` | permanent `24×80` placeholder | **Holds**, single site now | + +Nothing in the parent's mechanical model was falsified by the +re-scout. The decisions in §4 come from what Stage 1 *added*, not from +anything Stage 2 got wrong. + +## 2. What Stage 1 already built for Stage 2 + +More than the parent framing anticipated, which shrinks Stage 2 and +changes one of its wire contracts. + +- **`DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }` + exists** (`src/window.rs:522-528`), stored as + `FrontendView::frame_geometry: Option<_>` (`:589`) with `None` + meaning **unknown** — exactly Q#BP15a's "unknown is first-class". +- **The declaration path exists.** + `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) calls + `EditorCore::declare_frame_geometry` then `reconcile_panel_layout`. + Two daemon sites already drive it, both gated on + `panel_capable_for` (`src/daemon.rs:1882-1883` at attach, + `:1972-1973` on resize). +- **`paint_frame` itself declares geometry** (`src/editor.rs:3187`), + before the statusline fan-out and before the long mutable core + borrow, with a comment naming Q#BP2b/Q#BP15a. +- **`StatuslineEvaluationTarget`** (`src/statusline.rs:212-226`) is + already a two-variant enum — `Grid { frontend_id }` and `Semantic { + frontend_id, declared_buffer }`. Q#BP8's "generalize the fan-out" + is an added variant, not a refactor of a concrete type. +- **`primary_document_window`** exists (`src/editor_core.rs:2830`). + +## 3. What the re-scout found + +### 3.1 The geometry epoch is allocated daemon-side; the wire contract says frontend-side + +This is the one genuine conflict between landed Stage 1 and framed +Stage 2, and it needs a decision before implementation. + +`declare_frame_geometry` (`src/editor_core.rs:3155-3172`) **allocates +the epoch itself**: + +```rust +let next = view + .frame_geometry + .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); +``` + +Q#BP15a specifies the opposite: `FrontendEvent::FrontendCellGeometry { +frontend_id, geometry_epoch, total }` carries a **frontend-owned** +declaration id, and `PanelResizeRows` / `PanelPointer` / every +`Present` echo it. Under the landed code the daemon would have to +either ignore the wire epoch (breaking the echo contract the GPU +validates against) or overwrite its own allocator for panel-capable +semantic frontends only (two allocation regimes for one field). + +Two further details of the landed allocator matter: + +- **It dedups on value.** The function returns early when `total` is + unchanged, so the epoch advances only on an actual size change. For + a grid frontend that is correct — cells are the unit, and an + unchanged grid means an old `PanelFrame` is still valid under the new + metrics. For a frontend that *owns* its epoch, the daemon cannot + dedup by value without discarding a declaration the frontend already + considers current. +- **`saturating_add` is neither wrapping nor fail-closed.** Q#BP15a + requires exhaustion to "fail closed rather than wrap". Saturation + pins the epoch at `u64::MAX`, after which two different geometries + share one id — the exact staleness confusion the epoch exists to + prevent. Unreachable in practice; wrong as a contract, and free to + fix. + +**Q#BP2S1 (new, needs a decision).** Three candidate resolutions: + +1. **Frontend-owned, as framed.** `FrontendCellGeometry` carries the + epoch; the daemon stores it verbatim for semantic panel-capable + frontends and rejects a lower-or-equal epoch carrying different + data. Grid/LOCAL keep the local allocator, which never collides + because those frontends never send the event. Cost: one field, two + provenances, documented. +2. **Daemon-owned, GPU echoes.** `FrontendCellGeometry` carries only + `total`; the daemon allocates and the GPU learns its current epoch + from the next `PanelFrame`. Simpler invariant, but it reintroduces a + first-open ordering problem — the GPU must send `PanelResizeRows` + and `PanelPointer` carrying an epoch it has not been told yet, so + the first gesture after a resize is unvalidatable and must be + dropped. +3. **Frontend-owned everywhere.** Grid/LOCAL synthesize an epoch at + their existing declaration sites and the allocator moves out of + `EditorCore` entirely. Most uniform; largest Stage 1 churn, and it + touches code #155 just stabilized. + +**Recommendation: option 1.** It preserves the parent framing's +validation chain intact, and the "two provenances" cost is one doc +comment on a field that already carries three. + +### 3.2 The §1.3 census is essentially unrouted + +The parent framing's §1.3 lists 23 transitive active-context reads that +must route through `primary_document_window` before a panel can hold +focus without corrupting the document mirror. Stage 1 created the seam +but routed almost nothing through it: `primary_document_window` has +**three** references in `src/`, one of which is its own definition and +one a doc-comment link. The single production caller is +`src/daemon.rs:1639`. + +For scale, `src/*.rs` still contains ~80 non-test direct `.active` +reads (excluding `active_frontend`, setters, and predicates), on top of +the `active_window*` / `active_buffer*` helper family at +`src/editor_core.rs:663-967`. + +This is not a defect in Stage 1 — with `panel_capable = false` for +every semantic session, no semantic frontend can hold a side window, so +the unrouted reads are unreachable from the GPU. It does mean **the +census is the bulk of Stage 2's work**, not a tidy-up at the end, and +the stage plan in §5 sequences it first. + +### 3.3 The statusline read is three sites, not one + +Q#BP8 says closing the indirect `view.active` read at +`src/statusline.rs:634` falls out of the target generalization. There +are three: `:629` and `:675` compute `active: window_id == +view.active`, and `:644` does `.get(&view.active)`. They are the same +concern, but a fix that closes one and leaves two is a live risk, and +the acceptance criterion should name all three. + +### 3.4 Scout obligations still open + +Stated plainly rather than papered over. These were not re-verified in +this pass and must be before the doc leaves revision 1: + +- `pmacs-protocol/src/terminal.rs`'s validator internals, which Q#BP15 + asks to factor into a shared parameterized wire-cell-grid validator + (the `MAX_TERMINAL_ROWS/COLS = 512` split). +- `pmacs-gpu/src/attach.rs`'s bounded outbox policy and its existing + tail-coalescing classes, which Q#BP15a asks to extend with two new + classes and Q#BP16 with two more. +- The GPU-side band renderer and where it clips against the status + band — Q#BP15a's pixel formula is stated but its inputs + (`status_band_height_px`, `TEXT_TOP_px`, `code_line_height_px`, + `resolved_monospace_advance_px`) were not located in this pass. +- Whether folding Stage 3 lands first. Both stages touch the semantic + projection, and the ledger's standing rule is that whichever is + framed second re-scouts the other's landed state. + +## 4. Revisions to the parent framing + +Only these. Everything else in Q#BP8/9/14b/15/15a/16/17 stands. + +- **Q#BP9 resolves to v21.** No reservation was taken; none was needed. +- **Q#BP15a's epoch ownership is reopened as Q#BP2S1** (§3.1). +- **Q#BP8's statusline criterion names three sites** (§3.3). +- **Q#BP17's stale comment is at `src/window.rs:562`**, and its text is + now embedded in a longer `fold_projection` doc block that also + explains the Stage 2/Stage 3 split — the edit is a paragraph rewrite, + not a one-line correction. + +## 5. What ships, in order + +Sequenced so each step is independently gateable and the census — the +riskiest part — lands before anything depends on it. + +1. **Route the census.** Every §1.3 read through + `primary_document_window`, with `panel_capable` still `false`. No + wire change, no behavior change for any existing frontend; pure + seam adoption, falsifiable by revert. +2. **Extract the per-window painter.** Lift `paint_frame`'s per-window + body plus the active-window cursor-visible preparation into a + function taking the fold map as a **parameter** (Q#BP17), leaving + `sync_frame_geometry` and the statusline fan-out where Stage 1 put + them. Grid rendering must be byte-identical. +3. **Protocol v21.** Append `InstanceMessage::PanelFrame` and + `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, + PanelPointer}`, each with a byte pin on the current final variant. + Factor the shared cell-grid validator. Gated both directions. +4. **Daemon-side panel projection.** `PanelFrame` production, + presentation epochs, `Absent` authority, the third statusline + target, the focus-chrome pass (Q#BP14b). +5. **GPU band.** Geometry declaration, band paint, divider chrome, + document clip, pointer transport, and the `panel_capable = true` + flip for semantic sessions — the flip is last, and it is what makes + the whole stage observable. + +Steps 1 and 2 are reviewable without any protocol change and could ship +as a separate PR if the user prefers a smaller first review. **That is +one of the questions in §8.** + +## 6. Coherence impact (per `COHERENCE.md` §20) + +- **Journey steps touched:** none directly. Stage 2 does not add a + journey step; it removes a frontend-dependent *hole* in one. Today a + user who runs `pmacs --gpu` and triggers compile, grep, references, + or a terminal gets the non-side fallback — the panel silently becomes + a stolen window. Every journey step that ends in an output surface + behaves differently on GPU than on TUI, and Stage 2 is what closes + that. +- **Interaction islands added: none, and this is a reduction.** §6 + grades islands "weak, and growing by one island per modal feature". + The panel is the opposite move: `display = "panel"` is one adopted + policy across listview, compile, and terminal, and Stage 2 extends + the existing policy to a second frontend rather than adding a + parallel GPU-only surface. The focus-chrome routing table (Q#BP14b) + deliberately reuses the existing `SearchPrompt` / `MenuPrompt` / + `CompletionPopup` messages instead of minting panel-specific ones. +- **Config registry adoption:** inherited, not extended. Stage 1's + `window.panel-height` and `window.min-height` already live in the + registry; Stage 2 adds no new user-facing option. If the GPU needs a + band-specific preference, it enters the registry — no new + configuration mechanism. +- **Background-work attribution:** unchanged. Stage 2 introduces no + worker, task, or process. It does, however, make §9's "✓ mechanics / + ✗ visibility" gap materially cheaper to close on the GPU: a terminal + PTY appearing in no user-visible activity view (§9) is partly a + *placement* problem, and after Stage 2 both frontends have a place to + put one. +- **Section this serves:** `COHERENCE.md` §14, which already records + the panel primitive as landed for Stage 1 and names "Stage 2 (GPU + band) pending its own framing" as the open item. This is that doc. + +## 7. Acceptance criteria (draft) + +Numbered for review; each must be falsifiable by revert, per the +standing lesson that a guard with no production caller passes every +direct-call test. + +1. Every §1.3 census read resolves through `primary_document_window`; + asserted at the outermost user-reachable seam, not by direct call. +2. All three `src/statusline.rs` active reads route through the new + frontend-layout target. +3. Grid `paint_frame` output is byte-identical across the extraction. +4. A semantic frontend with `fold_projection = false` painting a panel + over a folded buffer shows **every** source line (Q#BP17), and the + panel path never calls `fold_map_for_window`. +5. v21 round-trips; v20 peers negotiate without the panel events; each + extended enum's previous final variant is byte-pinned. +6. `Absent` is emitted on both close and hide, and clears input + authority before any later event validates. +7. A `PanelPointer` failing any of Q#BP16's six checks mutates no view, + controller, selection, menu, or PTY. +8. A geometry epoch change makes an older `PanelFrame` non-painted and + non-hit-testable until a matching `Present` arrives. +9. A panel wider than 512 columns is legal; a panel exceeding the + shared wire budget fails closed to `Absent`, not to a partial frame. +10. Panel focus does not disturb the document mirror + (`BufferSnapshot`, `CursorByte`, `Viewport`). +11. Native popups clear on document→panel focus change and panel + popups clear on panel→document, per Q#BP14b's ordering. + +## 8. Questions for the user + +1. **Q#BP2S1 epoch ownership** (§3.1) — recommendation is option 1, + frontend-owned with the daemon storing verbatim. Confirm or pick + another. +2. **One PR or two?** Steps 1–2 (census + extraction) are protocol-free + and independently valuable; steps 3–5 are the wire and the band. + Splitting gives two small reviews instead of one very large one, at + the cost of a second round-trip. +3. **Ordering against folding Stage 3.** Both touch the semantic + projection. Framing bottom-panel Stage 2 first means folding Stage 3 + re-scouts against a landed band. Confirm that order. +4. **Scout obligations in §3.4** — should revision 2 close all four + before implementation, or is the GPU-side pixel formula (the largest + of them) allowed to be settled during implementation? + +## 9. Gates + +The standing suite, plus `bottom_panel_stage1_acceptance` and a new +`bottom_panel_stage2_acceptance`, the three vterm suites (the panel +hosts terminals), folding Stage 2's 48 (shared projection), and +`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. Protocol round-trip and +byte-pin tests ride step 3. From 69f9a1bdf9a8e4ce4247754c9c48a47f39f579e4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 18:23:06 -0400 Subject: [PATCH 20/91] docs: bottom-panel Stage 2 framing (revision 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review round 1 — 2 blocking, 3 high, 3 revision points — and rebases the ground truth onto `main` @ `d152120`. Both blockers were rev 1 asserting something the parent framing already decided otherwise: - **R1-1.** Rev 1 said all 23 census reads route through `primary_document_window`. Q#BP14 routes only the **Projection** class that way; focus/input (#13-#15, #23), focus chrome and surface-routed (#16-#19), and focus/session (#20) keep their own authorities. Rev 1's rule would have broken remote-op validation and application, `DispatchIdle`, presence, focused search/menu/completion routing, and terminal bell ownership. §3.2 restores the four classes as a table and the acceptance asserts each separately — the focus-class assertions are the load-bearing half, since a test that only proves "the document is used" passes with them wrongly rerouted. - **R1-2.** The three `src/statusline.rs` active reads have two dispositions, not one. Only `:644` selects the wrong window; `:629` and `:675` must keep tracking actual focus, because grid contexts need a truthful `active`, revalidation must notice a focus change, and parent acceptance 42 requires a document provider to be able to observe `active = false` while the panel is focused. The three high findings: - Q#BP2S1 resolves to frontend-owned epochs (option 1) — a font or scale transaction can need to invalidate an old `PanelFrame` while the derived `CellSize` is identical, which daemon value dedup cannot detect. Rev 2 adds the four-row transition table, splits grid allocation from semantic acceptance into two APIs rather than one ambiguous method, moves the grid allocator off `saturating_add` to checked-with-fail-closed, and defines the initial epoch and both exhaustion behaviors. Rev 1's "rejects a lower-or-equal epoch carrying different data" was itself wrong: a lower epoch carrying identical data is still stale. - The `panel_capable` flip is narrowed to an authenticated semantic session negotiated at **v21 or later**. Denying a v20 peer the new events is insufficient if the daemon still places its window in a side panel it cannot render — the gate is on placement. - Parent acceptance criteria 37-55 are declared authoritative and mapped to slices 2A/2B, with rev 1's eleven drafts demoted to refinements. The painter-extraction criterion now pins cells, the returned cursor, the focused window's `view_top` mutation, and passive-window state. All four scout obligations are closed (§5), and the pixel formula is treated as contract work, not implementation detail: - The shared/terminal-only validator boundary is named exactly. - Four new outbox tail-coalescing tags beside the existing four. - **`State::mono_advance` is unsafe to adopt**: absent a `FontFacts` probe it samples the document's first shaped glyph, which would make panel columns document-dependent. The declaration uses the existing stable normal-face `probe_mono_advance` instead, and declares zero usable geometry when it returns `None`. - `BASE_DIVIDER_HEIGHT` does not exist. Rev 2 decides its scaling and requires **one** document-bottom accessor routing every consumer (caret, hits, minimap, terminal geometry, clipping, edge scrolling) — a second unrouted seam is precisely the Stage 1 `Layout::compute` two-caller defect. The concrete base value is left open for round 2. Also: the coherence statement now names journey steps 7-10 instead of claiming none, and drops rev 1's overclaim that this advances background-work visibility — a panel gives output a placement but adds no join key to COHERENCE §9's four disjoint activity planes. The ledger's bottom-panel lane is updated from "no branch and no framing yet" to the framing's real state, and carries an explicit correction: that entry was itself the source of rev 1's census mis-statement. Factual corrections: `InitialTargetResult` is at `message.rs:1145`; `primary_document_window` has four references and two production paths (`daemon.rs:1639`, and `daemon.rs:2998` via `primary_document_buffer`, which is census #22); fifteen PRs merged since the parent's last re-scout, not eleven. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 44 +- docs/bottom-panel-stage2-framing.md | 681 +++++++++++++++++----------- 2 files changed, 449 insertions(+), 276 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index e031b4a..cdeddc6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -389,28 +389,42 @@ If it does not, stop and repair the remote/fetch configuration. **isolated-config workspace sweep 3,177 across 92 suites, zero failures**; `git diff --check` clean. Gates were run against the committed tree. -## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 (GPU band) is next +## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 IN FRAMING -Stage 1 is on `main`; nothing in this arc is in flight. Stage 2 has **no -branch and no framing yet** — the approved parent framing -`docs/bottom-panel-framing.md` (rev 4) is what it re-scouts against. +Stage 1 is on `main`. **Stage 2 is in framing**, no implementation in +flight. - Stage 1 merged as **#155** (`main` @ `e745068`, 2026-07-24, after two review rounds). No protocol change. Durable substrate facts live in `docs/agent-handoff.md` §1; the two round lessons are in §5. +- Landed-docs follow-up merged as **#156** (`main` @ `d152120`, + 2026-07-25). +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 2**, + on branch `githubsucks/bottom-panel-stage2-framing`, worktree + `../pmacs-bp-stage2`, based on `githubsucks/main` @ `d152120`. Round 1 + closed 2 blocking + 3 high findings; awaiting round 2. The approved + parent framing `docs/bottom-panel-framing.md` (rev 4) remains + authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree `../pmacs-bottom-panel`. -- **Stage 2 obligations, already named by the framing** — the starting - point for its own framing doc: `InstanceMessage::PanelFrame` plus - `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}` - at the next available protocol version, gated in both directions and - each extended enum byte-pinned on its own previous final variant; - extracting `paint_frame`'s per-window body *together with* the - active-window auto-scroll preparation; routing every consumer in the - framing's §1.3 census of 23 transitive active-context reads through - `primary_document_window`; the focus-chrome surface matrix (Q#BP14b); - and Q#BP17's fold-projection parameter plus the stale invariant comment - at `src/window.rs`. Stage 3 is the adopter default flip. +- **Stage 2 ships as two serial slices**, 2A landing before 2B branches: + **2A** = classified §1.3 census routing + `paint_frame` per-window + painter extraction (with the active-window auto-scroll preparation), no + protocol change; **2B** = protocol **v21** + (`InstanceMessage::PanelFrame` plus + `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`, + gated both directions, each extended enum byte-pinned on its own + previous final variant), daemon panel projection, the GPU band, and the + negotiated `panel_capable` flip. Stage 3 is the adopter default flip. +- **Correction — this entry previously mis-stated the census contract.** + It is **not** "route every consumer through `primary_document_window`". + Q#BP14 classifies the 23 reads into four classes and routes only the + **Projection** class that way; focus/input (#13–#15, #23), focus chrome + and surface-routed (#16–#19), and focus/session (#20) keep their own + authorities. Rerouting them would break remote-op validation and + application, `DispatchIdle`, presence, focused search/menu/completion + routing, and terminal bell ownership. The Stage 2 framing carries the + full table. - **Folding Stage 3 and this arc's Stage 2 both touch the semantic projection.** Whichever is framed second re-scouts the other's landed state. diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index 19f9203..c85821c 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,326 +1,485 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 1 — pre-implementation. Ground truth: canonical `main` @ -`5aa9044`, protocol v20, 2026-07-25.** +**Revision 2 — pre-implementation. Ground truth: canonical `main` @ +`d152120`, protocol v20, 2026-07-25.** Stage 1 (#155, merge `e745068`) gave pmacs window placement, window parameters, TUI side windows, the divider, and the adopter `display` opt-in. It deliberately set `FrontendView::panel_capable = false` for every semantic session, so a GPU frontend silently falls back to the -non-side target. **Stage 2 flips that bit and earns the right to.** +non-side target. **Stage 2 flips that bit, under an exact negotiated +rule, and earns the right to.** This document is the re-framing `docs/bottom-panel-framing.md` (rev 4) §2 requires before Stage 2 is implemented. It does **not** restate the -parent's decisions. It records what the re-scout against current `main` -found: which anchors moved, which parent claims survived, which did -not, and the four questions the parent framing cannot answer without a -decision from the user. +parent's decisions or replace its acceptance criteria. It records the +re-scout against current `main`, closes the four scout obligations +review round 1 required, and fixes what round 1 found wrong. -Read the parent framing's Q#BP8, Q#BP9, Q#BP14b, Q#BP15, Q#BP15a, -Q#BP16, and Q#BP17 alongside this. Those decisions stand except where -§4 below revises them. +**Inherited reading, all of which remains authoritative:** parent +Q#BP8 (the band), Q#BP9 (protocol), **Q#BP14 (the primary-document +projection contract and its census classification)**, **Q#BP14a (panel +input gating is per-window)**, Q#BP14b (focus chrome and per-window +overlay routing), Q#BP15 (`PanelFrame` lifecycle), Q#BP15a (three +geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and +**parent acceptance criteria 37–55**. -## 0. Why the re-scout was required +## 0. Revision history -The parent framing's Stage 2 sections were written against `main` @ -`0dd16a5` and last re-scouted at `47581f4`. Since then eleven PRs have -merged: #149/#150 (folding Stage 2), #152–#155 (through bottom-panel -Stage 1), #158–#166 (inline math, minimap, Lean 4 Stages 1–2, -COHERENCE.md, find-file, the dired framing and Stage 1, the GPU -terminal input fix). Every source anchor Stage 2 depends on has moved. +### 0.1 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed -Two things did **not** change, and both are load-bearing: - -- **Protocol is still v20.** `PROTOCOL_VERSION` is `20` - (`pmacs-protocol/src/message.rs:1568`); no intervening PR bumped it. - Q#BP9's conditional resolves: **Stage 2 is v21**. -- **Both byte pins are still the final variants.** - `InstanceMessage::InitialTargetResult` is last in its enum - (`message.rs:577` within the enum at `:569`), and - `FrontendEvent::TerminalPointer` is last in its own. Q#BP9's - append-plus-pin instruction applies verbatim, with no re-derivation. +- **R1-1 (blocker).** Rev 1 said all 23 census reads route through + `primary_document_window`. That contradicts Q#BP14, which routes only + the **Projection** class (#1–#12, #21–#22) that way and leaves focus, + input, chrome, and bell consumers on their own authorities. Rev 1's + rule would have broken remote-op validation, `DispatchIdle`, + presence, focused search/menu/completion routing, and bell ownership. + §3.2 now restores all four classes; §7's criterion pins them + separately. The inherited-reading list above gains Q#BP14 and Q#BP14a. +- **R1-2 (blocker).** Rev 1 treated the three `src/statusline.rs` + active reads as one disposition. Only `:644` selects the wrong + window; `:629` and `:675` must keep tracking **actual focus**. §3.3 + is rewritten and the criterion states the required behavior instead + of routing focus away. +- **R1-3 (high).** The `panel_capable` flip needed an exact attach + rule, not "for semantic sessions". §3.5 states it: **v21-or-later + negotiated authenticated semantic session only**. +- **R1-4 (high).** Option 1 accepted, but the epoch needed a state + machine, split APIs, and a fail-closed allocator. §3.1 now carries + the transition table and the API split. Rev 1's phrasing "rejects a + lower-or-equal epoch carrying different data" was itself wrong — a + lower epoch carrying *identical* data is still stale. +- **R1-5 (high).** Rev 1's eleven draft criteria silently omitted + parent 37–55. §7 now declares the parent list authoritative, maps it + to 2A/2B, and adds only refinements. The painter-extraction criterion + pins cursor, `view_top`, and passive-window state, not just cells. +- **R1-6.** All four scout obligations are closed in §5. +- **R1-7.** The coherence statement understated journey impact and + overclaimed on background work. §6 names journey steps 7–10 and + narrows the §9 claim. +- **R1-8.** Factual corrections in §1 and §3.2. ## 1. Anchor re-scout -Every line reference Stage 2 inherits, re-verified. "Claim" is the -parent framing's assertion about that site; "verdict" is what the code -at `5aa9044` actually says. +| Parent anchor | Now at | Verdict | +| --- | --- | --- | +| `paint_frame` returns cursor separately (`editor.rs:2833`) | `src/editor.rs:3171` | Holds | +| Cursor-visible prep (`editor.rs:2883-2935`) | `src/editor.rs:3249+` | Holds; Stage 1 inserted work above it (§2) | +| Per-window paint body (`editor.rs:2937-3040`) | after `src/editor.rs:3260` | Holds | +| `fold_map_for_window` gates on the **active** frontend (`editor_core.rs:566`) | `src/editor_core.rs:734`, gate at `:738` | Holds | +| Stale "semantic session never enters `paint_frame`" (`window.rs:339`) | `src/window.rs:562` | Holds, still stale; now embedded in a longer `fold_projection` doc block, so the edit is a paragraph rewrite | +| `Mouse` is contractually the grid path (`daemon.rs:3122-3130`) | `src/daemon.rs:3123` | Holds | +| Permanent `24×80` placeholder (`attach.rs:420-429`, `:573-577`) | `pmacs-gpu/src/attach.rs:577`, single site | Holds | +| Byte pin `InstanceMessage::InitialTargetResult` | `pmacs-protocol/src/message.rs:1145` | Holds — still the enum's final variant | +| Byte pin `FrontendEvent::TerminalPointer` | final variant of its enum | Holds | -| Parent anchor | Now at | Claim | Verdict | -| --- | --- | --- | --- | -| `src/editor.rs:2833` `paint_frame` returns cursor separately | `src/editor.rs:3171` | cells alone lose the caret | **Holds.** Signature still returns `Option` | -| `src/editor.rs:2883-2935` cursor-visible prep | `src/editor.rs:3249+` | extract with the per-window body | **Holds**, but see §3.1 — Stage 1 inserted work *above* it | -| `src/editor.rs:2937-3040` per-window paint body | after `:3260` | origin-agnostic `Viewport<'a>`, extractable | **Holds** | -| `src/editor_core.rs:566` `fold_map_for_window` | `src/editor_core.rs:734` | gates on the **active** frontend | **Holds** — `:738` is `if !self.fold_projection_active()` | -| `src/window.rs:339` stale invariant comment | `src/window.rs:562` | "a semantic session never enters `paint_frame`" | **Holds, still stale.** Updating it remains a Stage 2 obligation | -| `src/statusline.rs:634` indirect `view.active` read | `src/statusline.rs:629`, `:644`, `:675` | one read to close | **Revised: three sites**, not one | -| `src/daemon.rs:3122-3130` grid-only `Mouse` | `src/daemon.rs:3123` | `Mouse` is contractually the grid path | **Holds** | -| `pmacs-gpu/src/attach.rs:420-429`, `:573-577` | `pmacs-gpu/src/attach.rs:577` | permanent `24×80` placeholder | **Holds**, single site now | +**Protocol is still v20** (`pmacs-protocol/src/message.rs:1568`); no +intervening PR bumped it. Q#BP9's conditional resolves: **Stage 2 is +v21**, no reservation was taken and none was needed. -Nothing in the parent's mechanical model was falsified by the -re-scout. The decisions in §4 come from what Stage 1 *added*, not from -anything Stage 2 got wrong. +Fifteen PRs merged between the parent's last re-scout (`47581f4`) and +this one: #149, #150, #152–#155, #158–#166. Nothing in the parent's +mechanical model was falsified by any of them. ## 2. What Stage 1 already built for Stage 2 -More than the parent framing anticipated, which shrinks Stage 2 and -changes one of its wire contracts. +- `DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }` + (`src/window.rs:522-528`), held as + `FrontendView::frame_geometry: Option<_>` (`:589`) where `None` means + **unknown** — Q#BP15a's "unknown is first-class", already landed. +- `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) → + `declare_frame_geometry` + `reconcile_panel_layout`, driven from two + daemon sites gated on `panel_capable_for` (`src/daemon.rs:1882-1883` + attach, `:1972-1973` resize). +- `paint_frame` declares geometry itself (`src/editor.rs:3187`), before + the statusline fan-out and before the long mutable core borrow. +- `StatuslineEvaluationTarget` (`src/statusline.rs:212-226`) is already + a two-variant enum, so Q#BP8's fan-out generalization is an added + variant, not a refactor. +- `primary_document_window` (`src/editor_core.rs:2830`) and + `primary_document_buffer` (`:2845`). -- **`DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }` - exists** (`src/window.rs:522-528`), stored as - `FrontendView::frame_geometry: Option<_>` (`:589`) with `None` - meaning **unknown** — exactly Q#BP15a's "unknown is first-class". -- **The declaration path exists.** - `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) calls - `EditorCore::declare_frame_geometry` then `reconcile_panel_layout`. - Two daemon sites already drive it, both gated on - `panel_capable_for` (`src/daemon.rs:1882-1883` at attach, - `:1972-1973` on resize). -- **`paint_frame` itself declares geometry** (`src/editor.rs:3187`), - before the statusline fan-out and before the long mutable core - borrow, with a comment naming Q#BP2b/Q#BP15a. -- **`StatuslineEvaluationTarget`** (`src/statusline.rs:212-226`) is - already a two-variant enum — `Grid { frontend_id }` and `Semantic { - frontend_id, declared_buffer }`. Q#BP8's "generalize the fan-out" - is an added variant, not a refactor of a concrete type. -- **`primary_document_window`** exists (`src/editor_core.rs:2830`). +## 3. Findings and decisions -## 3. What the re-scout found +### 3.1 Q#BP2S1 — epoch ownership, resolved: frontend-owned, with an exact state machine -### 3.1 The geometry epoch is allocated daemon-side; the wire contract says frontend-side +**Decision: option 1.** The epoch is owned by the frontend for +negotiated semantic-panel sessions. The deciding argument is one rev 1 +missed: **a font or scale transaction can require invalidating an old +`PanelFrame` even when the derived `CellSize` is identical.** Daemon +value dedup cannot detect that case, because the cell totals it +compares are unchanged while the pixels behind them are not. -This is the one genuine conflict between landed Stage 1 and framed -Stage 2, and it needs a decision before implementation. +The landed allocator conflicts in three ways +(`src/editor_core.rs:3155-3172`): it allocates the id itself, it +early-returns when `total` is unchanged (value dedup), and it uses +`saturating_add`, which is neither wrapping nor fail-closed — it pins +at `u64::MAX`, after which two different geometries share one id. -`declare_frame_geometry` (`src/editor_core.rs:3155-3172`) **allocates -the epoch itself**: +**Acceptance rules for a semantic declaration:** -```rust -let next = view - .frame_geometry - .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); -``` +| Incoming declaration | Result | +| --- | --- | +| epoch **greater** than stored | Accept, store **verbatim**, even if `total` is unchanged | +| same epoch, same `total` | Idempotent no-op | +| same epoch, **different** `total` | Reject | +| **lower** epoch, any `total` | Reject | -Q#BP15a specifies the opposite: `FrontendEvent::FrontendCellGeometry { -frontend_id, geometry_epoch, total }` carries a **frontend-owned** -declaration id, and `PanelResizeRows` / `PanelPointer` / every -`Present` echo it. Under the landed code the daemon would have to -either ignore the wire epoch (breaking the echo contract the GPU -validates against) or overwrite its own allocator for panel-capable -semantic frontends only (two allocation regimes for one field). +The last row is deliberate and corrects rev 1: a lower epoch carrying +identical data is still stale and must not be accepted. -Two further details of the landed allocator matter: +**API split.** Two methods, not one method with an optional epoch: -- **It dedups on value.** The function returns early when `total` is - unchanged, so the epoch advances only on an actual size change. For - a grid frontend that is correct — cells are the unit, and an - unchanged grid means an old `PanelFrame` is still valid under the new - metrics. For a frontend that *owns* its epoch, the daemon cannot - dedup by value without discarding a declaration the frontend already - considers current. -- **`saturating_add` is neither wrapping nor fail-closed.** Q#BP15a - requires exhaustion to "fail closed rather than wrap". Saturation - pins the epoch at `u64::MAX`, after which two different geometries - share one id — the exact staleness confusion the epoch exists to - prevent. Unreachable in practice; wrong as a contract, and free to - fix. +- `declare_frame_geometry(fid, total)` — the **grid/LOCAL** allocator. + Keeps value dedup (correct there: cells are the unit, and an + unchanged grid means an old frame is still valid under unchanged + metrics). Changes from `saturating_add` to **checked** allocation + with an explicit fail-closed exhaustion arm. +- `accept_frame_geometry(fid, geometry_epoch, total) -> bool` — the + **semantic** path. No value dedup; applies the table above verbatim; + returns whether the declaration was accepted so the caller can drop + a stale event before any reconciliation. -**Q#BP2S1 (new, needs a decision).** Three candidate resolutions: +An ambiguous single method with an `Option` epoch is rejected +explicitly: it would let a future caller silently take the wrong regime. -1. **Frontend-owned, as framed.** `FrontendCellGeometry` carries the - epoch; the daemon stores it verbatim for semantic panel-capable - frontends and rejects a lower-or-equal epoch carrying different - data. Grid/LOCAL keep the local allocator, which never collides - because those frontends never send the event. Cost: one field, two - provenances, documented. -2. **Daemon-owned, GPU echoes.** `FrontendCellGeometry` carries only - `total`; the daemon allocates and the GPU learns its current epoch - from the next `PanelFrame`. Simpler invariant, but it reintroduces a - first-open ordering problem — the GPU must send `PanelResizeRows` - and `PanelPointer` carrying an epoch it has not been told yet, so - the first gesture after a resize is unvalidatable and must be - dropped. -3. **Frontend-owned everywhere.** Grid/LOCAL synthesize an epoch at - their existing declaration sites and the allocator moves out of - `EditorCore` entirely. Most uniform; largest Stage 1 churn, and it - touches code #155 just stabilized. +**Initial epoch and exhaustion.** The frontend's first declaration +after attach acceptance carries epoch `1`; `0` is reserved as "never +declared" and is rejected on the wire. Frontend-side allocation is +checked; on exhaustion the frontend stops declaring and **hides its +panel** rather than reusing or wrapping an id — it sends no further +geometry, so the daemon's last accepted declaration stands and no new +`Present` can claim a fresh identity. Daemon-side exhaustion on the +grid path fails closed the same way: no new declaration, panel stays +at its last valid geometry or hides under Q#BP2b. -**Recommendation: option 1.** It preserves the parent framing's -validation chain intact, and the "two provenances" cost is one doc -comment on a field that already carries three. +### 3.2 The census is classified, and it is mostly unrouted -### 3.2 The §1.3 census is essentially unrouted +**Correction to rev 1.** Q#BP14 routes only the **Projection** class +through `primary_document_window`. Rev 1's "all 23 reads" was wrong and +would have broken five subsystems. The four classes, restored: -The parent framing's §1.3 lists 23 transitive active-context reads that -must route through `primary_document_window` before a panel can hold -focus without corrupting the document mirror. Stage 1 created the seam -but routed almost nothing through it: `primary_document_window` has -**three** references in `src/`, one of which is its own definition and -one a doc-comment link. The single production caller is -`src/daemon.rs:1639`. +| Class | Census items | Authority | +| --- | --- | --- | +| **Projection** | #1–#7, #9, #10, #12, #21, #22 | `primary_document_window` / `primary_document_buffer` | +| **Projection + focus** | #8 (document `Pointer`), #11 (full-window `TerminalPointer`) | Align the primary document window **and then activate it** — the one place the two legitimately move together | +| **Focus / input** | #13 (remote-op validation), #14 (`dispatch_idle_for`), #15 (presence), #23 (remote-op application) | The frontend's **actually focused** window. Q#BP14a: gating is per-window, never per-buffer | +| **Focus chrome / surface-routed** | #16–#19 (search, menu, minibuffer, completion) | Q#BP14b's routing table — the currently owned surface, with authoritative clears for the other | +| **Focus / session** | #20 (terminal bell drain) | Per-session counter; the **focused** window chooses which session may drain | -For scale, `src/*.rs` still contains ~80 non-test direct `.active` -reads (excluding `active_frontend`, setters, and predicates), on top of -the `active_window*` / `active_buffer*` helper family at -`src/editor_core.rs:663-967`. +Rerouting any of the last three classes to the document is a defect, +not a simplification: it would break remote-op validation and +application, `DispatchIdle`, presence, focused search/menu/completion +routing, and bell ownership. -This is not a defect in Stage 1 — with `panel_capable = false` for -every semantic session, no semantic frontend can hold a side window, so -the unrouted reads are unreachable from the GPU. It does mean **the -census is the bulk of Stage 2's work**, not a tidy-up at the end, and -the stage plan in §5 sequences it first. +**How much is already routed.** `primary_document_window` has **four** +references in `src/` and **two production paths**: directly at +`src/daemon.rs:1639` (#148's initial-target bootstrap, Q#BP11b), and +through `primary_document_buffer` at `src/daemon.rs:2998`, which is +census **#22** and carries a comment naming it. So one census item is +routed and the Projection class is otherwise open. For scale, `src/*.rs` +still holds ~80 non-test direct `.active` reads on top of the +`active_window*` / `active_buffer*` helper family +(`src/editor_core.rs:663-967`). -### 3.3 The statusline read is three sites, not one +This is not a Stage 1 defect — with `panel_capable = false` no semantic +frontend can hold a side window, so the unrouted Projection reads are +unreachable from the GPU. It does mean **classified census routing is +the bulk of Stage 2**, which is why it is Stage 2A. -Q#BP8 says closing the indirect `view.active` read at -`src/statusline.rs:634` falls out of the target generalization. There -are three: `:629` and `:675` compute `active: window_id == -view.active`, and `:644` does `.get(&view.active)`. They are the same -concern, but a fix that closes one and leaves two is a live risk, and -the acceptance criterion should name all three. +### 3.3 The three statusline reads have two dispositions, not one -### 3.4 Scout obligations still open +All three sites are real, but only one is wrong: -Stated plainly rather than papered over. These were not re-verified in -this pass and must be before the doc leaves revision 1: +- `src/statusline.rs:644` — `.get(&view.active)` **selects the wrong + window** when a panel is focused. This is the Projection read (#12). +- `src/statusline.rs:629` and `:675` — `active: window_id == + view.active` **must continue tracking actual focus**. Three reasons: + grid contexts need a truthful `active`; post-callback revalidation + must notice a focus change; and parent acceptance 42 explicitly + requires that a document provider may observe `active = false` while + the panel is focused. -- `pmacs-protocol/src/terminal.rs`'s validator internals, which Q#BP15 - asks to factor into a shared parameterized wire-cell-grid validator - (the `MAX_TERMINAL_ROWS/COLS = 512` split). -- `pmacs-gpu/src/attach.rs`'s bounded outbox policy and its existing - tail-coalescing classes, which Q#BP15a asks to extend with two new - classes and Q#BP16 with two more. -- The GPU-side band renderer and where it clips against the status - band — Q#BP15a's pixel formula is stated but its inputs - (`status_band_height_px`, `TEXT_TOP_px`, `code_line_height_px`, - `resolved_monospace_advance_px`) were not located in this pass. -- Whether folding Stage 3 lands first. Both stages touch the semantic - projection, and the ledger's standing rule is that whichever is - framed second re-scouts the other's landed state. +**The new semantic-layout target** therefore captures the **primary +document window plus the visible side window**, marks each context +`active` iff its `window_id == view.active`, invokes each provider +**exactly once**, and **invalidates the entire evaluation** if a +callback mutates layout or focus. Unprojected document splits run no +callbacks (Q#BP8). Route the primary-document result to semantic +`StatuslineSegments` and the side result to the panel mode line. + +### 3.4 Fold projection + +Unchanged from Q#BP17, with the anchor corrected: the extracted painter +takes the map as a **parameter**; the panel path passes `None` when the +owning frontend's `fold_projection` is false and must never call +`fold_map_for_window`, which gates on the **active** frontend +(`src/editor_core.rs:734`, gate at `:738`) — right for command-time +reckoning, wrong for painting another frontend's panel. The stale +comment is at `src/window.rs:562`. + +### 3.5 The `panel_capable` flip needs a negotiated rule + +Not "true for semantic sessions". Exactly: + +> `panel_capable = true` **only** for an authenticated semantic session +> that negotiated **v21 or later**. + +A v6–v20 semantic frontend stays non-panel-capable and takes the +existing Stage 1 fallback: the non-side target with **every +side-specific parameter discarded**, leaving the document window +undedicated (Q#BP2c). "It receives no new events" is insufficient — if +the daemon nevertheless places that frontend's window in a side panel +it cannot render, the window becomes invisible. The gate is on +placement, not only on transport. Parent acceptance 51 pins the mixed +session. ## 4. Revisions to the parent framing -Only these. Everything else in Q#BP8/9/14b/15/15a/16/17 stands. +Only these; everything else stands. -- **Q#BP9 resolves to v21.** No reservation was taken; none was needed. -- **Q#BP15a's epoch ownership is reopened as Q#BP2S1** (§3.1). -- **Q#BP8's statusline criterion names three sites** (§3.3). -- **Q#BP17's stale comment is at `src/window.rs:562`**, and its text is - now embedded in a longer `fold_projection` doc block that also - explains the Stage 2/Stage 3 split — the edit is a paragraph rewrite, - not a one-line correction. +- **Q#BP9 resolves to v21.** +- **Q#BP15a's epoch ownership is specified** by §3.1's table and API + split, replacing the parent's one-line "frontend-owned" statement. +- **Q#BP8's statusline criterion splits** per §3.3: one read reroutes, + two keep tracking focus. +- **Q#BP17's stale comment is at `src/window.rs:562`**, and parent + acceptance 52's reference to `:339` should be read against that. -## 5. What ships, in order +## 5. The four scout obligations, closed -Sequenced so each step is independently gateable and the census — the -riskiest part — lands before anything depends on it. +### 5.1 The shared cell-grid validator boundary -1. **Route the census.** Every §1.3 read through - `primary_document_window`, with `panel_capable` still `false`. No - wire change, no behavior change for any existing frontend; pure - seam adoption, falsifiable by revert. -2. **Extract the per-window painter.** Lift `paint_frame`'s per-window - body plus the active-window cursor-visible preparation into a - function taking the fold map as a **parameter** (Q#BP17), leaving - `sync_frame_geometry` and the statusline fan-out where Stage 1 put - them. Grid rendering must be byte-identical. -3. **Protocol v21.** Append `InstanceMessage::PanelFrame` and - `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, - PanelPointer}`, each with a byte pin on the current final variant. - Factor the shared cell-grid validator. Gated both directions. -4. **Daemon-side panel projection.** `PanelFrame` production, - presentation epochs, `Absent` authority, the third statusline - target, the focus-chrome pass (Q#BP14b). -5. **GPU band.** Geometry declaration, band paint, divider chrome, - document clip, pointer transport, and the `panel_capable = true` - flip for semantic sessions — the flip is last, and it is what makes - the whole stage observable. +`TerminalFrame::validate` (`pmacs-protocol/src/terminal.rs:226`) +currently interleaves both concerns. The exact split: -Steps 1 and 2 are reviewable without any protocol change and could ship -as a separate PR if the user prefers a smaller first review. **That is -one of the questions in §8.** +- **Factored into the shared parameterized wire-cell-grid validator:** + checked area (the `checked_mul` + `usize::try_from` guard), the + `MAX_TERMINAL_VISIBLE_CELLS = 262,144` aggregate cap, cell-count + equality against declared area, cursor-in-bounds, and + `validate_cells`'s glyph width / continuation topology and aggregate + glyph-byte checks. +- **Stays terminal-only:** the `MAX_TERMINAL_ROWS/COLS = 512` per-axis + caps in `checked_area`, `validate_metadata` for title/signal/crash + text, `validate_selection`, and the `at_bottom == (scroll_offset == + 0)` coupling. + +`PanelFrame` takes the shared half plus its own presence/epoch rules +and does **not** inherit the 512 per-axis cap (Bet B5'), so a 4K +small-font panel wider than 512 columns is legal while the shared area +budget still binds. Parent acceptance 39 pins exactly this. + +### 5.2 The GPU outbox needs four more tags + +`coalesce_kind` (`pmacs-gpu/src/attach.rs:331`) today returns four +tail-only tags: `Viewport` → 0, `Pointer{Drag}` → 1, +`TerminalPointer{Move}` → 2, `TerminalPointer{Drag}` → 3. Everything +else is `None` = lossless, counting against `OUTBOX_MAX = 8192`. + +Stage 2 adds **four distinct tags**: `FrontendCellGeometry` → 4, +`PanelResizeRows` → 5, `PanelPointer{Move}` → 6, `PanelPointer{Drag}` +→ 7. Geometry is latest-wins (epochs need only increase, not be +consecutive); resize drag is latest-wins over the complete event +including its epochs. `PanelPointer` `Down`/`Up`/wheel/context stay +lossless and ordered — repeated left `Down`s are what the daemon click +state reads as a multi-click, and `Down(Right)` is the context-menu +gesture. Tail-only replacement preserves ordering across an +intervening event of any other class. + +### 5.3 The pixel formula's inputs — and one trap + +The formula in Q#BP15a is contract-level, not an implementation +detail, because its inputs are not all safe to adopt: + +| Input | Source | Note | +| --- | --- | --- | +| `status_band_height_px` | `FontMetrics::status_band_height` (`pmacs-gpu/src/main.rs:137`) = `BASE_STATUS_BAND_HEIGHT * scale` | Safe | +| `TEXT_TOP_px` | `const TEXT_TOP: f32 = 16.0` (`main.rs:352`) | Safe; unscaled today | +| `code_line_height_px` | `FontMetrics::code_line_height` (`main.rs:131`) = `BASE_CODE_LINE_HEIGHT * scale` | Safe | +| `resolved_monospace_advance_px` | `State::mono_advance` (`main.rs:4899`) | **Unsafe to adopt blindly** | +| `divider_height_px` | `BASE_DIVIDER_HEIGHT` | **Does not exist yet** | + +**The `mono_advance` trap.** `State::mono_advance` returns +`measured_mono_advance` when a `FontFacts` probe has been applied, but +otherwise falls back to **the first shaped glyph of the document +buffer** (`main.rs:4903+`). Panel column count would therefore become +**document-dependent**: two GPU frontends showing different files could +derive different `total.cols` from identical metrics, and the same +frontend's panel width could change when the document's first glyph +changes. + +**Decision.** The panel geometry declaration uses a **stable normal-face +probe**, never the document sample. `probe_mono_advance(font_system, +family, metrics)` (`main.rs:323`) already exists and is exactly this: it +shapes `ADVANCE_PROBE` in a scratch buffer, independent of document +contents, dividing total run width by logical cells so ligature +substitution survives. The declaration resolves its advance from that +probe for the current family/metrics. If the probe returns `None` (the +family shapes no width), the frontend declares **zero usable geometry** +under a new epoch — the panel hides — rather than falling back to a +document sample. + +**`BASE_DIVIDER_HEIGHT` must be decided before implementation**, because +the document-bottom seam it defines is depended on by caret placement, +hit testing, the minimap, terminal geometry, clipping, and edge +scrolling. Two sub-decisions: + +- **Value and scaling.** It joins the `BASE_*` family and scales as + `BASE_DIVIDER_HEIGHT * scale`, matching `status_band_height` — a + divider that does not scale with the font would misalign at non-1.0 + scale. The concrete base value is an open item for review round 2. +- **One seam, not several.** Today the document bottom is computed + from `status_band_height` at several sites + (`main.rs:3175`, `:3185`, `:6601`, `:6607`, `:8491`, and the + status-band rect at `:5910`). Stage 2 must introduce **one** + document-bottom accessor that subtracts status band **plus** the + installed band and divider, and route every one of those sites + through it. A second, unrouted seam is the exact shape of Stage 1's + `Layout::compute` two-caller defect, where `src/overlay_paint.rs` + derived its own rect and painted peer cursors at unfixed rows. + +Note the asymmetry Q#BP15a already requires: `divider_height_px` is +subtracted **for sizing purposes even while the panel is absent**, to +break the first-open cycle, while the document renderer does not +actually lose those pixels until a `Present` panel is painted. + +### 5.4 Ordering against folding Stage 3 + +Settled by review round 1: **bottom-panel Stage 2 first, through the +landed GPU band.** Folding Stage 3 then re-scouts the extracted +painter, the panel projection, clipping, and `fold_projection` behavior +exactly once. ## 6. Coherence impact (per `COHERENCE.md` §20) -- **Journey steps touched:** none directly. Stage 2 does not add a - journey step; it removes a frontend-dependent *hole* in one. Today a - user who runs `pmacs --gpu` and triggers compile, grep, references, - or a terminal gets the non-side fallback — the panel silently becomes - a stolen window. Every journey step that ends in an output surface +- **Journey steps touched: four, on the GPU frontend — steps 7–10** + (find symbol / find file, terminal, build and test, error + inspection). Rev 1 said "none directly", which contradicted its own + next sentence. Today a GPU user who triggers references, project + search, a terminal, compile, or error inspection gets the Stage 1 + non-side fallback: the output surface steals a document window + instead of opening a panel. Every one of those steps therefore behaves differently on GPU than on TUI, and Stage 2 is what closes - that. + the divergence. - **Interaction islands added: none, and this is a reduction.** §6 grades islands "weak, and growing by one island per modal feature". - The panel is the opposite move: `display = "panel"` is one adopted - policy across listview, compile, and terminal, and Stage 2 extends - the existing policy to a second frontend rather than adding a - parallel GPU-only surface. The focus-chrome routing table (Q#BP14b) - deliberately reuses the existing `SearchPrompt` / `MenuPrompt` / - `CompletionPopup` messages instead of minting panel-specific ones. -- **Config registry adoption:** inherited, not extended. Stage 1's + Stage 2 extends one already-adopted policy (`display = "panel"`, + used by listview, compile, and terminal) to a second frontend rather + than minting a GPU-only surface. Q#BP14b deliberately reuses the + existing `SearchPrompt` / `MenuPrompt` / `CompletionPopup` messages + instead of panel-specific twins. +- **Config registry adoption: inherited, not extended.** Stage 1's `window.panel-height` and `window.min-height` already live in the - registry; Stage 2 adds no new user-facing option. If the GPU needs a - band-specific preference, it enters the registry — no new - configuration mechanism. -- **Background-work attribution:** unchanged. Stage 2 introduces no - worker, task, or process. It does, however, make §9's "✓ mechanics / - ✗ visibility" gap materially cheaper to close on the GPU: a terminal - PTY appearing in no user-visible activity view (§9) is partly a - *placement* problem, and after Stage 2 both frontends have a place to - put one. -- **Section this serves:** `COHERENCE.md` §14, which already records - the panel primitive as landed for Stage 1 and names "Stage 2 (GPU - band) pending its own framing" as the open item. This is that doc. + registry. Stage 2 adds no new user-facing option; if the band needs + one, it enters the registry. +- **Background-work attribution: unchanged, and this stage does not + advance it.** Rev 1 implied Stage 2 helps §9's activity-view gap. It + does not. A panel gives output a coherent *placement*; it does not + make terminal PTYs, LSP servers, or workers appear in the + activity/ownership view §9 describes, and it adds no join key across + the four disjoint activity planes. The §9 gap is untouched. +- **Section this serves:** `COHERENCE.md` §14, which records the panel + primitive as landed for Stage 1 and names "Stage 2 (GPU band) + pending its own framing" as the open item. -## 7. Acceptance criteria (draft) +## 7. Acceptance -Numbered for review; each must be falsifiable by revert, per the -standing lesson that a guard with no production caller passes every -direct-call test. +**Parent criteria 37–55 remain authoritative and are not replaced.** +This section maps them to the two slices and adds only refinements. -1. Every §1.3 census read resolves through `primary_document_window`; - asserted at the outermost user-reachable seam, not by direct call. -2. All three `src/statusline.rs` active reads route through the new - frontend-layout target. -3. Grid `paint_frame` output is byte-identical across the extraction. -4. A semantic frontend with `fold_projection = false` painting a panel - over a folded buffer shows **every** source line (Q#BP17), and the - panel path never calls `fold_map_for_window`. -5. v21 round-trips; v20 peers negotiate without the panel events; each - extended enum's previous final variant is byte-pinned. -6. `Absent` is emitted on both close and hide, and clears input - authority before any later event validates. -7. A `PanelPointer` failing any of Q#BP16's six checks mutates no view, - controller, selection, menu, or PTY. -8. A geometry epoch change makes an older `PanelFrame` non-painted and - non-hit-testable until a matching `Present` arrives. -9. A panel wider than 512 columns is legal; a panel exceeding the - shared wire budget fails closed to `Absent`, not to a partial frame. -10. Panel focus does not disturb the document mirror - (`BufferSnapshot`, `CursorByte`, `Viewport`). -11. Native popups clear on document→panel focus change and panel - popups clear on panel→document, per Q#BP14b's ordering. +### 7.1 Stage 2A — classified census routing + painter extraction -## 8. Questions for the user +No protocol change. Parent criteria that apply in full: **42, 43, 44, +51 (the `LOCAL`-panel inheritance half), 52**. -1. **Q#BP2S1 epoch ownership** (§3.1) — recommendation is option 1, - frontend-owned with the daemon storing verbatim. Confirm or pick - another. -2. **One PR or two?** Steps 1–2 (census + extraction) are protocol-free - and independently valuable; steps 3–5 are the wire and the band. - Splitting gives two small reviews instead of one very large one, at - the cost of a second round-trip. -3. **Ordering against folding Stage 3.** Both touch the semantic - projection. Framing bottom-panel Stage 2 first means folding Stage 3 - re-scouts against a landed band. Confirm that order. -4. **Scout obligations in §3.4** — should revision 2 close all four - before implementation, or is the GPU-side pixel formula (the largest - of them) allowed to be settled during implementation? +Refinements 2A adds: -## 9. Gates +- **A2A-1 (replaces rev 1's criterion 1).** Every **Projection** census + item (#1–#7, #9, #10, #12, #21, #22) resolves through + `primary_document_window` / `primary_document_buffer`; #8 and #11 + align **and then activate**; **#13, #14, #15, #23 continue to resolve + the actually focused window**; #16–#19 follow Q#BP14b's routing + table; #20 keeps its per-session counter with focus choosing the + eligible terminal. Each class is asserted separately, at the + outermost user-reachable seam, and falsified by revert. A test that + only proves "the document is used" would pass with the focus classes + wrongly rerouted, so the focus-class assertions are the load-bearing + half. +- **A2A-2 (replaces rev 1's criterion 2).** `src/statusline.rs:644` + resolves the primary document window, while `:629` and `:675` + continue to report **actual focus** — pinned by a document provider + truthfully observing `active = false` while the panel is focused + (parent 42). The semantic-layout target captures primary document + + visible side window, invokes each provider exactly once, and + invalidates the whole evaluation when a callback mutates layout or + focus. +- **A2A-3 (replaces rev 1's criterion 3).** The painter extraction + preserves, for grid frontends: the painted **cells**, the **returned + cursor**, the **focused window's `view_top` mutation** from the + auto-scroll clamp, and **passive windows' untouched `view_top` and + scroll state**. Byte-identical cells alone would not catch a clamp + that silently moved to the wrong window. -The standing suite, plus `bottom_panel_stage1_acceptance` and a new -`bottom_panel_stage2_acceptance`, the three vterm suites (the panel -hosts terminals), folding Stage 2's 48 (shared projection), and -`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. Protocol round-trip and -byte-pin tests ride step 3. +### 7.2 Stage 2B — v21 protocol + daemon projection + GPU band + +Parent criteria that apply in full: **37, 38, 39, 40, 41, 45, 46, 47, +48, 49, 50, 51, 53, 54, 55**, plus re-assertion of 42/43/44 **through +the actual negotiated capability flip** rather than through a +test-only panel-capable semantic view. + +Refinements 2B adds: + +- **A2B-1.** The epoch state machine of §3.1 is pinned row by row, + including the lower-epoch-identical-data rejection and the + same-epoch-different-total rejection. Grid allocation is checked with + a fail-closed exhaustion arm; the semantic path performs no value + dedup. Epoch `0` is rejected on the wire. +- **A2B-2.** A font or scale change that leaves `CellSize` **identical** + still produces a new `geometry_epoch`, and the older `PanelFrame` + neither paints nor hit-tests until a matching `Present` arrives. This + is the case daemon value dedup cannot see and is why option 1 was + chosen. +- **A2B-3.** Panel columns are derived from the **stable normal-face + probe**, not `State::mono_advance`'s document-glyph fallback: two GPU + frontends with identical metrics and different documents derive + identical `total.cols`, and a probe returning `None` declares zero + usable geometry rather than falling back to a document sample. +- **A2B-4.** Every document-bottom consumer — caret, hit test, minimap, + terminal geometry, clipping, edge scrolling — routes through the one + document-bottom accessor. Falsified by introducing a band and + asserting each consumer moves; a second unrouted seam is the Stage 1 + `Layout::compute` defect class. +- **A2B-5.** `panel_capable` is true only for a v21+ negotiated + authenticated semantic session; a v20 semantic session is never + **placed** in a side window, not merely denied the events. + +## 8. Open items for review round 2 + +1. The concrete `BASE_DIVIDER_HEIGHT` value (§5.3). Scaling and the + single-seam rule are decided; the number is not. +2. Whether `TEXT_TOP` should scale. It is an unscaled constant today + and the formula consumes it as-is; that is pre-existing behavior + Stage 2 inherits rather than fixes, but it is worth a decision + before the conversion is pinned by acceptance. + +## 9. Slices, branches, and gates + +Per review round 1: **two serial implementation PRs**, each a named +slice under this framing so one-feature/one-branch/one-PR holds. **2A +lands before 2B branches** — not stacked. + +- **Stage 2A** — classified census routing + per-window painter + extraction. Branch `bottom-panel-stage2a`. No protocol change. +- **Stage 2B** — v21 protocol, daemon panel projection, GPU band, and + the negotiated `panel_capable` flip. Branch `bottom-panel-stage2b`, + cut from `main` after 2A merges. Repeats 2A's relevant census + assertions through the real capability flip. + +Gates for both: the standing suite, plus +`bottom_panel_stage1_acceptance`, the new +`bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance`, +the three vterm suites (the panel hosts terminals), folding Stage 2's +48 (shared projection), and `PMACS_REQUIRE_GPU=1 cargo test -p +pmacs-gpu`. Protocol round-trip and byte-pin tests ride 2B. Parent +criterion 54's `--headless-probe` run — one real daemon, real PTY, real +wgpu, through a panel-hosted terminal — is a 2B gate. From 857a86ff59a1aa8856a36b9306ece44952c68812 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 19:23:49 -0400 Subject: [PATCH 21/91] docs: bottom-panel Stage 2 framing (revision 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review round 2 — 1 blocking, 2 high, 1 medium — decides both remaining open items, and re-integrates canonical `main` @ `ccf29e3` (#172 + #157; documentation plus one `src/buffer.rs` regression test, no protocol or Stage 2 source anchor moved). **R2-1 (blocker) — the seam is three boundaries, not one.** Rev 2 asked for a single document-bottom accessor. That is wrong: once a panel is installed the present single value must DIVERGE, because several of its consumers must not move at all. `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is today `status_band_top`, `geometry_capacity_bottom`, and `document_text_bottom` at once. Rev 3 defines all three, classifies every one of its ~19 call sites as status-owned / document-owned / geometry, and records that four sites rev 2 named (`:3175`, `:3185`, `:6601`, `:6607`) consume a status-band HEIGHT and no bottom coordinate at all, while the status background `:5908` and status text `:7134`/`:7922` must stay at the physical window bottom. The acceptance is now a contrast assertion: installing a panel moves every document-owned consumer WHILE the status band stays pixel-identical. "Everything moved" alone is passed by a blanket rewrite of the helper, which is exactly the wrong implementation. **R2-2 (high) — epoch exactness.** `accept_frame_geometry` returns `Advanced | Duplicate | Rejected` instead of a boolean that cannot separate reconcile-needed from already-current from stale; if a boolean is ever kept internally it must be named `advanced`, since `Duplicate` is also accepted. Rev 2's exhaustion wording permitted retaining stale geometry, which is not fail-closed — a real resize after exhaustion would keep painting a panel sized to disowned geometry. The grid path now clears `frame_geometry` to unknown and reconciles hidden, and the frontend takes a terminal latch so a retained matching `Present` cannot resurrect the band; only a fresh session clears it. **R2-3 (high) — parent acceptance 52 splits.** 2A has no semantic panel projection, so it can only prove the extracted painter honors an explicit `None` map plus the `src/window.rs:562` comment fix. The real contract is production-reachable only in 2B and is reasserted there beside 42/43/44. **R2-4 (medium) — touched gates named**: `statusline_segments_acceptance`, `m11_5_semantic_acceptance`, `gpu_initial_target_acceptance`, `gpu_font_acceptance`, beside the vterm, folding, and GPU suites. Open items decided: `BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0, scaled by `FontMetrics::scale`, whole strip painted `ui.divider` and used as the exact hover/drag hit rect; `TEXT_TOP` stays `16.0` unscaled, with Q#BP15a's "all quantities use the frontend's current scale" narrowed to font-derived metrics and the divider. Wholesale surface-inset/DPI scaling is recorded as separate work, not smuggled in. The ledger's bottom-panel lane keeps its census correction and gains the three-boundary one. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 15 +- docs/bottom-panel-stage2-framing.md | 280 +++++++++++++++++++++------- 2 files changed, 229 insertions(+), 66 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index cdeddc6..b6b48f7 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -399,10 +399,11 @@ flight. `docs/agent-handoff.md` §1; the two round lessons are in §5. - Landed-docs follow-up merged as **#156** (`main` @ `d152120`, 2026-07-25). -- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 2**, +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 3**, on branch `githubsucks/bottom-panel-stage2-framing`, worktree - `../pmacs-bp-stage2`, based on `githubsucks/main` @ `d152120`. Round 1 - closed 2 blocking + 3 high findings; awaiting round 2. The approved + `../pmacs-bp-stage2`, based on `githubsucks/main` @ `ccf29e3`. Round 1 + closed 2 blocking + 3 high; round 2 closed 1 blocking + 2 high + 1 + medium and decided both open items. No open items remain. The approved parent framing `docs/bottom-panel-framing.md` (rev 4) remains authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree @@ -425,6 +426,14 @@ flight. application, `DispatchIdle`, presence, focused search/menu/completion routing, and terminal bell ownership. The Stage 2 framing carries the full table. +- **The GPU document bottom is three boundaries, not one.** + `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is today + `status_band_top`, `geometry_capacity_bottom`, and + `document_text_bottom` at once. Once a band is installed they diverge: + the status chrome must stay pixel-identical at the physical window + bottom while document consumers move. A blanket rewrite of that helper + moves both together and passes an "everything moved" assertion, so the + Stage 2 criterion asserts **both directions in one scenario**. - **Folding Stage 3 and this arc's Stage 2 both touch the semantic projection.** Whichever is framed second re-scouts the other's landed state. diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index c85821c..8cffec1 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,7 +1,7 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 2 — pre-implementation. Ground truth: canonical `main` @ -`d152120`, protocol v20, 2026-07-25.** +**Revision 3 — pre-implementation. Ground truth: canonical `main` @ +`ccf29e3`, protocol v20, 2026-07-25.** Stage 1 (#155, merge `e745068`) gave pmacs window placement, window parameters, TUI side windows, the divider, and the adopter `display` @@ -26,6 +26,33 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and ## 0. Revision history +### 0.0 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed + +- **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two + boundaries that must **diverge** once a panel exists. Several sites it + named are not document-bottom consumers at all: the status-band + background (`main.rs:5908`) must stay at the physical window bottom, + the status text buffers (`:3175`, `:3185`, `:6601`, `:6607`) consume a + *height* and never a bottom coordinate, and status text placement + (`:7134`) sits inside an unchanged band. §5.3 now splits the single + value into **three** named boundaries, classifies every existing + `text_area_bottom` call site, and adds the contrast assertion that + catches a uniformly-wrong implementation moving both together. +- **R2-2 (high).** `accept_frame_geometry -> bool` cannot distinguish + *advanced* from *accepted duplicate* from *rejected*. It now returns an + explicit three-valued result. The exhaustion wording also permitted + retaining stale geometry, which is not fail-closed: §3.1 now clears the + authoritative declaration and reconciles to hidden, and adds the + frontend-side terminal latch. +- **R2-3 (high).** Parent acceptance 52 was assigned wholly to 2A, but + 2A has no semantic panel projection — it can only prove the extracted + painter accepts an explicit `None`. 52 is now also reasserted in 2B, + where the contract becomes production-reachable. +- **R2-4 (medium).** §9 names the four touched acceptance suites + explicitly rather than relying on "standing suite". +- Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at + scale 1.0, and `TEXT_TOP` stays unscaled. + ### 0.1 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed - **R1-1 (blocker).** Rev 1 said all 23 census reads route through @@ -135,23 +162,58 @@ identical data is still stale and must not be accepted. unchanged grid means an old frame is still valid under unchanged metrics). Changes from `saturating_add` to **checked** allocation with an explicit fail-closed exhaustion arm. -- `accept_frame_geometry(fid, geometry_epoch, total) -> bool` — the - **semantic** path. No value dedup; applies the table above verbatim; - returns whether the declaration was accepted so the caller can drop - a stale event before any reconciliation. +- `accept_frame_geometry(fid, geometry_epoch, total) -> GeometryUpdate` + — the **semantic** path. No value dedup; applies the table above + verbatim. An ambiguous single method with an `Option` epoch is rejected explicitly: it would let a future caller silently take the wrong regime. -**Initial epoch and exhaustion.** The frontend's first declaration -after attach acceptance carries epoch `1`; `0` is reserved as "never -declared" and is rejected on the wire. Frontend-side allocation is -checked; on exhaustion the frontend stops declaring and **hides its -panel** rather than reusing or wrapping an id — it sends no further -geometry, so the daemon's last accepted declaration stands and no new -`Present` can claim a fresh identity. Daemon-side exhaustion on the -grid path fails closed the same way: no new declaration, panel stays -at its last valid geometry or hides under Q#BP2b. +**The result is three-valued, not a boolean.** A boolean cannot +distinguish the three outcomes the caller must act on differently: + +```rust +enum GeometryUpdate { + /// Epoch advanced: stored verbatim. Run panel reconciliation. + Advanced, + /// Same epoch, same total: already current. Do no work. + Duplicate, + /// Same epoch with different total, or a lower epoch: stale or + /// conflicting. Drop the event before any reconciliation. + Rejected, +} +``` + +`Advanced` reconciles, `Duplicate` returns without touching panel +state, and `Rejected` drops the event. Collapsing `Duplicate` into +either neighbour is a defect in one direction or the other: folded into +`Advanced` it reconciles on every repeated declaration, folded into +`Rejected` it would log or surface a stale-event condition that never +happened. (If a boolean is kept for a narrower internal caller, it must +be named `advanced`, never `accepted` — `Duplicate` *is* accepted.) + +**Initial epoch.** The frontend's first declaration after attach +acceptance carries epoch `1`. `0` is reserved as "never declared" and +is rejected on the wire. + +**Exhaustion fails closed on both sides, and rev 2's wording did not.** +Saying the panel "stays at its last valid geometry" is not fail-closed: +if the real frame resizes after the allocator is exhausted, the daemon +would keep painting a panel sized to geometry that no longer describes +the frontend. + +- **Grid/LOCAL path.** On checked-allocation exhaustion, **clear** the + authoritative `frame_geometry` (back to `None` = unknown) and + reconcile. Unknown is already non-presentable under Q#BP2b, so the + panel hides. Stale geometry is never retained. +- **Frontend path.** On exhaustion the frontend sets a **terminal + latch** for the life of the session: it sends no further geometry, + and — critically — an old matching `Present` **cannot** make the band + reappear, because the latch suppresses paint and hit-testing + independently of frame validity. Only a fresh session (reconnect) + clears it. Without the latch, a retained `Present` whose epoch still + matches the last declaration would resurrect a band under geometry + the frontend has disowned. ### 3.2 The census is classified, and it is mostly unrouted @@ -319,29 +381,81 @@ family shapes no width), the frontend declares **zero usable geometry** under a new epoch — the panel hides — rather than falling back to a document sample. -**`BASE_DIVIDER_HEIGHT` must be decided before implementation**, because -the document-bottom seam it defines is depended on by caret placement, -hit testing, the minimap, terminal geometry, clipping, and edge -scrolling. Two sub-decisions: +**`BASE_DIVIDER_HEIGHT = 4.0`** at scale 1.0, scaled by +`FontMetrics::scale` like `status_band_height`. A 1–2 px rule is +adequate decoration but too fragile as the drag hit strip; 4 px still +reads as a rule while giving the pointer a usable target. **The entire +strip is painted with `ui.divider`, and that exact rectangle is the +hover/drag hit region** — paint geometry and hit geometry are the same +rect, so they cannot drift apart. -- **Value and scaling.** It joins the `BASE_*` family and scales as - `BASE_DIVIDER_HEIGHT * scale`, matching `status_band_height` — a - divider that does not scale with the font would misalign at non-1.0 - scale. The concrete base value is an open item for review round 2. -- **One seam, not several.** Today the document bottom is computed - from `status_band_height` at several sites - (`main.rs:3175`, `:3185`, `:6601`, `:6607`, `:8491`, and the - status-band rect at `:5910`). Stage 2 must introduce **one** - document-bottom accessor that subtracts status band **plus** the - installed band and divider, and route every one of those sites - through it. A second, unrouted seam is the exact shape of Stage 1's - `Layout::compute` two-caller defect, where `src/overlay_paint.rs` - derived its own rect and painted peer cursors at unfixed rows. +**`TEXT_TOP` stays `16.0`, unscaled.** It is a fixed surface inset +today, like `TEXT_LEFT` and the other paddings, while +`FontMetrics::scale` governs font-derived metrics and row chrome. +Scaling it only inside the declaration formula would disagree with the +actual renderer; scaling every renderer and hit-test occurrence is a +wholesale inset/DPI change and is **named here as separate work**, not +smuggled into Stage 2. The formula is pinned to the real unscaled inset. -Note the asymmetry Q#BP15a already requires: `divider_height_px` is -subtracted **for sizing purposes even while the panel is absent**, to -break the first-open cycle, while the document renderer does not -actually lose those pixels until a `Present` panel is painted. +Accordingly, **Q#BP15a's "all quantities use the frontend's current +scale" is narrowed**: font-derived metrics and the divider scale; fixed +surface insets keep their current units. + +#### The seam is three boundaries, not one + +Rev 2 asked for a single document-bottom accessor. That was wrong: +once a panel is installed, today's single value must **diverge into +three**, because some of its consumers must not move at all. + +``` +status_band_top = surface_height - status_band_height + +geometry_capacity_bottom = status_band_top - reserved_divider_height + // divider reserved even while absent + +document_text_bottom = status_band_top + - (installed_panel_height + divider_height + if Present, else 0) +``` + +`geometry_capacity_bottom` is what Q#BP15a's asymmetry already +requires: the divider is subtracted **for sizing purposes even while +the panel is absent**, which is what breaks the first-open cycle, while +the document renderer does not actually lose those pixels until a +`Present` panel is painted. + +**Today `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is all three +at once**, and its doc comment calls it "the single source for every +bottom-of-text computation" (Q#S3). Its ~19 call sites split into three +classes: + +| Class | Boundary | Sites | +| --- | --- | --- | +| **Status-owned** — must stay pixel-identical at the window bottom | `status_band_top` | Status-band background rect `:5908`; band tops `:6003`, `:6027`, `:6140`; status text placement `:7134`, `:7922`; global minibuffer chrome | +| **Document-owned** — must move when a band is installed | `document_text_bottom` | Code/terminal clips `:7174`, `:7195`, `:7212`, `:7242`, `:7273`, `:7351`, `:7421`; caret visibility and code height `:4566`, `:6118`, `:6581`; document completion placement `:8077`; minimap `:8497`; visible-line estimate `:8501` | +| **Geometry declaration** | `geometry_capacity_bottom` | The Q#BP15a conversion only | + +**Sites that consume no bottom coordinate at all** and must not be +touched: `:3175`, `:3185`, `:6601`, `:6607` size the status text +buffers to `status_band_height` directly. Rev 2 listed them as seam +consumers; they are not. + +Each call site is classified individually. A blanket rewrite of +`text_area_bottom` to subtract the band would move the status chrome +with the document and is the defect this section exists to prevent. + +**The contrast assertion (A2B-4).** "Every document consumer moved" is +only half a test — a uniformly wrong implementation that moves +everything passes it. The criterion must assert **both directions in +one scenario**: installing a panel moves every document-owned consumer +**while the status band stays pixel-identical** at the physical window +bottom. That is the assertion a blanket rewrite fails. + +The one-accessor-per-boundary rule still holds within each class: a +second, unrouted derivation of any of the three is the exact shape of +Stage 1's `Layout::compute` two-caller defect, where +`src/overlay_paint.rs` derived its own rect and painted peer cursors at +unfixed rows. ### 5.4 Ordering against folding Stage 3 @@ -390,7 +504,17 @@ This section maps them to the two slices and adds only refinements. ### 7.1 Stage 2A — classified census routing + painter extraction No protocol change. Parent criteria that apply in full: **42, 43, 44, -51 (the `LOCAL`-panel inheritance half), 52**. +51 (the `LOCAL`-panel inheritance half)**, plus the extraction half of +**52**. + +**52 splits across the slices.** 2A has no semantic panel projection +and no `PanelFrame`, so all it can prove is that the extracted painter +honors an explicitly supplied `None` fold map and that the stale +`src/window.rs:562` comment is corrected. The actual contract — *a +semantic panel with `fold_projection = false` never collapses folds and +never calls `fold_map_for_window`* — is production-reachable only once +2B lands the projection and the capability flip. It is therefore +reasserted in 2B (§7.2). Refinements 2A adds: @@ -423,17 +547,26 @@ Refinements 2A adds: ### 7.2 Stage 2B — v21 protocol + daemon projection + GPU band Parent criteria that apply in full: **37, 38, 39, 40, 41, 45, 46, 47, -48, 49, 50, 51, 53, 54, 55**, plus re-assertion of 42/43/44 **through -the actual negotiated capability flip** rather than through a -test-only panel-capable semantic view. +48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44, and +52** **through the actual negotiated capability flip** rather than +through a test-only panel-capable semantic view. 52's 2B form is the +production one: a real semantic frontend with `fold_projection = false` +displaying a folded buffer in a panel shows every source line, and the +panel path never reaches `fold_map_for_window`. Refinements 2B adds: - **A2B-1.** The epoch state machine of §3.1 is pinned row by row, including the lower-epoch-identical-data rejection and the - same-epoch-different-total rejection. Grid allocation is checked with - a fail-closed exhaustion arm; the semantic path performs no value - dedup. Epoch `0` is rejected on the wire. + same-epoch-different-total rejection, and each row's + `Advanced`/`Duplicate`/`Rejected` result is asserted — a `Duplicate` + performs no reconciliation and a `Rejected` mutates nothing. Epoch + `0` is rejected on the wire. **Exhaustion is pinned on both sides**: + grid exhaustion clears `frame_geometry` to unknown and the panel + hides (a subsequent real resize must not paint a stale-geometry + panel), and a frontend that exhausts latches — a retained `Present` + whose epoch still matches cannot make the band reappear, and only a + fresh session clears the latch. - **A2B-2.** A font or scale change that leaves `CellSize` **identical** still produces a new `geometry_epoch`, and the older `PanelFrame` neither paints nor hit-tests until a matching `Present` arrives. This @@ -444,23 +577,33 @@ Refinements 2B adds: frontends with identical metrics and different documents derive identical `total.cols`, and a probe returning `None` declares zero usable geometry rather than falling back to a document sample. -- **A2B-4.** Every document-bottom consumer — caret, hit test, minimap, - terminal geometry, clipping, edge scrolling — routes through the one - document-bottom accessor. Falsified by introducing a band and - asserting each consumer moves; a second unrouted seam is the Stage 1 - `Layout::compute` defect class. +- **A2B-4 (contrast assertion).** Installing a panel moves **every** + document-owned consumer — code and terminal clips, caret visibility, + document completion placement, gutter/math clipping, minimap, + visible-line estimate, hit testing, edge scrolling — by exactly + `installed_panel_height + divider_height`, **while the status band + stays pixel-identical** at the physical window bottom (background + rect, band top, and status text placement all unchanged). Both halves + are asserted in one scenario: a uniformly wrong implementation that + moves the status band too would pass the "everything moved" half + alone. The geometry declaration separately reserves the divider while + the panel is `Absent`, and the document loses no pixels until a + `Present` is painted. - **A2B-5.** `panel_capable` is true only for a v21+ negotiated authenticated semantic session; a v20 semantic session is never **placed** in a side window, not merely denied the events. -## 8. Open items for review round 2 +## 8. Open items -1. The concrete `BASE_DIVIDER_HEIGHT` value (§5.3). Scaling and the - single-seam rule are decided; the number is not. -2. Whether `TEXT_TOP` should scale. It is an unscaled constant today - and the formula consumes it as-is; that is pre-existing behavior - Stage 2 inherits rather than fixes, but it is worth a decision - before the conversion is pinned by acceptance. +**None.** Both round-1 open items are decided in §5.3: +`BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0 (scaled, whole strip painted +`ui.divider` and used as the hit rect), and `TEXT_TOP` stays unscaled +with wholesale inset/DPI scaling named as separate work. + +One deferral is recorded rather than resolved: **wholesale surface-inset +scaling** (`TEXT_TOP`, `TEXT_LEFT`, and the sibling paddings under +`FontMetrics::scale`) is pre-existing behavior Stage 2 pins rather than +fixes. It belongs to a spacing-system change of its own. ## 9. Slices, branches, and gates @@ -475,11 +618,22 @@ lands before 2B branches** — not stacked. cut from `main` after 2A merges. Repeats 2A's relevant census assertions through the real capability flip. -Gates for both: the standing suite, plus -`bottom_panel_stage1_acceptance`, the new -`bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance`, -the three vterm suites (the panel hosts terminals), folding Stage 2's -48 (shared projection), and `PMACS_REQUIRE_GPU=1 cargo test -p -pmacs-gpu`. Protocol round-trip and byte-pin tests ride 2B. Parent -criterion 54's `--headless-probe` run — one real daemon, real PTY, real -wgpu, through a panel-hosted terminal — is a 2B gate. +Gates for both: the standing suite from `CLAUDE.md`, plus the **touched +acceptance suites named explicitly** — the standing rule is to run the +suites a change touches, and "standing suite" does not name them: + +- `bottom_panel_stage1_acceptance` — the substrate both slices build on. +- `bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance` + — new, one per slice. +- `statusline_segments_acceptance` — the fan-out target change (§3.3). +- `m11_5_semantic_acceptance` — the semantic census (§3.2). +- `gpu_initial_target_acceptance` — parent criterion 55. +- `gpu_font_acceptance` — font/scale geometry refresh (§5.3), including + the normal-face probe and the unscaled-`TEXT_TOP` decision. +- The three vterm suites — the panel hosts terminals. +- Folding Stage 2's 48 — shared projection. +- `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. + +Protocol round-trip and byte-pin tests ride 2B. Parent criterion 54's +`--headless-probe` run — one real daemon, real PTY, real wgpu, through a +panel-hosted terminal — is a 2B gate. From 73587b0e371f49c26c87926941fe7518cce54004 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 19:23:50 -0400 Subject: [PATCH 22/91] fix(lean): correlate the probe verdict with its own server and buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 review: two P1 asynchronous-correlation defects, with the focused suite at 25/25 while both were live. **1. A late version verdict retired nothing and claimed success.** `probe.watching` is cleared the moment the server initializes — it is failure-polling state. A slow `lake --version` landing after a successful initialize therefore reached `fire_latch(nil)`, which retires nothing: `_attach_buffer` found the still-live primary attachment, early-returned it, and the retry counted that as done. Status said "falling back", the config named the fallback, and the buffer stayed on the old server. **That is the round-1 silent no-op arriving through a third event ordering** — first as "no re-attach at all", then as "re-attach cleared by an unrelated buffer", now as "re-attach satisfied by the server we were supposed to replace". The fix separates the two facts that were being carried by one field: `probe.primary` is the server the verdict applies to and survives initialization; `probe.watching` is the failure poll and is cleared by it. The existing fixture could not reach this ordering at all — its `serve` sleeps, so the primary can never initialize before `--version` returns. The new one execs the fake LSP for `serve` and delays 0.6s before reporting 3.0.0. **2. `buf_key` was the most recently loaded Lean buffer.** Written on every Lean `buffer.after-load`, so a second Lean file opened before the verdict became the rebuild target while the latch still watched the FIRST buffer's server. Target buffer and primary server are one fact and are now armed together, exactly once. Both files in the new test share a package, so mis-targeting shows up as a stranded buffer rather than as two unrelated servers. **3. The failure message hardcoded `lake serve`** after the latch became command-agnostic, telling a user whose `my-lean-wrapper` failed to go debug lake. `configured_command()` names what is actually configured, arguments included. **4. The ledger** now records all fifteen bites across the three rounds, both prior review rounds' findings (the round-2 block was lost when an earlier edit script aborted before writing), and the durable lesson. That lesson, recorded for the handoff: **six tests across three rounds were written, ran green, and pinned nothing** — caught only by biting. The shapes are enumerated in the ledger; the rule is that a test is not evidence until the mutation it targets has been shown to fail it. Two of the six are subtle enough to be worth naming here: a bite that RAISES is swallowed by the hook's pcall and "passes" for the wrong reason, and a fixture whose `serve` sleeps cannot reach any ordering where the primary comes up first. --- builtin/runtime/lean.lua | 66 +++++++++--- docs/active-work.md | 80 +++++++++++++-- tests/lean4_server_acceptance.rs | 171 +++++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 23 deletions(-) diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua index 5f74792..8f3b339 100644 --- a/builtin/runtime/lean.lua +++ b/builtin/runtime/lean.lua @@ -127,10 +127,28 @@ local probe = { proc = nil, -- process id of the running probe out = "", -- accumulated probe stdout buf_key = nil, -- tostring() of the buffer that started this - watching = nil, -- sid we are waiting to see fail before initialize + watching = nil, -- sid still being polled for die-before-initialize + primary = nil, -- sid the probe's verdict applies to; NOT cleared + -- when the server initializes, because a late + -- version verdict still has to retire it + armed = false, -- the target buffer + primary have been captured saw_initialized = false, } +-- The command as configured, for status text. Hardcoding "lake serve" +-- was untruthful the moment the failure latch became command-agnostic: +-- a user whose `my-lean-wrapper` failed was told `lake serve` did. +local function configured_command() + local cfg = pmacs.lsp.config.lean4 + local cmd = cfg and cfg.command + if not cmd then return "the Lean server" end + local args = cfg.args or {} + if #args > 0 then + return "`" .. tostring(cmd) .. " " .. table.concat(args, " ") .. "`" + end + return "`" .. tostring(cmd) .. "`" +end + local function report(msg) -- COHERENCE §1.2: background work must leave an attributed trace. -- `pmacs.editor.set_status` is the channel that EXISTS; `pmacs.error` @@ -327,9 +345,19 @@ local function drain_probe() -- case, and covers it better. The probe answers only the ONE -- question failure detection would otherwise answer slowly: an -- old-but-working lake that starts a useless server. + -- **`probe.primary`, NOT `probe.watching`.** `watching` is + -- failure-polling state and is cleared the moment the server + -- initializes. A slow `--version` that lands after a successful + -- initialize would then arrive with nil, and `fire_latch(nil)` + -- retires nothing: `_attach_buffer` finds the still-live primary + -- attachment, early-returns it, and the retry calls that success. + -- Status and config would say "fell back" while the buffer stayed + -- on the old server — the same silent no-op as round 1, reached + -- through a different event ordering. Initializing must stop the + -- failure poll, not erase the server the verdict has to retire. if ev.kind == "exited" and ev.code == 0 and version_below_3_1(probe.out) then - fire_latch(probe.watching, "lake is older than 3.1.0") + fire_latch(probe.primary, "lake is older than 3.1.0") end end end @@ -393,18 +421,20 @@ local function poll_latch() if tostring(info.id) == skey then local kind = info.state and info.state.kind if kind == "initialized" then + -- Stop polling for failure; `probe.primary` deliberately + -- survives, because a later version verdict still needs it. probe.saw_initialized = true probe.watching = nil return end if kind == "crashed" or kind == "stopped" then - fire_latch(sid, "`lake serve` failed to start") + fire_latch(sid, configured_command() .. " failed to start") end return end end -- Gone from the manager entirely without ever initializing. - fire_latch(nil, "`lake serve` failed to start") + fire_latch(nil, configured_command() .. " failed to start") end -- Q#LN16 — `textDocument/waitForDiagnostics` -------------------------- @@ -488,11 +518,6 @@ pmacs.hook.add("buffer.after-load", function() local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf) if not ok_lang or lang ~= "lean4" then return end - -- The buffer that started this, remembered for the asynchronous - -- rebuild: `_attach_buffer` acts on whatever is active when the - -- verdict lands, which may be a different buffer entirely. - probe.buf_key = tostring(buf) - if not probe.started then local path = pmacs.editor.file_path() start_probe(path and M.root_for(path) or nil) @@ -500,9 +525,18 @@ pmacs.hook.add("buffer.after-load", function() local rec = pmacs.lsp.active_attachment() if rec and rec.language == "lean4" then - -- Watch only the FIRST Lean server: the latch is per session. - if not probe.latched and not probe.saw_initialized - and probe.watching == nil then + -- **Arm ONCE, capturing buffer and server together.** Setting + -- `buf_key` on every Lean load meant a second Lean buffer opened + -- before the verdict silently became the rebuild target while the + -- latch still watched the FIRST buffer's server — so the rebuild + -- either repaired the wrong buffer or accepted the second buffer's + -- unrelated live server as success, stranding the first. The pair + -- (target buffer, primary server) is one fact and is captured as + -- one. + if not probe.armed and not probe.latched and not probe.saw_initialized then + probe.armed = true + probe.buf_key = tostring(buf) + probe.primary = rec.server probe.watching = rec.server end return @@ -522,7 +556,13 @@ pmacs.hook.add("buffer.after-load", function() -- already swallowed upstream. That is not something to wait for; it -- is the failure itself, and the only place it is still observable. if not probe.latched then - fire_latch(nil, "`" .. tostring(cfg.command) .. "` could not be started") + -- No server was ever created, so there is no primary to retire — + -- but the rebuild still needs a target buffer. + if not probe.armed then + probe.armed = true + probe.buf_key = tostring(buf) + end + fire_latch(nil, configured_command() .. " could not be started") end end) diff --git a/docs/active-work.md b/docs/active-work.md index 4a650da..6cdac4f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ If it does not, stop and repair the remote/fetch configuration. - Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in `src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`, a `leanprogress` mode plus `waitForDiagnostics` validation on - `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (25 tests). + `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (28 tests). No protocol change. - **Stage 1's acceptance 12 is half superseded and was rewritten, not deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a @@ -279,12 +279,70 @@ If it does not, stop and repair the remote/fetch configuration. an EMPTY `lean-toolchain` (a legitimate marker — existence semantics, not content). Discriminator is `read`'s SECOND return; decline only on a non-nil err. Probed on LuaJIT 2.1. -- Seven bites recorded, each against the committed tree: bare `io.open` - → 24a fails / 24b passes; require-non-nil → 24b fails / 24a passes; - no canonicalization → symlinked open spawns two servers; no re-attach - after the swap → three latch tests fail; hook keyed on the attachment - → the missing-`lake` case fails; `waitForDiagnostics` without - `version` → acc37 fails with the server's InvalidParams. +- **Fifteen bites recorded, each against the committed tree.** R1: bare + `io.open` → 24a fails / 24b passes; require-non-nil → 24b fails / 24a + passes; no canonicalization → symlinked open spawns two servers; no + re-attach after the swap → three latch tests fail; hook keyed on the + attachment → the missing-`lake` case fails; `waitForDiagnostics` + without `version` → acc37 fails with InvalidParams. R2: skip retiring + a terminal server → `attempt` reaches 3; no originating-buffer gate → + the Lean buffer is left on the `lake` stub; retry-forever → the + failing-fallback test fails; version-probe any command → the + working-wrapper test fails; no disabled guard → the unconfigured test + sees "`nil` could not be started". R3: verdict keyed on `watching` → + the late-verdict test finds the buffer still on `lake`; `buf_key` + rewritten per load → the second-buffer test fails; hardcoded + `lake serve` → the wrapper-naming test fails. +- **Round-2 review: three more P1 lifecycle defects, suite 20/20 with + all of them live.** (1) The crashed primary respawned forever — + skipping the retire call avoided corrupting terminal servers but left + `next_restart_at` armed. **`forget` is the call for a TERMINAL server** + (it requires terminal state and removes the client, dropping the + restart timer); `stop` is for a live one and corrupts a terminal one. + (2) Re-attachment targeted whatever buffer was active when the async + verdict landed; an unrelated Rust attachment satisfied "a different + server id". (3) A failing fallback retried every tick forever, silent. + Plus two P2s: the Lake version parser was applied to arbitrary wrapper + output, and an UNCONFIGURED `config.lean4` was reported as failure and + latched, poisoning the session. +- **Round-3 review: two more P1s, both asynchronous correlation, suite + 25/25.** (a) `probe.watching` is cleared when the server initializes, + so a SLOW version verdict arrived with nil and retired nothing — + `_attach_buffer` returned the still-live primary and the retry called + it success, so status and config said "fell back" while the buffer + stayed put. **That is the round-1 silent no-op reached through a third + event ordering.** `probe.primary` is now separate from + `probe.watching` and survives initialization. (b) `buf_key` was + rewritten on every Lean `after-load`, so a second Lean buffer opened + before the verdict became the rebuild target while the latch still + watched the first buffer's server. Target buffer and primary server + are one fact and are now armed together, once. Plus a P2: the failure + message hardcoded `lake serve` after the latch became + command-agnostic, sending wrapper users to debug the wrong binary. +- **DURABLE LESSON — "the test that passes" vs "the test that + discriminates."** Six tests across three rounds were written, run + green, and only bite-testing showed they pinned nothing. **Carry this + to `docs/agent-handoff.md` when the lane lands.** The concrete shapes, + all from this branch: + 1. R1 acceptance 36 asserted "every server is terminal" — pinning the + ABSENCE of the fallback it claimed to test. + 2. "No live non-fallback server" misses a respawn loop: a respawning + server sits in `crashed` most of the time. `attempt` counts + respawns; liveness does not. + 3. Returning to a buffer via `find_or_open` re-fires + `buffer.after-load`, which repairs the attachment regardless of the + code under test. Use `switch_buffer`. + 4. A MISSING executable fails synchronously inside `after-load`, where + the rebuild happens inline — no async race can occur. Only the + probe path exercises asynchronous ordering. + 5. A mutation that RAISES (indexing a nil config) is swallowed by the + hook's pcall, so the bite "passes" for the wrong reason. A bite must + reproduce the original shape, not merely break the code. + 6. A fixture whose `serve` sleeps can never let the primary initialize + first, so it cannot reach the ordering where a late verdict must + retire a LIVE server. + Rule: **a test is not evidence until the mutation it targets has been + shown to fail it.** - **SUBSTRATE BUG FOUND, not fixed here (framing §6).** `LspManager::stop` on an ALREADY-terminal server takes its not-initialized branch, terminates the dead process and sets @@ -294,7 +352,9 @@ If it does not, stop and repair the remote/fetch configuration. `ShuttingDown` **forever**: `server_is_live` reads it as LIVE, so `attach_buffer` never rebuilds, and `forget` refuses it for not being terminal. **Stopping a dead server is what makes it un-replaceable.** - Lean works around it by checking the state before stopping. + Lean works around it by dispatching on state: `forget` when + terminal, `stop` when live. Merely SKIPPING the call is not + enough — that leaves `next_restart_at` armed. - Round-1 review found four P1s, all real: the latch swapped the config but never spawned or re-attached (and acc36 *asserted every server was terminal*, pinning the absence of the fallback); a missing `lake` @@ -308,9 +368,9 @@ If it does not, stop and repair the remote/fetch configuration. server-failure latch covers the rest. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; - lean4 server 25/25; lean4 stage 1 9/9; dispatch seams 15/15; + lean4 server 28/28; lean4 stage 1 9/9; dispatch seams 15/15; multi-root 13/13; M4 121; required GPU 155; **isolated-config - workspace sweep 3,214 across 94 suites, zero failures**; + workspace sweep 3,217 across 94 suites, zero failures**; `git diff --check` clean. (Round 1 of this entry recorded 17/17 and 3,206 — the PRE-fix counts — after the fixes were pushed. The ledger's protocol is that verification diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs index cba8792..db708f1 100644 --- a/tests/lean4_server_acceptance.rs +++ b/tests/lean4_server_acceptance.rs @@ -1069,3 +1069,174 @@ fn r2_an_unconfigured_lean_server_is_disabled_not_failed() { "and the session is not poisoned: a later config must still work" ); } + +// --------------------------------------------------------------------------- +// Round-3 review findings — asynchronous correlation. +// +// Both fail against 3377db0, where the suite was 25/25. +// --------------------------------------------------------------------------- + +impl Fixture { + /// A `lake` whose `serve` really works (it execs the fake LSP) but + /// whose `--version` answers slowly with an old version. This is the + /// ordering the previous fixtures could not produce: the primary + /// INITIALIZES before the version verdict arrives. + fn slow_version_lake(&self, rel: &str, server: &str, version_line: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt as _; + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n sleep 0.6\n echo '{version_line}'\n exit 0\nfi\nexec '{server}'\n" + ), + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } +} + +/// The command backing the active buffer's attached server. +fn attached_command(state: &EditorState) -> String { + eval( + state, + r#" + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.command) + end + end + return "gone" + "#, + ) +} + +#[test] +fn r3_a_late_version_verdict_still_retires_an_initialized_primary() { + // `probe.watching` is cleared the moment the server initializes. A + // verdict arriving after that used to call `fire_latch(nil)`, which + // retires nothing — `_attach_buffer` then returns the still-live + // primary and the retry calls it success. Status and config would + // say "fell back" while the buffer stayed put. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let lake = fx.slow_version_lake("bin/lake", &fake_lsp_path(), "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &lake); + + open(&state, &file); + // Let the primary initialize first — the ordering that matters. + tick_for(&mut state, 300); + assert_eq!( + attached_state(&state), + "initialized", + "precondition: the primary really did come up before the verdict" + ); + assert_eq!( + attached_command(&state), + lake.display().to_string(), + "precondition: and the buffer is on it" + ); + + // Now let the slow `--version` land and the fallback complete. + tick_for(&mut state, 1200); + + assert_eq!( + attached_command(&state), + fake_lsp_path(), + "a late version verdict must actually move the buffer to the \ + fallback, not just rewrite the config and claim it did" + ); + // And the retired primary is not left running or respawning. + let stale: i64 = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + local live = rec and tostring(rec.server) or "" + local n = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) ~= live then + local k = s.state and s.state.kind + if k ~= "stopped" and k ~= "crashed" then n = n + 1 end + end + end + return n + "#, + ); + assert_eq!(stale, 0, "the initialized primary was retired, not left up"); +} + +#[test] +fn r3_a_second_lean_buffer_does_not_steal_the_rebuild_target() { + // `buf_key` was written on every Lean `buffer.after-load`, so a + // second Lean file opened before the verdict became the rebuild + // target while the latch still watched the FIRST buffer's server. + // + // Both files live in the SAME Lake package, so they share one server + // and one root — which is what makes the mis-targeting observable as + // a stranded buffer rather than as two independent servers. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let first = fx.write("pkg/A.lean", "def a := 1\n"); + let second = fx.write("pkg/B.lean", "def b := 2\n"); + let lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &lake); + + open(&state, &first); + exec(&state, "_G.first_buf = pmacs.window.buffer()"); + // A second Lean buffer, opened before the probe's verdict lands. + open(&state, &second); + tick_for(&mut state, 500); + + // The armed target must still be the FIRST buffer. + let target_is_first: bool = eval( + &state, + "return pmacs.lean._probe.buf_key == tostring(_G.first_buf)", + ); + assert!( + target_is_first, + "the rebuild target is captured once, when the latch arms — a \ + later Lean buffer must not silently become the target" + ); + + // And the first buffer really does end up on the fallback. + exec(&state, "pmacs.window.switch_buffer(_G.first_buf)"); + tick_for(&mut state, 600); + assert_eq!( + attached_command(&state), + fake_lsp_path(), + "the originating buffer is the one repaired" + ); +} + +#[test] +fn r3_a_failing_wrapper_is_named_truthfully_not_as_lake_serve() { + // The failure latch is command-agnostic, so its message must be too. + // Telling a user that `lake serve` failed when they configured + // `my-lean-wrapper` sends them to debug the wrong thing. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/my-lean-wrapper"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + + open(&state, &file); + settle(&mut state); + + let status = state.core.borrow().status.clone(); + assert!( + status.contains("my-lean-wrapper"), + "the status names the command the user actually configured; saw \ + {status:?}" + ); + assert!( + !status.contains("lake serve"), + "and does not attribute the failure to `lake serve`; saw {status:?}" + ); +} From 49757e51a8513446eed38de667420b0046db0140 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 19:36:58 -0400 Subject: [PATCH 23/91] docs: bottom-panel Stage 2 framing (revision 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review round 3 — 1 blocking, 1 high, 1 medium. **R3-1 (blocker) — the call-site table contradicted the source.** The three-boundary model was right; five rows of its classification were not, and each was a real defect: - `:6140` is `completion_dropdown_layout` — DOCUMENT completion placement, deriving the space below the anchor line. Classified status-owned, it would let completion overlap the panel. - `:7195` and `:7212` are the `status_buffer` / `status_left_buffer` `TextBounds.top` — status text bounds, classified document-owned. - `:7351` clips global minibuffer CANDIDATE glyphs to the dropdown's band anchor; classified document-owned, they would be clipped against a boundary the dropdown does not sit above. - `:8561` (`edge_scroll_direction`, document edge scrolling) was missing entirely, leaving it tied to the old bottom. - `:8077` is `code_caret_rect_in_clip` — caret clipping, not completion placement. Its class was right, its label wrong. Every production site is now individually verified against the source and tabulated with what it actually is. The census is stated as arithmetic a reader can check: 29 matches = 20 production + 1 definition + 8 test sites. Root cause recorded in the revision history: rev 3's table was built from a `grep | head -20` over 29 matches, which is precisely why `:8561` vanished. The minibuffer's status-owned status is now argued from Q#BP14b rather than assumed — it is global, bufferless chrome anchored to the status band, so all four of its sites stay with the band. **R3-2 (high) — clamps preserved.** The three equations permitted negative coordinates on a surface shorter than its chrome, where today's `text_area_bottom` clamps with `.max(0.0)`. All three now clamp at zero, which keeps the "exact formula" exact exactly where it matters most. **R3-3 (medium) — attachment rejection classified SHARED.** `validate_cells` also rejects `cell.attachment.is_some()` (`terminal.rs:305`), whose error text reads "which terminals never use" (`:190-191`) — phrased as a terminal-specific fact, which is why rev 3's "exact split" missed it. Panels implement no attachment rendering in Stage 2, so a `PanelFrame` carrying one describes a surface the GPU would silently not draw; shared rejection fails closed on the producer side instead. The message is reworded grid-neutral when it moves, and giving panels attachment rendering later moves the rejection back deliberately rather than by default. A2B-4 now names the counts on both sides (twelve document-owned move, eight status-owned do not) and carries the three symptom-bearing rows that a plausible misclassification produces. §9 records that the GPU three-boundary split belongs to 2B, not 2A — it is only observable once a band can be installed. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 19 ++-- docs/bottom-panel-stage2-framing.md | 157 +++++++++++++++++++++++----- 2 files changed, 141 insertions(+), 35 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index b6b48f7..b60d62c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -399,11 +399,13 @@ flight. `docs/agent-handoff.md` §1; the two round lessons are in §5. - Landed-docs follow-up merged as **#156** (`main` @ `d152120`, 2026-07-25). -- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 3**, - on branch `githubsucks/bottom-panel-stage2-framing`, worktree - `../pmacs-bp-stage2`, based on `githubsucks/main` @ `ccf29e3`. Round 1 - closed 2 blocking + 3 high; round 2 closed 1 blocking + 2 high + 1 - medium and decided both open items. No open items remain. The approved +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 4**, + on branch `githubsucks/bottom-panel-stage2-framing` (three commits, + one per revision), worktree `../pmacs-bp-stage2`, based on + `githubsucks/main` @ `ccf29e3`. Round 1 closed 2 blocking + 3 high; + round 2 closed 1 blocking + 2 high + 1 medium and decided both open + items; round 3 closed 1 blocking + 1 high + 1 medium. No open items + remain. The approved parent framing `docs/bottom-panel-framing.md` (rev 4) remains authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree @@ -433,7 +435,12 @@ flight. the status chrome must stay pixel-identical at the physical window bottom while document consumers move. A blanket rewrite of that helper moves both together and passes an "everything moved" assertion, so the - Stage 2 criterion asserts **both directions in one scenario**. + Stage 2 criterion asserts **both directions in one scenario**. The + census is 20 production sites (8 status-owned, 12 document-owned) + 1 + definition + 8 test sites = 29 matches; the framing carries the + per-site table. The three easiest to misclassify are document + completion `:6140`, minibuffer candidates `:7351`, and edge scrolling + `:8561` — each with its own visible symptom. - **Folding Stage 3 and this arc's Stage 2 both touch the semantic projection.** Whichever is framed second re-scouts the other's landed state. diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index 8cffec1..38d94ed 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,6 +1,6 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 3 — pre-implementation. Ground truth: canonical `main` @ +**Revision 4 — pre-implementation. Ground truth: canonical `main` @ `ccf29e3`, protocol v20, 2026-07-25.** Stage 1 (#155, merge `e745068`) gave pmacs window placement, window @@ -26,7 +26,35 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and ## 0. Revision history -### 0.0 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed +### 0.0 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed + +- **R3-1 (blocker).** Rev 3's three-boundary model was right but its + call-site table was wrong in five places, and each error was a real + defect: `:6140` is **document completion placement** (classified + status-owned, which would let completion overlap the panel); + `:7195`/`:7212` are the two **status text bounds** (classified + document-owned); `:7351` clips **global minibuffer candidate glyphs** + to the dropdown's band anchor (classified document-owned, which would + clip them against the document boundary); `:8561` (**document edge + scrolling**) was missing entirely, leaving it tied to the old bottom; + and `:8077` was described as completion placement when it is **caret + clipping** (its class was right, its label wrong). §5.3's table is + rebuilt from the full census and every row is verified against the + source. + **Root cause worth recording:** rev 3's table was built from a + `grep | head -20` over 29 matches. The truncation is exactly why + `:8561` vanished. The census is now stated as 20 production sites + + 1 definition + 8 test sites = 29, so a future reader can check the + arithmetic instead of trusting the list. +- **R3-2 (high).** The three equations permitted negative coordinates + on a surface shorter than its chrome, where today's + `text_area_bottom` clamps with `.max(0.0)`. All three are now + explicitly clamped, preserving the current helper's behavior. +- **R3-3 (medium).** §5.1's "exact split" omitted `validate_cells`'s + `cell.attachment.is_some()` rejection. It is now classified — and + **shared**, with the reasoning pinned. + +### 0.1 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed - **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two boundaries that must **diverge** once a panel exists. Several sites it @@ -53,7 +81,7 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and - Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0, and `TEXT_TOP` stays unscaled. -### 0.1 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed +### 0.2 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed - **R1-1 (blocker).** Rev 1 said all 23 census reads route through `primary_document_window`. That contradicts Q#BP14, which routes only @@ -326,6 +354,20 @@ currently interleaves both concerns. The exact split: text, `validate_selection`, and the `at_bottom == (scroll_offset == 0)` coupling. +**Attachment rejection is shared, not terminal-only.** `validate_cells` +also rejects `cell.attachment.is_some()` +(`pmacs-protocol/src/terminal.rs:305`), and its error text reads "A +cell carries a frontend attachment, which terminals never use" +(`:190-191`) — phrased as a terminal-specific fact, which is why rev 3 +missed it. **Stage 2 classifies it shared**: panels implement no +attachment rendering, so a `PanelFrame` carrying one describes a +surface the GPU would silently not draw. Shared rejection fails closed +on the producer side rather than shipping an invisible cell. The error +message is reworded away from "which terminals never use" to a +grid-neutral phrasing when it moves. If a later stage gives panels +attachment rendering, this rejection moves back to terminal-only as a +deliberate, reviewed change — not by default. + `PanelFrame` takes the shared half plus its own presence/epoch rules and does **not** inherit the 512 per-axis cap (Bet B5'), so a 4K small-font panel wider than 512 columns is legal while the shared area @@ -407,15 +449,20 @@ Rev 2 asked for a single document-bottom accessor. That was wrong: once a panel is installed, today's single value must **diverge into three**, because some of its consumers must not move at all. -``` -status_band_top = surface_height - status_band_height +All three clamp at zero, preserving today's `text_area_bottom` +`.max(0.0)` behavior — without the clamps a surface shorter than its +own chrome yields negative coordinates, and the "exact formula" stops +being exact precisely where it matters most: -geometry_capacity_bottom = status_band_top - reserved_divider_height +``` +status_band_top = max(0, surface_height - status_band_height) + +geometry_capacity_bottom = max(0, status_band_top - divider_height) // divider reserved even while absent -document_text_bottom = status_band_top - - (installed_panel_height + divider_height - if Present, else 0) +document_text_bottom = max(0, status_band_top + - installed_panel_height + - installed_divider_height) ``` `geometry_capacity_bottom` is what Q#BP15a's asymmetry already @@ -426,20 +473,67 @@ the document renderer does not actually lose those pixels until a **Today `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is all three at once**, and its doc comment calls it "the single source for every -bottom-of-text computation" (Q#S3). Its ~19 call sites split into three -classes: +bottom-of-text computation" (Q#S3). -| Class | Boundary | Sites | -| --- | --- | --- | -| **Status-owned** — must stay pixel-identical at the window bottom | `status_band_top` | Status-band background rect `:5908`; band tops `:6003`, `:6027`, `:6140`; status text placement `:7134`, `:7922`; global minibuffer chrome | -| **Document-owned** — must move when a band is installed | `document_text_bottom` | Code/terminal clips `:7174`, `:7195`, `:7212`, `:7242`, `:7273`, `:7351`, `:7421`; caret visibility and code height `:4566`, `:6118`, `:6581`; document completion placement `:8077`; minimap `:8497`; visible-line estimate `:8501` | -| **Geometry declaration** | `geometry_capacity_bottom` | The Q#BP15a conversion only | +The census is **29 matches: 20 production call sites, 1 definition +(`:8490`), and 8 test sites** (`:12887`, `:12937`, `:12997`, `:13109`, +`:13793`, `:14013`, `:15306`, `:15386`). Every production site, +classified individually against the source: + +**Status-owned — must stay pixel-identical at the physical window +bottom, using `status_band_top`** (8 sites): + +| Site | What it is | +| --- | --- | +| `:5908` | Status-band background rect `y` | +| `:6003` | `mb_visible_window` — rows that fit **above the band** | +| `:6027` | `mb_dropdown_window` origin — dropdown grows up from the band | +| `:7134` | `status_top` for the right status group | +| `:7195` | `status_buffer` `TextBounds.top` — status text bound | +| `:7212` | `status_left_buffer` `TextBounds.top` — status text bound | +| `:7351` | Minibuffer **candidate glyph** clip, anchored to the dropdown's band origin | +| `:7922` | `status_top`, second site | + +The minibuffer is **global, bufferless chrome anchored to the status +band** (Q#BP14b keeps `MinibufferPrompt` global), so all four of its +sites — `:6003`, `:6027`, `:7351`, and its `status_left_buffer` bound +`:7212` — stay status-owned. Clipping candidate glyphs at +`document_text_bottom` would clip the dropdown against a boundary it +does not sit above. + +**Document-owned — must move when a band is installed, using +`document_text_bottom`** (12 sites): + +| Site | What it is | +| --- | --- | +| `:4566` | `terminal_cell_viewport` — drawable height for the cell grid | +| `:6118` | `completion_anchor_px` — anchor visibility bottom | +| `:6140` | `completion_dropdown_layout` — **document completion placement**; `band_top - (line_top + line_h)` is the space below the anchor line | +| `:6581` | `code_height` | +| `:7174` | Code text clip bottom | +| `:7242` | Math text clip bottom | +| `:7273` | Gutter clip bottom | +| `:7421` | Terminal clip bottom | +| `:8077` | `code_caret_rect_in_clip` — **caret clipping** | +| `:8497` | Minimap drawable height | +| `:8501` | Visible-line estimate | +| `:8561` | `edge_scroll_direction` — **document edge scrolling** | + +**Geometry declaration** uses `geometry_capacity_bottom`, and is the +Q#BP15a conversion only. **Sites that consume no bottom coordinate at all** and must not be touched: `:3175`, `:3185`, `:6601`, `:6607` size the status text buffers to `status_band_height` directly. Rev 2 listed them as seam consumers; they are not. +Three of these classifications are the ones a plausible implementation +gets wrong, and each has a visible symptom: document completion +(`:6140`) anchored to `status_band_top` **overlaps the panel**; +minibuffer candidates (`:7351`) clipped at `document_text_bottom` are +**cut off**; and edge scrolling (`:8561`) left on the old bottom +**auto-scrolls from inside the panel**. + Each call site is classified individually. A blanket rewrite of `text_area_bottom` to subtract the band would move the status chrome with the document and is the defect this section exists to prevent. @@ -577,18 +671,21 @@ Refinements 2B adds: frontends with identical metrics and different documents derive identical `total.cols`, and a probe returning `None` declares zero usable geometry rather than falling back to a document sample. -- **A2B-4 (contrast assertion).** Installing a panel moves **every** - document-owned consumer — code and terminal clips, caret visibility, - document completion placement, gutter/math clipping, minimap, - visible-line estimate, hit testing, edge scrolling — by exactly - `installed_panel_height + divider_height`, **while the status band - stays pixel-identical** at the physical window bottom (background - rect, band top, and status text placement all unchanged). Both halves - are asserted in one scenario: a uniformly wrong implementation that - moves the status band too would pass the "everything moved" half - alone. The geometry declaration separately reserves the divider while - the panel is `Absent`, and the document loses no pixels until a - `Present` is painted. +- **A2B-4 (contrast assertion).** Installing a panel moves **all twelve + document-owned consumers** of §5.3 by exactly + `installed_panel_height + divider_height`, **while all eight + status-owned sites stay pixel-identical** at the physical window + bottom. Both halves are asserted in one scenario: a uniformly wrong + implementation that moves the status band too passes the "everything + moved" half alone. Three rows carry their own named symptom because + they are the ones a plausible implementation misclassifies — + **document completion (`:6140`) must not overlap the band**, + **minibuffer candidates (`:7351`) must not be clipped by it**, and + **edge scrolling (`:8561`) must not trigger from inside it**. The + geometry declaration separately reserves the divider while the panel + is `Absent`, and the document loses no pixels until a `Present` is + painted. All three boundaries clamp at zero on a surface shorter than + its chrome. - **A2B-5.** `panel_capable` is true only for a v21+ negotiated authenticated semantic session; a v20 semantic session is never **placed** in a side window, not merely denied the events. @@ -612,7 +709,9 @@ slice under this framing so one-feature/one-branch/one-PR holds. **2A lands before 2B branches** — not stacked. - **Stage 2A** — classified census routing + per-window painter - extraction. Branch `bottom-panel-stage2a`. No protocol change. + extraction. Branch `bottom-panel-stage2a`. No protocol change. The + three-boundary GPU split is **2B**, not 2A: it is only observable + once a band can be installed. - **Stage 2B** — v21 protocol, daemon panel projection, GPU band, and the negotiated `panel_capable` flip. Branch `bottom-panel-stage2b`, cut from `main` after 2A merges. Repeats 2A's relevant census From 7c37bdc51455ba974a879340eabae700f1f1eb76 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 19:46:29 -0400 Subject: [PATCH 24/91] fix(lean): repair every buffer and retire every server on fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 review: one P1, and it is the same defect for the FOURTH time. `pmacs.lsp.config.lean4` is a single global entry, so swapping its command invalidates **every** Lean buffer and **every** Lean server — Q#LN15 gives one server per project root, so there can be several. Rounds 1-3 each repaired one buffer and retired one server, and round 3 shipped "repair the armed target, strand the rest": status and config said fallback while a second open Lean buffer stayed on the retired command, and a second project root's server stayed live. The shape that actually holds: * **Retire ALL `lean4` servers on latch**, not the one the probe happened to name. `probe.primary` identifies the server the VERDICT is about; it was never the set of servers the swap invalidates. * **Repair each buffer lazily and at most once**, when it becomes active — on `buffer.after-switch` and on the tick. `_attach_buffer` is an active-buffer-only seam, so a global swap cannot be applied to every open buffer at once; it has to be applied as they surface. lsp.lua's own `after-switch` re-pushes views but does not rebuild a stale attachment, so nothing else covered this. * The **once-per-buffer bound** is load-bearing: without it a fallback that also fails to spawn would retry every tick forever — the round-2 defect, which a naive global repair loop would reintroduce for every buffer instead of just one. * `shutting-down` is deliberately not treated as stale. It is still live by `server_is_live`'s reckoning, so attaching would early-return the stale record and burn that buffer's single attempt on a no-op. P2: argument-inclusive attribution was implemented in round 3 but pinned only by "contains the command name", so a mutation dropping every argument passed. Now asserted against the exact ` ` string. Also fixed a vacuous assertion this refactor created: a test checked `_probe.reattach_from == nil` for a field that no longer exists, which reads as nil and passes for nothing. It now asserts a positive count of recorded repair attempts. Three bites, each against 73587b0: repair only the armed buffer -> the second buffer stays on `lake`; retire only the named server -> one live stale server remains; drop arguments from attribution -> the exact-string assertion fails. The ledger records a second durable lesson beside the vacuity one: **a scope error repeats until the scope is named.** Four rounds of locally correct fixes, none of which asked what the config swap invalidates. When a change edits shared state, enumerate everything derived from it before repairing anything. --- builtin/runtime/lean.lua | 152 +++++++++++++++++++------------ docs/active-work.md | 33 ++++++- tests/lean4_server_acceptance.rs | 137 +++++++++++++++++++++++++++- 3 files changed, 257 insertions(+), 65 deletions(-) diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua index 8f3b339..546e123 100644 --- a/builtin/runtime/lean.lua +++ b/builtin/runtime/lean.lua @@ -132,6 +132,7 @@ local probe = { -- when the server initializes, because a late -- version verdict still has to retire it armed = false, -- the target buffer + primary have been captured + repaired = {}, -- buffer key -> repair attempted (at most once) saw_initialized = false, } @@ -149,6 +150,16 @@ local function configured_command() return "`" .. tostring(cmd) .. "`" end +-- The fallback command, for status text. +local function fallback_name() + local args = M._fallback.args or {} + if #args > 0 then + return "`" .. tostring(M._fallback.command) .. " " + .. table.concat(args, " ") .. "`" + end + return "`" .. tostring(M._fallback.command) .. "`" +end + local function report(msg) -- COHERENCE §1.2: background work must leave an attributed trace. -- `pmacs.editor.set_status` is the channel that EXISTS; `pmacs.error` @@ -232,8 +243,6 @@ end -- Retire the failed server, swap the command, then rebuild the -- attachment on the buffer that started this. -local try_reattach - -- Retire `sid` so it cannot come back. **Which call to use depends on -- the state, and using the wrong one is worse than doing nothing:** -- @@ -263,67 +272,85 @@ local function retire_server(sid) end end +-- Retire EVERY Lean server, not just the one that failed. +-- +-- `pmacs.lsp.config.lean4` is a single global entry, so swapping its +-- command invalidates every server spawned from the old one — and +-- Q#LN15 gives one server per project root, so there can be several. +-- Retiring only the server that happened to fail left the others live +-- and every buffer attached to them stranded on a command the config no +-- longer names. +local function retire_all_lean_servers() + local ok, rows = pcall(pmacs.lsp.list) + if not ok or not rows then return end + local ids = {} + for _, info in ipairs(rows) do + if info.language_id == "lean4" then ids[#ids + 1] = info.id end + end + for _, id in ipairs(ids) do retire_server(id) end +end + +-- Rebuild the ACTIVE buffer's attachment if it is Lean and stale. +-- +-- `_attach_buffer` is an active-buffer-only seam, so a global config +-- swap cannot be applied to every open buffer at once. It is applied +-- lazily instead: whenever a Lean buffer becomes the active one, if its +-- record points at a server that is gone or terminal, it is rebuilt. +-- +-- **At most one attempt per buffer.** Without that bound a fallback +-- that also fails to spawn would retry every tick forever with nothing +-- reported — the round-2 defect, which a general repair loop would +-- otherwise reintroduce for every buffer instead of just one. +-- +-- A `shutting-down` server is deliberately NOT treated as stale: it is +-- still live by `server_is_live`'s reckoning, so `attach_buffer` would +-- early-return the stale record and burn this buffer's single attempt +-- on a no-op. Skipping leaves the attempt for a later tick, once the +-- retirement has actually landed. +local function repair_active_if_stale() + if not probe.latched then return end + local buf = pmacs.window.buffer() + if not buf then return end + local key = tostring(buf) + if probe.repaired[key] then return end + local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf) + if not ok_lang or lang ~= "lean4" then return end + + local rec = pmacs.lsp.active_attachment() + local stale + if not rec then + stale = true + else + local kind = server_state_kind(rec.server) + stale = (kind == nil or kind == "crashed" or kind == "stopped") + end + if not stale then return end + + probe.repaired[key] = true + local ok, fresh = pcall(pmacs.lsp._attach_buffer) + if not ok or not fresh then + report("LSP: lean4 fallback " .. fallback_name() + .. " did not start either") + end +end + local function fire_latch(sid, why) if probe.latched then return end probe.latched = true probe.watching = nil - if sid then retire_server(sid) end if not swap_to_fallback() then report("LSP: lean4 " .. why) + -- Still retire: the servers are broken whether or not a replacement + -- command was installed, and leaving them live would keep the + -- restart machinery running against a command known to fail. + retire_all_lean_servers() return end - report("LSP: lean4 " .. why .. "; falling back to `" - .. tostring(M._fallback.command) .. "`") - -- **Spawn the replacement and re-point the buffer at it.** Swapping - -- the config is not a fallback on its own: nothing re-fires an attach - -- on a config change and `attach_buffer` early-returns for a live - -- attachment, so without this the buffer stays bound to the server we - -- just retired and the user has a config edit and no language server. - -- - -- The rebuild waits for two things, and conflating them is what made - -- round 2 wrong in two ways at once: - -- 1. the retired server actually reaching a terminal state (or - -- being gone) — `stop` leaves `shutting-down`, which - -- `server_is_live` counts as LIVE, so attaching before then - -- early-returns the stale record and the swap silently no-ops; - -- 2. the buffer that started this being the ACTIVE one, because - -- `_attach_buffer` is an active-buffer-only seam. The verdict - -- arrives asynchronously, so the user may well be somewhere else - -- by then — and "some attachment now names a different server" - -- is satisfied by an unrelated Rust buffer, which would clear the - -- retry while leaving the Lean buffer stale forever. - probe.reattach_from = sid and tostring(sid) or false - try_reattach() -end - --- Returns true when there is nothing left to do: either the initiating --- buffer is attached to the replacement, or the replacement itself --- failed and that has been reported. -function try_reattach() - if probe.reattach_from == nil then return true end - -- (2) Wait for the initiating buffer to be the active one. - local buf = pmacs.window.buffer() - if not buf or not probe.buf_key or tostring(buf) ~= probe.buf_key then - return false - end - -- (1) Wait for the retired server to stop counting as live. - if probe.reattach_from then - local kind = server_state_kind_for_key(probe.reattach_from) - if kind ~= nil and kind ~= "crashed" and kind ~= "stopped" then - return false - end - end - -- Both conditions met: attempt the replacement EXACTLY ONCE. Cleared - -- first so a failing fallback cannot retry every tick forever — - -- acceptance 27 promises a second failure surfaces rather than loops. - probe.reattach_from = nil - local ok, rec = pcall(pmacs.lsp._attach_buffer) - if not ok or not rec then - report("LSP: lean4 fallback `" .. tostring(M._fallback.command) - .. "` did not start either") - return false - end - return true + retire_all_lean_servers() + report("LSP: lean4 " .. why .. "; falling back to " .. fallback_name()) + -- Repair what is in front of the user now; everything else is + -- repaired lazily as it becomes active (see `repair_active_if_stale`). + repair_active_if_stale() end local function drain_probe() @@ -566,19 +593,26 @@ pmacs.hook.add("buffer.after-load", function() end end) +-- A buffer switch is the moment a stale Lean buffer becomes visible, so +-- repair immediately rather than waiting for the next tick. lsp.lua's +-- own `after-switch` subscription re-pushes views but does NOT rebuild a +-- stale attachment, so nothing else covers this. +pmacs.hook.add("buffer.after-switch", function() + repair_active_if_stale() +end) + pmacs.hook.add("process.after-tick", function() drain_probe() poll_latch() - -- Keep trying until the stopped server is really gone; see the note in - -- `fire_latch`. - if probe.reattach_from ~= nil then try_reattach() end + -- Repair the active buffer if the latch invalidated it. Cheap when + -- there is nothing to do, and bounded to one attempt per buffer. + repair_active_if_stale() end) -- Test seam: acceptance drives the latch deterministically rather than -- waiting on real process timing. Not part of the public surface. M._probe = probe M._fire_latch = fire_latch -M._try_reattach = try_reattach M._version_below_3_1 = version_below_3_1 pmacs.lean = M diff --git a/docs/active-work.md b/docs/active-work.md index 6cdac4f..e4e1064 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ If it does not, stop and repair the remote/fetch configuration. - Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in `src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`, a `leanprogress` mode plus `waitForDiagnostics` validation on - `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (28 tests). + `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (31 tests). No protocol change. - **Stage 1's acceptance 12 is half superseded and was rewritten, not deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a @@ -319,6 +319,21 @@ If it does not, stop and repair the remote/fetch configuration. are one fact and are now armed together, once. Plus a P2: the failure message hardcoded `lake serve` after the latch became command-agnostic, sending wrapper users to debug the wrong binary. +- **Round-4 review: one P1, and it is the same defect a FOURTH time.** + `pmacs.lsp.config.lean4` is a single global entry, so swapping its + command invalidates **every** Lean buffer and **every** Lean server — + Q#LN15 gives one per project root. Rounds 1–3 each fixed the repair + for one buffer and one server; round 4 is "repair the armed target, + strand the rest". The shape that finally holds: retire ALL `lean4` + servers on latch, and repair each buffer **lazily and at most once** + when it becomes active (`buffer.after-switch` + the tick), because + `_attach_buffer` is active-buffer-only and cannot reach the others. + The per-buffer once-only bound is what stops a failing fallback + retrying forever — the round-2 defect a naive global repair loop would + have reintroduced for every buffer instead of one. Plus a P2: the + argument-inclusive attribution was implemented but pinned only by + "contains the command name", so a mutation dropping every argument + still passed. - **DURABLE LESSON — "the test that passes" vs "the test that discriminates."** Six tests across three rounds were written, run green, and only bite-testing showed they pinned nothing. **Carry this @@ -341,8 +356,20 @@ If it does not, stop and repair the remote/fetch configuration. 6. A fixture whose `serve` sleeps can never let the primary initialize first, so it cannot reach the ordering where a late verdict must retire a LIVE server. + 7. Asserting on a field that no longer exists (`_probe.reattach_from` + after a refactor) reads as nil and passes for nothing. Assert + positive facts — a count, a command string — not absences. Rule: **a test is not evidence until the mutation it targets has been shown to fail it.** +- **SECOND DURABLE LESSON — a scope error repeats until the scope is + named.** The "fallback silently does not happen" defect came back four + times: no re-attach; re-attach cleared by an unrelated buffer; + re-attach satisfied by the server being replaced; re-attach of one + buffer while the others stay stale. Every fix was locally correct and + none asked *what does this config swap invalidate?* — the answer being + every Lean buffer and every Lean server, because the config entry is + global and servers are per-root. **When a change edits shared state, + enumerate everything derived from it before repairing anything.** - **SUBSTRATE BUG FOUND, not fixed here (framing §6).** `LspManager::stop` on an ALREADY-terminal server takes its not-initialized branch, terminates the dead process and sets @@ -368,9 +395,9 @@ If it does not, stop and repair the remote/fetch configuration. server-failure latch covers the rest. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; - lean4 server 28/28; lean4 stage 1 9/9; dispatch seams 15/15; + lean4 server 31/31; lean4 stage 1 9/9; dispatch seams 15/15; multi-root 13/13; M4 121; required GPU 155; **isolated-config - workspace sweep 3,217 across 94 suites, zero failures**; + workspace sweep 3,220 across 94 suites, zero failures**; `git diff --check` clean. (Round 1 of this entry recorded 17/17 and 3,206 — the PRE-fix counts — after the fixes were pushed. The ledger's protocol is that verification diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs index db708f1..32cdf3e 100644 --- a/tests/lean4_server_acceptance.rs +++ b/tests/lean4_server_acceptance.rs @@ -1010,9 +1010,19 @@ fn r2_a_failing_fallback_is_reported_once_and_does_not_retry_forever() { "a failing fallback surfaces rather than retrying silently; saw \ {status:?}" ); - // And the retry state is cleared, so it is not looping. - let pending: String = eval(&state, "return tostring(pmacs.lean._probe.reattach_from)"); - assert_eq!(pending, "nil", "the retry is retired, not spinning"); + // And the repair was ATTEMPTED and recorded, so it is bounded rather + // than spinning. Asserting on a field that no longer exists would + // read as nil and pass for nothing — the vacuity shape this branch + // keeps producing, so the assertion is on a positive count. + let attempted: i64 = eval( + &state, + "local n = 0 for _ in pairs(pmacs.lean._probe.repaired) do n = n + 1 end return n", + ); + assert_eq!( + attempted, 1, + "exactly one repair attempt was made and recorded, so a failing \ + fallback cannot retry every tick forever" + ); } #[test] @@ -1240,3 +1250,124 @@ fn r3_a_failing_wrapper_is_named_truthfully_not_as_lake_serve() { "and does not attribute the failure to `lake serve`; saw {status:?}" ); } + +// --------------------------------------------------------------------------- +// Round-4 review — the config swap is GLOBAL, so one repaired buffer is +// not a fallback. Both fail against 73587b0. +// --------------------------------------------------------------------------- + +#[test] +fn r4_every_open_lean_buffer_is_repaired_not_just_the_armed_one() { + // `pmacs.lsp.config.lean4` is a single entry; swapping its command + // invalidates every buffer attached to the old one. Round 3 repaired + // exactly `probe.buf_key` and cleared the retry, leaving every other + // open Lean buffer on the retired server while status and config + // both said "fell back". + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let first = fx.write("pkg/A.lean", "def a := 1\n"); + let second = fx.write("pkg/B.lean", "def b := 2\n"); + let lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &lake); + + open(&state, &first); + exec(&state, "_G.first_buf = pmacs.window.buffer()"); + open(&state, &second); + exec(&state, "_G.second_buf = pmacs.window.buffer()"); + tick_for(&mut state, 700); + + // The armed (first) buffer. + exec(&state, "pmacs.window.switch_buffer(_G.first_buf)"); + tick_for(&mut state, 500); + assert_eq!( + attached_command(&state), + fake_lsp_path(), + "the armed buffer is repaired" + ); + + // And the OTHER one, which round 3 stranded. + exec(&state, "pmacs.window.switch_buffer(_G.second_buf)"); + tick_for(&mut state, 500); + assert_eq!( + attached_command(&state), + fake_lsp_path(), + "every open Lean buffer ends up on the fallback — repairing only \ + the armed target leaves this one on the retired server" + ); +} + +#[test] +fn r4_a_second_project_roots_server_is_also_retired() { + // Q#LN15 gives one server per project root, so a swap can invalidate + // several. `probe.primary` names only the first; retiring only that + // leaves the second root's server live on a command the config no + // longer names. + let fx = Fixture::new(); + fx.toolchain("one", "v4.9.0\n"); + fx.toolchain("two", "v4.9.0\n"); + let a = fx.write("one/A.lean", "def a := 1\n"); + let b = fx.write("two/B.lean", "def b := 2\n"); + let lake = fx.lake_stub("bin/lake", "Lake version 3.0.0"); + let mut state = editor(&fx); + with_fallback(&state, &lake); + + open(&state, &a); + open(&state, &b); + // Two roots, two servers, before any verdict lands. + let before: i64 = eval(&state, "return #pmacs.lsp.list()"); + assert_eq!(before, 2, "precondition: one server per root"); + + tick_for(&mut state, 900); + + // No server may still be running the retired command. + let stale_live: i64 = eval( + &state, + &format!( + r#" + local n = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.command) == "{}" then + local k = s.state and s.state.kind + if k ~= "stopped" and k ~= "crashed" then n = n + 1 end + end + end + return n + "#, + lua_str(&lake) + ), + ); + assert_eq!( + stale_live, 0, + "every Lean server spawned from the old command is retired, not \ + just the one the probe happened to name" + ); +} + +#[test] +fn r4_attribution_names_the_exact_command_and_its_arguments() { + // Round 3 implemented argument-inclusive attribution but pinned only + // "contains my-lean-wrapper" and "does not contain lake serve" — a + // mutation dropping every argument still passed. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/my-lean-wrapper"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + exec( + &state, + "pmacs.lsp.config.lean4.args = { \"serve\", \"--quiet\" }", + ); + + open(&state, &file); + settle(&mut state); + + let status = state.core.borrow().status.clone(); + let expected = format!("`{} serve --quiet`", absent.display()); + assert!( + status.contains(&expected), + "the status names the exact configured command AND its arguments;\n \ + want substring: {expected}\n saw: {status:?}" + ); +} From 4fbd47f02553c363dfd96a031946111068881271 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 19:49:19 -0400 Subject: [PATCH 25/91] =?UTF-8?q?docs:=20refresh=20the=20handoff's=20Stage?= =?UTF-8?q?=202=20status=20(COHERENCE=20=C2=A725)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/agent-handoff.md` §1 still said Stage 2 "needs its own re-framing". It is framed, so that line would be false on `main` the moment this branch merges. It now records the approved shape — protocol v21, two serial slices (2A census routing + painter extraction, then 2B wire/projection/band/ capability flip), parent acceptance 37-55 still authoritative — and carries the census classification rule itself, since that is the fact the ledger previously got wrong and the one a future reader is most likely to re-derive incorrectly. Co-Authored-By: Claude Opus 5 (1M context) --- docs/agent-handoff.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d873063..b2d0aeb 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -171,10 +171,20 @@ commands, read `docs/active-work.md` immediately after this file. `bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121; required GPU 152; initial-target 14 CRDT; all three vterm suites; folding Stage 2 48. All 12 CI checks green at merge. - - **Stage 2 (the GPU panel band) needs its own re-framing** before - implementation and takes the next available protocol version; the - framing's §1.3 census of 23 transitive active-context reads is its map. - Stage 3 is the adopter default flip. + - **Stage 2 (the GPU panel band) is FRAMED** — + `docs/bottom-panel-stage2-framing.md`, four review rounds, no open + items. It takes protocol **v21** and ships as two serial slices: + **2A** classified census routing + per-window painter extraction (no + wire change), then **2B** the wire, the daemon projection, the band, + and the negotiated `panel_capable` flip. Parent acceptance 37–55 + remains authoritative. Stage 3 is the adopter default flip. + - **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the + Projection class (#1–#12, #21–#22) routes through + `primary_document_window`; focus/input (#13–#15, #23), focus chrome + and surface-routed (#16–#19), and focus/session (#20) keep their own + authorities. Rerouting them breaks remote-op validation and + application, `DispatchIdle`, presence, focused + search/menu/completion routing, and terminal bell ownership. - **GPU initial target LANDED — #148** (`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path From 2b42204693a0df3099e5a4cad0bd43951387abb9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 21:10:16 -0400 Subject: [PATCH 26/91] docs: integrate #175 and align the recovery threshold with the base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #175 (bottom-panel Stage 2 framing) landed after this branch's last head and touches both shared docs, so the previous green run did not cover the combination. Merged cleanly this time — no conflict. Also fixes an inconsistency this PR introduced: the recovery check still accepted `d152120` while the canonical-base line above declared a newer commit. A threshold looser than the base it guards passes on a tree the rest of the file does not describe, so the two now move together and the text says why. --- docs/active-work.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index bfacee3..1ad3bdc 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -23,14 +23,14 @@ here too until #172 removed it — that is the update those two owe.) machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `ccf29e3` (the CRDT undo repro #157 atop the - inline-math landed-doc refresh #172, the bottom-panel landed-doc - refresh #156, the inline-math slice #158, dired Stage 1 #165, the GPU - terminal input fix #166, Lean 4 Stage 2 #161, the dired framing #164, - COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, and the minimap - blank-slab fix #159; protocol v20). **Lanes below that name an older - base have not been re-based; derive their integration surface from - `git diff ..main`.** + `githubsucks/main` @ `c93f9ee` (the bottom-panel Stage 2 framing #175 + atop the CRDT undo repro #157, the inline-math landed-doc refresh #172, + the bottom-panel landed-doc refresh #156, the inline-math slice #158, + dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 Stage 2 + #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean 4 + Stage 1 #160, and the minimap blank-slab fix #159; protocol v20). + **Lanes below that name an older base have not been re-based; derive + their integration surface from `git diff ..main`.** - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -64,7 +64,11 @@ git worktree list git status --short --branch ``` -The `git log` command must expose `d152120` or a newer intentional main. +The `git log` command must expose `c93f9ee` — the base named above — or a +newer intentional main. Keep this threshold and the canonical-base line in +step: a recovery check that accepts an older commit than the base it +declares canonical will pass on a tree the rest of this file does not +describe. If it does not, stop and repair the remote/fetch configuration. ## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161) From dd581cd90ce1cc72c5d9a5f09caa3ca852b853b9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 21:12:51 -0400 Subject: [PATCH 27/91] docs: frame the PTY terminate diagnostic (revision 4) A docs-only PR (#172) failed Test (macos-latest / luajit) on acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel with "kill: EPERM: Operation not permitted" raised out of terminate. A docs diff cannot cause that, main was green at the PR's exact base, and three other PRs passed the same job. This framing reaches revision 4 after three review rounds, and what it proposes is much smaller than what it started with. Revisions 1 to 3 each proposed a tolerance rule -- treat some errno as success -- and each was unsound in the same way: they concluded something about a process from something that was not about that process. Revision 1 concluded from an errno alone, which says only that a syscall failed. Revision 2 concluded from the spawned leader while a PTY signal targets the tty's foreground process group, which diverges from the leader exactly when job control is in use. Revision 3 corrected EPERM but kept group-directed ESRCH, which proves only that the selected foreground group vanished, not that the leader exited. So no tolerance rule lands. The disposition is preserved exactly: every failing call still fails, with no state transition and no ledger arming. What lands is that the failure explains itself, recording the target source and value, the spawn-time pgid or leader pid, the errno, and the leader's real try_wait state as five separate facts. Every candidate fix is decidable from those together and none is decidable from the errno alone. Two claims are stated more narrowly than earlier revisions had them. Consulting try_wait reaps an exited child and caches its status, so this is not "strictly additive" -- it is "no disposition change", with an event-count test pinning that poll_one still emits exactly one exit event. And the test seam injects the kill attempt's result only, never the observation, so the real ChildHandle::try_wait runs against the real child; a stubbed observation would bypass the path under test. Parked with their reasons: all tolerance rules, terminate becoming idempotent for an already-reaped process (an independent fix answering a different failure), and signal_target's read-then-kill of tcgetpgrp, which is the most likely real fix site. The lane closes when this lands rather than waiting for the flake to recur; the next occurrence carries its own evidence under whoever's PR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HZjWMjwPXhPbt9upku9mCk --- docs/process-signal-tolerance-framing.md | 258 +++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/process-signal-tolerance-framing.md diff --git a/docs/process-signal-tolerance-framing.md b/docs/process-signal-tolerance-framing.md new file mode 100644 index 0000000..1d3c45d --- /dev/null +++ b/docs/process-signal-tolerance-framing.md @@ -0,0 +1,258 @@ +# Framing — make the PTY terminate failure self-describing (diagnostic only) + +**Revision 4.** Status: awaiting review round 4. Lane: +`pty-terminate-eperm`, worktree `../pmacs-math-slice`, based on +`githubsucks/main` @ `ccf29e3`. + +**Diagnostic only. No disposition changes, no tolerance rules, no +behavioural fix.** Every rule this document proposed across revisions 1 +to 3 is parked (§5). The lane's entire deliverable is that the next +occurrence of the failure explains itself. + +## Revision history + +**Revision 3 → 4**, after review round 3 (two blocking, one major) and +its scope call. All accepted. + +- **Group-directed ESRCH was also unsafe**, for the same reason EPERM + was: it proves the selected *foreground group* vanished, not that the + leader exited. A job-control race — foreground job exits after + `tcgetpgrp` and before `kill`, shell alive and not yet reclaiming the + terminal — would have been reported as success with the leader never + signalled. Rev 3's acceptance 7 pinned that unsafe behaviour. **All + tolerance is parked** (§5). +- **Rev 3's Stage A implemented Stage B.** It declared itself + diagnostic-only, then listed tolerance and bookkeeping acceptances. + Removed. +- **Q#PS6 (already-reaped `terminate` is `Ok`) is parked separately.** + It is an independent behavioural fix answering a different failure; + under one-feature/one-PR it does not ride with instrumentation. +- **"Strictly additive / cannot regress behaviour" was overstated** and + is narrowed (Q#PD3). +- The injected-kill seam is restored as an explicit decision (Q#PD4). + +**Rounds 1–3, for the record.** Rev 1 classified on errno alone and +claimed a live owned child cannot yield EPERM — false. Rev 2 gated on +`try_wait`, which observes the leader while a PTY signal targets the +foreground group — unsound whenever those diverge, and it could not be +shown to fix the observed failure at all. Rev 3 corrected EPERM but left +ESRCH unsafe and mixed the stages. **Three consecutive designs were +wrong in the same direction: each tried to conclude something about a +process from something that was not about that process.** + + +## 0. Coherence impact (COHERENCE §20) + +- **Journey step 8, "Open a terminal"** (§2), teardown half. **No grade + change and no behavioural change** — this lane only improves what a + failure reports. +- **Serves §9 (worker model), failure attribution**, in its most literal + sense: an error that names only an errno cannot be attributed. +- **Interaction islands: none. Config registry: not adopted. + Background-work attribution: unchanged.** +- **No audited claim in COHERENCE.md changes**, so under §25 no + COHERENCE edit rides this PR. + + +## 1. Ground truth (scouted @ `ccf29e3`, re-verified each revision) + +### 1.1 The failure reports an errno and nothing else + +`ProcessSupervisor::signal` (`src/process.rs:921`) maps the `kill` +failure to `format!("kill: {e}")` (`:931`). That string is everything a +reader gets. + +### 1.2 The signal target is not the observation target + +- **Signal target** — `signal_target` (`:687`) returns `-pgrp` for a + PTY, where `pgrp = master.process_group_leader()`: the tty's + **current foreground process group**, read at signal time. +- **Observation target** — `ChildHandle::try_wait` (`:668`) observes the + **spawned leader**. + +They coincide only while the leader owns the terminal. Job control is +precisely the mechanism that makes them diverge, and the PTY path is +**always group-directed by design** — spawn rejects `group = true` for +PTY mode with the rationale that "PTY children already lead their own +session and are signaled group-wide" (`:1428-1429`). + +**This is why every tolerance rule across rev 1–3 failed review**, and +why the diagnostic must record the target and the leader state as +*separate* facts. + +### 1.3 The reap ledger is disjoint from this path + +`tick_reap_ledger` (`:1075`) treats any probe error as "nothing left we +can reach" for **bounded growth**, asserting EPERM "cannot happen for our +own children". It is armed only for `proc.spec.group`, which PTY mode +cannot set. Rev 1's "asymmetry" argument was a misreading; withdrawn. + +### 1.4 The observed failure, and the limits of the evidence + +macOS CI, PR #172 (**docs-only** diff), `Test (macos-latest / luajit)`, +`acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel` +([attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30177276839/attempts/1)): + +``` +in function 'terminate' +cause: ExternalError(Process("kill: EPERM: Operation not permitted")) +``` + +**Established:** the errno, and the call path +(`pmacs.terminal.terminate` → `session.rs:566` → `signal`). + +**Not established:** that the child had exited (the probe's last source +statement is a file write at +`tests/bottom_panel_stage1_acceptance.rs:2239`; CPython teardown follows +and does not synchronise with it); that any pgid was recycled; or what +the signal target actually was. + +**This is the whole reason the lane is diagnostic.** Every candidate fix +needs at least one of those three facts, and none is available. + +### 1.5 Caller inventory + +| Caller | Disposition | +|---|---| +| `src/lsp.rs:1364`, `:2427` | discards (`let _ =`) | +| `src/mcp.rs:1229`, `:1239`, `:1915` | discards (`let _ =`) | +| `src/terminal/session.rs:319`, `:607`, `:635` | discards (`let _ =`) | +| **`src/terminal/session.rs:566`** (propagating at `:577`) | **propagates** as `TerminalError::Process` | +| supervisor-internal `shutdown` path | discards | +| `src/lua_bindings/mod.rs:8150`, `:8164` | propagates to Lua | +| `src/lua_bindings/mod.rs:8717` | propagates (via `session.rs:566`) | +| `src/daemon.rs:4162` | **test-only** `.expect`, not production | + +No test in the repository asserts either error string, so widening the +message breaks nothing. + +### 1.6 `portable-pty` caches the exit status on Unix + +Pinned `portable-pty 0.9.0`: `spawn_command` returns +`std::process::Child` (`unix.rs:228`), and `impl Child for +std::process::Child::try_wait` delegates to +`std::process::Child::try_wait` (`lib.rs:271-277`), which caches into +`self.status`. Both `ChildHandle` variants therefore cache. + + +## 2. Decisions + +### Q#PD1 — what the widened error records + +On a `kill` failure in `signal`, the error carries: + +| Field | Why | +|---|---| +| **target source** — `tcgetpgrp` vs `group` vs `leader-pid` fallback | which branch of `signal_target` (`:687`) ran | +| **target kind and value** — `-pgid` or `pid`, with the number | the entity actually signalled | +| **spawn-time pgid / leader pid** | a divergence from the target is the job-control hypothesis, visible only by comparison | +| **errno** | as today | +| **leader `try_wait` state** — `exited(status)` / `live` / `unobservable(e)` | separates "the leader is gone" from "the group we signalled is gone" — the distinction all three failed designs collapsed | + +Every candidate Stage B rule is decidable from these five together, and +none is decidable from the errno alone. + +### Q#PD2 — the disposition is preserved exactly + +The call still fails, with the same `Err`, in every case. No state +transition changes, no ledger arming changes, no tolerance. A reader +diffing behaviour should find none. + +### Q#PD3 — the honest claim is "no disposition change", not "strictly additive" + +Rev 3 said the diagnostic was only an error-string change and could not +regress behaviour. **That overstated it.** `try_wait` on an exited child +**reaps it and caches the status**, so consulting it in the failure path +is an internal state change: the child may be reaped earlier than it +otherwise would be. + +Observably safe, because both variants cache (§1.6) and `poll_one` +(`:1133`) will still see `Ok(Some(_))` and emit its event. But safe by +argument is not safe by assertion, so the terminate-failure-then-tick +event pin is retained (acceptance 5). + +### Q#PD4 — the injected-kill seam injects the KILL, never the observation + +Acceptance 5 needs a forced `kill` failure while the **real** +`ChildHandle::try_wait` runs against the **real** child. A stubbed +observation would bypass exactly the code path in question. + +So the seam is a test-only override of the *kill attempt's result*, +consumed once by the signal path; everything downstream — target +selection, the observation, the error construction — runs for real. This +also makes the diagnostic's own fields testable without racing the +kernel. + +### Q#PD5 — nothing else lands here + +No tolerance rule, no idempotence change, no `signal_target` change. See +§5. + + +## 3. Bets (falsifiable) + +- **B1 — The five fields are sufficient to discriminate the §1.4 + hypotheses.** Falsified if a recurrence carries all five and still + leaves the cause ambiguous — which would itself be a finding worth + having. +- **B2 — Widening the message breaks no caller.** Evidence: §1.5, and no + test asserts the string. + +*Retracted across revisions and not reinstated:* rev 1's "a live owned +child cannot yield EPERM"; rev 2's "exit observation suffices"; rev 2's +"this removes the failure class"; rev 3's "group ESRCH is safe to +tolerate". + + +## 4. Acceptance + +1. A group-directed `kill` failure produces an error carrying all five + Q#PD1 fields, with the target rendered as `-pgid` and the leader + state distinct from it. +2. A leader-directed `kill` failure does the same, with the target + rendered as `pid` and the target source recorded as the fallback + branch. +3. The leader state renders each of `exited(status)`, `live`, and + `unobservable(e)` correctly. +4. **The disposition is unchanged**: every injected failure still + returns `Err`, with no state transition and no ledger arming + (Q#PD2). Falsified by revert — flipping any arm to `Ok` fails this. +5. **Forced injected kill failure against the real PTY child + observation**, then tick: exactly one exit event, with the correct + status (Q#PD3/Q#PD4). A fully stubbed observation does not satisfy + this and is rejected as vacuous. +6. The existing suites stay green, pinning "no behavioural change" from + the outside. + + +## 5. Parked (not deferred-and-forgotten — each needs its own evidence) + +- **All tolerance rules.** Group-directed EPERM *and* ESRCH both fail on + the §1.2 entity split; leader-directed tolerance is plausible but + unmotivated until evidence shows the fallback branch is ever taken. + Needs Stage A evidence first. +- **Q#PS6, `terminate` on an already-reaped process returning `Ok`.** + Independent behavioural fix, different failure (§1.6 of rev 3), its + own lane under one-feature/one-PR. +- **`signal_target`'s read-then-kill of `tcgetpgrp`** — still the most + likely real fix site, still unframed. +- `terminate` cancelling pending restarts; PTYs in + `pmacs.process.list`; any change to `C-c` delivery. + + +## 6. Gates + +Full suite per `CLAUDE.md`. Touched suites: +`bottom_panel_stage1_acceptance`, the vterm stages, and +`compile_mode_acceptance`. Sweep with `-- --skip basedpyright`. + + +## 7. Branch plan + +`pty-terminate-eperm`, one PR, diagnostic only. This framing is its first +commit; the instrumentation and its tests are the second. + +**The lane then closes.** It does not wait for the flake to recur: the +next occurrence — whenever it happens, under whoever's PR — carries its +own evidence, and Stage B is framed then. Math work proceeds immediately +after this lands. From 62316a9ced3e8e36d9771fb3bd365640cc757052 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 21:25:58 -0400 Subject: [PATCH 28/91] fix(process): make a failing kill describe itself (Q#PD1) A failing kill in ProcessSupervisor::signal reported an errno and nothing else, which is not enough to diagnose the macOS CI failure that prompted this lane: three different hypotheses about that EPERM produce the same message, and the fix each one implies is different. The error now carries five facts as separate fields: the target source (which branch of signal_target ran), the target kind and value, the spawn-time group for a group-directed signal, the errno, and the spawned leader's real try_wait state. Keeping the target and the leader apart is the whole point. For a PTY the signal goes to the terminal's foreground process group, read from the tty at signal time, while the leader is the child that was spawned. Those are different entities whenever job control has moved the terminal, and three rejected designs for this code were unsound precisely because they concluded something about one from the other. The report states both and concludes nothing. The disposition is unchanged. Every call that failed before still fails, with no state transition and no reap-ledger arming. That is asserted directly rather than assumed, because it is what separates this from the tolerance rules review rejected. Q#PD3, stated narrowly: this is not a pure message change. Consulting try_wait reaps an exited child and caches its status, so the child may be reaped earlier than it otherwise would be. That is observably safe because portable-pty 0.9.0 returns a std::process::Child on Unix and delegates try_wait straight to it, so the status is cached and poll_one still sees it -- but safe by argument is not safe by assertion, so a test forces a kill failure against the real PTY child and then checks that exactly one terminal event survives. Q#PD4: the test seam injects the kill attempt's result only, never the observation. Target selection, the real ChildHandle::try_wait against the real child, and the error construction all run unmodified; a stubbed observation would bypass the code path under test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HZjWMjwPXhPbt9upku9mCk --- src/process.rs | 385 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 378 insertions(+), 7 deletions(-) diff --git a/src/process.rs b/src/process.rs index 2b53bf3..88fb42e 100644 --- a/src/process.rs +++ b/src/process.rs @@ -470,6 +470,13 @@ pub struct ProcessSupervisor { /// TERM→KILL window used when arming the ledger. Constant /// [`GROUP_TERM_GRACE`] in production; overridable in tests. group_term_grace: Duration, + /// Q#PD4 test seam: forces the next `kill(2)` attempt in + /// [`Self::signal`] to fail with this errno, consumed once. + /// Always `None` in production — there is no way to set it outside + /// `cfg(test)`. It replaces the *kill result only*, so the leader + /// observation still runs against the real child handle; a stubbed + /// observation would bypass the code path under test. + forced_kill_errno: Option, } /// One armed group in the reap ledger. @@ -684,7 +691,50 @@ impl ChildHandle { } } -fn signal_target(proc: &ManagedProcess, pid: u32) -> Result { +/// Which branch of [`signal_target`] chose the target (Q#PD1). +/// +/// Recorded on failure because the branches differ in what a failing +/// `kill` can possibly mean: only [`Self::LeaderPid`] aims at the +/// spawned child itself. The other two aim at a *group*, which for a +/// PTY is read from the terminal and can belong to something the +/// supervisor never spawned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetSource { + /// The tty's current foreground process group, read at signal + /// time. Diverges from the leader exactly when job control has + /// moved the terminal. + ForegroundGroup, + /// A `group = true` pipe child leading its own process group. + SpawnGroup, + /// The child's own pid. + LeaderPid, +} + +impl TargetSource { + fn as_str(self) -> &'static str { + match self { + Self::ForegroundGroup => "tcgetpgrp", + Self::SpawnGroup => "group", + Self::LeaderPid => "leader-pid", + } + } + + /// Whether the target is a process group rather than one process. + fn is_group(self) -> bool { + matches!(self, Self::ForegroundGroup | Self::SpawnGroup) + } +} + +/// The entity a signal was actually aimed at, plus the branch that +/// chose it. Carried so a failure can report the target as a fact +/// separate from the leader's state (Q#PD1). +#[derive(Debug, Clone, Copy)] +struct SignalTarget { + pid: Pid, + source: TargetSource, +} + +fn signal_target(proc: &ManagedProcess, pid: u32) -> Result { if let Some(runtime) = proc.runtime.as_ref() && let ChildHandle::Pty { _master: master, .. @@ -692,7 +742,10 @@ fn signal_target(proc: &ManagedProcess, pid: u32) -> Result { && let Some(pgrp) = master.process_group_leader() && pgrp > 0 { - return Ok(Pid::from_raw(-pgrp)); + return Ok(SignalTarget { + pid: Pid::from_raw(-pgrp), + source: TargetSource::ForegroundGroup, + }); } // `group = true` pipe children lead a fresh process group // (`process_group(0)` at spawn ⇒ pgid == pid), so fatal signals @@ -700,11 +753,80 @@ fn signal_target(proc: &ManagedProcess, pid: u32) -> Result { // (Q#CM3). if proc.spec.group { let pgid = i32::try_from(pid).map_err(|e| e.to_string())?; - return Ok(Pid::from_raw(-pgid)); + return Ok(SignalTarget { + pid: Pid::from_raw(-pgid), + source: TargetSource::SpawnGroup, + }); } - Ok(Pid::from_raw( - i32::try_from(pid).map_err(|e| e.to_string())?, - )) + Ok(SignalTarget { + pid: Pid::from_raw(i32::try_from(pid).map_err(|e| e.to_string())?), + source: TargetSource::LeaderPid, + }) +} + +/// The spawned leader's state at the moment a `kill` failed (Q#PD1). +/// +/// Deliberately reported *beside* the target rather than folded into a +/// verdict: for a PTY the two are different entities whenever job +/// control has moved the terminal, and three successive designs for +/// this code were unsound precisely because they collapsed them. +enum LeaderObservation { + Exited(TermStatus), + Live, + Unobservable(String), + NoRuntime, +} + +impl LeaderObservation { + fn render(&self) -> String { + match self { + Self::Exited(TermStatus::Exited(code)) => format!("exited(code {code})"), + Self::Exited(TermStatus::Signaled(sig)) => format!("exited(signal {sig})"), + Self::Live => "live".to_owned(), + Self::Unobservable(e) => format!("unobservable({e})"), + Self::NoRuntime => "no-runtime".to_owned(), + } + } +} + +/// Observe the spawned leader. Note this *reaps* an exited child and +/// caches its status; that is why Q#PD3 claims "no disposition change" +/// rather than "strictly additive", and why an event-count test pins +/// that `poll_one` still emits exactly one exit event afterwards. +fn observe_leader(proc: &mut ManagedProcess) -> LeaderObservation { + let Some(runtime) = proc.runtime.as_mut() else { + return LeaderObservation::NoRuntime; + }; + match runtime.child.try_wait() { + Ok(Some(status)) => LeaderObservation::Exited(status), + Ok(None) => LeaderObservation::Live, + Err(e) => LeaderObservation::Unobservable(e), + } +} + +/// Render a failing `kill` as the five facts of Q#PD1. The disposition +/// is unchanged (Q#PD2) — this only replaces a message that said +/// nothing but the errno. +fn signal_failure_report( + target: SignalTarget, + leader_pid: u32, + errno: &nix::errno::Errno, + leader: &LeaderObservation, +) -> String { + let expected = if target.source.is_group() { + match i32::try_from(leader_pid) { + Ok(p) => format!(", expected_group=-{p}"), + Err(_) => String::new(), + } + } else { + String::new() + }; + format!( + "kill: {errno} (target={} via {}, leader_pid={leader_pid}{expected}, leader={})", + target.pid.as_raw(), + target.source.as_str(), + leader.render(), + ) } /// Termination status of one generation. Internal --- the supervisor @@ -807,9 +929,20 @@ impl ProcessSupervisor { shut_down: false, reap_ledger: HashMap::new(), group_term_grace: GROUP_TERM_GRACE, + forced_kill_errno: None, } } + /// Q#PD4 test seam: make the next `kill(2)` attempt in + /// [`Self::signal`] report `errno` instead of calling the kernel. + /// Consumed by that one attempt. Everything downstream — target + /// selection, the leader observation against the real child, and + /// the error construction — runs unmodified. + #[cfg(test)] + fn force_next_kill_errno(&mut self, errno: nix::errno::Errno) { + self.forced_kill_errno = Some(errno); + } + /// Override the SIGTERM-to-SIGKILL grace window. Test helper. pub fn set_grace_period(&mut self, d: Duration) { self.grace_period = d; @@ -928,7 +1061,21 @@ impl ProcessSupervisor { return Err(format!("process {id} is not running")); }; let target = signal_target(proc, pid)?; - nix::sys::signal::kill(target, Some(signal)).map_err(|e| format!("kill: {e}"))?; + // Q#PD4: the seam injects the KILL attempt's result only — + // never the observation below — so target selection, the real + // `ChildHandle::try_wait` against the real child, and the error + // construction all run for real. Consumed once. + let kill_result = match self.forced_kill_errno.take() { + Some(errno) => Err(errno), + None => nix::sys::signal::kill(target.pid, Some(signal)), + }; + if let Err(errno) = kill_result { + // Q#PD1/Q#PD2: the failure describes itself; the + // disposition is unchanged — this still returns `Err`, + // with no state transition and no ledger arming. + let leader = observe_leader(proc); + return Err(signal_failure_report(target, pid, &errno, &leader)); + } if matches!(signal, Signal::SIGTERM | Signal::SIGKILL | Signal::SIGHUP) { proc.state = ProcessState::Exiting { pid, @@ -2131,6 +2278,230 @@ mod tests { ); } + /// Spawn a PTY child that stays alive until terminated, and wait + /// for its `Started` event so a pid and a foreground group exist. + fn spawn_live_pty(sup: &mut ProcessSupervisor, name: &str) -> ProcessId { + let mut spec = ProcessSpec::new(name, "/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.mode = ProcessMode::Pty { + rows: 24, + cols: 80, + mode: TerminalMode::Canonical, + }; + let id = sup.spawn(spec).expect("spawn"); + let _ = drain_until(sup, id, Duration::from_secs(5), |evs| { + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) + }); + id + } + + /// Q#PD1 acceptance 1 — a group-directed failure names the target, + /// the branch that chose it, the expected group, the errno, and the + /// leader's own state, as five separate facts. + /// + /// The leader field is the one that matters: for a PTY the signal + /// goes to the terminal's foreground group, which is a different + /// entity from the spawned child whenever job control has moved + /// the terminal. Three rejected designs for this code collapsed + /// the two; the report keeps them apart. + #[test] + fn a_group_directed_kill_failure_reports_target_and_leader_separately() { + let mut sup = ProcessSupervisor::new(); + let id = spawn_live_pty(&mut sup, "diag-group"); + + sup.force_next_kill_errno(nix::errno::Errno::EPERM); + let err = sup.terminate(id).expect_err("injected EPERM must fail"); + + assert!(err.contains("EPERM"), "errno is reported: {err}"); + assert!( + err.contains("via tcgetpgrp"), + "the target SOURCE distinguishes a tty-read group from a spawn group: {err}" + ); + assert!( + err.contains("target=-"), + "a group target renders negative: {err}" + ); + assert!( + err.contains("expected_group=-"), + "the spawn-time group is shown so a divergence is visible: {err}" + ); + assert!( + err.contains("leader=live"), + "the leader is observed independently of the group: {err}" + ); + // Non-vacuity: the two numbers are actually rendered, not empty. + assert!( + err.contains("leader_pid=") && !err.contains("leader_pid=0,"), + "a real leader pid is reported: {err}" + ); + } + + /// Q#PD1 acceptance 2 — a leader-directed failure records the + /// fallback branch and a positive target, and omits the group + /// field that would be meaningless for it. + #[test] + fn a_leader_directed_kill_failure_reports_the_fallback_branch() { + let mut sup = ProcessSupervisor::new(); + let mut spec = ProcessSpec::new("diag-leader", "/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + let id = sup.spawn(spec).expect("spawn"); + let _ = drain_until(&mut sup, id, Duration::from_secs(5), |evs| { + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) + }); + + sup.force_next_kill_errno(nix::errno::Errno::ESRCH); + let err = sup.terminate(id).expect_err("injected ESRCH must fail"); + + assert!(err.contains("ESRCH"), "errno is reported: {err}"); + assert!( + err.contains("via leader-pid"), + "a non-group pipe child targets its own pid: {err}" + ); + assert!( + !err.contains("target=-"), + "a leader target renders positive: {err}" + ); + assert!( + !err.contains("expected_group="), + "the group field is omitted where it has no meaning: {err}" + ); + let _ = sup.signal(id, Signal::SIGKILL); + } + + /// Q#PD1 acceptance 3 — every leader state renders distinctly. The + /// `Unobservable` and `NoRuntime` arms cannot be produced by a + /// real child on demand, so they are pinned directly; `live` and + /// `exited` are pinned through the real path by the tests around + /// this one. + #[test] + fn every_leader_observation_renders_distinctly() { + assert_eq!( + LeaderObservation::Exited(TermStatus::Exited(0)).render(), + "exited(code 0)" + ); + assert_eq!( + LeaderObservation::Exited(TermStatus::Signaled("SIGTERM".into())).render(), + "exited(signal SIGTERM)" + ); + assert_eq!(LeaderObservation::Live.render(), "live"); + assert_eq!( + LeaderObservation::Unobservable("try_wait: boom".into()).render(), + "unobservable(try_wait: boom)" + ); + assert_eq!(LeaderObservation::NoRuntime.render(), "no-runtime"); + } + + /// Q#PD1 acceptance 3, exited arm through the REAL path — the + /// leader has genuinely exited and the report says so. + #[test] + fn a_failure_after_the_child_exits_reports_the_leader_as_exited() { + let mut sup = ProcessSupervisor::new(); + let mut spec = ProcessSpec::new("diag-exited", "/bin/sh"); + spec.args = vec!["-c".into(), "exit 3".into()]; + let id = sup.spawn(spec).expect("spawn"); + // Wait for the child to actually be gone, but do NOT tick past + // the point where the record leaves Running — `signal` needs a + // live record to reach the kill at all. + std::thread::sleep(Duration::from_millis(300)); + + sup.force_next_kill_errno(nix::errno::Errno::EPERM); + let err = sup.terminate(id).expect_err("injected EPERM must fail"); + + assert!( + err.contains("leader=exited("), + "an exited leader is observed as exited, not guessed from the errno: {err}" + ); + } + + /// Q#PD2 acceptance 4 — **the disposition is unchanged.** An + /// injected failure still fails, and neither the state transition + /// nor the reap-ledger arming runs. This is the assertion that + /// separates a diagnostic from the tolerance rules three review + /// rounds rejected; flipping any arm to `Ok` fails it. + #[test] + fn an_injected_failure_changes_no_state_and_arms_no_ledger() { + let mut sup = ProcessSupervisor::new(); + let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.group = true; + let id = sup.spawn(spec).expect("spawn"); + let _ = drain_until(&mut sup, id, Duration::from_secs(5), |evs| { + evs.iter() + .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) + }); + assert!( + sup.reap_ledger.is_empty(), + "precondition: nothing armed before the attempt" + ); + + sup.force_next_kill_errno(nix::errno::Errno::EPERM); + let err = sup.terminate(id).expect_err("injected EPERM must fail"); + assert!(err.contains("via group"), "a group=true pipe child: {err}"); + + assert!( + matches!( + sup.processes.get(&id).expect("record").state, + ProcessState::Running { .. } + ), + "a failed kill must not transition the record to Exiting" + ); + assert!( + sup.reap_ledger.is_empty(), + "a failed kill must not arm the reap ledger" + ); + + let _ = sup.signal(id, Signal::SIGKILL); + } + + /// Q#PD3/Q#PD4 acceptance 5 — the diagnostic consults the REAL + /// `ChildHandle::try_wait` on the REAL child, which reaps it and + /// caches the status. `poll_one` must still emit exactly one exit + /// event afterwards. + /// + /// A stubbed observation would bypass the double-`try_wait` path + /// entirely and pin nothing, so the injection replaces the kill + /// result only. + #[test] + fn observing_the_leader_does_not_consume_the_exit_event() { + let mut sup = ProcessSupervisor::new(); + let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh"); + spec.args = vec!["-c".into(), "exit 7".into()]; + spec.mode = ProcessMode::Pty { + rows: 24, + cols: 80, + mode: TerminalMode::Canonical, + }; + let id = sup.spawn(spec).expect("spawn"); + std::thread::sleep(Duration::from_millis(300)); + + // The forced failure drives `observe_leader`, which try_waits + // the real PTY child for the first time. + sup.force_next_kill_errno(nix::errno::Errno::EPERM); + let err = sup.terminate(id).expect_err("injected EPERM must fail"); + assert!( + err.contains("leader=exited("), + "the real handle was consulted: {err}" + ); + + // Now the supervisor's own try_wait must still see the status. + let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); + let terminal = evs + .iter() + .filter(|e| { + matches!( + e.kind, + ProcessEventKind::Exited { .. } | ProcessEventKind::Signaled { .. } + ) + }) + .count(); + assert_eq!( + terminal, 1, + "exactly one terminal event survives the diagnostic's try_wait" + ); + } + #[test] fn signal_terminates_a_running_child() { let mut sup = ProcessSupervisor::new(); From 19f48d46c0eed0ef1ddf17de1bc180b22420372e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 21:28:11 -0400 Subject: [PATCH 29/91] fix(lsp,lean): bound the fallback's own failure; heal at point of use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 review: one P1, a frontend scope hole, and three P2s. **1. A fallback that SPAWNS and then dies retried forever.** The once-per-buffer guard bounds calls to `_attach_buffer`, not the server those calls produce. `ensure_server` still never forwards `cfg.restart`, so the fallback inherits `OnCrash`; an executable that exits before `initialize` is respawned by the manager with no attempt ceiling — silently, because `latched` has already disabled the primary's failure poll. The fallback now gets its own one-shot die-before-initialize watch, which retires it (ending the respawn loop) and reports. The prior failing-fallback test used a NONEXISTENT executable, so it only ever exercised synchronous ENOENT. To reach "spawned, then died" the fixture has to actually spawn. **2. Simultaneous frontends.** Both repair triggers read the ambient `pmacs.window.buffer()`, and the daemon restores `active_frontend` to the last-dispatched frontend before `tick_processes` — so a Lean buffer active in ANOTHER frontend receives no `buffer.after-switch` here and stays stale after its server is globally retired. Fixed at the seam that is frontend-agnostic: **make consumption safe.** `attached_for_active` now rebuilds rather than returning a record whose server is dead, and `attachment_for_request` reports none (it must not perturb LSP state, so it cannot rebuild). Whichever frontend runs a command is the active one while it runs, so healing at the point of use reaches every buffer no eager sweep can. This also closes the half where a dead attachment was handed to a command and the request vanished. **3. The retirement sweep stopped user-managed servers.** Selecting on `language_id == "lean4"` also names servers the user spawned from `init.lua`, which are not derived from `pmacs.lsp.config.lean4`. It now keys on the `default-lean4` label `ensure_server` stamps — the derivation discriminator. **4. Repair ran even when no swap occurred.** `swap_to_fallback()` returning false left `latched` true, so the next tick retried the UNCHANGED configuration and reported it as a fallback failure. Split into `probe.fallback_installed`: repair exists to apply a swap, so no swap means nothing to apply. **5. The once-per-buffer assertion counted table keys**, which cannot distinguish "once per buffer" from "every tick for one buffer" — cardinality stays 1 either way. Replaced with a numeric attempt counter; the bite reports 174 attempts against the expected 1. Five bites, each against 7c37bdc: no fallback watch -> attempt reaches 4; retire by language_id -> the user's server is stopped; gate repair on `latched` -> a repair is attempted with no swap; drop the once-per-buffer guard -> 174 vs 1; hand back a dead attachment -> a command receives a `stopped` server. Two more vacuity shapes recorded in the ledger (8 and 9): counting distinct keys cannot bound repeated work, and a nonexistent executable cannot reach any post-spawn failure. --- builtin/runtime/lean.lua | 66 +++++++- builtin/runtime/lsp.lua | 26 +++ docs/active-work.md | 36 +++- tests/lean4_server_acceptance.rs | 271 +++++++++++++++++++++++++++++++ 4 files changed, 391 insertions(+), 8 deletions(-) diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua index 546e123..6490296 100644 --- a/builtin/runtime/lean.lua +++ b/builtin/runtime/lean.lua @@ -133,6 +133,12 @@ local probe = { -- version verdict still has to retire it armed = false, -- the target buffer + primary have been captured repaired = {}, -- buffer key -> repair attempted (at most once) + repair_attempts = 0, -- COUNT of attach attempts, not distinct buffers: + -- table cardinality cannot tell "once per buffer" + -- from "every tick for one buffer" + fallback_installed = false, + fallback_watch = nil, -- fallback sid being polled for die-before-init + fallback_failed = false, saw_initialized = false, } @@ -280,12 +286,21 @@ end -- Retiring only the server that happened to fail left the others live -- and every buffer attached to them stranded on a command the config no -- longer names. -local function retire_all_lean_servers() +-- Only servers this module's config produced. `ensure_server` labels +-- every auto-attached server `default-`, so that label is the +-- derivation discriminator: a server the USER spawned from `init.lua` +-- carries their own label, is not derived from `pmacs.lsp.config.lean4`, +-- and must not be stopped because our config changed. Selecting on +-- `language_id` alone swept those up too — a destructive side effect on +-- state this module does not own. +local DERIVED_LABEL = "default-lean4" + +local function retire_derived_lean_servers() local ok, rows = pcall(pmacs.lsp.list) if not ok or not rows then return end local ids = {} for _, info in ipairs(rows) do - if info.language_id == "lean4" then ids[#ids + 1] = info.id end + if info.label == DERIVED_LABEL then ids[#ids + 1] = info.id end end for _, id in ipairs(ids) do retire_server(id) end end @@ -308,7 +323,14 @@ end -- on a no-op. Skipping leaves the attempt for a later tick, once the -- retirement has actually landed. local function repair_active_if_stale() - if not probe.latched then return end + -- **`fallback_installed`, not `latched`.** When the swap does not + -- happen — the config already names the fallback, or it vanished + -- before an asynchronous verdict landed — `fire_latch` returns early + -- but `latched` stays true. Gating repair on `latched` then retried + -- the UNCHANGED configuration and reported the result as a fallback + -- failure, which is both a second pointless spawn and a misleading + -- message. Repair exists to apply a swap; no swap, nothing to apply. + if not probe.fallback_installed then return end local buf = pmacs.window.buffer() if not buf then return end local key = tostring(buf) @@ -327,10 +349,42 @@ local function repair_active_if_stale() if not stale then return end probe.repaired[key] = true + probe.repair_attempts = probe.repair_attempts + 1 local ok, fresh = pcall(pmacs.lsp._attach_buffer) if not ok or not fresh then report("LSP: lean4 fallback " .. fallback_name() .. " did not start either") + return + end + -- **A successful SPAWN is not a successful START.** The once-per- + -- buffer bound stops `_attach_buffer` being called again, but it says + -- nothing about the server it produced: `ensure_server` never forwards + -- `cfg.restart`, so the fallback inherits `OnCrash`, and an executable + -- that dies before `initialize` is respawned by the manager forever + -- with no attempt ceiling — silently, because `latched` has already + -- disabled the primary's failure poll. Watch this one too, once. + if not probe.fallback_watch and not probe.fallback_failed then + probe.fallback_watch = fresh.server + end +end + +-- The fallback's own die-before-initialize poll. One shot: on failure it +-- retires the server (which is what actually ends the respawn loop) and +-- reports, and never re-arms. +local function poll_fallback() + local sid = probe.fallback_watch + if not sid then return end + local kind = server_state_kind(sid) + if kind == "initialized" then + probe.fallback_watch = nil + return + end + if kind == nil or kind == "crashed" or kind == "stopped" then + probe.fallback_watch = nil + probe.fallback_failed = true + if kind ~= nil then retire_server(sid) end + report("LSP: lean4 fallback " .. fallback_name() + .. " started but did not stay up") end end @@ -343,10 +397,11 @@ local function fire_latch(sid, why) -- Still retire: the servers are broken whether or not a replacement -- command was installed, and leaving them live would keep the -- restart machinery running against a command known to fail. - retire_all_lean_servers() + retire_derived_lean_servers() return end - retire_all_lean_servers() + retire_derived_lean_servers() + probe.fallback_installed = true report("LSP: lean4 " .. why .. "; falling back to " .. fallback_name()) -- Repair what is in front of the user now; everything else is -- repaired lazily as it becomes active (see `repair_active_if_stale`). @@ -607,6 +662,7 @@ pmacs.hook.add("process.after-tick", function() -- Repair the active buffer if the latch invalidated it. Cheap when -- there is nothing to do, and bounded to one attempt per buffer. repair_active_if_stale() + poll_fallback() end) -- Test seam: acceptance drives the latch deterministically rather than diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 0749cfa..6081a3d 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -871,6 +871,21 @@ local function attached_for_active() if not buf then return nil end local key = tostring(buf) local rec = attachments[key] + -- A record whose server is dead is worse than no record: every + -- command below issues requests against it and gets silence. Rebuild + -- instead, which is what `attach_buffer` does for a stale attachment + -- anyway — this just stops the dead record short-circuiting that. + -- + -- Load-bearing for anything that retires a server out from under open + -- buffers (Arc 8 Stage 3b's fallback latch retires every Lean server + -- at once). Buffers in OTHER frontends get no `buffer.after-switch` + -- in this one, so an eager repair sweep keyed on the ambient active + -- buffer cannot reach them; healing at the point of USE is + -- frontend-agnostic, because whichever frontend runs the command is + -- the active one while it runs. + if rec and not server_is_live(rec.server) then + rec = nil + end if rec then -- Every interactive command resolves its attachment here before -- issuing requests; flushing now means the server answers those @@ -942,6 +957,17 @@ function pmacs.lsp.attachment_for_request() local key = tostring(buf) local rec = attachments[key] if not rec then return nil end + -- Same liveness rule as `attached_for_active`: a record naming a dead + -- server is worse than none, because the caller issues a request + -- against it and waits for a reply that cannot come. Unlike that + -- function this one is deliberately non-attaching (it must not + -- perturb LSP state), so a dead record reads as "no attachment" + -- rather than triggering a rebuild. + if not server_is_live(rec.server) then + attachments[key] = nil + pending_did_change[key] = nil + return nil + end flush_did_change(key) return rec end diff --git a/docs/active-work.md b/docs/active-work.md index e4e1064..6b99985 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ If it does not, stop and repair the remote/fetch configuration. - Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in `src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`, a `leanprogress` mode plus `waitForDiagnostics` validation on - `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (31 tests). + `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (36 tests). No protocol change. - **Stage 1's acceptance 12 is half superseded and was rewritten, not deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a @@ -334,6 +334,31 @@ If it does not, stop and repair the remote/fetch configuration. argument-inclusive attribution was implemented but pinned only by "contains the command name", so a mutation dropping every argument still passed. +- **Round-5 review: one P1 plus a frontend scope hole, and four more.** + (1) A fallback that SPAWNS and then dies retried forever: the + once-per-buffer guard bounds `_attach_buffer`, not the server it + produced, and `ensure_server` never forwards `cfg.restart` so the + fallback inherits `OnCrash` — respawned by the manager with no + ceiling, silently, because `latched` had disabled the primary's poll. + The fallback now gets its own one-shot die-before-initialize watch. + (2) **Simultaneous frontends**: both repair triggers read the ambient + `pmacs.window.buffer()`, and the daemon restores `active_frontend` to + the last-dispatched one before `tick_processes`, so a Lean buffer + active in ANOTHER frontend gets no `after-switch` and stays stale. + Fixed at the right seam — **make CONSUMPTION safe**: both + `attached_for_active` and `attachment_for_request` now refuse a record + whose server is dead (the former rebuilds, the latter reports none, + since it must not perturb LSP state). Healing at the point of use is + frontend-agnostic, because whichever frontend runs a command is active + while it runs. (3) The retirement sweep selected on `language_id`, so + it stopped USER-spawned Lean servers too; it now keys on the + `default-lean4` label `ensure_server` stamps, which is the derivation + discriminator. (4) `probe.latched` gated repair even when NO swap + occurred, so an already-fallback config was retried and misreported. + Split out `probe.fallback_installed`. (5) The once-per-buffer + assertion counted TABLE KEYS, which cannot distinguish "once per + buffer" from "every tick for one buffer" — cardinality stays 1 either + way. Now a numeric attempt counter; the bite shows **174 vs 1**. - **DURABLE LESSON — "the test that passes" vs "the test that discriminates."** Six tests across three rounds were written, run green, and only bite-testing showed they pinned nothing. **Carry this @@ -359,6 +384,11 @@ If it does not, stop and repair the remote/fetch configuration. 7. Asserting on a field that no longer exists (`_probe.reattach_from` after a refactor) reads as nil and passes for nothing. Assert positive facts — a count, a command string — not absences. + 8. Counting DISTINCT KEYS cannot bound REPEATED WORK: a per-tick retry + on one buffer keeps `#repaired == 1` forever. Count the attempts, + not the things attempted against (bite: 174 vs 1). + 9. A NONEXISTENT executable only exercises synchronous ENOENT. To + reach "spawned, then died", the fixture must actually spawn. Rule: **a test is not evidence until the mutation it targets has been shown to fail it.** - **SECOND DURABLE LESSON — a scope error repeats until the scope is @@ -395,9 +425,9 @@ If it does not, stop and repair the remote/fetch configuration. server-failure latch covers the rest. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; - lean4 server 31/31; lean4 stage 1 9/9; dispatch seams 15/15; + lean4 server 36/36; lean4 stage 1 9/9; dispatch seams 15/15; multi-root 13/13; M4 121; required GPU 155; **isolated-config - workspace sweep 3,220 across 94 suites, zero failures**; + workspace sweep 3,225 across 94 suites, zero failures**; `git diff --check` clean. (Round 1 of this entry recorded 17/17 and 3,206 — the PRE-fix counts — after the fixes were pushed. The ledger's protocol is that verification diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs index 32cdf3e..e28ac8f 100644 --- a/tests/lean4_server_acceptance.rs +++ b/tests/lean4_server_acceptance.rs @@ -1371,3 +1371,274 @@ fn r4_attribution_names_the_exact_command_and_its_arguments() { want substring: {expected}\n saw: {status:?}" ); } + +// --------------------------------------------------------------------------- +// Round-5 review. All fail against 7c37bdc. +// --------------------------------------------------------------------------- + +#[test] +fn r5_a_fallback_that_dies_after_spawning_is_bounded_and_reported() { + // The once-per-buffer guard bounds calls to `_attach_buffer`, not + // the server it produced. `ensure_server` never forwards + // `cfg.restart`, so the fallback inherits `OnCrash` and a binary + // that exits before `initialize` is respawned forever — silently, + // because `latched` has already disabled the primary's poll. The + // prior failing-fallback test used a NONEXISTENT executable, which + // only exercises synchronous ENOENT. + use std::os::unix::fs::PermissionsExt as _; + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent_primary = fx.dir("bin/no-such-lake"); + let dying_fallback = fx.root.join("bin/dying-lean"); + std::fs::create_dir_all(dying_fallback.parent().unwrap()).unwrap(); + std::fs::write(&dying_fallback, "#!/bin/sh\nexit 4\n").unwrap(); + std::fs::set_permissions(&dying_fallback, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent_primary), + lua_str(&dying_fallback) + ), + ); + + open(&state, &file); + tick_for(&mut state, 1600); + + // Nothing may be respawning: `attempt` counts spawns per server. + let worst_attempt: i64 = eval( + &state, + r" + local worst = 0 + for _, s in ipairs(pmacs.lsp.list()) do + local a = s.attempt or 0 + if a > worst then worst = a end + end + return worst + ", + ); + assert!( + worst_attempt <= 1, + "a dying fallback must not be respawned indefinitely; saw \ + attempt {worst_attempt}" + ); + let status = state.core.borrow().status.clone(); + assert!( + status.contains("did not stay up") || status.contains("did not start"), + "and the second failure is reported; saw {status:?}" + ); +} + +#[test] +fn r5_a_user_spawned_lean_server_is_not_retired_by_the_fallback() { + // `retire_*` selected on `language_id == "lean4"`, which also names + // servers the user spawned themselves from `init.lua`. Those are not + // derived from `pmacs.lsp.config.lean4` and stopping them is a + // destructive side effect on state this module does not own. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lake"); + let mut state = editor(&fx); + with_fallback(&state, &absent); + exec( + &state, + &format!( + r#" + _G.mine = pmacs.lsp.spawn({{ + label = "my-own-lean", + language_id = "lean4", + command = "{}", + args = {{}}, + }}) + "#, + fake_lsp_path() + ), + ); + settle(&mut state); + + open(&state, &file); + tick_for(&mut state, 600); + + let mine_alive: bool = eval( + &state, + r#" + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(_G.mine) then + local k = s.state and s.state.kind + return k ~= "stopped" and k ~= "crashed" + end + end + return false + "#, + ); + assert!( + mine_alive, + "a user-spawned Lean server survives a config-driven fallback — \ + it was never derived from that config" + ); +} + +#[test] +fn r5_no_swap_means_no_repair_attempts() { + // When the config already names the fallback, `swap_to_fallback` + // returns false and `fire_latch` returns early — but `latched` is + // true, so a repair gated on `latched` retried the UNCHANGED + // configuration and reported it as a fallback failure. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent = fx.dir("bin/no-such-lean"); + let mut state = editor(&fx); + // Config and fallback are the SAME missing command, so no swap is + // possible. + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{}} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent), + lua_str(&absent) + ), + ); + + open(&state, &file); + tick_for(&mut state, 400); + + let attempts: i64 = eval(&state, "return pmacs.lean._probe.repair_attempts"); + assert_eq!( + attempts, 0, + "no swap happened, so there is nothing to apply and no repair \ + should be attempted" + ); + let status = state.core.borrow().status.clone(); + assert!( + !status.contains("falling back"), + "and nothing claims a fallback occurred; saw {status:?}" + ); +} + +#[test] +fn r5_repair_is_attempted_at_most_once_per_buffer_by_count() { + // Counting keys in the `repaired` table cannot distinguish + // "once per buffer" from "every tick for one buffer" — the + // cardinality stays 1 either way. Count the ATTEMPTS. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let absent_primary = fx.dir("bin/no-such-lake"); + let absent_fallback = fx.dir("bin/no-such-lean"); + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent_primary), + lua_str(&absent_fallback) + ), + ); + + open(&state, &file); + // Many ticks; a per-tick retry would climb without bound. + tick_for(&mut state, 900); + + let attempts: i64 = eval(&state, "return pmacs.lean._probe.repair_attempts"); + assert_eq!( + attempts, 1, + "exactly one repair attempt across many ticks for one buffer" + ); +} + +#[test] +fn r5_a_dead_attachment_is_never_handed_to_a_command() { + // Buffers live in other frontends get no `buffer.after-switch` here, + // so an eager sweep keyed on the ambient active buffer cannot reach + // them. Healing at the point of USE is frontend-agnostic: + // `attached_for_active` must not return a record whose server is + // gone. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + // A working primary, so we get a live attachment first. + exec( + &state, + &format!("pmacs.lsp.config.lean4.command = \"{}\"", fake_lsp_path()), + ); + open(&state, &file); + settle(&mut state); + let first: String = attached_sid(&state); + assert_ne!(first, "none", "precondition: attached"); + + // Retire it out from under the buffer, as the latch does globally, + // WITHOUT any switch or repair tick. + exec( + &state, + r" + local rec = pmacs.lsp.active_attachment() + pcall(pmacs.lsp.stop, rec.server) + ", + ); + for _ in 0..40 { + state.tick_processes(); + state.tick_lsp(); + std::thread::sleep(Duration::from_millis(5)); + } + + // Now a command resolves its attachment. It must not get the dead + // one; it must rebuild. + // `attachment_for_request` is deliberately non-attaching, so a dead + // record must read as "no attachment" rather than being handed over. + let for_request: String = eval( + &state, + r#" + local rec = pmacs.lsp.attachment_for_request() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.state and s.state.kind) + end + end + return "gone" + "#, + ); + assert_eq!( + for_request, "none", + "a non-attaching resolve must not hand back a dead server" + ); + + // And the attaching path rebuilds rather than returning the corpse. + let rebuilt: String = eval( + &state, + r#" + pmacs.lsp._attach_buffer() + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.state and s.state.kind) + end + end + return "gone" + "#, + ); + assert!( + rebuilt != "stopped" && rebuilt != "crashed" && rebuilt != "gone" && rebuilt != "none", + "the attaching path rebuilds against a live server; saw \ + {rebuilt:?}" + ); +} From 52731ba1216055ab1c1380ee4f3cc63b42bfae26 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 21:30:23 -0400 Subject: [PATCH 30/91] style: pass Errno by value (clippy pedantic) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HZjWMjwPXhPbt9upku9mCk --- src/process.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/process.rs b/src/process.rs index 88fb42e..1453e10 100644 --- a/src/process.rs +++ b/src/process.rs @@ -810,7 +810,7 @@ fn observe_leader(proc: &mut ManagedProcess) -> LeaderObservation { fn signal_failure_report( target: SignalTarget, leader_pid: u32, - errno: &nix::errno::Errno, + errno: nix::errno::Errno, leader: &LeaderObservation, ) -> String { let expected = if target.source.is_group() { @@ -1074,7 +1074,7 @@ impl ProcessSupervisor { // disposition is unchanged — this still returns `Err`, // with no state transition and no ledger arming. let leader = observe_leader(proc); - return Err(signal_failure_report(target, pid, &errno, &leader)); + return Err(signal_failure_report(target, pid, errno, &leader)); } if matches!(signal, Signal::SIGTERM | Signal::SIGKILL | Signal::SIGHUP) { proc.state = ProcessState::Exiting { From 8e8f281f0e70818d3aca20bc2d1b5747c2e2faad Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 21:47:37 -0400 Subject: [PATCH 31/91] fix(terminal): close review round 1 on Stage 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings, all real. The blocker and both majors are the same mistake in three places: a claim asserted somewhere cheaper than where it actually lives. COHERENCE.md was stale in four places, not the three reported. Step 8 still read "no keybinding" and §11 still read "five settings", but §6's dispatch table also still cited `is_terminal_escape_chord` — a symbol this branch deletes. §25 requires that update to ride the PR, so a PR changing audited ground truth has to re-grep the audit for its own symbols, not only for its topic. Acceptance 5 asserted a registry round-trip, which is a test of the registry: it stayed green with the setting's only consumer deleted. It now opens a real terminal whose child overflows the 24-row screen, scrolls the view to its oldest retained row, and asserts LINE001 is present at 10,000 and absent at 0. Acceptance 8a waited for the session count to fall, which the rejected editor-side cache map satisfies exactly — a map with no purge hook leaks while sessions drain. Adds `TerminalManager::escape_caches()`, the lifetime half of Q#TC4c's contract that `escape_parses` cannot cover. `table.sort` over `pmacs.terminal.profiles` raised "attempt to compare number with string" on the unknown-profile path whenever the user's table held both a string and a numeric key, replacing the exact diagnostic being asked for; `%q` raised likewise on a non-string `profile` argument. Both are partial functions applied to user input on a diagnostic path. Also corrects the framing's status line, and a status message whose embedded whitespace run had survived a rustfmt reflow. Three new bites, each falsified by revert: deleting the scrollback consumer fails acc5 and only acc5; restoring the raw-key sort reproduces the comparison error verbatim; and implementing the rejected map fails the new acc8a at left: 2, right: 1 while passing the old session-count version. Merges githubsucks/main @ ccf29e3. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- COHERENCE.md | 32 ++- builtin/runtime/terminal.lua | 30 ++- docs/active-work.md | 66 +++++- docs/terminal-config-and-copy-mode-framing.md | 26 ++- src/terminal/session.rs | 20 +- tests/terminal_config_acceptance.rs | 192 +++++++++++++++++- 6 files changed, 331 insertions(+), 35 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 9fe85f0..4e7361c 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -368,7 +368,7 @@ Full verdict table: | 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:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | | 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing PR #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 but undiscoverable** | Full PTY with scrollback + modeline segment — reachable only as `M-x terminal`, no keybinding. *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.* | +| 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). Named limitation: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there. *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 | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) | | 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 | **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) | @@ -379,6 +379,13 @@ A journey observation worth keeping verbatim from the audit: C-M-s` opens all folds, while opening a file, opening a terminal, and running a build have no bindings at all. +Two of that observation's three examples have since been answered — +opening a file by `C-x C-f` (#162) and opening a terminal by `C-c t` +(#173). **Running a build still has no binding**, and the underlying +inversion is a standing bias in how new work gets bound, not three +isolated omissions: the quote stays as written because it names the +pattern, and the pattern is not retired until step 9 is. + --- ## 3. A Strong Zero-Configuration State @@ -639,7 +646,7 @@ Everything funnels through one function: `EditorInstance::dispatch_key` | 3 | query-replace | `editor.rs:945` | `QueryReplaceKey::from_chord` (`editor.rs:2967`) | **full shadow** | | 4 | Minibuffer | `editor.rs:951` | `MinibufferAction::from_chord` (`src/minibuffer.rs:468`) | **full shadow** | | 5 | Completion popup | `editor.rs:958-971` | `CompletionPopupKey::from_chord` (`editor.rs:3056`) | **partial shadow** (control chords only; skipped while a multi-key prefix is pending) | -| 6 | Terminal transport + `C-c` escape | `editor.rs:973-1010` | `is_terminal_escape_chord` (`editor.rs:4355`) | **partial, transport-level** | +| 6 | Terminal transport + configurable escape | `editor.rs:973-1010` | `EditorState::terminal_escape_chord` → `TerminalManager::escape_chord` (`src/terminal/session.rs`) | **partial, transport-level** | | 7 | Ordinary dispatch | `editor.rs:1018-1032` | `KeymapStack::resolve` | the only inspectable layer | Facts that define the gap: @@ -647,8 +654,10 @@ Facts that define the gap: - **Full shadows eat every key**, including unrecognized ones (each decoder has an `Ignore`/`Dismiss` fallback arm). While a terminal buffer is focused and unescaped, *all* keys encode to the child — - `C-c`-leading user bindings are **structurally unreachable** in a - terminal buffer. + bindings led by the escape chord are **structurally unreachable** in + a terminal buffer. Since #173 that chord is `terminal.escape-key` + rather than a hardcoded `C-c`, so a user can *move* which prefix is + eaten; they cannot make the shadow stop eating one. - **No transient-keymap mechanism exists to migrate to.** `KeymapStack` has exactly three fixed scopes — `Buffer(BufferId)`, `Mode(String)`, `Global` (`src/keymap_stack.rs:37-44`); resolution order buffer → @@ -1013,20 +1022,29 @@ layering, provenance, and adoption have not followed.** `ConfigValue`s; `describe-setting`'s "Source:" names where `define()` ran. The inspection view sketched above is currently impossible to render. -- **Adoption is five settings**: `editing.auto-pair` (pair.lua), +- **Adoption is eight settings**: `editing.auto-pair` (pair.lua), `editing.trim-on-save` (editops.lua), `autosave.interval-ms` (autosave.lua), `window.panel-height` + `window.min-height` - (window.lua). Everything else a user might set — theme, fonts, LSP + (window.lua), and `terminal.default-profile` + + `terminal.scrollback-rows` + `terminal.escape-key` (terminal.lua, + #173). Everything else a user might set — theme, fonts, LSP server config, killring size, recentf/saveplace/desktop enables, pair sets, comment strings, `pmacs.parse.*` — lives in raw Lua outside the registry and is therefore invisible to `describe-setting` and any future settings UI. The migration list is already written: `docs/config-registry-framing.md` "named deferrals" (table-valued settings are the hard prerequisite for LSP/pair/comment tables). +- **The table-valued gap now has a named, shipped instance.** + `pmacs.terminal.profiles` (#173) is a raw Lua table sitting beside + three registered scalars *for the same feature*, because a profile is + inherently `{ command, args, cwd, env }` and the registry stores four + scalars. It is the clearest evidence yet that table-valued settings + are the blocking prerequisite: the terminal is now half-registered, + and no settings UI can render the half that matters most. - **No persistence**: settings changed at runtime do not survive restart (the `custom-file` split-brain question is a named deferral). - The three-level separation holds in principle today (registry / - hooks+keymaps / packages), but with five settings registered, level 1 + hooks+keymaps / packages), but with eight settings registered, level 1 is effectively empty — users need executable Lua for nearly every ordinary preference, which is the exact failure the section warns about. diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua index a6573fe..143a663 100644 --- a/builtin/runtime/terminal.lua +++ b/builtin/runtime/terminal.lua @@ -65,27 +65,44 @@ local PROFILE_FIELDS = { env = "table", } +-- Every diagnostic below renders a caller- or user-supplied value, so +-- rendering must never be the thing that fails. `%q` is partial — it +-- raises on a table or function — and a profile name arrives straight +-- from `open { profile = ... }`. +local function describe_name(name) + if type(name) == "string" then return string.format("%q", name) end + return string.format("<%s %s>", type(name), tostring(name)) +end + local function validate_profile(name, profile) + local shown = describe_name(name) if type(profile) ~= "table" then - error(string.format("terminal profile %q must be a table", name), 0) + error(string.format("terminal profile %s must be a table", shown), 0) end for key, value in pairs(profile) do local expected = PROFILE_FIELDS[key] if not expected then - error(string.format("terminal profile %q: unknown field %q", name, tostring(key)), 0) + error(string.format("terminal profile %s: unknown field %q", shown, tostring(key)), 0) end if type(value) ~= expected then error(string.format( - "terminal profile %q: field %q must be a %s, got %s", - name, key, expected, type(value)), 0) + "terminal profile %s: field %q must be a %s, got %s", + shown, key, expected, type(value)), 0) end end return profile end +-- `terminal.profiles` is a raw user table, so its keys are whatever the +-- user wrote. Sorting them directly raises "attempt to compare number +-- with string" the moment the table holds both a string and a numeric +-- key — and it raises on the UNKNOWN-PROFILE path, replacing the very +-- error this list exists to explain with an opaque one. Sorting DISPLAY +-- strings is total over every key type, so the diagnostic survives a +-- malformed table. local function known_profile_names() local names = {} - for name in pairs(terminal.profiles) do names[#names + 1] = name end + for name in pairs(terminal.profiles) do names[#names + 1] = tostring(name) end table.sort(names) return names end @@ -106,7 +123,8 @@ local function resolve_profile(requested) local known = known_profile_names() local listed = #known > 0 and table.concat(known, ", ") or "(none defined)" error(string.format( - "terminal profile %q is not defined; known profiles: %s", name, listed), 0) + "terminal profile %s is not defined; known profiles: %s", + describe_name(name), listed), 0) end return validate_profile(name, profile) end diff --git a/docs/active-work.md b/docs/active-work.md index aeac490..972c40b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -396,8 +396,9 @@ If it does not, stop and repair the remote/fetch configuration. Stage 1's branch. Two stages, two branches, two PRs; **no protocol change**. - **Stage 1 = `githubsucks/terminal-config`**, worktree - `../pmacs-terminal-config`, based on `githubsucks/main` @ `d152120`. - Profiles, scrollback, escape key, and the `C-c t` opening binding. + `../pmacs-terminal-config`, based on `githubsucks/main` @ `d152120` + and merged up to `ccf29e3` during review round 1. Profiles, + scrollback, escape key, and the `C-c t` opening binding. - **Stage 2 = `terminal-copy-mode`, not started.** Branch it off `main` after Stage 1 merges: no dependency, but both edit `builtin/runtime/terminal.lua`. @@ -424,16 +425,63 @@ If it does not, stop and repair the remote/fetch configuration. `Ctrl-X` is invisible; and the probe **counts occurrences** rather than testing presence, because a single-character probe collides with the child's own banner text. -- Verification on this branch (against the committed tree): `cargo fmt - --check` clean; strict workspace Clippy clean; 1,832 default + 2,009 - CRDT library tests; `terminal_config_acceptance` 10/10 in **both** - configurations; vterm Stage 1/2/3 9+10 / 6+6 / 5+9; config registry 16; - bottom-panel 46; listview 6; compile 67 (isolated config); M4 121; - required GPU 202; **isolated-config workspace sweep 3,262 across 94 - suites**; `git diff --check` clean. +- **Review round 1 (2026-07-25) — five findings, all real, all fixed.** + One blocker and two majors were the same failure in three places: a + claim asserted somewhere cheaper than where it lives. + - *Blocker — `COHERENCE.md` was stale in four places, not the three + reported.* Step 8 still read "no keybinding"; §11 still read "five + settings"; and §6's dispatch table still cited + `is_terminal_escape_chord`, **a symbol this PR deletes**. §25 makes + that update ride the PR. A PR that changes audited ground truth has + to re-grep the audit for its own symbols, not only for its topic. + - *Major — acceptance 5 was vacuous.* It asserted a registry + round-trip, so it stayed green with the setting's **only** consumer + deleted. It now opens a real terminal whose child overflows the + 24-row screen, scrolls the view to its oldest retained row, and + asserts `LINE001` is present at 10,000 and absent at 0. **Asserting + a value was stored is not asserting anything reads it.** + - *Major — acceptance 8a asserted the session count, not the cache.* + An editor-side map with no purge hook — the exact rejected design — + leaks *while* sessions drain, so it passed. Fixed with a + `TerminalManager::escape_caches()` seam. **A lifecycle claim needs a + lifecycle observable.** + - *Moderate — `table.sort` over user-controlled profile keys.* A + table holding both a string and a numeric key raised `attempt to + compare number with string` **on the unknown-profile path**, + replacing the diagnostic being asked for; `%q` raised likewise on a + non-string `profile` argument. Both are partial functions applied to + user input **on a diagnostic path** — the error reporter was the + thing that failed. + - *Minor — the committed framing still said "not yet approved".* +- **Three new bites, each falsified by revert**: deleting the scrollback + consumer fails acc5 (and only acc5); restoring the raw-key sort + reproduces `attempt to compare string with number` verbatim; and + implementing the rejected editor-side map fails the new acc8a at + `left: 2, right: 1` **while passing the old session-count version** — + which is the review finding demonstrated rather than argued. +- Verification after the round-1 fixes, on the tree merged with + `githubsucks/main` @ `ccf29e3`: `cargo fmt --check` clean; strict + workspace Clippy clean; 1,832 default + 2,009 CRDT library tests; + `terminal_config_acceptance` **12/12 in both configurations**; vterm + Stage 1/2 9+10 / 6+6; config registry 16+16; bottom-panel Stage 1 + 46+46; M4 121; required GPU 202; `git diff --check` clean. - `compile_mode_acceptance` fails 11/67 against the **real** user config and passes 67/67 with an isolated `XDG_CONFIG_HOME` — the known pre-existing trap, not this branch. + - **`vterm_stage3_acceptance::a37` fails on this machine — and fails + identically on the PR's own base `d152120`**, so it is not this + branch's regression. It is load-sensitive: it passed at `d152120` + once and failed at that same commit twenty minutes later, with a + second agent saturating the machine with `rustc` in between. Two + ways it lies, both worth knowing: it **silently returns `ok` when + `pmacs-gpu` is not built** in the same target dir (only + `PMACS_REQUIRE_GPU=1` promotes that skip to a failure, and the gate + list applies that flag to `-p pmacs-gpu`, a *different* package), and + it is **crdt-gated, so CI has never run it at all**. A green a37 in + a gate log means nothing unless the binary was built and the flag + was set. Needs its own lane; see the CI `crdt`-coverage lane on #168. + - `pmacs-gpu` itself failed 201/202 once under the same load and passed + 202/202 on immediate rerun. ## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 (GPU band) is next diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md index 3f13987..48b75d8 100644 --- a/docs/terminal-config-and-copy-mode-framing.md +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -1,7 +1,9 @@ # Terminal configuration and copy mode **Revision 4 — scouted against canonical `main` @ `b889873` (protocol v20), -2026-07-25. Not yet approved; no branch, no implementation.** +2026-07-25. APPROVED after four review rounds. Stage 1 is implemented on +branch `terminal-config` (PR #173); Stage 2 (`terminal-copy-mode`) is +framed but not started, and branches off `main` after Stage 1 merges.** Revision 4 gives the escape-key cache an owner and a lifecycle (Q#TC4c) — revision 3 named the key but not the storage, and two implementations @@ -503,6 +505,15 @@ additive, on its own binding, and does not replace scroll-and-select. error that **lists the known profile names**, and creates no buffer, session, or process. An explicitly passed unknown `profile` fails the same way **even when `terminal.default-profile` is valid** (Q#TC3a). +2a. That diagnostic is **total over a malformed profiles table** (review round + 1). `pmacs.terminal.profiles` is a raw user table, so listing its names must + not assume its keys are comparable and rendering a requested name must not + assume it is a string: a table holding both a string and a numeric key made + `table.sort` raise `attempt to compare number with string` *on the + unknown-profile path*, replacing the exact error being asked for, and `%q` + raises on a non-string `profile` argument. Both are partial functions + applied to user input on a diagnostic path — the failure class is + "the error reporter is the thing that fails". 3. Field-by-field resolution follows Q#TC3a: explicit open field beats profile field beats scalar setting beats `$SHELL`. `env` **merges**, with explicit entries overriding profile entries of the same name. @@ -623,6 +634,19 @@ Full gate suite per `CLAUDE.md` for each PR separately, plus: chord unreachable); **10** (its failure mode is a terminal nobody can escape); and **16/17** (a read-only buffer that silently accepts an edit on both sides). +- **The observation seams the cache pins need are `escape_parses` (how often) + and `escape_caches` (how many are still held).** Neither is inferable from + behavior: for a *valid* setting a correct per-session cache and a leaking + editor-side map produce identical keystroke results, and both leave the + session count draining normally. Review round 1 caught 8a asserting the + session count instead — which the unpurged-map bite passes, since a map with + no purge hook leaks *while* sessions drain. A lifecycle claim needs a + lifecycle observable; the count of live sessions is not one. +- **Criterion 5 must open a real terminal and read back retained history.** + Round 1 caught it asserting a registry round-trip instead, which is a test of + the registry: it stays green with the setting's only consumer deleted. The + same shape to watch for anywhere — *asserting that a value was stored is not + asserting that anything reads it*. - **Do not gate the new suites on `#[cfg(feature = "crdt")]` unless a test genuinely needs CRDT.** CI never enables that feature, so a suite gated that way is written and then never run — 264 tests are currently dark for exactly diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 6e71ea3..c731fb0 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -607,7 +607,7 @@ impl TerminalManager { let already = previously_reported.as_deref() == Some(spelling); let message = (!already).then(|| { format!( - "terminal.escape-key {spelling:?} is not a valid chord ({error}); using C-c" + "terminal.escape-key {spelling:?} is not a valid chord ({error}); using C-c" ) }); (fallback, Some(spelling.to_owned()), message) @@ -633,6 +633,24 @@ impl TerminalManager { self.escape_parses } + /// How many terminals currently hold a cached escape chord. + /// + /// The LIFETIME half of Q#TC4c's cache contract, which `escape_parses` + /// cannot cover: parse counting says a valid setting is read once, but + /// says nothing about whether the cache is ever released. Because the + /// cache lives on [`TerminalSession`], this count falls with the + /// session set by construction — which is exactly the property worth + /// pinning, since the rejected alternative (an editor-side + /// `HashMap`) has no purge hook and would hold + /// this at its high-water mark while sessions drained. + #[must_use] + pub fn escape_caches(&self) -> usize { + self.sessions + .values() + .filter(|session| session.escape.is_some()) + .count() + } + /// Resize a terminal screen and its PTY after validating shared limits. pub fn resize( &mut self, diff --git a/tests/terminal_config_acceptance.rs b/tests/terminal_config_acceptance.rs index a613eb2..ceeb8fe 100644 --- a/tests/terminal_config_acceptance.rs +++ b/tests/terminal_config_acceptance.rs @@ -32,13 +32,18 @@ fn eval_err(state: &EditorState, src: &str) -> String { } } -fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String { - let manager = state.terminal_manager.borrow(); - let Some(snapshot) = manager.snapshot(buffer) else { - return String::new(); - }; +/// The viewport every test projects through. Deliberately SHORTER than +/// the 24-row screen a terminal opens with, so "scroll to the oldest +/// retained row" has somewhere to go even when nothing is retained — +/// which is what makes the two scrollback arms differ by content rather +/// than by whether scrolling was possible at all. +fn viewport() -> CellSize { + CellSize::new(10, 40) +} + +fn cells_to_text(cells: &[pmacs::cell::Cell]) -> String { let mut text = String::new(); - for cell in &snapshot.cells { + for cell in cells { match &cell.glyph { Glyph::Char(c) => text.push(*c), Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)), @@ -48,6 +53,33 @@ fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String { text } +fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String { + let manager = state.terminal_manager.borrow(); + let Some(snapshot) = manager.snapshot(buffer) else { + return String::new(); + }; + cells_to_text(&snapshot.cells) +} + +/// Text a view actually shows, which is where retained history is +/// visible at all — the live `screen_text` above always reads the tail. +fn view_text(state: &EditorState, key: TerminalViewKey) -> String { + let mut manager = state.terminal_manager.borrow_mut(); + manager + .snapshot_for_view(key, viewport()) + .map(|snapshot| cells_to_text(&snapshot.cells)) + .unwrap_or_default() +} + +/// Scroll a view to its OLDEST retained row and read it back. +fn oldest_view_text(state: &EditorState, key: TerminalViewKey) -> String { + state + .terminal_manager + .borrow_mut() + .scroll_view(key, viewport(), i32::MAX); + view_text(state, key) +} + fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool { let deadline = Instant::now() + Duration::from_secs(5); loop { @@ -71,7 +103,7 @@ fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> Windo let mut manager = state.terminal_manager.borrow_mut(); manager.register_view(key); manager.claim_controller(key); - let _ = manager.snapshot_for_view(key, CellSize::new(10, 40)); + let _ = manager.snapshot_for_view(key, viewport()); window } @@ -223,6 +255,44 @@ fn acc2_unknown_profile_lists_known_names_and_creates_nothing() { assert_eq!(state.terminal_manager.borrow().len(), 0); } +/// Acceptance 2 (malformed table): `pmacs.terminal.profiles` is a raw +/// user table, so a diagnostic that walks its keys must be total over +/// them. A table holding both a string and a numeric key made +/// `table.sort` raise "attempt to compare number with string" — on the +/// unknown-profile path, replacing the exact error being asked for. +#[test] +fn acc2_malformed_profile_keys_do_not_mask_the_unknown_profile_error() { + let state = EditorState::new(); + exec(&state, CAT_PROFILE); + exec( + &state, + r#"pmacs.terminal.profiles[1] = { command = "/bin/sh" }"#, + ); + + let err = eval_err( + &state, + r#"return pmacs.terminal.open { profile = "ghost" }"#, + ); + assert!( + err.contains("ghost") && err.contains("echo"), + "the unknown-profile error must survive a malformed table: {err}" + ); + assert!( + !err.contains("attempt to compare"), + "listing known profiles must not raise: {err}" + ); + + // Rendering the REQUESTED name is partial too: `%q` raises on a + // table, and the name arrives straight from the caller. + let err = eval_err(&state, r"return pmacs.terminal.open { profile = {} }"); + assert!( + err.contains("is not defined") && err.contains("known profiles"), + "a non-string profile name must render, not raise: {err}" + ); + + assert_eq!(state.terminal_manager.borrow().len(), 0); +} + /// Acceptance 3: explicit beats profile beats setting beats `$SHELL`, and /// `env` MERGES rather than replacing. #[test] @@ -282,10 +352,77 @@ fn acc3_acc4_explicit_command_wins_and_empty_default_means_no_profile() { state.process_supervisor.borrow_mut().shutdown(); } -/// Acceptance 5: scrollback resolves from the setting, is overridden by an -/// explicit value, and `0` is legal. +/// A child that overflows the 24-row screen and then goes quiet, so its +/// early output can only still be found in RETAINED HISTORY. Zero-padded +/// so `LINE001` is not a substring of `LINE100`. +const FILL_PROFILE: &str = r#" +pmacs.terminal.profiles.fill = { + command = "/bin/sh", + args = { "-c", + "i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" }, +} +"#; + +/// Acceptance 5: the scrollback SETTING reaches the screen's retained +/// history, an explicit spec value overrides it, and `0` is legal. +/// +/// Asserted end to end, through a real child and a real view, rather +/// than by reading the value back out of the registry: a registry +/// round-trip is a test of the registry, and would stay green with the +/// setting's only consumer (`terminal.lua`'s `resolved.scrollback_rows` +/// fallback) deleted outright. #[test] -fn acc5_scrollback_setting_override_and_bounds() { +fn acc5_scrollback_setting_reaches_retained_history() { + let mut state = EditorState::new(); + exec(&state, FILL_PROFILE); + + // Arm 1: `0` is legal, and means the early rows are GONE. + exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#); + let none = open_cat_terminal(&state, r#"profile = "fill""#); + assert!(tick_until(&mut state, "DONE", none), "child finished"); + let window = focus_terminal(&state, none); + let none_key = TerminalViewKey::new(FrontendId::LOCAL, window, none); + let oldest = oldest_view_text(&state, none_key); + assert!( + !oldest.contains("LINE001"), + "with scrollback 0 the oldest retained row must not be the \ + child's first line: {oldest:?}" + ); + + // Arm 2: a large setting retains it, reachable by scrolling back. + exec( + &state, + r#"pmacs.config.set("terminal.scrollback-rows", 10000)"#, + ); + let kept = open_cat_terminal(&state, r#"profile = "fill""#); + assert!(tick_until(&mut state, "DONE", kept), "child finished"); + let window = focus_terminal(&state, kept); + let kept_key = TerminalViewKey::new(FrontendId::LOCAL, window, kept); + let oldest = oldest_view_text(&state, kept_key); + assert!( + oldest.contains("LINE001"), + "with scrollback 10000 the first line must survive in history: \ + {oldest:?}" + ); + + // Arm 3: an explicit spec value beats the setting, which is still 10000. + let overridden = open_cat_terminal(&state, r#"profile = "fill", scrollback_rows = 0"#); + assert!(tick_until(&mut state, "DONE", overridden), "child finished"); + let window = focus_terminal(&state, overridden); + let overridden_key = TerminalViewKey::new(FrontendId::LOCAL, window, overridden); + let oldest = oldest_view_text(&state, overridden_key); + assert!( + !oldest.contains("LINE001"), + "an explicit scrollback_rows = 0 must beat the setting: {oldest:?}" + ); + + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 5 (bounds): the registered range rejects out-of-range +/// values, and `0` is inside it rather than a disabled sentinel. +#[test] +fn acc5_scrollback_bounds() { let state = EditorState::new(); exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#); assert_eq!( @@ -397,6 +534,11 @@ fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() { ); assert!(escape_was_armed(&mut state, b, 'N'), "B primes on its C-b"); let primed = state.terminal_manager.borrow().escape_parses(); + assert_eq!( + state.terminal_manager.borrow().escape_caches(), + 2, + "each primed terminal holds its own cache" + ); // Acceptance 7 — BOTH directions. Asserting only that A still works // after A->B->A is not enough: an epoch-only cache hands whichever @@ -442,6 +584,13 @@ fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() { ); // Acceptance 8a: the cache dies with its terminal. + // + // Waiting for the SESSION count to fall is not the assertion — a + // session set that drains while an editor-side `HashMap` keeps its entry (the rejected implementation named + // in Q#TC4c, which has no purge hook) satisfies it exactly. The + // discriminating observable is the CACHE count, which such a map + // would hold at its high-water mark of 2. let sessions_before = state.terminal_manager.borrow().len(); exec(&state, "pmacs.terminal.terminate(TERM_A)"); exec(&state, "pmacs.buffer.kill(TERM_A)"); @@ -452,10 +601,31 @@ fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() { state.tick_processes(); assert!( Instant::now() < deadline, - "killing the terminal must remove its session, and with it the cache" + "killing the terminal must remove its session" ); thread::sleep(Duration::from_millis(20)); } + assert_eq!( + state.terminal_manager.borrow().escape_caches(), + 1, + "killing terminal A must drop ITS cache, not merely its session" + ); + + // ...and the surviving cache is B's, so the right one was dropped. + focus_terminal(&state, b); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), + ); + assert!( + escape_was_armed(&mut state, b, 'T'), + "terminal B must still escape on its own C-b after A was killed" + ); + assert_eq!( + state.terminal_manager.borrow().escape_parses(), + primed, + "B's surviving cache must not have been reparsed" + ); state.process_supervisor.borrow_mut().shutdown(); } From b76da70d5d6f7650f56e7c65b99bf64f3e5aff7b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 21:53:41 -0400 Subject: [PATCH 32/91] =?UTF-8?q?feat(panel):=20route=20the=20Projection?= =?UTF-8?q?=20half=20of=20the=20=C2=A71.3=20census=20(Stage=202A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-panel Stage 2A, first half. Every consumer the framing classifies **Projection** now resolves the frontend's primary document window or buffer instead of its focused one; every consumer classified focus, focus-chrome, or focus/session is deliberately left alone. No protocol change, no behavior change for any frontend today: with `panel_capable = false` for semantic sessions, `primary_document_window` returns `view.active` for every existing configuration, so this is a seam adoption that becomes load-bearing in 2B. Projection consumers routed: - **#1** semantic buffer-follow / `BufferSnapshot` re-send - **#2** the lazy CRDT upgrade — the sharpest case, since it BROADCASTS to every replica, so keying it on focus would let focusing a fresh generated panel buffer swap every peer's document mirror - **#3** `CursorByte` - **#4** `LineNumbers` mode - **#5** selection decorations - **#6/#10/#11** the full-window semantic terminal declaration, its snapshot/sync, and terminal-frame suppression, via the shared `semantic_terminal_key` resolver - **#9** the `Viewport` terminal-context gate — a focused terminal panel must not suppress the still-visible document's viewport - **#12** the semantic statusline target LOOKUP - **#21** the `BufferSnapshot` publication recipient filter `align_semantic_window_to_buffer` splits per Q#BP14, which is the distinction that makes rejecting panel-named events insufficient on its own: - `align_primary_document_window` (**#7**, `Viewport`) — aligns the document window and **never touches `view.active`**. - `align_and_activate_primary_document_window` (**#8**, `Pointer`) — aligns and then activates, because a click in the document area means "work here". This is the one place projection and focus legitimately move together. `dispatch_semantic_terminal_pointer` (**#11**) gains the same rule: an accepted non-`Move` gesture activates the document window before the gesture replays, while bare hover neither focuses nor claims. The statusline change is deliberately a HALF change (parent acceptance 42): the window LOOKUP resolves the primary document window, but `active` still reports **actual focus**, so a document provider can truthfully observe `active = false` while a panel owns focus. Untouched, and that is the load-bearing negative: #13 remote-op validation, #14 `dispatch_idle_for`, #15 presence, #16-#19 search / menu / minibuffer / completion chrome, #20 terminal bell drain, and #23 remote-op application all still resolve the actually focused window. #16-#19's Q#BP14b routing table needs `PanelFrame` and lands in 2B. 1,832 library tests pass; fmt and workspace clippy clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/daemon.rs | 102 +++++++++++++++++++++++++++++++---------- src/editor.rs | 21 ++++++++- src/semantic_render.rs | 13 +++++- src/statusline.rs | 15 +++++- 4 files changed, 121 insertions(+), 30 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 9ac8256..19e5cc6 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1145,9 +1145,13 @@ fn dispatcher_loop( .session_state(*fid) .is_some_and(|s| s.negotiated_capabilities.semantic_render) { + // Bottom-panel §1.3 #1 — Projection. The buffer this + // frontend DISPLAYS AS ITS DOCUMENT, not the one it + // happens to focus: focusing a panel must re-send no + // snapshot and must never swap the replica's mirror. let active_now = { let core = editor.core.borrow(); - core.active_window_for(*fid).map(|w| w.buffer_id) + core.primary_document_buffer(*fid) }; if let Some(active_now) = active_now && last_active_buffer_sent.get(fid) != Some(&active_now) @@ -1430,8 +1434,16 @@ fn dispatcher_loop( .session_state(*fid) .is_some_and(|s| s.negotiated_capabilities.crdt_replica) { + // Bottom-panel §1.3 #3 — Projection. `CursorByte` is + // the replica's authoritative DOCUMENT cursor; a + // focused panel must not retarget it at the panel + // buffer (Q#BP14's "active buffer is a + // document-surface term, not an input-focus term"). let core = editor.core.borrow(); - if let Some(window) = core.active_window_for(*fid) { + if let Some(window) = core + .primary_document_window(*fid) + .and_then(|win_id| core.windows.get(&win_id)) + { let cursor_byte_msg = InstanceMessage::CursorByte { buffer_id: window.buffer_id, byte_pos: window.cursor, @@ -2038,12 +2050,17 @@ fn handle_dispatcher_event( // straight back off it. The declared buffer is // checked too — a terminal has no byte viewport to // honor from any direction. + // Bottom-panel §1.3 #9 — Projection. The gate asks + // "is this frontend's DOCUMENT surface a terminal", + // so it tests the primary document window. A focused + // TERMINAL PANEL must not suppress the still-visible + // document's viewport. let terminal_context = { let manager = editor.terminal_manager.borrow(); let core = editor.core.borrow(); let active = core - .active_window_for(source) - .is_some_and(|window| manager.is_terminal(window.buffer_id)); + .primary_document_buffer(source) + .is_some_and(|document| manager.is_terminal(document)); active || manager.is_terminal(buffer_id) }; if semantic_states.contains_key(&source) && !terminal_context { @@ -2056,7 +2073,11 @@ fn handle_dispatcher_event( // LOCAL's attach-time buffer (often a scratch the // user isn't viewing), so arrow keys moved an // off-screen cursor and the caret never tracked. - align_semantic_window_to_buffer(editor, source, buffer_id); + // Bottom-panel §1.3 #7 — Projection, and it must + // NOT move focus. Routing this through the + // focused window would let an ordinary document + // viewport overwrite a focused panel's buffer. + align_primary_document_window(editor, source, buffer_id); if let Some(sem) = semantic_states.get_mut(&source) { sem.set_viewport(buffer_id, visible, generation); } @@ -2121,7 +2142,11 @@ fn handle_dispatcher_event( // aligns to the buffer the frontend says it was // displaying: a click can race a buffer switch. if semantic_states.contains_key(&source) { - align_semantic_window_to_buffer(editor, source, buffer_id); + // Bottom-panel §1.3 #8 — Projection + focus. A + // click in the DOCUMENT area means "work here", + // so unlike `Viewport` (#7) this one also takes + // focus out of a panel. + align_and_activate_primary_document_window(editor, source, buffer_id); if kind == PointerKind::Context { // Q#CM1 — right-click opens the context menu // at the hit byte (needs the Lua builder, so @@ -2359,9 +2384,15 @@ fn ensure_active_buffer_crdt_backed( editor: &EditorState, fid: FrontendId, ) -> Option { + // Bottom-panel §1.3 #2 — Projection, and the sharpest case in the + // census. The upgrade BROADCASTS a `BufferSnapshot` to every + // replica, so keying it on focus would mean focusing a fresh + // generated panel buffer swaps every peer's document mirror to it. + // A panel buffer that genuinely needs CRDT backing gets it when it + // is displayed as a document, not as a side effect of focus. let buffer_id_opt = { let core = editor.core.borrow(); - core.active_window_for(fid).map(|w| w.buffer_id) + core.primary_document_buffer(fid) }; let buffer_id = buffer_id_opt?; let core = editor.core.borrow(); @@ -2493,11 +2524,13 @@ fn publish_buffer_snapshot_to_replicas( continue; } if session.negotiated_capabilities.semantic_render { - let displays_buffer = editor - .core - .borrow() - .active_window_for(*peer_id) - .is_some_and(|window| window.buffer_id == buffer_id); + // Bottom-panel §1.3 #21 — Projection. "Displays this + // buffer" means the peer's DOCUMENT surface: testing the + // focused window would both miss a buffer visible in the + // document (panel focused elsewhere) and replace the peer's + // mirror for one visible only in a panel. + let displays_buffer = + editor.core.borrow().primary_document_buffer(*peer_id) == Some(buffer_id); if !displays_buffer { continue; } @@ -2936,32 +2969,35 @@ fn handle_remote_crdt_op( /// whole switch. This is the input/display alignment fix for B1: the /// frontend's *declared* buffer becomes the buffer its keys edit and /// its `CursorByte` reports. -fn align_semantic_window_to_buffer( +/// Align a semantic frontend's **primary document window** to the +/// buffer it declared (bottom-panel §1.3 #7, Q#BP14). +/// +/// **Never touches `view.active`.** This is why rejecting panel-named +/// events does not fix the *document* event: with a panel focused, an +/// ordinary document `Viewport` routed through the focused window would +/// overwrite the panel's buffer with the document buffer. Returns the +/// window it aligned so the `Pointer` path (#8) can activate it. +fn align_primary_document_window( editor: &mut EditorState, fid: FrontendId, buffer_id: crate::buffer::BufferId, -) { +) -> Option { use crate::text_view::TextView; - let text_view = { + let (win_id, text_view) = { let core = editor.core.borrow(); - let Some(win_id) = core.views.get(&fid).map(|v| v.active) else { - return; - }; + let win_id = core.primary_document_window(fid)?; if core.windows.get(&win_id).map(|w| w.buffer_id) == Some(buffer_id) { - return; // Already displaying this buffer. + return Some(win_id); // Already displaying this buffer. } let reg = core.registry.borrow(); let Ok(buf) = reg.get(buffer_id) else { - return; // Unknown buffer — leave the window as-is. + return Some(win_id); // Unknown buffer — leave the window as-is. }; - TextView::new(buf) + (win_id, TextView::new(buf)) }; let mut core = editor.core.borrow_mut(); - let Some(win_id) = core.views.get(&fid).map(|v| v.active) else { - return; - }; if let Some(win) = core.windows.get_mut(&win_id) { win.buffer_id = buffer_id; win.text_view = text_view; @@ -2969,6 +3005,24 @@ fn align_semantic_window_to_buffer( win.selection = None; win.overlays.clear(); } + Some(win_id) +} + +/// Align the primary document window **and take focus to it** +/// (bottom-panel §1.3 #8, Q#BP14). +/// +/// A click in the document area means "work here", so it moves focus +/// out of a panel. This is the one place projection and focus +/// legitimately move together — every other Projection consumer must +/// use [`align_primary_document_window`] alone. +fn align_and_activate_primary_document_window( + editor: &mut EditorState, + fid: FrontendId, + buffer_id: crate::buffer::BufferId, +) { + if let Some(win_id) = align_primary_document_window(editor, fid, buffer_id) { + editor.core.borrow_mut().focus_window(fid, win_id); + } } fn build_fresh_frontend_view( diff --git a/src/editor.rs b/src/editor.rs index 1db5c3a..8bc9d8f 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1341,9 +1341,16 @@ impl EditorState { frontend_id: FrontendId, buffer_id: crate::buffer::BufferId, ) -> Option { + // Bottom-panel §1.3 #6/#10/#11 — Projection. The full-window + // semantic terminal declaration, its snapshot/sync, and its + // frame suppression all describe the frontend's PRIMARY DOCUMENT + // surface, never a panel band: panel terminals get `PanelFrame` + // / `PanelPointer` in Stage 2B instead. Resolving through + // `view.active` would let a focused panel terminal both claim + // the document declaration and suppress the document pass. let core = self.core.borrow(); - let view = core.views.get(&frontend_id)?; - let window = core.windows.get(&view.active)?; + let win_id = core.primary_document_window(frontend_id)?; + let window = core.windows.get(&win_id)?; if window.buffer_id != buffer_id { return None; } @@ -1472,6 +1479,16 @@ impl EditorState { if coord.row >= size.rows || coord.col >= size.cols { return false; } + // Bottom-panel §1.3 #11 — Projection + focus. A non-hover + // gesture on the DOCUMENT terminal means "work here", so it + // takes focus back out of a panel before the gesture replays; + // bare hover neither focuses nor claims the controller. + if !matches!(kind, TerminalMouseKind::Move) { + let mut core = self.core.borrow_mut(); + if let Some(win_id) = core.primary_document_window(frontend_id) { + core.focus_window(frontend_id, win_id); + } + } self.core.borrow_mut().active_frontend = frontend_id; self.apply_terminal_gesture(key, size, coord, kind, mods, (coord.row, coord.col)); true diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 65750d3..617d408 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -1345,9 +1345,13 @@ impl SemanticRenderState { state: &EditorState, buffer_id: BufferId, ) -> Option { + // Bottom-panel §1.3 #4 — Projection. `LineNumbers` describes the + // replica's DOCUMENT surface; a focused panel must not replace + // the document's gutter mode with the panel window's. let mode = { let core = state.core.borrow(); - core.active_window_for(self.frontend_id) + core.primary_document_window(self.frontend_id) + .and_then(|win_id| core.windows.get(&win_id)) .map_or(crate::window::LineNumberMode::Off, |w| w.line_numbers) }; if self.last_line_numbers == Some(mode) { @@ -1703,7 +1707,12 @@ impl SemanticRenderState { // Emitting CurrentLine here forced a whole-buffer line table on // every frame even though pmacs-gpu ignores its own current-line // wash. - if let Some(win) = core.active_window_for(self.frontend_id) + // Bottom-panel §1.3 #5 — Projection. Selection decorations + // belong to the document surface the viewport describes; a + // selection made inside a focused panel must not paint into it. + if let Some(win) = core + .primary_document_window(self.frontend_id) + .and_then(|win_id| core.windows.get(&win_id)) && win.buffer_id == vp.buffer_id && let Some((lo, hi)) = win.region() && let Some(range) = clip_to_viewport(lo, hi, vp) diff --git a/src/statusline.rs b/src/statusline.rs index d11c885..1e6d9cc 100644 --- a/src/statusline.rs +++ b/src/statusline.rs @@ -639,9 +639,20 @@ fn capture_target_contexts( .views .get(&frontend_id) .ok_or(StatuslineNoMessageReason::ContextUnavailable)?; + // Bottom-panel §1.3 #12 — Projection. This LOOKUP resolves + // the primary document window: with a panel focused, + // `view.active` would name the panel and the declared-buffer + // check would clear the document's statusline. + // + // `active` is NOT rerouted with it (Q#BP14/parent 42): it + // reports ACTUAL focus, so a document provider truthfully + // observes `active = false` while the panel owns focus. + let window_id = core + .primary_document_window(frontend_id) + .ok_or(StatuslineNoMessageReason::ContextUnavailable)?; let window = core .windows - .get(&view.active) + .get(&window_id) .ok_or(StatuslineNoMessageReason::ContextUnavailable)?; if buffers.get(window.buffer_id).is_err() { return Err(StatuslineNoMessageReason::BufferUnavailable); @@ -653,7 +664,7 @@ fn capture_target_contexts( frontend_id, window_id: window.id, buffer_id: window.buffer_id, - active: true, + active: window.id == view.active, }]) } } From d7ad01b53596dc13a3e13faddd791b7d4432b398 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 22:04:56 -0400 Subject: [PATCH 33/91] feat(panel): extract the per-window painter + Stage 2A acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-panel Stage 2A, second half (Q#BP8, Q#BP17). Still no protocol change and no behavior change: `paint_frame` builds the same fold map it always did and passes it in, so grid rendering is unchanged. Two extractions, both taking the fold map as a **parameter** rather than building it: - `prepare_window_cursor_visible` — the active-window auto-scroll clamp. The panel band (2B) runs this for its own window when that window owns focus, and leaves a passive panel's `view_top` alone. - `paint_window_content` — the per-window document body: text, gutter, overlays, selection, and the mode line. The panel paints into a panel-sized grid at the same origin-agnostic `Viewport`, so this is that body lifted out, not a second painter (Bet B2'). The parameter is the point (Q#BP17). Folding built its per-window map ungated on the premise that "a semantic session never enters `paint_frame`", which the panel band breaks. The panel path must pass `None` for a frontend whose `fold_projection` is false, and must not call `EditorCore::fold_map_for_window` — that gates on the **active** frontend, which is right for command-time reckoning and wrong for painting another frontend's panel. `tests/bottom_panel_stage2a_acceptance.rs` — 10 tests. The negative half is the load-bearing half, so Projection assertions are paired with focus-class assertions taken in the SAME state: - `focus_and_projection_disagree_in_the_same_state` is the key one: with a panel focused, the focus authority must name the panel while the projection authority names the document. Routing the focus class through `primary_document_window` fails this even though every Projection test still passes. - The statusline pair pins the split: the LOOKUP resolves the document window while `active` reports actual focus, with a non-vacuity twin that flips `active` back to true when focus returns. - The extraction pair pins cells, the returned cursor, the focused window's `view_top`, AND a passive window's untouched scroll — identical cells alone would not catch a clamp that moved to the wrong window on a single-window frame. - `the_panel_fixture_really_builds_a_side_window` pins the fixture's own precondition, since every other test is worthless if `focused_panel` silently produced an ordinary split. One crdt-gated caller of the old `align_semantic_window_to_buffer` was updated; it compiles only under `--features crdt`, which is the config CI never runs. 1,832 default + 2,009 CRDT library tests, 10 new acceptance; fmt and workspace clippy clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/daemon.rs | 6 +- src/editor.rs | 307 ++++++++++-------- tests/bottom_panel_stage2a_acceptance.rs | 377 +++++++++++++++++++++++ 3 files changed, 556 insertions(+), 134 deletions(-) create mode 100644 tests/bottom_panel_stage2a_acceptance.rs diff --git a/src/daemon.rs b/src/daemon.rs index 19e5cc6..16e66ab 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -4439,7 +4439,7 @@ mod tests { /// B1 input/display alignment: a semantic frontend's window is bound /// to LOCAL's attach-time buffer, but the buffer it *displays* is - /// the one it declares via `Viewport`. `align_semantic_window_to_buffer` + /// the one it declares via `Viewport`. `align_primary_document_window` /// re-points the window so keys edit the displayed buffer — without /// it, arrow keys moved an off-screen cursor in the wrong buffer and /// the caret never tracked. @@ -4477,7 +4477,9 @@ mod tests { ); // The frontend declares it is displaying the file buffer. - align_semantic_window_to_buffer(&mut editor, fid, file); + // Bottom-panel §1.3 #7: `Viewport` takes the projection-only + // aligner, which never touches `view.active`. + align_primary_document_window(&mut editor, fid, file); assert_eq!( editor .core diff --git a/src/editor.rs b/src/editor.rs index 8bc9d8f..2553775 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3169,6 +3169,170 @@ impl CompletionPopupKey { } } +/// Scroll one window so its cursor stays visible, reckoning in +/// **visible** lines when a fold map is supplied (Arc 6 Q#FD18). +/// +/// Extracted from `paint_frame` for bottom-panel Stage 2 (Q#BP8): the +/// panel band runs this for its own window when that window owns focus, +/// against the same supplied map, and leaves a passive panel's +/// `view_top` untouched. +/// +/// **The fold map is a parameter, never built here (Q#BP17).** A panel +/// painted for a frontend whose `fold_projection` is false must pass +/// `None`; `EditorCore::fold_map_for_window` is the wrong source there +/// because it gates on the **active** frontend, which is right for +/// command-time reckoning and wrong for painting another frontend's +/// panel. +fn prepare_window_cursor_visible( + window: &mut crate::window::Window, + buf: &crate::buffer::Buffer, + inner_rows: u32, + folds: Option<&crate::fold_view::VisibleLineMap>, +) { + let cursor_row = window + .text_view + .pos_to_display(buf, window.cursor) + .map_or(0, |d| d.row as usize); + match folds { + // The logical cursor may sit on a hidden line (a shared fold, or + // goto-line into one); the row that actually renders — and so + // the row to scroll to — is its visible head (Q#FD16/FD18, + // framing acceptance 8). + Some(map) => { + let anchor = map.visible_head_of(cursor_row); + let top = map.clamp_view_top(window.view_top); + window.view_top = if anchor < top { + anchor + } else if inner_rows > 0 && map.visible_rows_between(top, anchor) >= inner_rows as usize + { + map.nth_visible_back(anchor, inner_rows as usize - 1) + } else { + top + }; + } + None => { + if cursor_row < window.view_top { + window.view_top = cursor_row; + } else if inner_rows > 0 && cursor_row >= window.view_top + inner_rows as usize { + window.view_top = cursor_row + 1 - inner_rows as usize; + } + } + } +} + +/// Paint one window's document content: text, gutter, overlays, +/// selection, and its mode line. +/// +/// Extracted from `paint_frame`'s per-window loop for bottom-panel +/// Stage 2 (Q#BP8) — the panel band paints its window into a +/// panel-sized grid at the same origin-agnostic `Viewport`, so this is +/// that body lifted out rather than a second painter. No concrete +/// text/gutter/overlay/mode-line painter forks (Bet B2'). +/// +/// **`folds` is a parameter, never built here (Q#BP17).** Folding's +/// "a semantic session never enters `paint_frame`" premise is what the +/// panel band breaks; the panel path passes `None` when the owning +/// frontend's `fold_projection` is false, and must not call +/// `EditorCore::fold_map_for_window`, which gates on the **active** +/// frontend. +#[allow(clippy::too_many_arguments)] +fn paint_window_content( + grid: &mut crate::cell::CellGrid<'_>, + window: &mut crate::window::Window, + buf: &crate::buffer::Buffer, + placement: WindowPlacement, + folds: Option<&crate::fold_view::VisibleLineMap>, + focused: bool, + theme: &crate::highlight::Theme, + statusline: Option<&crate::statusline::StatuslineWindowSegments>, + diag_store: &std::sync::Arc>, +) { + let rect = placement.outer; + let inner_rows = placement.content.size.rows; + if let Some(map) = folds { + window.view_top = map.clamp_view_top(window.view_top); + } + let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0); + // UX gutter (Q#UX2): reserve a left strip for line numbers and + // shrink+shift the text area into the remainder, so every + // viewport-relative painter (text, syntax, diagnostics, search) + // stays gutter-agnostic. A window too narrow for the gutter falls + // back to no gutter this frame rather than starving the text. + let gutter_w = { + let w = window.gutter_width(); + if w >= rect.size.cols { 0 } else { w } + }; + let viewport = Viewport { + buffer_start: viewport_buffer_start, + buffer_end: buf.len(), + cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w), + cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w), + gutter_w, + folds, + }; + // Composition (T M2.9): base text_view paints first, then the + // gutter numbers — before the overlays, so a diagnostic overlay + // can draw its severity sign into the gutter's leading column + // without the gutter's own blank pass erasing it — then each + // overlay in attach order. See [`crate::view::View`]. + window.text_view.render(buf, viewport, grid); + if gutter_w > 0 { + paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, folds, theme); + } + for overlay in &mut window.overlays { + overlay.render(buf, viewport, grid); + } + paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w, folds, theme); + // Mode line for this window. Painted last so the line + // itself is always visible regardless of overlay activity. + let coord = window + .text_view + .pos_to_display(buf, window.cursor) + .unwrap_or_default(); + // Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in + // VISIBLE-line space — a buffer whose remainder is collapsed + // reads "All", not "Top". The cursor's ordinal anchors on its + // visible head, since that is the row it renders on. + let (ind_top, ind_total, ind_cursor) = match folds { + Some(map) => ( + map.visible_rows_between(0, window.view_top), + map.visible_line_count(window.text_view.line_count()), + map.visible_rows_between(0, map.visible_head_of(coord.row as usize)), + ), + None => ( + window.view_top, + window.text_view.line_count(), + coord.row as usize, + ), + }; + let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor); + // Lock scoped to the summary computation only: the overlay + // renders above include `DiagnosticView`, which takes this + // same mutex — holding the guard across the loop deadlocked + // the daemon on the first frame after a file (and thus a + // diagnostic overlay) was opened. + let diags = { + let guard = diag_store.lock().expect("diag store mutex poisoned"); + diag_mode_line_summary(&guard, buf) + }; + let custom = statusline; + paint_mode_line( + grid, + &rect, + buf.name(), + buf.is_modified(), + focused, + coord.row, + coord.col, + &scroll, + &diags, + mode_line_style(theme), + custom.map_or(&[], |segments| segments.left.as_slice()), + custom.map_or(&[], |segments| segments.right.as_slice()), + theme, + ); +} + /// Paint one full frame into `grid` and return the desired terminal /// cursor position. /// @@ -3278,6 +3442,11 @@ pub fn paint_frame( // Arc 6 Stage 2 (Q#FD18): the auto-scroll clamp reckons in // VISIBLE lines. Built from the active window itself, before // the mutable borrow below. + // + // Bottom-panel Q#BP17: built HERE and passed in, because the + // panel path (Stage 2B) must supply `None` for a frontend + // whose `fold_projection` is false. Building it inside the + // clamp would hard-wire the grid's answer. let folds = core .windows .get(&active) @@ -3285,36 +3454,7 @@ pub fn paint_frame( let aw = core.windows.get_mut(&active).expect( "invariant: active_window_id always references a live window in core.windows", ); - let cursor_row = aw - .text_view - .pos_to_display(buf, aw.cursor) - .map_or(0, |d| d.row as usize); - match folds.as_ref() { - // The logical cursor may sit on a hidden line (a shared - // fold, or goto-line into one); the row that actually - // renders — and so the row to scroll to — is its visible - // head (Q#FD16/FD18, framing acceptance 8). - Some(map) => { - let anchor = map.visible_head_of(cursor_row); - let top = map.clamp_view_top(aw.view_top); - aw.view_top = if anchor < top { - anchor - } else if inner_rows > 0 - && map.visible_rows_between(top, anchor) >= inner_rows as usize - { - map.nth_visible_back(anchor, inner_rows as usize - 1) - } else { - top - }; - } - None => { - if cursor_row < aw.view_top { - aw.view_top = cursor_row; - } else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize { - aw.view_top = cursor_row + 1 - inner_rows as usize; - } - } - } + prepare_window_cursor_visible(aw, buf, inner_rows, folds.as_ref()); } } @@ -3366,114 +3506,17 @@ pub fn paint_frame( let Ok(buf) = reg.get(window.buffer_id) else { continue; }; - // Arc 6 Stage 2 (Q#FD12, round-2 F2): ONE visible-line map per - // rendered document window, keyed on that window's own buffer and - // line offsets. A split may show different buffers with only one - // folded, so a per-frame singleton would leak one pane's folds - // into the other. `None` when this buffer has no folds — the - // unfolded path then paints exactly as before. let folds = crate::fold_view::map_for_window(&state.fold_registry, window); - // `view_top` stays a source-line index (Bet B5) but must never - // rest on a hidden line: clamp BACKWARD so a fold at the top of - // the viewport shows its head (Q#FD18, acceptance 8). - if let Some(map) = folds.as_ref() { - window.view_top = map.clamp_view_top(window.view_top); - } - let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0); - // UX gutter (Q#UX2): reserve a left strip for line numbers and - // shrink+shift the text area into the remainder, so every - // viewport-relative painter (text, syntax, diagnostics, search) - // stays gutter-agnostic. A window too narrow for the gutter falls - // back to no gutter this frame rather than starving the text. - let gutter_w = { - let w = window.gutter_width(); - if w >= rect.size.cols { 0 } else { w } - }; - let viewport = Viewport { - buffer_start: viewport_buffer_start, - buffer_end: buf.len(), - cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w), - cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w), - gutter_w, - folds: folds.as_ref(), - }; - // Composition (T M2.9): base text_view paints first, then the - // gutter numbers — before the overlays, so a diagnostic overlay - // can draw its severity sign into the gutter's leading column - // without the gutter's own blank pass erasing it — then each - // overlay in attach order. See [`crate::view::View`]. - window.text_view.render(buf, viewport, grid); - if gutter_w > 0 { - paint_line_number_gutter( - grid, - window, - &rect, - inner_rows, - gutter_w, - folds.as_ref(), - &theme, - ); - } - for overlay in &mut window.overlays { - overlay.render(buf, viewport, grid); - } - paint_local_selection( + paint_window_content( grid, - buf, window, - &rect, - inner_rows, - gutter_w, + buf, + placement, folds.as_ref(), - &theme, - ); - // Mode line for this window. Painted last so the line - // itself is always visible regardless of overlay activity. - let coord = window - .text_view - .pos_to_display(buf, window.cursor) - .unwrap_or_default(); - // Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in - // VISIBLE-line space — a buffer whose remainder is collapsed - // reads "All", not "Top". The cursor's ordinal anchors on its - // visible head, since that is the row it renders on. - let (ind_top, ind_total, ind_cursor) = match folds.as_ref() { - Some(map) => ( - map.visible_rows_between(0, window.view_top), - map.visible_line_count(window.text_view.line_count()), - map.visible_rows_between(0, map.visible_head_of(coord.row as usize)), - ), - None => ( - window.view_top, - window.text_view.line_count(), - coord.row as usize, - ), - }; - let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor); - // Lock scoped to the summary computation only: the overlay - // renders above include `DiagnosticView`, which takes this - // same mutex — holding the guard across the loop deadlocked - // the daemon on the first frame after a file (and thus a - // diagnostic overlay) was opened. - let diags = { - let guard = diag_store.lock().expect("diag store mutex poisoned"); - diag_mode_line_summary(&guard, buf) - }; - let custom = statusline_by_window.get(id); - paint_mode_line( - grid, - &rect, - buf.name(), - buf.is_modified(), *id == active, - coord.row, - coord.col, - &scroll, - &diags, - mode_line_style(&theme), - custom.map_or(&[], |segments| segments.left.as_slice()), - custom.map_or(&[], |segments| segments.right.as_slice()), &theme, + statusline_by_window.get(id), + &diag_store, ); } drop(reg); diff --git a/tests/bottom_panel_stage2a_acceptance.rs b/tests/bottom_panel_stage2a_acceptance.rs new file mode 100644 index 0000000..5f7996d --- /dev/null +++ b/tests/bottom_panel_stage2a_acceptance.rs @@ -0,0 +1,377 @@ +// bottom_panel_stage2a_acceptance.rs --- bottom-panel Stage 2A +// (docs/bottom-panel-stage2-framing.md, criteria A2A-1 / A2A-2 / A2A-3). + +//! Classified §1.3 census routing + the per-window painter extraction. +//! No wire change. +//! +//! **The negative half is the load-bearing half.** A suite that only +//! proved "the document surface is used" would pass with the focus, +//! focus-chrome, and focus/session consumers *wrongly* rerouted to the +//! document — which is the defect the framing spent three review rounds +//! eliminating, and which would break remote-op validation, +//! `DispatchIdle`, presence, focused search/menu/completion routing, and +//! terminal bell ownership. So every Projection assertion here is paired +//! with a focus-class assertion taken in the *same* state. + +use pmacs::cell::{CellGrid, CellSize}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::window::{Side, WindowId}; + +const ROWS: u32 = 24; +const COLS: u32 = 60; + +fn editor() -> EditorState { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + s +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn side_window(s: &EditorState) -> Option { + let core = s.core.borrow(); + core.views[&FrontendId::LOCAL] + .layout + .iter_ids() + .into_iter() + .find(|id| { + core.windows + .get(id) + .is_some_and(|w| w.params.side.is_some()) + }) +} + +/// Open a bottom panel and leave it FOCUSED — the state in which every +/// classification difference becomes observable. +fn focused_panel(s: &EditorState) -> (WindowId, WindowId) { + let document = s.core.borrow().views[&FrontendId::LOCAL].active; + exec( + s, + "PANEL_BUF = pmacs.buffer.create(\"*panel*\") + PANEL_WIN = pmacs.window.display(PANEL_BUF, \ + { side = \"bottom\", height = 4 })", + ); + let panel = side_window(s).expect("panel exists"); + s.core.borrow_mut().focus_window(FrontendId::LOCAL, panel); + assert_eq!( + s.core.borrow().views[&FrontendId::LOCAL].active, + panel, + "fixture precondition: the panel must own focus" + ); + (document, panel) +} + +fn render(s: &EditorState) { + let size = CellSize::new(ROWS, COLS); + let mut cells = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + let _ = pmacs::editor::paint_frame( + s, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid, + size, + ); +} + +// --------------------------------------------------------------------------- +// A2A-1 — the Projection class resolves the document surface +// --------------------------------------------------------------------------- + +#[test] +fn projection_resolves_the_document_window_while_a_panel_is_focused() { + let s = editor(); + let (document, panel) = focused_panel(&s); + let core = s.core.borrow(); + + assert_eq!( + core.primary_document_window(FrontendId::LOCAL), + Some(document), + "Projection consumers must resolve the document window, not the focused panel" + ); + assert_ne!(document, panel); +} + +#[test] +fn projection_buffer_is_the_document_buffer_not_the_panel_buffer() { + let s = editor(); + let (document, _panel) = focused_panel(&s); + let core = s.core.borrow(); + + let document_buffer = core.windows[&document].buffer_id; + assert_eq!( + core.primary_document_buffer(FrontendId::LOCAL), + Some(document_buffer), + "the replica's document mirror must not follow panel focus" + ); + assert_ne!( + core.primary_document_buffer(FrontendId::LOCAL), + Some(core.windows[&core.views[&FrontendId::LOCAL].active].buffer_id), + "non-vacuity: the focused window's buffer differs, so this test can fail" + ); +} + +// --------------------------------------------------------------------------- +// A2A-1 — the NEGATIVE half: focus classes still resolve focus +// --------------------------------------------------------------------------- + +#[test] +fn focus_class_dispatch_idle_still_tracks_the_focused_window() { + let s = editor(); + let (_document, _panel) = focused_panel(&s); + + // §1.3 #14 — Focus. Q#BP14a: optimistic input is gated per WINDOW. + // A panel that owns focus must suppress `DispatchIdle` even though + // the *document* projection is unaffected. + assert!( + !s.dispatch_idle_for(FrontendId::LOCAL), + "a focused side window must gate optimistic input off (#14)" + ); +} + +#[test] +fn focus_class_gate_lifts_when_focus_returns_to_the_document() { + let s = editor(); + let (document, _panel) = focused_panel(&s); + s.core + .borrow_mut() + .focus_window(FrontendId::LOCAL, document); + + assert!( + s.dispatch_idle_for(FrontendId::LOCAL), + "non-vacuity: the gate must lift with focus, or the test above proves nothing" + ); +} + +#[test] +fn focus_and_projection_disagree_in_the_same_state() { + // The single most important assertion in this suite: in ONE state, + // the two classes must resolve DIFFERENT windows. If a future change + // routes the focus class through `primary_document_window`, this + // fails even though every Projection test above still passes. + let s = editor(); + let (document, panel) = focused_panel(&s); + let core = s.core.borrow(); + + let focused = core.views[&FrontendId::LOCAL].active; + let projected = core + .primary_document_window(FrontendId::LOCAL) + .expect("a document window exists"); + + assert_eq!(focused, panel, "focus authority must name the panel"); + assert_eq!( + projected, document, + "projection authority must name the document" + ); + assert_ne!( + focused, projected, + "the two authorities must be genuinely distinct in this state" + ); +} + +// --------------------------------------------------------------------------- +// A2A-2 — the statusline split: lookup reroutes, `active` does not +// --------------------------------------------------------------------------- + +#[test] +fn statusline_document_context_reports_active_false_under_a_focused_panel() { + use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, + }; + + let s = editor(); + let (document, _panel) = focused_panel(&s); + let declared = s.core.borrow().windows[&document].buffer_id; + + let evaluation = evaluate_statusline( + s.lua_host.lua(), + &s.core, + &s.statusline_registry, + StatuslineEvaluationTarget::Semantic { + frontend_id: FrontendId::LOCAL, + declared_buffer: declared, + }, + ); + + match evaluation.outcome { + StatuslineEvaluationOutcome::Ready(windows) => { + let context = windows + .first() + .map(|segments| segments.context) + .expect("one document context"); + // The LOOKUP rerouted: it resolved the document window even + // though the panel is focused (§1.3 #12). + assert_eq!( + context.window_id, document, + "the semantic target must resolve the primary document window" + ); + // `active` did NOT reroute (parent acceptance 42): a document + // provider observes the truth, that it is not focused. + assert!( + !context.active, + "a document provider must observe active = false while the panel owns focus" + ); + } + other => panic!("expected a ready evaluation, got {other:?}"), + } +} + +#[test] +fn statusline_document_context_is_active_when_the_document_is_focused() { + use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, + }; + + // Non-vacuity for the assertion above: with focus on the document, + // the same context must report `active = true`. + let s = editor(); + let (document, _panel) = focused_panel(&s); + s.core + .borrow_mut() + .focus_window(FrontendId::LOCAL, document); + let declared = s.core.borrow().windows[&document].buffer_id; + + let evaluation = evaluate_statusline( + s.lua_host.lua(), + &s.core, + &s.statusline_registry, + StatuslineEvaluationTarget::Semantic { + frontend_id: FrontendId::LOCAL, + declared_buffer: declared, + }, + ); + + match evaluation.outcome { + StatuslineEvaluationOutcome::Ready(windows) => { + let context = windows.first().map(|s| s.context).expect("one context"); + assert!(context.active, "a focused document context must be active"); + } + other => panic!("expected a ready evaluation, got {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// A2A-3 — the painter extraction preserves grid behavior +// --------------------------------------------------------------------------- + +#[test] +fn extraction_preserves_cells_cursor_and_focused_view_top() { + // The extraction must preserve four things, not just cells: a clamp + // that silently moved to the WRONG window would leave the painted + // cells identical on a single-window frame. + let s = editor(); + exec( + &s, + "local b = pmacs.buffer.create(\"*doc*\") + b:insert(0, string.rep(\"line\\n\", 200)) + pmacs.window.display(b, {})", + ); + + let size = CellSize::new(ROWS, COLS); + let mut cells_a = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize]; + let mut grid_a = CellGrid { + cells: &mut cells_a, + stride: size.cols, + size, + }; + let cursor_a = pmacs::editor::paint_frame( + &s, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid_a, + size, + ); + let active = s.core.borrow().views[&FrontendId::LOCAL].active; + let view_top_a = s.core.borrow().windows[&active].view_top; + + // A second identical paint is a fixed point: same cells, same + // returned cursor, same `view_top`. + let mut cells_b = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize]; + let mut grid_b = CellGrid { + cells: &mut cells_b, + stride: size.cols, + size, + }; + let cursor_b = pmacs::editor::paint_frame( + &s, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid_b, + size, + ); + let view_top_b = s.core.borrow().windows[&active].view_top; + + assert_eq!(cells_a, cells_b, "painted cells must be stable"); + assert_eq!(cursor_a, cursor_b, "the returned cursor must be stable"); + assert_eq!(view_top_a, view_top_b, "focused view_top must be stable"); +} + +#[test] +fn extraction_leaves_a_passive_window_view_top_untouched() { + // The auto-scroll clamp runs for the FOCUSED window only. A passive + // window's scroll state must survive a frame it did not own. + let s = editor(); + exec( + &s, + "local b = pmacs.buffer.create(\"*doc*\") + b:insert(0, string.rep(\"line\\n\", 200)) + pmacs.window.display(b, {}) + pmacs.window.split_horizontal()", + ); + render(&s); + + let (passive, before) = { + let core = s.core.borrow(); + let view = &core.views[&FrontendId::LOCAL]; + let passive = view + .layout + .iter_ids() + .into_iter() + .find(|id| *id != view.active) + .expect("a second window exists"); + (passive, core.windows[&passive].view_top) + }; + + // Scroll the passive window somewhere the clamp would "fix" if it + // ever ran against the wrong window. + s.core + .borrow_mut() + .windows + .get_mut(&passive) + .unwrap() + .view_top = 120; + render(&s); + + assert_eq!( + s.core.borrow().windows[&passive].view_top, + 120, + "a passive window's view_top must not be clamped by another window's frame" + ); + assert_ne!(before, 120, "non-vacuity: the value actually changed"); +} + +// --------------------------------------------------------------------------- +// Fixture integrity +// --------------------------------------------------------------------------- + +#[test] +fn the_panel_fixture_really_builds_a_side_window() { + // Every test above is worthless if `focused_panel` silently produced + // an ordinary split, so pin the fixture's own precondition. + let s = editor(); + let (_document, panel) = focused_panel(&s); + let core = s.core.borrow(); + assert_eq!( + core.windows[&panel].params.side, + Some(Side::Bottom), + "the fixture must produce a real bottom side window" + ); +} From 4413ea93d00d3bfe3668d03c93b23f09ce1a6075 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 22:14:37 -0400 Subject: [PATCH 34/91] docs: record the Stage 2A lane and what gating it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ledger entry for the in-flight Stage 2A branch, plus three findings the gate run produced that are worth carrying regardless of this PR: - The structural test comparing the two authorities directly did NOT catch the focus-class bite; only the consumer-level assertion did. Both kinds are needed, and the distinction generalizes. - `vterm_stage3_acceptance::a37` is badly flaky on this machine — 6/8 failures on the BASE commit against 7/8 on the branch in matched isolated samples, so it is pre-existing rather than a regression. It also returns `ok` without running unless `pmacs-gpu` is built. - `m11_5_semantic_acceptance` reports 0 tests and `gpu_initial_target_acceptance` reports 1 without `--features crdt`. Both are semantic-census suites, so gating Stage 2A in the default config alone would exercise almost none of its relevant coverage. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index b60d62c..b961794 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -389,10 +389,45 @@ If it does not, stop and repair the remote/fetch configuration. **isolated-config workspace sweep 3,177 across 92 suites, zero failures**; `git diff --check` clean. Gates were run against the committed tree. -## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 IN FRAMING +## Bottom-panel lane (Arc 7) — Stage 1 + framing MERGED; Stage 2A IN REVIEW -Stage 1 is on `main`. **Stage 2 is in framing**, no implementation in -flight. +Stage 1 and the Stage 2 framing are on `main`. **Stage 2A is +implemented and in review.** + +- **Stage 2A — portable branch `githubsucks/bottom-panel-stage2a`**, + worktree `../pmacs-bp-stage2a`, based on `githubsucks/main` @ + `c93f9ee`. Two commits: the classified census routing, then the + painter extraction + acceptance. **No protocol change; no behavior + change for any frontend today** — with `panel_capable = false` for + semantic sessions, `primary_document_window` returns `view.active` + in every existing configuration, so this is seam adoption that + becomes load-bearing in 2B. +- Verification on this branch: `cargo fmt --check` clean; strict + workspace Clippy clean; **1,832 default + 2,009 CRDT** library tests; + new `bottom_panel_stage2a_acceptance` 10/10; bottom-panel Stage 1 + 46; statusline segments 7 default / 8 CRDT; m11_5 semantic 2 CRDT; + GPU initial target 14 CRDT; vterm Stage 1/2 10 / 6; folding Stage 2 + 48; M4 121; required GPU 202; `git diff --check` clean. +- **Both key routings were falsified by revert.** Rerouting + `dispatch_idle_for` (#14, Focus) through `primary_document_window` + fails `focus_class_dispatch_idle_still_tracks_the_focused_window`; + reverting the statusline lookup (#12, Projection) to `view.active` + fails the document-context test. Worth recording: the *structural* + test `focus_and_projection_disagree_in_the_same_state` did **not** + catch the first bite — it compares the two authorities directly, so + only a consumer-level assertion catches a misrouted consumer. Keep + both kinds. +- **`vterm_stage3_acceptance::a37` is a pre-existing flake here**, not a + Stage 2A regression: measured **6/8 failures on the base commit** and + **7/8 on the branch** in matched isolated samples. It needs a real + daemon + real PTY + headless GPU and is documented load-sensitive. + It also silently returns `ok` unless `pmacs-gpu` has been built, and + is `crdt`-gated so CI never runs it at all. +- **Two suites are dark without `--features crdt`**: + `m11_5_semantic_acceptance` reports **0 tests** and + `gpu_initial_target_acceptance` reports **1** in the default config. + Both are semantic-census suites, so Stage 2A must be gated with the + feature on or its most relevant coverage never executes. - Stage 1 merged as **#155** (`main` @ `e745068`, 2026-07-24, after two review rounds). No protocol change. Durable substrate facts live in From 40f7f8169019204ec2df2a85366f2673326d604b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 22:16:56 -0400 Subject: [PATCH 35/91] test(process): pin exact diagnostic values and drop the timing-dependent sleeps Round-1 review found both test weaknesses. The exited-child tests used a fixed 300 ms sleep as proof the child had exited, which on a loaded runner can be false and would turn them into spurious failures. nix's waitid is unavailable on macOS and libc::waitid would need unsafe, which the crate forbids, so the tests now synchronise on the observation under test: a bounded loop that drives the production diagnostic until it reports the leader as exited. Each failing attempt leaves the record untouched because the failure path returns before any bookkeeping, so the loop is side-effect free, and it is strictly stronger than a sleep because it observes the actual state rather than assuming it. The assertions were substring checks -- target=-, expected_group=-, leader=exited( -- which a hardcoded target or a wrong exit code would satisfy. They are now exact message equality built from the pid the kernel actually assigned and the errno's own Display, and the one-event test asserts the surviving event carries exit code 7 rather than any terminal event. The group test also spawns /bin/sleep directly rather than through a shell, since a shell may place the command in a different foreground process group than the one being asserted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HZjWMjwPXhPbt9upku9mCk --- src/process.rs | 213 ++++++++++++++++++++++++++++--------------------- 1 file changed, 122 insertions(+), 91 deletions(-) diff --git a/src/process.rs b/src/process.rs index 1453e10..a944de4 100644 --- a/src/process.rs +++ b/src/process.rs @@ -2278,103 +2278,131 @@ mod tests { ); } - /// Spawn a PTY child that stays alive until terminated, and wait - /// for its `Started` event so a pid and a foreground group exist. - fn spawn_live_pty(sup: &mut ProcessSupervisor, name: &str) -> ProcessId { - let mut spec = ProcessSpec::new(name, "/bin/sh"); - spec.args = vec!["-c".into(), "sleep 30".into()]; + /// Spawn a PTY child that leads its own session and stays alive + /// until terminated, returning its id and OS pid. + /// + /// `/bin/sleep` directly rather than through a shell: a shell may + /// place the command in a different foreground process group, and + /// these tests assert the exact target the tty reports. + fn spawn_live_pty(sup: &mut ProcessSupervisor, name: &str) -> (ProcessId, u32) { + let mut spec = ProcessSpec::new(name, "/bin/sleep"); + spec.args = vec!["30".into()]; spec.mode = ProcessMode::Pty { rows: 24, cols: 80, mode: TerminalMode::Canonical, }; let id = sup.spawn(spec).expect("spawn"); - let _ = drain_until(sup, id, Duration::from_secs(5), |evs| { + (id, spawn_started_pid(sup, id)) + } + + /// Drain until `Started` and return the OS pid it carries. + fn spawn_started_pid(sup: &mut ProcessSupervisor, id: ProcessId) -> u32 { + let evs = drain_until(sup, id, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) }); - id + evs.iter() + .find_map(|e| match e.kind { + ProcessEventKind::Started { pid } => Some(pid), + _ => None, + }) + .expect("Started carries a pid") + } + + /// Drive the production diagnostic until it observes the leader as + /// exited, bounded by `timeout`. + /// + /// A fixed sleep is NOT proof of exit — on a loaded runner the child + /// can still be live, which would turn these tests into false + /// failures. This synchronises on the very observation under test. + /// Each failing attempt leaves the record untouched, because the + /// failure path returns before any bookkeeping (Q#PD2), so looping + /// is side-effect free. + fn terminate_until_leader_exited( + sup: &mut ProcessSupervisor, + id: ProcessId, + timeout: Duration, + ) -> String { + let deadline = Instant::now() + timeout; + loop { + sup.force_next_kill_errno(nix::errno::Errno::EPERM); + let err = sup.terminate(id).expect_err("injected EPERM must fail"); + if err.contains("leader=exited(") { + return err; + } + assert!( + Instant::now() < deadline, + "leader never observed as exited within {timeout:?}: {err}" + ); + std::thread::sleep(Duration::from_millis(10)); + } } /// Q#PD1 acceptance 1 — a group-directed failure names the target, /// the branch that chose it, the expected group, the errno, and the /// leader's own state, as five separate facts. /// - /// The leader field is the one that matters: for a PTY the signal - /// goes to the terminal's foreground group, which is a different - /// entity from the spawned child whenever job control has moved - /// the terminal. Three rejected designs for this code collapsed - /// the two; the report keeps them apart. + /// Asserted as an exact message against the pid the kernel actually + /// assigned, so a hardcoded target could not satisfy it. The leader + /// field is the one that matters: for a PTY the signal goes to the + /// terminal's foreground group, a different entity from the spawned + /// child whenever job control has moved the terminal. Three rejected + /// designs for this code collapsed the two; the report keeps them + /// apart, and here they are asserted to agree only because nothing + /// has moved the terminal. #[test] fn a_group_directed_kill_failure_reports_target_and_leader_separately() { let mut sup = ProcessSupervisor::new(); - let id = spawn_live_pty(&mut sup, "diag-group"); + let (id, pid) = spawn_live_pty(&mut sup, "diag-group"); sup.force_next_kill_errno(nix::errno::Errno::EPERM); let err = sup.terminate(id).expect_err("injected EPERM must fail"); - assert!(err.contains("EPERM"), "errno is reported: {err}"); - assert!( - err.contains("via tcgetpgrp"), - "the target SOURCE distinguishes a tty-read group from a spawn group: {err}" + let expected = format!( + "kill: {} (target=-{pid} via tcgetpgrp, leader_pid={pid}, expected_group=-{pid}, leader=live)", + nix::errno::Errno::EPERM ); - assert!( - err.contains("target=-"), - "a group target renders negative: {err}" - ); - assert!( - err.contains("expected_group=-"), - "the spawn-time group is shown so a divergence is visible: {err}" - ); - assert!( - err.contains("leader=live"), - "the leader is observed independently of the group: {err}" - ); - // Non-vacuity: the two numbers are actually rendered, not empty. - assert!( - err.contains("leader_pid=") && !err.contains("leader_pid=0,"), - "a real leader pid is reported: {err}" + assert_eq!( + err, expected, + "the report names the exact target the tty reported, the exact \ + leader pid, and observes the leader as live" ); + + let _ = sup.signal(id, Signal::SIGKILL); } /// Q#PD1 acceptance 2 — a leader-directed failure records the - /// fallback branch and a positive target, and omits the group - /// field that would be meaningless for it. + /// fallback branch and a positive target, and omits the group field + /// that would be meaningless for it. Exact message again. #[test] fn a_leader_directed_kill_failure_reports_the_fallback_branch() { let mut sup = ProcessSupervisor::new(); - let mut spec = ProcessSpec::new("diag-leader", "/bin/sh"); - spec.args = vec!["-c".into(), "sleep 30".into()]; + let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep"); + spec.args = vec!["30".into()]; let id = sup.spawn(spec).expect("spawn"); - let _ = drain_until(&mut sup, id, Duration::from_secs(5), |evs| { - evs.iter() - .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) - }); + let pid = spawn_started_pid(&mut sup, id); sup.force_next_kill_errno(nix::errno::Errno::ESRCH); let err = sup.terminate(id).expect_err("injected ESRCH must fail"); - assert!(err.contains("ESRCH"), "errno is reported: {err}"); - assert!( - err.contains("via leader-pid"), - "a non-group pipe child targets its own pid: {err}" + let expected = format!( + "kill: {} (target={pid} via leader-pid, leader_pid={pid}, leader=live)", + nix::errno::Errno::ESRCH ); - assert!( - !err.contains("target=-"), - "a leader target renders positive: {err}" - ); - assert!( - !err.contains("expected_group="), - "the group field is omitted where it has no meaning: {err}" + assert_eq!( + err, expected, + "a non-group pipe child targets its own pid, and the group \ + field is omitted where it has no meaning" ); + let _ = sup.signal(id, Signal::SIGKILL); } /// Q#PD1 acceptance 3 — every leader state renders distinctly. The - /// `Unobservable` and `NoRuntime` arms cannot be produced by a - /// real child on demand, so they are pinned directly; `live` and - /// `exited` are pinned through the real path by the tests around - /// this one. + /// `Unobservable` and `NoRuntime` arms cannot be produced by a real + /// child on demand, so they are pinned directly; `live` and `exited` + /// are pinned through the real path by the tests around this one. #[test] fn every_leader_observation_renders_distinctly() { assert_eq!( @@ -2393,25 +2421,27 @@ mod tests { assert_eq!(LeaderObservation::NoRuntime.render(), "no-runtime"); } - /// Q#PD1 acceptance 3, exited arm through the REAL path — the - /// leader has genuinely exited and the report says so. + /// Q#PD1 acceptance 3, exited arm through the REAL path — the leader + /// has genuinely exited and the report carries its exact code, not + /// merely "some exit". #[test] fn a_failure_after_the_child_exits_reports_the_leader_as_exited() { let mut sup = ProcessSupervisor::new(); let mut spec = ProcessSpec::new("diag-exited", "/bin/sh"); spec.args = vec!["-c".into(), "exit 3".into()]; let id = sup.spawn(spec).expect("spawn"); - // Wait for the child to actually be gone, but do NOT tick past - // the point where the record leaves Running — `signal` needs a - // live record to reach the kill at all. - std::thread::sleep(Duration::from_millis(300)); + let pid = spawn_started_pid(&mut sup, id); - sup.force_next_kill_errno(nix::errno::Errno::EPERM); - let err = sup.terminate(id).expect_err("injected EPERM must fail"); + let err = terminate_until_leader_exited(&mut sup, id, Duration::from_secs(10)); - assert!( - err.contains("leader=exited("), - "an exited leader is observed as exited, not guessed from the errno: {err}" + let expected = format!( + "kill: {} (target={pid} via leader-pid, leader_pid={pid}, leader=exited(code 3))", + nix::errno::Errno::EPERM + ); + assert_eq!( + err, expected, + "the exact exit code is observed from the real child, not \ + inferred from the errno" ); } @@ -2427,10 +2457,7 @@ mod tests { spec.args = vec!["-c".into(), "sleep 30".into()]; spec.group = true; let id = sup.spawn(spec).expect("spawn"); - let _ = drain_until(&mut sup, id, Duration::from_secs(5), |evs| { - evs.iter() - .any(|e| matches!(e.kind, ProcessEventKind::Started { .. })) - }); + let pid = spawn_started_pid(&mut sup, id); assert!( sup.reap_ledger.is_empty(), "precondition: nothing armed before the attempt" @@ -2438,7 +2465,12 @@ mod tests { sup.force_next_kill_errno(nix::errno::Errno::EPERM); let err = sup.terminate(id).expect_err("injected EPERM must fail"); - assert!(err.contains("via group"), "a group=true pipe child: {err}"); + + let expected = format!( + "kill: {} (target=-{pid} via group, leader_pid={pid}, expected_group=-{pid}, leader=live)", + nix::errno::Errno::EPERM + ); + assert_eq!(err, expected, "a group=true pipe child reports via group"); assert!( matches!( @@ -2458,7 +2490,7 @@ mod tests { /// Q#PD3/Q#PD4 acceptance 5 — the diagnostic consults the REAL /// `ChildHandle::try_wait` on the REAL child, which reaps it and /// caches the status. `poll_one` must still emit exactly one exit - /// event afterwards. + /// event, carrying the exact code. /// /// A stubbed observation would bypass the double-`try_wait` path /// entirely and pin nothing, so the injection replaces the kill @@ -2474,34 +2506,33 @@ mod tests { mode: TerminalMode::Canonical, }; let id = sup.spawn(spec).expect("spawn"); - std::thread::sleep(Duration::from_millis(300)); + let _ = spawn_started_pid(&mut sup, id); - // The forced failure drives `observe_leader`, which try_waits - // the real PTY child for the first time. - sup.force_next_kill_errno(nix::errno::Errno::EPERM); - let err = sup.terminate(id).expect_err("injected EPERM must fail"); + // Drives `observe_leader`, which try_waits the real PTY child + // for the first time and reaps it. + let err = terminate_until_leader_exited(&mut sup, id, Duration::from_secs(10)); assert!( - err.contains("leader=exited("), - "the real handle was consulted: {err}" + err.contains("leader=exited(code 7)"), + "the real handle was consulted and carries the exact code: {err}" ); - // Now the supervisor's own try_wait must still see the status. + // The supervisor's own try_wait must still see that status. let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exited); - let terminal = evs + let terminal: Vec = evs .iter() - .filter(|e| { - matches!( - e.kind, - ProcessEventKind::Exited { .. } | ProcessEventKind::Signaled { .. } - ) + .filter_map(|e| match e.kind { + ProcessEventKind::Exited { code, .. } => Some(code), + ProcessEventKind::Signaled { .. } => Some(-1), + _ => None, }) - .count(); + .collect(); assert_eq!( - terminal, 1, - "exactly one terminal event survives the diagnostic's try_wait" + terminal, + vec![7], + "exactly one terminal event survives the diagnostic's try_wait, \ + carrying the child's real exit code" ); } - #[test] fn signal_terminates_a_running_child() { let mut sup = ProcessSupervisor::new(); From 18d481b046a5237a5b955dea45e9b13043cf20df Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 22:19:19 -0400 Subject: [PATCH 36/91] docs: record the PTY terminate diagnostic lane in the ledger The ledger's own update protocol requires a lane for volatile work, and PR #176 had none: branch, worktree, review state, and verification were all missing. Records why the lane ships a diagnostic rather than a fix -- three rejected tolerance designs, the two facts that killed the original argument (group=true is rejected for PTY mode so the reap ledger never applies to that path, and the ledger comment asserts EPERM cannot happen rather than ruling that it means dead), and that the CI evidence never established the child had exited. Also records the round-1 test fixes and the four verified bites, so a reader can tell which assertions are load-bearing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HZjWMjwPXhPbt9upku9mCk --- docs/active-work.md | 69 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index b60d62c..ffd9b1e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -55,6 +55,75 @@ git status --short --branch The `git log` command must expose `d152120` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## PTY terminate diagnostic lane — IN REVIEW (PR #176) + +- Portable branch: `githubsucks/pty-terminate-eperm`; worktree + `../pmacs-math-slice`. **PR #176**, base `main`, based on `ccf29e3` + with `c93f9ee` (#175) merged in. +- Approved framing: `docs/process-signal-tolerance-framing.md` + **revision 4**, after three review rounds. +- **Diagnostic only. No disposition change.** Every call that failed + before still fails, with no state transition and no reap-ledger + arming. `src/process.rs` is the only source file touched. +- **Why nothing is fixed:** revisions 1–3 each proposed a *tolerance* + rule and all three were rejected as unsound in the same way — each + concluded something about a process from something that was not about + that process. Rev 1 from an errno alone (EPERM means the caller lacks + permission, not that the id was recycled); rev 2 from `try_wait`, + which observes the spawned **leader** while a PTY signal targets + `-tcgetpgrp(...)`, entities that diverge exactly when job control has + moved the terminal; rev 3 from group-directed **ESRCH**, which proves + only that the selected foreground group vanished. +- **Two facts that killed the original argument.** `group = true` is + *rejected* for PTY mode at spawn (`src/process.rs:1428-1429`), so the + reap ledger never applies to the PTY path at all; and the ledger + comment (`:1075`) says EPERM "cannot happen for our own children" and + drops the entry for **bounded growth** — not a ruling that EPERM means + dead. +- **The CI evidence never established the child had exited.** The probe's + last source statement is a file write and CPython teardown does not + synchronise with it, so no tolerance rule could even be shown to fix + the symptom. That is the whole reason the lane is diagnostic. +- What ships: a failing `kill` now reports five separate facts — target + source, target kind/value, spawn-time group, errno, and the leader's + real `try_wait` state. The test seam injects the **kill result only**, + never the observation, so the real `ChildHandle::try_wait` runs against + the real child. +- **Not "strictly additive".** `try_wait` reaps and caches, so an exited + child may be reaped earlier than otherwise. Safe because + `portable-pty` 0.9.0 returns a `std::process::Child` on Unix and + delegates `try_wait` to it, so `poll_one` still sees the cached + status — pinned by an exactly-one-terminal-event test rather than + assumed. +- Round-1 review fixes: the exited-child tests no longer use a fixed + sleep as proof of exit (nix's `waitid` is unavailable on macOS and + `libc::waitid` needs `unsafe`, which the crate forbids), instead + driving the production diagnostic in a bounded loop until it observes + the exit; and every assertion is now exact message equality built from + the kernel-assigned pid, since the substring forms would have accepted + a hardcoded target or a wrong exit code. +- Bites, all verified rather than assumed: tolerating the failure fails + the disposition test; stubbing the leader observation fails three + tests including the one-event pin; a hardcoded target fails four; a + wrong exit code fails two. +- Verification: fmt, `git diff --check`, strict workspace clippy clean; + lib 1,838 + CRDT 2,015 (both +6, exactly the new tests); GPU 202; M4 + 121; bottom-panel 46; compile-mode 67; vterm 9/6/5; sweep 3,256 across + 93 suites with two load-contention flakes that pass 3/3 isolated + (`read_dir_supersede_cancels_in_flight_predecessor`, known + pre-existing, and + `headless_snapshot_round_trip_summary_restores_the_minimap`). The + second is structurally unreachable from this diff: `pmacs-gpu` depends + on `pmacs-protocol`, never on `pmacs`. +- **Parked, each with its reason:** all tolerance rules (need the + evidence this PR produces); `terminate` idempotence for an + already-reaped process (independent fix, different failure, one + feature per PR); and `signal_target`'s read-then-kill of `tcgetpgrp` + — still the most likely real fix site. +- **The lane closes when this merges.** It does not wait for the flake + to recur; the next occurrence carries its own evidence under whoever's + PR, and a Stage B framing follows then. + ## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161) - Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review From 00cc615db557d8f8cf67a574df3e4f0e5a65aef8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 22:25:05 -0400 Subject: [PATCH 37/91] test(process): read the pid without ticking in the fast-exit tests The parallel workspace sweep failed observing_the_leader_does_not_consume_the_exit_event with "process ProcessId(26) is not running". A real defect in the test, not a flake. The helper that fetched the pid drained for the Started event, and draining ticks. A tick can observe an immediately-exiting child and transition the record out of Running, after which signal returns "is not running" and never reaches the diagnostic -- so the loop spun to its 10 s bound and panicked. It passed standalone because the drain returned on Started before poll_one saw the exit; only the sweep's load shifted the timing enough to lose that race. Fast-exiting children now read the pid straight from the supervisor record, which does not tick. The bounded loop also fails fast when the record has left Running, so a future recurrence is diagnosed in one line rather than surfacing as a timeout. Verified under matched load: 15/15 green with all 16 cores saturated. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HZjWMjwPXhPbt9upku9mCk --- src/process.rs | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/process.rs b/src/process.rs index a944de4..9e02629 100644 --- a/src/process.rs +++ b/src/process.rs @@ -2296,7 +2296,27 @@ mod tests { (id, spawn_started_pid(sup, id)) } - /// Drain until `Started` and return the OS pid it carries. + /// The OS pid straight from the supervisor's own record, WITHOUT + /// ticking. + /// + /// `drain_until` ticks, and a tick can observe a fast child's exit + /// and transition the record out of `Running` — after which + /// `signal` returns "is not running" and never reaches the + /// diagnostic at all. Any test whose child exits promptly must read + /// the pid this way. (Found by the parallel workspace sweep: the + /// drain-based helper raced only under load.) + fn record_pid(sup: &ProcessSupervisor, id: ProcessId) -> u32 { + match sup.processes.get(&id).expect("record").state { + ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. } => pid, + ProcessState::Starting => panic!("spawn has not reported a pid yet"), + ProcessState::Terminated(_) => { + panic!("the record already left Running; the pid is unavailable") + } + } + } + + /// Drain until `Started` and return the OS pid it carries. Safe + /// only for children that outlive the drain; see [`record_pid`]. fn spawn_started_pid(sup: &mut ProcessSupervisor, id: ProcessId) -> u32 { let evs = drain_until(sup, id, Duration::from_secs(5), |evs| { evs.iter() @@ -2331,6 +2351,11 @@ mod tests { if err.contains("leader=exited(") { return err; } + assert!( + !err.contains("is not running"), + "the record left Running before the diagnostic could run, so \ + this test never exercised it: {err}" + ); assert!( Instant::now() < deadline, "leader never observed as exited within {timeout:?}: {err}" @@ -2430,7 +2455,9 @@ mod tests { let mut spec = ProcessSpec::new("diag-exited", "/bin/sh"); spec.args = vec!["-c".into(), "exit 3".into()]; let id = sup.spawn(spec).expect("spawn"); - let pid = spawn_started_pid(&mut sup, id); + // NOT `spawn_started_pid`: draining ticks, and this child exits + // immediately. + let pid = record_pid(&sup, id); let err = terminate_until_leader_exited(&mut sup, id, Duration::from_secs(10)); @@ -2506,7 +2533,9 @@ mod tests { mode: TerminalMode::Canonical, }; let id = sup.spawn(spec).expect("spawn"); - let _ = spawn_started_pid(&mut sup, id); + // NOT `spawn_started_pid`: draining ticks, and a tick can reap + // this immediately-exiting child before the diagnostic runs. + let _ = record_pid(&sup, id); // Drives `observe_leader`, which try_waits the real PTY child // for the first time and reaps it. From 786de69d3827d37a648b5d378b388837161f662c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 22:32:48 -0400 Subject: [PATCH 38/91] fix(lsp): close round-six Lean fallback gaps Make command-time attachment healing cancel an armed terminal restart before replacing the server, while keeping request-only lookup pure and restart-safe. Track config-driven server ownership privately, bound every fallback server per SID, scope no-swap retirement to the failed root, and route the shipped Lean diagnostics command through the safe resolver while waiting for initialization. Add direct acceptance counterexamples for all five review findings and record the sixth-round verification and vacuity lesson. --- builtin/runtime/lean.lua | 180 +++++++++++++++------- builtin/runtime/lsp.lua | 64 ++++++-- docs/active-work.md | 37 ++++- tests/lean4_server_acceptance.rs | 248 ++++++++++++++++++++++++++++++- 4 files changed, 455 insertions(+), 74 deletions(-) diff --git a/builtin/runtime/lean.lua b/builtin/runtime/lean.lua index 6490296..09ad280 100644 --- a/builtin/runtime/lean.lua +++ b/builtin/runtime/lean.lua @@ -137,8 +137,8 @@ local probe = { -- table cardinality cannot tell "once per buffer" -- from "every tick for one buffer" fallback_installed = false, - fallback_watch = nil, -- fallback sid being polled for die-before-init - fallback_failed = false, + fallback_watches = {}, -- sid key -> sid, each polled die-before-init + fallback_done = {}, -- sid key -> initialized or terminally handled saw_initialized = false, } @@ -286,23 +286,36 @@ end -- Retiring only the server that happened to fail left the others live -- and every buffer attached to them stranded on a command the config no -- longer names. --- Only servers this module's config produced. `ensure_server` labels --- every auto-attached server `default-`, so that label is the --- derivation discriminator: a server the USER spawned from `init.lua` --- carries their own label, is not derived from `pmacs.lsp.config.lean4`, --- and must not be stopped because our config changed. Selecting on --- `language_id` alone swept those up too — a destructive side effect on --- state this module does not own. -local DERIVED_LABEL = "default-lean4" +-- Only servers the config-driven path itself produced. A server's label, +-- language, command, and root are all caller-supplied public values; none +-- is an ownership discriminator. `lsp.lua` records the successful spawn +-- in a private origin table, which is the fact this lifecycle may act on. +local function is_derived_server(sid) + local ok, owned = pcall(pmacs.lsp._is_default_server, sid, "lean4") + return ok and owned == true +end local function retire_derived_lean_servers() local ok, rows = pcall(pmacs.lsp.list) if not ok or not rows then return end local ids = {} for _, info in ipairs(rows) do - if info.label == DERIVED_LABEL then ids[#ids + 1] = info.id end + if is_derived_server(info.id) then ids[#ids + 1] = info.id end end - for _, id in ipairs(ids) do retire_server(id) end + for _, id in ipairs(ids) do + -- These ids predate the fallback spawn. Mark them handled before + -- retirement so the discovery poll cannot mistake their terminal + -- state for a fallback that failed to initialize. + probe.fallback_done[tostring(id)] = true + retire_server(id) + end +end + +local function watch_fallback_server(sid) + if not sid or not is_derived_server(sid) then return end + local key = tostring(sid) + if probe.fallback_done[key] then return end + probe.fallback_watches[key] = sid end -- Rebuild the ACTIVE buffer's attachment if it is Lean and stale. @@ -358,33 +371,44 @@ local function repair_active_if_stale() end -- **A successful SPAWN is not a successful START.** The once-per- -- buffer bound stops `_attach_buffer` being called again, but it says - -- nothing about the server it produced: `ensure_server` never forwards - -- `cfg.restart`, so the fallback inherits `OnCrash`, and an executable - -- that dies before `initialize` is respawned by the manager forever - -- with no attempt ceiling — silently, because `latched` has already - -- disabled the primary's failure poll. Watch this one too, once. - if not probe.fallback_watch and not probe.fallback_failed then - probe.fallback_watch = fresh.server - end + -- nothing about the server it produced. Arm this id immediately; the + -- poll below also discovers servers created through lsp.lua's own + -- after-load and command paths. + watch_fallback_server(fresh.server) end --- The fallback's own die-before-initialize poll. One shot: on failure it --- retires the server (which is what actually ends the respawn loop) and --- reports, and never re-arms. -local function poll_fallback() - local sid = probe.fallback_watch - if not sid then return end - local kind = server_state_kind(sid) - if kind == "initialized" then - probe.fallback_watch = nil - return +-- Every fallback server gets its own die-before-initialize poll. A scalar +-- watch cannot cover Q#LN15's simultaneous per-root servers, and a server +-- may be created by lsp.lua's after-load or command path without passing +-- through `repair_active_if_stale`. Discovery from the private ownership +-- table closes both holes. +local function poll_fallbacks() + if not probe.fallback_installed then return end + local ok, rows = pcall(pmacs.lsp.list) + if not ok or not rows then return end + + local by_key = {} + for _, info in ipairs(rows) do + local key = tostring(info.id) + by_key[key] = info + if not probe.fallback_done[key] and is_derived_server(info.id) then + probe.fallback_watches[key] = info.id + end end - if kind == nil or kind == "crashed" or kind == "stopped" then - probe.fallback_watch = nil - probe.fallback_failed = true - if kind ~= nil then retire_server(sid) end - report("LSP: lean4 fallback " .. fallback_name() - .. " started but did not stay up") + + for key, sid in pairs(probe.fallback_watches) do + local info = by_key[key] + local kind = info and info.state and info.state.kind + if kind == "initialized" then + probe.fallback_watches[key] = nil + probe.fallback_done[key] = true + elseif info == nil or kind == "crashed" or kind == "stopped" then + probe.fallback_watches[key] = nil + probe.fallback_done[key] = true + if info ~= nil then retire_server(sid) end + report("LSP: lean4 fallback " .. fallback_name() + .. " started but did not stay up") + end end end @@ -394,10 +418,10 @@ local function fire_latch(sid, why) probe.watching = nil if not swap_to_fallback() then report("LSP: lean4 " .. why) - -- Still retire: the servers are broken whether or not a replacement - -- command was installed, and leaving them live would keep the - -- restart machinery running against a command known to fail. - retire_derived_lean_servers() + -- No shared config changed, so only the server whose failure + -- triggered this verdict is invalid. Sweeping every root here stops + -- healthy instances of a root-sensitive command for no reason. + if sid and is_derived_server(sid) then retire_server(sid) end return end retire_derived_lean_servers() @@ -551,22 +575,66 @@ function M.wait_for_diagnostics(sid, uri, version, fn) return rid end +local function when_server_ready(sid, fn) + local function state_kind() + local ok, state = pcall(pmacs.lsp.status, sid) + if not ok or not state then return nil end + return state.kind + end + + local kind = state_kind() + if kind == "initialized" then + fn(nil) + return + end + if kind == nil or kind == "crashed" or kind == "stopped" then + fn("server did not initialize") + return + end + + -- A command may have just healed a dead attachment, in which case the + -- replacement is still starting. Requests are not queued before + -- initialize, so issue this one after the lifecycle reaches ready + -- rather than replacing the attachment and immediately failing on it. + pmacs.async(function() + for _ = 1, 300 do + pmacs.async.yield_to_next_tick() + kind = state_kind() + if kind == "initialized" then + fn(nil) + return + end + if kind == nil or kind == "crashed" or kind == "stopped" then + fn("server did not initialize") + return + end + end + fn("server initialization timed out") + end) +end + pmacs.command.define { name = "lean.wait-for-diagnostics", description = "Wait for the Lean server to finish elaborating this file", fn = function() - local rec = pmacs.lsp.active_attachment() + local rec = pmacs.lsp._attachment_for_command() if not rec or rec.language ~= "lean4" then pmacs.editor.set_status("lean: no Lean server for this buffer") return end pmacs.editor.set_status("lean: elaborating…") - M.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err) - if err then - pmacs.editor.set_status("lean: " .. tostring(err)) - else - pmacs.editor.set_status("lean: elaboration complete") + when_server_ready(rec.server, function(init_err) + if init_err then + pmacs.editor.set_status("lean: " .. tostring(init_err)) + return end + M.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err) + if err then + pmacs.editor.set_status("lean: " .. tostring(err)) + else + pmacs.editor.set_status("lean: elaboration complete") + end + end) end) end, } @@ -600,13 +668,17 @@ pmacs.hook.add("buffer.after-load", function() local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf) if not ok_lang or lang ~= "lean4" then return end - if not probe.started then - local path = pmacs.editor.file_path() - start_probe(path and M.root_for(path) or nil) - end - local rec = pmacs.lsp.active_attachment() if rec and rec.language == "lean4" then + -- A matching-root server supplied by the user may be adopted by + -- `ensure_server`. Its lifecycle is not evidence about the + -- config-driven command, and neither the version probe nor fallback + -- latch may mutate config because that foreign server changed state. + if not is_derived_server(rec.server) then return end + if not probe.started then + local path = pmacs.editor.file_path() + start_probe(path and M.root_for(path) or nil) + end -- **Arm ONCE, capturing buffer and server together.** Setting -- `buf_key` on every Lean load meant a second Lean buffer opened -- before the verdict silently became the rebuild target while the @@ -632,6 +704,10 @@ pmacs.hook.add("buffer.after-load", function() -- that produced no attachment is a failure. local cfg = pmacs.lsp.config.lean4 if not cfg or not cfg.command then return end + if not probe.started then + local path = pmacs.editor.file_path() + start_probe(path and M.root_for(path) or nil) + end -- No attachment for a Lean buffer with a configured command means -- `ensure_server` could not spawn at all — a synchronous ENOENT, @@ -662,7 +738,7 @@ pmacs.hook.add("process.after-tick", function() -- Repair the active buffer if the latch invalidated it. Cheap when -- there is nothing to do, and bounded to one attempt per buffer. repair_active_if_stale() - poll_fallback() + poll_fallbacks() end) -- Test seam: acceptance drives the latch deterministically rather than diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 6081a3d..bf56a4c 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -607,6 +607,12 @@ local function project_root_for(language, path) return dir_of(path), "fallback" end +-- Servers created by the automatic config-driven path. This is the +-- ownership fact a caller-supplied `label` cannot provide: labels are +-- public, unreserved display strings, while entries here are written +-- only after this module itself successfully spawns a server. +local default_servers = {} + local function ensure_server(language, path) local cfg = pmacs.lsp.config[language] if not cfg or not cfg.command then return nil end @@ -660,7 +666,30 @@ local function ensure_server(language, path) cwd = root, root_uri = key_uri, }) - if ok then return sid end + if ok then + default_servers[tostring(sid)] = language + return sid + end + return nil +end + +-- Internal ownership seam for builtins whose lifecycle follows the +-- config-driven server set (currently Lean's one-shot fallback). A +-- user-managed server may deliberately use the same language id, label, +-- command, and root; none of those make it ours. +function pmacs.lsp._is_default_server(sid, language) + local owned_language = default_servers[tostring(sid)] + return owned_language ~= nil + and (language == nil or owned_language == language) +end + +local function server_state_kind(sid) + if not sid then return nil end + for _, info in ipairs(pmacs.lsp.list()) do + if tostring(info.id) == tostring(sid) then + return info.state and info.state.kind + end + end return nil end @@ -669,14 +698,8 @@ end -- forgotten, or was spawned against a now-replaced `pmacs.lsp.config` -- entry — get rebuilt on the next attach attempt. local function server_is_live(sid) - if not sid then return false end - for _, info in ipairs(pmacs.lsp.list()) do - if tostring(info.id) == tostring(sid) then - local kind = info.state and info.state.kind - return kind ~= "crashed" and kind ~= "stopped" - end - end - return false + local kind = server_state_kind(sid) + return kind ~= nil and kind ~= "crashed" and kind ~= "stopped" end local function server_is_initialized(sid) @@ -811,6 +834,15 @@ local function attach_buffer(buf) local existing = attachments[key] if existing and server_is_live(existing.server) then return existing end if existing then + local kind = server_state_kind(existing.server) + if kind == "crashed" or kind == "stopped" then + -- A terminal OnCrash client may still have `next_restart_at` + -- armed. Spawning beside it creates two same-root servers when + -- the old id restarts. `forget` is the terminal-state operation: + -- it removes the client and cancels that pending restart before + -- the replacement is created. + pcall(pmacs.lsp.forget, existing.server) + end attachments[key] = nil -- Unsent edits targeted the dead attachment; the did_open below -- carries the full current text, superseding them. @@ -896,6 +928,14 @@ local function attached_for_active() return attach_buffer(buf) end +-- Internal command-path resolver for builtin request producers outside +-- this module. Unlike `active_attachment` it may replace a dead record; +-- unlike `attachment_for_request` it is called only from an explicit +-- user command, where attach-on-use is the intended policy. +function pmacs.lsp._attachment_for_command() + return attached_for_active() +end + -- Pure, side-effect-free attachment lookup for the active buffer: -- returns the live record (with `.uri`) when a server is already -- attached, else nil. Unlike `attached_for_active`, it never *triggers* @@ -964,8 +1004,10 @@ function pmacs.lsp.attachment_for_request() -- perturb LSP state), so a dead record reads as "no attachment" -- rather than triggering a rebuild. if not server_is_live(rec.server) then - attachments[key] = nil - pending_did_change[key] = nil + -- Preserve the record. A crashed OnCrash server may restart under + -- the SAME id; clearing the map here would orphan that recovered + -- server, while this non-attaching lookup has no authority to + -- cancel the restart or create a replacement. return nil end flush_did_change(key) diff --git a/docs/active-work.md b/docs/active-work.md index 6b99985..b96cc42 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ If it does not, stop and repair the remote/fetch configuration. - Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in `src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`, a `leanprogress` mode plus `waitForDiagnostics` validation on - `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (36 tests). + `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (40 tests). No protocol change. - **Stage 1's acceptance 12 is half superseded and was rewritten, not deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a @@ -359,9 +359,29 @@ If it does not, stop and repair the remote/fetch configuration. assertion counted TABLE KEYS, which cannot distinguish "once per buffer" from "every tick for one buffer" — cardinality stays 1 either way. Now a numeric attempt counter; the bite shows **174 vs 1**. +- **Round-6 review: four P1s and one P2, suite 40/40.** (1) General + point-of-use healing treated a crashed OnCrash server as absent and + spawned beside it while its old id still had `next_restart_at` armed; + `attach_buffer` now forgets a terminal record before replacement. + `attachment_for_request` remains non-attaching and preserves the + record, so a same-id restart can recover instead of being orphaned. + (2) The fallback watch was scalar, while Q#LN15 permits simultaneous + per-root servers and lsp.lua can create them without passing through + Lean's repair function. Watches are now per-SID and discover every + config-driven Lean server from a private origin table. (3) The shipped + `lean.wait-for-diagnostics` command bypassed both safe resolvers and + still consumed a stopped record; it now uses a command-safe resolver, + waits asynchronously for a healed replacement to initialize, and the + test requires the real request to finish. (4) When no config swap + occurred, one failed root still swept a healthy root; that arm now + retires only the SID whose verdict fired. (5) `label` is public and + unreserved, therefore not ownership. lsp.lua records successful + config-driven spawns privately, and every Lean lifecycle decision keys + on that origin fact; the user-server pin deliberately collides on + `default-lean4`. - **DURABLE LESSON — "the test that passes" vs "the test that - discriminates."** Six tests across three rounds were written, run - green, and only bite-testing showed they pinned nothing. **Carry this + discriminates."** Green tests across six rounds repeatedly pinned only + a nearby helper or an absence, and only biting exposed it. **Carry this to `docs/agent-handoff.md` when the lane lands.** The concrete shapes, all from this branch: 1. R1 acceptance 36 asserted "every server is terminal" — pinning the @@ -389,6 +409,11 @@ If it does not, stop and repair the remote/fetch configuration. not the things attempted against (bite: 174 vs 1). 9. A NONEXISTENT executable only exercises synchronous ENOENT. To reach "spawned, then died", the fixture must actually spawn. + 10. Calling the two SAFE HELPERS directly does not pin a shipped + command that bypasses both. Drive the command registry entry and + require its terminal result — replacing a dead record with a + `starting` server is still not success if the request is issued + before initialize. Rule: **a test is not evidence until the mutation it targets has been shown to fail it.** - **SECOND DURABLE LESSON — a scope error repeats until the scope is @@ -424,10 +449,10 @@ If it does not, stop and repair the remote/fetch configuration. works. Only a parseable version below 3.1.0 triggers it; the server-failure latch covers the rest. - Verification on this branch: `cargo fmt --check` clean; strict - workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; - lean4 server 36/36; lean4 stage 1 9/9; dispatch seams 15/15; + workspace Clippy clean; 1,829 default + 2,003 CRDT library tests; + lean4 server 40/40; lean4 stage 1 9/9; dispatch seams 15/15; multi-root 13/13; M4 121; required GPU 155; **isolated-config - workspace sweep 3,225 across 94 suites, zero failures**; + serial workspace sweep 3,229 across 94 suites, zero failures**; `git diff --check` clean. (Round 1 of this entry recorded 17/17 and 3,206 — the PRE-fix counts — after the fixes were pushed. The ledger's protocol is that verification diff --git a/tests/lean4_server_acceptance.rs b/tests/lean4_server_acceptance.rs index e28ac8f..86be1d5 100644 --- a/tests/lean4_server_acceptance.rs +++ b/tests/lean4_server_acceptance.rs @@ -1438,10 +1438,10 @@ fn r5_a_fallback_that_dies_after_spawning_is_bounded_and_reported() { #[test] fn r5_a_user_spawned_lean_server_is_not_retired_by_the_fallback() { - // `retire_*` selected on `language_id == "lean4"`, which also names - // servers the user spawned themselves from `init.lua`. Those are not - // derived from `pmacs.lsp.config.lean4` and stopping them is a - // destructive side effect on state this module does not own. + // Language id AND label are public caller-supplied values. Even a + // user server that deliberately collides with the automatic path's + // `default-lean4` display label is not derived from + // `pmacs.lsp.config.lean4` and must not be stopped. let fx = Fixture::new(); fx.toolchain("pkg", "v4.9.0\n"); let file = fx.write("pkg/A.lean", "def a := 1\n"); @@ -1453,7 +1453,7 @@ fn r5_a_user_spawned_lean_server_is_not_retired_by_the_fallback() { &format!( r#" _G.mine = pmacs.lsp.spawn({{ - label = "my-own-lean", + label = "default-lean4", language_id = "lean4", command = "{}", args = {{}}, @@ -1642,3 +1642,241 @@ fn r5_a_dead_attachment_is_never_handed_to_a_command() { {rebuilt:?}" ); } + +// --------------------------------------------------------------------------- +// Round-6 review. Each is a direct counterexample against 19f48d4. +// --------------------------------------------------------------------------- + +#[test] +fn r6_the_shipped_lean_command_rebuilds_a_dead_attachment() { + // The round-5 test called `attachment_for_request` and + // `_attach_buffer` directly, while the shipped Lean command read the + // raw `active_attachment` and still handed its request to a stopped + // server. Drive the production command this time. + let fx = Fixture::new(); + fx.toolchain("pkg", "v4.9.0\n"); + let file = fx.write("pkg/A.lean", "def a := 1\n"); + let mut state = editor(&fx); + open(&state, &file); + settle(&mut state); + + exec( + &state, + r" + local rec = pmacs.lsp.active_attachment() + assert(rec) + pmacs.lsp.stop(rec.server) + ", + ); + tick_for(&mut state, 200); + + exec( + &state, + r#"pmacs.command.invoke("lean.wait-for-diagnostics")"#, + ); + let kind: String = eval( + &state, + r#" + local rec = pmacs.lsp.active_attachment() + if not rec then return "none" end + for _, s in ipairs(pmacs.lsp.list()) do + if tostring(s.id) == tostring(rec.server) then + return tostring(s.state and s.state.kind) + end + end + return "gone" + "#, + ); + assert!( + kind != "stopped" && kind != "crashed" && kind != "gone" && kind != "none", + "the shipped command must resolve through the command-safe \ + attachment path; saw {kind:?}" + ); + tick_for(&mut state, 500); + let status = state.core.borrow().status.clone(); + assert_eq!( + status, "lean: elaboration complete", + "the rebuilt command path must deliver the request, not merely \ + replace the attachment" + ); +} + +#[test] +fn r6_every_spawned_fallback_server_is_bounded() { + // A scalar fallback watch covers only one Q#LN15 root. The second + // server can also be created directly by lsp.lua's after-load path, + // bypassing `repair_active_if_stale` entirely. + use std::os::unix::fs::PermissionsExt as _; + + let fx = Fixture::new(); + fx.toolchain("one", "v4.9.0\n"); + fx.toolchain("two", "v4.9.0\n"); + let first = fx.write("one/A.lean", "def a := 1\n"); + let second = fx.write("two/B.lean", "def b := 2\n"); + let absent_primary = fx.dir("bin/no-such-lake"); + let dying_fallback = fx.root.join("bin/dying-lean"); + std::fs::create_dir_all(dying_fallback.parent().unwrap()).unwrap(); + std::fs::write(&dying_fallback, "#!/bin/sh\nexit 4\n").unwrap(); + std::fs::set_permissions(&dying_fallback, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{ "serve" }} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&absent_primary), + lua_str(&dying_fallback) + ), + ); + + open(&state, &first); + open(&state, &second); + tick_for(&mut state, 1600); + + let worst_attempt: i64 = eval( + &state, + r" + local worst = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if s.language_id == 'lean4' and (s.attempt or 0) > worst then + worst = s.attempt + end + end + return worst + ", + ); + assert!( + worst_attempt <= 1, + "every fallback server must be bounded; an unwatched root \ + reached attempt {worst_attempt}" + ); +} + +#[test] +fn r6_point_of_use_healing_does_not_duplicate_a_restarting_server() { + // A crashed OnCrash server still has `next_restart_at` armed. + // Spawning a fresh id beside it produces two same-root servers when + // the old one restarts. Use Rust so this pins the general lsp.lua + // seam independently of Lean's fallback lifecycle. + let fx = Fixture::new(); + let file = fx.write("A.rs", "fn main() {}\n"); + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.rust = {{ + command = "{}", + args = {{}}, + env = {{ PMACS_FAKE_LSP_MODE = "crash" }}, + }} + "#, + fake_lsp_path() + ), + ); + open(&state, &file); + + let mut crashed = false; + for _ in 0..100 { + state.tick_processes(); + state.tick_lsp(); + crashed = eval( + &state, + r#" + for _, s in ipairs(pmacs.lsp.list()) do + if s.language_id == "rust" + and s.state and s.state.kind == "crashed" then + return true + end + end + return false + "#, + ); + if crashed { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(crashed, "precondition: the attached server crashed"); + + exec(&state, "pmacs.lsp.hover_at_cursor()"); + let rust_servers: i64 = eval( + &state, + r#" + local n = 0 + for _, s in ipairs(pmacs.lsp.list()) do + if s.language_id == "rust" then n = n + 1 end + end + return n + "#, + ); + assert_eq!( + rust_servers, 1, + "healing must cancel the old id's armed restart before spawning \ + its replacement" + ); +} + +#[test] +fn r6_no_swap_retires_only_the_failed_root() { + // When config already equals the fallback, no shared config changed. + // One root's failure must not globally retire another root's healthy + // instance of the same cwd-sensitive command. + use std::os::unix::fs::PermissionsExt as _; + + let fx = Fixture::new(); + fx.toolchain("bad", "v4.9.0\n"); + fx.toolchain("good", "v4.9.0\n"); + let bad = fx.write("bad/A.lean", "def a := 1\n"); + let good = fx.write("good/B.lean", "def b := 2\n"); + let wrapper = fx.root.join("bin/root-sensitive-lean"); + std::fs::create_dir_all(wrapper.parent().unwrap()).unwrap(); + std::fs::write( + &wrapper, + format!( + "#!/bin/sh\ncase \"$PWD\" in */bad) exit 4;; esac\nexec \"{}\"\n", + fake_lsp_path() + ), + ) + .unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut state = editor(&fx); + exec( + &state, + &format!( + r#" + pmacs.lsp.config.lean4.command = "{}" + pmacs.lsp.config.lean4.args = {{}} + pmacs.lean._fallback = {{ command = "{}", args = {{}} }} + "#, + lua_str(&wrapper), + lua_str(&wrapper) + ), + ); + open(&state, &bad); + open(&state, &good); + tick_for(&mut state, 700); + + let good_alive: bool = eval( + &state, + r#" + for _, s in ipairs(pmacs.lsp.list()) do + if s.cwd and s.cwd:match("/good$") then + local k = s.state and s.state.kind + return k ~= "stopped" and k ~= "crashed" + end + end + return false + "#, + ); + assert!( + good_alive, + "one root's failure must not stop another root when no config \ + swap occurred" + ); +} From 3b7cc67197924474544d9d73e6539c9446f1ac75 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 22:33:22 -0400 Subject: [PATCH 39/91] docs: record the sweep-found test race in the lane The parallel sweep failed one of the new tests for a real reason, not a flake: drain_until ticks, and a tick can reap an immediately-exiting child before the diagnostic runs. Recorded with the matched-load measurement that shows the fix is load-bearing (0/15 fixed vs 1/10 unfixed under full saturation), and the final sweep numbers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HZjWMjwPXhPbt9upku9mCk --- docs/active-work.md | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index ffd9b1e..497dd63 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -106,15 +106,32 @@ If it does not, stop and repair the remote/fetch configuration. the disposition test; stubbing the leader observation fails three tests including the one-event pin; a hardcoded target fails four; a wrong exit code fails two. +- **The sweep found a real defect in these tests, not a flake.** + `observing_the_leader_does_not_consume_the_exit_event` failed with + "process ProcessId(26) is not running": the pid helper drained for + `Started`, and **`drain_until` ticks**. A tick can observe an + immediately-exiting child and move the record out of `Running`, after + which `signal` never reaches the diagnostic at all, so the bounded + loop spun to its limit. It passed standalone because the drain + returned on `Started` before `poll_one` saw the exit; only load lost + the race. Fast-exiting children now read the pid straight from the + supervisor record (no tick), and the loop fails fast if the record + left `Running`. **Verified under matched load: 0/15 with all 16 cores + saturated, while the old ticking helper fails 1/10 — the fix is + load-bearing.** - Verification: fmt, `git diff --check`, strict workspace clippy clean; lib 1,838 + CRDT 2,015 (both +6, exactly the new tests); GPU 202; M4 - 121; bottom-panel 46; compile-mode 67; vterm 9/6/5; sweep 3,256 across - 93 suites with two load-contention flakes that pass 3/3 isolated - (`read_dir_supersede_cancels_in_flight_predecessor`, known - pre-existing, and - `headless_snapshot_round_trip_summary_restores_the_minimap`). The - second is structurally unreachable from this diff: `pmacs-gpu` depends - on `pmacs-protocol`, never on `pmacs`. + 121; bottom-panel 46; compile-mode 67; vterm 9/6/5; **isolated-config + `--no-fail-fast` sweep 3,258 across 93 suites, zero failures**. + Earlier sweeps on this branch showed two failures and then one; the + totals reconcile (3,256/2 → 3,257/1 → 3,258/0, same test count). The + two that were genuinely unrelated — + `read_dir_supersede_cancels_in_flight_predecessor` (known + pre-existing) and + `headless_snapshot_round_trip_summary_restores_the_minimap` — are + load-contention flakes; the second is structurally unreachable from + this diff, since `pmacs-gpu` depends on `pmacs-protocol` and never on + `pmacs`. - **Parked, each with its reason:** all tolerance rules (need the evidence this PR produces); `terminate` idempotence for an already-reaped process (independent fix, different failure, one From dec51960da5967c4da9477cbe8a5e10059487481 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 22:36:02 -0400 Subject: [PATCH 40/91] docs: record round-six bite evidence Capture the concrete counterfactual outcomes for all five review fixes against 19f48d4. --- docs/active-work.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index b96cc42..2efce61 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -378,7 +378,10 @@ If it does not, stop and repair the remote/fetch configuration. unreserved, therefore not ownership. lsp.lua records successful config-driven spawns privately, and every Lean lifecycle decision keys on that origin fact; the user-server pin deliberately collides on - `default-lean4`. + `default-lean4`. All five bites against `19f48d4` discriminate: the + old files produce 2 same-root servers, a fallback attempt of 4, a + shipped command still targeting `stopped`, retirement of the healthy + root, and retirement of the colliding user server, respectively. - **DURABLE LESSON — "the test that passes" vs "the test that discriminates."** Green tests across six rounds repeatedly pinned only a nearby helper or an absence, and only biting exposed it. **Carry this From 3b54c784949847bd47b7b03f2ce91da2453cb88f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 10:10:06 -0400 Subject: [PATCH 41/91] =?UTF-8?q?docs(lean4):=20rev=206=20=E2=80=94=20re-s?= =?UTF-8?q?cout=20Stage=204=20and=20split=20it=20into=204a=20and=204b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stages 3a and 3b landed (#167, #170). Re-scouting Stage 4 against main @ d400f30 produced six findings that change the plan and three that confirm it. The pmacs-side facts were verified in a worktree at that commit; the upstream facts by reading leanprover/vscode-lean4 @ 17d1d08. The split: Stage 4's risk column read "refactors pair.lua's provenance read" — every language's auto-pairing — for a stage the prose called the Lean input method, which is exactly the rule §4 states and exactly what round 4 found for Stage 3. Rev 5 had noticed the shape and answered it with a commit boundary; a commit boundary is not a review boundary. Stage 4a is now the typed-edit consumer chain (substrate, no Lean) and 4b the input method. Rev 5's expansion semantics were wrong in three ways. Resolution is the shortest key having the input as a prefix (\al yields ∀ from `all`, not `alpha`); there is no terminator list at all ('+ ' is a key, so space extends after \+; '\' is a key, so \\ yields \); and an unmatchable tail is appended rather than dropped (\alp7 yields α7). Three further findings. There is no cursor-motion hook, so acceptance 43 as written was not buildable and abandonment is lazy. dispatch_key is only half of 4b's production path — \ and the letters are not excluded from the optimistic classifier, and that producer is crdt-gated, so a crdt-gated integration test is dark in CI and dark in the gate list. And the whole expansion has cross-peer-degraded undo, a wider bite than Q#LN6's three bracket pairs; set_round_trip_input would fix it and is rejected with reasons. New decisions Q#LN21 (undo degradation) and Q#LN22 (the state machine); Q#LN10 and Q#LN11 rewritten; §2.11 records the upstream algorithm; §9.1 states the coherence impact for both stages. Acceptance keeps its existing numbers and adds letter suffixes on both sides of the split. Citation sweep per COHERENCE §25: five live citations moved in the 50 commits since rev 5. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- docs/active-work.md | 73 +++- docs/lean4-mode-framing.md | 703 +++++++++++++++++++++++++++++++------ 2 files changed, 667 insertions(+), 109 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index e4f0859..52d44ba 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -14,11 +14,13 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `d152120` (the bottom-panel landed-doc refresh #156 - atop the inline-math slice #158, dired Stage 1 #165, the GPU terminal - input fix #166, Lean 4 Stage 2 #161, the dired framing #164, - COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, and the minimap - blank-slab fix #159; protocol v20). + `githubsucks/main` @ `d400f30` (Lean 4 Stage 3b #170 atop Stage 3a + #167, the bottom-panel landed-doc refresh #156, the inline-math slice + #158, dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 + Stage 2 #161, the dired framing #164, COHERENCE.md #163, find-file + #162, Lean 4 Stage 1 #160, and the minimap blank-slab fix #159; + protocol v20). The previous snapshot named `d152120`; the recovery + check below accepts it or anything newer. - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -55,7 +57,7 @@ git status --short --branch The `git log` command must expose `d152120` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Lean 4 lane (Arc 8) — Stages 1+2 MERGED; 3a IN REVIEW (#167); 3b STACKED +## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b MERGED; Stage 4 IN FRAMING - Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review round, all twelve checks green). Branch `githubsucks/lean4-stage1` @@ -189,7 +191,7 @@ If it does not, stop and repair the remote/fetch configuration. suites**; `git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME` and `-- --skip basedpyright`. -### Stage 3a — dispatch seams + `pmacs.fs.canonicalize` (branch `lean4-stage3a-seams`) +### Stage 3a — dispatch seams + `pmacs.fs.canonicalize` — MERGED #167 (`main` @ `6f348c9`) - Worktree `../pmacs-lean-stage3`, branched off `githubsucks/main` @ `46a1b8f`. Carries framing **rev 5** (the Stage 3 split) as its first @@ -253,7 +255,7 @@ If it does not, stop and repair the remote/fetch configuration. `#[cfg(unix)]` is NOT sufficient for such a fixture — `#[cfg(target_os = "linux")]` is. Cost one red CI round to learn. -### Stage 3b — the Lean language server (branch `lean4-stage3b-server`) +### Stage 3b — the Lean language server — MERGED #170 (`main` @ `d400f30`) - Same worktree `../pmacs-lean-stage3`, **branched off `lean4-stage3a-seams`, not off `main`** — 3b consumes 3a's response @@ -463,6 +465,61 @@ If it does not, stop and repair the remote/fetch configuration. describes the pushed tree; recording it late is the #161 fmt-blocker error in a slower form.) +### Stage 4 — framing rev 6, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`) + +- Stages 3a and 3b **merged as #167** (`main` @ `6f348c9`) and **#170** + (`main` @ `d400f30`), 2026-07-26. Both were integrated against a main + that had advanced 50 commits mid-review; the only conflict either time + was this ledger's own lane headings, resolved by keeping both sides. +- Worktree `../pmacs-lean-stage4`, branched off `main` @ `d400f30`. + Framing-only so far: `docs/lean4-mode-framing.md` **revision 6**. No + code. Awaiting user approval before implementation, per the workflow. +- **Round 5 re-scout split Stage 4 into 4a (substrate) and 4b (Lean).** + 4a is the typed-edit consumer chain — `builtin/runtime/typed_edit.lua` + plus `pair.lua` re-expressed as one registered consumer, no behavior + change. 4b is the input method. The split is forced by §4's own rule, + which Stage 4's risk column ("refactors `pair.lua`'s provenance read") + broke while the prose called the stage Lean-only. +- **This is the SECOND consecutive re-scout to find that rule broken** + (round 4 found it for Stage 3). Rev 5 had even noticed the shape and + answered it with a commit boundary. **A commit boundary is not a review + boundary.** Re-check every remaining stage against §4 at scout time; + the rule is not self-enforcing. +- **Rev 5's expansion semantics were wrong in three ways**, found by + reading `leanprover/vscode-lean4` @ `17d1d08` rather than inferring + from behavior. Resolution is *shortest key having the input as a + prefix* (`\al` → `∀` from `all`, not `alpha`); there is **no + terminator list** (`'+ '` is a key, so space extends after `\+`; `'\'` + is a key, so `\\` → `\`); and an unmatchable tail is **appended**, + not dropped (`\alp7` → `α7`). +- **There is no cursor-motion hook**, so rev 5's acceptance 43 ("moving + the cursor out abandons it") was not buildable. Abandonment is lazy — + validated at the next typed edit — and the criterion now asserts what + pmacs can actually detect. Upstream drives this off `changeSelections`; + that seam does not exist here. +- **`dispatch_key` is only half the production path for 4b.** The + auto-pair suite gets away with dispatch-only because Q#AP1 removed the + pair chars from the optimistic classifiers; `\` and the letters are + NOT excluded, so on a CRDT frontend the optimistic producer is the real + path. That producer is `#[cfg(feature = "crdt")]` and CI never enables + `crdt`, and the gate list runs `--features crdt` only for `--lib` — a + crdt-gated integration test is **dark twice over**. +- The whole expansion has cross-peer-degraded undo (Q#LN21): six + source-peer optimistic inserts replaced by one daemon-peer op. + `set_round_trip_input` would fix it and is rejected — it also disables + `dispatch_idle`, so RET stops inserting a newline. +- Table facts re-derived at `17d1d08`: 1,855 entries, 36,861 bytes, all + keys ASCII, **64** keys carry a `lean4` pair-set char, **305** keys are + proper prefixes of another (so 1,550 expand eagerly), **26** values + carry `$CURSOR`, **93** are multi-codepoint. +- Citation sweep per COHERENCE §25: five live citations moved in the 50 + commits since rev 5 — `take_typed_edit` 12827→12990, + `handle_server_requests` 1549→1815, `fs.stat` 93→133, + `detect_buffer_language` 452→457, `send_request`/`send_notification` + 9342/9361→9507/9527. +- Verification: none yet — the branch carries no code. `git diff --check` + clean. + ## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) - Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index ec62980..e39eb36 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -6,7 +6,7 @@ pmacs has no Lean support of any kind: `grep -rin lean` over `*.rs`, plain buffer — no grammar, no major mode, no comment syntax, no pair set, no server. -This lane closes that in eight stages. Stage boundaries are drawn where +This lane closes that in nine stages. Stage boundaries are drawn where the *substrate* changes, not where the feature list does — see §4. §9 states the lane's coherence impact per `COHERENCE.md` §20. @@ -21,11 +21,12 @@ the *substrate* changes, not where the feature list does — see §4. #144 (LaTeX), #146 (HTML+CSS). Stage 1 is that pattern almost exactly. - Stages 2 and 4–6 are **not** that pattern, and none should be mistaken for a one-liner. Stage 2 changes `ensure_server`, shared by every LSP - language. Stage 4 builds the editor's first input method. Stage 5 is the + language. Stage 4a changes how typed-character provenance is consumed + and Stage 4b builds the editor's first input method. Stage 5 is the first consumer of a non-standard LSP method family. Stage 6 adds a severity-routing policy to `LspServerSpec`. - The user's stated north star is **matching or exceeding what VS Code - does with Lean**. §5's bet 6 scores honestly how close the eight stages get + does with Lean**. §5's bet 6 scores honestly how close the nine stages get and names precisely what is still missing. Parallel-safety: Stage 1 touches `Cargo.toml`, `src/syntax.rs`, @@ -283,10 +284,131 @@ rather than a bare root), `ensure_server` 527 → **610**, pre-#161 line numbers inside Q#LN15 are left as written: that stage has landed and its citations are historical record, not navigation. +### Round 5 (rev 5 → rev 6) — Stage 4 re-scout and split + +Stages 3a and 3b landed (#167, #170). Re-scouting Stage 4 against `main` +@ `d400f30` produced **six findings that change the plan** and three +that confirm it. The pmacs-side facts were verified in a worktree at +that commit; the upstream facts were verified by downloading and reading +`leanprover/vscode-lean4` at commit `17d1d08` (2026-05-29) — the +algorithm, not its documentation, since the `lean4-unicode-input` +package ships no README. + +1. **Stage 4 violated this document's own splitting rule — the same way + Stage 3 did.** §4 says "no PR in this arc mixes a cross-cutting + substrate change with Lean feature content," and §4's own risk column + for Stage 4 read *"refactors `pair.lua`'s provenance read."* + `pair.lua` is every language's auto-pairing; the refactor is + cross-cutting substrate by exactly the test that split out stages 2 + and 3a. Rev 5 already conceded the shape without acting on it — + Q#LN10 said the refactor "lands *first*, as its own commit with no + behavior change, so a regression bisects cleanly." A commit boundary + is not a review boundary. **Stage 4 is now 4a (the typed-edit + consumer chain, no Lean) and 4b (the input method).** Confirmed + `pair.lua:226` is still the **only** production `take_typed_edit` + caller; the other eight call sites are all in + `tests/auto_pair_acceptance.rs`. +2. **The expansion semantics in rev 5's Q#LN10 were wrong in three + ways.** Reading `AbbreviationProvider.ts` and `TrackedAbbreviation.ts` + rather than inferring from behavior: + - Rev 5 said expansion fires on "a unique complete match that no + longer key extends." Upstream's rule is + `findSymbolsByAbbreviationPrefix(abbrev)[0]` — the symbol of the + **shortest key having `abbrev` as a prefix**. `\alp` + space is not + a failure; it yields `α`, because `alpha` is the shortest key + starting with `alp`. Verified against the table: `\al` → `∀`, from + `all`, not from `alpha`. + - Rev 5 named "an explicit terminator (space, tab, RET, or a second + `\`)." **There is no terminator list upstream.** A character + terminates iff extending the pending key by it leaves zero prefix + matches. Space usually does — but `'+ '` **is a key** (one of + 1,855), so after `\+` a space extends rather than terminates. And a + second `\` is not a terminator either: `'\'` is a key mapping to + `\`, so `\\` extends, matches uniquely, and expands to a single + backslash. It terminates only when the pending key is non-empty and + no key extends it. + - Rev 5 did not carry the suffix rule at all. When no key has + `abbrev` as a prefix, upstream recurses on `abbrev` minus its last + character and **appends the leftover**: `\alp7` → `α7`. Dropping + this makes a large class of real input silently unexpandable. +3. **There is no cursor-motion hook, so acceptance 43 as written cannot + be built.** The Rust core fires exactly eight named hooks + (`builtin/hooks/default.lua`): `buffer.before-save`, + `buffer.after-load`, `buffer.after-edit`, `buffer.after-switch`, + `buffer.after-save`, `editor.before-quit`, `frontend.detached`, + `process.after-tick`. Upstream drives abandonment off + `changeSelections`, a seam pmacs does not have. Abandonment must + therefore be **lazy** — validated at the next typed edit against the + pending region — which changes what acceptance 43 can assert. Q#LN22 + states the state machine this forces. +4. **`dispatch_key` is only half of Stage 4b's production path.** Rev 5 + inherited the auto-pairing suite's dispatch-driven harness without + noticing why that harness is sufficient *there*: Q#AP1 removed the + pair characters from both optimistic classifiers, so for pair chars + dispatch **is** production. `\` and the ASCII letters are not + excluded — `classify_key` returns `Insert(c)` for them + (`src/optimistic.rs:144`: `Char(c) if !c.is_control() && + !is_builtin_pair_char(c)`), so on a CRDT frontend an abbreviation is + typed entirely through the *optimistic* producer, which arms the same + record from `handle_remote_crdt_op` (`src/daemon.rs:3965` pins the + classification). A dispatch-only Stage 4b suite would pin the path + real users do not take. The trap underneath: that producer is + `#[cfg(feature = "crdt")]`, and CI never enables `crdt` — so a + crdt-gated integration test is dark twice over, since the required + gate list runs `--features crdt` only for `--lib`. Q#LN22 and §7 say + what to do about it instead of discovering it in review. +5. **The whole expansion has cross-peer-degraded undo, and it is a + larger bite than `⟨⟩`'s.** Q#LN6 already accepts this for three + bracket pairs. But there the mismatch is one optimistic opener + against one daemon-peer closer; here the user's `\alpha` is six + source-peer optimistic inserts and the expansion is a single + daemon-peer `replace` **over all six**. Q#LN21 takes the decision — + including why `pmacs.buffer.set_round_trip_input`, which already + exists and would fix it, is the wrong instrument. +6. **The table's shape is sharper than "1,855 entries."** Re-counted at + `17d1d08`: 1,855 entries, all `string → string`, **all keys ASCII**, + longest key 25 characters, 36,861 bytes of JSON. **64** keys contain + a `lean4` pair-set character (rev 5's number, reproduced exactly). + Three numbers rev 5 did not have and the algorithm needs: **305** + keys are proper prefixes of another key (so 1,550 are eager-expandable + on uniqueness and 305 are not), **26** values carry `$CURSOR` (not + just `\<>`), and **93** values are multi-codepoint. Two values contain + a backslash — `n` → `\n` and `setminus` → `\` — which is why upstream + needs a `doNotTrackNewAbbr` guard and why §2.11 records that pmacs + does not. + +Confirmations, recorded because each was load-bearing and unverified: + +7. **`take_typed_edit`'s one-shot contract is unchanged** + (`src/editor_core.rs:4047`): per-frontend, cleared by the producer + when the fan-out returns, nil to a nested manual `hook.run`. The + hazard rev 5 built Q#LN10 around is real and still the reason 4a + exists. +8. **Load order still constrains the chain.** `pair.lua` loads at + `src/editor.rs:430` and `lsp.lua` at `:436`, and Q#AP7's reason + holds: `lsp.lua`'s `buffer.after-edit` callback synchronously flushes + `didChange` on the signature-trigger path. An expansion that landed + after that flush would send the server the unexpanded text. +9. **Embedding the table needs no special machinery.** Every builtin + runtime chunk is an `include_str!`, and `lsp.lua` is already 111 KB + of the 414 KB total. A ~45 KB generated Lua table is within the + existing practice, so Q#LN11 embeds it rather than inventing a + lazy-load path. + +Citation drift repaired per COHERENCE §25, on the same terms as round +4's sweep. Five live citations moved in the 50 commits since rev 5: +`take_typed_edit` 12827 → **12990**, `handle_server_requests` 1549 → +**1815**, `fs.stat` 93 → **133**, `detect_buffer_language` 452 → +**457**, and `send_request`/`send_notification` 9342/9361 → +**9507**/**9527**. Left as written: the pre-#161 numbers inside Q#LN15 +and the revision-history entries above, which are historical record +rather than navigation. + ## 1. What ships -Eight stages, after round 4 split Stage 3. The north star is VS Code -parity; the honest statement of where that lands is in §5, bet 6. +Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The +north star is VS Code parity; the honest statement of where that lands +is in §5, bet 6. **Stage 1 — grammar, mode, and the editing table stakes.** `.lean` files highlight, carry a `lean4` major mode, and get comment-toggle and @@ -315,10 +437,19 @@ on 3a's seam. Adds `textDocument/waitForDiagnostics`. Diagnostics, hover, completion, goto-definition, document symbols, and semantic tokens all arrive through the existing typed surfaces. -**Stage 4 — the Unicode input method.** Typing `\alpha` produces `α`, +**Stage 4a — the typed-edit consumer chain.** Pure substrate, no Lean +content, split from Stage 4 in round 5 for the reason stages 2 and 3a +were: it changes machinery every language runs through. The one-shot +`take_typed_edit()` record stops being auto-pairing's private property +and becomes a small ordered chain that reads it once and offers it to +registered consumers. `pair.lua` becomes the chain's first and only +consumer, with no behavior change. + +**Stage 4b — the Unicode input method.** Typing `\alpha` produces `α`, `\to` produces `→`, `\<>` produces `⟨⟩` with the point between them. -1,855 abbreviations vendored from vscode-lean4. This is the stage that -makes Lean actually typable in pmacs. +1,855 abbreviations vendored from vscode-lean4, registered as a chain +consumer ahead of auto-pairing. This is the stage that makes Lean +actually typable in pmacs. **Stage 5 — the goal view.** A `*lean-goal*` panel that renders `$/lean/plainGoal` at the point, refreshed on a debounced tick and on @@ -405,7 +536,7 @@ injections_query }`. Adding a grammar is one entry plus one `Cargo.toml` line; the doc comment at `src/syntax.rs:756` says exactly this and it has held for every grammar since. -`builtin/runtime/syntax.lua:452` `detect_buffer_language` resolves, in +`builtin/runtime/syntax.lua:457` `detect_buffer_language` resolves, in order: modeline → `pmacs.parse.language_for_path` (the grammar extension table) → `pmacs.lsp.filetypes[ext]` → `pmacs.parse.language_from_filename` → shebang. A grammar entry claiming `lean` therefore resolves `.lean` @@ -486,13 +617,13 @@ and pin it.* - `pmacs.lsp` already exposes generic `send_request(id, method, params)` → request id and `send_notification(id, method, params)` - (`src/lua_bindings/mod.rs:9342`, `:9361`). Non-standard methods need no + (`src/lua_bindings/mod.rs:9507`, `:9527`). Non-standard methods need no new Rust to *send*. - `LspEventKind` (`src/lsp.rs:264`) has generic `Notification { method, params }` and `Response { id, result, error, method }` variants. Unknown server methods are delivered, not dropped. - **But `events_take` has exactly one consumer**: `handle_server_requests` - at `builtin/runtime/lsp.lua:1549`, driven off `pmacs._async.tick`. It + at `builtin/runtime/lsp.lua:1815`, driven off `pmacs._async.tick`. It `take`s — a drain. Its `if/elseif` chain handles five `request` methods and `initialized`, and **ignores every `notification` and every `response`**. A second module calling `events_take` would steal events @@ -556,7 +687,7 @@ character": subscribe to `buffer.after-edit`, gate on `ed.this_command() == "buffer.self-insert"` (`pair.lua:229`), then take the exact provenance record. -`pmacs.editor.take_typed_edit()` (`src/lua_bindings/mod.rs:12827`) returns +`pmacs.editor.take_typed_edit()` (`src/lua_bindings/mod.rs:12990`) returns `{ buffer, window, codepoint, char, requested_start, requested_end, effective_start, effective_end, inserted_len, post_cursor, clean }` — or nil. Its doc comment is explicit: @@ -570,7 +701,19 @@ it on every self-insert. A Lean abbreviation expander that independently calls `take_typed_edit()` in the same `buffer.after-edit` fan-out gets nil or steals it from auto-pairing, depending on hook order — and hook order is not a contract. This is the single load-bearing constraint on Stage 4 and -the reason Stage 4 is its own PR rather than a rider on Stage 1. +the reason Stage 4 is its own PR rather than a rider on Stage 1 — and, +after round 5, the reason its substrate half is Stage 4a rather than a +first commit on a Lean branch. + +Re-verified at `d400f30`: `pair.lua:226` remains the **only** production +caller. The eight other call sites in the tree are all in +`tests/auto_pair_acceptance.rs`. So the chain Stage 4a introduces has +exactly one consumer to migrate, which is what makes a no-behavior-change +substrate PR possible at all. + +Two producers arm the record, not one, and §2.11 is where that matters: +the dispatch fallback and — under `#[cfg(feature = "crdt")]` — the +optimistic CRDT arm reached from `handle_remote_crdt_op`. Related, from `pair.lua:30`'s Q#AP1 note: only the nine built-in pair chars `()[]{}"'` and backtick are excluded from the frontends' optimistic @@ -680,6 +823,78 @@ The publish path absorbs into the Rust store *and* still delivers the notification to `events_take`, so Lua can observe them; but suppressing them from the store needs a Rust-side policy, not a Lua filter. Q#LN18. +### 2.11 The upstream input method (external, verified by reading it) + +Scouted 2026-07-26 against `leanprover/vscode-lean4` @ `17d1d08`, +package `lean4-unicode-input`, files `AbbreviationProvider.ts`, +`TrackedAbbreviation.ts`, `AbbreviationRewriter.ts`, +`AbbreviationConfig.ts`, and `abbreviations.json`. The package ships no +README, so the algorithm below is read off the source. Apache-2.0. + +**Resolution.** `findSymbolsByAbbreviationPrefix(p)` collects every key +having `p` as a prefix, sorts them by **key length ascending**, and maps +to symbols. `getReplacementText(a)`: + +1. If any key has `a` as a prefix, return the shortest such key's symbol. +2. Otherwise recurse on `a` minus its last character; if that yields + something, return it **with the dropped character appended**. +3. Otherwise undefined — no expansion. + +Verified against the table: `alpha` → `α`, `alp` → `α` (via `alpha`), +`al` → `∀` (via `all`, *not* `alpha` — shortest wins, and this is +surprising enough to be worth an acceptance criterion), `alp7` → `α7` +via rule 2, `a` → `α` (`a` is itself a key, among 29 prefix matches). + +**Tracking.** The leader `\` is inserted into the buffer like any other +character, and the tracked range starts after it; the replaced range +spans the leader inclusive (`abbreviationRange.moveKeepEnd(-1)`). So the +buffer literally shows `\alpha` until expansion, then that whole span +becomes `α`. + +**Termination.** There is no terminator set. On each typed character +`c`, if `findSymbolsByAbbreviationPrefix(a .. c)` is empty the +abbreviation is marked `finished`, **`c` is not absorbed into it**, and +the pending text expands before `c` lands. Otherwise `c` extends the +key. Two consequences the obvious "space ends it" model gets wrong: + +- `'+ '` is a key, so after `\+` a space **extends**. Space is a + terminator by consequence, never by rule. +- `'\'` is a key (→ `\`), so `\\` extends, is uniquely complete, and + eagerly expands to one backslash. A second `\` terminates only when + the pending key is non-empty and unextendable — at which point the + rewriter starts a *new* tracked abbreviation on it. + +**Eager expansion.** When `eagerReplacementEnabled`, an abbreviation +expands the moment it is *unique and complete*: exactly one key has it +as a prefix, and it is itself a key. 1,550 of the 1,855 keys qualify; +the other 305 are proper prefixes of some other key and must wait for +termination. `\to` is in the first group — it expands with no terminator +typed, which is why acceptance 41 is meaningful and not a restatement of +38. + +**Cursor placement.** `$CURSOR` is stripped from the symbol and its +index becomes the post-expansion point, applied only when the point sat +at the end of the abbreviation. 26 values carry it. + +**Abandonment.** Upstream expands on `changeSelections` — any tracked +abbreviation the cursor has left. pmacs has no cursor-motion hook +(round-5 finding 3), so this seam does not exist here and Q#LN22 makes +abandonment lazy instead. + +**The re-arm guard pmacs does not need.** `setminus` → `\` and `n` → +`\n`, so an expansion can insert a backslash; upstream sets +`doNotTrackNewAbbr` across the replace so that backslash does not open a +new abbreviation. In pmacs the expansion is a programmatic `buf:replace` +that arms no typed-edit record, so the chain sees nothing and cannot +re-arm. The guard is unnecessary here **because of** the provenance +contract, not by accident — and the acceptance must pin it, because a +future consumer that inferred from buffer text rather than provenance +would reintroduce the bug. + +**What pmacs does not have to carry.** Multi-cursor. Upstream tracks a +`Set` and sorts changes bottom-up for that reason; +pmacs has one point, so one pending abbreviation per buffer. + ## 3. Decisions ### Q#LN1 — Bundle `arborium-lean` 2.18; reject `tree-sitter-lean4` @@ -911,7 +1126,7 @@ file's directory. **How the walk tests for the marker — and why not the obvious way.** `pmacs.fs.stat` is asynchronous: it returns an awaitable handle -(`builtin/runtime/fs.lua:93`) that only settles under `:await()` inside a +(`builtin/runtime/fs.lua:133`) that only settles under `:await()` inside a coroutine. The resolver has no coroutine. It runs synchronously inside `ensure_server` ← `attach_buffer` ← the `buffer.after-load` hook, so awaiting is not merely slow there, it is unavailable — and blocking the @@ -1102,86 +1317,192 @@ The binding is general, not Lean-shaped: it serves every future function-valued `root`, and it is what lets #161's doc comment stop warning about a footgun and start naming a fix. -### Q#LN10 — Stage 4 mechanism: one shared provenance read, not two +### Q#LN10 — Stage 4a: one shared provenance read, not two The hazard is §2.6 — `take_typed_edit()` is one-shot and `pair.lua` -already consumes it. +already consumes it. A second independent caller in the same +`buffer.after-edit` fan-out gets nil or steals the record, depending on +hook order, and hook order is not a contract. Decision: **`pair.lua` stops being the sole consumer.** Extract the provenance read into a single `buffer.after-edit` subscriber owned by a -small shared module, which takes the record once and passes it to an -ordered list of typed-edit consumers (auto-pair, Lean abbreviation). -Consumers return whether they handled the edit; the first that does stops -the chain. +small shared module — `builtin/runtime/typed_edit.lua`, loaded +immediately before `pair.lua` — which takes the record once and offers +it to registered consumers in a defined order. A consumer returns +whether it **claimed** the edit; the first that claims stops the chain. -Two consequences worth stating up front: +`pmacs.typed_edit.add_consumer { name = , priority = , +fn = function(rec) ... end }`, lowest priority first, ties broken by +registration order. Priority is an explicit number rather than +load-order-implied because Q#LN22's collision makes ordering +load-bearing, and rev 5's "the abbreviation consumer runs first" is a +claim a reader must be able to check without reconstructing +`src/editor.rs`'s include list. -- This touches `pair.lua`, which is load-bearing for auto-pairing - acceptance. The full pairing suite is a required gate for Stage 4, and - the refactor lands *first*, as its own commit with no behavior change, - so a regression bisects cleanly. -- Ordering is a contract, not an accident, and the collision is real: - **64 of the 1,855 abbreviation keys contain a character in the proposed - `lean4` pair set** — `\[[]]` → `⟦⟧`, `\(())` → `⸨⸩`, `\{{}}` → `⦃⦄`, - `\{}` → `{$CURSOR}`. With pairing first, typing `\[` inserts `[]` - with the point between, so the pending key is corrupted to `\[]` before - the second `[` is ever typed and `\[[]]` becomes unreachable. The - abbreviation consumer runs first. +**Stage 4a ships this and nothing else.** Its whole content is: +`typed_edit.lua`, `pair.lua` re-expressed as one registered consumer, +and the `include_str!` line. Round 5's finding 1 is why this is a PR and +not a first commit — `pair.lua` is every language's auto-pairing, and a +reviewer looking at a Lean PR should not have to also review a rewrite +of it. - (Rev 1 justified this with `\<>`, which was wrong: `<` is not in the - pair set per Q#LN6, so that key is safe under either order.) +**The no-behavior-change claim must be pinned, not asserted.** The full +`tests/auto_pair_acceptance.rs` suite is a required gate for 4a and must +pass **unmodified** — a suite edited to accommodate the refactor proves +nothing (the recorded lesson: what a test suite pins is its assertions). +Three assertions the existing suite already makes are the load-bearing +ones, because they are what a chain could plausibly break: that a second +`take_typed_edit()` in the same fan-out yields nil, that pairing still +sees the exact record via `_capture_records`, and that the Q#AP7 ordering +against `lsp.lua`'s `didChange` flush still holds. -**The contract that collision exposes:** the abbreviation consumer must -claim a self-insert that **extends an open pending abbreviation**, not -only one that completes an expansion. A consumer that only claims -completed expansions hands every intermediate keystroke to auto-pairing, -which is exactly how `\[` gets corrupted. "Claimed" here means the chain -stops, not that an edit was made. +**What 4a deliberately does not do.** It does not change the `all-must- +succeed` contract, so a consumer that throws still fails the fan-out for +everyone. The chain owner therefore `pcall`s each consumer and reports +through `pmacs.editor.set_status`, matching `pair.lua`'s existing +never-throw-from-after-edit discipline — this is behavior-preserving for +pairing (which already never throws) and is the guardrail 4b needs. -Expansion semantics (matching vscode-lean4 and `lean4-input`): - -- `\` opens a pending abbreviation, tracked per buffer with its start - offset. Every subsequent self-insert that extends it is claimed. The - pending state is abandoned on any non-self-insert command, buffer - switch, or cursor move away from the pending region. -- Expansion fires on a unique complete match that no longer key extends, - or on an explicit terminator (space, tab, RET, or a second `\`). -- The vendored table's `$CURSOR` placeholder becomes the point position - after the replace — this is how `\<>` yields `⟨|⟩`. -- The whole expansion is **one `buf:replace`** — one undo step, one CRDT - op, one effective-edit verification. Same discipline as - `comment.lua`'s Q#CT5. -- Gated by `pmacs.config.define{ name = "lean.abbrev", type = "boolean", - default = true, mutability = "live" }`, read against the *source* buffer - of the typed edit — the `editing.auto-pair` precedent (`pair.lua:44`), - including its round-2 correction to resolve `rec.buffer` rather than - `pmacs.window.buffer()`. - -### Q#LN11 — Stage 4 data: vendor the table, generated, attributed +### Q#LN11 — Stage 4b data: vendor the table, generated, attributed `abbreviations.json` in `leanprover/vscode-lean4` is a flat -`string → string` object of **1,855 entries** (counted, not estimated), -of which **64 contain a character in the `lean4` pair set** — the -collision Q#LN10's ordering exists to handle. vscode-lean4 is Apache-2.0. +`string → string` object of **1,855 entries**, verified at commit +`17d1d08` (2026-05-29), 36,861 bytes, all keys ASCII, longest key 25 +characters. The counts the algorithm depends on, all re-derived from the +file rather than estimated: + +| Count | What it drives | +|---|---| +| 64 keys containing a `lean4` pair-set char | Q#LN22's ordering | +| 305 keys that are proper prefixes of another | which keys can expand eagerly | +| 1,550 keys uniquely-and-completely matching | the eager-expansion set | +| 26 values containing `$CURSOR` | point placement | +| 93 multi-codepoint values | the replace is not one-char-for-many | + +vscode-lean4 is Apache-2.0. Vendor it as a generated `builtin/runtime/lean_abbrev.lua` with a header -recording source repo, commit, license, and the regeneration command — -the `builtin/queries/latex/highlights.scm` precedent (#144) for -third-party data, extended with provenance because this is a much larger -artifact under a named license. +recording source repo, commit, license, entry count, and the +regeneration command — the `builtin/queries/latex/highlights.scm` +precedent (#144) for third-party data, extended with provenance because +this is a much larger artifact under a named license. -Not fetched at runtime, not a package-manager dependency: the input method -must work offline and on first launch. +Not fetched at runtime, not a package-manager dependency: the input +method must work offline and on first launch. -**Upkeep is a documented manual process, not code.** There is no automatic -sync and none is wanted — an editor that silently re-downloads its input -method has a supply-chain problem, not a feature. The generator script -lives at `scripts/regen-lean-abbrev`, takes a vscode-lean4 commit as its -argument, and rewrites the file including its provenance header. The -header records source commit, license, entry count, and the regeneration -command, so the file is self-describing to whoever next touches it. A -refresh is an ordinary PR with a visible diff — which is the point: the -diff is the review. +**Embedded, not lazily loaded.** ~45 KB of generated Lua joins the 414 KB +of builtin runtime already compiled in by `include_str!`, of which +`lsp.lua` alone is 111 KB. Inventing a lazy-load path for an 11% increase +would be new machinery bought with no measurement, and the arithmetic is +stated here so a reviewer can disagree with it on numbers. + +**Upkeep is a documented manual process, not code.** There is no +automatic sync and none is wanted — an editor that silently re-downloads +its input method has a supply-chain problem, not a feature. The generator +script lives at `scripts/regen-lean-abbrev`, takes a vscode-lean4 commit +as its argument, and rewrites the file including its provenance header, +so the file is self-describing to whoever next touches it. A refresh is +an ordinary PR with a visible diff — which is the point: the diff is the +review. + +**The generator must reject a table it cannot faithfully encode.** Keys +are ASCII today but nothing upstream promises that; a key containing a +character the emitted Lua would have to escape, or a duplicate after +normalization, aborts the regeneration rather than silently emitting a +table that disagrees with its source. Same discipline as Q#LN20's +refusal to hand back a lossy path. + +### Q#LN21 — Stage 4b: the expansion's undo is cross-peer-degraded; ship it, name it + +`classify_key` (`src/optimistic.rs:144`) returns `Insert(c)` for `\` and +for every ASCII letter — only the nine built-in pair chars are excluded +(Q#AP1). So on a CRDT frontend the user's `\alpha` arrives as six +**source-peer** optimistic inserts, while the expansion is a single +**daemon-peer** `buf:replace` spanning all six. Undo across that boundary +is not chronologically arbitrated; this is the same defect Q#LN6 already +accepts for `⟨⟩`, `⦃⦄`, `⟮⟯`, one order of magnitude wider. + +Considered and rejected: `pmacs.buffer.set_round_trip_input(buf, true)`, +which exists, is per-buffer, and would fix this exactly. Its six current +callers are all read-only generated buffers — listview, compile, dired, +terminal — and it does considerably more than disable optimistic insert: +per `src/editor_core.rs:505`, `dispatch_idle` reports false, so RET +reaches buffer-local bindings instead of inserting a newline. Turning it +on for every ordinary editable Lean source file would trade a known undo +degradation for an unknown behavior change across the whole editing +surface, and would make Lean the one language whose typing has a +different latency profile. + +Also rejected: adding `\` to the always-round-trip set. It is +frontend-side and language-blind, so this would tax LaTeX, C, shell, and +every string literal in the editor to fix one language. + +Decision: **accept the degradation, name it in the module comment, and +do not paper over it.** The general fix is chronological cross-peer undo +arbitration — already on the standing backlog, and the same fix Q#LN6 +points at. What Stage 4b owes is honesty about scope: this is not "a few +brackets," it is every abbreviation the user types on a CRDT frontend. + +### Q#LN22 — Stage 4b mechanism: lazy abandonment, explicit ordering + +**Ordering.** The abbreviation consumer registers ahead of auto-pairing. +The collision is real: 64 keys contain a `lean4` pair-set character — +`\[[]]` → `⟦⟧`, `\(())` → `⸨⸩`, `\{{}}` → `⦃⦄`, `\{}` → `{$CURSOR}`. +With pairing first, typing `\[` inserts `[]` with the point between, so +the pending key is corrupted to `\[]` before the second `[` is typed and +`\[[]]` becomes unreachable. + +(Rev 1 justified this with `\<>`, which was wrong: `<` is not in the pair +set per Q#LN6, so that key is safe under either order.) + +**The contract the collision exposes:** the consumer must claim a +self-insert that **extends an open pending abbreviation**, not only one +that completes an expansion. A consumer that claims only completed +expansions hands every intermediate keystroke to auto-pairing, which is +exactly how `\[` gets corrupted. "Claimed" means the chain stops, not +that an edit was made. + +**State machine**, per §2.11's ground truth rather than rev 5's +reconstruction of it: + +- `\` typed in a `lean4` buffer opens a pending abbreviation: `{ buffer, + start_offset, text = "" }`, one per buffer, keyed on `rec.buffer`. +- A subsequent self-insert `c` is claimed iff at least one key has + `text .. c` as a prefix; then `text = text .. c`. If it is also + uniquely-and-completely matching (one of the 1,550), expand now. +- If no key extends `text .. c`, expand `text` **first**, then let `c` + land normally — the chain does *not* claim `c`. +- Expansion resolves through §2.11's three-rule `getReplacementText`, + including the suffix rule (`\alp7` → `α7`). +- `$CURSOR` is stripped from the symbol and its index becomes the point. + +**Abandonment is lazy, because there is no cursor-motion hook** (round-5 +finding 3). Pending state is validated at the next typed edit and +discarded when any of these no longer holds: the record's buffer is the +pending buffer; `rec.effective_start` equals `start_offset + 1 + +#text` (the point is still at the end of the pending span); and the +buffer's `revision()` advanced by exactly the pending edit. `buffer. +after-switch` clears it eagerly since that hook *does* exist. The +practical difference from upstream: a user who clicks away mid-`\alp` +and types elsewhere gets the pending state dropped rather than expanded. +Upstream expands it. **This is a deliberate divergence** — expanding +into a region the user has left is the worse failure, and pmacs cannot +detect the departure at the moment it happens. + +**One `buf:replace`** for the whole expansion — one undo step, one CRDT +op, one effective-edit verification, with the same +rejected/altered-by-intercept reporting as `comment.lua`'s Q#CT5 and +`pair.lua`. A rejection drops the pending state; it does not retry. + +**Gate:** `pmacs.config.define{ name = "lean.abbrev", type = "boolean", +default = true, mutability = "live" }`, read against the **source** +buffer of the typed edit — the `editing.auto-pair` precedent +(`pair.lua:46`), including its round-2 correction to resolve +`rec.buffer` rather than `pmacs.window.buffer()`. + +**Language gate:** the consumer opens no pending abbreviation outside a +`lean4` buffer, resolved from `rec.buffer` for the same reason. `\` in a +Rust buffer is an ordinary character and `\[` there still pairs. ### Q#LN12 — Stage 5 sends `$/lean/plainGoal` through a typed Rust request @@ -1418,13 +1739,14 @@ never lands. | 2 | multi-root server affinity | **`ensure_server`, shared by every language** | — | | 3a | notification/response seams + purge; `pmacs.fs.canonicalize` | **the shared event drain, run by every language** | — | | 3b | `lake serve` + probe/latch, Lake root, `waitForDiagnostics` | none — Lean-only files plus one config entry | 1, 2, 3a | -| 4 | Unicode input method | **refactors `pair.lua`'s provenance read** | 1 | +| 4a | typed-edit consumer chain | **refactors `pair.lua`'s provenance read, shared by every language** | — | +| 4b | Unicode input method | none — Lean-only files plus one chain consumer | 1, 4a | | 5 | goal panel | new typed LSP request; panel adopter | 3a, 3b | | 6 | `#eval` / `#check` output channel | **new `LspServerSpec` policy field** | 3b, 5 | | 7 | module hierarchy | listview adopter + one typed Rust request | 3a, 3b | -Four of the eight carry risk that is *not* about Lean — stages 1, 2, 3a, -and 6 each change something every language touches. That is the +Five of the nine carry risk that is *not* about Lean — stages 1, 2, 3a, +4a, and 6 each change something every language touches. That is the organizing principle of the split: **no PR in this arc mixes a cross-cutting substrate change with Lean feature content.** A reviewer looking at Stage 2 sees only `ensure_server`; a reviewer looking at Stage @@ -1436,6 +1758,16 @@ Lean-only. One generalization shipped as Stage 2; extracting the other as 3a is what makes the claim true again. The rule is only worth writing down if it survives contact with a stage that is inconvenient to split. +Round 5 found the *same* rule broken again, by Stage 4, whose risk column +read "refactors `pair.lua`'s provenance read" — every language's +auto-pairing — for a stage described as the Lean input method. Rev 5 had +noticed the shape and answered it with a commit boundary; a commit +boundary is not a review boundary. Twice in two re-scouts is the +interesting part: **this rule is not self-enforcing, and a stage only +looks Lean-only until someone re-reads its own risk column.** Every +remaining stage should be re-checked against it at scout time, not +assumed. + Ordering notes: - **Stage 2 has no Lean in it and could ship independently of this arc.** @@ -1453,11 +1785,21 @@ Ordering notes: `builtin/runtime/lsp.lua`. Unlike stages 1 and 2, this pair is strictly sequential — recorded here, per the #126/#127 lesson, rather than discovered in a rebase. -- **Stage 4 does not depend on stages 2, 3a, or 3b** and could run in - parallel, but should not: both touch `lsp.lua`/`pair.lua`-adjacent - runtime files, and the #126/#127 lesson is that parallel-safety - requires the file split be agreed *before* either lane starts. - Sequential is cheaper. +- **Stage 4a depends on nothing in this arc** — not even Stage 1. It is + a pure runtime-substrate change whose only content is `pair.lua` and a + new module beside it, and it would be worth landing if the Lean arc + were abandoned tomorrow, because "the typed-edit record has exactly + one consumer forever" is not a property anyone chose. +- **4a and 4b cannot run as sibling worktrees**, for the 3a/3b reason: + 4b's consumer is written against the registration API 4a adds. Strictly + sequential, recorded before either starts. +- **Stage 4b depends on stages 1 and 4a and on nothing else** — not on + 2, 3a, or 3b. The input method is useful with no language server at + all, which is the honest ordering argument for putting it this early: + a user with no Lean toolchain installed still gets a Lean editor that + can type Lean. It could run in parallel with the 5/6/7 lane, but + should not, per the #126/#127 lesson that parallel-safety requires the + file split be agreed *before* either lane starts. - **Stage 6 depends on Stage 5** only for the read-only generated-buffer and panel machinery, which Stage 5 establishes. If Stage 5 slips, Stage 6 can carry that machinery itself at the cost of duplicating it. @@ -1496,7 +1838,22 @@ Stated so they can be scored, per house style. inside `buffer.after-edit` re-enters the hook in a way pairing does not already survive. Confidence: medium — pairing does the same thing, but over a single codepoint rather than a multi-byte span. -6. **These eight stages reach rough VS Code parity for everything except +5a. **Lazy abandonment is good enough without a cursor-motion hook** + (rev 6, Q#LN22). Falsified if a user in normal editing hits a case + where stale pending state produces a *wrong* expansion rather than a + dropped one — the failure mode this design chooses. Confidence: + medium-high, because every path that can invalidate the state either + goes through `buffer.after-edit` (where it is checked) or through + `buffer.after-switch` (where it is cleared), and the residual is a + cursor move with no intervening edit, which the next typed edit + catches by position. If it fails, the fix is a cursor-motion hook — + substrate work with its own framing, not a patch to this stage. +5b. **Stage 4a is behavior-preserving.** Falsified by any change to + `tests/auto_pair_acceptance.rs` being needed to make it pass. + Confidence: high, and cheap to score — it is a diff-level check, not + a judgment call. This bet is stated separately from bet 5 because it + is the one a reviewer can falsify in ten seconds. +6. **These nine stages reach rough VS Code parity for everything except the interactive infoview.** Scored honestly rather than aspirationally. What lands: highlighting, goal view, Unicode input, diagnostics, hover, completion, goto-definition, symbols, semantic tokens, `#eval` @@ -1528,10 +1885,26 @@ What remains deferred: - **GPU goal band** — blocked on bottom-panel Stage 2 (Q#LN14). The panel is grid-only until then. - **A `cursor.after-move` hook** — there is none (Q#LN13), so Stage 5 - polls off `process.after-tick`. A real motion hook would serve the goal - view, `completion.lua`'s cursor-delta heuristic, and the outline/hover - panels alike; it is substrate work that should not be invented inside a - language lane. + polls off `process.after-tick` and Stage 4b abandons pending + abbreviations lazily rather than on departure (Q#LN22, round-5 finding + 3). A real motion hook would serve the goal view, the input method, + `completion.lua`'s cursor-delta heuristic, and the outline/hover panels + alike; it is substrate work that should not be invented inside a + language lane. Two consumers in this arc now want it, which is worth + recording as evidence for whoever frames it. +- **Chronological cross-peer undo arbitration** — the general fix for + Q#LN6's bracket pairs and Q#LN21's abbreviation expansions alike. + Already on the standing backlog; named again here because Stage 4b + widens the exposure from three pair characters to every abbreviation a + user types on a CRDT frontend, which changes how often the existing + defect is met without changing what it is. +- **Per-buffer optimistic-apply policy** — the narrower thing Q#LN21 + actually wanted and did not build. `set_round_trip_input` is the only + existing lever and it is too blunt (it also changes RET dispatch); a + frontend-side, language-aware round-trip character set would fix the + undo degradation for Lean without taxing every other language, and + would retire Q#AP1's limitation too. Frontend + protocol work, so + Q#LN14's no-protocol-change rule keeps it out of this arc entirely. - **LSP server reaping / LRU** — Q#LN15's per-root affinity makes unbounded `lake serve` growth possible. No editor caps this by default and pmacs will not either in this arc, but the policy question is now @@ -1758,12 +2131,44 @@ revision take letter suffixes rather than displacing anything. Round 3's finding 4 was stale cross-references surviving a renumber; not renumbering is the cheaper way to not repeat it. -**Stage 4 — the Unicode input method** +**Stage 4a — the typed-edit consumer chain** -38. `\alpha` + space yields `α`; the whole expansion is a single undo step. +Criterion 46 keeps its number and moves here — it was always the +substrate pin, filed under Stage 4 only because Stage 4 was one stage. +Per the no-renumbering rule above, round 5's additions take letter +suffixes on both sides of the split. + +46. **Provenance-refactor pin:** the full `tests/auto_pair_acceptance.rs` + suite passes **unmodified**. A suite edited to accommodate the + refactor proves nothing; the diff for 4a must show zero lines + changed in that file. +46a. The chain reads the record exactly once: with two consumers + registered, a `take_typed_edit()` from inside either observes nil, + and both consumers receive the *same* record fields. Bites against a + chain that re-takes per consumer (which would hand the second one + nil in production and pass a single-consumer test). +46b. Ordering is by declared priority, not registration order: two + consumers registered low-priority-last still run + low-priority-first. Bites against a chain that "works" only because + `include_str!` order happens to agree with intent. +46c. A claiming consumer stops the chain — a later consumer does not + run — and a non-claiming one does not. +46d. A consumer that throws is contained: the fan-out still succeeds, + the other consumers still run, and the failure reports through + `set_status`. Bites against the `all-must-succeed` contract taking + the whole fan-out down with one bad consumer (Q#LN10). +46e. **Q#AP7 ordering survives.** The existing `sighelp` fake-server + test — pairing's closer must be in the buffer before `lsp.lua` + flushes `didChange` — still holds with pairing behind the chain. + Falsified by moving the chain's registration after `lsp.lua`'s. + +**Stage 4b — the Unicode input method** + +38. `\alpha` + space yields `α`; the whole expansion is a single undo + step, and one undo restores `\alpha` rather than `\alph`. 39. `\<>` yields `⟨⟩` with the point between them, from the `$CURSOR` placeholder. -40. **Pair-collision pin (Q#LN10).** `\[[]]` yields `⟦⟧`: each `[` is +40. **Pair-collision pin (Q#LN22).** `\[[]]` yields `⟦⟧`: each `[` is claimed as an extension of the pending abbreviation, so auto-pairing never inserts a closing `]` into the pending key. Bites against an ordering where pairing runs first, and against a consumer that claims @@ -1773,15 +2178,55 @@ renumbering is the cheaper way to not repeat it. 41. `\to` yields `→` eagerly on uniqueness, with no terminator typed. 42. A prefix with no match (`\zzzz` + space) is left as literal text; no edit is made. -43. Moving the cursor out of a pending abbreviation abandons it. +43. **Lazy abandonment (Q#LN22).** Because there is no cursor-motion + hook, this asserts what pmacs can actually detect: after `\alp`, an + explicit `goto_byte` elsewhere followed by typing `h` inserts a + plain `h` and leaves the `\alp` text untouched — the pending state + is dropped, not expanded. Plus: `buffer.after-switch` clears pending + state eagerly. **Rev 5's version of this criterion was not + buildable**; recorded so the change is visible rather than silent. 44. `pmacs.config.set("lean.abbrev", false)` disables expansion; the setting is read against the typed edit's **source** buffer. 45. Expansion does not fire in a non-`lean4` buffer — including that a pending abbreviation is never opened there, so `\[` in a Rust buffer still pairs normally. -46. **Provenance-refactor pin:** the full auto-pairing acceptance suite - passes unchanged, and a bite against the pre-refactor `pair.lua` - confirms the shared-consumer commit is behavior-preserving. +45a. **Shortest-key resolution (§2.11).** `\alp` + space yields `α`, and + `\al` + space yields `∀` — from `all`, not `alpha`. The second is + the one that bites: a "longest match" or "unique match only" + implementation passes the first and fails this. +45b. **Suffix rule.** `\alp7` + space yields `α7`. Bites against an + implementation that drops unmatchable trailing characters or + abandons the whole abbreviation. +45c. **There is no terminator list.** `\+` followed by space extends + rather than terminating, because `'+ '` is a key. Bites against any + implementation with a hardcoded space/tab/RET terminator set — which + is what rev 5 specified. +45d. **`\\` yields a single `\`**, by extension-and-eager-match rather + than by treating the second `\` as a terminator. And after a + *non-empty* pending key, a second `\` does terminate and open a new + abbreviation: `\alpha\to` + space yields `α→`. +45e. **No re-arm through inserted text (§2.11).** `\setminus` + space + yields a literal `\`, and typing an ordinary letter after it inserts + that letter — the inserted backslash opens no pending abbreviation, + because the expansion is a programmatic replace that arms no record. + Bites against a future consumer that infers pending state from + buffer text instead of provenance. +45f. **Both producers, and the CI-darkness stated.** The dispatch path + is pinned by the criteria above. The optimistic CRDT producer + (round-5 finding 4) is pinned by a separate criterion driving + `handle_remote_crdt_op`, which is `#[cfg(feature = "crdt")]` and + therefore **dark in CI and dark in the required gate list**, since + that list runs `--features crdt` only for `--lib`. The PR must + either land that coverage as a `--lib` test where the gate reaches + it, or state in its description that the optimistic path was + verified only locally and name the command. Silence here is the + failure mode — a green CI would otherwise read as covering the path + most users take. +45g. **Table integrity.** The generated `lean_abbrev.lua` round-trips: + its entry count matches the header's declared count, and a spot set + of entries (`alpha`, `to`, `<>`, `+ `, `\`, `n`, `setminus`) matches + `abbreviations.json` byte-for-byte. Bites against a generator that + silently drops or mangles keys (Q#LN11). **Stage 5 — the goal view** @@ -1847,7 +2292,7 @@ renumbering is the cheaper way to not repeat it. it, with the extra success-gate §2.9 forces. - **#110 (auto-pairing)** — `take_typed_edit()` provenance, the fail-closed discipline on transformed source edits, and Q#AP1's optimistic-classifier - limitation. Stage 4 is built on all three. + limitation. Stage 4a generalizes the first; 4b is built on all three. - **#127 (config registry)** — `pmacs.config.define` and the source-buffer-resolution correction. Q#LN10's gate follows `editing.auto-pair` exactly. @@ -1895,8 +2340,8 @@ alongside sixteen other languages — deliberately *not* the typed registry, because moving one language's entry there while the other sixteen stay put would fragment the surface rather than unify it. Migrating `pmacs.lsp.config` wholesale is a config-arc concern; this lane must not -create a precedent that makes it harder. Stage 4's `lean.abbrev` gate is -where this arc does enter the registry, and Q#LN10 already commits to the +create a precedent that makes it harder. Stage 4b's `lean.abbrev` gate is +where this arc does enter the registry, and Q#LN22 already commits to the `editing.auto-pair` shape. **Background-work attribution (§9).** Three pieces of background work, @@ -1928,3 +2373,59 @@ uncapped event queue, the dropped `cfg.restart`, and — unchanged from #161 — surfacing the spawn failure itself. Each is a behavior change for languages other than Lean, and §4's rule is what keeps them out of a Lean PR. + +### 9.1 Coherence impact — stages 4a and 4b (rev 6) + +**Sections served.** §6 (interaction islands) primarily, and in the +*preventing* direction rather than the fixing one — see below. §11 +(config registry) secondarily, by adding one option in the established +shape rather than a new switch mechanism. + +**Golden journey (§2).** No step is touched by 4a. 4b improves step 4 +(editing) for Lean specifically and changes nothing for any other +language: the pending-abbreviation state exists only in `lean4` buffers. +Neither stage changes launch, open, or attach. + +**Interaction islands (§6).** **None added, and this is the load-bearing +claim of Stage 4b.** An input method is the archetypal island: a modal +state where ordinary keys mean something else, usually with its own +keymap, its own escape, and its own set of commands that only work +inside it. Stage 4b deliberately has none of those. There is no keymap, +no dispatch shadow, no mode line indicator, no command that only works +mid-abbreviation, and no key that exits. The pending state is invisible +to every other subsystem, is abandoned by ordinary editing, and its +worst failure is that the user's literal text stays literal. The +`lean.abbrev` switch is an ordinary registry boolean, not an island +toggle. + +Stage 4a's chain is the mechanism that makes that possible, and it also +retires a smaller island risk: today the only way for a second feature to +react to a typed character is to compete with `pair.lua` for a one-shot +record, and the natural workaround — inferring from buffer text — is how +input methods grow their own private state and, eventually, their own +modal surface. + +**Config registry (§11).** One option, `lean.abbrev`, in exactly the +`editing.auto-pair` shape (boolean, `mutability = "live"`, resolved +against the typed edit's source buffer). This is the arc entering the +registry as §9's earlier text predicted, and it is a genuine adoption +rather than a new surface. Stage 4a adds none. + +**Background-work attribution (§9).** Neither stage does background work. +Both are synchronous inside an existing hook fan-out; no process is +spawned, no timer armed, no request issued. There is nothing to attribute +and nothing to worsen — recorded explicitly because "none" is an answer +this section should be able to give without ambiguity. + +**Debt this revision retires.** The unowned assumption that +`take_typed_edit()` has exactly one consumer forever. That was never a +decision — it was the shape of the only caller — and every future +typed-character feature would have had to rediscover it. Stage 4a turns +an accident into an API with a stated ordering contract. + +**Debt this revision names rather than pays.** One, and it is real: +Q#LN21's cross-peer undo degradation, now covering every abbreviation +rather than three bracket pairs. The fix is chronological cross-peer undo +arbitration, already on the standing backlog and already blocking Q#LN6. +Stage 4b makes the existing gap more visible without widening the class +of defect — but "more visible" is the honest word, not "unchanged." From 1b1e5990709842706d7b1b70054dd08d0095ff47 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 10:10:27 -0400 Subject: [PATCH 42/91] feat(terminal): copy mode over retained scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of docs/terminal-config-and-copy-mode-framing.md (rev 4, approved). `M-x terminal.copy-mode`, or `C-t` in a terminal buffer — physically `C-c C-t`, since every unescaped key goes to the child — materializes the retained scrollback into an ordinary read-only, path-less buffer, with `g` to re-snapshot and `q` to return. No protocol change. Materializing is the whole design. isearch, motion, selection and the kill ring work with no new substrate because the snapshot is a rope, so SearchStore and the existing match painting apply unchanged. And "keys must not reach the child" dissolves structurally rather than being guarded: the transport arm keys on is_terminal(buffer_id), and a snapshot is not a terminal, so the arm never fires. The dispatch-shadow count stays at six and describe-key keeps telling the truth — asserted directly, since that is the observable difference between the buffer-local idiom and a shadow. One serializer, not two (Q#TC7). `copy_retained` builds a whole-range selection and hands it to `copy_selection_bytes`; a second walk would re-derive soft-wrap joining, wide-glyph continuation, cluster bytes and per-row trailing-blank trimming, and the two would drift. Four unit pins in view.rs assert exact bytes against the same projection fixtures that pin the serializer itself. Q#TC6a is implemented as two calls, and the second is the load-bearing one: an intercept guards dispatch only, and no Lua binding sets Buffer::read_only, so set_round_trip_input is what keeps a replica frontend from applying optimistically and emitting an op that would pass ensure_writable and mutate both sides. Acceptance 16 pins that UNGATED, because CI never compiles the crdt feature. Eight of nine criteria. Criterion 17's semantic-frontend end-to-end pin is deliberately absent: the optimistic apply lives only in pmacs-gpu/src/main.rs and the headless SemanticClient has no optimistic path, so a faithful test needs the real GPU binary — the a37 foundation, which CI never compiles, silently returns ok when the binary is unbuilt, and is load-sensitive. Both halves of the mechanism are pinned ungated instead (16, and 16b for the hazard); the wire-level half stays an explicit obligation of the CI crdt-coverage lane. Substrate fact found while wiring lifecycle: TerminalManager::prune REACTS to a buffer already gone from the registry rather than removing one, so a child exiting leaves both the terminal and its snapshot alive. That is why on_removed is a sound teardown hook, and why a finished command's output stays readable. Five bites, five different wrong implementations, each failing exactly one test: removing set_round_trip_input fails acceptance 16 in the DEFAULT configuration; a naive independent serializer fails all four unit pins, with the diffs naming each drift mode; making re-invoke create a fresh buffer fails 18; dropping the kill-with-terminal teardown fails 18; removing the intercept fails 16b. COHERENCE.md: §6 gains this as the worked example that a modal-looking feature need not become a shadow; §11 records the scope="global" deferral's second live case, making the argument for both registry deferrals cumulative; §2 step 8 gains copy mode and keeps the still-missing close command named. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- COHERENCE.md | 34 +- builtin/runtime/terminal.lua | 186 +++++++ docs/active-work.md | 49 +- docs/terminal-config-and-copy-mode-framing.md | 35 +- src/lua_bindings/mod.rs | 19 + src/terminal/view.rs | 143 +++++ tests/terminal_copy_mode_acceptance.rs | 516 ++++++++++++++++++ 7 files changed, 970 insertions(+), 12 deletions(-) create mode 100644 tests/terminal_copy_mode_acceptance.rs diff --git a/COHERENCE.md b/COHERENCE.md index 4e7361c..f707234 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -368,7 +368,7 @@ Full verdict table: | 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:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | | 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing PR #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). Named limitation: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there. *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.* | +| 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 | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) | | 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 | **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) | @@ -658,6 +658,25 @@ Facts that define the gap: a terminal buffer. Since #173 that chord is `terminal.escape-key` rather than a hardcoded `C-c`, so a user can *move* which prefix is eaten; they cannot make the shadow stop eating one. +- **A worked example that a modal-*looking* feature need not become a + shadow.** Terminal copy mode (Stage 2 of the terminal-config arc) is + the case that most invited a seventh rung: it wants motion, search and + its own `g`/`q` inside a surface where every unescaped key otherwise + goes to a child process. It resolves to the buffer-local keymap idiom + instead, by **materializing** the retained scrollback into an ordinary + read-only document buffer. The keys-must-not-reach-the-child problem + then dissolves structurally rather than being guarded: the transport + arm keys on `is_terminal(buffer_id)`, and a snapshot buffer is not a + terminal, so the arm never fires. No new precedence rung, no new + hand-synced guard-list entry, and `describe-key` keeps reporting the + truth — pinned by asserting exactly that for the snapshot's `g` and + `q`, which is the observable difference between the idiom and a + shadow. **The count stays at six.** + + The transferable rule: when a feature wants a keymap over *content*, + ask whether the content can become a buffer. The shadows that exist + are the cases where it genuinely cannot (a minibuffer prompt, a + live search prompt) — not the cases where nobody tried. - **No transient-keymap mechanism exists to migrate to.** `KeymapStack` has exactly three fixed scopes — `Buffer(BufferId)`, `Mode(String)`, `Global` (`src/keymap_stack.rs:37-44`); resolution order buffer → @@ -1041,6 +1060,19 @@ layering, provenance, and adoption have not followed.** scalars. It is the clearest evidence yet that table-valued settings are the blocking prerequisite: the terminal is now half-registered, and no settings UI can render the half that matters most. +- **The missing `scope = "global"` flag has its second live case.** After + `autosave.interval-ms`, the terminal's two *open-time* settings — + `terminal.default-profile` and `terminal.scrollback-rows` — are read + before their terminal's identity buffer exists, so a buffer-local + override can never be consulted. The registry accepts `set_local` on + them anyway, because `Live` mutability is all it can express. Nothing + breaks; the setting simply has no effect, which is the worst shape a + configuration surface can take. `terminal.escape-key` is the contrast + that shows this is a real distinction rather than a blanket wish: it + *deliberately* supports buffer-locals, and per-terminal escapes are a + feature. So the argument for both deferrals is now **cumulative and + concrete** rather than hypothetical — two adopters, two distinct + missing primitives, one feature. - **No persistence**: settings changed at runtime do not survive restart (the `custom-file` split-brain question is a named deferral). - The three-level separation holds in principle today (registry / diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua index 143a663..b44e824 100644 --- a/builtin/runtime/terminal.lua +++ b/builtin/runtime/terminal.lua @@ -49,6 +49,18 @@ local function bind_terminal_keys(buffer) bind("C-v", "terminal.page-down") bind("M-<", "terminal.scroll-oldest") bind("M->", "terminal.scroll-bottom") + -- Q#TC8a/Q#TC9: copy mode is ADDITIVE. The live keys above are + -- unchanged; this is one more leaf beside them. `C-t` is globally + -- `edit.transpose-chars`, which is meaningless in a read-only + -- terminal buffer, and binding it buffer-locally is the scoped + -- idiom rather than a shadow — `keymap.bind`'s strictness rejects + -- binding a PREFIX of an existing sequence within a scope, not + -- cross-scope shadowing. + -- + -- Physically typed as `C-c C-t`: in a terminal every unescaped key + -- goes to the child, so terminal-local bindings are reached through + -- the escape. That also matches emacs-libvterm's own chord. + bind("C-t", "terminal.copy-mode") end -- Q#TC1: profiles are a raw Lua table, not a config setting. The @@ -190,6 +202,180 @@ pmacs.command.define { -- `C-c` is consumed as the escape. `M-x terminal` still works there. pmacs.keymap.bind { scope = "global", sequence = "C-c t", command = "terminal" } +-- === Copy mode (Stage 2, Q#TC6) ========================================= +-- +-- `terminal.copy-mode` MATERIALIZES the retained rows into an ordinary +-- read-only document buffer instead of adding a modal state to the +-- terminal. That choice is the whole design: +-- +-- * isearch, motion, selection, `M-w` and the kill ring all work with no +-- new substrate — the snapshot is a rope, so `SearchStore` and the +-- existing match painting apply unchanged; +-- * "keys must not reach the child" dissolves structurally rather than +-- being guarded: the transport arm keys on `is_terminal(buffer)`, and +-- a snapshot buffer is not a terminal, so it never fires; +-- * the dispatch-shadow count stays at SIX (`COHERENCE.md` §6) and +-- `describe-key` keeps telling the truth, because the bindings are +-- buffer-local and inspectable. + +local raw_copy_retained = assert(terminal._copy_retained, + "pmacs.terminal._copy_retained is required") + +-- snapshot buffer name -> { terminal = , buffer = } +-- +-- Keyed by NAME, not by buffer handle: handles are not stable table keys, +-- and a name survives the user killing the snapshot (listview precedent). +local snapshots = {} + +local function buffer_name(buf) + local ok, described = pcall(pmacs.describe.buffer, buf) + if ok and described then return described.name end + return nil +end + +local function find_buffer_by_name(name) + for _, id in ipairs(pmacs.buffer.list()) do + local ok, described = pcall(pmacs.describe.buffer, id) + if ok and described and described.name == name then return id end + end + return nil +end + +-- `*terminal:bash*` -> `*terminal-copy: terminal:bash*`. The surrounding +-- asterisks are stripped before nesting so the result reads as one +-- generated-buffer name rather than two. +local function snapshot_name_for(term_buf) + local name = buffer_name(term_buf) or "terminal" + return string.format("*terminal-copy: %s*", (name:gsub("^%*", ""):gsub("%*$", ""))) +end + +-- Q#TC7: the snapshot text comes from the SAME serializer selection-copy +-- uses, so soft wraps, wide glyphs, clusters and trailing blanks cannot +-- drift between the two. +local function render_snapshot(record) + local text = raw_copy_retained(record.terminal) or "" + local buf = record.buffer + local len = buf:len() + -- Snapshot writes bypass the read-only intercept; everything else is + -- rejected by it. + if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end + if #text > 0 then buf:insert(0, text, { bypass_intercept = true }) end +end + +local function ensure_snapshot(term_buf) + local name = snapshot_name_for(term_buf) + local record = snapshots[name] + if record and record.buffer:is_valid() then + -- Q#TC8: re-invoking refreshes IN PLACE. Retarget the terminal too, + -- in case a terminal buffer was recreated under the same name. + record.terminal = term_buf + return record + end + + local buf = find_buffer_by_name(name) or pmacs.buffer.create(name) + record = { terminal = term_buf, buffer = buf } + snapshots[name] = record + + -- Q#TC6a — BOTH calls, and the second is the load-bearing one. + -- + -- An intercept guards the dispatch/edit path only. It does NOT set + -- `Buffer::read_only` (deliberately independent), and no Lua binding + -- sets that flag at all, so an optimistic CRDT op from a semantic + -- frontend bypasses the intercept AND passes `ensure_writable()` — + -- mutating the daemon buffer in lockstep with the mirror, with no + -- divergence to notice. `set_round_trip_input` prevents that at the + -- only point it can be prevented: `dispatch_idle_for` reports false + -- while this buffer is focused, so the frontend never applies + -- optimistically and never emits the op. It is the guard, not + -- hardening. + pmacs.buffer.add_intercept(buf, function() + error(name .. " is read-only") + end) + pmacs.buffer.set_round_trip_input(buf, true) + + pmacs.keymap.bind { scope = "buffer", buffer = buf, + sequence = "g", command = "terminal.copy-refresh" } + pmacs.keymap.bind { scope = "buffer", buffer = buf, + sequence = "q", command = "terminal.copy-quit" } + + -- Q#TC8 lifecycle, both directions. Killing the terminal takes its + -- snapshot with it; killing the snapshot alone leaves the terminal + -- running and merely forgets the record, so a later invoke rebuilds. + -- + -- `on_removed` is sound here because every user-facing kill path + -- routes through `pmacs.buffer.kill`, which fires the callbacks. The + -- terminal manager's own `prune` does not — but it never removes a + -- buffer either; it REACTS to one already gone from the registry. A + -- child exiting therefore leaves both the terminal and its snapshot + -- alive, which is what makes reading back a finished command's output + -- work at all. + pcall(pmacs.buffer.on_removed, term_buf, function() + local current = snapshots[name] + if current and current.buffer:is_valid() then + pcall(pmacs.buffer.kill, current.buffer) + end + snapshots[name] = nil + end) + pcall(pmacs.buffer.on_removed, buf, function() + snapshots[name] = nil + end) + + return record +end + +-- The snapshot record whose buffer the active window shows, or nil. +local function snapshot_for_current_buffer() + local buf = pmacs.window.buffer() + if not buf then return nil end + local name = buffer_name(buf) + if not name then return nil end + return snapshots[name] +end + +function terminal.copy_mode(term_buf) + term_buf = term_buf or pmacs.window.buffer() + assert(term_buf, "terminal.copy-mode: no active buffer") + if not terminal.is_terminal(term_buf) then + error("terminal.copy-mode: the current buffer is not a terminal", 0) + end + local record = ensure_snapshot(term_buf) + render_snapshot(record) + pmacs.window.switch_buffer(record.buffer) + return record.buffer +end + +pmacs.command.define { + name = "terminal.copy-mode", + description = "Open a searchable read-only snapshot of this terminal's scrollback.", + fn = function() return terminal.copy_mode() end, +} + +pmacs.command.define { + name = "terminal.copy-refresh", + description = "Re-snapshot the source terminal into this copy buffer.", + fn = function() + local record = snapshot_for_current_buffer() + if not record then return end + if not record.terminal:is_valid() then + pmacs.editor.set_status("terminal.copy-refresh: the source terminal is gone") + return + end + render_snapshot(record) + end, +} + +pmacs.command.define { + name = "terminal.copy-quit", + description = "Return to the terminal this copy buffer was taken from.", + fn = function() + local record = snapshot_for_current_buffer() + if not record then return end + if record.terminal:is_valid() then + pmacs.window.switch_buffer(record.terminal) + end + end, +} + pmacs.command.define { name = "terminal.copy-selection", description = "Copy the active terminal selection.", diff --git a/docs/active-work.md b/docs/active-work.md index 88e70df..bfdb7ff 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -389,19 +389,52 @@ If it does not, stop and repair the remote/fetch configuration. **isolated-config workspace sweep 3,177 across 92 suites, zero failures**; `git diff --check` clean. Gates were run against the committed tree. -## Terminal config + copy mode arc — Stage 1 IN REVIEW +## Terminal config + copy mode arc — Stage 1 MERGED; Stage 2 IN REVIEW - Approved framing: `docs/terminal-config-and-copy-mode-framing.md` **revision 4** (four review rounds), committed as the first commit of Stage 1's branch. Two stages, two branches, two PRs; **no protocol change**. -- **Stage 1 = `githubsucks/terminal-config`**, worktree - `../pmacs-terminal-config`, based on `githubsucks/main` @ `d152120` - and merged up to `c93f9ee` during review round 1. Profiles, - scrollback, escape key, and the `C-c t` opening binding. -- **Stage 2 = `terminal-copy-mode`, not started.** Branch it off `main` - after Stage 1 merges: no dependency, but both edit - `builtin/runtime/terminal.lua`. +- **Stage 1 MERGED as #173** (`main` @ `cf54270`, 2026-07-26, one review + round, twelve checks green). Branch `githubsucks/terminal-config` and + worktree `../pmacs-terminal-config` retained. +- **Stage 2 = `githubsucks/terminal-copy-mode`**, worktree + `../pmacs-terminal-copy-mode`, based on `githubsucks/main` @ + `cf54270`. Copy mode: `M-x terminal.copy-mode` / `C-c C-t`. +- **Stage 2 ships eight of nine criteria, and the missing one is named.** + Criterion 17 (a real semantic frontend proving neither daemon buffer + nor mirror mutates) is **not pinned**: the optimistic apply exists only + in `pmacs-gpu/src/main.rs`, and the headless `SemanticClient` every + other semantic test uses has no optimistic path, so a faithful test + must drive the real GPU binary — the `a37` foundation, which CI never + compiles, silently skips without the binary, and is load-sensitive. A + second test on that footing buys the appearance of coverage. Both + halves of the mechanism are pinned **ungated** instead: acceptance 16 + (the guard is armed — `dispatch_idle` false while the snapshot is + focused) and 16b (the hazard is real — the snapshot's `is_read_only()` + is **false** despite the intercept, so nothing at the rope/CRDT + boundary would stop an op that did arrive). The wire-level half is an + explicit obligation of the CI `crdt`-coverage lane. +- Load-bearing Stage 2 decisions: + - **The snapshot MATERIALIZES into an ordinary buffer**, so isearch, + motion, selection and the kill ring work with no new substrate, and + "keys must not reach the child" dissolves structurally — the + transport arm keys on `is_terminal(buffer_id)` and a snapshot is not + a terminal. **The dispatch-shadow count stays at six.** + - **One serializer, not two** (Q#TC7): `copy_retained` builds a + whole-range *selection* and hands it to `copy_selection_bytes`. + - **`prune` reacts to removal rather than causing it** — it filters on + `!registry.contains(buffer_id)`, so a child exiting does NOT remove + the terminal buffer. That is why `on_removed` is a sound teardown + hook, and why a finished command's output stays readable. +- **Five bites, five different wrong implementations.** Removing + `set_round_trip_input` fails acceptance 16 **in the default + configuration** (the whole reason that pin is ungated); a naive + independently-written serializer fails all four unit pins, with the + diffs naming each drift mode (broken soft wrap, untrimmed blanks, + trailing newline); making re-invoke create a fresh buffer fails 18; + dropping the kill-with-terminal teardown fails 18; removing the + intercept fails 16b. Each failed exactly one test. - Load-bearing decisions, each forced by scouted ground truth: - profiles are a **raw Lua table** — `ConfigValue` is four scalars with no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`; diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md index 48b75d8..68a88dd 100644 --- a/docs/terminal-config-and-copy-mode-framing.md +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -1,9 +1,16 @@ # Terminal configuration and copy mode **Revision 4 — scouted against canonical `main` @ `b889873` (protocol v20), -2026-07-25. APPROVED after four review rounds. Stage 1 is implemented on -branch `terminal-config` (PR #173); Stage 2 (`terminal-copy-mode`) is -framed but not started, and branches off `main` after Stage 1 merges.** +2026-07-25. APPROVED after four review rounds. Stage 1 MERGED as #173 +(`main` @ `cf54270`, 2026-07-26). Stage 2 implemented on branch +`terminal-copy-mode` off `main` @ `cf54270`; no protocol change.** + +**Stage 2 ships eight of its nine criteria.** Criterion 17's semantic-frontend +end-to-end pin is deliberately absent — see the note under it — because a +faithful version requires the real `pmacs-gpu` optimistic path, and therefore +the `a37` foundation, which CI never compiles and which skips silently. Both +halves of the *mechanism* it guards are pinned ungated instead (16, 16b). No +other criterion is partial. Revision 4 gives the escape-key cache an owner and a lifecycle (Q#TC4c) — revision 3 named the key but not the storage, and two implementations @@ -576,6 +583,28 @@ additive, on its own binding, and does not replace scroll-and-select. emitted, bypasses the Lua intercept, passes `ensure_writable()`, and mutates **both sides** — a buffer the editor calls read-only silently accepts an edit. + + **NOT PINNED as specified, deliberately, and this is the one gap in + Stage 2.** A faithful test has to drive the *real* `pmacs-gpu` binary: + the optimistic apply lives only in `pmacs-gpu/src/main.rs` + (`optimistic_crdt_insert` / `optimistic_insert_text`), and the headless + `SemanticClient` the other semantic tests use has no optimistic path at + all, so it cannot produce the op whose absence is the claim. That means + building on the `a37` foundation — which is `crdt`-gated so CI never + compiles it, **returns `ok` without running** when `pmacs-gpu` is absent + from the target directory, and is load-sensitive enough to pass and fail + at the same commit twenty minutes apart. A second test on that footing + would add the appearance of coverage without the substance. + + What IS pinned instead, ungated and in CI: acceptance 16 asserts the + guard is armed (`dispatch_idle` false while the snapshot is focused, so + no replica can apply optimistically or emit), and acceptance 16b asserts + the hazard is real by showing the snapshot buffer's `is_read_only()` is + **false** despite the intercept — i.e. nothing at the rope/CRDT boundary + would stop such an op if one arrived. Together those cover both halves of + Q#TC6a's *mechanism*. What remains unproven is only the end-to-end wire + behaviour of a real GPU frontend, and it stays an explicit obligation of + the CI `crdt`-coverage lane rather than being quietly dropped. 18. Re-invoking against the same terminal refreshes in place; the buffer count does not grow (Q#TC8). Killing the snapshot leaves the terminal running; killing the terminal removes the snapshot. diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 1e7ac04..37a1307 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8836,6 +8836,25 @@ fn install_terminal( )?; } + { + let manager = manager.clone(); + terminal.set( + "_copy_retained", + // Q#TC7: returns the whole retained range as a string, through + // the same serializer selection-copy uses. Takes an explicit + // buffer rather than resolving the active view, because copy + // mode reads a terminal that may not be displayed — and + // because the caller already holds the handle it keyed its + // snapshot on. + lua.create_function(move |lua, buffer: BufferIdLua| { + let Some(bytes) = manager.borrow().copy_retained(buffer.0) else { + return Ok(None); + }; + Ok(Some(lua.create_string(&bytes)?)) + })?, + )?; + } + pmacs.set("terminal", terminal) } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1c0957d..b787dee 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -329,6 +329,29 @@ impl TerminalManager { copy_selection_bytes(&rows, selection) } + /// Serialize a session's ENTIRE retained range — scrollback plus the + /// visible screen — through the same path [`copy_selection`] uses. + /// + /// Q#TC7. This deliberately builds a whole-range *selection* and hands + /// it to the existing serializer rather than walking the rows itself. + /// Soft-wrap joining, wide-glyph continuation, cluster bytes, and + /// per-row trailing-blank trimming are Vterm Stage 2 criterion 21's + /// pinned behavior; a second walk would re-derive all four and the two + /// would drift. That inheritance is what acceptance 13 asserts, by + /// comparing this against a full-range `copy_selection` rather than + /// against a literal. + /// + /// Returns `None` for a non-terminal buffer and for a session whose + /// retained rows are all empty — there is no cell to anchor to. + /// Unlike `copy_selection` this needs no registered view, so copy mode + /// does not depend on the terminal being currently displayed. + #[must_use] + pub fn copy_retained(&self, buffer_id: BufferId) -> Option> { + let session = self.sessions.get(&buffer_id)?; + let projection = session.screen.projection_ref(); + retained_bytes(&retained_rows(projection)) + } + /// Start an editor-owned primary selection at a viewport coordinate. pub fn begin_selection( &mut self, @@ -540,6 +563,40 @@ fn retained_rows(projection: BorrowedScreenProjection<'_>) -> RetainedRows<'_> { RetainedRows { projection } } +/// Serialize every retained cell, through the selection-copy serializer. +/// +/// Split out from [`TerminalManager::copy_retained`] so the fidelity +/// claims — soft-wrap joining, per-row trailing-blank trimming, wide-glyph +/// continuation, cluster bytes — are testable against the same projection +/// fixtures that pin `copy_selection_bytes` itself. Those four are exactly +/// what a second, independently written walk would get wrong. +fn retained_bytes(rows: &RetainedRows<'_>) -> Option> { + copy_selection_bytes(rows, full_retained_selection(rows)?) +} + +/// The selection spanning every retained cell. +/// +/// Rows with no cells are skipped at both ends rather than clamped: an +/// anchor into a zero-width row cannot resolve (`resolve_anchor` requires +/// `cell_offset` to fall inside `cell_offset .. cell_offset + len`), so +/// including one would make the whole range unresolvable and silently +/// yield nothing. Interior empty rows are untouched, because trailing- and +/// interior-blank handling belongs to the serializer. +fn full_retained_selection(rows: &RetainedRows<'_>) -> Option { + let mut occupied = rows.iter().filter(|row| !row.cells.is_empty()); + let first = occupied.next()?; + // `RetainedRows::iter` is a chain of slice iterators exposed as + // `impl Iterator`, so it is not double-ended; scan forward. + let last = occupied.last().unwrap_or(first); + Some(TerminalSelection { + anchor: row_lead(first), + head: LogicalCellAnchor { + logical_line_id: last.logical_line_id, + cell_offset: last.cell_offset.saturating_add(last.cells.len() as u32 - 1), + }, + }) +} + fn row_lead(row: &TerminalRow) -> LogicalCellAnchor { LogicalCellAnchor { logical_line_id: row.logical_line_id, @@ -1001,6 +1058,92 @@ mod tests { assert_eq!(bytes, b"abcd\ne"); } + /// Stage 2 criteria 13 and 14. Every property here is one a second, + /// independently written whole-range walk would get wrong: a naive + /// walk emits a newline per physical row (breaking the soft wrap), + /// keeps trailing default blanks, and has to rediscover that history + /// precedes the visible screen. Asserting exact bytes is what makes + /// "it reuses the serializer" falsifiable. + #[test] + fn retained_copy_spans_history_joins_soft_wraps_and_trims_blanks() { + let source = projection( + vec![row(1, 0, "ab ", true), row(1, 3, "cd ", false)], + vec![row(2, 0, "e ", false), row(3, 0, " ", false)], + ); + let retained = retained_rows(source.as_borrowed()); + let bytes = retained_bytes(&retained).expect("whole range resolves"); + // `ab`+`cd` joined across the soft wrap; `e` on its own hard row; + // the all-blank final row trimmed to nothing but still separated. + assert_eq!(bytes, b"abcd\ne\n"); + } + + /// The whole-range selection must not depend on a view existing, and + /// must agree with an explicit full-span selection through the public + /// serializer — the anti-drift half of criterion 13. + #[test] + fn retained_copy_agrees_with_an_explicit_full_span_selection() { + let source = projection( + vec![row(1, 0, "aaa", false)], + vec![row(2, 0, "bbb", false), row(3, 0, "ccc", false)], + ); + let retained = retained_rows(source.as_borrowed()); + let explicit = copy_selection_bytes( + &retained, + TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 3, + cell_offset: 2, + }, + }, + ) + .expect("explicit selection resolves"); + assert_eq!(retained_bytes(&retained).expect("whole range"), explicit); + assert_eq!(explicit, b"aaa\nbbb\nccc"); + } + + /// A wide glyph must be copied once across the whole range too, not + /// once per cell it occupies. + #[test] + fn retained_copy_emits_a_wide_glyph_once() { + let wide = TerminalRow { + cells: vec![ + Cell { + glyph: Glyph::Char('界'), + style: Style::default(), + attachment: None, + }, + Cell { + glyph: Glyph::Continuation, + style: Style::default(), + attachment: None, + }, + Cell::default(), + ], + logical_line_id: 9, + cell_offset: 0, + soft_wrapped: false, + }; + let source = projection(Vec::new(), vec![wide]); + let retained = retained_rows(source.as_borrowed()); + assert_eq!( + retained_bytes(&retained).expect("whole range"), + "界".as_bytes() + ); + } + + /// A session with nothing retained yields `None` rather than an empty + /// string, so the caller can tell "no terminal" from "empty terminal". + #[test] + fn retained_copy_of_zero_width_rows_is_none() { + let source = projection(Vec::new(), vec![row(1, 0, "", false)]); + let retained = retained_rows(source.as_borrowed()); + assert!(retained_bytes(&retained).is_none()); + } + #[test] fn wide_continuation_canonicalizes_to_lead_and_copies_once() { let wide = TerminalRow { diff --git a/tests/terminal_copy_mode_acceptance.rs b/tests/terminal_copy_mode_acceptance.rs new file mode 100644 index 0000000..5b62ad8 --- /dev/null +++ b/tests/terminal_copy_mode_acceptance.rs @@ -0,0 +1,516 @@ +//! Terminal copy-mode acceptance (Stage 2 of +//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 13-21). +//! +//! **Deliberately NOT `#[cfg(feature = "crdt")]`.** CI never enables that +//! feature, so a gated suite is written and then never run — 264 tests are +//! dark workspace-wide for exactly that reason. Criterion 16, the +//! round-trip gate Q#TC6a's entire safety argument rests on, needs no CRDT +//! and must be caught by the default configuration. + +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use mlua::Value; +use pmacs::cell::{CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::terminal::TerminalViewKey; + +const SNAPSHOT_NAME: &str = "*terminal-copy: terminal:sh*"; + +fn exec(state: &EditorState, src: &str) { + state + .lua_host + .lua() + .load(src) + .exec() + .unwrap_or_else(|e| panic!("lua failed: {src}\n{e}")); +} + +fn eval(state: &EditorState, src: &str) -> T { + state + .lua_host + .lua() + .load(src) + .eval() + .unwrap_or_else(|e| panic!("lua eval failed: {src}\n{e}")) +} + +fn eval_err(state: &EditorState, src: &str) -> String { + let result: mlua::Result = state.lua_host.lua().load(src).eval(); + match result { + Ok(_) => panic!("expected an error from: {src}"), + Err(e) => e.to_string(), + } +} + +fn press(state: &mut EditorState, code: KeyCode, mods: KeyModifiers) { + state.dispatch_key(FrontendId::LOCAL, KeyEvent::new(code, mods)); +} + +/// The live terminal screen's text, used only to wait for the child. +fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String { + let manager = state.terminal_manager.borrow(); + let Some(snapshot) = manager.snapshot(buffer) else { + return String::new(); + }; + let mut text = String::new(); + for cell in &snapshot.cells { + match &cell.glyph { + Glyph::Char(c) => text.push(*c), + Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)), + Glyph::Continuation => {} + } + } + text +} + +fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + state.tick_processes(); + if screen_text(state, buffer).contains(needle) { + return true; + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(20)); + } +} + +fn terminal_buffers(state: &EditorState) -> Vec { + let manager = state.terminal_manager.borrow(); + state + .core + .borrow() + .registry + .borrow() + .ids() + .iter() + .copied() + .filter(|id| manager.is_terminal(*id)) + .collect() +} + +/// A child that overflows the 24-row screen and then goes quiet, so its +/// early lines exist ONLY in scrollback — which is what makes criterion +/// 15's "content only in scrollback" claim meaningful. +const FILL_PROFILE: &str = r#" +pmacs.terminal.profiles.fill = { + command = "/bin/sh", + args = { "-c", + "printf 'NEEDLE-IN-SCROLLBACK\r\n'; i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" }, +} +"#; + +/// Open the fill terminal, wait for the child to finish, and return its id. +fn open_fill_terminal(state: &mut EditorState) -> pmacs::buffer::BufferId { + exec(state, FILL_PROFILE); + let before = terminal_buffers(state); + exec( + state, + r#"TERM_BUF = pmacs.terminal.open { profile = "fill" }"#, + ); + let fresh: Vec<_> = terminal_buffers(state) + .into_iter() + .filter(|id| !before.contains(id)) + .collect(); + assert_eq!(fresh.len(), 1, "exactly one terminal must have opened"); + let buffer = fresh[0]; + assert!(tick_until(state, "DONE", buffer), "the child must finish"); + buffer +} + +/// Give LOCAL a window on the terminal and register/claim its view, which +/// is what makes `dispatch_key`'s terminal transport arm reachable. +fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) { + state.core.borrow_mut().switch_active_buffer(buffer).ok(); + let window = state.core.borrow().active_window_id(); + let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer); + let mut manager = state.terminal_manager.borrow_mut(); + manager.register_view(key); + manager.claim_controller(key); + let _ = manager.snapshot_for_view(key, CellSize::new(10, 40)); +} + +fn buffer_text_by_name(state: &EditorState, name: &str) -> Option { + eval( + state, + &format!( + r" + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == {name:?} then + return id:slice(0, id:len()) + end + end + return nil + " + ), + ) +} + +fn active_buffer_name(state: &EditorState) -> String { + eval( + state, + r"local b = pmacs.window.buffer(); return (pmacs.describe.buffer(b)).name", + ) +} + +fn buffer_count(state: &EditorState) -> usize { + state.core.borrow().registry.borrow().ids().len() +} + +/// Acceptance 13: the snapshot's text is exactly the whole retained range +/// as the existing copy path serializes it. +/// +/// Compared against `_copy_retained` rather than a literal, so this cannot +/// pass by both sides drifting the same way; the exact-bytes fidelity +/// claims (criterion 14) are pinned at the unit level in +/// `src/terminal/view.rs`, against the same projection fixtures that pin +/// `copy_selection_bytes` itself. +#[test] +fn acc13_snapshot_is_the_whole_retained_range_through_the_shared_serializer() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + + exec(&state, "SNAP = pmacs.terminal.copy_mode(TERM_BUF)"); + let snapshot_text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot buffer exists"); + let serialized: String = eval( + &state, + r"return pmacs.terminal._copy_retained(TERM_BUF) or ''", + ); + + assert_eq!( + snapshot_text, serialized, + "the snapshot must be byte-identical to the shared serializer's output" + ); + assert!( + snapshot_text.contains("NEEDLE-IN-SCROLLBACK") && snapshot_text.contains("LINE200"), + "the range must span scrollback AND the visible screen" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 14 (end-to-end half): the snapshot really is a rope-backed +/// document buffer and not a terminal, which is what makes every +/// buffer-shaped consumer work and what removes the transport arm. +#[test] +fn acc14_the_snapshot_is_an_ordinary_non_terminal_buffer() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let is_terminal: bool = eval( + &state, + r"local b = pmacs.window.buffer(); return pmacs.terminal.is_terminal(b)", + ); + assert!( + !is_terminal, + "the snapshot must NOT be a terminal — that is what structurally \ + removes the transport arm rather than guarding it" + ); + assert_eq!(active_buffer_name(&state), SNAPSHOT_NAME); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 15: isearch finds content that exists ONLY in scrollback, +/// with no change to `src/search.rs` (B1). +#[test] +fn acc15_isearch_finds_content_only_in_scrollback() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + + // The needle is off the visible screen: the live terminal cannot see it. + assert!( + !screen_text(&state, terminal).contains("NEEDLE-IN-SCROLLBACK"), + "precondition: the needle must have scrolled off the live screen" + ); + + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + state.core.borrow_mut().set_cursor_byte(0); + + // Drive real isearch: C-s then the needle. + press(&mut state, KeyCode::Char('s'), KeyModifiers::CONTROL); + for ch in "NEEDLE-IN-SCROLLBACK".chars() { + press(&mut state, KeyCode::Char(ch), KeyModifiers::NONE); + } + let cursor = state.core.borrow().cursor(); + press(&mut state, KeyCode::Enter, KeyModifiers::NONE); + + let text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); + let expected = text + .find("NEEDLE-IN-SCROLLBACK") + .expect("the needle is in the snapshot") as u64; + assert_eq!( + cursor, + expected, + "isearch must land on the scrollback-only match; text was {:?}", + &text[..text.len().min(80)] + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 16 — the load-bearing pin, and the reason this suite is +/// ungated. `set_round_trip_input` is the ONLY thing standing between a +/// replica frontend and unauthorized mutation (Q#TC6a), so its regression +/// must be caught in the configuration CI actually compiles. +#[test] +fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + assert!( + !state.dispatch_idle(), + "a focused snapshot must round-trip keys, so no replica applies \ + optimistically and none emits a CRDT op" + ); + // ...and it is the SNAPSHOT that does it, not merely "some terminal + // buffer is around": switching to an ordinary buffer restores idle. + exec( + &state, + r#"pmacs.window.switch_buffer(pmacs.buffer.create("*plain*"))"#, + ); + assert!(state.dispatch_idle(), "an ordinary buffer is idle again"); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 16 (the other half): the intercept rejects ordinary edits, +/// and — the fact that makes round-trip load-bearing rather than defence +/// in depth — the buffer is **not** `read_only` at the rope boundary. +#[test] +fn acc16b_the_intercept_rejects_edits_but_is_not_rope_level_protection() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let before = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); + press(&mut state, KeyCode::Char('z'), KeyModifiers::NONE); + let after = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); + assert_eq!(before, after, "the read-only intercept rejects self-insert"); + + // Q#TC6a, stated as a test so the next reader does not mistake the + // intercept for real immutability: no Lua binding sets + // `Buffer::read_only`, so this buffer accepts rope/CRDT mutation and + // only the round-trip mark above keeps a replica from producing one. + let core = state.core.borrow(); + let registry = core.registry.borrow(); + let ids = registry.ids(); + let snapshot = ids + .iter() + .copied() + .find(|id| { + registry + .get(*id) + .is_ok_and(|buf| buf.name() == SNAPSHOT_NAME) + }) + .expect("snapshot buffer id"); + assert!( + !registry + .get(snapshot) + .expect("snapshot buffer") + .is_read_only(), + "the Lua intercept does NOT set Buffer::read_only — this is why \ + set_round_trip_input is the guard and not hardening" + ); + drop(registry); + drop(core); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 18: re-invoking refreshes in place, and the lifecycle runs +/// both directions. +#[test] +fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + let count_after_first = buffer_count(&state); + + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + assert_eq!( + buffer_count(&state), + count_after_first, + "re-invoking must refresh in place, not accumulate buffers" + ); + + // Killing the snapshot alone leaves the terminal running. + exec( + &state, + &format!( + r" + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == {SNAPSHOT_NAME:?} then pmacs.buffer.kill(id) end + end + " + ), + ); + assert!( + state.terminal_manager.borrow().is_terminal(terminal), + "killing the snapshot must leave the terminal untouched" + ); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(), + "the snapshot buffer is gone" + ); + + // ...and it can be rebuilt afterwards. + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME).is_some(), + "a later invoke rebuilds the snapshot" + ); + + // Killing the terminal takes its snapshot with it. + exec(&state, "pmacs.terminal.terminate(TERM_BUF)"); + exec(&state, "pmacs.buffer.kill(TERM_BUF)"); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(), + "killing the terminal must remove its snapshot" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 19: `C-t` in a terminal — physically `C-c C-t`, because every +/// unescaped key goes to the child — enters copy mode; `g` refreshes and +/// `q` returns to the source terminal. +#[test] +fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + let terminal_name = active_buffer_name(&state); + + // The escape, then the terminal-local binding. + press(&mut state, KeyCode::Char('c'), KeyModifiers::CONTROL); + press(&mut state, KeyCode::Char('t'), KeyModifiers::CONTROL); + assert_eq!( + active_buffer_name(&state), + SNAPSHOT_NAME, + "C-c C-t must enter copy mode" + ); + + // `g` re-snapshots in place. + let before = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); + press(&mut state, KeyCode::Char('g'), KeyModifiers::NONE); + let after = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); + assert_eq!(before, after, "a quiet terminal re-snapshots identically"); + assert_eq!( + active_buffer_name(&state), + SNAPSHOT_NAME, + "g must not move us" + ); + + // `q` returns to the source terminal. + press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE); + assert_eq!( + active_buffer_name(&state), + terminal_name, + "q must return to the terminal the snapshot was taken from" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 20: copy mode is additive — the live terminal's own keys are +/// unchanged while a snapshot exists, and the terminal still follows its +/// tail. +#[test] +fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + // Back to the terminal; its five live bindings must still resolve. + exec(&state, "pmacs.window.switch_buffer(TERM_BUF)"); + for (sequence, command) in [ + ("M-w", "terminal.copy-selection"), + ("M-v", "terminal.page-up"), + ("C-v", "terminal.page-down"), + ("M-<", "terminal.scroll-oldest"), + ("M->", "terminal.scroll-bottom"), + ] { + let resolved: Option = eval( + &state, + &format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"), + ); + assert_eq!( + resolved.as_deref(), + Some(command), + "{sequence} must still be the live terminal binding" + ); + } + + // The terminal is still following its tail: the child's last output is + // visible without scrolling. + assert!( + screen_text(&state, terminal).contains("DONE"), + "the live terminal keeps following its tail" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 21: the dispatch-shadow count is unchanged at six, pinned by +/// the observable difference between a buffer-local keymap and a shadow — +/// `describe-key` telling the truth about `g` and `q` in the snapshot. +/// +/// A seventh shadow would decode these keys before `KeymapStack::resolve` +/// ever ran, so introspection would report whatever the global binding is +/// (or nothing) while the keys behaved differently. +#[test] +fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + for (sequence, command) in [("g", "terminal.copy-refresh"), ("q", "terminal.copy-quit")] { + let resolved: Option = eval( + &state, + &format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"), + ); + assert_eq!( + resolved.as_deref(), + Some(command), + "describe-key must report the buffer-local {sequence} binding" + ); + } + + // And the binding really is scoped: back in the terminal, `q` is not + // the copy-mode command. + exec(&state, "pmacs.window.switch_buffer(TERM_BUF)"); + let resolved: Option = eval( + &state, + r#"local d = pmacs.describe.key("q"); return d and d.command"#, + ); + assert_ne!( + resolved.as_deref(), + Some("terminal.copy-quit"), + "the snapshot's q must not leak into the terminal buffer" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Copy mode refuses a non-terminal buffer rather than producing an empty +/// snapshot of nothing. +#[test] +fn copy_mode_refuses_a_non_terminal_buffer() { + let state = EditorState::new(); + let err = eval_err(&state, "return pmacs.terminal.copy_mode()"); + assert!( + err.contains("not a terminal"), + "the refusal must say why: {err}" + ); +} From ccdf352258ad959cf88a1bba0fc2fbd5816a74d6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 10:38:21 -0400 Subject: [PATCH 43/91] fix(panel): close Stage 2A review round 2 (2 P1, 1 P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **P1-1 — the `Invalidated` arm published the panel context on the document wire.** Real bug, and the live half of the routing defect: the semantic peer has ONE statusline slot, so emitting an authoritative-empty payload for every context replaced the document's with the panel's. Now filtered by document-window identity exactly like the `Ready` arm; a panel's own clear belongs to `PanelFrame` in 2B. Pinned by `invalidated_statusline_clears_only_the_document_not_the_panel`, which reproduces the reported shape — two targets instead of one — when the filter is removed. Honest note on the `Ready` arm: its identity selector is **defensive**, not independently falsifiable today, because the document context is captured first so "first context for my frontend" happens to pick it. Rather than leave that as a silent dependency, `the_semantic_fan_out_captures_the_document_first` pins the order and says why it matters. **P1-2 — round-1 finding 3 was not closed; four of my pins were vacuous.** All four confirmed and fixed: - The statusline consumer test discarded `render_frame`'s output. It now observes the WIRE payload from a v18 peer with a registered provider, and asserts non-emptiness so it cannot pass by emitting nothing. - The terminal test compared two NON-terminal buffers, so both routings answered `false`. The document window now holds a REAL terminal, so the routes disagree; reverting `semantic_terminal_key` fails it. - The decorations test used different buffers and an empty selection — again the same answer either way. The panel now displays the declared buffer with a non-empty selection while the document has none. - #1/#3/#21 had no discriminating pin at all. Their only production caller is `dispatcher_loop`, which no test can drive, so this extracts three named seams the loop calls — `document_buffer_to_follow`, `document_cursor_byte`, `peer_displays_buffer_as_document` — and pins each. Also newly pinned: #2 the lazy CRDT upgrade (the census's sharpest case), #7 `Viewport` aligning WITHOUT taking focus, and #9 a focused terminal panel not suppressing the document viewport. **Every one of the nine pins was falsified by revert.** Two needed a second attempt after the first bite came back green. **P2-3 — stale docs.** `StatuslineEvaluationTarget::Semantic`'s documentation described evaluating only the focused window; it now describes the document-plus-side fan-out, the capture order, the identity-selection requirement, and that `active` reports actual focus. The ledger's Stage 2A entry is corrected to five commits, 2,014 CRDT tests, and 16 acceptance tests. Two clippy findings the refactor introduced were fixed: `document_buffer_to_follow` is `crdt`-gated to match its only caller, and the `CursorByte` guard collapses into one `if`. Gates: fmt clean; workspace clippy clean; 1,832 default + 2,014 CRDT library; Stage 2A 16; Stage 1 46; statusline 8; m11_5 2; GPU initial target 14; terminal config 12; folding Stage 2 48; vterm 1/2 10 / 6; M4 121; required GPU 202; `git diff --check` clean. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 43 +-- src/daemon.rs | 343 ++++++++++++++++++++--- src/semantic_render.rs | 26 +- src/statusline.rs | 21 +- tests/bottom_panel_stage2a_acceptance.rs | 259 +++++++++++++++-- 5 files changed, 602 insertions(+), 90 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 98727e5..36a99de 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -492,27 +492,36 @@ implemented and in review.** worktree `../pmacs-bp-stage2a`, **canonical `main` @ `cf54270` integrated** (review round 1, finding 4 — the terminal-config lane #173 also changes `src/editor.rs`, so gates were rerun on the merge - result, not the old combination). Two commits: the classified census routing, then the - painter extraction + acceptance. **No protocol change; no behavior + result, not the old combination). Five commits: the classified census + routing, the painter extraction + acceptance, the lane record, then + the round-1 and round-2 review fixes. **No protocol change; no behavior change for any frontend today** — with `panel_capable = false` for semantic sessions, `primary_document_window` returns `view.active` in every existing configuration, so this is seam adoption that becomes load-bearing in 2B. -- Verification on this branch: `cargo fmt --check` clean; strict - workspace Clippy clean; **1,832 default + 2,009 CRDT** library tests; - new `bottom_panel_stage2a_acceptance` 10/10; bottom-panel Stage 1 - 46; statusline segments 7 default / 8 CRDT; m11_5 semantic 2 CRDT; - GPU initial target 14 CRDT; vterm Stage 1/2 10 / 6; folding Stage 2 - 48; M4 121; required GPU 202; `git diff --check` clean. -- **Both key routings were falsified by revert.** Rerouting - `dispatch_idle_for` (#14, Focus) through `primary_document_window` - fails `focus_class_dispatch_idle_still_tracks_the_focused_window`; - reverting the statusline lookup (#12, Projection) to `view.active` - fails the document-context test. Worth recording: the *structural* - test `focus_and_projection_disagree_in_the_same_state` did **not** - catch the first bite — it compares the two authorities directly, so - only a consumer-level assertion catches a misrouted consumer. Keep - both kinds. +- Verification on the merge result: `cargo fmt --check` clean; strict + workspace Clippy clean; **1,832 default + 2,014 CRDT** library tests; + `bottom_panel_stage2a_acceptance` **16**; bottom-panel Stage 1 46; + statusline segments 8 CRDT; m11_5 semantic 2 CRDT; GPU initial target + 14 CRDT; terminal config 12 CRDT; vterm Stage 1/2 10 / 6; folding + Stage 2 48; M4 121; required GPU 202; `git diff --check` clean. +- **Every routed producer is now pinned at a seam its production caller + uses, and each pin was falsified by revert**: #1 follow, #2 lazy CRDT + upgrade, #3 `CursorByte`, #5 decorations, #7 `Viewport` (aligns + without focusing), #8 `Pointer` (aligns and focuses), #9 the + terminal-context gate, #12 statusline, #21 the publication filter, + plus the focus-class negatives. #1/#3/#21 required extracting three + named helpers, because their only production caller is + `dispatcher_loop`, which no test can drive. +- **Three lessons about the TESTS, not the code, all from review:** + (a) a *structural* test comparing the two authorities directly does + **not** catch a misrouted consumer — only consumer-level assertions + do; (b) a daemon-path test must `register_session` or the event is + dropped at the uninstalled-session check before reaching the code + under test; (c) a discriminating fixture must make the two routings + DISAGREE — comparing two non-terminal buffers, or two windows with no + selection, yields the same answer either way and proves nothing. + Round 2 found four of my own pins vacuous by exactly these shapes. - **Review round 1 closed: 4 P1 + 2 P2, all real.** The P1s were a stale-`Pointer` focus steal (the failed-alignment arm returned the window, so #8's activation focused it before `dispatch_pointer` diff --git a/src/daemon.rs b/src/daemon.rs index 606839d..63f6b87 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1145,14 +1145,7 @@ fn dispatcher_loop( .session_state(*fid) .is_some_and(|s| s.negotiated_capabilities.semantic_render) { - // Bottom-panel §1.3 #1 — Projection. The buffer this - // frontend DISPLAYS AS ITS DOCUMENT, not the one it - // happens to focus: focusing a panel must re-send no - // snapshot and must never swap the replica's mirror. - let active_now = { - let core = editor.core.borrow(); - core.primary_document_buffer(*fid) - }; + let active_now = document_buffer_to_follow(editor, *fid); if let Some(active_now) = active_now && last_active_buffer_sent.get(fid) != Some(&active_now) { @@ -1433,25 +1426,15 @@ fn dispatcher_loop( && session_registry .session_state(*fid) .is_some_and(|s| s.negotiated_capabilities.crdt_replica) + && let Some((buffer_id, byte_pos)) = document_cursor_byte(editor, *fid) { - // Bottom-panel §1.3 #3 — Projection. `CursorByte` is - // the replica's authoritative DOCUMENT cursor; a - // focused panel must not retarget it at the panel - // buffer (Q#BP14's "active buffer is a - // document-surface term, not an input-focus term"). - let core = editor.core.borrow(); - if let Some(window) = core - .primary_document_window(*fid) - .and_then(|win_id| core.windows.get(&win_id)) - { - let cursor_byte_msg = InstanceMessage::CursorByte { - buffer_id: window.buffer_id, - byte_pos: window.cursor, - }; - if let Err(e) = write_message(stream, &cursor_byte_msg) { - eprintln!("pmacs: write CursorByte for {fid:?} failed: {e}"); - write_failed = true; - } + let cursor_byte_msg = InstanceMessage::CursorByte { + buffer_id, + byte_pos, + }; + if let Err(e) = write_message(stream, &cursor_byte_msg) { + eprintln!("pmacs: write CursorByte for {fid:?} failed: {e}"); + write_failed = true; } } } @@ -2524,13 +2507,7 @@ fn publish_buffer_snapshot_to_replicas( continue; } if session.negotiated_capabilities.semantic_render { - // Bottom-panel §1.3 #21 — Projection. "Displays this - // buffer" means the peer's DOCUMENT surface: testing the - // focused window would both miss a buffer visible in the - // document (panel focused elsewhere) and replace the peer's - // mirror for one visible only in a panel. - let displays_buffer = - editor.core.borrow().primary_document_buffer(*peer_id) == Some(buffer_id); + let displays_buffer = peer_displays_buffer_as_document(editor, *peer_id, buffer_id); if !displays_buffer { continue; } @@ -2969,6 +2946,53 @@ fn handle_remote_crdt_op( /// whole switch. This is the input/display alignment fix for B1: the /// frontend's *declared* buffer becomes the buffer its keys edit and /// its `CursorByte` reports. +/// The buffer a semantic frontend DISPLAYS AS ITS DOCUMENT — the +/// buffer-follow / `BufferSnapshot` re-send target (bottom-panel §1.3 +/// #1, Projection). +/// +/// Not the focused buffer: focusing a panel must re-send no snapshot and +/// must never swap the replica's document mirror. Named as its own +/// function so the rule is pinnable — its only caller is +/// `dispatcher_loop`, which no test can drive. +#[cfg(feature = "crdt")] +fn document_buffer_to_follow( + editor: &EditorState, + fid: FrontendId, +) -> Option { + editor.core.borrow().primary_document_buffer(fid) +} + +/// The `(buffer, byte)` a semantic replica's authoritative `CursorByte` +/// describes (bottom-panel §1.3 #3, Projection). +/// +/// Q#BP14's vocabulary split: "active buffer" in the replica is a +/// DOCUMENT-SURFACE term, not an input-focus term, so a focused panel +/// must not retarget the document caret at the panel's buffer. +fn document_cursor_byte( + editor: &EditorState, + fid: FrontendId, +) -> Option<(crate::buffer::BufferId, u64)> { + let core = editor.core.borrow(); + let win_id = core.primary_document_window(fid)?; + let window = core.windows.get(&win_id)?; + Some((window.buffer_id, window.cursor)) +} + +/// Whether `peer_id` displays `buffer_id` on its DOCUMENT surface — the +/// `BufferSnapshot` publication recipient filter (bottom-panel §1.3 #21, +/// Projection). +/// +/// Testing the focused window instead would both miss a buffer visible +/// in the document while a panel holds focus, and replace the peer's +/// document mirror for a buffer visible only in a panel. +fn peer_displays_buffer_as_document( + editor: &EditorState, + peer_id: FrontendId, + buffer_id: crate::buffer::BufferId, +) -> bool { + editor.core.borrow().primary_document_buffer(peer_id) == Some(buffer_id) +} + /// Align a semantic frontend's **primary document window** to the /// buffer it declared (bottom-panel §1.3 #7, Q#BP14). /// @@ -4770,4 +4794,257 @@ mod tests { "non-vacuity: the document window is a real, distinct focus target" ); } + + /// Bottom-panel §1.3 #1/#3/#21 — the three Projection producers whose + /// only production caller is `dispatcher_loop`, pinned at the named + /// seams that loop calls. Round 2 finding: reverting any of them to + /// `active_window_for` previously left every test green. + #[cfg(feature = "crdt")] + #[test] + fn tick_producers_describe_the_document_while_a_panel_is_focused() { + let (editor, fid, document, panel) = panel_focused_semantic_fixture(); + let (doc_buf, panel_buf, doc_cursor) = { + let core = editor.core.borrow(); + ( + core.windows[&document].buffer_id, + core.windows[&panel].buffer_id, + core.windows[&document].cursor, + ) + }; + assert_ne!(doc_buf, panel_buf, "fixture: distinct buffers"); + + // #1 buffer-follow / BufferSnapshot re-send target. + assert_eq!( + document_buffer_to_follow(&editor, fid), + Some(doc_buf), + "#1: the follow target must be the DOCUMENT buffer, not the focused panel's" + ); + + // #3 CursorByte. + assert_eq!( + document_cursor_byte(&editor, fid), + Some((doc_buf, doc_cursor)), + "#3: CursorByte must describe the DOCUMENT surface" + ); + + // #21 publication recipient filter, both directions. + assert!( + peer_displays_buffer_as_document(&editor, fid, doc_buf), + "#21: a buffer visible in the document must still receive publications while a panel holds focus" + ); + assert!( + !peer_displays_buffer_as_document(&editor, fid, panel_buf), + "#21: a buffer visible only in a panel must NOT replace the document mirror" + ); + } + + /// Bottom-panel §1.3 #2 — the sharpest census case: the lazy CRDT + /// upgrade BROADCASTS a snapshot, so keying it on focus would let + /// focusing a fresh generated panel buffer swap every peer's mirror. + #[cfg(feature = "crdt")] + #[test] + fn lazy_crdt_upgrade_never_targets_a_focused_panel_buffer() { + let (editor, fid, document, panel) = panel_focused_semantic_fixture(); + let (doc_buf, panel_buf) = { + let core = editor.core.borrow(); + ( + core.windows[&document].buffer_id, + core.windows[&panel].buffer_id, + ) + }; + + let upgraded = ensure_active_buffer_crdt_backed(&editor, fid); + assert_eq!( + upgraded, + Some(doc_buf), + "#2: the upgrade must target the DOCUMENT buffer" + ); + assert_ne!( + upgraded, + Some(panel_buf), + "#2: focusing a panel must never trigger its buffer's upgrade+broadcast" + ); + } + + /// Bottom-panel §1.3 #7 vs #8 — `Viewport` aligns WITHOUT moving + /// focus; only `Pointer` activates. Driven through the real + /// dispatcher seam. + #[cfg(feature = "crdt")] + #[test] + fn viewport_aligns_the_document_without_taking_focus_from_the_panel() { + let (mut editor, fid, document, panel) = panel_focused_semantic_fixture(); + let other = { + let mut core = editor.core.borrow_mut(); + core.registry.borrow_mut().create("*other*") + }; + + dispatch_one_semantic_event( + &mut editor, + fid, + FrontendEvent::Viewport { + frontend_id: fid, + buffer_id: other, + visible: pmacs_protocol::ByteRange { start: 0, end: 0 }, + generation: 0, + }, + ); + + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "#7: a document Viewport must NOT move focus out of the panel" + ); + assert_eq!( + editor.core.borrow().windows[&document].buffer_id, + other, + "#7: it must still have ALIGNED the document window to the declared buffer" + ); + } + + /// Shared fixture: a semantic frontend with a document window and a + /// FOCUSED bottom panel. `panel_capable` is set explicitly because + /// Stage 1 ships `false` for semantic sessions and 2B flips it for a + /// v21-negotiated peer. + #[cfg(feature = "crdt")] + fn panel_focused_semantic_fixture() -> ( + crate::editor::EditorState, + FrontendId, + crate::window::WindowId, + crate::window::WindowId, + ) { + use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; + + let editor = crate::editor::EditorState::new(); + let fid = FrontendId(91); + let (document, panel) = { + let mut core = editor.core.borrow_mut(); + let doc_buf = core.active_window().buffer_id; + let panel_buf = core.registry.borrow_mut().create("*panel*"); + let document = crate::window::WindowId::next(); + let panel = crate::window::WindowId::next(); + let (doc_view, panel_view) = { + let reg = core.registry.borrow(); + ( + crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")), + crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")), + ) + }; + core.windows + .insert(document, Window::new(document, doc_buf, doc_view)); + let mut panel_window = Window::new(panel, panel_buf, panel_view); + let mut params = WindowParams::default(); + params.side = Some(crate::window::Side::Bottom); + params.fixed_rows = Some(4); + panel_window.params = params; + core.windows.insert(panel, panel_window); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout { + root: LayoutNode::Split { + orientation: Orientation::Horizontal, + children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)], + weights: vec![1, 1], + }, + }, + active: panel, + fold_projection: false, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + (document, panel) + }; + editor.sync_frame_geometry(fid, CellSize::new(24, 80)); + (editor, fid, document, panel) + } + + /// Drive ONE authenticated semantic event through the real + /// dispatcher. The session must be registered or the event is + /// dropped at the uninstalled-session check before reaching any + /// handler. + #[cfg(feature = "crdt")] + fn dispatch_one_semantic_event( + editor: &mut crate::editor::EditorState, + fid: FrontendId, + event: FrontendEvent, + ) { + let mut render_states = HashMap::new(); + let mut semantic_states = HashMap::new(); + semantic_states.insert(fid, crate::semantic_render::SemanticRenderState::new(fid)); + let mut streams = HashMap::new(); + let mut term_sizes = HashMap::new(); + term_sizes.insert(fid, CellSize::new(24, 80)); + let mut last_idle = HashMap::new(); + let mut last_active = HashMap::new(); + let mut bells = HashMap::new(); + let mut registry = SessionRegistry::new(); + registry.register_session( + fid, + crate::presence::SessionState { + negotiated_protocol_version: pmacs_protocol::PROTOCOL_VERSION, + negotiated_capabilities: crate::protocol::NegotiatedCapabilities { + semantic_render: true, + crdt_replica: true, + ..Default::default() + }, + color_slot: 0, + }, + ); + handle_dispatcher_event( + DispatcherEvent::FrontendEvent { source: fid, event }, + editor, + &mut render_states, + &mut semantic_states, + &mut streams, + &mut term_sizes, + &mut last_idle, + &mut last_active, + &mut bells, + &mut registry, + ); + } + + /// Bottom-panel §1.3 #9 — Projection. The `Viewport` terminal-context + /// gate asks "is this frontend's DOCUMENT surface a terminal", so a + /// focused TERMINAL PANEL must not suppress the still-visible + /// document's viewport. + #[cfg(feature = "crdt")] + #[test] + fn a_focused_terminal_panel_does_not_suppress_the_document_viewport() { + use crate::terminal::TerminalSpec; + + let (mut editor, fid, document, panel) = panel_focused_semantic_fixture(); + let other = editor.core.borrow().registry.borrow_mut().create("*other*"); + + // A REAL terminal in the focused panel. + let mut spec = TerminalSpec::new("/bin/sh"); + spec.rows = 10; + spec.cols = 40; + let term_buf = editor.open_terminal(spec).expect("a real terminal"); + editor + .core + .borrow_mut() + .install_buffer_in_window(panel, term_buf) + .expect("terminal into the panel"); + editor.core.borrow_mut().focus_window(fid, panel); + + dispatch_one_semantic_event( + &mut editor, + fid, + FrontendEvent::Viewport { + frontend_id: fid, + buffer_id: other, + visible: pmacs_protocol::ByteRange { start: 0, end: 0 }, + generation: 0, + }, + ); + + assert_eq!( + editor.core.borrow().windows[&document].buffer_id, + other, + "#9: a focused TERMINAL panel must not suppress the document viewport — the document window should still have aligned to the declared buffer" + ); + } } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index b63efcf..af08741 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -985,10 +985,18 @@ impl SemanticRenderState { StatuslineEvaluationOutcome::Invalidated { authoritative_empty, } => { - for context in authoritative_empty - .into_iter() - .filter(|context| context.frontend_id == frontend_id) - { + // Bottom-panel A2A-2: the clear must be filtered by + // DOCUMENT WINDOW exactly like the Ready arm. The + // semantic peer has ONE statusline slot, so publishing + // the panel context's clear here replaces the document's + // payload with the panel's — the same misrouting the + // Ready arm was fixed for, on the clear path. + // + // A panel's own clear belongs to the future panel + // painter (`PanelFrame`, Stage 2B), not to this wire. + for context in authoritative_empty.into_iter().filter(|context| { + context.frontend_id == frontend_id && Some(context.window_id) == document_window + }) { self.emit_statusline_payload(context.buffer_id, Vec::new(), Vec::new(), out); } } @@ -2949,11 +2957,15 @@ mod tests { "stale evaluation retains the prior baseline until snapshot reset" ); + // Bottom-panel A2A-2: the clear is filtered by DOCUMENT window + // identity, so the context under test must BE the document + // window — passing `None` here would assert nothing. + let document_window = crate::window::WindowId::next(); let invalidated = || StatuslineEvaluation { outcome: StatuslineEvaluationOutcome::Invalidated { authoritative_empty: vec![crate::statusline::StatuslineContext { frontend_id: FrontendId::LOCAL, - window_id: crate::window::WindowId::next(), + window_id: document_window, buffer_id, active: true, }], @@ -2961,13 +2973,13 @@ mod tests { new_failures: Vec::new(), }; let mut replacement = Vec::new(); - semantic.emit_statusline_segments(invalidated(), None, &mut replacement); + semantic.emit_statusline_segments(invalidated(), Some(document_window), &mut replacement); assert_eq!( statusline_of(&replacement), Some((buffer_id, Vec::new(), Vec::new())) ); let mut unchanged = Vec::new(); - semantic.emit_statusline_segments(invalidated(), None, &mut unchanged); + semantic.emit_statusline_segments(invalidated(), Some(document_window), &mut unchanged); assert!( unchanged.is_empty(), "the empty invalidation became baseline" diff --git a/src/statusline.rs b/src/statusline.rs index 7bf345f..3eb4ef6 100644 --- a/src/statusline.rs +++ b/src/statusline.rs @@ -215,10 +215,25 @@ pub enum StatuslineEvaluationTarget { /// Frontend whose entire visible layout is evaluated. frontend_id: FrontendId, }, - /// Only the frontend's active window, iff it still displays the declared - /// semantic viewport buffer. + /// The frontend's **primary document window**, iff it still displays + /// the declared semantic viewport buffer, **plus its visible side + /// window** when one exists (bottom-panel Q#BP8 / A2A-2). + /// + /// Two contexts, not one: the document result feeds the semantic + /// `StatuslineSegments` wire, while the side result paints in the + /// panel's own mode line. Unprojected document splits run no + /// callbacks, and a derived-hidden side (Q#BP2b) is omitted because + /// it has no mode line to paint this frame. + /// + /// The document context is captured **first**; consumers must still + /// select by window identity rather than position, since only one of + /// the two may reach the single semantic statusline slot. + /// + /// `active` on each context reports **actual focus**, so a document + /// provider truthfully observes `active = false` while a panel owns + /// focus (Q#BP14, parent acceptance 42). Semantic { - /// Frontend whose focused daemon window is evaluated. + /// Frontend whose document (and visible side) window is evaluated. frontend_id: FrontendId, /// Buffer declared by the semantic viewport. declared_buffer: BufferId, diff --git a/tests/bottom_panel_stage2a_acceptance.rs b/tests/bottom_panel_stage2a_acceptance.rs index 3b6c183..1063e9f 100644 --- a/tests/bottom_panel_stage2a_acceptance.rs +++ b/tests/bottom_panel_stage2a_acceptance.rs @@ -32,6 +32,14 @@ fn exec(s: &EditorState, src: &str) { s.lua_host.lua().load(src.to_string()).exec().unwrap(); } +fn side_window_of(core: &pmacs::editor_core::EditorCore, fid: FrontendId) -> Option { + core.views[&fid].layout.iter_ids().into_iter().find(|id| { + core.windows + .get(id) + .is_some_and(|w| w.params.side.is_some()) + }) +} + fn side_window(s: &EditorState) -> Option { let core = s.core.borrow(); core.views[&FrontendId::LOCAL] @@ -540,48 +548,239 @@ fn consumer_line_numbers_follow_the_document_not_the_focused_panel() { } #[test] -fn consumer_statusline_segments_name_the_document_window() { - use pmacs::protocol::ByteRange; +fn consumer_statusline_segments_carry_the_document_payload_not_the_panel() { + use pmacs::protocol::{ByteRange, InstanceMessage}; use pmacs::semantic_render::SemanticRenderState; - // §1.3 #12 at the producer: the wire segments must be selected by - // the DOCUMENT window even though the fan-out now also evaluates the - // visible side window (A2A-2). + // §1.3 #12 / A2A-2 at the WIRE. Round 2 finding: the previous + // version discarded `render_frame`'s output and only reasserted + // `primary_document_window`, so restoring the producer's + // "first context for my frontend" selector left it green. + // + // The peer must negotiate v18 or no `StatuslineSegments` is emitted + // at all and the assertion would be vacuous a second way. let s = editor(); - let (fid, doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + let (fid, _doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + let panel_buf = { + let core = s.core.borrow(); + let panel = side_window_of(&core, fid).expect("panel"); + core.windows[&panel].buffer_id + }; - let mut sem = SemanticRenderState::new(fid); + // One provider so a payload exists to misroute. + exec( + &s, + "pmacs.statusline.register({ name = \"probe\", side = \"left\", + face = \"ui.modeline\", fn = function(ctx) return \"X\" end })", + ); + + let mut sem = SemanticRenderState::for_peer(fid, 18); sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0); - // Not asserting on message presence (a peer that never negotiated - // v18 emits none); asserting the routing input the producer uses. - let _ = sem.render_frame(&s); + let msgs = sem.render_frame(&s); - assert_eq!( - s.core.borrow().primary_document_window(fid), - Some(doc_win), - "the producer's document-window selector must name the document" + let targets: Vec<_> = msgs + .iter() + .filter_map(|m| match m { + InstanceMessage::StatuslineSegments { buffer_id, .. } => Some(*buffer_id), + _ => None, + }) + .collect(); + + assert!( + !targets.is_empty(), + "non-vacuity: a v18 peer with a registered provider must emit StatuslineSegments" + ); + assert!( + targets.iter().all(|b| *b == doc_buf), + "every StatuslineSegments must target the DOCUMENT buffer; got {targets:?} (document {doc_buf:?}, panel {panel_buf:?})" + ); + assert!( + !targets.contains(&panel_buf), + "the panel's context must never reach the document statusline wire" ); } #[test] -fn consumer_terminal_declaration_cannot_be_claimed_by_a_focused_panel() { - // §1.3 #6/#10/#11 through the real guard: with the panel focused, - // a declaration naming the PANEL's buffer must be refused, because - // the full-window terminal surface is the document window. - let s = editor(); - let (fid, _doc_win, panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); - let panel_buf = s.core.borrow().windows[&panel_win].buffer_id; +fn consumer_terminal_declaration_resolves_the_document_not_the_focused_panel() { + use pmacs::terminal::TerminalSpec; + + // §1.3 #6/#10/#11 through the real guard. Round 2 finding: the + // previous version compared two NON-terminal buffers, so both the + // old and new routings returned `false` and it could not + // discriminate. Make the DOCUMENT window hold a real terminal: the + // document routing then answers `true` while the old `view.active` + // routing (which names the focused panel) answers `false`. + let mut s = editor(); + let (fid, doc_win, _panel_win, _doc_buf) = semantic_frontend_with_focused_panel(&s); + + let mut spec = TerminalSpec::new("/bin/sh"); + spec.rows = 10; + spec.cols = 40; + let term_buf = s.open_terminal(spec).expect("a real terminal session"); + + // Install the terminal in the DOCUMENT window; the panel keeps its + // own non-terminal buffer and keeps focus. + { + let mut core = s.core.borrow_mut(); + core.install_buffer_in_window(doc_win, term_buf) + .expect("install the terminal in the document window"); + } + let panel_buf = { + let core = s.core.borrow(); + let panel = side_window_of(&core, fid).expect("panel"); + core.windows[&panel].buffer_id + }; assert!( - !s.semantic_terminal_declaration_is_active(fid, panel_buf), - "a focused panel's buffer must not become the document terminal declaration" + s.semantic_terminal_declaration_is_active(fid, term_buf), + "the DOCUMENT window's terminal must be declarable while the panel owns focus" ); - // Non-vacuity: the document buffer is not a terminal either, so pin - // that the guard resolves the DOCUMENT window by asserting the - // window identity the resolver used. - assert_eq!( - s.core.borrow().primary_document_buffer(fid), - Some(doc_buf), - "the terminal resolver's window must be the document window" + assert!( + !s.semantic_terminal_declaration_is_active(fid, panel_buf), + "the focused panel's own buffer must never claim the document declaration" + ); +} + +#[test] +fn invalidated_statusline_clears_only_the_document_not_the_panel() { + use pmacs::protocol::{ByteRange, InstanceMessage}; + use pmacs::semantic_render::SemanticRenderState; + + // Round 2 finding 1. The `Invalidated` arm emits an + // authoritative-empty payload for EVERY context of the frontend. + // Once A2A-2's fan-out yields document + panel, that publishes two + // clears on a wire with ONE statusline slot, so the panel's payload + // replaces the document's. This is the live, observable half of the + // routing bug — the `Ready` arm happens to be safe today only + // because the document context is captured first. + let s = editor(); + let (fid, _doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + let panel_buf = { + let core = s.core.borrow(); + let panel = side_window_of(&core, fid).expect("panel"); + core.windows[&panel].buffer_id + }; + assert_ne!(doc_buf, panel_buf, "fixture: the two buffers must differ"); + + // A provider that unregisters itself mid-evaluation is the canonical + // registry-mutation invalidation. + exec( + &s, + r"_G.SL_SELF = pmacs.statusline.register { + name='self-remove', side='left', priority=100, + fn=function() pmacs.statusline.unregister(SL_SELF); return 'STALE' end, + }", + ); + + let mut sem = SemanticRenderState::for_peer(fid, 18); + sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0); + let msgs = sem.render_frame(&s); + + let targets: Vec<_> = msgs + .iter() + .filter_map(|m| match m { + InstanceMessage::StatuslineSegments { buffer_id, .. } => Some(*buffer_id), + _ => None, + }) + .collect(); + + assert!( + !targets.contains(&panel_buf), + "an invalidated evaluation must not clear the PANEL's context on the \ + document statusline wire; got {targets:?} (document {doc_buf:?}, \ + panel {panel_buf:?})" + ); +} + +#[test] +fn the_semantic_fan_out_captures_the_document_first() { + use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, + }; + + // The `Ready` arm selects by window identity, so capture order is not + // load-bearing for correctness — but it IS load-bearing for the + // falsifiability of that selector, so pin it explicitly rather than + // leaving a silent dependency. If a future change reorders the + // fan-out, this fails and whoever reads it learns why it mattered. + let s = editor(); + let (fid, doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + + let evaluation = evaluate_statusline( + s.lua_host.lua(), + &s.core, + &s.statusline_registry, + StatuslineEvaluationTarget::Semantic { + frontend_id: fid, + declared_buffer: doc_buf, + }, + ); + + match evaluation.outcome { + StatuslineEvaluationOutcome::Ready(windows) => { + assert_eq!(windows.len(), 2, "document + visible side window"); + assert_eq!( + windows[0].context.window_id, doc_win, + "the DOCUMENT context must be captured first" + ); + } + other => panic!("expected Ready, got {other:?}"), + } +} + +#[test] +fn consumer_decorations_follow_the_document_selection_not_the_panel() { + use pmacs::protocol::{ByteRange, InstanceMessage}; + use pmacs::semantic_render::SemanticRenderState; + + // §1.3 #5 — Projection. A selection made inside a FOCUSED PANEL must + // not paint selection decorations into the document's viewport. + // + // To DISCRIMINATE, the panel must display the SAME buffer the + // viewport declares and hold a NON-EMPTY selection while the + // document holds none. With different buffers (the first attempt) + // both routings emit nothing and the test proves nothing. + let s = editor(); + let (fid, doc_win, panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s); + + exec(&s, "PROBE = pmacs.buffer.list()[1]"); + { + let mut core = s.core.borrow_mut(); + // Put real text in the document buffer so a span exists. + { + let reg = core.registry.borrow(); + let _ = reg.get(doc_buf).expect("doc"); + } + // The panel shows the document's buffer and selects a range. + core.install_buffer_in_window(panel_win, doc_buf) + .expect("panel shows the document buffer"); + let panel = core.windows.get_mut(&panel_win).expect("panel"); + panel.selection = Some(pmacs::window::Selection { anchor: 0 }); + panel.cursor = 4; + // The document window selects nothing. + let doc = core.windows.get_mut(&doc_win).expect("doc"); + doc.selection = None; + doc.cursor = 0; + } + + let mut sem = SemanticRenderState::for_peer(fid, 18); + sem.set_viewport(doc_buf, ByteRange { start: 0, end: 8 }, 0); + let msgs = sem.render_frame(&s); + + let selection_decorations: usize = msgs + .iter() + .filter_map(|m| match m { + InstanceMessage::Decorations { segments, .. } => Some( + segments + .iter() + .map(|seg| seg.decorations.len()) + .sum::(), + ), + _ => None, + }) + .sum(); + assert_eq!( + selection_decorations, 0, + "a selection living in the focused PANEL must not decorate the document viewport" ); } From c4fad0731ceb726965964c0deb29a8893a87135a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 10:39:01 -0400 Subject: [PATCH 44/91] =?UTF-8?q?docs(lean4):=20rev=207=20=E2=80=94=20roun?= =?UTF-8?q?d=206=20review,=20five=20P1s,=20and=20reconcile=20the=20ledgers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4a/4b split held; five P1s against rev 6's own content, all real, all reproduced. Four share a root: rev 6 verified its external facts and under-verified its internal ones. 1. Stage 4a's declared footprint excluded the tests its own acceptance required. 46a-46e cannot live in tests/auto_pair_acceptance.rs, which criterion 46 requires byte-identical. Footprint now names tests/typed_edit_chain_acceptance.rs and gates on it. 2. Pending abbreviation state had the wrong owner. pmacs is multi-frontend: EditorCore.views is per-FrontendId with its own active window, take_typed_edit is already frontend-keyed, and buffer.after-switch fires with no arguments — so a buffer-keyed clear-on-switch lets any frontend discard another's pending abbreviation. Now keyed (frontend, buffer) with a window check, frontend-scoped clearing, a frontend.detached purge, and acceptance 45i, which the buffer-keyed design passes every other criterion without. 3. The shortest-match rule was missing its tie-break: upstream keeps declaration order among equal-length shortest keys, and 101 prefixes have equal-shortest candidates resolving to different symbols (f picks f< over f>). A pairs-iterated Lua map cannot express this, so the vendored artifact is now an ordered sequence and resolution sorts by (#key, source rank). Rev 6 missed this because it declared the package ships no README after a 404 on the package root, with the directory listing showing src/README.md already in hand — a 404 on a guessed path is not evidence of absence, and the README states the rule in one sentence. 4. The generator's rejection rule rejected the current table: \ is a key and " begins eleven, while acceptance 45d requires \ to work. Replaced with canonical lossless escaping; aborts only on duplicate keys, invalid UTF-8, and a failed self-round-trip. 45g no longer claims to diff against abbreviations.json, which is not shipped. 5. Durable and volatile state were not reconciled. agent-handoff.md anchored main at d152120 with neither #167 nor #170 and no Lean arc bullet at all; active-work.md kept 407 lines of merged Stage 1/2/3a/3b history against its own instruction to prune merged entries, under a stale snapshot date. Durable facts moved to the handoff; the ledger keeps only the unlanded Stage 4 lane. Also corrected: 119 multi-codepoint symbols (26 with $CURSOR), not 93; three backslash values, not two; Q#LN22 now states the terminating-\ reprocess rule acceptance 45d depended on; acceptance 38 says the terminator is retained, so undo restores "\alpha " with its space; coherence cites golden-journey step 5, not step 4; and the config-registry prior art points at Q#LN22. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- docs/active-work.md | 435 +++---------------------------------- docs/agent-handoff.md | 71 +++++- docs/lean4-mode-framing.md | 324 +++++++++++++++++++++++---- 3 files changed, 371 insertions(+), 459 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 52d44ba..47c3677 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-25.** This file records volatile work that has not +**Snapshot: 2026-07-26.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -59,421 +59,40 @@ If it does not, stop and repair the remote/fetch configuration. ## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b MERGED; Stage 4 IN FRAMING -- Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review - round, all twelve checks green). Branch `githubsucks/lean4-stage1` - retained; it was worked in the shared checkout (no sibling worktree). -- Approved framing: `docs/lean4-mode-framing.md` revision 4, committed as - the branch's first commit (`a382965`) after three review rounds. **Seven - stages**, 19 decisions (Q#LN1–19), 64 acceptance criteria. North star: - match or exceed VS Code's Lean support. -- **Stage 1 implemented; no wire change (protocol stays v20), no LSP, no - frontend change.** Four commits: framing, grammar, theme captures, - editing surface + acceptance. - - `Cargo.toml` + `src/syntax.rs`: `arborium-lean` 2.18 and one - `BUILTIN_LANGUAGES` entry named **`lean4`** (Q#LN2 — the name becomes - the `didOpen` language_id), claiming `.lean` only. - - `src/highlight.rs`: four capture entries — `constructor`, `character`, - `keyword.conditional`, `warning`. - - `builtin/runtime/{comment,pair,syntax}.lua`: `--` comments, the - `⟨⟩ ⦃⦄ ⟮⟯` pair set, the `lean` → `lean4` modeline alias. - - `tests/lean4_stage1_acceptance.rs` plus unit tests in `syntax.rs` / - `highlight.rs`: 12 criteria, 17 tests. -- **Q#LN1's open obligation is discharged.** `tree-sitter-lean4` is - unusable (depends on `tree-sitter ^0.25` directly against our 0.26, - exports no `LANGUAGE` const despite its README, packages no queries); - `arborium-lean` rides `tree-sitter-language 0.1` with a pre-generated - ABI-15 parser. `cargo tree -d` shows no duplicate core. The parse smoke - pins the failure mode that matters: `→`/`∀`/`≥` must produce - `(arrow)`/`(forall)`/`(comparison)`, since a mismatched-core build - degrades silently on exactly those characters rather than failing loudly. -- **Q#LN4 is a deliberate retro-paint of seven language entries**, not - four: `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated - base-first into javascriptreact/typescript/typescriptreact. Its shape is - "every capitalized identifier" (`#match? "^[A-Z]"`) plus every Lua table - brace — not "constructors". Pinned in both directions per #146. -- Implementation findings not in the framing: - - `warning` had to move from bold red to bold **bright** red: `number` - is plain `fg(1)`, so `sorry` and an adjacent numeric literal were the - same colour. Found by writing the test. - - `Some(1)` is **not** `@constructor` — in call position a narrower - `@function` pattern wins. Only bare or pattern-position capitalized - identifiers reach it. Pinned so the blast-radius claim stays honest. - - Lean node kinds nest: `module > declaration > def|theorem`. - - `pmacs.parse.injection_aliases` is a documented **write-only** Lua - proxy (canonical map is Rust-side), so fence tests must drive - `_parse_now` and inspect layer languages, never read the table back. -- **Review round 1 addressed.** The finding: acc12's server-list assertion - could not fail for the regression it named — the shared `editor()` - helper wipes `pmacs.lsp.config` before any buffer opens, so - `#pmacs.lsp.list() == 0` holds for every language regardless of what - Stage 1 ships. It now asserts against a **pristine** `EditorState` that - `pmacs.lsp.config.lean4` is nil, with a non-vacuity check that the same - lookup finds `rust`; bite-verified by adding a `lean4` config to - `lsp.lua` and watching it fail. Also fixed a stale column in a - `highlight.rs` comment. -- Verification on this branch: `cargo fmt --check` clean; strict workspace - Clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1 - 9/9; comment toggle 14; auto-pair 45; injection 4; M4 121; required GPU - 152; **isolated-config workspace sweep 3,150 across 90 suites**; - `git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME` - for the reason recorded in the bottom-panel lane below. -### Stage 2 — multi-root LSP server affinity (Q#LN15) +- **Stages 1, 2, 3a and 3b are MERGED** — #160 (`main` @ `0827dd1`), + #161 (`46a1b8f`), #167 (`6f348c9`), #170 (`d400f30`). Their full + histories were pruned from this ledger in round 6, per this file's own + instruction to remove entries when their PR merges; the durable facts + now live in `docs/agent-handoff.md` §1's Lean 4 bullet, which is where + a fresh machine should read them. `docs/lean4-mode-framing.md` rev 7 + carries the decisions. -- Portable branch: `githubsucks/lsp-multi-root-affinity`, shared checkout, - based on `githubsucks/main` @ `0827dd1`. Named for the substrate, not - for Lean: **the diff contains no Lean content**, because `ensure_server` - is the one server-affinity function every LSP language shares and a - cross-cutting change to it must not be reviewable only as a Lean - feature. -- Three files, no protocol change: `src/lua_bindings/mod.rs` (the - `lsp.list()` row builder gains `root_uri` + `cwd`), - `builtin/runtime/lsp.lua` (`project_root_for` returns `root, source`; - `ensure_server` hoists it above the reuse loop and matches on it), - `tests/lsp_multi_root_acceptance.rs` (9 tests, acceptance 13–21). -- **The rule that keeps this from regressing every other language: the - affinity key is the root only when a root was actually FOUND.** - `project_root_for` never returns nil for a file with a path — its last - resort is the file's own directory — so a naive `(language_id, root)` - key gives every directory of loose scratch files its own server, for - every language. `source` is `"config" | "detected" | "fallback"` and - only the first two become a key. -- **Wire-identical for the fallback case, and that is provable rather - than hoped.** Matching is on the spawned spec's `root_uri` (nil matching - nil), so the fallback spawn passes `root_uri = nil`; `cwd` still carries - the directory and `build_initialize` derives the identical `rootUri` - from `cwd` when the field is None, using a percent-encoder with the same - allowed set as Lua's `file_uri_for`. `build_initialize` (`src/lsp.rs`) - is the **only** reader of `spec.root_uri` in the tree. -- Deliberate behavior change, asserted not discovered: a server - hand-spawned from `init.lua` with only `cwd` set also reads back nil, so - a root-bearing attach will not adopt it. -- `config[language].root` may now be a `function(path) -> string|nil`, - memoized per directory — needed because the hoist puts root resolution - on every attach rather than every spawn. The memo is keyed **weakly by - the resolver function itself**, so replacing `config[lang].root` cannot - serve a root the previous resolver computed. This is Q#LN8's - generalization landing early; the Lean resolver that uses it is Stage 3. -- Bite-verified three ways: 5/9 fail against the pre-change `lsp.lua`, - 8/9 against the pre-change `mod.rs`, and — the one that matters most — - installing the naive always-key-on-root variant fails acceptance 20 and - 21 exactly as Q#LN15 part 2 predicts. The four that survive the first - bite (13, 15, 16, 19) are the regression pins; passing on both sides is - their job. -- Every fixture sets `pmacs.project.set_search_boundary` at its own - tempdir root. Without it the marker walk climbs to the filesystem root - and a stray `.git` above the temp directory turns the markerless cases - into detected ones — the assertions would still pass while testing - nothing. -- **Found but not fixed here (pre-existing, own lane):** `ensure_server` - never forwards `cfg.restart` to `pmacs.lsp.spawn`, so a - `restart = "never"` in `pmacs.lsp.config[lang]` is silently dropped on - the auto-attach path. At least one existing test sets it believing it - takes effect. Out of scope for a PR whose acceptance 16 pins existing - attach behavior as unchanged. -- **Review round 1 addressed.** The blocker was process, not design: the - test file was committed *before* `cargo fmt` ran, so the fix sat - uncommitted in the working tree and the branch as pushed failed the - first gate. The reported "fmt clean" described the worktree, not the - branch — gate results are only meaningful when run against the pushed - tree. Also added the two pins review asked for (a **string** `config - .root` as an affinity key — acc17 only covered the function form; and - `root = false` reading as unset), each bite-verified against exactly - the mutation it targets and neither against the other. And documented - the canonicalization obligation: the `"detected"` arm is canonicalized - for free, a **configured** root is not, so on macOS a resolver - returning `/var/…` and a detected `/private/var/…` are different keys - for one directory. Stage 3's Lean resolver is the first real consumer, - so the obligation is written at the point of use. -- Verification on this branch: `cargo fmt --check` clean; strict - workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; - multi-root 11/11; M4 121; statusline 7; completion popup 9; auto-pair - 45; required GPU 155; **isolated-config workspace sweep 3,164 across 91 - suites**; `git diff --check` clean. The sweep needs an isolated - `XDG_CONFIG_HOME` and `-- --skip basedpyright`. - -### Stage 3a — dispatch seams + `pmacs.fs.canonicalize` — MERGED #167 (`main` @ `6f348c9`) - -- Worktree `../pmacs-lean-stage3`, branched off `githubsucks/main` @ - `46a1b8f`. Carries framing **rev 5** (the Stage 3 split) as its first - two commits, then the implementation, then a bite-driven correction. -- **Stage 2 merged as #161** (`main` @ `46a1b8f`, 2026-07-25, two review - rounds). COHERENCE.md §7 records the slice; §1.2 records the dead - `pmacs.error` channel found landing it. -- **Framing rev 5 splits Stage 3 into 3a and 3b** because rev 4 broke its - own §4 rule — the row read "two `lsp.lua` generalizations" under prose - claiming Stage 3 was Lean-only. One generalization shipped as Stage 2; - the other (Q#LN9's seams) is the shared event drain, so it is now its - own substrate stage. 3a and 3b are **strictly sequential** — 3b's - subscriber is written against 3a's seam and both touch `lsp.lua`. -- Ships: `pmacs.lsp.on_notification` / `on_response`, two arms in - `handle_server_requests`, a pending-response purge, and - `pmacs.fs.canonicalize` (Q#LN20). No protocol change, no Lean content. -- **Two framing claims were corrected during implementation**, both - recorded in §0.1 finding 6 and in the round-2 commit: - 1. The reachable leak is **not** a killed buffer. The Rust core fires - exactly five hooks (`buffer.after-edit`, `buffer.after-load`, - `buffer.after-switch`, `frontend.detached`, `process.after-tick`) — - **there is no buffer-kill hook**, so nothing tears an attachment - down and the drain keeps reaching that server. The real path is - `attach_buffer` dropping a dead sid from `attachments` and - rebuilding against a fresh server, which makes `crashed`/`stopped` - the event *least* likely to be drained. Hence the purge polls - `pmacs.lsp.list()` rather than riding the drain. - 2. Acceptance 32 does **not** pin "removed before invocation" — - `pcall` catches the raise either way, so before/after is - unobservable without a re-entrant drain. It pins removal being - **unconditional**; renamed accordingly. -- **`pmacs._fs` is installed from `install_async`, not `install_project`**, - purely for load order: `make_workspace` runs *after* `fs.lua` is - evaluated, so a canonicalizer placed there reads nil. This cost one - failing run to discover and is the kind of thing to check first. -- Bites recorded (all against the committed tree): removal gated on a - clean return → acc32 fails 2 != 1; an event-driven purge → the - no-attachment case fails "never called" while the attached case still - passes; a resolver without `canonicalize` → two servers (34b's own - falsification, which ships as a test). -- **Known unpinned:** the purge's generation (`attempt`) check. Reaching - it needs a crash *and* its restart to fall in a gap with no - `_async.tick`; the backoff is 500ms, so any tick sees `crashed` first - and the absent-or-terminal arm fires. Labelled as defensive in the - code rather than left looking covered. -- Verification on this branch: `cargo fmt --check` clean; strict - workspace Clippy clean; 1,826 default + 2,003 CRDT library tests; - dispatch seams 15/15 on Linux (14 on macOS — see below); multi-root - 13/13; M4 121; required GPU 155; **isolated-config workspace sweep - 3,189 across 93 suites, zero failures**; `git diff --check` clean. -- **Two flakes/portability facts from CI round 1, both worth keeping:** - 1. `composition_overhead_under_ten_percent` tripped once in a local - sweep at 18.8% against a 10% budget, then passed 3/3 in isolation - here, passed in isolation on main, and passed a full sweep rerun. - The tell is in its own output: the same run reported realistic-frame - overhead as **-4.6%**, and a negative figure is measurement noise, - not added work. Load-sensitive under a parallel `--workspace` run. - 2. **A non-UTF-8 filename fixture cannot be built on macOS.** APFS - enforces valid UTF-8, so `std::fs::write` fails with EILSEQ - ("Illegal byte sequence") before the code under test is reached. - `#[cfg(unix)]` is NOT sufficient for such a fixture — - `#[cfg(target_os = "linux")]` is. Cost one red CI round to learn. - -### Stage 3b — the Lean language server — MERGED #170 (`main` @ `d400f30`) - -- Same worktree `../pmacs-lean-stage3`, **branched off - `lean4-stage3a-seams`, not off `main`** — 3b consumes 3a's response - seam and `pmacs.fs.canonicalize`, so it is strictly sequential. - **Retarget PR #170 to `main` BEFORE merging #167, not after** — the - kill-ring lesson exactly. (Round 1 of this ledger entry stated the - reverse in its first sentence and the correct rule in the next; the - review caught it. A safety rule written twice with opposite senses is - worse than not written.) -- Ships `builtin/runtime/lean.lua` (new), one `include_str!` line in - `src/editor.rs`, `pmacs.lsp._attach_buffer` exported from `lsp.lua`, - a `leanprogress` mode plus `waitForDiagnostics` validation on - `pmacs_fake_lsp`, and `tests/lean4_server_acceptance.rs` (40 tests). - No protocol change. -- **Stage 1's acceptance 12 is half superseded and was rewritten, not - deleted.** It asserted `pmacs.lsp.config.lean4 == nil` to catch a - Stage-3 front-run; 3b is that stage. What survives is the restraint - half — constructing an editor spawns nothing though the config now - names `lake`, and opening a Lean buffer with no server configured - spawns nothing — which is what holds Q#LN7's "not at init" promise. -- **The marker test is wrong in two opposite directions if done naively** - and both are pinned: `io.open` SUCCEEDS on a directory (so truthiness - accepts a `lean-toolchain` dir), but requiring a non-nil read rejects - an EMPTY `lean-toolchain` (a legitimate marker — existence semantics, - not content). Discriminator is `read`'s SECOND return; decline only on - a non-nil err. Probed on LuaJIT 2.1. -- **Fifteen bites recorded, each against the committed tree.** R1: bare - `io.open` → 24a fails / 24b passes; require-non-nil → 24b fails / 24a - passes; no canonicalization → symlinked open spawns two servers; no - re-attach after the swap → three latch tests fail; hook keyed on the - attachment → the missing-`lake` case fails; `waitForDiagnostics` - without `version` → acc37 fails with InvalidParams. R2: skip retiring - a terminal server → `attempt` reaches 3; no originating-buffer gate → - the Lean buffer is left on the `lake` stub; retry-forever → the - failing-fallback test fails; version-probe any command → the - working-wrapper test fails; no disabled guard → the unconfigured test - sees "`nil` could not be started". R3: verdict keyed on `watching` → - the late-verdict test finds the buffer still on `lake`; `buf_key` - rewritten per load → the second-buffer test fails; hardcoded - `lake serve` → the wrapper-naming test fails. -- **Round-2 review: three more P1 lifecycle defects, suite 20/20 with - all of them live.** (1) The crashed primary respawned forever — - skipping the retire call avoided corrupting terminal servers but left - `next_restart_at` armed. **`forget` is the call for a TERMINAL server** - (it requires terminal state and removes the client, dropping the - restart timer); `stop` is for a live one and corrupts a terminal one. - (2) Re-attachment targeted whatever buffer was active when the async - verdict landed; an unrelated Rust attachment satisfied "a different - server id". (3) A failing fallback retried every tick forever, silent. - Plus two P2s: the Lake version parser was applied to arbitrary wrapper - output, and an UNCONFIGURED `config.lean4` was reported as failure and - latched, poisoning the session. -- **Round-3 review: two more P1s, both asynchronous correlation, suite - 25/25.** (a) `probe.watching` is cleared when the server initializes, - so a SLOW version verdict arrived with nil and retired nothing — - `_attach_buffer` returned the still-live primary and the retry called - it success, so status and config said "fell back" while the buffer - stayed put. **That is the round-1 silent no-op reached through a third - event ordering.** `probe.primary` is now separate from - `probe.watching` and survives initialization. (b) `buf_key` was - rewritten on every Lean `after-load`, so a second Lean buffer opened - before the verdict became the rebuild target while the latch still - watched the first buffer's server. Target buffer and primary server - are one fact and are now armed together, once. Plus a P2: the failure - message hardcoded `lake serve` after the latch became - command-agnostic, sending wrapper users to debug the wrong binary. -- **Round-4 review: one P1, and it is the same defect a FOURTH time.** - `pmacs.lsp.config.lean4` is a single global entry, so swapping its - command invalidates **every** Lean buffer and **every** Lean server — - Q#LN15 gives one per project root. Rounds 1–3 each fixed the repair - for one buffer and one server; round 4 is "repair the armed target, - strand the rest". The shape that finally holds: retire ALL `lean4` - servers on latch, and repair each buffer **lazily and at most once** - when it becomes active (`buffer.after-switch` + the tick), because - `_attach_buffer` is active-buffer-only and cannot reach the others. - The per-buffer once-only bound is what stops a failing fallback - retrying forever — the round-2 defect a naive global repair loop would - have reintroduced for every buffer instead of one. Plus a P2: the - argument-inclusive attribution was implemented but pinned only by - "contains the command name", so a mutation dropping every argument - still passed. -- **Round-5 review: one P1 plus a frontend scope hole, and four more.** - (1) A fallback that SPAWNS and then dies retried forever: the - once-per-buffer guard bounds `_attach_buffer`, not the server it - produced, and `ensure_server` never forwards `cfg.restart` so the - fallback inherits `OnCrash` — respawned by the manager with no - ceiling, silently, because `latched` had disabled the primary's poll. - The fallback now gets its own one-shot die-before-initialize watch. - (2) **Simultaneous frontends**: both repair triggers read the ambient - `pmacs.window.buffer()`, and the daemon restores `active_frontend` to - the last-dispatched one before `tick_processes`, so a Lean buffer - active in ANOTHER frontend gets no `after-switch` and stays stale. - Fixed at the right seam — **make CONSUMPTION safe**: both - `attached_for_active` and `attachment_for_request` now refuse a record - whose server is dead (the former rebuilds, the latter reports none, - since it must not perturb LSP state). Healing at the point of use is - frontend-agnostic, because whichever frontend runs a command is active - while it runs. (3) The retirement sweep selected on `language_id`, so - it stopped USER-spawned Lean servers too; it now keys on the - `default-lean4` label `ensure_server` stamps, which is the derivation - discriminator. (4) `probe.latched` gated repair even when NO swap - occurred, so an already-fallback config was retried and misreported. - Split out `probe.fallback_installed`. (5) The once-per-buffer - assertion counted TABLE KEYS, which cannot distinguish "once per - buffer" from "every tick for one buffer" — cardinality stays 1 either - way. Now a numeric attempt counter; the bite shows **174 vs 1**. -- **Round-6 review: four P1s and one P2, suite 40/40.** (1) General - point-of-use healing treated a crashed OnCrash server as absent and - spawned beside it while its old id still had `next_restart_at` armed; - `attach_buffer` now forgets a terminal record before replacement. - `attachment_for_request` remains non-attaching and preserves the - record, so a same-id restart can recover instead of being orphaned. - (2) The fallback watch was scalar, while Q#LN15 permits simultaneous - per-root servers and lsp.lua can create them without passing through - Lean's repair function. Watches are now per-SID and discover every - config-driven Lean server from a private origin table. (3) The shipped - `lean.wait-for-diagnostics` command bypassed both safe resolvers and - still consumed a stopped record; it now uses a command-safe resolver, - waits asynchronously for a healed replacement to initialize, and the - test requires the real request to finish. (4) When no config swap - occurred, one failed root still swept a healthy root; that arm now - retires only the SID whose verdict fired. (5) `label` is public and - unreserved, therefore not ownership. lsp.lua records successful - config-driven spawns privately, and every Lean lifecycle decision keys - on that origin fact; the user-server pin deliberately collides on - `default-lean4`. All five bites against `19f48d4` discriminate: the - old files produce 2 same-root servers, a fallback attempt of 4, a - shipped command still targeting `stopped`, retirement of the healthy - root, and retirement of the colliding user server, respectively. -- **DURABLE LESSON — "the test that passes" vs "the test that - discriminates."** Green tests across six rounds repeatedly pinned only - a nearby helper or an absence, and only biting exposed it. **Carry this - to `docs/agent-handoff.md` when the lane lands.** The concrete shapes, - all from this branch: - 1. R1 acceptance 36 asserted "every server is terminal" — pinning the - ABSENCE of the fallback it claimed to test. - 2. "No live non-fallback server" misses a respawn loop: a respawning - server sits in `crashed` most of the time. `attempt` counts - respawns; liveness does not. - 3. Returning to a buffer via `find_or_open` re-fires - `buffer.after-load`, which repairs the attachment regardless of the - code under test. Use `switch_buffer`. - 4. A MISSING executable fails synchronously inside `after-load`, where - the rebuild happens inline — no async race can occur. Only the - probe path exercises asynchronous ordering. - 5. A mutation that RAISES (indexing a nil config) is swallowed by the - hook's pcall, so the bite "passes" for the wrong reason. A bite must - reproduce the original shape, not merely break the code. - 6. A fixture whose `serve` sleeps can never let the primary initialize - first, so it cannot reach the ordering where a late verdict must - retire a LIVE server. - 7. Asserting on a field that no longer exists (`_probe.reattach_from` - after a refactor) reads as nil and passes for nothing. Assert - positive facts — a count, a command string — not absences. - 8. Counting DISTINCT KEYS cannot bound REPEATED WORK: a per-tick retry - on one buffer keeps `#repaired == 1` forever. Count the attempts, - not the things attempted against (bite: 174 vs 1). - 9. A NONEXISTENT executable only exercises synchronous ENOENT. To - reach "spawned, then died", the fixture must actually spawn. - 10. Calling the two SAFE HELPERS directly does not pin a shipped - command that bypasses both. Drive the command registry entry and - require its terminal result — replacing a dead record with a - `starting` server is still not success if the request is issued - before initialize. - Rule: **a test is not evidence until the mutation it targets has been - shown to fail it.** -- **SECOND DURABLE LESSON — a scope error repeats until the scope is - named.** The "fallback silently does not happen" defect came back four - times: no re-attach; re-attach cleared by an unrelated buffer; - re-attach satisfied by the server being replaced; re-attach of one - buffer while the others stay stale. Every fix was locally correct and - none asked *what does this config swap invalidate?* — the answer being - every Lean buffer and every Lean server, because the config entry is - global and servers are per-root. **When a change edits shared state, - enumerate everything derived from it before repairing anything.** -- **SUBSTRATE BUG FOUND, not fixed here (framing §6).** - `LspManager::stop` on an ALREADY-terminal server takes its - not-initialized branch, terminates the dead process and sets - `ShuttingDown { .. None }` on the premise that "the next exit - observation cleans up" — but the exit already happened, which is what - made it `Crashed`. No further event arrives, so the client is stuck in - `ShuttingDown` **forever**: `server_is_live` reads it as LIVE, so - `attach_buffer` never rebuilds, and `forget` refuses it for not being - terminal. **Stopping a dead server is what makes it un-replaceable.** - Lean works around it by dispatching on state: `forget` when - terminal, `stop` when live. Merely SKIPPING the call is not - enough — that leaves `next_restart_at` armed. -- Round-1 review found four P1s, all real: the latch swapped the config - but never spawned or re-attached (and acc36 *asserted every server was - terminal*, pinning the absence of the fallback); a missing `lake` - bypassed probe and latch entirely because the hook keyed on an - attachment that ENOENT prevents; `waitForDiagnostics` omitted the - `version` Lean requires; and the ledger stated the dangerous stacking - order. -- The probe's non-zero exit is deliberately NOT a fallback trigger — - §2.9's elan shim makes `lake --version` fail where `lake serve` still - works. Only a parseable version below 3.1.0 triggers it; the - server-failure latch covers the rest. -- Verification on this branch: `cargo fmt --check` clean; strict - workspace Clippy clean; 1,829 default + 2,003 CRDT library tests; - lean4 server 40/40; lean4 stage 1 9/9; dispatch seams 15/15; - multi-root 13/13; M4 121; required GPU 155; **isolated-config - serial workspace sweep 3,229 across 94 suites, zero failures**; - `git diff --check` clean. (Round 1 of - this entry recorded 17/17 and 3,206 — the PRE-fix counts — after the - fixes were pushed. The ledger's protocol is that verification - describes the pushed tree; recording it late is the #161 fmt-blocker - error in a slower form.) - -### Stage 4 — framing rev 6, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`) +### Stage 4 — framing rev 7, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`) - Stages 3a and 3b **merged as #167** (`main` @ `6f348c9`) and **#170** (`main` @ `d400f30`), 2026-07-26. Both were integrated against a main that had advanced 50 commits mid-review; the only conflict either time was this ledger's own lane headings, resolved by keeping both sides. - Worktree `../pmacs-lean-stage4`, branched off `main` @ `d400f30`. - Framing-only so far: `docs/lean4-mode-framing.md` **revision 6**. No + Framing-only so far: `docs/lean4-mode-framing.md` **revision 7**. No code. Awaiting user approval before implementation, per the workflow. +- **Round 6 review found five P1s, four of them internal to rev 6** — + facts about pmacs the revision asserted without checking, while its + external (upstream) facts held. Fixed in rev 7: Stage 4a's footprint + omitted the test file its own acceptance requires; pending + abbreviation state was keyed by buffer when pmacs is **multi-frontend** + (`EditorCore.views` is per-`FrontendId`, `take_typed_edit` is already + frontend-keyed, and `buffer.after-switch` fires with NO arguments, so + a buffer-keyed clear lets any frontend discard another's pending + state); the shortest-match rule was missing its **tie-break by source + declaration order**, which 101 prefixes depend on and a `pairs`- + iterated Lua map cannot express; and the generator's "abort on keys + needing escaping" rule **rejects the real table** (`\` is a key, `"` + begins eleven). +- **A 404 on a guessed path is not evidence of absence.** Rev 6 declared + the upstream package ships no README after fetching the package root, + with the directory listing showing `src/README.md` already in hand. + The README states the tie rule in one sentence. - **Round 5 re-scout split Stage 4 into 4a (substrate) and 4b (Lean).** 4a is the typed-edit consumer chain — `builtin/runtime/typed_edit.lua` plus `pair.lua` re-expressed as one registered consumer, no behavior diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index b2d0aeb..a176505 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,7 +1,8 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-25, after the inline-math slice (#158) landed — -the first mathematical typesetting in pmacs — following find-file (#162), +**Last updated: 2026-07-26, after Lean 4 stages 3a and 3b (#167, #170) +landed — pmacs' first Lean language server — following the inline-math +slice (#158), the first mathematical typesetting in pmacs, and find-file (#162), the dired arc's Stage 0, and COHERENCE.md (#163), Lean 4 Stage 1 (#160), the minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the @@ -25,15 +26,15 @@ 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-07-25) +## 1. Where the project stands (2026-07-26) -- `main` @ `d152120` (the bottom-panel landed-doc refresh #156 atop the - inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix - #166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163, - find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159, - bottom-panel Stage 1 #155). Protocol unchanged at **v20**. The bullets - below describe the arcs in their own terms; this line is the - head-of-`main` anchor. +- `main` @ `d400f30` (Lean 4 Stage 3b #170 atop Stage 3a #167, the + bottom-panel landed-doc refresh #156, the inline-math slice #158, + dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 Stage 2 + #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean + 4 Stage 1 #160, minimap blank-slab #159, bottom-panel Stage 1 #155). + Protocol unchanged at **v20**. The bullets below describe the arcs in + their own terms; this line is the head-of-`main` anchor. - **`COHERENCE.md` is now required reading and a required framing input — #163.** It carries the product-coherence thesis, an audited scorecard, per-concern gaps, and §20's priority order, and it is the @@ -42,6 +43,56 @@ commands, read `docs/active-work.md` immediately after this file. interaction islands added, config-registry adoption, background-work attribution. Its §2 grades the golden journey **broken at step 3** (`pmacs .` exits 1). +- **Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b LANDED** + (`docs/lean4-mode-framing.md`; #160, #161, #167, #170; merge + `d400f30`). 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 + one-shot `lean --server` fallback, and `waitForDiagnostics`. **No + protocol change in any stage** (still v20). + - **Two of the four stages contained no Lean at all**, and that is the + arc's organizing rule: *no PR mixes a cross-cutting substrate change + with Lean feature content.* Stage 2 made LSP server affinity + per-project-root (`ensure_server` had been reusing one server across + roots — a correctness bug for every language, not just Lean). Stage + 3a added notification/response subscription seams to + `handle_server_requests`, the single shared LSP event drain, plus + `pmacs.fs.canonicalize`. + - **Two consecutive re-scouts found that rule broken by the stage + being scouted** — Stage 3 in round 4, Stage 4 in round 5, each time + by a risk column that contradicted its own prose. The rule is not + self-enforcing. Re-check every remaining stage's risk column at + scout time. + - **A configured LSP root must be a canonical absolute path.** It + reaches `file_uri_for` verbatim and that URI is the affinity key, so + one package opened by two spellings spawns two servers. Stage 3a's + `pmacs.fs.canonicalize` is the primitive; it returns nil rather than + a lossy path for non-UTF-8 input. + - **`LspManager::stop` on an already-terminal client strands it in + `ShuttingDown` forever** — `server_is_live` then counts it live so + nothing rebuilds against it, and `forget` refuses it for not being + terminal. *Stopping a dead server is what makes it un-replaceable.* + Stage 3b works around it by dispatching on state (`forget` when + terminal, `stop` when live); merely skipping the call leaves + `next_restart_at` armed. The real fix is unframed substrate work. + - **`elan` shims lie**: `lake --version` and `lean --version` can both + fail ("no default toolchain configured") on a machine where Lean + otherwise works, so `command -v lake` is worthless as a capability + check. Lean acceptance is fake-server; live smokes must be PATH- + **and** success-gated. + - Stage 3b took six review rounds, and **the same defect appeared four + times**: "the fallback silently doesn't happen," as no re-attach, + then re-attach cleared by an unrelated buffer, then satisfied by the + very server being replaced, then repairing one buffer while the rest + stayed stale. Each fix was locally right; none asked what a *global* + config swap invalidates. The durable lesson is to heal at + **consumption** — the point where a stale record is handed out — not + at the moment of the swap. + - Remaining: Stage 4a (typed-edit consumer chain) and 4b (the Unicode + input method) are framed and awaiting approval; stages 5 (goal + panel), 6 (`#eval` output channel), and 7 (module hierarchy) are + framed but not scouted against current `main`. + - **Inline math LANDED — #158** (`docs/inline-math-slice-framing.md` rev 3; merge `5aa9044`). pmacs renders `$…$` as typeset mathematics in the GPU frontend. **No protocol change (still v20); the whole slice lives in diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index e39eb36..ca50696 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -46,7 +46,7 @@ during a rebase. ## 0.1 Revision history -Revision 1 — initial. +Revision 1 — initial. Current revision: **7**. ### Round 1 (rev 1 → rev 2) @@ -288,11 +288,15 @@ landed and its citations are historical record, not navigation. Stages 3a and 3b landed (#167, #170). Re-scouting Stage 4 against `main` @ `d400f30` produced **six findings that change the plan** and three -that confirm it. The pmacs-side facts were verified in a worktree at +that confirm it. (Round 6 found five more, four of them internal to this +revision; read that section too before trusting a rev-6 statement.) The pmacs-side facts were verified in a worktree at that commit; the upstream facts were verified by downloading and reading `leanprover/vscode-lean4` at commit `17d1d08` (2026-05-29) — the algorithm, not its documentation, since the `lean4-unicode-input` -package ships no README. +package ships no README. *(Round 6: it does, at `src/README.md` — see +that section. Corrections to round 5's own numbers are marked inline +below rather than rewritten, per the standing rule that revision +entries are record, not navigation.)* 1. **Stage 4 violated this document's own splitting rule — the same way Stage 3 did.** §4 says "no PR in this arc mixes a cross-cutting @@ -377,6 +381,14 @@ package ships no README. needs a `doNotTrackNewAbbr` guard and why §2.11 records that pmacs does not. + *Corrected in round 6.* **119** symbols are multi-codepoint, of which + 26 carry `$CURSOR`; "93" was the non-`$CURSOR` subset stated as a + total. **Three** values contain a backslash — the `\` → `\` identity + entry was missed. And this entry's biggest omission is not a number: + the shortest-key rule needs a **tie-break by source declaration + order**, which the README round 5 said did not exist states outright. + §2.11 and Q#LN11 carry the corrected facts. + Confirmations, recorded because each was load-bearing and unverified: 7. **`take_typed_edit`'s one-shot contract is unchanged** @@ -404,6 +416,70 @@ Citation drift repaired per COHERENCE §25, on the same terms as round and the revision-history entries above, which are historical record rather than navigation. +### Round 6 (rev 6 → rev 7) + +The 4a/4b split held; five P1s against the revision's own content, all +real, all reproduced. Four share a root: **rev 6 verified its external +facts and under-verified its internal ones.** + +1. **Stage 4a's declared footprint excluded the tests its acceptance + required.** Q#LN10 listed three production files while 46a–46e demand + chain-specific tests that cannot live in + `tests/auto_pair_acceptance.rs` — criterion 46 requires that file + byte-identical. Footprint now names + `tests/typed_edit_chain_acceptance.rs` and adds it to the PR's gates. +2. **Pending state had the wrong owner.** §2.11 reasoned "no + multi-cursor, therefore one point" and Q#LN22 keyed pending + abbreviations by buffer. pmacs is multi-frontend: `EditorCore.views` + is per-`FrontendId` with its own active window, `take_typed_edit` is + *already* frontend-keyed, and `pmacs.frontend.id()` exists. Two + frontends on one Lean buffer — the TUI-plus-GPU case this project + ships — would share one slot. Worse, `buffer.after-switch` takes no + arguments, so a buffer-keyed clear-on-switch lets any frontend + discard another's pending abbreviation. Now keyed + `(frontend, buffer)` with a window check, frontend-scoped + after-switch clearing, a `frontend.detached` purge, and acceptance + 45i — which the buffer-keyed design passes every other criterion + without. +3. **The shortest-match rule was missing its tie-break, and rev 6's + research method is why.** Upstream keeps declaration order among + equal-length shortest keys. The README states it in one sentence — + and rev 6 asserted "the package ships no README" after a 404 on the + package root, without checking the directory listing it had already + fetched, which shows `README.md` under `src/`. **A 404 on a guessed + path is not evidence of absence.** The rule is load-bearing: 101 + prefixes have equal-shortest candidates resolving to *different* + symbols (`f` → `f<` not `f>`; `"` picks `"A` from eleven). A `pairs`- + iterated Lua map cannot express it, so Q#LN11 now emits an ordered + sequence and Q#LN22 sorts by `(#key, source rank)`. +4. **The generator's rejection rule rejected the current table.** "Abort + on keys needing Lua escaping" would reject `\` and the eleven `"X` + keys — and acceptance 45d requires `\` to work. Replaced with + canonical lossless escaping; the generator aborts only on duplicate + keys, invalid UTF-8, and a failed self-round-trip. Relatedly, 45g + claimed the suite compares against `abbreviations.json`, which is not + shipped; it now pins self-consistency properties and leaves + source fidelity to the generator, where the source is in hand. +5. **Durable and volatile state were not reconciled** — + `docs/agent-handoff.md` still anchored `main` at `d152120` with + neither #167 nor #170, while `docs/active-work.md` kept full merged + Stage 3a/3b histories against its own instruction to remove merged + entries, under a stale July 25 snapshot date. Round 5 updated the + ledger and skipped the handoff; per CLAUDE.md both are required + reading, and the one that outranks the other was the one left wrong. + +Corrections carried in the same revision, each verified against the +data: the README exists (finding 3); there are **119** multi-codepoint +symbols, of which 26 carry `$CURSOR` — rev 6's "93" was the +non-`$CURSOR` subset reported as a total; **three** values contain a +backslash (`\`, `n`, `setminus`), not two; Q#LN22 now states the rule +acceptance 45d depended on, that an unclaimed terminating `\` is +reprocessed as a new leader; acceptance 38 now says the terminator is +retained, so undo restores `\alpha ` with its space; the coherence +section cites golden-journey **step 5** ("Edit immediately"), not step +4; and §8's config-registry prior art points at Q#LN22, where the gate +now lives. + ## 1. What ships Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The @@ -828,8 +904,15 @@ them from the store needs a Rust-side policy, not a Lua filter. Q#LN18. Scouted 2026-07-26 against `leanprover/vscode-lean4` @ `17d1d08`, package `lean4-unicode-input`, files `AbbreviationProvider.ts`, `TrackedAbbreviation.ts`, `AbbreviationRewriter.ts`, -`AbbreviationConfig.ts`, and `abbreviations.json`. The package ships no -README, so the algorithm below is read off the source. Apache-2.0. +`AbbreviationConfig.ts`, `abbreviations.json`, and — round 6 — the +package README at `lean4-unicode-input/src/README.md`. Apache-2.0. + +**Rev 6 first claimed this package ships no README. It does**, at +`src/README.md` rather than the package root, and the 404 on the root +path was taken as absence without checking the directory listing that +was already in hand. That cost the tie rule below: the README states it +in one sentence, and reading only the code left it as an inference from +`Array.prototype.sort`'s stability rather than a documented contract. **Resolution.** `findSymbolsByAbbreviationPrefix(p)` collects every key having `p` as a prefix, sorts them by **key length ascending**, and maps @@ -845,6 +928,32 @@ Verified against the table: `alpha` → `α`, `alp` → `α` (via `alpha`), surprising enough to be worth an acceptance criterion), `alp7` → `α7` via rule 2, `a` → `α` (`a` is itself a key, among 29 prefix matches). +**The tie rule, and why it is a constraint on the vendored format.** +When several shortest keys have equal length, upstream takes **the one +declared first in `abbreviations.json`**. The README says so outright; +the code achieves it because `Object.keys()` yields JSON insertion order +and `Array.prototype.sort` is stable. Ties are not rare: **101 prefixes +have equal-shortest candidates that resolve to *different* symbols**. +`f` picks `f<` → `‹` over `f>` → `›`; `"` picks `"A` → `Ä` from eleven +equal-length candidates; `(` picks `()` over `(=`, `(b`, `((`, `([`. + +A Lua table iterated with `pairs` has no order at all, so **a generated +`{ [key] = symbol }` map cannot express this contract** — it would +resolve these 101 prefixes nondeterministically, and worse, *stably +wrong* per build. Q#LN11 therefore carries source rank alongside the +symbol. + +**Two things the README explains that the code does not.** `Tab` is the +manual early-replacement trigger upstream binds, which is why +`getReplacementText`'s shortest-prefix rule is user-visible at all +rather than an internal detail. And the `[]_`/`{}_` entries in the table +are not symbols anyone types — they are **decoys**, added so that `\[` +is not uniquely-and-completely matching and therefore does not eagerly +expand before the user can type the second `[`. That is the same +collision Q#LN22 handles from the pairing side, solved upstream by +editing the data. Anyone regenerating the table must not "clean up" +those entries. + **Tracking.** The leader `\` is inserted into the buffer like any other character, and the tracked range starts after it; the replaced range spans the leader inclusive (`abbreviationRange.moveKeepEnd(-1)`). So the @@ -881,8 +990,10 @@ abbreviation the cursor has left. pmacs has no cursor-motion hook (round-5 finding 3), so this seam does not exist here and Q#LN22 makes abandonment lazy instead. -**The re-arm guard pmacs does not need.** `setminus` → `\` and `n` → -`\n`, so an expansion can insert a backslash; upstream sets +**The re-arm guard pmacs does not need.** Three values contain a +backslash — `\` → `\`, `n` → `\n`, and `setminus` → `\` (rev 6 first +said two, dropping the `\` → `\` identity entry) — so an expansion can +insert a backslash; upstream sets `doNotTrackNewAbbr` across the replace so that backslash does not open a new abbreviation. In pmacs the expansion is a programmatic `buf:replace` that arms no typed-edit record, so the chain sees nothing and cannot @@ -891,9 +1002,25 @@ contract, not by accident — and the acceptance must pin it, because a future consumer that inferred from buffer text rather than provenance would reintroduce the bug. -**What pmacs does not have to carry.** Multi-cursor. Upstream tracks a -`Set` and sorts changes bottom-up for that reason; -pmacs has one point, so one pending abbreviation per buffer. +**What pmacs does not have to carry.** Multi-cursor within a frontend. +Upstream tracks a `Set` and sorts changes bottom-up +for that reason; pmacs has one point per frontend view. + +**What pmacs has instead, and rev 6 got wrong.** Rev 6 read "no +multi-cursor" as "one point" and keyed pending state by buffer alone. +**pmacs is multi-frontend**: `EditorCore.views` is a +`HashMap`, each with its own active window and +cursor; `take_typed_edit` is already keyed by frontend +(`typed_edit_armed: Option<(FrontendId, TypedEditRecord)>`, matched +against `active_frontend`); the record carries `window` as well as +`buffer`; and `pmacs.frontend.id()` is exposed to Lua. Two frontends +editing the same Lean buffer — the ordinary TUI-plus-GPU case, not an +exotic one — would share a single buffer-keyed pending slot, so one +could extend, expand, or silently clear the other's half-typed +abbreviation. `buffer.after-switch` makes it worse: it fires with no +arguments, so a buffer-keyed clear-on-switch would let *any* frontend's +navigation discard a pending abbreviation belonging to another. Q#LN22 +keys the state accordingly. ## 3. Decisions @@ -1340,11 +1467,27 @@ claim a reader must be able to check without reconstructing `src/editor.rs`'s include list. **Stage 4a ships this and nothing else.** Its whole content is: -`typed_edit.lua`, `pair.lua` re-expressed as one registered consumer, -and the `include_str!` line. Round 5's finding 1 is why this is a PR and -not a first commit — `pair.lua` is every language's auto-pairing, and a -reviewer looking at a Lean PR should not have to also review a rewrite -of it. + +| File | Change | +|---|---| +| `builtin/runtime/typed_edit.lua` | new — the chain owner | +| `builtin/runtime/pair.lua` | re-expressed as one registered consumer | +| `src/editor.rs` | one `include_str!` line, before `pair.lua`'s | +| `tests/typed_edit_chain_acceptance.rs` | new — criteria 46a–46e | +| `tests/auto_pair_acceptance.rs` | **unchanged, zero lines** | + +Rev 6 listed only the first three and then required criteria 46a–46e, +which no existing suite can host: the auto-pairing suite must stay +untouched (that is the whole point of criterion 46), so the chain's own +behavior — take-once, priority order, claim-stops-chain, throw +containment — has nowhere to live. A declared footprint that excludes +the tests its own acceptance demands is not a footprint. The new suite +joins the required gate list for this PR alongside +`tests/auto_pair_acceptance.rs`. + +Round 5's finding 1 is why this is a PR and not a first commit — +`pair.lua` is every language's auto-pairing, and a reviewer looking at a +Lean PR should not have to also review a rewrite of it. **The no-behavior-change claim must be pinned, not asserted.** The full `tests/auto_pair_acceptance.rs` suite is a required gate for 4a and must @@ -1377,10 +1520,24 @@ file rather than estimated: | 305 keys that are proper prefixes of another | which keys can expand eagerly | | 1,550 keys uniquely-and-completely matching | the eager-expansion set | | 26 values containing `$CURSOR` | point placement | -| 93 multi-codepoint values | the replace is not one-char-for-many | +| 119 multi-codepoint symbols (26 of them `$CURSOR`-bearing) | the replace is not one-char-for-many | +| 101 prefixes with disagreeing equal-shortest ties | why the format carries source rank | + +(Rev 6 gave the multi-codepoint figure as 93, which was the count +*excluding* the `$CURSOR` entries — a subset reported as a total.) vscode-lean4 is Apache-2.0. +**Format: an ordered array, not a map.** §2.11's tie rule makes source +order semantic, and a Lua `{ [key] = symbol }` table iterated with +`pairs` cannot carry it. The generated file emits a **sequence** — +`{ {key, symbol}, ... }` in `abbreviations.json` order — plus a derived +`key → index` lookup built at load time for the exact-match case. +Resolution sorts candidates by `(#key, index)`, so the 101 ties resolve +the way upstream resolves them and the file's own line order is the +audit trail. A map-shaped emit would be nondeterministic across builds +and, once a hash order happened to be stable, *stably wrong*. + Vendor it as a generated `builtin/runtime/lean_abbrev.lua` with a header recording source repo, commit, license, entry count, and the regeneration command — the `builtin/queries/latex/highlights.scm` @@ -1405,12 +1562,29 @@ so the file is self-describing to whoever next touches it. A refresh is an ordinary PR with a visible diff — which is the point: the diff is the review. -**The generator must reject a table it cannot faithfully encode.** Keys -are ASCII today but nothing upstream promises that; a key containing a -character the emitted Lua would have to escape, or a duplicate after -normalization, aborts the regeneration rather than silently emitting a -table that disagrees with its source. Same discipline as Q#LN20's -refusal to hand back a lossy path. +**Escaping is canonical and lossless, not a rejection trigger.** Rev 6 +said the generator aborts on "a key containing a character the emitted +Lua would have to escape." **That rule rejects the current table**: `\` +is a key, `"` begins eleven keys (`"A` → `Ä` …), and acceptance 45d +requires `\` to work. The generator instead emits every key and symbol +through one canonical Lua string escaper — `\\`, `\"`, `\n`, `\r`, +`\t`, and `\ddd` for any other control byte, everything else literal +UTF-8 — chosen so the emit is byte-deterministic across runs. + +What the generator *does* abort on, because these are real corruption +rather than syntax: + +- a duplicate key after decoding (JSON permits it; the table must not), +- a key or symbol that is not well-formed UTF-8, +- a round-trip mismatch: the generator re-parses its own output and + compares the full ordered sequence against the source, entry for + entry, and fails if they differ anywhere. + +That last check is what makes the artifact trustworthy, and it belongs +in the generator rather than in the acceptance suite — the suite cannot +see `abbreviations.json`, which is not shipped. Same discipline as +Q#LN20's refusal to hand back a lossy path: refuse rather than emit +something plausible. ### Q#LN21 — Stage 4b: the expansion's undo is cross-peer-degraded; ship it, name it @@ -1466,23 +1640,54 @@ that an edit was made. reconstruction of it: - `\` typed in a `lean4` buffer opens a pending abbreviation: `{ buffer, - start_offset, text = "" }`, one per buffer, keyed on `rec.buffer`. + window, start_offset, text = "" }`, keyed on **`(frontend, buffer)`** — + see below. - A subsequent self-insert `c` is claimed iff at least one key has `text .. c` as a prefix; then `text = text .. c`. If it is also uniquely-and-completely matching (one of the 1,550), expand now. - If no key extends `text .. c`, expand `text` **first**, then let `c` land normally — the chain does *not* claim `c`. -- Expansion resolves through §2.11's three-rule `getReplacementText`, - including the suffix rule (`\alp7` → `α7`). +- **A terminating `c` that is itself `\` is then reprocessed as a new + leader**, opening a fresh pending abbreviation at its position. This + is the rule acceptance 45d depends on (`\alpha\to` → `α→`) and rev 6 + specified the acceptance without specifying the rule; upstream gets it + from `processChange`, where a `finished` abbreviation reports + `isAffected = false` and so does not suppress the new-leader branch. + Note this is *not* the `\\` case: there the pending text is empty, `\` + extends rather than terminates, and the result is one literal + backslash with no pending state left open. +- Expansion resolves through §2.11's rules — shortest key wins, ties + broken by source rank, unmatchable tail appended (`\alp7` → `α7`). - `$CURSOR` is stripped from the symbol and its index becomes the point. +**Ownership is per frontend, not per buffer** (§2.11). The key is +`(pmacs.frontend.id(), rec.buffer)`, and the stored `window` must still +match `rec.window` for the state to be usable — a frontend that moved +the same buffer into a different window is no longer typing where the +pending span is. Two consequences the buffer-only design got wrong: + +- `buffer.after-switch` fires with **no arguments**, so it cannot say + whose switch it was. The subscriber reads `pmacs.frontend.id()` at + callback time — documented as "the frontend that produced the most + recent dispatched input event" — and clears **only that frontend's** + entries. A blanket clear would let one frontend's navigation discard + another's half-typed abbreviation. +- `frontend.detached` fires with the raw frontend id and is the purge + seam, exactly as `killring.lua` uses it (Q#KR11). Without it a + detached frontend's pending state leaks for the life of the session. + +This costs one table level and buys correctness in the ordinary +TUI-plus-GPU configuration, which is not an exotic setup — it is the +one this project ships two frontends for. + **Abandonment is lazy, because there is no cursor-motion hook** (round-5 finding 3). Pending state is validated at the next typed edit and -discarded when any of these no longer holds: the record's buffer is the -pending buffer; `rec.effective_start` equals `start_offset + 1 + -#text` (the point is still at the end of the pending span); and the -buffer's `revision()` advanced by exactly the pending edit. `buffer. -after-switch` clears it eagerly since that hook *does* exist. The +discarded when any of these no longer holds: the record's buffer and +window are the pending ones; `rec.effective_start` equals `start_offset ++ 1 + #text` (the point is still at the end of the pending span); and +the buffer's `revision()` advanced by exactly the pending edit. +`buffer.after-switch` clears the acting frontend's entries eagerly, +since that hook *does* exist. The practical difference from upstream: a user who clicks away mid-`\alp` and types elsewhere gets the pending state dropped rather than expanded. Upstream expands it. **This is a deliberate divergence** — expanding @@ -2138,6 +2343,11 @@ substrate pin, filed under Stage 4 only because Stage 4 was one stage. Per the no-renumbering rule above, round 5's additions take letter suffixes on both sides of the split. +46a–46e live in a **new `tests/typed_edit_chain_acceptance.rs`**, which +is part of Stage 4a's declared footprint (Q#LN10) and a required gate +for its PR. They cannot live in `tests/auto_pair_acceptance.rs`, which +criterion 46 requires to stay byte-identical. + 46. **Provenance-refactor pin:** the full `tests/auto_pair_acceptance.rs` suite passes **unmodified**. A suite edited to accommodate the refactor proves nothing; the diff for 4a must show zero lines @@ -2164,8 +2374,12 @@ suffixes on both sides of the split. **Stage 4b — the Unicode input method** -38. `\alpha` + space yields `α`; the whole expansion is a single undo - step, and one undo restores `\alpha` rather than `\alph`. +38. `\alpha` + space yields `α ` — the space lands first and the + expansion runs in the following `buffer.after-edit`, so the + terminator is **retained**, not consumed. The expansion is a single + undo step: one undo restores `\alpha ` (with its space), not + `\alph`. Rev 6 wrote the post-undo text as `\alpha`, which would be + true only if the terminator were swallowed. 39. `\<>` yields `⟨⟩` with the point between them, from the `$CURSOR` placeholder. 40. **Pair-collision pin (Q#LN22).** `\[[]]` yields `⟦⟧`: each `[` is @@ -2211,6 +2425,14 @@ suffixes on both sides of the split. because the expansion is a programmatic replace that arms no record. Bites against a future consumer that infers pending state from buffer text instead of provenance. +45i. **Pending state is per frontend (Q#LN22).** Two frontends attached + to the same `lean4` buffer: A types `\al`, B types `\to` + space in + the same buffer. B's expansion yields `→` and leaves A's `\al` + pending and intact; A then typing `l` + space still yields `∀`. + Plus: B switching buffers does not clear A's pending state, and a + `frontend.detached` for B purges B's entries only. Bites against the + buffer-keyed design rev 6 specified — which passes every + single-frontend criterion above. 45f. **Both producers, and the CI-darkness stated.** The dispatch path is pinned by the criteria above. The optimistic CRDT producer (round-5 finding 4) is pinned by a separate criterion driving @@ -2222,11 +2444,30 @@ suffixes on both sides of the split. verified only locally and name the command. Silence here is the failure mode — a green CI would otherwise read as covering the path most users take. -45g. **Table integrity.** The generated `lean_abbrev.lua` round-trips: - its entry count matches the header's declared count, and a spot set - of entries (`alpha`, `to`, `<>`, `+ `, `\`, `n`, `setminus`) matches - `abbreviations.json` byte-for-byte. Bites against a generator that - silently drops or mangles keys (Q#LN11). +45g. **Table integrity — what the suite can actually check.** + `abbreviations.json` is not shipped, so the suite cannot diff + against it and rev 6's "matches byte-for-byte" was unbuildable; a + count plus seven spot entries could not prove 1,855 round-trip + anyway. The full source-fidelity check belongs to the generator + (Q#LN11: re-parse own output, compare the ordered sequence entry for + entry, fail on any difference). What the suite pins instead are + self-consistency properties that a corrupt emit breaks: + - the loaded sequence's length equals the header's declared count, + and equals the declared count for the recorded upstream commit; + - every key is unique, and the derived `key → index` lookup has the + same cardinality as the sequence (a collision would silently drop + entries); + - every key and symbol is well-formed UTF-8, and no symbol contains + `$CURSOR` more than once; + - the resolution spot-set behaves: `alpha`, `to`, `<>`, `+ `, `\`, + `n`, `setminus`, and the tie cases from 45h. +45h. **Tie-break by source order (§2.11).** `\f` + space yields `‹` — + `f<` and `f>` are both length 2, and `f<` is declared first. Same + for `\"` + space → `Ä`, first of eleven equal-length candidates. + **This is the criterion that bites a map-shaped vendored table**: + with `pairs` iteration it passes or fails by hash order, so it must + also be run against a deliberately reversed sequence and shown to + fail. 101 prefixes are exposed to this rule. **Stage 5 — the goal view** @@ -2294,7 +2535,7 @@ suffixes on both sides of the split. discipline on transformed source edits, and Q#AP1's optimistic-classifier limitation. Stage 4a generalizes the first; 4b is built on all three. - **#127 (config registry)** — `pmacs.config.define` and the - source-buffer-resolution correction. Q#LN10's gate follows + source-buffer-resolution correction. Q#LN22's gate follows `editing.auto-pair` exactly. - **#129 (mode system)** — mode-scoped keymaps for Stage 5. - **#155 (bottom panel)** — `pmacs.window.display` and the panel adopter @@ -2381,9 +2622,10 @@ PR. (config registry) secondarily, by adding one option in the established shape rather than a new switch mechanism. -**Golden journey (§2).** No step is touched by 4a. 4b improves step 4 -(editing) for Lean specifically and changes nothing for any other -language: the pending-abbreviation state exists only in `lean4` buffers. +**Golden journey (§2).** No step is touched by 4a. 4b improves **step 5 +("Edit immediately")** for Lean specifically and changes nothing for any +other language — rev 6 cited step 4, which is "Understand the visible +interface" and is untouched by both stages: the pending-abbreviation state exists only in `lean4` buffers. Neither stage changes launch, open, or attach. **Interaction islands (§6).** **None added, and this is the load-bearing From 2eb6218ccd3400e3aca640898b29c00b8055f3a6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 10:45:32 -0400 Subject: [PATCH 45/91] fix(terminal): close review round 1 on copy mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all real, and they rhyme in pairs. Two implementation defects and two vacuous pins, all four tracing to one root: a name is not an identity, and a context-free readout is not a state observation. A foreign buffer carrying the snapshot's name was adopted and then overwritten. `pmacs.buffer.create` accepts any caller-chosen name and snapshot writes use bypass_intercept, so found-by-name adoption clobbered user data — the reviewer reproduced "do not clobber" becoming 23 newlines. Now follows dired's F7 rule: ownership means "in copy mode's own handle table", never "found by name", and a taken name yields a `<2>` variant. Snapshot identity was keyed by terminal NAME. `TerminalManager::open` uniquifies only the derived name — an explicit `name = ...` is inserted verbatim — so two valid terminals can share one, and a name-keyed table handed them a single snapshot: the second invocation retargeted it, `q` returned to the wrong terminal, and killing either removed the shared buffer. Identity is now the terminal buffer, compared in an array, because BufferIdLua implements `__eq` but each wrapper is a distinct table key: comparison works, hashing does not. The kill-with-terminal callback now closes over its own record rather than looking the name up again. The refresh pins were vacuous. Acceptance 19 compared a quiet terminal's snapshot against itself and 18 counted buffers, so both passed with render_snapshot replaced by a no-op. The child is `exec cat`, so the tests now type a marker into the focused terminal, require it ABSENT from the existing snapshot, and only then refresh — via `g` and via re-invocation respectively. The tail-follow pin could not observe view state. `TerminalManager::snapshot(buffer_id)` is context-free and always returns the live screen, so it reported "at the tail" even for a view forced to the oldest retained row. Now read through `snapshot_for_view`'s at_bottom and its projected cells. Adds acceptance 18a (a foreign same-named buffer is never adopted or clobbered) and 18b (two same-named terminals get two independent snapshots, each `q` returns to its own source, and killing one leaves the other's snapshot alive). Four new bites, all discriminating: restoring adopt-by-name fails 18a AND 18b; restoring name-keyed identity fails 18b; making render_snapshot a no-op fails BOTH 18 and 19, which is the vacuity demonstrated rather than argued; and forcing the view off the tail fails 20. Criterion 17 stays a named follow-up, per review agreement, until the real GPU probe is non-skipping and CI-executed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- builtin/runtime/terminal.lua | 125 +++++--- docs/active-work.md | 29 ++ docs/terminal-config-and-copy-mode-framing.md | 48 ++- tests/terminal_copy_mode_acceptance.rs | 290 ++++++++++++++++-- 4 files changed, 432 insertions(+), 60 deletions(-) diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua index b44e824..f2eca85 100644 --- a/builtin/runtime/terminal.lua +++ b/builtin/runtime/terminal.lua @@ -221,11 +221,56 @@ pmacs.keymap.bind { scope = "global", sequence = "C-c t", command = "terminal" } local raw_copy_retained = assert(terminal._copy_retained, "pmacs.terminal._copy_retained is required") --- snapshot buffer name -> { terminal = , buffer = } +-- An ARRAY of `{ terminal = , buffer = }`, scanned linearly and +-- compared with `==`, following dired's handle table (F7). -- --- Keyed by NAME, not by buffer handle: handles are not stable table keys, --- and a name survives the user killing the snapshot (listview precedent). -local snapshots = {} +-- Not `snapshots[name]`, and not `snapshots[buf]`, for two separate +-- reasons — both of which were live defects in review round 1: +-- +-- * **A terminal name is not a unique key.** `TerminalManager::open` +-- uniquifies only the DERIVED name; an explicitly passed +-- `name = "*same*"` is inserted verbatim +-- (`src/terminal/session.rs`, `if spec.name.is_some()`). Two valid +-- terminals can therefore share a name, and a name-keyed table gives +-- them one snapshot between them: the second invocation silently +-- retargets it, `q` returns to the wrong terminal, and killing either +-- one removes the shared buffer. +-- * **A buffer handle is not a stable table key.** `BufferIdLua` +-- implements `__eq` but each wrapper is a distinct table key, so +-- `snapshots[buf]` would miss on a freshly minted handle for the same +-- buffer. Comparison works; hashing does not. Hence the scan. +local handles = {} + +-- Compact dead entries first, so a command in a killed snapshot sees +-- "not in copy mode" rather than operating on dead state. +local function live_handles() + local live = {} + for _, h in ipairs(handles) do + local term_ok, term_valid = pcall(h.terminal.is_valid, h.terminal) + local snap_ok, snap_valid = pcall(h.buffer.is_valid, h.buffer) + if term_ok and term_valid and snap_ok and snap_valid then + live[#live + 1] = h + end + end + handles = live + return live +end + +local function handle_for_terminal(term_buf) + if term_buf == nil then return nil end + for _, h in ipairs(live_handles()) do + if h.terminal == term_buf then return h end + end + return nil +end + +local function handle_for_snapshot(buf) + if buf == nil then return nil end + for _, h in ipairs(live_handles()) do + if h.buffer == buf then return h end + end + return nil +end local function buffer_name(buf) local ok, described = pcall(pmacs.describe.buffer, buf) @@ -233,7 +278,7 @@ local function buffer_name(buf) return nil end -local function find_buffer_by_name(name) +local function buffer_named(name) for _, id in ipairs(pmacs.buffer.list()) do local ok, described = pcall(pmacs.describe.buffer, id) if ok and described and described.name == name then return id end @@ -244,11 +289,31 @@ end -- `*terminal:bash*` -> `*terminal-copy: terminal:bash*`. The surrounding -- asterisks are stripped before nesting so the result reads as one -- generated-buffer name rather than two. -local function snapshot_name_for(term_buf) +local function snapshot_base_name(term_buf) local name = buffer_name(term_buf) or "terminal" return string.format("*terminal-copy: %s*", (name:gsub("^%*", ""):gsub("%*$", ""))) end +-- How far the `<2>`, `<3>`, ... disambiguation walks before giving up. +local NAME_VARIANT_LIMIT = 99 + +-- `pmacs.buffer.create` takes any caller-chosen name, so a foreign buffer +-- may already be called `*terminal-copy: sh*` — and two same-named +-- terminals legitimately produce the same base name. Painting into a +-- buffer we did not create would clobber a user's data through +-- `bypass_intercept`, so **found-by-name is NOT adoption**: ownership +-- means "this buffer is in the handle table above", exactly as in dired. +local function unique_snapshot_name(term_buf) + local name = snapshot_base_name(term_buf) + if buffer_named(name) == nil then return name end + for i = 2, NAME_VARIANT_LIMIT do + local candidate = string.format("%s<%d>", name, i) + if buffer_named(candidate) == nil then return candidate end + end + error(string.format( + "terminal.copy-mode: %s is taken and no free variant remains", name), 0) +end + -- Q#TC7: the snapshot text comes from the SAME serializer selection-copy -- uses, so soft wraps, wide glyphs, clusters and trailing blanks cannot -- drift between the two. @@ -262,19 +327,17 @@ local function render_snapshot(record) if #text > 0 then buf:insert(0, text, { bypass_intercept = true }) end end -local function ensure_snapshot(term_buf) - local name = snapshot_name_for(term_buf) - local record = snapshots[name] - if record and record.buffer:is_valid() then - -- Q#TC8: re-invoking refreshes IN PLACE. Retarget the terminal too, - -- in case a terminal buffer was recreated under the same name. - record.terminal = term_buf - return record - end +local function claim_snapshot(term_buf) + -- Q#TC8: re-invoking against the same terminal refreshes IN PLACE. + -- Identity is the terminal BUFFER, so two same-named terminals get two + -- snapshots and neither can retarget the other's. + local existing = handle_for_terminal(term_buf) + if existing then return existing end - local buf = find_buffer_by_name(name) or pmacs.buffer.create(name) - record = { terminal = term_buf, buffer = buf } - snapshots[name] = record + local name = unique_snapshot_name(term_buf) + local buf = pmacs.buffer.create(name) + local record = { terminal = term_buf, buffer = buf } + handles[#handles + 1] = record -- Q#TC6a — BOTH calls, and the second is the load-bearing one. -- @@ -298,9 +361,11 @@ local function ensure_snapshot(term_buf) pmacs.keymap.bind { scope = "buffer", buffer = buf, sequence = "q", command = "terminal.copy-quit" } - -- Q#TC8 lifecycle, both directions. Killing the terminal takes its - -- snapshot with it; killing the snapshot alone leaves the terminal - -- running and merely forgets the record, so a later invoke rebuilds. + -- Q#TC8 lifecycle, both directions. Killing the terminal takes ITS + -- snapshot with it — `record`, captured here, not "whatever is + -- currently filed under this name"; killing the snapshot alone leaves + -- the terminal running, and `live_handles` compacts the entry out so a + -- later invoke rebuilds. -- -- `on_removed` is sound here because every user-facing kill path -- routes through `pmacs.buffer.kill`, which fires the callbacks. The @@ -310,14 +375,8 @@ local function ensure_snapshot(term_buf) -- alive, which is what makes reading back a finished command's output -- work at all. pcall(pmacs.buffer.on_removed, term_buf, function() - local current = snapshots[name] - if current and current.buffer:is_valid() then - pcall(pmacs.buffer.kill, current.buffer) - end - snapshots[name] = nil - end) - pcall(pmacs.buffer.on_removed, buf, function() - snapshots[name] = nil + local ok, valid = pcall(record.buffer.is_valid, record.buffer) + if ok and valid then pcall(pmacs.buffer.kill, record.buffer) end end) return record @@ -325,11 +384,7 @@ end -- The snapshot record whose buffer the active window shows, or nil. local function snapshot_for_current_buffer() - local buf = pmacs.window.buffer() - if not buf then return nil end - local name = buffer_name(buf) - if not name then return nil end - return snapshots[name] + return handle_for_snapshot(pmacs.window.buffer()) end function terminal.copy_mode(term_buf) @@ -338,7 +393,7 @@ function terminal.copy_mode(term_buf) if not terminal.is_terminal(term_buf) then error("terminal.copy-mode: the current buffer is not a terminal", 0) end - local record = ensure_snapshot(term_buf) + local record = claim_snapshot(term_buf) render_snapshot(record) pmacs.window.switch_buffer(record.buffer) return record.buffer diff --git a/docs/active-work.md b/docs/active-work.md index bfdb7ff..9ba9efa 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -435,6 +435,35 @@ If it does not, stop and repair the remote/fetch configuration. trailing newline); making re-invoke create a fresh buffer fails 18; dropping the kill-with-terminal teardown fails 18; removing the intercept fails 16b. Each failed exactly one test. +- **Review round 1 — four findings, all real, and they rhyme in pairs.** + Two P1 implementation defects and two P2 vacuous pins, all four tracing + to one root: **a name is not an identity, and a context-free readout is + not a state observation.** + - *P1 — a foreign same-named buffer was adopted and clobbered.* Snapshot + writes use `bypass_intercept`, so found-by-name adoption overwrote a + user's buffer; the reviewer reproduced "do not clobber" becoming 23 + newlines. Fixed by dired's F7 rule: **ownership means "in our own + handle table"**, and a taken name yields a `<2>` variant. + - *P1 — snapshot identity was keyed by terminal NAME.* + `TerminalManager::open` uniquifies only the *derived* name, so an + explicit `name = "*same*"` lets two valid terminals share one; they + then shared a snapshot, `q` returned to the wrong terminal, and + killing either removed it. Now keyed by comparing buffer handles in an + array — `BufferIdLua` implements `__eq` but each wrapper is a distinct + table key, so **comparison works and hashing does not**. + - *P2 — the refresh pins were vacuous.* 19 compared a quiet terminal's + snapshot against itself and 18 counted buffers, so both passed with + `render_snapshot` replaced by a no-op. Now the test types a marker + into the `cat` child, requires it **absent** first, then refreshes. + - *P2 — the tail-follow pin could not observe view state.* + `manager.snapshot(buffer_id)` is context-free and always reads the + live screen, so it reported "at the tail" for a view forced to the + oldest retained row. Now read through `snapshot_for_view`'s + `at_bottom` and projected cells. +- **Four more bites, all discriminating.** Restoring adopt-by-name fails + 18a *and* 18b; restoring name-keyed identity fails 18b; making + `render_snapshot` a no-op fails **both** 18 and 19 (the vacuity, + demonstrated); and forcing the view off the tail fails 20. - Load-bearing decisions, each forced by scouted ground truth: - profiles are a **raw Lua table** — `ConfigValue` is four scalars with no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`; diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md index 68a88dd..b7ced8e 100644 --- a/docs/terminal-config-and-copy-mode-framing.md +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -5,12 +5,19 @@ (`main` @ `cf54270`, 2026-07-26). Stage 2 implemented on branch `terminal-copy-mode` off `main` @ `cf54270`; no protocol change.** -**Stage 2 ships eight of its nine criteria.** Criterion 17's semantic-frontend -end-to-end pin is deliberately absent — see the note under it — because a -faithful version requires the real `pmacs-gpu` optimistic path, and therefore -the `a37` foundation, which CI never compiles and which skips silently. Both -halves of the *mechanism* it guards are pinned ungated instead (16, 16b). No -other criterion is partial. +**Stage 2 ships eight of its nine criteria, plus 18a and 18b added in review +round 1.** Criterion 17's semantic-frontend end-to-end pin is deliberately +absent — see the note under it — because a faithful version requires the real +`pmacs-gpu` optimistic path, and therefore the `a37` foundation, which CI never +compiles and which skips silently. Both halves of the *mechanism* it guards are +pinned ungated instead (16, 16b). No other criterion is partial. + +**Review round 1 found four defects, and the pair of them rhymes.** Two were +implementation (18a's foreign-buffer clobber, 18b's name-keyed identity) and +two were vacuous pins (18/19's refresh, 20's tail-follow) — and all four trace +to the same root: **a name is not an identity, and a context-free readout is +not a state observation.** The name mistake produced both P1s; the readout +mistake produced both P2s. Revision 4 gives the escape-key cache an owner and a lifecycle (Q#TC4c) — revision 3 named the key but not the storage, and two implementations @@ -608,11 +615,40 @@ additive, on its own binding, and does not replace scroll-and-select. 18. Re-invoking against the same terminal refreshes in place; the buffer count does not grow (Q#TC8). Killing the snapshot leaves the terminal running; killing the terminal removes the snapshot. + + **The refresh half must be observed by CONTENT, not by buffer count** + (review round 1). Counting buffers, or comparing a quiet terminal's + snapshot against itself, passes with `render_snapshot` replaced by a + no-op. The child is `exec cat`, so the test types a marker into the + focused terminal, requires it **absent** from the existing snapshot, and + only then re-invokes — the "advance the world" discipline. +18a. **A foreign buffer carrying the snapshot's name is never adopted.** + `pmacs.buffer.create` accepts any caller-chosen name, and snapshot writes + use `bypass_intercept`, so found-by-name adoption silently overwrites a + user's data — reproduced in review round 1 as "do not clobber" becoming + 23 newlines. Ownership means **"in copy mode's own handle table"**, which + is dired's F7 rule; a taken name yields a `<2>` variant. +18b. **Snapshot identity is the terminal BUFFER, not its name.** + `TerminalManager::open` uniquifies only the *derived* name — an explicit + `name = ...` is inserted verbatim — so two valid terminals can share one. + A name-keyed table hands them a single snapshot: the second invocation + retargets it, `q` returns to the wrong terminal, and killing either one + removes the shared buffer. Keyed instead by comparing buffer handles in + an array, because `BufferIdLua` implements `__eq` but each wrapper is a + distinct table key — comparison works, hashing does not. 19. `C-t` in a terminal buffer (physically `C-c C-t`) enters copy mode; `g` refreshes the snapshot from the live terminal and `q` returns to the source terminal (Q#TC8a). 20. The live terminal's own keys are unchanged while a snapshot exists (Q#TC9), and the terminal keeps following its tail. + + **Tail-following must be read through the registered VIEW.** Review + round 1: `TerminalManager::snapshot(buffer_id)` is context-free and + always returns the live screen, so it reports "at the tail" even for a + view forced to the oldest retained row — falsified by doing exactly + that and watching the assertion still pass. `snapshot_for_view`'s + `at_bottom` plus its projected cells are the only observables that can + tell the two apart. 21. The dispatch-shadow count is **unchanged at six** — pinned by asserting `describe-key` reports the truth for the snapshot buffer's `g` and `q`, which is the observable difference between the buffer-local idiom and a diff --git a/tests/terminal_copy_mode_acceptance.rs b/tests/terminal_copy_mode_acceptance.rs index 5b62ad8..39c2753 100644 --- a/tests/terminal_copy_mode_acceptance.rs +++ b/tests/terminal_copy_mode_acceptance.rs @@ -123,16 +123,66 @@ fn open_fill_terminal(state: &mut EditorState) -> pmacs::buffer::BufferId { buffer } +fn viewport() -> CellSize { + CellSize::new(10, 40) +} + /// Give LOCAL a window on the terminal and register/claim its view, which /// is what makes `dispatch_key`'s terminal transport arm reachable. -fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) { +/// Returns the view key, so assertions can read the *projected* view +/// rather than the context-free live screen. +fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> TerminalViewKey { state.core.borrow_mut().switch_active_buffer(buffer).ok(); let window = state.core.borrow().active_window_id(); let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer); let mut manager = state.terminal_manager.borrow_mut(); manager.register_view(key); manager.claim_controller(key); - let _ = manager.snapshot_for_view(key, CellSize::new(10, 40)); + let _ = manager.snapshot_for_view(key, viewport()); + key +} + +/// Make the child produce NEW output, so a refresh has something to find. +/// +/// The child is `exec cat`, so typing into the focused terminal echoes +/// back. Without this, "refresh" tests compare a quiet terminal against +/// itself and pass with the render replaced by a no-op — the defect review +/// round 1 found in acceptance 18 and 19. +fn emit_into_child(state: &mut EditorState, terminal: pmacs::buffer::BufferId, marker: &str) { + focus_terminal(state, terminal); + for ch in marker.chars() { + press(state, KeyCode::Char(ch), KeyModifiers::NONE); + } + assert!( + tick_until(state, marker, terminal), + "the child must echo {marker:?} back onto the live screen" + ); +} + +/// What the registered VIEW currently projects — which, unlike +/// `manager.snapshot(buffer)`, depends on where the view is anchored. +fn view_text(state: &EditorState, key: TerminalViewKey) -> String { + let mut manager = state.terminal_manager.borrow_mut(); + let Some(snapshot) = manager.snapshot_for_view(key, viewport()) else { + return String::new(); + }; + let mut text = String::new(); + for cell in &snapshot.cells { + match &cell.glyph { + Glyph::Char(c) => text.push(*c), + Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)), + Glyph::Continuation => {} + } + } + text +} + +fn view_at_bottom(state: &EditorState, key: TerminalViewKey) -> bool { + state + .terminal_manager + .borrow_mut() + .snapshot_for_view(key, viewport()) + .is_some_and(|snapshot| snapshot.at_bottom) } fn buffer_text_by_name(state: &EditorState, name: &str) -> Option { @@ -336,13 +386,30 @@ fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() { exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); let count_after_first = buffer_count(&state); + assert!( + !buffer_text_by_name(&state, SNAPSHOT_NAME) + .expect("snapshot") + .contains("REINVOKE"), + "precondition: the marker has not been emitted yet" + ); + // Advance the world, then re-invoke. Counting buffers alone is + // vacuous: it passes with the render replaced by a no-op, so the + // refresh must be observed by CONTENT that only exists after the + // first snapshot was taken. + emit_into_child(&mut state, terminal, "REINVOKE"); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME) + .expect("snapshot") + .contains("REINVOKE"), + "re-invoking must actually re-serialize, not just reuse the buffer" + ); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); assert_eq!( buffer_count(&state), count_after_first, - "re-invoking must refresh in place, not accumulate buffers" + "...and it must refresh IN PLACE, not accumulate buffers" ); // Killing the snapshot alone leaves the terminal running. @@ -402,17 +469,6 @@ fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { "C-c C-t must enter copy mode" ); - // `g` re-snapshots in place. - let before = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); - press(&mut state, KeyCode::Char('g'), KeyModifiers::NONE); - let after = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot"); - assert_eq!(before, after, "a quiet terminal re-snapshots identically"); - assert_eq!( - active_buffer_name(&state), - SNAPSHOT_NAME, - "g must not move us" - ); - // `q` returns to the source terminal. press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE); assert_eq!( @@ -420,6 +476,45 @@ fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { terminal_name, "q must return to the terminal the snapshot was taken from" ); + + // Now advance the world and come back WITHOUT re-invoking copy mode, + // so the snapshot is genuinely stale. Comparing a quiet terminal's + // snapshot against itself is vacuous — it passes with `render_snapshot` + // replaced by a no-op. + emit_into_child(&mut state, terminal, "AFTER-G"); + exec( + &state, + &format!( + r" + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == {SNAPSHOT_NAME:?} then + pmacs.window.switch_buffer(id) + end + end + " + ), + ); + assert!( + !buffer_text_by_name(&state, SNAPSHOT_NAME) + .expect("snapshot") + .contains("AFTER-G"), + "the snapshot must still be stale before `g` — otherwise the next \ + assertion proves nothing" + ); + + press(&mut state, KeyCode::Char('g'), KeyModifiers::NONE); + assert!( + buffer_text_by_name(&state, SNAPSHOT_NAME) + .expect("snapshot") + .contains("AFTER-G"), + "`g` must re-snapshot from the live terminal" + ); + assert_eq!( + active_buffer_name(&state), + SNAPSHOT_NAME, + "g must not move us" + ); state.process_supervisor.borrow_mut().shutdown(); } @@ -430,7 +525,7 @@ fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() { fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() { let mut state = EditorState::new(); let terminal = open_fill_terminal(&mut state); - focus_terminal(&state, terminal); + let key = focus_terminal(&state, terminal); exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); // Back to the terminal; its five live bindings must still resolve. @@ -453,11 +548,25 @@ fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() { ); } - // The terminal is still following its tail: the child's last output is - // visible without scrolling. + // The terminal still FOLLOWS ITS TAIL while a snapshot exists. + // + // Read through the registered view, not `manager.snapshot(buffer)`: + // that call is context-free and always returns the live screen, so it + // reports "at the tail" even for a view forced to the oldest retained + // row. The projected view is the only thing that can distinguish them. assert!( - screen_text(&state, terminal).contains("DONE"), - "the live terminal keeps following its tail" + view_at_bottom(&state, key), + "precondition: the view starts at the tail" + ); + emit_into_child(&mut state, terminal, "TAILMARK"); + assert!( + view_at_bottom(&state, key), + "new child output must not knock the view off the tail" + ); + assert!( + view_text(&state, key).contains("TAILMARK"), + "the freshest output must be visible in the PROJECTED view: {:?}", + view_text(&state, key) ); state.process_supervisor.borrow_mut().shutdown(); } @@ -503,6 +612,149 @@ fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() { state.process_supervisor.borrow_mut().shutdown(); } +/// Acceptance 18a (review round 1, P1): a foreign buffer that happens to +/// carry the snapshot's name is **never adopted**. +/// +/// `pmacs.buffer.create` takes any caller-chosen name, and snapshot writes +/// use `bypass_intercept`, so found-by-name adoption clobbers a user's +/// data outright. Ownership means "in copy mode's own handle table" +/// (dired's F7 rule); a taken name gets a `<2>` variant instead. +#[test] +fn acc18a_a_foreign_same_named_buffer_is_never_adopted_or_clobbered() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + + // A user's buffer, sitting exactly where the snapshot wants to go. + exec( + &state, + &format!( + r" + FOREIGN = pmacs.buffer.create({SNAPSHOT_NAME:?}) + FOREIGN:insert(0, 'do not clobber') + " + ), + ); + + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let foreign_text: String = eval(&state, r"return FOREIGN:slice(0, FOREIGN:len())"); + assert_eq!( + foreign_text, "do not clobber", + "the foreign buffer must be untouched" + ); + assert_ne!( + active_buffer_name(&state), + SNAPSHOT_NAME, + "copy mode must not display the foreign buffer" + ); + assert_eq!( + active_buffer_name(&state), + format!("{SNAPSHOT_NAME}<2>"), + "a taken name must yield a unique variant" + ); + assert!( + buffer_text_by_name(&state, &format!("{SNAPSHOT_NAME}<2>")) + .expect("variant snapshot") + .contains("LINE200"), + "the variant is the real snapshot" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + +/// Acceptance 18b (review round 1, P1): snapshot identity is the terminal +/// BUFFER, not its name. +/// +/// `TerminalManager::open` uniquifies only the *derived* name — an +/// explicit `name = ...` is inserted verbatim — so two valid terminals can +/// share a name. Keying snapshots by name gives them one buffer between +/// them: the second invocation retargets it, `q` returns to the wrong +/// terminal, and killing either one removes the shared snapshot. +#[test] +fn acc18b_two_same_named_terminals_get_two_independent_snapshots() { + let mut state = EditorState::new(); + exec(&state, FILL_PROFILE); + + let before = terminal_buffers(&state); + exec( + &state, + r#"TERM_A = pmacs.terminal.open { profile = "fill", name = "*same*" }"#, + ); + exec( + &state, + r#"TERM_B = pmacs.terminal.open { profile = "fill", name = "*same*" }"#, + ); + let fresh: Vec<_> = terminal_buffers(&state) + .into_iter() + .filter(|id| !before.contains(id)) + .collect(); + assert_eq!(fresh.len(), 2, "two terminals opened under one name"); + + // Distinguish them by content, since their names are identical. + emit_into_child(&mut state, fresh[0], "AAAA"); + emit_into_child(&mut state, fresh[1], "BBBB"); + + focus_terminal(&state, fresh[0]); + let snap_a: String = eval( + &state, + r"local b = pmacs.terminal.copy_mode(TERM_A); return (pmacs.describe.buffer(b)).name", + ); + focus_terminal(&state, fresh[1]); + let snap_b: String = eval( + &state, + r"local b = pmacs.terminal.copy_mode(TERM_B); return (pmacs.describe.buffer(b)).name", + ); + + assert_ne!( + snap_a, snap_b, + "two terminals must not share one snapshot buffer" + ); + let text_a = buffer_text_by_name(&state, &snap_a).expect("snapshot A"); + let text_b = buffer_text_by_name(&state, &snap_b).expect("snapshot B"); + assert!( + text_a.contains("AAAA") && !text_a.contains("BBBB"), + "snapshot A must hold only A's output: {:?}", + &text_a[text_a.len().saturating_sub(60)..] + ); + assert!( + text_b.contains("BBBB") && !text_b.contains("AAAA"), + "snapshot B must hold only B's output" + ); + + // `q` from each snapshot returns to ITS OWN terminal, which is only + // observable through the buffer id — the two names are the same. + exec( + &state, + &format!( + r" + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == {snap_b:?} then pmacs.window.switch_buffer(id) end + end + " + ), + ); + press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE); + let returned_is_b: bool = eval(&state, r"return pmacs.window.buffer() == TERM_B"); + assert!( + returned_is_b, + "q from B's snapshot must return to terminal B" + ); + + // Killing terminal A removes only A's snapshot. + exec(&state, "pmacs.terminal.terminate(TERM_A)"); + exec(&state, "pmacs.buffer.kill(TERM_A)"); + assert!( + buffer_text_by_name(&state, &snap_a).is_none(), + "A's snapshot dies with A" + ); + assert!( + buffer_text_by_name(&state, &snap_b).is_some(), + "B's snapshot must SURVIVE — a shared buffer would have gone too" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + /// Copy mode refuses a non-terminal buffer rather than producing an empty /// snapshot of nothing. #[test] From 174e36fce384375b94c4c84af5a7a27f41e318da Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 10:49:43 -0400 Subject: [PATCH 46/91] docs(lean4): reconcile peer edits with pending ownership Advance the Stage 4 framing to revision 8. Keep pending abbreviation state frontend-owned while conservatively invalidating it after any intervening shared-buffer edit, make the revision token explicit, and rewrite acceptance 45i around that contract. Correct the active-work multi-codepoint count and the stale coherence revision label. --- docs/active-work.md | 18 ++++++++--- docs/lean4-mode-framing.md | 62 ++++++++++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 47c3677..cf4ea6e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -64,17 +64,17 @@ If it does not, stop and repair the remote/fetch configuration. histories were pruned from this ledger in round 6, per this file's own instruction to remove entries when their PR merges; the durable facts now live in `docs/agent-handoff.md` §1's Lean 4 bullet, which is where - a fresh machine should read them. `docs/lean4-mode-framing.md` rev 7 + a fresh machine should read them. `docs/lean4-mode-framing.md` rev 8 carries the decisions. -### Stage 4 — framing rev 7, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`) +### Stage 4 — framing rev 8, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`) - Stages 3a and 3b **merged as #167** (`main` @ `6f348c9`) and **#170** (`main` @ `d400f30`), 2026-07-26. Both were integrated against a main that had advanced 50 commits mid-review; the only conflict either time was this ledger's own lane headings, resolved by keeping both sides. - Worktree `../pmacs-lean-stage4`, branched off `main` @ `d400f30`. - Framing-only so far: `docs/lean4-mode-framing.md` **revision 7**. No + Framing-only so far: `docs/lean4-mode-framing.md` **revision 8**. No code. Awaiting user approval before implementation, per the workflow. - **Round 6 review found five P1s, four of them internal to rev 6** — facts about pmacs the revision asserted without checking, while its @@ -93,6 +93,15 @@ If it does not, stop and repair the remote/fetch configuration. the upstream package ships no README after fetching the package root, with the directory listing showing `src/README.md` already in hand. The README states the tie rule in one sentence. +- **Round 7 review found one remaining P1 in acceptance 45i.** Rev 7 + required A's pending abbreviation to survive B editing the same + buffer, while Q#LN22 also required an exact buffer-revision advance. + Those cannot both hold: revisions are buffer-global and every edit + bumps them. Rev 8 keeps the conservative guard and separates + ownership from survival — B cannot consume A's record, but B editing + the shared buffer invalidates A lazily; B switching buffers or + detaching remains frontend-scoped when no shared-buffer edit + intervenes. - **Round 5 re-scout split Stage 4 into 4a (substrate) and 4b (Lean).** 4a is the typed-edit consumer chain — `builtin/runtime/typed_edit.lua` plus `pair.lua` re-expressed as one registered consumer, no behavior @@ -130,7 +139,8 @@ If it does not, stop and repair the remote/fetch configuration. - Table facts re-derived at `17d1d08`: 1,855 entries, 36,861 bytes, all keys ASCII, **64** keys carry a `lean4` pair-set char, **305** keys are proper prefixes of another (so 1,550 expand eagerly), **26** values - carry `$CURSOR`, **93** are multi-codepoint. + carry `$CURSOR`, and **119** are multi-codepoint — the 26 + `$CURSOR`-bearing values plus 93 others. - Citation sweep per COHERENCE §25: five live citations moved in the 50 commits since rev 5 — `take_typed_edit` 12827→12990, `handle_server_requests` 1549→1815, `fs.stat` 93→133, diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index ca50696..650b2f3 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -46,7 +46,7 @@ during a rebase. ## 0.1 Revision history -Revision 1 — initial. Current revision: **7**. +Revision 1 — initial. Current revision: **8**. ### Round 1 (rev 1 → rev 2) @@ -480,6 +480,29 @@ section cites golden-journey **step 5** ("Edit immediately"), not step 4; and §8's config-registry prior art points at Q#LN22, where the gate now lives. +### Round 7 (rev 7 → rev 8) + +One P1 remained in the new multi-frontend acceptance, plus two +documentation cleanups. + +1. **Acceptance 45i contradicted Q#LN22's conservative abandonment + rule.** It required frontend A's pending abbreviation to survive + frontend B editing the same buffer, but `buffer:revision()` is + buffer-global and advances on every edit. B's first edit therefore + invalidates A's record under the exact-revision guard. The criterion + now separates the two contracts: another frontend cannot consume A's + record, but any intervening edit to their shared buffer invalidates + it lazily; navigation and detachment remain frontend-scoped when no + shared-buffer edit intervenes. The pending record now names its + `expected_revision` explicitly so the validation rule is buildable. + Preserving A's record through peer edits would require translating + and validating its span across arbitrary edits, a substantially + larger substrate change that Stage 4b does not take on. +2. **The volatile ledger retained rev 6's undercount.** Its table facts + now say 119 multi-codepoint symbols — 26 `$CURSOR`-bearing and 93 + others — matching §2.11 and Q#LN11. +3. **§9.1's revision label was stale.** It now names rev 8. + ## 1. What ships Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The @@ -1640,8 +1663,9 @@ that an edit was made. reconstruction of it: - `\` typed in a `lean4` buffer opens a pending abbreviation: `{ buffer, - window, start_offset, text = "" }`, keyed on **`(frontend, buffer)`** — - see below. + window, start_offset, text = "", expected_revision }`, keyed on + **`(frontend, buffer)`** — see below. `expected_revision` is the + buffer's revision after that leader edit. - A subsequent self-insert `c` is claimed iff at least one key has `text .. c` as a prefix; then `text = text .. c`. If it is also uniquely-and-completely matching (one of the 1,550), expand now. @@ -1685,7 +1709,14 @@ finding 3). Pending state is validated at the next typed edit and discarded when any of these no longer holds: the record's buffer and window are the pending ones; `rec.effective_start` equals `start_offset + 1 + #text` (the point is still at the end of the pending span); and -the buffer's `revision()` advanced by exactly the pending edit. +the buffer's `revision()` equals `expected_revision + 1`, meaning the +current typed edit is the only edit since this frontend last extended +the pending abbreviation. A claimed extension stores the current +revision as the new `expected_revision`. This is deliberately +conservative across frontends: any intervening edit to the shared +buffer invalidates the pending record even if it occurred elsewhere. +Keeping the record alive would require translating and validating its +span through arbitrary peer edits, substrate Stage 4b does not add. `buffer.after-switch` clears the acting frontend's entries eagerly, since that hook *does* exist. The practical difference from upstream: a user who clicks away mid-`\alp` @@ -2425,14 +2456,19 @@ criterion 46 requires to stay byte-identical. because the expansion is a programmatic replace that arms no record. Bites against a future consumer that infers pending state from buffer text instead of provenance. -45i. **Pending state is per frontend (Q#LN22).** Two frontends attached - to the same `lean4` buffer: A types `\al`, B types `\to` + space in - the same buffer. B's expansion yields `→` and leaves A's `\al` - pending and intact; A then typing `l` + space still yields `∀`. - Plus: B switching buffers does not clear A's pending state, and a - `frontend.detached` for B purges B's entries only. Bites against the - buffer-keyed design rev 6 specified — which passes every - single-frontend criterion above. +45i. **Pending state is per frontend, with conservative shared-buffer + invalidation (Q#LN22).** Two frontends share a `lean4` buffer at + distinct points. A types `\al`; B types `p`. B's `p` lands normally + at B's point rather than extending A's record. Because that edit + advances the shared buffer's revision, A then typing `l` + space + leaves literal `\all ` rather than expanding: A's stale record is + abandoned lazily. In a fresh setup, A types `\al`, B switches + buffers **without editing the shared buffer**, and A typing `l` + + space still yields `∀`; B's switch clears only B's entries. Finally, + `frontend.detached` for B purges B's entries only and does not clear + a still-valid A record. Bites both against the buffer-keyed design + rev 6 specified and against the impossible rev-7 promise that + pending state survives arbitrary peer edits. 45f. **Both producers, and the CI-darkness stated.** The dispatch path is pinned by the criteria above. The optimistic CRDT producer (round-5 finding 4) is pinned by a separate criterion driving @@ -2615,7 +2651,7 @@ uncapped event queue, the dropped `cfg.restart`, and — unchanged from languages other than Lean, and §4's rule is what keeps them out of a Lean PR. -### 9.1 Coherence impact — stages 4a and 4b (rev 6) +### 9.1 Coherence impact — stages 4a and 4b (rev 8) **Sections served.** §6 (interaction islands) primarily, and in the *preventing* direction rather than the fixing one — see below. §11 From 24ca9062944d639e4aa90c3567a9e67c4497e49b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 13:00:03 -0400 Subject: [PATCH 47/91] feat(typed-edit): the typed-edit consumer chain (Arc 8 Stage 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pmacs.editor.take_typed_edit()` is one-shot and per-frontend (Q#AP9): the first `buffer.after-edit` callback to call it clears the slot, and every later callback in the same fan-out sees nil. That was survivable only because auto-pairing was the sole consumer — never a property anyone chose. A second independent caller would get nil or steal the record from pairing depending on hook registration order, and registration order is not a contract. This makes it one. `builtin/runtime/typed_edit.lua` owns the single after-edit subscriber that reads the record, and offers that one read to consumers registered through `pmacs.typed_edit.add_consumer{ name, priority, fn }`: lowest priority first, ties by registration order, and the first consumer to return truthy claims the edit and stops the chain. `pair.lua` becomes that chain's only consumer, at priority 100. No Lean content. Stage 4b's abbreviation expander is what needs the ordering guarantee (64 of its 1,855 keys contain a `lean4` pair-set character, so pairing running first corrupts them), but the chain is substrate every language runs through, which is why it ships alone — framing Q#LN10, and §4's rule that no PR in this arc mixes a cross-cutting substrate change with Lean feature content. Three design points worth review attention: - Consumers are called even when the record is nil. "This fan-out carried no typed edit" is information a consumer acts on: it is how pairing's test seam observes a non-event, and how Stage 4b will abandon a pending abbreviation an unrelated edit invalidated. Three existing auto-pairing tests fail if the chain skips consumers on nil. - The chain pcalls each consumer. `buffer.after-edit` is all-must-succeed, so a throwing consumer would otherwise fail the fan-out for every other subscriber, including lsp.lua's didChange flush. Behavior-preserving for pairing, which already never throws. - Ordered insertion, not `table.sort`, which is not stable in Lua — "ties by registration order" is a stated contract, not a coincidence. `tests/auto_pair_acceptance.rs` is UNCHANGED — zero lines — and its 45 tests pass. That is criterion 46 and the whole no-behavior-change claim; a suite edited to accommodate the refactor would prove nothing. `tests/typed_edit_chain_acceptance.rs` adds 9 tests for criteria 46a-46e. Every one is bite-verified by mutation: appending instead of ordered insert (5 fail), `>=` for the tiebreak (1), re-taking per consumer (4), ignoring the claim (1), dropping the pcall (1), skipping nil fan-outs (1 here plus 3 in the untouched auto-pair suite), and loading the chain after lsp.lua (the Q#AP7 flush test fails, alongside the two existing pairing ones). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- builtin/runtime/pair.lua | 85 +++-- builtin/runtime/typed_edit.lua | 112 ++++++ src/editor.rs | 15 + tests/typed_edit_chain_acceptance.rs | 517 +++++++++++++++++++++++++++ 4 files changed, 699 insertions(+), 30 deletions(-) create mode 100644 builtin/runtime/typed_edit.lua create mode 100644 tests/typed_edit_chain_acceptance.rs diff --git a/builtin/runtime/pair.lua b/builtin/runtime/pair.lua index 9ed9d1f..5869dce 100644 --- a/builtin/runtime/pair.lua +++ b/builtin/runtime/pair.lua @@ -4,20 +4,30 @@ -- next char is already `)` steps over it instead of doubling it. The -- carrier is a `buffer.after-edit` reaction (Q#AP1): the opener stays -- a genuine single-codepoint self-insert — the classification --- signature help depends on — and this hook inserts (or swallows) the --- closer as a second edit. Provenance is the exact one-shot typed-edit --- record (`pmacs.editor.take_typed_edit()`, Q#AP9), not buffer-text --- inference: pastes, programmatic edits, manual hook runs, and a stale --- `this_command` have no record and never pair, and a transformed, --- relocated, or context-switching source self-insert fails closed. +-- signature help depends on — and this reaction inserts (or swallows) +-- the closer as a second edit. Provenance is the exact one-shot +-- typed-edit record (`pmacs.editor.take_typed_edit()`, Q#AP9), not +-- buffer-text inference: pastes, programmatic edits, manual hook runs, +-- and a stale `this_command` have no record and never pair, and a +-- transformed, relocated, or context-switching source self-insert fails +-- closed. -- --- This chunk loads BEFORE lsp.lua (Q#AP7): registration order is hook --- execution order, and lsp.lua's after-edit callback synchronously --- flushes didChange on the signature-trigger path — the closer must --- already be in the buffer when that callback runs. Everything under --- `pmacs.lsp` is therefore looked up lazily at callback time. +-- Since Arc 8 Stage 4a (Q#LN10) pairing no longer subscribes to +-- `buffer.after-edit` itself. It registers on the typed-edit chain +-- (`builtin/runtime/typed_edit.lua`), which owns the single subscriber +-- and the single one-shot read. Everything above still holds — the +-- record is the same record — but the chain, not this file, decides +-- who sees it and in what order. -- --- Framing: docs/auto-pairing-framing.md. +-- This chunk loads AFTER typed_edit.lua (it registers into it) and +-- BEFORE lsp.lua (Q#AP7): registration order is hook execution order, +-- and lsp.lua's after-edit callback synchronously flushes didChange on +-- the signature-trigger path — the closer must already be in the +-- buffer when that callback runs. Everything under `pmacs.lsp` is +-- therefore looked up lazily at callback time. +-- +-- Framing: docs/auto-pairing-framing.md; Stage 4a in +-- docs/lean4-mode-framing.md Q#LN10. pmacs.pair = pmacs.pair or {} @@ -40,7 +50,7 @@ local ed = pmacs.editor -- Per-buffer on/off switch (Q#CR8's flagship adopter). Read against the -- SOURCE buffer of the typed edit, never the currently active one — see --- the hook body below, which resolves it the same way `set_for` resolves +-- the consumer body below, which resolves it the same way `set_for` resolves -- the buffer's pair set (round 2, finding 2): `rec.buffer`, not -- `pmacs.window.buffer()`. pmacs.config.define { @@ -214,28 +224,36 @@ end -- Acceptance tests flip `_capture_records` on; each fan-out then -- publishes the record it observed (or nil) to `_last_record`, which -- is how tests read the exact codepoint / effective triple and prove --- one-shot-ness (this callback registers first and consumes it). +-- one-shot-ness (the chain takes the record before any other +-- `buffer.after-edit` subscriber can, and hands it here). pmacs.pair._capture_records = false -pmacs.hook.add("buffer.after-edit", function() +-- The typed-edit consumer (Arc 8 Stage 4a, Q#LN10). `rec` is the one +-- record `typed_edit.lua` read for this fan-out — possibly nil, which +-- is why the capture seam below is updated before the nil guard. +-- Returns whether pairing CLAIMED the keystroke: true once it has +-- committed to reacting (a skip-over or a closer insert, landed or +-- intercept-rejected), false on every decline. Pairing is last of the +-- builtin consumers, so nothing currently observes that value; it is +-- stated correctly so it stays correct when something does. +local function on_typed_edit(rec) -- One-shot provenance (Q#AP9). Absence — paste, programmatic edit, -- manual hook run, rejected insert, a post-insert mutation by the -- command, stale `this_command` — is a silent non-event; only a -- live record for a pair-set character that then fails a gate -- reports. - local rec = ed.take_typed_edit and ed.take_typed_edit() if pmacs.pair._capture_records then pmacs.pair._last_record = rec end - if not rec then return end - if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end + if not rec then return false end + if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return false end -- The master switch, per-buffer (Q#CR4): the SOURCE buffer of the -- typed edit, resolved buffer-local -> global -> default(true). A -- second buffer of the same language is untouched by a buffer-local -- override here (acceptance 29). - if not pmacs.config.get("editing.auto-pair", rec.buffer) then return end + if not pmacs.config.get("editing.auto-pair", rec.buffer) then return false end local buf = pmacs.window.buffer() - if not buf then return end + if not buf then return false end -- Relevance first (PR #110 round 1, finding 2): pairing has no -- interest in characters outside the set, so a transformed or @@ -247,14 +265,14 @@ pmacs.hook.add("buffer.after-edit", function() -- Rust. local ch = rec.char local openers, closers = maps_for(set_for(rec.buffer)) - if not (openers[ch] or closers[ch]) then return end + if not (openers[ch] or closers[ch]) then return false end -- Fail closed on a transformed source self-insert (Q#AP3): the -- intercept's positional result stands as produced; pairing on top -- of a relocated or expanded opener would compound it. if not rec.clean then ed.set_status("auto-pair skipped: source self-insert transformed") - return + return false end -- Fail closed when the source edit's context is no longer current: -- an intercept switched window/buffer, or something moved the @@ -268,14 +286,14 @@ pmacs.hook.add("buffer.after-edit", function() or pmacs.window.current() ~= rec.window or ed.cursor() ~= rec.post_cursor then ed.set_status("auto-pair skipped: source context changed") - return + return false end -- Region guard (Q#AP3/Q#AP6): on the dispatch route type-over has -- already consumed and cleared the region. A region surviving the -- edit means the TUI's selection-blind optimistic gate let a custom -- pair char through (named deferral) — reacting would pile a closer -- onto an unconsumed region. - if ed.region() ~= nil then return end + if ed.region() ~= nil then return false end local cursor = rec.post_cursor @@ -294,19 +312,19 @@ pmacs.hook.add("buffer.after-edit", function() if not ok then -- The duplicate stays (e.g. `())`); report, no retry. ed.set_status("auto-pair skip rejected by buffer intercept") - return + return true end if estart ~= cursor or estop ~= cursor + #ch or einserted ~= 0 then ed.set_status("auto-pair skip altered by buffer intercept") repair_cursor(win0, buf, cursor, estart, estop, einserted) end - return + return true end end local closer = openers[ch] - if not closer then return end - if not should_pair(buf, cursor, closers) then return end + if not closer then return false end + if not should_pair(buf, cursor, closers) then return false end local win0 = pmacs.window.current() local ok, estart, estop, einserted = pcall(function() @@ -315,7 +333,7 @@ pmacs.hook.add("buffer.after-edit", function() if not ok then -- Nothing landed; the opener stands alone. ed.set_status("auto-pair closer rejected by buffer intercept") - return + return true end if estart ~= cursor or estop ~= cursor or einserted ~= #closer then ed.set_status("auto-pair closer altered by buffer intercept") @@ -324,4 +342,11 @@ pmacs.hook.add("buffer.after-edit", function() -- Clean path: no cursor motion — the insert landed at the cursor -- and Lua mutators move no cursors, so it already sits between the -- pair; the daemon's per-tick CursorByte re-grounds both frontends. -end) + return true +end + +pmacs.typed_edit.add_consumer { + name = "auto-pair", + priority = 100, + fn = on_typed_edit, +} diff --git a/builtin/runtime/typed_edit.lua b/builtin/runtime/typed_edit.lua new file mode 100644 index 0000000..59f7366 --- /dev/null +++ b/builtin/runtime/typed_edit.lua @@ -0,0 +1,112 @@ +-- typed_edit.lua --- the typed-character consumer chain (Arc 8 Stage 4a). +-- +-- `pmacs.editor.take_typed_edit()` is ONE-SHOT and per-frontend (Q#AP9): +-- the first `buffer.after-edit` callback to call it clears the slot, and +-- every later callback in the same fan-out --- including a nested manual +-- `pmacs.hook.run` --- sees nil. That was survivable only because +-- auto-pairing was the sole consumer, which was never a property anyone +-- chose. A second independent caller gets nil or steals the record from +-- pairing depending on hook registration order, and registration order +-- is not a contract. +-- +-- This module makes it one. It owns the single `buffer.after-edit` +-- subscriber that reads the record, and offers that one read to +-- consumers registered through `pmacs.typed_edit.add_consumer`: +-- +-- pmacs.typed_edit.add_consumer { +-- name = "auto-pair", -- for error reporting; must be unique-ish +-- priority = 100, -- LOWEST runs FIRST +-- fn = function(rec) ... return claimed end, +-- } +-- +-- A consumer returns whether it CLAIMED the edit; the first that claims +-- stops the chain. "Claimed" means the chain stops, not that an edit was +-- made --- Stage 4b's abbreviation expander claims every keystroke that +-- extends a pending abbreviation precisely so that auto-pairing does not +-- also react to it (Q#LN22). +-- +-- Priority is an explicit number rather than load-order-implied, because +-- the ordering is load-bearing (Q#LN22: 64 Lean abbreviation keys +-- contain a character in the `lean4` pair set, and pairing running first +-- corrupts them) and a reader must be able to check it without +-- reconstructing `src/editor.rs`'s include list. +-- +-- ORDERING CONTRACT: this chunk loads BEFORE pair.lua, which registers +-- into it, and therefore before lsp.lua. That preserves Q#AP7 --- see +-- pair.lua's header and the load site in `src/editor.rs`. +-- +-- Framing: docs/lean4-mode-framing.md Q#LN10. + +pmacs.typed_edit = pmacs.typed_edit or {} + +-- Consumers in run order: lowest `priority` first, registration order +-- breaking ties. Maintained by ordered INSERTION rather than +-- `table.sort`, which is not stable in Lua --- equal priorities would +-- otherwise resolve arbitrarily, and "ties broken by registration +-- order" is part of the stated contract, not an incidental property. +local consumers = {} + +-- Register a typed-edit consumer. Argument errors throw: registration +-- happens at chunk-load or config-load time, where a throw is a visible +-- startup failure rather than a silently missing feature. Nothing in +-- the after-edit path throws --- see the fan-out below. +function pmacs.typed_edit.add_consumer(spec) + if type(spec) ~= "table" then + error("pmacs.typed_edit.add_consumer: spec must be a table", 2) + end + local name, priority, fn = spec.name, spec.priority, spec.fn + if type(name) ~= "string" or name == "" then + error("pmacs.typed_edit.add_consumer: name must be a non-empty string", 2) + end + if type(priority) ~= "number" then + error("pmacs.typed_edit.add_consumer: " .. name .. + ": priority must be a number", 2) + end + if type(fn) ~= "function" then + error("pmacs.typed_edit.add_consumer: " .. name .. + ": fn must be a function", 2) + end + + -- STRICTLY-greater comparison, so a new consumer lands AFTER every + -- already-registered consumer of equal priority. That is exactly the + -- registration-order tiebreak; `>=` here would silently reverse it. + local at = #consumers + 1 + for i, c in ipairs(consumers) do + if c.priority > priority then + at = i + break + end + end + table.insert(consumers, at, { name = name, priority = priority, fn = fn }) +end + +pmacs.hook.add("buffer.after-edit", function() + local ed = pmacs.editor + -- ONE read for the whole fan-out (Q#AP9). The record may be nil --- + -- paste, programmatic mutation, manual hook run, a replicated CRDT + -- op, a stale `this_command` --- and consumers are called ANYWAY, + -- with nil. That is deliberate: "this fan-out carried no typed edit" + -- is information a consumer acts on. Auto-pairing's test seam + -- observes the non-event through it, and Stage 4b abandons a pending + -- abbreviation that an unrelated edit invalidated. Skipping the + -- fan-out on nil would leave both reading stale state. + local rec = ed.take_typed_edit and ed.take_typed_edit() + + for _, c in ipairs(consumers) do + -- `buffer.after-edit` is all-must-succeed (builtin/hooks/default.lua): + -- a throwing consumer would fail the fan-out for every OTHER + -- subscriber, including lsp.lua's didChange flush. Contain it, + -- report it, and keep going --- a broken consumer must not be able + -- to stop the editor from telling the language server what changed. + -- This matches pair.lua's existing never-throw-from-after-edit + -- discipline; it does not weaken the hook's contract for anyone + -- else, because the chain itself still never fails. + local ok, claimed = pcall(c.fn, rec) + if not ok then + ed.set_status("typed-edit consumer '" .. c.name .. "' failed: " .. + tostring(claimed)) + elseif claimed then + return + end + end +end) diff --git a/src/editor.rs b/src/editor.rs index dcff55a..673ded6 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -415,6 +415,18 @@ impl EditorState { include_str!("../builtin/runtime/listview.lua"), ) .expect("load listview builtin chunk"); + // The typed-edit consumer chain (Arc 8 Stage 4a, Q#LN10) — + // ORDERING CONTRACT: typed_edit.lua must load BEFORE pair.lua, + // which registers a consumer into it, and therefore before + // lsp.lua. It owns the single `buffer.after-edit` subscriber + // that reads the one-shot typed-edit record, so its + // registration position is what preserves Q#AP7 below. + lua_host + .eval( + Some("@pmacs/builtin/runtime/typed_edit.lua"), + include_str!("../builtin/runtime/typed_edit.lua"), + ) + .expect("load typed_edit builtin chunk"); // Auto-pairing (Arc 2, Q#AP7) — ORDERING CONTRACT: pair.lua // must load BEFORE lsp.lua. Hook callbacks run in registration // order, and lsp.lua's `buffer.after-edit` callback flushes @@ -424,6 +436,9 @@ impl EditorState { // the closer stays unsynchronized until the next edit (hook // edits don't re-fire the hook). pair.lua's `pmacs.lsp.*` // lookups are lazy and nil-guarded for the same reason. + // Since Stage 4a the closer is inserted from the chain's + // subscriber rather than pair.lua's own, which is registered + // one chunk earlier — strictly safer for this contract. lua_host .eval( Some("@pmacs/builtin/runtime/pair.lua"), diff --git a/tests/typed_edit_chain_acceptance.rs b/tests/typed_edit_chain_acceptance.rs new file mode 100644 index 0000000..c0170f4 --- /dev/null +++ b/tests/typed_edit_chain_acceptance.rs @@ -0,0 +1,517 @@ +//! Typed-edit consumer chain acceptance (Arc 8 Stage 4a, +//! docs/lean4-mode-framing.md Q#LN10, criteria 46a–46e). +//! +//! The chain owns the single `buffer.after-edit` subscriber that reads +//! the one-shot typed-edit record (Q#AP9) and offers it to consumers in +//! priority order. These tests pin the chain's OWN behavior — take-once, +//! priority ordering, claim-stops-chain, throw containment, and the +//! Q#AP7 flush ordering it inherited from `pair.lua`. +//! +//! They deliberately do not re-test auto-pairing: criterion 46 requires +//! `tests/auto_pair_acceptance.rs` to pass byte-identical, and that +//! suite is the no-behavior-change pin. Pairing appears here only as +//! the chain's last consumer, which is how 46c observes that a claim +//! really stopped the chain. +//! +//! Dispatch-driven throughout: `dispatch_key` is the producer that arms +//! the record for a grid frontend. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; +use pmacs::protocol::FrontendId; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +fn fresh_state_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-typededit-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +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() +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +/// Fresh scratch-buffer editor, cursor at 0. Scratch pairing uses the +/// `default` set, so `(` pairs — which is what 46c reads. +fn editor_with(body: &str) -> EditorState { + let s = EditorState::new(); + if !body.is_empty() { + exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})")); + } + exec(&s, "pmacs.editor.goto_byte(0)"); + s +} + +// --------------------------------------------------------------------------- +// 46a — one read for the whole fan-out +// --------------------------------------------------------------------------- + +#[test] +fn chain_reads_the_record_once_and_hands_the_same_one_to_every_consumer() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.seen = {} + local function spy(tag) + return function(rec) + -- Each consumer independently attempts its own take. Under + -- the pre-chain design this is exactly what a second + -- consumer would have done, and exactly what would have + -- returned nil (or stolen the record from pairing). + local own = pmacs.editor.take_typed_edit() + _G.seen[#_G.seen + 1] = { + tag = tag, + char = rec and rec.char, + post_cursor = rec and rec.post_cursor, + clean = rec and rec.clean, + own_take_was_nil = (own == nil), + } + return false + end + end + pmacs.typed_edit.add_consumer { name = "spy-a", priority = 1, fn = spy("a") } + pmacs.typed_edit.add_consumer { name = "spy-b", priority = 2, fn = spy("b") } + "#, + ); + + type_str(&mut s, "x"); + + let (n, a_char, b_char, a_pc, b_pc, a_clean, b_clean, a_nil, b_nil): ( + i64, + String, + String, + i64, + i64, + bool, + bool, + bool, + bool, + ) = eval( + &s, + " + local a, b = _G.seen[1], _G.seen[2] + return #_G.seen, a.char, b.char, a.post_cursor, b.post_cursor, + a.clean, b.clean, a.own_take_was_nil, b.own_take_was_nil + ", + ); + + assert_eq!(n, 2, "both consumers ran for one typed character"); + // The same record, not two reads of a slot that only one could win. + assert_eq!(a_char, "x"); + assert_eq!(b_char, "x", "the second consumer sees the record too"); + assert_eq!((a_pc, b_pc), (1, 1), "identical post_cursor"); + assert!(a_clean && b_clean, "identical clean verdict"); + // ...and the chain, not the consumers, did the taking. + assert!( + a_nil && b_nil, + "a consumer's own take_typed_edit() observes nil — the chain \ + already consumed the one-shot slot (Q#AP9)" + ); +} + +#[test] +fn consumers_run_when_the_fan_out_carries_no_record() { + // The chain calls consumers with nil rather than skipping them. + // Three tests in the auto-pairing suite depend on this (they assert + // `_last_record == nil` after a record-less fan-out), so it is a + // load-bearing decision and not an implementation detail. + let s = editor_with(""); + exec( + &s, + r#" + _G.calls, _G.nil_calls = 0, 0 + pmacs.typed_edit.add_consumer { + name = "nil-spy", priority = 1, + fn = function(rec) + _G.calls = _G.calls + 1 + if rec == nil then _G.nil_calls = _G.nil_calls + 1 end + return false + end, + } + "#, + ); + + // A manual fan-out arms no record. + exec(&s, "pmacs.hook.run(\"buffer.after-edit\")"); + + let (calls, nil_calls): (i64, i64) = eval(&s, "return _G.calls, _G.nil_calls"); + assert_eq!(calls, 1, "the consumer ran"); + assert_eq!(nil_calls, 1, "and was handed nil, not skipped"); +} + +// --------------------------------------------------------------------------- +// 46b — priority order, not registration order +// --------------------------------------------------------------------------- + +#[test] +fn consumers_run_in_priority_order_not_registration_order() { + let mut s = editor_with(""); + // Registered HIGH priority first. If the chain honored registration + // order (or `include_str!` order, which is the same failure dressed + // differently), the observed order would be the registration order. + exec( + &s, + r#" + _G.order = {} + local function mark(tag) + return function() _G.order[#_G.order + 1] = tag; return false end + end + pmacs.typed_edit.add_consumer { name = "late", priority = 30, fn = mark("late") } + pmacs.typed_edit.add_consumer { name = "early", priority = 10, fn = mark("early") } + pmacs.typed_edit.add_consumer { name = "mid", priority = 20, fn = mark("mid") } + "#, + ); + + type_str(&mut s, "x"); + + let order: String = eval(&s, "return table.concat(_G.order, ',')"); + assert_eq!( + order, "early,mid,late", + "lowest priority runs first, regardless of when it registered" + ); +} + +#[test] +fn equal_priorities_break_by_registration_order() { + // The stated tiebreak. Lua's `table.sort` is not stable, so this + // bites an implementation that sorts instead of inserting in place. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.order = {} + local function mark(tag) + return function() _G.order[#_G.order + 1] = tag; return false end + end + pmacs.typed_edit.add_consumer { name = "first", priority = 5, fn = mark("first") } + pmacs.typed_edit.add_consumer { name = "second", priority = 5, fn = mark("second") } + pmacs.typed_edit.add_consumer { name = "third", priority = 5, fn = mark("third") } + "#, + ); + + type_str(&mut s, "x"); + + let order: String = eval(&s, "return table.concat(_G.order, ',')"); + assert_eq!(order, "first,second,third"); +} + +// --------------------------------------------------------------------------- +// 46c — a claim stops the chain +// --------------------------------------------------------------------------- + +#[test] +fn a_claiming_consumer_stops_the_chain() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.later_ran = false + pmacs.typed_edit.add_consumer { + name = "claimer", priority = 1, fn = function() return true end, + } + pmacs.typed_edit.add_consumer { + name = "later", priority = 2, + fn = function() _G.later_ran = true; return false end, + } + "#, + ); + + type_str(&mut s, "("); + + let later_ran: bool = eval(&s, "return _G.later_ran"); + assert!(!later_ran, "a later consumer must not run after a claim"); + // Pairing is the chain's last consumer at priority 100, so the + // claim is observable in the buffer: no closer was inserted. This + // is the assertion that makes the criterion about behavior rather + // than about a bookkeeping flag. + assert_eq!( + buffer_text(&s), + "(", + "auto-pairing never ran, so the opener stands alone" + ); +} + +#[test] +fn a_non_claiming_consumer_does_not_stop_the_chain() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.later_ran = false + pmacs.typed_edit.add_consumer { + name = "passer", priority = 1, fn = function() return false end, + } + pmacs.typed_edit.add_consumer { + name = "later", priority = 2, + fn = function() _G.later_ran = true; return false end, + } + "#, + ); + + type_str(&mut s, "("); + + let later_ran: bool = eval(&s, "return _G.later_ran"); + assert!(later_ran, "a declining consumer passes the edit along"); + assert_eq!( + buffer_text(&s), + "()", + "and pairing, still last in the chain, reacted normally" + ); +} + +// --------------------------------------------------------------------------- +// 46d — a throwing consumer is contained +// --------------------------------------------------------------------------- + +#[test] +fn a_throwing_consumer_is_contained_reported_and_does_not_stop_the_chain() { + let mut s = editor_with(""); + exec( + &s, + r#" + _G.later_ran = false + pmacs.typed_edit.add_consumer { + name = "boom", priority = 1, + fn = function() error("consumer exploded") end, + } + pmacs.typed_edit.add_consumer { + name = "later", priority = 2, + fn = function() _G.later_ran = true; return false end, + } + "#, + ); + + // `buffer.after-edit` is all-must-succeed: an uncontained throw + // would fail the fan-out for every other subscriber, including + // lsp.lua's didChange flush. + type_str(&mut s, "("); + + let later_ran: bool = eval(&s, "return _G.later_ran"); + assert!(later_ran, "a throwing consumer must not stop the chain"); + assert_eq!( + buffer_text(&s), + "()", + "and pairing still ran — the fan-out survived the throw" + ); + let st = status(&s); + assert!( + st.contains("boom") && st.contains("consumer exploded"), + "the failure is reported by consumer name and message, got {st:?}" + ); +} + +#[test] +fn add_consumer_rejects_malformed_registrations() { + let s = editor_with(""); + for (src, want) in [ + ( + "pmacs.typed_edit.add_consumer(\"nope\")", + "spec must be a table", + ), + ( + "pmacs.typed_edit.add_consumer{ priority = 1, fn = function() end }", + "name must be a non-empty string", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", fn = function() end }", + "priority must be a number", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = 1 }", + "fn must be a function", + ), + ] { + let err = s + .lua_host + .lua() + .load(src.to_string()) + .exec() + .expect_err("malformed registration must throw"); + let msg = err.to_string(); + assert!( + msg.contains(want), + "expected {want:?} in the error for {src:?}, got {msg:?}" + ); + } +} + +// --------------------------------------------------------------------------- +// 46e — the Q#AP7 flush ordering the chain inherited +// --------------------------------------------------------------------------- + +fn fake_lsp_path() -> String { + env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() +} + +fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool { + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + let done: bool = state + .lua_host + .lua() + .load(format!("return ({flag}) == true")) + .eval() + .unwrap_or(false); + if done { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// The `text` of every `textDocument/didChange` line in the sink, in +/// arrival order. +fn did_change_texts(sink: &std::path::Path) -> Vec { + let Ok(raw) = std::fs::read_to_string(sink) else { + return Vec::new(); + }; + raw.lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange")) + .filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned)) + .collect() +} + +#[test] +fn a_chain_consumers_edit_reaches_the_first_did_change() { + // Q#AP7 generalized from pairing to the chain: lsp.lua's after-edit + // callback flushes didChange SYNCHRONOUSLY on the signature-trigger + // path, so every reaction to a typed character must already be in + // the buffer when it runs. The auto-pairing suite pins this for + // pairing; this pins it for the chain itself, which is what now + // owns the registration position. + // + // Falsified by loading typed_edit.lua after lsp.lua in + // `src/editor.rs`: the consumer's text would then arrive in the + // SECOND didChange, or not at all. + let dir = fresh_state_dir(); + let sink = dir.join("changes.jsonl"); + let sink_disp = sink.display().to_string(); + let fake = fake_lsp_path(); + + let mut s = EditorState::new(); + s.lua_host.lua().remove_app_data::(); + s.lua_host.lua().set_app_data(StateDir(dir.clone())); + exec(&s, "pmacs.lsp.config = {}"); + exec( + &s, + &format!( + "pmacs.lsp.config.rust = {{ + command = '{fake}', + env = {{ + PMACS_FAKE_LSP_MODE = 'sighelp', + PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}', + }}, + }}" + ), + ); + + // A consumer that appends a marker of its own, ahead of pairing. + // It declines the claim so pairing still runs — the assertion is + // about ordering against the flush, not about claiming. + exec( + &s, + r#" + pmacs.typed_edit.add_consumer { + name = "marker", priority = 1, + fn = function(rec) + if not rec then return false end + if rec.char ~= "(" then return false end + local buf = pmacs.window.buffer() + buf:insert(buf:len(), "Z") + return false + end, + } + "#, + ); + + let f = dir.join("a.rs"); + std::fs::write(&f, "\n").unwrap(); + let fd = f.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + let initialized = "(function() \ + for _,r in ipairs(pmacs.lsp.list()) do \ + if r.state and r.state.kind=='initialized' then return true end \ + end \ + return false \ + end)()"; + assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init"); + + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "()\nZ", + "both the chain consumer's marker and pairing's closer landed" + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let changes = loop { + s.tick_processes(); + s.tick_lsp(); + s.tick_async(); + let c = did_change_texts(&sink); + if !c.is_empty() { + break c; + } + assert!( + Instant::now() < deadline, + "no didChange reached the fake server" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!( + changes[0], "()\nZ", + "the FIRST didChange carries BOTH reactions — the chain ran \ + before lsp.lua's synchronous flush (Q#AP7)" + ); +} From c7072b49e9e7ef427fd449100aa3d3ed5b70f80a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 13:05:41 -0400 Subject: [PATCH 48/91] docs: record the Stage 4a lane and its bite table Verification describes the pushed tree, per the standing rule. Includes the bite that was worthless as first written: moving only typed_edit.lua past lsp.lua broke the runtime load instead of testing flush ordering. A bite that kills everything has not isolated anything. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- docs/active-work.md | 56 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index cf4ea6e..bd0c0b1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -57,7 +57,7 @@ git status --short --branch The `git log` command must expose `d152120` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b MERGED; Stage 4 IN FRAMING +## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b MERGED; Stage 4a IN REVIEW - **Stages 1, 2, 3a and 3b are MERGED** — #160 (`main` @ `0827dd1`), #161 (`46a1b8f`), #167 (`6f348c9`), #170 (`d400f30`). Their full @@ -146,8 +146,58 @@ If it does not, stop and repair the remote/fetch configuration. `handle_server_requests` 1549→1815, `fs.stat` 93→133, `detect_buffer_language` 452→457, `send_request`/`send_notification` 9342/9361→9507/9527. -- Verification: none yet — the branch carries no code. `git diff --check` - clean. +### Stage 4a — the typed-edit consumer chain (IMPLEMENTED, same branch) + +- Footprint exactly as Q#LN10 declares it: `builtin/runtime/typed_edit.lua` + (new, 112 lines), `pair.lua` re-expressed as one consumer, + `src/editor.rs` +15 (the `include_str!` and its ordering comment), and + `tests/typed_edit_chain_acceptance.rs` (new, 9 tests). + **`tests/auto_pair_acceptance.rs` is UNCHANGED — `git diff --stat + main...HEAD -- tests/auto_pair_acceptance.rs` is empty.** That is + criterion 46 checked at the diff, which is the only way it means + anything. +- **The chain calls consumers even when the record is nil.** This is a + decision, not an implementation detail: three existing auto-pairing + tests assert `pmacs.pair._last_record == nil` after a record-less + fan-out (paste, programmatic insert, nested manual `hook.run`), so + skipping consumers on nil fails them. Stage 4b needs the same + delivery to abandon a pending abbreviation an unrelated edit + invalidated. +- **Ordered insertion, not `table.sort`** — Lua's sort is not stable, and + "ties broken by registration order" is a stated contract. +- **The chain `pcall`s each consumer** and reports through + `set_status`. `buffer.after-edit` is all-must-succeed, so an + uncontained throw fails the fan-out for every other subscriber + including lsp.lua's didChange flush. +- **Every acceptance test is bite-verified by mutation**, per the + standing rule that a test is not evidence until the mutation it + targets has been shown to fail it: + + | Mutation | Tests it fails | + |---|---| + | append instead of ordered insert | 5 chain | + | `>=` instead of `>` in the insert scan | 1 chain (tiebreak) | + | re-take the record per consumer | 4 chain | + | ignore the claim return value | 1 chain | + | drop the `pcall` | 1 chain | + | skip consumers when `rec == nil` | 1 chain + **3 auto-pair** | + | load `typed_edit.lua` after `lsp.lua` | 1 chain + **2 auto-pair** (Q#AP7) | + + The first attempt at the last bite was WORTHLESS as written: moving + only `typed_edit.lua` past `lsp.lua` left `pair.lua` calling a nil + `add_consumer`, so the runtime failed to load and all 9 tests died — + loud, but not a test of the flush-ordering property. Moving + `typed_edit.lua` AND `pair.lua` past `lsp.lua` is the faithful + falsification: registration succeeds, the hook lands late, and exactly + the three ordering tests fail. **A bite that kills everything has not + isolated anything.** +- Verification on this branch (commit-then-gate, so this describes the + pushed tree): `cargo fmt --check` clean; strict workspace Clippy + clean; 1,832 default + 2,009 CRDT library tests; auto-pair 45/45; + typed-edit chain 9/9; M4 121; required GPU 202; **isolated-config + workspace sweep 3,328 across 97 suites, zero failures** with + `grep -c basedpyright` = 0; `git diff --check` clean. +- Stage 4b (the input method) is NOT in this PR and not started. ## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) From 8c5b39ef32afc05970b8a7f63c39bcdc8f064f26 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 13:12:08 -0400 Subject: [PATCH 49/91] fix(buffer): make generated buffers survive undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2, P1. Undo could empty the "read-only" snapshot. `render_snapshot` wrote with bypass_intercept, which leaves ordinary undo history behind, and `Buffer::undo` reaches the rope through `ensure_writable` without ever consulting the intercept chain. So a single `C-/` — or `M-x buffer.undo`, which needs no keymap at all — replaced a freshly rendered snapshot with an empty buffer. `set_round_trip_input` does not help: it routes the key into the daemon command path, which is exactly where undo runs. Rebinding the undo chords buffer-locally would not have closed this, and `compile.lua` already says so in a comment: "command/menu undo stays dispatchable". `*compilation*` and listview panels therefore carry the same latent defect today. Adds `Buffer::set_generated_contents` (Lua: `pmacs.buffer.set_generated_contents`): lift `read_only`, replace the contents skipping intercepts, discard the resulting history, re-assert `read_only`. This ships the framing's deferred immutability lane as ONE primitive rather than exposing the setter — a bare `set_read_only` would let a caller lock a buffer it can no longer refresh, which is precisely why that lane was deferred. Discarding history is load-bearing twice: it removes what undo would replay, and it stops a periodically refreshed buffer accumulating rope clones that `read_only` guarantees nothing can ever pop. New acceptance 16c drives the real M-x path (`command.invoke_interactive`), the chord, and redo, and asserts the owner's own refresh still works — the operation plain `read_only` would have broken. Acceptance 16b flips from asserting `is_read_only()` is false to true, because the property it documented is the one that was wrong. Three `buffer.rs` unit tests cover the primitive directly, including that ten refreshes leave an empty undo stack. Bite: restoring the delete+insert render reproduces the report exactly — `left: Some("")` against the full snapshot — failing 16c and 16b. Still open, and now named in the framing, COHERENCE.md §14 and the ledger: `*compilation*` and listview have not adopted the primitive and remain emptiable by `M-x buffer.undo`; a streaming-friendly variant is needed for the append case. In CRDT mode `read_only` is what refuses undo, since loro's UndoManager exposes no clear through `CrdtState`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- COHERENCE.md | 11 +- builtin/runtime/terminal.lua | 18 ++- docs/active-work.md | 33 +++++ docs/terminal-config-and-copy-mode-framing.md | 38 ++++++ src/buffer.rs | 115 ++++++++++++++++++ src/lua_bindings/mod.rs | 23 ++++ tests/terminal_copy_mode_acceptance.rs | 85 +++++++++++-- 7 files changed, 306 insertions(+), 17 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index f707234..93298fc 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -1214,7 +1214,16 @@ Primitive-by-primitive against the list above: rebindable (§6's counter-example). - **Output channel** ✓ — the compile-mode `*compilation*` model (streamed, intercept-read-only, error-rule parsing), reused by grep - and shell-command. + and shell-command. **Caveat found in terminal copy mode's review + (Stage 2): "intercept-read-only" is not read-only.** `Buffer::undo` + reaches the rope through `ensure_writable` without consulting the + intercept chain, so `M-x buffer.undo` empties such a buffer — and + rebinding the undo *chords* buffer-locally does not close it, as + `compile.lua`'s own comment admits ("command/menu undo stays + dispatchable"). `Buffer::set_generated_contents` (write + discard + history + assert `read_only`, in one authorized call) now fixes this + for the terminal snapshot; `*compilation*` and listview panels have + not yet adopted it and remain emptiable. - **Diagnostics collection** ✓ — `DiagnosticStore` + signs + unified `error.next` source. - **Transient selector** ✓ — the minibuffer (though its `source` diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua index f2eca85..5a912da 100644 --- a/builtin/runtime/terminal.lua +++ b/builtin/runtime/terminal.lua @@ -319,12 +319,18 @@ end -- drift between the two. local function render_snapshot(record) local text = raw_copy_retained(record.terminal) or "" - local buf = record.buffer - local len = buf:len() - -- Snapshot writes bypass the read-only intercept; everything else is - -- rejected by it. - if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end - if #text > 0 then buf:insert(0, text, { bypass_intercept = true }) end + -- The owner-authorized write, and the ONLY one this buffer accepts. + -- + -- Not `delete`+`insert` with `bypass_intercept` (review round 2): that + -- leaves the buffer writable at the rope, and it leaves undo history + -- behind. `Buffer::undo` reaches the rope through `ensure_writable` + -- without consulting the intercept chain, so a single `C-/` — or + -- `M-x buffer.undo`, which no buffer-local rebinding can take away — + -- replaced a freshly rendered snapshot with an empty buffer. + -- `set_generated_contents` writes, discards the history, and leaves + -- `read_only` asserted, so undo/redo and remote CRDT imports are all + -- refused at the rope. + pmacs.buffer.set_generated_contents(record.buffer, text) end local function claim_snapshot(term_buf) diff --git a/docs/active-work.md b/docs/active-work.md index 362dd1c..1ac337d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -738,6 +738,39 @@ If it does not, stop and repair the remote/fetch configuration. 18a *and* 18b; restoring name-keyed identity fails 18b; making `render_snapshot` a no-op fails **both** 18 and 19 (the vacuity, demonstrated); and forcing the view off the tail fails 20. +- **Review round 2 — one P1, and its fix retires half a named deferral.** + **Undo emptied the "read-only" snapshot.** `render_snapshot` wrote with + `bypass_intercept`, leaving ordinary undo history, and **`Buffer::undo` + reaches the rope through `ensure_writable` without ever consulting the + intercept chain** — so `C-/` *or* `M-x buffer.undo` replaced a freshly + rendered snapshot with an empty buffer. `set_round_trip_input` does not + help: it routes the key into the daemon command path, which is where + undo runs. + - **Rebinding the undo chords would NOT have fixed it**, and + `compile.lua` already says so in a comment — "command/menu undo stays + dispatchable". `*compilation*` and listview panels therefore carry the + same latent defect today. + - Fixed with `Buffer::set_generated_contents` (Lua + `pmacs.buffer.set_generated_contents`): lift `read_only`, replace + skipping intercepts, **discard history**, re-assert `read_only`. This + ships the deferred lane's two halves *as one primitive* — a bare + `set_read_only` would let a caller lock a buffer it can no longer + refresh, which is exactly why that lane was deferred. Clearing history + also stops a periodically refreshed buffer accumulating rope clones + nothing can ever pop. + - New pins: **acc16c** drives the real M-x path + (`command.invoke_interactive`), the chord, and redo, and asserts the + owner's refresh still works; **acc16b** flipped from asserting + `is_read_only()` is *false* to *true*, because the property it + described is the one that was fixed; plus three `buffer.rs` unit tests. + - Bite: restoring the `delete`+`insert` render reproduces the report + exactly — `left: Some("")` against the full snapshot — failing acc16c + and acc16b. + - **Still open:** `*compilation*` and listview remain emptiable by + `M-x buffer.undo`; the primitive they need now exists and is proven, + so the remainder is adoption plus a streaming-friendly variant. In + CRDT mode `read_only` is what refuses undo, since loro's `UndoManager` + exposes no clear through `CrdtState`. - Load-bearing decisions, each forced by scouted ground truth: - profiles are a **raw Lua table** — `ConfigValue` is four scalars with no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`; diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md index b7ced8e..9d2a494 100644 --- a/docs/terminal-config-and-copy-mode-framing.md +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -508,6 +508,35 @@ additive, on its own binding, and does not replace scroll-and-select. "skip the intercepts". Naming only the setter would have made it look like a one-line follow-up. + **PARTIALLY RETIRED in Stage 2, because review round 2 turned it from a + nice-to-have into a defect.** An intercept guards the dispatch path only, + and `Buffer::undo` reaches the rope through `ensure_writable` without ever + consulting the intercept chain — so a single `C-/` replaced a freshly + rendered snapshot with an empty buffer. Rebinding the undo chords + buffer-locally, which is `*compilation*`'s existing idiom, does **not** + close it: `compile.lua` says so itself ("command/menu undo stays + dispatchable"), and `M-x buffer.undo` needs no keymap. + + The fix ships the deferral's two halves together as **one** primitive + rather than exposing the setter: `Buffer::set_generated_contents` (Lua: + `pmacs.buffer.set_generated_contents`) lifts `read_only`, replaces the + contents skipping intercepts, **discards the history**, and re-asserts + `read_only`. Pairing the lock with the write is precisely what makes it + safe — a bare `set_read_only` would let a caller lock a buffer it can no + longer refresh, which is why the lane was deferred in the first place. + Discarding history is load-bearing twice: it removes the entries undo + would replay, and it stops a periodically refreshed buffer accumulating + rope clones that `read_only` guarantees nothing can ever pop. + + **What remains of the lane:** `*compilation*` and listview panels still + rely on intercept-plus-round-trip and are still emptiable by + `M-x buffer.undo`. The primitive they need now exists and is proven, so + the remaining work is adoption plus a streaming-friendly variant + (`*compilation*` appends rather than replacing wholesale). The CRDT half + is also still open: `set_generated_contents` clears the v0.1 stacks, and + in CRDT mode `read_only` is what refuses undo, since loro's + `UndoManager` has no clear exposed through `CrdtState`. + ## Acceptance ### Stage 1 — `terminal-config` @@ -577,6 +606,15 @@ additive, on its own binding, and does not replace scroll-and-select. them for selection copy. 15. isearch over the snapshot finds content that is **only in scrollback** (scrolled off the visible screen), with no change to `src/search.rs` (B1). +16c. **Undo cannot empty the snapshot, by chord OR by command** (review + round 2). `Buffer::undo` bypasses the intercept chain entirely, so the + snapshot must be `read_only` at the rope. Pinning only the chords would + be a false pass: `M-x buffer.undo` and the menu reach the command with + no keymap involved, which is why `*compilation*`'s chord-rebinding idiom + does not close this. Pinned through **`invoke_interactive`**, the real + M-x path, plus the chord, plus redo — and paired with an assertion that + the owner's own refresh still works, since that is what plain + `read_only` would have broken. 16. **Ungated, runs in CI:** focusing the snapshot buffer makes `dispatch_idle_for` report **false**. This is the whole mechanism Q#TC6a depends on, it needs no CRDT, and it fails the moment diff --git a/src/buffer.rs b/src/buffer.rs index 7f048d5..27a496a 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -504,6 +504,55 @@ impl Buffer { self.read_only = read_only; } + /// Replace a generated buffer's entire contents on behalf of its owner, + /// and leave it genuinely immutable. + /// + /// This is the **owner-authorized update path** that genuine + /// immutability for generated buffers requires. A snapshot, panel or + /// `*compilation*` buffer must reject ordinary edits, **undo, redo**, + /// and remote CRDT imports alike — and only [`read_only`] does that. + /// An edit intercept is not enough: it guards the dispatch/edit path + /// only, while [`Buffer::undo`] reaches the rope through + /// `ensure_writable` without ever consulting the intercept chain. A + /// buffer protected by an intercept alone can therefore be emptied by + /// `C-/`, by `M-x buffer.undo`, or by the menu — the command is + /// reachable even where the chords are rebound to no-ops. + /// + /// But `read_only` also blocks the owner's own refresh, which is the + /// operation such buffers exist for. So the owner needs exactly one + /// door, and this is it: lift the flag, replace the contents skipping + /// intercepts, **discard the resulting history**, re-assert the flag. + /// + /// Discarding history is not tidiness. Without it every refresh pushes + /// undo entries holding full rope clones that nothing can ever pop — + /// `read_only` guarantees they are unreachable — so a periodically + /// refreshed buffer would grow without bound. + /// + /// [`read_only`]: Self::set_read_only + pub fn set_generated_contents(&mut self, bytes: &[u8]) -> Result<(), BufferError> { + self.read_only = false; + let result = self.replace_whole_buffer(bytes); + // Cleared even on failure: a partial replace must not leave a + // half-applied edit reachable through an undo the owner cannot see. + self.undo.clear(); + self.redo.clear(); + self.read_only = true; + result + } + + fn replace_whole_buffer(&mut self, bytes: &[u8]) -> Result<(), BufferError> { + let len = self.len(); + if len > 0 { + self.apply_edit_skip_intercepts(EditOp::Delete { + range: Range::new(0, len), + })?; + } + if !bytes.is_empty() { + self.apply_edit_skip_intercepts(EditOp::Insert { pos: 0, bytes })?; + } + Ok(()) + } + fn ensure_writable(&self) -> Result<(), BufferError> { if self.read_only { Err(BufferError::ReadOnly { @@ -1955,6 +2004,72 @@ mod tests { } ); + /// The whole point of the primitive: after an owner write the buffer + /// is immutable, and `undo` — which never consults the intercept + /// chain — cannot reach back past it. + #[test] + fn set_generated_contents_writes_then_locks_and_leaves_nothing_to_undo() { + let mut buf = Buffer::new(BufferId::next(), "*generated*"); + buf.set_generated_contents(b"first render").expect("write"); + + assert_eq!(buf.len(), 12); + assert!(buf.is_read_only(), "the buffer ends immutable"); + assert!( + matches!(buf.undo(), Err(BufferError::ReadOnly { .. })), + "undo must be refused at the rope, not merely at dispatch" + ); + assert!(matches!(buf.redo(), Err(BufferError::ReadOnly { .. }))); + + // Even with the lock lifted there is no history to replay — the + // protection does not depend on the flag alone. + buf.set_read_only(false); + assert!(matches!(buf.undo(), Err(BufferError::NothingToUndo))); + assert!(matches!(buf.redo(), Err(BufferError::NothingToRedo))); + } + + /// Refreshing repeatedly must not accumulate unreachable history. + /// Each render would otherwise push entries holding full rope clones + /// that `read_only` guarantees nothing can ever pop. + #[test] + fn repeated_generated_writes_do_not_accumulate_history() { + let mut buf = Buffer::new(BufferId::next(), "*generated*"); + for i in 0..10 { + buf.set_generated_contents(format!("render {i}").as_bytes()) + .expect("write"); + } + let mut bytes = vec![0u8; buf.len() as usize]; + buf.snapshot_rope().slice(0, buf.len(), &mut bytes); + assert_eq!(String::from_utf8(bytes).expect("utf8"), "render 9"); + + buf.set_read_only(false); + assert!( + matches!(buf.undo(), Err(BufferError::NothingToUndo)), + "ten renders must leave an empty undo stack, not ten entries" + ); + } + + /// An ordinary edit is still refused after a generated write, so the + /// primitive does not quietly leave the buffer writable. + #[test] + fn set_generated_contents_still_refuses_ordinary_edits() { + let mut buf = Buffer::new(BufferId::next(), "*generated*"); + buf.set_generated_contents(b"content").expect("write"); + assert!(matches!( + buf.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"x" + }), + Err(BufferError::ReadOnly { .. }) + )); + assert!(matches!( + buf.apply_edit_skip_intercepts(EditOp::Insert { + pos: 0, + bytes: b"x" + }), + Err(BufferError::ReadOnly { .. }) + )); + } + #[cfg(feature = "crdt")] #[test] fn read_only_rejects_remote_crdt_before_import_and_allows_empty_bootstrap() { diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index e27d9dd..cff5bda 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -3065,6 +3065,29 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result Date: Sun, 26 Jul 2026 13:18:05 -0400 Subject: [PATCH 50/91] fix(panel): close Stage 2A review round 3 (2 P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **P1-1 — layout invalidation could suppress the authoritative clear.** Real bug. Both render paths resolved the document identity AFTER the evaluator ran callbacks, but BOTH outcome arms carry PHASE-1 contexts. A provider that closes the primary document split changes `primary_document_window` mid-evaluation, so the filter compared phase-1 contexts against a replacement identity, matched nothing, and emitted no clear — leaving stale statusline text on the wire forever. The identity is now captured BEFORE `evaluate_statusline` runs and threaded through both paths (the terminal path via `terminal_chrome`). Pinning it took three attempts, and the two failures are the useful part: - `pmacs.window.close()` takes no argument — it closes the ACTIVE window. The first version passed a window id that was silently ignored, so it closed the panel instead of the document. - The Lua window API acts on the ACTIVE FRONTEND, so driving it against a synthetic semantic view changed nothing at all. - Closing the only document window is structurally REFUSED (Q#BP6 forbids a lone side window as a resting state), so the fixture needs TWO document windows for the close to be legal. The test now asserts its own precondition — that the callback really changed the identity — before asserting the clear, and reproduces the reported symptom (no `StatuslineSegments` at all) when the fix is reverted. **P1-2 — #21 was pinned at the helper, not the producer.** Confirmed: reverting only the call site inside `publish_buffer_snapshot_to_replicas` left both the helper test and the existing socket-pair test green. The helper assertions are removed (with a note saying why) and replaced by `snapshot_publication_follows_the_document_under_a_focused_panel`, which drives the real producer over socket pairs and asserts BOTH directions: the document buffer's snapshot is delivered while a panel holds focus, and a panel-only buffer's is not. Biting that test exposed a defect in the test itself: the delivery read had no timeout, so a regression made it HANG rather than fail. A hanging test is strictly worse than a red one — every read now has a timeout. Gates: fmt clean; workspace clippy clean; 1,832 default + 2,015 CRDT library; Stage 2A 17; Stage 1 46; statusline 8; m11_5 2; GPU initial target 14; terminal config 12; folding Stage 2 48; vterm 1/2 10 / 6; M4 121; required GPU 202; `git diff --check` clean. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 13 ++- src/daemon.rs | 106 +++++++++++++++++++-- src/semantic_render.rs | 53 ++++++++--- tests/bottom_panel_stage2a_acceptance.rs | 112 +++++++++++++++++++++++ 4 files changed, 259 insertions(+), 25 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 36a99de..a46a908 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -494,14 +494,14 @@ implemented and in review.** #173 also changes `src/editor.rs`, so gates were rerun on the merge result, not the old combination). Five commits: the classified census routing, the painter extraction + acceptance, the lane record, then - the round-1 and round-2 review fixes. **No protocol change; no behavior + the round-1, round-2 and round-3 review fixes. **No protocol change; no behavior change for any frontend today** — with `panel_capable = false` for semantic sessions, `primary_document_window` returns `view.active` in every existing configuration, so this is seam adoption that becomes load-bearing in 2B. - Verification on the merge result: `cargo fmt --check` clean; strict - workspace Clippy clean; **1,832 default + 2,014 CRDT** library tests; - `bottom_panel_stage2a_acceptance` **16**; bottom-panel Stage 1 46; + workspace Clippy clean; **1,832 default + 2,015 CRDT** library tests; + `bottom_panel_stage2a_acceptance` **17**; bottom-panel Stage 1 46; statusline segments 8 CRDT; m11_5 semantic 2 CRDT; GPU initial target 14 CRDT; terminal config 12 CRDT; vterm Stage 1/2 10 / 6; folding Stage 2 48; M4 121; required GPU 202; `git diff --check` clean. @@ -521,7 +521,12 @@ implemented and in review.** under test; (c) a discriminating fixture must make the two routings DISAGREE — comparing two non-terminal buffers, or two windows with no selection, yields the same answer either way and proves nothing. - Round 2 found four of my own pins vacuous by exactly these shapes. + Round 2 found four of my own pins vacuous by exactly these shapes, and + round 3 found two more problems of the same family: a pin placed at a + HELPER while production called it from a producer (reverting only the + producer's call site left every test green), and a socket-pair + assertion whose blocking read made a regression HANG instead of fail. + Both now assert at the producer, with read timeouts on every read. - **Review round 1 closed: 4 P1 + 2 P2, all real.** The P1s were a stale-`Pointer` focus steal (the failed-alignment arm returned the window, so #8's activation focused it before `dispatch_pointer` diff --git a/src/daemon.rs b/src/daemon.rs index 63f6b87..84716eb 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3483,6 +3483,96 @@ mod tests { ); } + /// Bottom-panel §1.3 #21 through the REAL producer (round 3). + /// + /// A semantic peer with a FOCUSED PANEL must still receive the + /// snapshot for the buffer on its DOCUMENT surface, and must NOT + /// receive one for a buffer visible only in its panel. Asserting the + /// helper alone was insufficient: reverting the producer's call site + /// to focused-window routing left every helper-level test green. + #[cfg(feature = "crdt")] + #[test] + fn snapshot_publication_follows_the_document_under_a_focused_panel() { + let (editor, fid, document, panel) = panel_focused_semantic_fixture(); + let (doc_buf, panel_buf) = { + let core = editor.core.borrow(); + ( + core.windows[&document].buffer_id, + core.windows[&panel].buffer_id, + ) + }; + assert_ne!(doc_buf, panel_buf, "fixture: distinct buffers"); + + let caps = crate::protocol::NegotiatedCapabilities { + multi_frontend: true, + crdt_replica: true, + semantic_render: true, + }; + let mut registry = SessionRegistry::new(); + registry.register_session( + fid, + crate::presence::SessionState::new(PROTOCOL_VERSION, caps, 0), + ); + + // The DOCUMENT buffer's snapshot must be delivered. + { + let (server, mut client) = UnixStream::pair().expect("socketpair"); + // A read timeout on the DELIVERY read too. Without it a + // regression that suppresses the snapshot makes this test + // HANG rather than fail, which is strictly worse than a red + // assertion — found by biting this very test. + client + .set_read_timeout(Some(Duration::from_millis(500))) + .expect("delivery timeout"); + let mut streams = HashMap::from([(fid, server)]); + let message = InstanceMessage::BufferSnapshot { + buffer_id: doc_buf, + crdt_snapshot: vec![1, 2, 3], + }; + publish_buffer_snapshot_to_replicas( + &editor, + doc_buf, + &message, + ®istry, + &mut streams, + &mut HashMap::new(), + ); + let delivered: InstanceMessage = + read_message(&mut client).expect("the document snapshot must arrive"); + assert_eq!( + delivered, message, + "#21: a buffer on the DOCUMENT surface must still be published while a \ + panel holds focus" + ); + } + + // The PANEL-only buffer's snapshot must NOT be delivered. + { + let (server, mut client) = UnixStream::pair().expect("socketpair"); + let mut streams = HashMap::from([(fid, server)]); + let message = InstanceMessage::BufferSnapshot { + buffer_id: panel_buf, + crdt_snapshot: vec![4, 5, 6], + }; + publish_buffer_snapshot_to_replicas( + &editor, + panel_buf, + &message, + ®istry, + &mut streams, + &mut HashMap::new(), + ); + client + .set_read_timeout(Some(Duration::from_millis(50))) + .expect("timeout"); + assert!( + read_message::(&mut client).is_err(), + "#21: a buffer visible only in a PANEL must not replace the peer's \ + document mirror" + ); + } + } + // ---- GPU terminal input: the double terminal-layout sync ------------- // // These drive `sync_terminal_layouts_for_tick` — the REAL dispatcher loop @@ -4827,15 +4917,13 @@ mod tests { "#3: CursorByte must describe the DOCUMENT surface" ); - // #21 publication recipient filter, both directions. - assert!( - peer_displays_buffer_as_document(&editor, fid, doc_buf), - "#21: a buffer visible in the document must still receive publications while a panel holds focus" - ); - assert!( - !peer_displays_buffer_as_document(&editor, fid, panel_buf), - "#21: a buffer visible only in a panel must NOT replace the document mirror" - ); + // #21 is deliberately NOT asserted here. Round 3: pinning it at + // this helper left the real producer free to regress — reverting + // the call site inside `publish_buffer_snapshot_to_replicas` + // kept both this test and the existing socket-pair test green. + // It is pinned through the producer instead, in + // `snapshot_publication_follows_the_document_under_a_focused_panel`. + let _ = panel_buf; } /// Bottom-panel §1.3 #2 — the sharpest census case: the lazy CRDT diff --git a/src/semantic_render.rs b/src/semantic_render.rs index af08741..db96fc4 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -625,6 +625,22 @@ impl SemanticRenderState { // post-evaluation face inventory must then precede the authoritative // segment replacement in this same frame. Unsupported peers skip the // evaluator entirely and therefore pay no Lua callback/dynamic-face cost. + // Bottom-panel A2A-2, round 3: the document identity used to + // FILTER the results must be the PRE-CALLBACK one. Both outcome + // arms carry phase-1 contexts, and a provider that closes the + // primary document split changes `primary_document_window` + // mid-evaluation — reading it after the fact would compare + // phase-1 contexts against a replacement identity, match + // nothing, and silently suppress the authoritative clear. + let statusline_document_window = self + .peer_knows_statusline_segments + .then(|| { + state + .core + .borrow() + .primary_document_window(self.frontend_id) + }) + .flatten(); let statusline_evaluation = self.peer_knows_statusline_segments.then(|| { evaluate_statusline( state.lua_host.lua(), @@ -810,11 +826,7 @@ impl SemanticRenderState { out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. if let Some(evaluation) = statusline_evaluation { - let document_window = state - .core - .borrow() - .primary_document_window(self.frontend_id); - self.emit_statusline_segments(evaluation, document_window, &mut out); + self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } out } @@ -859,6 +871,16 @@ impl SemanticRenderState { // Evaluate callbacks before `ThemeFacts` for the same reason the // document path does: a callback may register a face, and the // face inventory must precede the segment text that names it. + // Same pre-callback capture as the document path (round 3). + let statusline_document_window = self + .peer_knows_statusline_segments + .then(|| { + state + .core + .borrow() + .primary_document_window(self.frontend_id) + }) + .flatten(); let statusline_evaluation = self.peer_knows_statusline_segments.then(|| { evaluate_statusline( state.lua_host.lua(), @@ -885,7 +907,12 @@ impl SemanticRenderState { // a verdict we hold. if self.last_terminal_frame.as_ref() == Some(&frame) { self.terminal_error_latched = false; - out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation)); + out.extend(self.terminal_chrome( + state, + buffer_id, + statusline_evaluation, + statusline_document_window, + )); return Some(out); } match frame.validate() { @@ -909,7 +936,12 @@ impl SemanticRenderState { } } - out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation)); + out.extend(self.terminal_chrome( + state, + buffer_id, + statusline_evaluation, + statusline_document_window, + )); Some(out) } @@ -924,6 +956,7 @@ impl SemanticRenderState { state: &EditorState, buffer_id: BufferId, statusline_evaluation: Option, + statusline_document_window: Option, ) -> Vec { let mut out = Vec::new(); out.extend(self.status_facts_msg(state, buffer_id)); @@ -933,11 +966,7 @@ impl SemanticRenderState { out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. if let Some(evaluation) = statusline_evaluation { - let document_window = state - .core - .borrow() - .primary_document_window(self.frontend_id); - self.emit_statusline_segments(evaluation, document_window, &mut out); + self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } out } diff --git a/tests/bottom_panel_stage2a_acceptance.rs b/tests/bottom_panel_stage2a_acceptance.rs index 1063e9f..d39d804 100644 --- a/tests/bottom_panel_stage2a_acceptance.rs +++ b/tests/bottom_panel_stage2a_acceptance.rs @@ -784,3 +784,115 @@ fn consumer_decorations_follow_the_document_selection_not_the_panel() { "a selection living in the focused PANEL must not decorate the document viewport" ); } + +#[test] +fn a_provider_closing_the_document_split_still_clears_the_statusline() { + use pmacs::protocol::{ByteRange, InstanceMessage}; + use pmacs::semantic_render::SemanticRenderState; + + // Round 3 finding 1. `authoritative_empty` carries PHASE-1 contexts, + // so the identity used to filter them must be the PRE-CALLBACK one. + // A provider that closes the primary document split changes + // `primary_document_window` mid-evaluation; reading it afterwards + // compares phase-1 contexts against a replacement identity, matches + // nothing, and silently suppresses the authoritative clear — leaving + // stale statusline text on screen forever. + // + // Driven on LOCAL, because the Lua window API acts on the ACTIVE + // FRONTEND: a synthetic semantic view would be untouched by + // `pmacs.window.close()` and the identity would never change, which + // is exactly how the first version of this test came back vacuous. + // TWO document windows plus the panel: closing the only document + // window is structurally refused (Q#BP6 forbids a lone side window + // as a resting state), so the first attempt could not change the + // identity at all. Distinct buffers make the target selectable from + // a Lua provider, which has no focus-by-id. + let s = editor(); + exec( + &s, + "DOC_A = pmacs.buffer.create(\"*doc-a*\") + DOC_B = pmacs.buffer.create(\"*doc-b*\") + pmacs.window.display(DOC_A, {}) + pmacs.window.split_horizontal() + pmacs.window.focus_next() + pmacs.window.display(DOC_B, {})", + ); + let (_origin, panel) = focused_panel(&s); + let (document, doc_buf) = { + let core = s.core.borrow(); + let win = core + .primary_document_window(FrontendId::LOCAL) + .expect("a primary document window"); + (win, core.windows[&win].buffer_id) + }; + assert_ne!(document, panel); + + let mut sem = SemanticRenderState::for_peer(FrontendId::LOCAL, 18); + sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0); + + // Seed a baseline payload so a CLEAR is observable as a change. + exec( + &s, + r"_G.SL_SEED = pmacs.statusline.register { + name='seed', side='left', priority=10, + fn=function() return 'OLD' end, + }", + ); + let seeded = sem.render_frame(&s); + assert!( + seeded + .iter() + .any(|m| matches!(m, InstanceMessage::StatuslineSegments { .. })), + "non-vacuity: a baseline payload must exist before we test its clear" + ); + + // A provider that unregisters itself (making the evaluation + // Invalidated) AND closes the captured document window. `close()` + // closes the ACTIVE window and Lua has no focus-by-id, so step + // around the ring until the captured buffer is current. + s.lua_host + .lua() + .globals() + .set("TARGET_BUF", pmacs::lua_bindings::BufferIdLua(doc_buf)) + .expect("expose the target buffer"); + exec( + &s, + r"_G.SL_CLOSER = pmacs.statusline.register { + name='closer', side='left', priority=100, + fn=function() + pmacs.statusline.unregister(SL_CLOSER) + for _ = 1, 8 do + if pmacs.window.buffer() == TARGET_BUF then break end + pmacs.window.focus_next() + end + pmacs.window.close() + return 'STALE' + end, + }", + ); + + let msgs = sem.render_frame(&s); + + // The fixture must actually have changed the identity, or this test + // discriminates nothing. + assert_ne!( + s.core.borrow().primary_document_window(FrontendId::LOCAL), + Some(document), + "fixture: the callback must really have changed the document identity" + ); + + let cleared = msgs.iter().any(|m| match m { + InstanceMessage::StatuslineSegments { + buffer_id, + left, + right, + .. + } => *buffer_id == doc_buf && left.is_empty() && right.is_empty(), + _ => false, + }); + assert!( + cleared, + "an invalidated evaluation must still publish the authoritative EMPTY clear for \ + the phase-1 document identity, even when a callback closed that window; got {msgs:?}" + ); +} From aef4e98c26ecb2c840c564e2a4f721241e229e0a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 13:39:33 -0400 Subject: [PATCH 51/91] fix(typed-edit): close round-8 review on the consumer chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five defects in the chain itself, plus the stale handoff state. Each consumer now gets its own shallow copy of the typed-edit record. Handing everyone the same table let a DECLINING consumer rewrite provenance for the ones behind it, and pairing decides what to close from `rec.char` — so a forged `char` turned a typed `x` into `x)`. Every field is a scalar or an opaque id, so a shallow copy is complete. The fan-out iterates a snapshot of the consumer list. It was iterating the same array `add_consumer` mutates: a consumer that registered a lower-priority one shifted itself forward under `ipairs` and ran twice, and re-registering made that unbounded. Registrations and removals made during a fan-out now take effect on the next one, stated as a contract and pinned in both directions. `tostring` on the caught error moved inside the containment. A Lua error may be any value, including a table whose `__tostring` throws — rendering it outside the `pcall` reintroduced exactly the escape the containment exists to prevent. Priorities are validated as finite integers in i32 range, matching `pmacs.completion.register`. NaN is a number and every ordered comparison with it is false, so a NaN consumer landed wherever the insertion scan gave up and silently voided the lowest-first ordering that Q#LN22 depends on. `add_consumer` returns a handle and `remove_consumer` unregisters it, reporting whether it was live. Without teardown the chain inherited the `pmacs.hook.add` callback leak COHERENCE.md §13 already records, and spread it to every consumer. Also corrects the rationale the containment was documented with, in the module, the test, and the framing: an uncontained throw does NOT take the fan-out's other subscribers down. `run_all_must_succeed` (src/hook.rs:332) collects errors and continues, so lsp.lua still flushes didChange. The containment is still required — the throw skips every later consumer in the chain — but the reason is narrower than rev 7 claimed. Criteria 46f (record isolation), 46g (snapshot iteration), and 46h (lifecycle and priority validation) added; 46d's rationale corrected. Four new tests, all bite-verified by mutation, each failing only its target: shared record table (1), live-array iteration (1), unprotected tostring (1), bare number check (1), no-op removal (2). The suite also runs green under `--features lua54`. docs/agent-handoff.md said Stage 4a was awaiting approval while this branch had it implemented and in review. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- builtin/runtime/typed_edit.lua | 113 ++++++++++--- docs/active-work.md | 41 ++++- docs/agent-handoff.md | 24 ++- docs/lean4-mode-framing.md | 42 ++++- tests/typed_edit_chain_acceptance.rs | 232 ++++++++++++++++++++++++++- 5 files changed, 406 insertions(+), 46 deletions(-) diff --git a/builtin/runtime/typed_edit.lua b/builtin/runtime/typed_edit.lua index 59f7366..6baf5f8 100644 --- a/builtin/runtime/typed_edit.lua +++ b/builtin/runtime/typed_edit.lua @@ -13,11 +13,12 @@ -- subscriber that reads the record, and offers that one read to -- consumers registered through `pmacs.typed_edit.add_consumer`: -- --- pmacs.typed_edit.add_consumer { --- name = "auto-pair", -- for error reporting; must be unique-ish +-- local handle = pmacs.typed_edit.add_consumer { +-- name = "auto-pair", -- for error reporting -- priority = 100, -- LOWEST runs FIRST -- fn = function(rec) ... return claimed end, -- } +-- pmacs.typed_edit.remove_consumer(handle) -- -> true if it was live -- -- A consumer returns whether it CLAIMED the edit; the first that claims -- stops the chain. "Claimed" means the chain stops, not that an edit was @@ -46,10 +47,19 @@ pmacs.typed_edit = pmacs.typed_edit or {} -- order" is part of the stated contract, not an incidental property. local consumers = {} --- Register a typed-edit consumer. Argument errors throw: registration --- happens at chunk-load or config-load time, where a throw is a visible --- startup failure rather than a silently missing feature. Nothing in --- the after-edit path throws --- see the fan-out below. +-- Handles are opaque to callers; only identity matters. An integer +-- counter is enough because nothing ever reuses one. +local next_handle = 0 + +-- `math.huge` is the only portable spelling of infinity available in +-- both LuaJIT and 5.4, and NaN is the only value not equal to itself. +local INT32_MIN, INT32_MAX = -2147483648, 2147483647 + +-- Register a typed-edit consumer; returns an opaque handle for +-- `remove_consumer`. Argument errors throw: registration happens at +-- chunk-load or config-load time, where a throw is a visible startup +-- failure rather than a silently missing feature. Nothing in the +-- after-edit path throws --- see the fan-out below. function pmacs.typed_edit.add_consumer(spec) if type(spec) ~= "table" then error("pmacs.typed_edit.add_consumer: spec must be a table", 2) @@ -58,9 +68,18 @@ function pmacs.typed_edit.add_consumer(spec) if type(name) ~= "string" or name == "" then error("pmacs.typed_edit.add_consumer: name must be a non-empty string", 2) end - if type(priority) ~= "number" then + -- A bare `type(priority) == "number"` admits NaN and the infinities, + -- and EVERY ordered comparison against NaN is false --- so a NaN + -- consumer silently lands wherever the insertion scan happens to give + -- up, and the lowest-first contract other consumers depend on stops + -- holding. Bounded integers match `pmacs.completion.register`, whose + -- priority is an i32 on the Rust side. + if type(priority) ~= "number" or priority ~= priority + or priority == math.huge or priority == -math.huge + or priority % 1 ~= 0 + or priority < INT32_MIN or priority > INT32_MAX then error("pmacs.typed_edit.add_consumer: " .. name .. - ": priority must be a number", 2) + ": priority must be a finite integer in [-2147483648, 2147483647]", 2) end if type(fn) ~= "function" then error("pmacs.typed_edit.add_consumer: " .. name .. @@ -77,7 +96,27 @@ function pmacs.typed_edit.add_consumer(spec) break end end - table.insert(consumers, at, { name = name, priority = priority, fn = fn }) + next_handle = next_handle + 1 + local handle = next_handle + table.insert(consumers, at, + { handle = handle, name = name, priority = priority, fn = fn }) + return handle +end + +-- Unregister a consumer by the handle `add_consumer` returned. Returns +-- true if it was registered, false otherwise (so a double-remove is a +-- reportable no-op rather than a throw). Without this, re-evaluating a +-- config or reloading a package accumulates callbacks permanently --- +-- the leak COHERENCE.md §13 already records against `pmacs.hook.add`, +-- which this chain would otherwise inherit and spread. +function pmacs.typed_edit.remove_consumer(handle) + for i, c in ipairs(consumers) do + if c.handle == handle then + table.remove(consumers, i) + return true + end + end + return false end pmacs.hook.add("buffer.after-edit", function() @@ -92,19 +131,51 @@ pmacs.hook.add("buffer.after-edit", function() -- fan-out on nil would leave both reading stale state. local rec = ed.take_typed_edit and ed.take_typed_edit() - for _, c in ipairs(consumers) do - -- `buffer.after-edit` is all-must-succeed (builtin/hooks/default.lua): - -- a throwing consumer would fail the fan-out for every OTHER - -- subscriber, including lsp.lua's didChange flush. Contain it, - -- report it, and keep going --- a broken consumer must not be able - -- to stop the editor from telling the language server what changed. - -- This matches pair.lua's existing never-throw-from-after-edit - -- discipline; it does not weaken the hook's contract for anyone - -- else, because the chain itself still never fails. - local ok, claimed = pcall(c.fn, rec) + -- Iterate a SNAPSHOT. A consumer may register or remove consumers + -- while the chain is running, and `table.insert`/`table.remove` on + -- the live array shifts indices under `ipairs` --- a consumer that + -- registers a lower-priority one shifts itself forward and runs + -- twice, and repeating that is unbounded. Registrations and removals + -- made during a fan-out therefore take effect on the NEXT fan-out. + local snapshot = {} + for i, c in ipairs(consumers) do + snapshot[i] = c + end + + for _, c in ipairs(snapshot) do + -- Each consumer gets its OWN copy of the record. The table handed + -- out is plain Lua data, so a declining consumer could otherwise + -- edit `rec.char` in place and the next consumer would act on the + -- forged value --- auto-pairing reads `rec.char` to decide what to + -- close, so a rewritten `char` makes it insert a pair the user + -- never typed. Every field is a scalar or an opaque id, so a + -- shallow copy is a complete snapshot. + local mine = nil + if rec ~= nil then + mine = {} + for k, v in pairs(rec) do + mine[k] = v + end + end + + -- Contain the consumer. A throw here would skip every LATER + -- consumer in the chain and mark the whole `buffer.after-edit` run + -- failed; the other subscribers still run, because all-must-succeed + -- collects errors and continues (`src/hook.rs`'s + -- `run_all_must_succeed`), but one broken consumer must not be able + -- to silently disable the ones behind it. This matches pair.lua's + -- existing never-throw-from-after-edit discipline. + local ok, claimed = pcall(c.fn, mine) if not ok then - ed.set_status("typed-edit consumer '" .. c.name .. "' failed: " .. - tostring(claimed)) + -- Rendering is itself protected: a Lua error may be any value, + -- including a table whose `__tostring` throws, and an escaping + -- error here would defeat the containment above. + local shown, rendered = pcall(tostring, claimed) + if not shown or type(rendered) ~= "string" then + rendered = "" + end + pcall(ed.set_status, + "typed-edit consumer '" .. c.name .. "' failed: " .. rendered) elseif claimed then return end diff --git a/docs/active-work.md b/docs/active-work.md index bd0c0b1..ba1947a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -149,9 +149,9 @@ If it does not, stop and repair the remote/fetch configuration. ### Stage 4a — the typed-edit consumer chain (IMPLEMENTED, same branch) - Footprint exactly as Q#LN10 declares it: `builtin/runtime/typed_edit.lua` - (new, 112 lines), `pair.lua` re-expressed as one consumer, + (new), `pair.lua` re-expressed as one consumer, `src/editor.rs` +15 (the `include_str!` and its ordering comment), and - `tests/typed_edit_chain_acceptance.rs` (new, 9 tests). + `tests/typed_edit_chain_acceptance.rs` (new, 13 tests). **`tests/auto_pair_acceptance.rs` is UNCHANGED — `git diff --stat main...HEAD -- tests/auto_pair_acceptance.rs` is empty.** That is criterion 46 checked at the diff, which is the only way it means @@ -166,9 +166,26 @@ If it does not, stop and repair the remote/fetch configuration. - **Ordered insertion, not `table.sort`** — Lua's sort is not stable, and "ties broken by registration order" is a stated contract. - **The chain `pcall`s each consumer** and reports through - `set_status`. `buffer.after-edit` is all-must-succeed, so an - uncontained throw fails the fan-out for every other subscriber - including lsp.lua's didChange flush. + `set_status`. Rev 7 justified this by claiming an uncontained throw + would fail the fan-out for every other subscriber including lsp.lua's + didChange flush; **that is wrong** — `run_all_must_succeed` + (`src/hook.rs:332`) collects errors and continues, so the other + subscribers still run. The real consequence is narrower and still + worth containing: the throw skips every LATER consumer in the chain. + The rendering is protected too, because a Lua error may be a table + whose `__tostring` throws. +- **Round 8 (review) findings, all fixed on this branch:** each consumer + now gets its **own shallow copy** of the record (the same table let a + declining consumer rewrite `rec.char`, which pairing reads — typing + `x` could produce `x)`); the fan-out iterates a **snapshot** (a + consumer registering a lower-priority one shifted itself forward under + `ipairs` and ran twice, unbounded if repeated); `tostring` moved + inside the containment; **non-finite and non-integer priorities are + rejected** (NaN is a number and every ordered comparison with it is + false, so it landed wherever the insertion scan gave up and silently + voided the ordering contract); and `add_consumer` now returns a handle + with `remove_consumer` beside it, so re-evaluating a config no longer + leaks callbacks the way `pmacs.hook.add` does (COHERENCE §13). - **Every acceptance test is bite-verified by mutation**, per the standing rule that a test is not evidence until the mutation it targets has been shown to fail it: @@ -182,6 +199,11 @@ If it does not, stop and repair the remote/fetch configuration. | drop the `pcall` | 1 chain | | skip consumers when `rec == nil` | 1 chain + **3 auto-pair** | | load `typed_edit.lua` after `lsp.lua` | 1 chain + **2 auto-pair** (Q#AP7) | + | hand every consumer the same record table | 1 chain (46f) | + | iterate the live array instead of a snapshot | 1 chain (46g) | + | render the error outside the `pcall` | 1 chain (46d) | + | accept any Lua number as a priority | 1 chain (46h) | + | make `remove_consumer` a no-op | 2 chain (46g, 46h) | The first attempt at the last bite was WORTHLESS as written: moving only `typed_edit.lua` past `lsp.lua` left `pair.lua` calling a nil @@ -194,9 +216,12 @@ If it does not, stop and repair the remote/fetch configuration. - Verification on this branch (commit-then-gate, so this describes the pushed tree): `cargo fmt --check` clean; strict workspace Clippy clean; 1,832 default + 2,009 CRDT library tests; auto-pair 45/45; - typed-edit chain 9/9; M4 121; required GPU 202; **isolated-config - workspace sweep 3,328 across 97 suites, zero failures** with - `grep -c basedpyright` = 0; `git diff --check` clean. + typed-edit chain 13/13 (and 13/13 again under `--no-default-features + --features lua54`, since the fixes touch `math.huge`, `%`, and + `__tostring` behavior that differs between the backends); M4 121; + required GPU 202; **isolated-config workspace sweep 3,332 across 97 + suites, zero failures** with `grep -c basedpyright` = 0; `git diff + --check` clean. - Stage 4b (the input method) is NOT in this PR and not started. ## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index a176505..66ccc25 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -88,10 +88,26 @@ commands, read `docs/active-work.md` immediately after this file. config swap invalidates. The durable lesson is to heal at **consumption** — the point where a stale record is handed out — not at the moment of the swap. - - Remaining: Stage 4a (typed-edit consumer chain) and 4b (the Unicode - input method) are framed and awaiting approval; stages 5 (goal - panel), 6 (`#eval` output channel), and 7 (module hierarchy) are - framed but not scouted against current `main`. + - **Stage 4a (typed-edit consumer chain) is implemented and in review + as PR #179** (branch `lean4-stage4a-typed-edit-chain`, framing rev + 8). It is substrate only: `builtin/runtime/typed_edit.lua` owns the + single `buffer.after-edit` subscriber and the single one-shot read, + `pair.lua` becomes its first registered consumer, and + `tests/auto_pair_acceptance.rs` is unchanged by zero lines + (criterion 46, verified at the diff). No protocol change, no Lean + content. The three decisions that turned out load-bearing rather + than stylistic: consumers are called **even when the record is + nil** (three existing auto-pair tests assert the non-event through + it, and 4b abandons stale pending state on it); each consumer gets + its **own copy** of the record, because pairing reads `rec.char` + and a declining consumer could otherwise forge it; and the fan-out + iterates a **snapshot**, because a consumer that registers a + lower-priority one shifts itself forward under `ipairs` and runs + twice. + - Remaining: Stage 4b (the Unicode input method) is framed and + awaiting approval — not started; stages 5 (goal panel), 6 (`#eval` + output channel), and 7 (module hierarchy) are framed but not + scouted against current `main`. - **Inline math LANDED — #158** (`docs/inline-math-slice-framing.md` rev 3; merge `5aa9044`). pmacs renders `$…$` as typeset mathematics in the GPU diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index 650b2f3..789e419 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -1496,7 +1496,7 @@ claim a reader must be able to check without reconstructing | `builtin/runtime/typed_edit.lua` | new — the chain owner | | `builtin/runtime/pair.lua` | re-expressed as one registered consumer | | `src/editor.rs` | one `include_str!` line, before `pair.lua`'s | -| `tests/typed_edit_chain_acceptance.rs` | new — criteria 46a–46e | +| `tests/typed_edit_chain_acceptance.rs` | new — criteria 46a–46h | | `tests/auto_pair_acceptance.rs` | **unchanged, zero lines** | Rev 6 listed only the first three and then required criteria 46a–46e, @@ -2374,7 +2374,7 @@ substrate pin, filed under Stage 4 only because Stage 4 was one stage. Per the no-renumbering rule above, round 5's additions take letter suffixes on both sides of the split. -46a–46e live in a **new `tests/typed_edit_chain_acceptance.rs`**, which +46a–46h live in a **new `tests/typed_edit_chain_acceptance.rs`**, which is part of Stage 4a's declared footprint (Q#LN10) and a required gate for its PR. They cannot live in `tests/auto_pair_acceptance.rs`, which criterion 46 requires to stay byte-identical. @@ -2394,14 +2394,44 @@ criterion 46 requires to stay byte-identical. `include_str!` order happens to agree with intent. 46c. A claiming consumer stops the chain — a later consumer does not run — and a non-claiming one does not. -46d. A consumer that throws is contained: the fan-out still succeeds, - the other consumers still run, and the failure reports through - `set_status`. Bites against the `all-must-succeed` contract taking - the whole fan-out down with one bad consumer (Q#LN10). +46d. A consumer that throws is contained: the later consumers still + run, and the failure reports through `set_status`. Bites against a + chain where one bad consumer silently disables every consumer + behind it. (Round 8 correction: an uncontained throw would *not* + take the fan-out's other subscribers down — `run_all_must_succeed` + in `src/hook.rs` collects errors and continues, so `lsp.lua` still + flushes. Rev 7 claimed otherwise. The containment is still + required; the reason is narrower than stated.) Rendering the error + is itself protected: a Lua error may be any value, including a + table whose `__tostring` throws, and reporting outside the + containment reintroduces the escape it exists to prevent. 46e. **Q#AP7 ordering survives.** The existing `sighelp` fake-server test — pairing's closer must be in the buffer before `lsp.lua` flushes `didChange` — still holds with pairing behind the chain. Falsified by moving the chain's registration after `lsp.lua`'s. +46f. **Each consumer's record is its own.** A declining consumer that + mutates the record it was handed cannot change what a later + consumer sees. Bites against handing every consumer the same + mutable table: pairing decides what to close from `rec.char`, so a + forged `char` makes it insert a pair the user never typed. Every + field is a scalar or an opaque id, so a shallow copy is a complete + snapshot. +46g. **The fan-out iterates a snapshot.** A consumer may register or + remove consumers while the chain runs; both take effect on the next + fan-out. Bites against iterating the live array, where a consumer + that registers a lower-priority one shifts itself forward under + `ipairs` and runs twice — unbounded if it re-registers each time. +46h. **The registrar has a lifecycle.** `add_consumer` returns an + opaque handle; `remove_consumer` unregisters it and reports whether + it was live, so a double-remove is a no-op rather than a throw. + Without it, re-evaluating a config or reloading a package + accumulates callbacks permanently — the leak `COHERENCE.md` §13 + already records against `pmacs.hook.add`, which a teardown-less + chain would inherit and spread to every consumer. Priority is + validated as a **finite integer in i32 range**, matching + `pmacs.completion.register`: NaN is a number and every ordered + comparison with it is false, so a bare type check lets it land + wherever the insertion scan gives up and silently voids 46b. **Stage 4b — the Unicode input method** diff --git a/tests/typed_edit_chain_acceptance.rs b/tests/typed_edit_chain_acceptance.rs index c0170f4..8ff9c13 100644 --- a/tests/typed_edit_chain_acceptance.rs +++ b/tests/typed_edit_chain_acceptance.rs @@ -1,11 +1,13 @@ //! Typed-edit consumer chain acceptance (Arc 8 Stage 4a, -//! docs/lean4-mode-framing.md Q#LN10, criteria 46a–46e). +//! docs/lean4-mode-framing.md Q#LN10, criteria 46a–46h). //! //! The chain owns the single `buffer.after-edit` subscriber that reads //! the one-shot typed-edit record (Q#AP9) and offers it to consumers in //! priority order. These tests pin the chain's OWN behavior — take-once, -//! priority ordering, claim-stops-chain, throw containment, and the -//! Q#AP7 flush ordering it inherited from `pair.lua`. +//! priority ordering, claim-stops-chain, throw containment, per-consumer +//! record isolation, snapshot iteration under re-entrant registration, +//! the registration lifecycle, and the Q#AP7 flush ordering it inherited +//! from `pair.lua`. //! //! They deliberately do not re-test auto-pairing: criterion 46 requires //! `tests/auto_pair_acceptance.rs` to pass byte-identical, and that @@ -324,9 +326,12 @@ fn a_throwing_consumer_is_contained_reported_and_does_not_stop_the_chain() { "#, ); - // `buffer.after-edit` is all-must-succeed: an uncontained throw - // would fail the fan-out for every other subscriber, including - // lsp.lua's didChange flush. + // An uncontained throw would abandon every LATER consumer in the + // chain and mark the whole `buffer.after-edit` run failed. It would + // NOT stop the hook's other subscribers — all-must-succeed collects + // errors and keeps going (`src/hook.rs`'s `run_all_must_succeed`) — + // so what this pins is that one broken consumer cannot silently + // disable the ones behind it. type_str(&mut s, "("); let later_ran: bool = eval(&s, "return _G.later_ran"); @@ -357,12 +362,43 @@ fn add_consumer_rejects_malformed_registrations() { ), ( "pmacs.typed_edit.add_consumer{ name = \"n\", fn = function() end }", - "priority must be a number", + "priority must be a finite integer", ), ( "pmacs.typed_edit.add_consumer{ name = \"n\", priority = 1 }", "fn must be a function", ), + // NaN is a number and every ordered comparison with it is + // false, so a bare type check lets it land wherever the + // insertion scan gives up — and the lowest-first contract the + // Lean expander depends on quietly stops holding. The + // infinities and non-integers go with it: priority matches + // `pmacs.completion.register`'s i32. + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = 0/0, \ + fn = function() end }", + "priority must be a finite integer", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = math.huge, \ + fn = function() end }", + "priority must be a finite integer", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = -math.huge, \ + fn = function() end }", + "priority must be a finite integer", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = 1.5, \ + fn = function() end }", + "priority must be a finite integer", + ), + ( + "pmacs.typed_edit.add_consumer{ name = \"n\", priority = 4e9, \ + fn = function() end }", + "priority must be a finite integer", + ), ] { let err = s .lua_host @@ -378,6 +414,188 @@ fn add_consumer_rejects_malformed_registrations() { } } +#[test] +fn an_error_whose_rendering_throws_is_still_contained() { + // A Lua error may be any value, including a table whose + // `__tostring` throws. Rendering it outside the containment is a + // second, uncontained throw — the chain would stop at exactly the + // consumer it was trying to report. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.later_ran = false + local hostile = setmetatable({}, { + __tostring = function() error("rendering exploded") end, + }) + pmacs.typed_edit.add_consumer { + name = "boom", priority = 1, fn = function() error(hostile) end, + } + pmacs.typed_edit.add_consumer { + name = "later", priority = 2, + fn = function() _G.later_ran = true; return false end, + } + "#, + ); + + type_str(&mut s, "("); + + let later_ran: bool = eval(&s, "return _G.later_ran"); + assert!( + later_ran, + "an unrenderable error must not escape the containment" + ); + assert_eq!(buffer_text(&s), "()", "and pairing still ran"); + let st = status(&s); + assert!( + st.contains("boom") && st.contains(""), + "the consumer is still named, with a placeholder body, got {st:?}" + ); +} + +// --------------------------------------------------------------------------- +// The record a consumer sees is its own +// --------------------------------------------------------------------------- + +#[test] +fn a_consumers_mutation_of_the_record_cannot_reach_the_next_consumer() { + // The record is plain Lua data. Handing every consumer the same + // table lets a DECLINING consumer rewrite provenance for the ones + // behind it — and pairing decides what to close from `rec.char`, + // so a forged `char` makes it insert a pair the user never typed. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.downstream_char = "unset" + pmacs.typed_edit.add_consumer { + name = "vandal", priority = 1, + fn = function(rec) + if rec then rec.char = "("; rec.codepoint = 40 end + return false + end, + } + pmacs.typed_edit.add_consumer { + name = "witness", priority = 2, + fn = function(rec) + _G.downstream_char = rec and rec.char or "nil" + return false + end, + } + "#, + ); + + type_str(&mut s, "x"); + + let downstream: String = eval(&s, "return _G.downstream_char"); + assert_eq!( + downstream, "x", + "the next consumer sees the real typed character" + ); + assert_eq!( + buffer_text(&s), + "x", + "and pairing, reading the same field, did not close a forged opener" + ); +} + +// --------------------------------------------------------------------------- +// Re-entrant registration, and the consumer lifecycle +// --------------------------------------------------------------------------- + +#[test] +fn registering_or_removing_during_a_fan_out_takes_effect_on_the_next_one() { + // The fan-out iterates a snapshot. Iterating the live array instead + // lets a consumer that registers a LOWER-priority one shift itself + // forward under `ipairs` and run twice in a single fan-out — and + // repeating the registration makes that unbounded. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.order = {} + local function mark(tag) + return function() _G.order[#_G.order + 1] = tag; return false end + end + _G.doomed = pmacs.typed_edit.add_consumer { + name = "doomed", priority = 50, fn = mark("doomed"), + } + _G.did_register = false + pmacs.typed_edit.add_consumer { + name = "a", priority = 10, + fn = function() + _G.order[#_G.order + 1] = "a" + if not _G.did_register then + _G.did_register = true + pmacs.typed_edit.add_consumer { name = "b", priority = 5, fn = mark("b") } + pmacs.typed_edit.remove_consumer(_G.doomed) + end + return false + end, + } + "#, + ); + + type_str(&mut s, "x"); + let first: String = eval(&s, "return table.concat(_G.order, ',')"); + assert_eq!( + first, "a,doomed", + "`a` runs once even though it registered ahead of itself, and \ + `doomed` still runs in the fan-out it was removed during" + ); + + exec(&s, "_G.order = {}"); + type_str(&mut s, "y"); + let second: String = eval(&s, "return table.concat(_G.order, ',')"); + assert_eq!( + second, "b,a", + "both the registration and the removal land on the next fan-out" + ); +} + +#[test] +fn remove_consumer_unregisters_and_reports_whether_it_was_live() { + // Without removal, re-evaluating a config or reloading a package + // accumulates callbacks permanently — the leak COHERENCE.md §13 + // already records against `pmacs.hook.add`. A chain with no + // teardown would inherit it and spread it to every consumer. + let mut s = editor_with(""); + exec( + &s, + r#" + _G.runs = 0 + _G.h = pmacs.typed_edit.add_consumer { + name = "temporary", priority = 1, + fn = function() _G.runs = _G.runs + 1; return false end, + } + "#, + ); + + type_str(&mut s, "x"); + let runs: i64 = eval(&s, "return _G.runs"); + assert_eq!(runs, 1, "registered consumers run"); + + let first_removal: bool = eval(&s, "return pmacs.typed_edit.remove_consumer(_G.h)"); + let second_removal: bool = eval(&s, "return pmacs.typed_edit.remove_consumer(_G.h)"); + assert!(first_removal, "removing a live consumer reports true"); + assert!( + !second_removal, + "a double-remove is a reportable no-op, not a throw" + ); + + type_str(&mut s, "y"); + let runs: i64 = eval(&s, "return _G.runs"); + assert_eq!(runs, 1, "the removed consumer no longer runs"); + // Removal is surgical: the chain itself, and pairing on it, survive. + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "("); + assert_eq!( + buffer_text(&s), + "xy()", + "the rest of the chain is untouched" + ); +} + // --------------------------------------------------------------------------- // 46e — the Q#AP7 flush ordering the chain inherited // --------------------------------------------------------------------------- From a0fb01f24c2fc2e49b5302332b8327b7311309c4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 13:43:22 -0400 Subject: [PATCH 52/91] fix(buffer): fan out generated writes, and clear the history that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3, on the round-2 primitive itself. One lesson covers all three findings: a rope write is only half of an edit, and "discard history" means whichever history the buffer actually has. P1 — the binding swallowed the edit. `set_generated_contents` returned `()`, so nothing reached `notify_buffer_edit_to_windows`. Two consequences, both reproduced by the reviewer. In the default build a window showing the buffer kept a `TextView` line index describing the PREVIOUS contents, and the next paint indexed the new rope with stale ranges — `assertion failed: end <= self.len()` in `src/rope.rs`. In the CRDT build `pending_crdt_ops` stayed empty, so replica mirrors never imported the owner's write and their optimistic edits were generated against content already replaced. The `delete`+`insert` pair this replaced had done that fan-out for free. Now applies ONE whole-buffer `Replace`, returns its `Edit`, and notifies from the binding. The doc comment states the obligation, because the next owner to adopt the primitive inherits it. P2 — "discard history" was false in CRDT mode. The v0.1 stacks are bypassed entirely there; the history lives in loro's `UndoManager`. `read_only` stops the replay but not the retention, which is the memory cost the contract claims to eliminate. `UndoManager` exposes no clear, but needs none: it records only what happens after it is constructed, the same property `CrdtState::from_bytes` already uses to keep the seed insert out of undo. `CrdtState::clear_undo_history` rebinds a fresh manager to the same doc. P2 — the docs described the pre-fix architecture. Q#TC6a said no Lua binding sets `read_only` and round-trip input is the only guard; the acceptance text still said `is_read_only() == false` while 16b had been flipped to true; `terminal.lua`'s comment repeated the obsolete claim. The architecture is layered and now says so: rope-level read-only protects the daemon copy, round-trip input protects the replica's optimistic mirror, and neither substitutes for the other. Q#TC6a keeps its analysis under a superseded-in-part box rather than being silently rewritten — its conclusion survives, two of its premises do not. New pins. acc16d paints the window after a SHRINKING generated write: stale offsets then point past the buffer end, so the failure is the reported crash rather than merely stale pixels. acc16e asserts the refresh is queued for mirrors, through the real copy-mode path; `crdt`-gated and therefore dark in CI, which is why 16d drives the binding rather than the terminal. Plus a CRDT unit test that ten renders leave the `UndoManager` with nothing recorded. Bites: dropping the notify panics acc16d at `rope.rs:145` and fails acc16e with `queued: []`; dropping the `UndoManager` rebind fails the new unit test on `can_undo`. Still open, and recorded in COHERENCE.md §14: the fan-out obligation makes `*compilation*`/listview adoption more than a one-line swap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- COHERENCE.md | 8 +- builtin/runtime/terminal.lua | 31 ++-- docs/active-work.md | 49 +++++- docs/terminal-config-and-copy-mode-framing.md | 78 ++++++++- src/buffer.rs | 69 ++++++-- src/crdt.rs | 20 +++ src/lua_bindings/mod.rs | 19 ++- tests/terminal_copy_mode_acceptance.rs | 154 ++++++++++++++++++ 8 files changed, 382 insertions(+), 46 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 93298fc..0ba2ac8 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -1223,7 +1223,13 @@ Primitive-by-primitive against the list above: dispatchable"). `Buffer::set_generated_contents` (write + discard history + assert `read_only`, in one authorized call) now fixes this for the terminal snapshot; `*compilation*` and listview panels have - not yet adopted it and remain emptiable. + not yet adopted it and remain emptiable. **A second half of the same + caveat, found in round 3: a rope write is only half of an edit.** The + owner-authorized write must be fanned out to the windows showing the + buffer and queued for replica mirrors, or the displaying window keeps + a line index describing the previous contents and the next paint + indexes the new rope with stale ranges. Adoption is therefore not a + one-line swap. - **Diagnostics collection** ✓ — `DiagnosticStore` + signs + unified `error.next` source. - **Transient selector** ✓ — the minibuffer (though its `source` diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua index 5a912da..ef2fea4 100644 --- a/builtin/runtime/terminal.lua +++ b/builtin/runtime/terminal.lua @@ -329,7 +329,10 @@ local function render_snapshot(record) -- replaced a freshly rendered snapshot with an empty buffer. -- `set_generated_contents` writes, discards the history, and leaves -- `read_only` asserted, so undo/redo and remote CRDT imports are all - -- refused at the rope. + -- refused at the rope. Its binding also fans the resulting edit out to + -- the windows showing this buffer and to replica mirrors (review round + -- 3) — a rope write alone leaves a displaying window indexing the new + -- contents with stale line offsets. pmacs.buffer.set_generated_contents(record.buffer, text) end @@ -345,18 +348,22 @@ local function claim_snapshot(term_buf) local record = { terminal = term_buf, buffer = buf } handles[#handles + 1] = record - -- Q#TC6a — BOTH calls, and the second is the load-bearing one. + -- Q#TC6a — BOTH calls, and the protection is now LAYERED. Review + -- round 2 changed what each one is for. -- - -- An intercept guards the dispatch/edit path only. It does NOT set - -- `Buffer::read_only` (deliberately independent), and no Lua binding - -- sets that flag at all, so an optimistic CRDT op from a semantic - -- frontend bypasses the intercept AND passes `ensure_writable()` — - -- mutating the daemon buffer in lockstep with the mirror, with no - -- divergence to notice. `set_round_trip_input` prevents that at the - -- only point it can be prevented: `dispatch_idle_for` reports false - -- while this buffer is focused, so the frontend never applies - -- optimistically and never emits the op. It is the guard, not - -- hardening. + -- `set_generated_contents` leaves `read_only` asserted at the rope, so + -- on the DAEMON side undo, redo, ordinary edits and imported CRDT ops + -- are all refused by `ensure_writable()`. The intercept below is no + -- longer the daemon's guard; it survives to give a dispatching edit a + -- named error instead of a bare refusal. + -- + -- `set_round_trip_input` still guards the half `read_only` cannot + -- reach: a semantic frontend applies optimistically in its own MIRROR + -- before the daemon ever sees the op. `dispatch_idle_for` reports + -- false while this buffer is focused, so the mirror never mutates and + -- no op is emitted to be refused. That is the layering — rope-level + -- read-only protects the daemon copy, round-trip input protects the + -- replica copy — and neither substitutes for the other. pmacs.buffer.add_intercept(buf, function() error(name .. " is read-only") end) diff --git a/docs/active-work.md b/docs/active-work.md index 1ac337d..dc8c306 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -768,9 +768,52 @@ If it does not, stop and repair the remote/fetch configuration. and acc16b. - **Still open:** `*compilation*` and listview remain emptiable by `M-x buffer.undo`; the primitive they need now exists and is proven, - so the remainder is adoption plus a streaming-friendly variant. In - CRDT mode `read_only` is what refuses undo, since loro's `UndoManager` - exposes no clear through `CrdtState`. + so the remainder is adoption plus a streaming-friendly variant. +- **Review round 3 — one P1 and two P2s, all on the round-2 primitive.** + The lesson: **a rope write is only half of an edit, and "discard + history" means whichever history the buffer actually has.** + - **P1 — the binding swallowed the edit.** `set_generated_contents` + returned `()`, so nothing called `notify_buffer_edit_to_windows`. + Two consequences, both reproduced by the reviewer: in the default + build a window showing the buffer kept a `TextView` line index + describing the *previous* contents, and the next paint indexed the + new rope with stale ranges — `assertion failed: end <= self.len()` + in `src/rope.rs`; in the CRDT build `pending_crdt_ops` stayed empty, + so replica mirrors never received the owner's write. The prior + `buf:delete`/`buf:insert` pair had done this fan-out for free. + Fixed by applying **one whole-buffer `Replace`**, returning its + `Edit`, and notifying from the binding. + - **P2 — "discard history" was false in CRDT mode.** The v0.1 stacks + are bypassed entirely there; the history lives in loro's + `UndoManager`. `read_only` stops the replay but not the retention, + which is the memory cost the contract claims to eliminate. + `UndoManager` has no `clear`, but needs none — it records only what + happens after construction, the property `CrdtState::from_bytes` + already uses to keep the seed insert out of undo. New + `CrdtState::clear_undo_history` rebinds a fresh manager to the + same doc. + - **P2 — the docs described the pre-fix architecture.** Q#TC6a said no + Lua binding sets `read_only` and round-trip input is the only guard; + the acceptance text still said `is_read_only() == false` while 16b + had been flipped to `true`; `terminal.lua`'s comment repeated the + obsolete claim. The architecture is **layered** and now says so: + rope-level read-only protects the daemon copy, round-trip input + protects the replica's optimistic mirror, and neither substitutes + for the other. Q#TC6a carries a superseded-in-part box rather than + being silently rewritten. + - New pins: **acc16d** paints the window after a *shrinking* generated + write (the stale offsets then point past the end, which is the + reported crash rather than stale pixels); **acc16e** asserts the + refresh is queued for mirrors through the real copy-mode path + (`crdt`-gated, therefore dark in CI — 16d is the half that runs); + plus a CRDT `buffer.rs` unit test that ten renders leave the + `UndoManager` with nothing recorded. + - Bites: dropping the notify panics acc16d at `rope.rs:145` and fails + acc16e with `queued: []`; dropping the `UndoManager` rebind fails + the new unit test on `can_undo`. + - **Still open:** the fan-out obligation makes `*compilation*`/listview + adoption more than a one-line swap — recorded in `COHERENCE.md` §14 + alongside the undo half. - Load-bearing decisions, each forced by scouted ground truth: - profiles are a **raw Lua table** — `ConfigValue` is four scalars with no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`; diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md index 9d2a494..3d9891d 100644 --- a/docs/terminal-config-and-copy-mode-framing.md +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -6,7 +6,10 @@ `terminal-copy-mode` off `main` @ `cf54270`; no protocol change.** **Stage 2 ships eight of its nine criteria, plus 18a and 18b added in review -round 1.** Criterion 17's semantic-frontend end-to-end pin is deliberately +round 1 and 16c-16e in rounds 2-3.** Rounds 2 and 3 changed the design, not +just the code: the snapshot is now genuinely `read_only` at the rope, so +**Q#TC6a's analysis below is superseded in part** — read the box at its head +before the analysis. Q#TC6a's conclusion survives; two of its premises do not. Criterion 17's semantic-frontend end-to-end pin is deliberately absent — see the note under it — because a faithful version requires the real `pmacs-gpu` optimistic path, and therefore the `a37` foundation, which CI never compiles and which skips silently. Both halves of the *mechanism* it guards are @@ -348,6 +351,32 @@ ordinary document buffer, so: and `set_round_trip_input` is the ONLY thing standing between a replica frontend and unauthorized mutation.** +> **SUPERSEDED IN PART BY IMPLEMENTATION (review rounds 2-3). Read this +> box before the analysis below it.** The reasoning is still the correct +> account of the substrate *as it stood when this was written*, and its +> conclusion about round-trip input still holds. Two of its premises no +> longer do: +> +> - "**No Lua binding sets `read_only` at all**" — one does now. +> `pmacs.buffer.set_generated_contents` leaves it asserted, so on the +> daemon side undo, redo, ordinary edits and imported CRDT ops are all +> refused by `ensure_writable()`. That closed a real defect: undo +> bypasses the intercept chain, so `M-x buffer.undo` emptied the +> snapshot. +> - "**`set_round_trip_input` is the ONLY thing**" — it is now the only +> thing standing between a replica and *mirror* mutation, which is the +> half `read_only` cannot reach. A semantic frontend applies +> optimistically in its own mirror before the daemon sees the op; a +> daemon-side refusal cannot prevent that, it can only make the two +> copies disagree. +> +> The protection is therefore **layered, not singular**: rope-level +> read-only protects the daemon copy, round-trip input protects the +> replica copy, and neither substitutes for the other. The intercept +> survives only to give a dispatching edit a named error. The Deferred +> lane below records what this leaves open for `*compilation*` and +> listview, which have **not** adopted the primitive. + The established idiom is two calls: `listview.lua:106` and `compile.lua:272` each pair `pmacs.buffer.add_intercept` with `pmacs.buffer.set_round_trip_input(buf, true)`. Revision 2 described the @@ -382,7 +411,8 @@ Two things follow, and both are recorded rather than fixed here: genuinely immutable at the rope/CRDT boundary the way terminal identity buffers are, turning round-trip back into real defence in depth. That is a substrate change affecting listview and compile as much as this snapshot, so - it is named in Deferred with its own lane. + it is named in Deferred with its own lane. **Done for this snapshot only**, + and not by exposing the setter — see the Deferred lane and the box above. **Q#TC7 — the materializer reuses the existing serializer.** A whole-range variant of `copy_selection_bytes` over `retained_rows` inherits the criterion @@ -532,10 +562,19 @@ additive, on its own binding, and does not replace scroll-and-select. rely on intercept-plus-round-trip and are still emptiable by `M-x buffer.undo`. The primitive they need now exists and is proven, so the remaining work is adoption plus a streaming-friendly variant - (`*compilation*` appends rather than replacing wholesale). The CRDT half - is also still open: `set_generated_contents` clears the v0.1 stacks, and - in CRDT mode `read_only` is what refuses undo, since loro's - `UndoManager` has no clear exposed through `CrdtState`. + (`*compilation*` appends rather than replacing wholesale). + + **The CRDT half is closed too** (review round 3). Clearing the v0.1 + stacks proves nothing in CRDT mode, where they are bypassed entirely and + the history lives in loro's `UndoManager`. `read_only` would stop that + history being *replayed* but not *retained* — a panel refreshed on a + timer still grows without bound, which is the condition the contract + says it eliminates. `UndoManager` exposes no `clear`, but it needs none: + a manager records only what happens after it is constructed, which + `CrdtState::from_bytes` already relies on to keep the seed insert out of + undo. `CrdtState::clear_undo_history` rebinds a fresh manager to the same + doc, and `set_generated_contents` clears whichever history the buffer + actually has. ## Acceptance @@ -615,6 +654,23 @@ additive, on its own binding, and does not replace scroll-and-select. M-x path, plus the chord, plus redo — and paired with an assertion that the owner's own refresh still works, since that is what plain `read_only` would have broken. +16d. **A generated write reaches the window, not just the rope** (review + round 3). `set_generated_contents` returns one whole-buffer `Replace` + and its binding fans it out; swallowing it leaves a displaying + window's `TextView` line index describing the *previous* contents. + Pinned by **painting** — a shrinking write, so the stale offsets point + past the buffer end and the next render trips + `assertion failed: end <= self.len()` in `src/rope.rs`, which is the + reported crash rather than merely stale pixels. Driven through the Lua + binding copy mode itself calls, so it covers every future owner of the + primitive. +16e. **The same write is queued for replica mirrors** (review round 3, + CRDT half). The dropped fan-out also skipped + `queue_daemon_origin_crdt_op`, so a replica's mirror never imports the + owner's write and its optimistic edits are generated against content + already replaced. Pinned through the real copy-mode refresh on an + upgraded snapshot. `crdt`-gated, therefore dark in CI — 16d is the half + that actually runs there. 16. **Ungated, runs in CI:** focusing the snapshot buffer makes `dispatch_idle_for` report **false**. This is the whole mechanism Q#TC6a depends on, it needs no CRDT, and it fails the moment @@ -644,9 +700,13 @@ additive, on its own binding, and does not replace scroll-and-select. What IS pinned instead, ungated and in CI: acceptance 16 asserts the guard is armed (`dispatch_idle` false while the snapshot is focused, so no replica can apply optimistically or emit), and acceptance 16b asserts - the hazard is real by showing the snapshot buffer's `is_read_only()` is - **false** despite the intercept — i.e. nothing at the rope/CRDT boundary - would stop such an op if one arrived. Together those cover both halves of + the buffer is `is_read_only()` **true** at the rope, so an op that did + arrive at the daemon would be refused by `ensure_writable()` rather + than applied. (Rounds 1-2 asserted **false** here, documenting the + hazard; round 2 closed it, and the assertion was flipped with it. + That does not make 17 redundant — a daemon-side refusal cannot stop a + replica mutating its own mirror, which is precisely what + `set_round_trip_input` is for.) Together those cover both halves of Q#TC6a's *mechanism*. What remains unproven is only the end-to-end wire behaviour of a real GPU frontend, and it stays an explicit obligation of the CI `crdt`-coverage lane rather than being quietly dropped. diff --git a/src/buffer.rs b/src/buffer.rs index 27a496a..a9c01e9 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -526,31 +526,43 @@ impl Buffer { /// Discarding history is not tidiness. Without it every refresh pushes /// undo entries holding full rope clones that nothing can ever pop — /// `read_only` guarantees they are unreachable — so a periodically - /// refreshed buffer would grow without bound. + /// refreshed buffer would grow without bound. In CRDT mode the same + /// retention lives in loro's `UndoManager`, so both are cleared. + /// + /// # The returned edit must be fanned out + /// + /// One whole-buffer [`EditOp::Replace`] is applied, and its [`Edit`] + /// is returned rather than swallowed, because a rope write is only + /// half of an edit. Callers **must** route the result through their + /// normal edit-notification path (for the Lua surface, + /// `notify_buffer_edit_to_windows`). A window already displaying the + /// buffer keeps a stale `TextView` line cache otherwise, and the next + /// paint indexes the new rope with old ranges; and in CRDT mode the + /// op never reaches replica mirrors, so their optimistic edits are + /// generated against content the owner has already replaced. /// /// [`read_only`]: Self::set_read_only - pub fn set_generated_contents(&mut self, bytes: &[u8]) -> Result<(), BufferError> { + pub fn set_generated_contents(&mut self, bytes: &[u8]) -> Result { self.read_only = false; - let result = self.replace_whole_buffer(bytes); + let result = self.apply_edit_skip_intercepts(EditOp::Replace { + range: Range::new(0, self.len()), + bytes, + }); // Cleared even on failure: a partial replace must not leave a // half-applied edit reachable through an undo the owner cannot see. - self.undo.clear(); - self.redo.clear(); + self.clear_history(); self.read_only = true; result } - fn replace_whole_buffer(&mut self, bytes: &[u8]) -> Result<(), BufferError> { - let len = self.len(); - if len > 0 { - self.apply_edit_skip_intercepts(EditOp::Delete { - range: Range::new(0, len), - })?; + /// Drop undo and redo history in whichever mode this buffer is in. + fn clear_history(&mut self) { + self.undo.clear(); + self.redo.clear(); + #[cfg(feature = "crdt")] + if let Some(crdt) = self.crdt.as_ref() { + crdt.clear_undo_history(); } - if !bytes.is_empty() { - self.apply_edit_skip_intercepts(EditOp::Insert { pos: 0, bytes })?; - } - Ok(()) } fn ensure_writable(&self) -> Result<(), BufferError> { @@ -2048,6 +2060,33 @@ mod tests { ); } + /// Review round 3, P2. In CRDT mode the v0.1 stacks are bypassed + /// entirely, so clearing them proves nothing: the history the + /// primitive promises to discard lives in loro's `UndoManager`. + /// The lock is lifted deliberately — `read_only` stops the replay, + /// but the contract is that there is nothing left to replay. + #[cfg(feature = "crdt")] + #[test] + fn generated_writes_accumulate_no_crdt_history_either() { + let mut buf = + Buffer::new_with_crdt(BufferId::next(), "*generated*", 1).expect("crdt construction"); + for i in 0..10 { + buf.set_generated_contents(format!("render {i}").as_bytes()) + .expect("write"); + } + assert_eq!(rope_string(&buf), "render 9"); + assert!( + !buf.crdt_state().expect("crdt-backed").can_undo(), + "the UndoManager must have nothing recorded" + ); + + buf.set_read_only(false); + assert!( + matches!(buf.undo(), Err(BufferError::NothingToUndo)), + "CRDT-mode undo must find no history either" + ); + } + /// An ordinary edit is still refused after a generated write, so the /// primitive does not quietly leave the buffer writable. #[test] diff --git a/src/crdt.rs b/src/crdt.rs index 8cef9e3..ca68d1a 100644 --- a/src/crdt.rs +++ b/src/crdt.rs @@ -486,6 +486,26 @@ impl CrdtState { pub fn record_checkpoint(&self) -> LoroResult<()> { self.undo.borrow_mut().record_new_checkpoint() } + + /// Discard the bound peer's undo and redo history, keeping the + /// document itself untouched. + /// + /// Loro's `UndoManager` exposes no `clear`, but it does not need + /// one: a manager records only what happens **after** it is + /// constructed. [`Self::from_bytes`] already relies on exactly + /// that property to keep the seed insert out of undo. Replacing + /// the manager with a fresh one bound to the same doc therefore + /// leaves nothing to undo, and drops the old manager's retained + /// stacks with it. + /// + /// Used by [`crate::buffer::Buffer::set_generated_contents`], whose + /// contract is that a generated buffer accumulates no history + /// across refreshes. Marking the buffer read-only would stop the + /// history being *replayed*, but not being *retained* — a panel + /// refreshed on a timer would grow without bound. + pub fn clear_undo_history(&self) { + *self.undo.borrow_mut() = Self::create_undo_manager(&self.doc); + } } /// T M10.3: map a [`crate::protocol::FrontendId`] to the loro `PeerID` diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index cff5bda..aa5de88 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -3077,12 +3077,19 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result pmacs::buffer::BufferId { + let core = state.core.borrow(); + let reg = core.registry.borrow(); + reg.ids() + .iter() + .copied() + .find(|id| reg.get(*id).is_ok_and(|b| b.name() == SNAPSHOT_NAME)) + .expect("snapshot buffer exists") +} + +/// Rendered cells of the active window (the `m4_acceptance` grid helper; +/// cross-crate test code can't import it). +fn render_active_window_to_grid( + state: &mut EditorState, + rows: u32, + cols: u32, +) -> Vec { + use pmacs::cell::{Cell, CellGrid}; + use pmacs::view::{View, Viewport}; + use pmacs::window::Rect; + + let mut core = state.core.borrow_mut(); + let active = core.active_window_id(); + let registry = core.registry.clone(); + let win = core.windows.get_mut(&active).expect("active window"); + let rect = Rect::new(0, 0, rows, cols); + let mut backing = vec![Cell::default(); (rows * cols) as usize]; + let reg = registry.borrow(); + let buf = reg.get(win.buffer_id).expect("buffer in registry"); + let viewport = Viewport { + buffer_start: 0, + buffer_end: buf.len(), + cell_origin: rect.origin, + cell_size: CellSize::new(rows, cols), + gutter_w: 0, + folds: None, + }; + let mut grid = CellGrid { + cells: &mut backing, + stride: cols, + size: CellSize::new(rows, cols), + }; + win.text_view.render(buf, viewport, &mut grid); + backing +} + +fn grid_row(cells: &[pmacs::cell::Cell], row: u32, cols: u32) -> String { + (0..cols) + .map(|c| match cells[(row * cols + c) as usize].glyph { + Glyph::Char(ch) => ch, + _ => ' ', + }) + .collect::() + .trim_end() + .to_owned() +} + +/// Review round 3, P1. A rope write is only half of an edit: the window +/// showing the buffer holds a `TextView` line index that only `on_edit` +/// maintains, so a write that reaches the rope without the notification +/// leaves the two disagreeing. +/// +/// Pinned by PAINTING, because that is where the disagreement bites: with +/// the fan-out dropped, the next render indexes the new rope with the old +/// line offsets. A shrinking write is used deliberately — stale offsets +/// then point past the buffer end, which is the reported crash rather than +/// merely stale pixels. +/// +/// Driven through `pmacs.buffer.set_generated_contents`, the seam copy +/// mode's refresh actually calls, so it also covers `*compilation*` and +/// any other owner that adopts the primitive later. +#[test] +fn acc16d_a_generated_write_notifies_the_window_that_displays_it() { + let mut state = EditorState::new(); + exec( + &state, + r" + GEN = pmacs.buffer.create('*generated-probe*') + pmacs.buffer.set_generated_contents(GEN, 'alpha\nbeta\ngamma\ndelta\nepsilon\n') + pmacs.window.switch_buffer(GEN) + ", + ); + let painted = render_active_window_to_grid(&mut state, 6, 20); + assert_eq!( + grid_row(&painted, 0, 20), + "alpha", + "precondition: the window paints the generated buffer" + ); + + exec( + &state, + r"pmacs.buffer.set_generated_contents(GEN, 'CHANGED\n')", + ); + let painted = render_active_window_to_grid(&mut state, 6, 20); + assert_eq!( + grid_row(&painted, 0, 20), + "CHANGED", + "the window must paint the refreshed contents" + ); + assert_eq!( + grid_row(&painted, 1, 20), + "", + "and nothing of the longer contents it replaced" + ); +} + +/// Review round 3, P1, CRDT half. The same dropped fan-out also skips +/// `queue_daemon_origin_crdt_op`, so replica mirrors never import the +/// owner's write and their optimistic edits are generated against content +/// the owner has already replaced. +/// +/// Gated because `upgrade_to_crdt` is — and therefore dark in CI, which +/// never enables the feature. The default-configuration half above is the +/// one that actually runs there. +#[cfg(feature = "crdt")] +#[test] +fn acc16e_a_refresh_queues_the_owners_write_for_replica_mirrors() { + let mut state = EditorState::new(); + let terminal = open_fill_terminal(&mut state); + focus_terminal(&state, terminal); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let snapshot = snapshot_buffer_id(&state); + { + let core = state.core.borrow(); + let mut reg = core.registry.borrow_mut(); + let buffer = reg.get_mut(snapshot).expect("snapshot buffer"); + // `read_only` refuses the upgrade's own bookkeeping path the same + // way it refuses everything else, so lift it around the upgrade. + buffer.set_read_only(false); + buffer.upgrade_to_crdt(2).expect("upgrade"); + buffer.set_read_only(true); + } + state.core.borrow_mut().pending_crdt_ops.clear(); + + emit_into_child(&mut state, terminal, "MIRRORME"); + exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)"); + + let queued: Vec<_> = state + .core + .borrow() + .pending_crdt_ops + .iter() + .map(|(_, id, _)| *id) + .collect(); + assert!( + queued.contains(&snapshot), + "the owner's refresh must be queued for broadcast; queued: {queued:?}" + ); + state.process_supervisor.borrow_mut().shutdown(); +} + /// Acceptance 18: re-invoking refreshes in place, and the lifecycle runs /// both directions. #[test] From ea9b8c379e97fdfb84e86d37b21a3de8bbb7c1f9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 13:43:30 -0400 Subject: [PATCH 53/91] docs(lean4): correct Q#LN10's throw-containment rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q#LN10 still said a throwing consumer "fails the fan-out for everyone." It does not: `run_all_must_succeed` (src/hook.rs:332) collects the error and continues to the hook's remaining subscribers, so `lsp.lua` still flushes didChange. The throw stops every LATER consumer in the chain, which is a narrower consequence and still worth containing — the failure is silent exactly where the abandoned consumers registered. The module comment, criterion 46d, the test, and the ledger were all corrected in the previous commit; Q#LN10 is the decision they descend from, so leaving it stale would have made the disproven claim the authoritative one. Also records the protected-rendering rule there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- docs/lean4-mode-framing.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index 789e419..c4a32e5 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -1523,11 +1523,28 @@ sees the exact record via `_capture_records`, and that the Q#AP7 ordering against `lsp.lua`'s `didChange` flush still holds. **What 4a deliberately does not do.** It does not change the `all-must- -succeed` contract, so a consumer that throws still fails the fan-out for -everyone. The chain owner therefore `pcall`s each consumer and reports -through `pmacs.editor.set_status`, matching `pair.lua`'s existing -never-throw-from-after-edit discipline — this is behavior-preserving for -pairing (which already never throws) and is the guardrail 4b needs. +succeed` contract. What that contract actually does on a throw was +stated wrongly through rev 7 and is corrected here, because this +paragraph is the authority the module comment, criterion 46d, the test, +and the ledger all descend from: `run_all_must_succeed` +(`src/hook.rs:332`) **collects** the error and continues to the hook's +remaining subscribers, marking only the run as failed. An uncontained +throw inside the chain therefore does **not** stop `lsp.lua` from +flushing `didChange`. What it does stop is every LATER consumer in the +chain — the chain is one subscriber, and a throw abandons the rest of +its loop. + +That is a narrower consequence than rev 7 claimed and still worth +containing, because the failure is silent in the direction that matters: +a consumer that throws disables the consumers behind it with no signal +at the seam where they were registered. The chain owner therefore +`pcall`s each consumer and reports through `pmacs.editor.set_status`, +matching `pair.lua`'s existing never-throw-from-after-edit discipline — +this is behavior-preserving for pairing (which already never throws) and +is the guardrail 4b needs. The **rendering** of the caught error is +protected the same way: a Lua error may be any value, including a table +whose `__tostring` throws, so `tostring` outside the `pcall` would +reintroduce the escape the containment exists to prevent. ### Q#LN11 — Stage 4b data: vendor the table, generated, attributed From 23c966cc31142ad6ce2e4ba4cc32749b8eaff9be Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 14:38:22 -0400 Subject: [PATCH 54/91] docs(terminal): restate criterion 17's bite for the fixed substrate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 4, P2. A fix can invalidate a test that was never written. Criterion 17 still specified the pre-round-2 world: remove `set_round_trip_input` and the optimistic op "passes `ensure_writable()` and mutates BOTH sides, silently, with no divergence to notice". That was true while no Lua binding set `read_only`. Since `set_generated_contents` does, the daemon refuses the op — so only the frontend's own mirror mutates, and the copies diverge. The gap matters precisely because 17 is unpinned. A real-GPU test written to the old spec would hunt for a daemon-side edit that can no longer occur and pass for the wrong reason, quietly readmitting the round-2 regression through a test not yet built. The specification is the artifact under review here, not the code. Restated around unauthorized MIRROR mutation plus daemon refusal — divergence — in all four places carrying the obsolete claim: the criterion itself, the Q#TC6a heading, the acceptance-16 doc comment, and the bite roster. The heading's "ONLY thing" now says what it is the only thing FOR: the replica's own mirror. `docs/active-work.md` also still described acceptance 16b as asserting `is_read_only()` is false, which round 2 flipped. Why round-trip input stays load-bearing rather than redundant, now stated wherever the daemon guard is mentioned: a refusal arrives after the frontend has already applied optimistically and painted. It buys divergence instead of silent agreement; it does not prevent the mutation the user is looking at. Also recorded, after capturing it properly this time: the gate-run flake in `cargo test --lib --features crdt` is `process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` (`active_reader_probe` -> None, "live runtime probe"), ~1 run in 5. Pre-existing and unrelated — this branch does not touch `src/process.rs`, the test passes 10/10 standalone and 2017/2017 at `--test-threads=1`, and it is another instance of the known `drain_until` trap: draining for `Started` also ticks, and a tick reaps the leader. That also explains the unattributed "2 failed" run noted in round 2. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- docs/active-work.md | 45 +++++++++++++++++-- docs/terminal-config-and-copy-mode-framing.md | 39 +++++++++++----- tests/terminal_copy_mode_acceptance.rs | 11 ++++- 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index dc8c306..62a9b6d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -685,10 +685,15 @@ If it does not, stop and repair the remote/fetch configuration. second test on that footing buys the appearance of coverage. Both halves of the mechanism are pinned **ungated** instead: acceptance 16 (the guard is armed — `dispatch_idle` false while the snapshot is - focused) and 16b (the hazard is real — the snapshot's `is_read_only()` - is **false** despite the intercept, so nothing at the rope/CRDT - boundary would stop an op that did arrive). The wire-level half is an - explicit obligation of the CI `crdt`-coverage lane. + focused) and 16b (the daemon holds — `is_read_only()` is **true** at + the rope, so an op that did arrive is refused by `ensure_writable()`). + **Rounds 2-3 changed what 17 must show.** 16b asserted `false` through + round 1, documenting the hazard; round 2 closed it. So the eventual + real-GPU test must look for **mirror mutation plus daemon refusal — + divergence** — not the "mutates both sides, silently" the criterion + originally specified, which after the fix cannot happen and would pass + for the wrong reason. The wire-level half stays an explicit obligation + of the CI `crdt`-coverage lane. - Load-bearing Stage 2 decisions: - **The snapshot MATERIALIZES into an ordinary buffer**, so isearch, motion, selection and the kill ring work with no new substrate, and @@ -814,6 +819,38 @@ If it does not, stop and repair the remote/fetch configuration. - **Still open:** the fan-out obligation makes `*compilation*`/listview adoption more than a one-line swap — recorded in `COHERENCE.md` §14 alongside the undo half. +- **Review round 4 — one P2, docs only, and it is the interesting kind.** + **A fix can invalidate a test that was never written.** Criterion 17's + *bite* still described the pre-round-2 world: remove + `set_round_trip_input` and the op "mutates both sides, silently, with + no divergence to notice". True while nothing set `read_only` from Lua; + false once `set_generated_contents` did. A real-GPU test written to + that spec would hunt for a daemon-side edit that can no longer occur + and pass for the wrong reason — the specification would have leaked + the round-2 regression back in, through a test not yet built. + - Restated around **unauthorized mirror mutation plus daemon refusal = + divergence**, in all four places that carried the old claim: the + criterion, the Q#TC6a heading, the acceptance-16 doc comment, and the + bite roster. The heading's "ONLY thing" now says what it is the only + thing *for* — the replica's own mirror. + - Why round-trip input is still load-bearing rather than redundant: a + daemon refusal arrives after the frontend has already applied + optimistically and painted. It buys divergence instead of silent + agreement; it does not prevent the mutation the user sees. + - **Gate-run flake identified and attributed, not waved off.** + `cargo test --lib --features crdt` failed ~1 run in 5 on + `process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` + — `active_reader_probe` returning `None` at `process.rs:3179` + ("live runtime probe"). **Pre-existing and unrelated:** this branch + does not touch `src/process.rs` (last changed by the Darwin PTY + signal-name fix), and the test passes 10/10 standalone, failing only + under full-suite parallelism. It is **another instance of the known + `drain_until` trap** — draining for `Started` to learn the pid also + ticks, and a tick reaps the leader, so the probe that follows finds + nothing live. Same module and same signature as the earlier + `signal`-says-"is not running" case. This also explains the + unattributed "2 failed" CRDT run recorded in round 2. Belongs to the + CI `crdt`-coverage lane, which is where the whole class lives. - Load-bearing decisions, each forced by scouted ground truth: - profiles are a **raw Lua table** — `ConfigValue` is four scalars with no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`; diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md index 3d9891d..15878cf 100644 --- a/docs/terminal-config-and-copy-mode-framing.md +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -9,7 +9,12 @@ round 1 and 16c-16e in rounds 2-3.** Rounds 2 and 3 changed the design, not just the code: the snapshot is now genuinely `read_only` at the rope, so **Q#TC6a's analysis below is superseded in part** — read the box at its head -before the analysis. Q#TC6a's conclusion survives; two of its premises do not. Criterion 17's semantic-frontend end-to-end pin is deliberately +before the analysis. Q#TC6a's conclusion survives; two of its premises do +not, and **criterion 17's bite was restated with them** — the daemon now +refuses the op, so the failure it must look for is mirror mutation plus +divergence, not silent agreement. + +Criterion 17's semantic-frontend end-to-end pin is deliberately absent — see the note under it — because a faithful version requires the real `pmacs-gpu` optimistic path, and therefore the `a37` foundation, which CI never compiles and which skips silently. Both halves of the *mechanism* it guards are @@ -347,9 +352,10 @@ ordinary document buffer, so: inspectable — the idiom `COHERENCE.md` §6 identifies as the right side of the line. -**Q#TC6a — the snapshot is BOTH intercept-read-only AND round-trip-marked, -and `set_round_trip_input` is the ONLY thing standing between a replica -frontend and unauthorized mutation.** +**Q#TC6a — the snapshot is read-only at the rope AND round-trip-marked, and +each guard covers a copy the other cannot reach: `read_only` refuses the op +at the daemon, `set_round_trip_input` is the ONLY thing standing between a +replica frontend and unauthorized mutation of its own mirror.** > **SUPERSEDED IN PART BY IMPLEMENTATION (review rounds 2-3). Read this > box before the analysis below it.** The reasoning is still the correct @@ -680,10 +686,22 @@ additive, on its own binding, and does not replace scroll-and-select. 17. **Through a semantic frontend** (this one does need CRDT): keys typed in the snapshot buffer reach ordinary dispatch and never the child, and **neither the daemon buffer nor the frontend's mirror is mutated** - (Q#TC6a). Bite: with `set_round_trip_input` removed, the optimistic op is - emitted, bypasses the Lua intercept, passes `ensure_writable()`, and - mutates **both sides** — a buffer the editor calls read-only silently - accepts an edit. + (Q#TC6a). Bite: with `set_round_trip_input` removed, the frontend + applies the edit **optimistically to its own mirror** and emits the op; + the mirror now shows text the user was told is read-only. The daemon + refuses the op at `ensure_writable()` — `set_generated_contents` leaves + `read_only` asserted — so the two copies **diverge**, and the local + mirror is the one the user is looking at. + + **This bite changed in review round 3, and the direction matters.** + Rounds 1-2 specified it as "mutates *both sides*, silently, with no + divergence to notice" — true when nothing set `read_only` from Lua, + and false now. The eventual real-GPU test must assert **mirror + mutation plus daemon refusal**, not silent agreement; written the old + way it would look for a daemon-side edit that can no longer happen and + pass for the wrong reason. That the daemon now holds is exactly why + round-trip input is still load-bearing rather than redundant: a + refusal protects the daemon's copy and does nothing for the replica's. **NOT PINNED as specified, deliberately, and this is the one gap in Stage 2.** A faithful test has to drive the *real* `pmacs-gpu` binary: @@ -795,8 +813,9 @@ Full gate suite per `CLAUDE.md` for each PR separately, plus: implementations (epoch-only key, single last-entry, unpurged map), which is why one pin was not enough; **9** (a hardcoded `0x03` makes the configured chord unreachable); **10** (its failure mode is a terminal nobody can - escape); and **16/17** (a read-only buffer that silently accepts an edit on - both sides). + escape); and **16** (a read-only buffer whose replica mirror accepts an + edit the user is then looking at — 17's daemon half was closed in review + round 2, and its bite restated in round 3). - **The observation seams the cache pins need are `escape_parses` (how often) and `escape_caches` (how many are still held).** Neither is inferable from behavior: for a *valid* setting a correct per-session cache and a leaking diff --git a/tests/terminal_copy_mode_acceptance.rs b/tests/terminal_copy_mode_acceptance.rs index 9572cbc..ed3f6d7 100644 --- a/tests/terminal_copy_mode_acceptance.rs +++ b/tests/terminal_copy_mode_acceptance.rs @@ -308,8 +308,15 @@ fn acc15_isearch_finds_content_only_in_scrollback() { /// Acceptance 16 — the load-bearing pin, and the reason this suite is /// ungated. `set_round_trip_input` is the ONLY thing standing between a -/// replica frontend and unauthorized mutation (Q#TC6a), so its regression -/// must be caught in the configuration CI actually compiles. +/// replica frontend and unauthorized mutation **of its own mirror** +/// (Q#TC6a), so its regression must be caught in the configuration CI +/// actually compiles. +/// +/// Rope-level `read_only` does not substitute for it. Since review round 2 +/// the daemon refuses such an op at `ensure_writable()` — but a refusal +/// arrives after the frontend has already applied optimistically and +/// painted the result. What that buys is divergence instead of silent +/// agreement; what stops the mutation is this. #[test] fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() { let mut state = EditorState::new(); From a58bb110bc871e95126381d97486e9388bdd50f5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 14:54:37 -0400 Subject: [PATCH 55/91] docs(terminal): qualify process flake attribution Separate the observed suite behavior from the suspected drain_until mechanism, and leave round-2 failures unattributed without test names. --- docs/active-work.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 62a9b6d..3a5bb88 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -837,20 +837,23 @@ If it does not, stop and repair the remote/fetch configuration. daemon refusal arrives after the frontend has already applied optimistically and painted. It buys divergence instead of silent agreement; it does not prevent the mutation the user sees. - - **Gate-run flake identified and attributed, not waved off.** + - **Gate-run flake observed and scoped without overclaiming its cause.** `cargo test --lib --features crdt` failed ~1 run in 5 on `process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` — `active_reader_probe` returning `None` at `process.rs:3179` ("live runtime probe"). **Pre-existing and unrelated:** this branch does not touch `src/process.rs` (last changed by the Darwin PTY - signal-name fix), and the test passes 10/10 standalone, failing only - under full-suite parallelism. It is **another instance of the known - `drain_until` trap** — draining for `Started` to learn the pid also - ticks, and a tick reaps the leader, so the probe that follows finds - nothing live. Same module and same signature as the earlier - `signal`-says-"is not running" case. This also explains the - unattributed "2 failed" CRDT run recorded in round 2. Belongs to the - CI `crdt`-coverage lane, which is where the whole class lives. + signal-name fix), and the test passed 10/10 standalone; the observed + failures were during parallel full-suite runs. That localizes the + trigger to suite load or interaction, but does **not** distinguish + parallelism from another full-suite effect — no serial full-suite bite + was run. The leading code-path explanation is the known `drain_until` + trap: draining for `Started` also ticks, and a tick can reap the leader + before the following `active_reader_probe`. That is an inference from + the failure site and control flow, not yet a falsified root cause. + It belongs to the CI `crdt`-coverage lane for discrimination. The two + round-2 CRDT failures had no captured test names; this flake is a + plausible candidate for them, but they remain **unattributed**. - Load-bearing decisions, each forced by scouted ground truth: - profiles are a **raw Lua table** — `ConfigValue` is four scalars with no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`; From 44f5e44b9fce3cb4654e8f95249e8fd39e4371c8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 15:48:03 -0400 Subject: [PATCH 56/91] docs: record #168 and #178 as landed, and the generated-buffer invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation only; no code, no protocol change. Handoff §1 anchors on `main` @ `fe8b8ba` and leads with terminal copy mode (#178) and its landed-doc pair (#168). The active-work terminal lane flips to BOTH STAGES MERGED, and the #166 lane records #168 landing with what it measured: 264 tests dark workspace-wide for want of `crdt` in CI, and the vterm audit's honest count of acceptances that drive a real daemon (2, not 3). The substantive addition is a new §4 substrate invariant for `Buffer::set_generated_contents`, because it is now the one authorized write for every generated buffer and three of its properties are non-obvious enough that four review rounds were spent finding them: - an intercept is not read-only — `Buffer::undo` reaches the rope through `ensure_writable` without consulting the intercept chain, and rebinding the undo chords does not help because `M-x buffer.undo` needs no keymap; - a bare `set_read_only` would be worse than nothing, since it also refuses the owner's refresh — the pairing is the primitive, which is why no Lua `set_read_only` exists; - a rope write is only half of an edit: the returned `Edit` must be fanned out, or a displaying window paints the new rope with stale line offsets and replica mirrors never import the write. Also recorded: history clearing must clear whichever history the buffer has (the v0.1 stacks are bypassed in CRDT mode), that `*compilation*` and listview have NOT adopted the primitive and remain emptiable by `M-x buffer.undo`, and that this does not replace `set_round_trip_input` — the two guards cover different copies, and a daemon refusal arrives after the frontend has already painted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- docs/active-work.md | 20 ++++++++++++---- docs/agent-handoff.md | 55 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 7df3466..0c234ee 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -468,7 +468,7 @@ If it does not, stop and repair the remote/fetch configuration. **not** `crdt`-gated and do run under CI's exact flags, including the controller-release pin whose only job is catching the plausible wrong fix. -## Terminal config + copy mode arc — Stage 1 MERGED; Stage 2 IN REVIEW +## Terminal config + copy mode arc — BOTH STAGES MERGED (arc complete) - Approved framing: `docs/terminal-config-and-copy-mode-framing.md` **revision 4** (four review rounds), committed as the first commit of @@ -481,9 +481,15 @@ If it does not, stop and repair the remote/fetch configuration. binding; no protocol change. Main was integrated **twice** during the single review round (`ccf29e3`, then `c93f9ee` after the first merge left the PR conflicting) — see the no-CI-while-conflicting fact below. -- **Stage 2 = `githubsucks/terminal-copy-mode`**, worktree - `../pmacs-terminal-copy-mode`, based on `githubsucks/main` @ - `cf54270`. Copy mode: `M-x terminal.copy-mode` / `C-c C-t`. +- **Stage 2 MERGED as #178** (`main` @ `fe8b8ba`, 2026-07-26, **four + review rounds**, twelve checks green on head `1b44c69` — verified by + `head_sha`, not by the check summary). Copy mode: + `M-x terminal.copy-mode` / `C-c C-t`. Branch + `githubsucks/terminal-copy-mode` and worktree + `../pmacs-terminal-copy-mode` retained. Main was integrated once, after + #168 landed; the `docs/active-work.md` terminal-lane conflict resolved + by taking main's fuller Stage 1 sentence under this lane's Stage 2 + record. - **Stage 2 ships eight of nine criteria, and the missing one is named.** Criterion 17 (a real semantic frontend proving neither daemon buffer nor mirror mutates) is **not pinned**: the optimistic apply exists only @@ -947,6 +953,12 @@ git worktree add --track \ reproduces in-process and so is not the GUI/TUI asymmetry; and a geometry change appearing to clear the visible screen, which reproduces pre-fix). Branch `gpu-terminal-input` and worktree `../pmacs-gui-term-input` retained. + **Its landed-doc pair MERGED as #168** (`main` @ `1b6a084`, + 2026-07-26): #166 recorded as landed, the CI `crdt`-coverage gap + measured (**264 tests dark workspace-wide**, 177 in the library), the + vterm audit corrected — "only 3 of 9 acceptances drive a real daemon" + was optimistic; without the frontend binary the honest number is + **2** — and the a37 findings folded into the coverage lane. - **Inline-math slice — MERGED as #158** (`main` @ `5aa9044`, 2026-07-25). Detect → parse → layout → draw for `$…$`, entirely inside `pmacs-gpu`, no protocol change. Verified by the user's manual pass on diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 45ee353..7d1b326 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,9 +1,13 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-26, after Lean 4 Stage 4a (#179) — the typed-edit +**Last updated: 2026-07-26, after terminal copy mode (#178) — `C-c C-t` +materializes a terminal's whole retained range into an ordinary buffer, +plus `Buffer::set_generated_contents`, the first genuinely immutable +generated-buffer write path — and its landed-doc pair (#168); following +Lean 4 Stage 4a (#179) — the typed-edit consumer chain — and bottom-panel Stage 2A (#177), the classified census routing that makes every Projection-class consumer ask -`primary_document_window`; following the bottom-panel Stage 2 framing +`primary_document_window`; the bottom-panel Stage 2 framing (#175), terminal configuration Stage 1 (#173) — profiles, scrollback, a per-terminal configurable escape key, and the `C-c t` opening binding — Lean 4 stages 3a and 3b (#167, #170), pmacs' first Lean language server; @@ -37,7 +41,8 @@ commands, read `docs/active-work.md` immediately after this file. ## 1. Where the project stands (2026-07-26) -- `main` @ `a27f646` (Lean 4 Stage 4a #179 atop bottom-panel Stage 2A +- `main` @ `fe8b8ba` (terminal copy mode #178 atop the GPU-terminal-input + landed docs #168, Lean 4 Stage 4a #179, bottom-panel Stage 2A #177, the bottom-panel Stage 2 framing #175, terminal configuration Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a #167, the CRDT undo repro #157, the inline-math landed-doc refresh #172, the bottom-panel @@ -884,6 +889,50 @@ before trusting them: ## 4. Substrate invariants (do not undo; tests enforce most of these) +**Generated buffers: `Buffer::set_generated_contents` is the ONE +authorized write** (terminal copy mode #178) — lift `read_only`, replace +via a single whole-buffer `Replace` skipping intercepts, discard history, +re-assert `read_only`, and **return the `Edit`**. Three things make it a +unit rather than a convenience: + +- **An intercept is not read-only.** `Buffer::undo` reaches the rope + through `ensure_writable` and never consults the intercept chain, so an + intercept-only "read-only" buffer is emptied by `M-x buffer.undo`. + Rebinding the undo *chords* buffer-locally does **not** close it — + `compile.lua`'s own comment says so ("command/menu undo stays + dispatchable"). Only rope-level `read_only` does. +- **A bare `set_read_only` would be worse than nothing**, because it also + refuses the owner's refresh — the operation such buffers exist for. + That is why the pairing, not the setter, is the primitive. There is + deliberately no Lua `set_read_only`. +- **A rope write is only half of an edit.** The returned `Edit` must be + fanned out (`notify_buffer_edit_to_windows`, which also queues the + daemon-origin CRDT op). Skip it and a displaying window keeps a + `TextView` line index describing the previous contents — the next paint + indexes the new rope with stale ranges and trips + `assertion failed: end <= self.len()` — while replica mirrors never + import the write at all. + +History clearing is load-bearing twice (nothing can pop entries +`read_only` makes unreachable, so they leak), and must clear **whichever +history the buffer has**: the v0.1 stacks are bypassed in CRDT mode, where +it lives in loro's `UndoManager`. That has no `clear`, and needs none — a +manager records only what happens after construction, so +`CrdtState::clear_undo_history` rebinds a fresh one to the same doc. + +**Not yet adopted:** `*compilation*` and listview panels still rely on +intercept-plus-`set_round_trip_input` and remain emptiable by +`M-x buffer.undo`. Adoption is not a one-line swap — it inherits the +fan-out obligation, and `*compilation*` appends rather than replacing, so +it needs a streaming variant. Recorded in `COHERENCE.md` §14. + +**And it does not replace `set_round_trip_input`.** The protection is +layered across two copies: rope-level `read_only` refuses the op at the +daemon; round-trip input stops a semantic frontend applying +optimistically to its **own mirror**, which a daemon-side refusal cannot +reach — the refusal arrives after the frontend has already painted, so it +buys divergence, not prevention. + **Command boundaries (Arc 2 kill-ring substrate)** — `EditorCore.command_history: HashMap`, per frontend. Rotate on: keybound command, self-insert, menu invoke, From a53965474d245d29d9454ec420d199216c2a6cd3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:24:06 -0400 Subject: [PATCH 57/91] feat(lean4): the Unicode input method (Arc 8 Stage 4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing `\alpha` in a Lean 4 buffer gives `α`; `\<>` gives `⟨⟩` with the point between them. The abbreviation table is vendored from vscode-lean4 and the expander is a typed-edit consumer registered on the Stage 4a chain at priority 50, ahead of auto-pairing. The ordering is load-bearing. 64 abbreviation keys contain a character in the `lean4` pair set, so with pairing first, typing `\[` would insert `[]` and corrupt the pending key to `\[]` before the second `[` arrives — `\[[]]` becomes unreachable. The consumer therefore claims every keystroke that EXTENDS a pending abbreviation, not only one that completes an expansion; claiming only completions would hand each intermediate `[` to pairing by a different route. The vendored table is an ORDERED SEQUENCE, not a map. Upstream breaks equal-length ties by source declaration order — 101 prefixes depend on it, and `\f` resolves through `f<` rather than `f>` — which a `pairs`-iterated Lua table cannot express. `scripts/regen-lean-abbrev` takes a vscode-lean4 commit, emits the file with its provenance header, and aborts on a duplicate key, invalid UTF-8, or a round-trip mismatch. Undo is cross-peer-degraded on CRDT frontends and that is accepted and named, not papered over (Q#LN21): `\alpha` arrives as six source-peer optimistic inserts while the expansion is one daemon-peer replace. `set_round_trip_input` would fix it and also makes `dispatch_idle` report false, so RET would stop inserting a newline. Round 9 corrects three approved acceptance criteria that the real table contradicts, found by simulating the state machine over all 1,855 entries and re-reading upstream at the pinned commit rather than re-reading the prose. `\to` is not eager — `top`, `to0` and `toa` extend it. `\zzzz` expands to `ζzzz ` because `ze`, `zeta` and `zsqrtd` exist; only `$ % , ; @ W` open no key at all. And `\alpha`'s undo does not restore `\alpha ` because `alpha` IS eager, so the terminator is a separate edit. Criteria 38, 41 and 42 now state both paths, and the false halves are asserted too: they read as correct until the table is consulted. Three implementation traps worth the record. The generator's own round-trip check was broken twice and failed closed both times: `str.splitlines()` splits on U+2028, which 53 symbols contain, and escaping through `chr(byte)` produced a latin-1-shaped string that the UTF-8 write re-encoded. The first check compared in-memory strings and agreed with itself; it now stages the file, re-reads the bytes from disk, and renames into place only on a match. And the expansion SHRINKS the buffer, so the point must be placed explicitly — pairing's no-cursor-motion rule holds only for an insert AT the cursor, and without this every self-insert after the first expansion is silently rejected and the editor looks dead. 25 acceptance tests plus one `--lib` test for the optimistic CRDT producer (45f), which is where the gate list's `--features crdt` run reaches it; a crdt-gated integration test would be dark in CI and in the gates both. Fifteen mutations bite, each failing its target. Three of these tests were vacuous when first written and biting is what found them: the abandonment test asserted text a surviving record would also produce, the re-arm test used an example that never reaches the re-arm branch, and both switch tests ran through `find_or_open`'s fresh-load path rather than `buffer.after-switch`. No protocol change (Q#LN14). Also reconciles the handoff and ledger for Stage 4a (#179) and adds `lean.abbrev` to COHERENCE.md's config-registry adoption census, now nine settings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- COHERENCE.md | 9 +- builtin/runtime/lean_abbrev.lua | 1883 +++++++++++++++++++++++++++++++ builtin/runtime/lean_input.lua | 362 ++++++ docs/active-work.md | 235 ++-- docs/agent-handoff.md | 46 +- docs/lean4-mode-framing.md | 76 +- scripts/regen-lean-abbrev | 254 +++++ src/daemon.rs | 137 +++ src/editor.rs | 21 + tests/lean_input_acceptance.rs | 678 +++++++++++ 10 files changed, 3521 insertions(+), 180 deletions(-) create mode 100644 builtin/runtime/lean_abbrev.lua create mode 100644 builtin/runtime/lean_input.lua create mode 100755 scripts/regen-lean-abbrev create mode 100644 tests/lean_input_acceptance.rs diff --git a/COHERENCE.md b/COHERENCE.md index 4e7361c..f172a21 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -1022,12 +1022,15 @@ layering, provenance, and adoption have not followed.** `ConfigValue`s; `describe-setting`'s "Source:" names where `define()` ran. The inspection view sketched above is currently impossible to render. -- **Adoption is eight settings**: `editing.auto-pair` (pair.lua), +- **Adoption is nine settings**: `editing.auto-pair` (pair.lua), `editing.trim-on-save` (editops.lua), `autosave.interval-ms` (autosave.lua), `window.panel-height` + `window.min-height` - (window.lua), and `terminal.default-profile` + + (window.lua), `terminal.default-profile` + `terminal.scrollback-rows` + `terminal.escape-key` (terminal.lua, - #173). Everything else a user might set — theme, fonts, LSP + #173), and `lean.abbrev` (lean_input.lua, Arc 8 Stage 4b) — a + `live` boolean read against the typed edit's SOURCE buffer, the + `editing.auto-pair` shape including its correction to resolve + `rec.buffer` rather than the active buffer. Everything else a user might set — theme, fonts, LSP server config, killring size, recentf/saveplace/desktop enables, pair sets, comment strings, `pmacs.parse.*` — lives in raw Lua outside the registry and is therefore invisible to `describe-setting` diff --git a/builtin/runtime/lean_abbrev.lua b/builtin/runtime/lean_abbrev.lua new file mode 100644 index 0000000..769c9d4 --- /dev/null +++ b/builtin/runtime/lean_abbrev.lua @@ -0,0 +1,1883 @@ +-- lean_abbrev.lua --- VENDORED DATA. Do not edit by hand. +-- +-- The Lean 4 abbreviation table, generated from: +-- +-- repo: https://github.com/leanprover/vscode-lean4 +-- path: lean4-unicode-input/src/abbreviations.json +-- commit: 17d1d08 +-- license: Apache-2.0 +-- entries: 1855 (26 carry $CURSOR) +-- source: 36861 bytes +-- +-- Regenerate with: +-- +-- scripts/regen-lean-abbrev 17d1d08 +-- +-- An ORDERED SEQUENCE, not a map: upstream resolves equal-length ties +-- by source declaration order (101 prefixes depend on it), and a +-- `pairs`-iterated Lua map cannot express that. The file's own line +-- order is the audit trail. Consumers must not reorder it. +-- +-- Not fetched at runtime and not a package dependency: the input method +-- has to work offline and on first launch. Upkeep is a documented +-- manual process — see docs/lean4-mode-framing.md Q#LN11. + +pmacs = pmacs or {} + +pmacs.lean_abbrev = { + { "{}", "{$CURSOR}" }, + { "{}_", "{$CURSOR}_" }, + { "{{}}", "⦃$CURSOR⦄" }, + { "[]", "[$CURSOR]" }, + { "[]_", "[$CURSOR]_" }, + { "[[]]", "⟦$CURSOR⟧" }, + { "<>", "⟨$CURSOR⟩" }, + { "()", "($CURSOR)" }, + { "()_", "($CURSOR)_" }, + { "^()", "⁽$CURSOR⁾" }, + { "_()", "₍$CURSOR₎" }, + { "([])'", "⟮$CURSOR⟯" }, + { "(())", "⸨$CURSOR⸩" }, + { "f<>", "‹$CURSOR›" }, + { "f<<>>", "«$CURSOR»" }, + { "h<>", "❰$CURSOR❱" }, + { "[--]", "⁅$CURSOR⁆" }, + { "||||", "‖$CURSOR‖" }, + { "nnnorm", "‖$CURSOR‖₊" }, + { "norm", "‖$CURSOR‖" }, + { "floor", "⌊$CURSOR⌋" }, + { "ceil", "⌈$CURSOR⌉" }, + { "nfloor", "⌊$CURSOR⌋₊" }, + { "nceil", "⌈$CURSOR⌉₊" }, + { "s[]", "⦋$CURSOR⦌" }, + { "simplex", "⦋$CURSOR⦌" }, + { "\\", "\\" }, + { "a", "α" }, + { "b", "β" }, + { "c", "χ" }, + { "d", "↓" }, + { "e", "ε" }, + { "g", "γ" }, + { "i", "∩" }, + { "m", "μ" }, + { "n", "\\n" }, + { "o", "∘" }, + { "p", "Π" }, + { "t", "▸" }, + { "r", "→" }, + { "u", "↑" }, + { "v", "∨" }, + { "x", "×" }, + { "-", "⁻¹" }, + { "~", "∼" }, + { ".", "·" }, + { "*", "⋆" }, + { "!", "¬" }, + { "?", "¿" }, + { "1", "₁" }, + { "2", "₂" }, + { "3", "₃" }, + { "4", "₄" }, + { "5", "₅" }, + { "6", "₆" }, + { "7", "₇" }, + { "8", "₈" }, + { "9", "₉" }, + { "0", "₀" }, + { "l", "←" }, + { "<", "⟨" }, + { ">", "⟩" }, + { "O", "Ø" }, + { "&", "⅋" }, + { "A", "𝔸" }, + { "C", "ℂ" }, + { "D", "Δ" }, + { "F", "𝔽" }, + { "G", "Γ" }, + { "H", "ℍ" }, + { "I", "⋂" }, + { "I0", "⋂₀" }, + { "K", "𝕂" }, + { "L", "Λ" }, + { "N", "ℕ" }, + { "P", "Π" }, + { "Q", "ℚ" }, + { "R", "ℝ" }, + { "S", "Σ" }, + { "U", "⋃" }, + { "U0", "⋃₀" }, + { "Z", "ℤ" }, + { "#", "♯" }, + { ":", "∶" }, + { "|", "∣" }, + { "rw", "▸" }, + { "coe", "↑" }, + { "be", "β" }, + { "ga", "γ" }, + { "de", "δ" }, + { "ep", "ε" }, + { "ze", "ζ" }, + { "et", "η" }, + { "th", "θ" }, + { "io", "ι" }, + { "ka", "κ" }, + { "la", "λ" }, + { "mu", "μ" }, + { "nu", "ν" }, + { "xi", "ξ" }, + { "pi", "π" }, + { "rh", "ρ" }, + { "vsi", "ς" }, + { "si", "σ" }, + { "ta", "τ" }, + { "ph", "φ" }, + { "ch", "χ" }, + { "ps", "ψ" }, + { "om", "ω" }, + { "`A", "À" }, + { "'A", "Á" }, + { "^{A}", "Â" }, + { "~A", "Ã" }, + { "\"A", "Ä" }, + { "-{A}", "Ā" }, + { "cC", "Ç" }, + { "`E", "È" }, + { "'E", "É" }, + { "^{E}", "Ê" }, + { "\"E", "Ë" }, + { "-{E}", "Ē" }, + { "`I", "Ì" }, + { "'I", "Í" }, + { "^{I}", "Î" }, + { "\"I", "Ï" }, + { "-{I}", "Ī" }, + { "~N", "Ñ" }, + { "`O", "Ò" }, + { "'O", "Ó" }, + { "^{O}", "Ô" }, + { "~O", "Õ" }, + { "\"O", "Ö" }, + { "/O", "Ø" }, + { "-{O}", "Ō" }, + { "`U", "Ù" }, + { "'U", "Ú" }, + { "^{U}", "Û" }, + { "\"U", "Ü" }, + { "-{U}", "Ū" }, + { "'Y", "Ý" }, + { "`a", "à" }, + { "'a", "á" }, + { "^{a}", "â" }, + { "~a", "ã" }, + { "\"a", "ä" }, + { "-{a}", "ā" }, + { "cc", "ç" }, + { "`e", "è" }, + { "'e", "é" }, + { "^{e}", "ê" }, + { "\"e", "ë" }, + { "-{e}", "ē" }, + { "`i", "ì" }, + { "'i", "í" }, + { "^{i}", "î" }, + { "\"i", "ï" }, + { "-{i}", "ī" }, + { "~{n}", "ñ" }, + { "`o", "ò" }, + { "'o", "ó" }, + { "^{o}", "ô" }, + { "~o", "õ" }, + { "\"o", "ö" }, + { "/o", "ø" }, + { "-{o}", "ō" }, + { "`u", "ù" }, + { "'u", "ú" }, + { "^{u}", "û" }, + { "\"u", "ü" }, + { "-{u}", "ū" }, + { "'y", "ý" }, + { "\"y", "ÿ" }, + { "/L", "Ł" }, + { "note", "♩" }, + { "not", "¬" }, + { "notin", "∉" }, + { "notlt", "≮" }, + { "nomisma", "𐆎" }, + { "nin", "∉" }, + { "nni", "∌" }, + { "ni", "∋" }, + { "nattrans", "⟹" }, + { "nat_trans", "⟹" }, + { "natural", "♮" }, + { "nat", "ℕ" }, + { "naira", "₦" }, + { "nabla", "∇" }, + { "napprox", "≉" }, + { "numero", "№" }, + { "nLeftarrow", "⇍" }, + { "nLeftrightarrow", "⇎" }, + { "nRightarrow", "⇏" }, + { "nVDash", "⊯" }, + { "nVdash", "⊮" }, + { "ncong", "≇" }, + { "nearrow", "↗" }, + { "neg", "¬" }, + { "nequiv", "≢" }, + { "neq", "≠" }, + { "nexists", "∄" }, + { "ne", "≠" }, + { "ngeqq", "≱" }, + { "ngeqslant", "≱" }, + { "ngeq", "≱" }, + { "ngtr", "≯" }, + { "nleftarrow", "↚" }, + { "nleftrightarrow", "↮" }, + { "nleqq", "≰" }, + { "nleqslant", "≰" }, + { "nleq", "≰" }, + { "nless", "≮" }, + { "nmid", "∤" }, + { "nparallel", "∦" }, + { "npreceq", "⋠" }, + { "nprec", "⊀" }, + { "nrightarrow", "↛" }, + { "nshortmid", "∤" }, + { "nsimeq", "≄" }, + { "nsim", "≁" }, + { "nsubseteqq", "⊈" }, + { "nsubseteq", "⊈" }, + { "nsubset", "⊄" }, + { "nsucceq", "⋡" }, + { "nsucc", "⊁" }, + { "nsupseteqq", "⊉" }, + { "nsupseteq", "⊉" }, + { "nsupset", "⊅" }, + { "ntrianglelefteq", "⋬" }, + { "ntriangleleft", "⋪" }, + { "ntrianglerighteq", "⋭" }, + { "ntriangleright", "⋫" }, + { "nvDash", "⊭" }, + { "nvdash", "⊬" }, + { "nwarrow", "↖" }, + { "eqn", "≠" }, + { "equiv", "≃" }, + { "eqcirc", "≖" }, + { "eqcolon", "≕" }, + { "eqslantgtr", "⋝" }, + { "eqslantless", "⋜" }, + { "entails", "⊢" }, + { "en", "–" }, + { "exn", "∄" }, + { "exists", "∃" }, + { "ex", "∃" }, + { "emptyset", "∅" }, + { "empty", "∅" }, + { "em", "—" }, + { "epsilon", "ε" }, + { "eps", "ε" }, + { "euro", "€" }, + { "eta", "η" }, + { "ell", "ℓ" }, + { "iso", "≅" }, + { "in", "∈" }, + { "inn", "∉" }, + { "inter", "∩" }, + { "intercal", "⊺" }, + { "intersection", "∩" }, + { "integral", "∫" }, + { "integral-", "⨍" }, + { "int", "ℤ" }, + { "inv", "⁻¹" }, + { "increment", "∆" }, + { "inf", "⊓" }, + { "infi", "⨅" }, + { "infty", "∞" }, + { "iff", "↔" }, + { "imp", "→" }, + { "imath", "ı" }, + { "iota", "ι" }, + { "=n", "≠" }, + { "==n", "≢" }, + { "===", "≣" }, + { "==>", "⟹" }, + { "==", "≡" }, + { "=:", "≕" }, + { "=o", "≗" }, + { "=>n", "⇏" }, + { "=>", "⇒" }, + { "~n", "≁" }, + { "~~n", "≉" }, + { "~~~", "≋" }, + { "~~-", "≊" }, + { "~~", "≈" }, + { "~-n", "≄" }, + { "~-", "≃" }, + { "~=n", "≇" }, + { "~=", "≅" }, + { "homotopy", "∼" }, + { "hom", "⟶" }, + { "hori", "ϩ" }, + { "hookleftarrow", "↩" }, + { "hookrightarrow", "↪" }, + { "hryvnia", "₴" }, + { "heta", "ͱ" }, + { "heartsuit", "♥" }, + { "hbar", "ℏ" }, + { ":~", "∻" }, + { ":=", "≔" }, + { "::-", "∺" }, + { "::", "∷" }, + { "-~", "≂" }, + { "-|", "⊣" }, + { "-1", "⁻¹" }, + { "^-1", "⁻¹" }, + { "-2", "⁻²" }, + { "-3", "⁻³" }, + { "-:", "∹" }, + { "->n", "↛" }, + { "->", "→" }, + { "-->", "⟶" }, + { "---", "─" }, + { "--=", "═" }, + { "--_", "━" }, + { "--.", "╌" }, + { "-o", "⊸" }, + { ".=.", "≑" }, + { ".=", "≐" }, + { ".+", "∔" }, + { ".-", "∸" }, + { "...", "⋯" }, + { "(=", "≘" }, + { "(b", "⟅" }, + { "and=", "≙" }, + { "and", "∧" }, + { "an", "∧" }, + { "angle", "∠" }, + { "rightangle", "∟" }, + { "angstrom", "Å" }, + { "all", "∀" }, + { "allf", "∀ᶠ" }, + { "all^f", "∀ᶠ" }, + { "allm", "∀ᵐ" }, + { "all^m", "∀ᵐ" }, + { "alpha", "α" }, + { "aleph", "ℵ" }, + { "aleph0", "ℵ₀" }, + { "asterisk", "⁎" }, + { "ast", "∗" }, + { "asymp", "≍" }, + { "apl", "⌶" }, + { "approxeq", "≊" }, + { "approx", "≈" }, + { "aa", "å" }, + { "ae", "æ" }, + { "austral", "₳" }, + { "amalg", "∐" }, + { "average", "⨍" }, + { "-int", "⨍" }, + { "or=", "≚" }, + { "ordfeminine", "ª" }, + { "ordmasculine", "º" }, + { "or", "∨" }, + { "oplus", "⊕" }, + { "od", "ᵒᵈ" }, + { "orderdual", "ᵒᵈ" }, + { "addopposite", "ᵃᵒᵖ" }, + { "aop", "ᵃᵒᵖ" }, + { "mulopposite", "ᵐᵒᵖ" }, + { "mop", "ᵐᵒᵖ" }, + { "opposite", "ᵒᵖ" }, + { "op", "ᵒᵖ" }, + { "o+", "⊕" }, + { "o--", "⊖" }, + { "o-", "⊝" }, + { "ox", "⊗" }, + { "o/", "⊘" }, + { "o.", "⊙" }, + { "oo", "⊚" }, + { "o*", "∘*" }, + { "o=", "⊜" }, + { "oe", "œ" }, + { "octagonal", "🛑" }, + { "ohm", "Ω" }, + { "ounce", "℥" }, + { "omega", "ω" }, + { "omicron", "ο" }, + { "ominus", "⊖" }, + { "odot", "⊙" }, + { "oint", "∮" }, + { "oiint", "∯" }, + { "oslash", "⊘" }, + { "otimes", "⊗" }, + { "tensorproduct", "⊗" }, + { "pitensorproduct", "⨂" }, + { "tensorpower", "⨂" }, + { "pd", "∂" }, + { "*=", "≛" }, + { "t=", "≜" }, + { "tint", "∯" }, + { "transport", "▹" }, + { "trans", "▹" }, + { "triangledown", "▿" }, + { "trianglelefteq", "⊴" }, + { "triangleleft", "◃" }, + { "triangleq", "≜" }, + { "trianglerighteq", "⊵" }, + { "triangleright", "▹" }, + { "triangle", "▵" }, + { "tr", "⬝" }, + { "tb", "◂" }, + { "twoheadleftarrow", "↞" }, + { "twoheadrightarrow", "↠" }, + { "tw", "◃" }, + { "tie", "⁀" }, + { "times", "×" }, + { "theta", "θ" }, + { "therefore", "∴" }, + { "thickapprox", "≈" }, + { "thicksim", "∼" }, + { "telephone", "℡" }, + { "tenge", "₸" }, + { "textmusicalnote", "♪" }, + { "textmu", "µ" }, + { "textfractionsolidus", "⁄" }, + { "textbaht", "฿" }, + { "textdied", "✝" }, + { "textdiscount", "⁒" }, + { "textcolonmonetary", "₡" }, + { "textcircledP", "℗" }, + { "textwon", "₩" }, + { "textnaira", "₦" }, + { "textnumero", "№" }, + { "textpeso", "₱" }, + { "textpertenthousand", "‱" }, + { "textlira", "₤" }, + { "textlquill", "⁅" }, + { "textrecipe", "℞" }, + { "textreferencemark", "※" }, + { "textrquill", "⁆" }, + { "textinterrobang", "‽" }, + { "textestimated", "℮" }, + { "textopenbullet", "◦" }, + { "tugrik", "₮" }, + { "tau", "τ" }, + { "top", "⊤" }, + { "to", "→" }, + { "to0", "→₀" }, + { "r0", "→₀" }, + { "to_0", "→₀" }, + { "r_0", "→₀" }, + { "finsupp", "→₀" }, + { "to1", "→₁" }, + { "r1", "→₁" }, + { "to_1", "→₁" }, + { "r_1", "→₁" }, + { "l1", "→₁" }, + { "to1s", "→₁ₛ" }, + { "r1s", "→₁ₛ" }, + { "to_1s", "→₁ₛ" }, + { "r_1s", "→₁ₛ" }, + { "l1simplefunc", "→₁ₛ" }, + { "toa", "→ₐ" }, + { "ra", "→ₐ" }, + { "to_a", "→ₐ" }, + { "r_a", "→ₐ" }, + { "alghom", "→ₐ" }, + { "tob", "→ᵇ" }, + { "rb", "→ᵇ" }, + { "to^b", "→ᵇ" }, + { "r^b", "→ᵇ" }, + { "boundedcontinuousfunction", "→ᵇ" }, + { "tol", "→ₗ" }, + { "rl", "→ₗ" }, + { "to_l", "→ₗ" }, + { "r_l", "→ₗ" }, + { "linearmap", "→ₗ" }, + { "tosl", "→ₛₗ" }, + { "rsl", "→ₛₗ" }, + { "to_sl", "→ₛₗ" }, + { "r_sl", "→ₛₗ" }, + { "semilinearmap", "→ₛₗ" }, + { "tom", "→ₘ" }, + { "rm", "→ₘ" }, + { "to_m", "→ₘ" }, + { "r_m", "→ₘ" }, + { "aeeqfun", "→ₘ" }, + { "rp", "→ₚ" }, + { "to_p", "→ₚ" }, + { "r_p", "→ₚ" }, + { "dfinsupp", "→ₚ" }, + { "tos", "→ₛ" }, + { "rs", "→ₛ" }, + { "to_s", "→ₛ" }, + { "r_s", "→ₛ" }, + { "simplefunc", "→ₛ" }, + { "heyting", "⇨" }, + { "himp", "⇨" }, + { "hnot", "¬" }, + { "covers", "⋖" }, + { "covby", "⋖" }, + { "wcovby", "⩿" }, + { "wcovers", "⩿" }, + { "def=", "≝" }, + { "defs", "≙" }, + { "degree", "°" }, + { "dei", "ϯ" }, + { "delta", "δ" }, + { "doteqdot", "≑" }, + { "doteq", "≐" }, + { "dotplus", "∔" }, + { "dotsquare", "⊡" }, + { "dot", "·" }, + { "dong", "₫" }, + { "downarrow", "↓" }, + { "downdownarrows", "⇊" }, + { "downleftharpoon", "⇃" }, + { "downrightharpoon", "⇂" }, + { "dr-", "↘" }, + { "dr=", "⇘" }, + { "drachma", "₯" }, + { "dr", "↘" }, + { "dl-", "↙" }, + { "dl=", "⇙" }, + { "dl", "↙" }, + { "d-2", "⇊" }, + { "d-u-", "⇵" }, + { "d-|", "↧" }, + { "d-", "↓" }, + { "d==", "⟱" }, + { "d=", "⇓" }, + { "dd-", "↡" }, + { "ddagger", "‡" }, + { "ddag", "‡" }, + { "ddots", "⋱" }, + { "dz", "↯" }, + { "dib", "◆" }, + { "diw", "◇" }, + { "di.", "◈" }, + { "die", "⚀" }, + { "division", "÷" }, + { "divideontimes", "⋇" }, + { "div", "÷" }, + { "diameter", "⌀" }, + { "diamondsuit", "♢" }, + { "diamond", "⋄" }, + { "digamma", "ϝ" }, + { "di", "◆" }, + { "dagger", "†" }, + { "dag", "†" }, + { "daleth", "ℸ" }, + { "dashv", "⊣" }, + { "dh", "ð" }, + { "dvd", "∣" }, + { "m=", "≞" }, + { "meet", "⊓" }, + { "member", "∈" }, + { "mem", "∈" }, + { "measuredangle", "∡" }, + { "ma", "↦" }, + { "mapsto", "↦" }, + { "male", "♂" }, + { "maltese", "✠" }, + { "manat", "₼" }, + { "mathscr{I}", "ℐ" }, + { "minus", "−" }, + { "mill", "₥" }, + { "micro", "µ" }, + { "mid", "∣" }, + { "multiplication", "×" }, + { "multimap", "⊸" }, + { "mho", "℧" }, + { "models", "⊧" }, + { "mp", "∓" }, + { "?=", "≟" }, + { "??", "⁇" }, + { "?!", "‽" }, + { "prohibited", "🛇" }, + { "prod", "∏" }, + { "propto", "∝" }, + { "precapprox", "≾" }, + { "preceq", "≼" }, + { "precnapprox", "⋨" }, + { "precnsim", "⋨" }, + { "precsim", "≾" }, + { "prec", "≺" }, + { "preim", "⁻¹'" }, + { "preimage", "⁻¹'" }, + { "prime", "′" }, + { "pr", "↣" }, + { "powerset", "𝒫" }, + { "pounds", "£" }, + { "pound", "£" }, + { "pab", "▰" }, + { "paw", "▱" }, + { "partnership", "㉐" }, + { "partial", "∂" }, + { "paragraph", "¶" }, + { "parallel", "∥" }, + { "pa", "▰" }, + { "pm", "±" }, + { "perp", "⟂" }, + { "^perp", "ᗮ" }, + { "permil", "‰" }, + { "per", "⅌" }, + { "peso", "₱" }, + { "peseta", "₧" }, + { "pilcrow", "¶" }, + { "pitchfork", "⋔" }, + { "psi", "ψ" }, + { "phi", "φ" }, + { "leqn", "≰" }, + { "leqq", "≦" }, + { "leqslant", "≤" }, + { "leq", "≤" }, + { "len", "≰" }, + { "leadsto", "↝" }, + { "leftarrowtail", "↢" }, + { "leftarrow", "←" }, + { "leftharpoondown", "↽" }, + { "leftharpoonup", "↼" }, + { "leftleftarrows", "⇇" }, + { "leftrightarrows", "⇆" }, + { "leftrightarrow", "↔" }, + { "leftrightharpoons", "⇋" }, + { "leftrightsquigarrow", "↭" }, + { "leftthreetimes", "⋋" }, + { "lessapprox", "≲" }, + { "lessdot", "⋖" }, + { "lesseqgtr", "⋚" }, + { "lesseqqgtr", "⋚" }, + { "lessgtr", "≶" }, + { "lesssim", "≲" }, + { "le", "≤" }, + { "lub", "⊔" }, + { "lr--", "⟷" }, + { "lr-n", "↮" }, + { "lr-", "↔" }, + { "lr=n", "⇎" }, + { "lr=", "⇔" }, + { "lr~", "↭" }, + { "lrcorner", "⌟" }, + { "lr", "↔" }, + { "l-2", "⇇" }, + { "l-r-", "⇆" }, + { "l--", "⟵" }, + { "l-n", "↚" }, + { "l-|", "↤" }, + { "l->", "↢" }, + { "l-", "←" }, + { "l==", "⇚" }, + { "l=n", "⇍" }, + { "l=", "⇐" }, + { "l~", "↜" }, + { "ll-", "↞" }, + { "llcorner", "⌞" }, + { "llbracket", "〚" }, + { "ll", "≪" }, + { "lbag", "⟅" }, + { "lambda", "λ" }, + { "lamda", "λ" }, + { "lam", "λ" }, + { "lari", "₾" }, + { "langle", "⟨" }, + { "lira", "₤" }, + { "lceil", "⌈" }, + { "ldots", "…" }, + { "ldq", "“" }, + { "ldata", "《" }, + { "lfloor", "⌊" }, + { "lf", "⧏" }, + { "<|", "⧏" }, + { "lhd", "◁" }, + { "lnapprox", "⋦" }, + { "lneqq", "≨" }, + { "lneq", "≨" }, + { "lnsim", "⋦" }, + { "lnot", "¬" }, + { "longleftarrow", "⟵" }, + { "longleftrightarrow", "⟷" }, + { "longrightarrow", "⟶" }, + { "looparrowleft", "↫" }, + { "looparrowright", "↬" }, + { "lozenge", "✧" }, + { "lq", "‘" }, + { "ltimes", "⋉" }, + { "lvertneqq", "≨" }, + { "geqn", "≱" }, + { "geqq", "≧" }, + { "geqslant", "≥" }, + { "geq", "≥" }, + { "gen", "≱" }, + { "gets", "←" }, + { "ge", "≥" }, + { "glb", "⊓" }, + { "glqq", "„" }, + { "glq", "‚" }, + { "guarani", "₲" }, + { "gangia", "ϫ" }, + { "gamma", "γ" }, + { "ggg", "⋙" }, + { "gg", "≫" }, + { "gimel", "ℷ" }, + { "gnapprox", "⋧" }, + { "gneqq", "≩" }, + { "gneq", "≩" }, + { "gnsim", "⋧" }, + { "gtrapprox", "≳" }, + { "gtrdot", "⋗" }, + { "gtreqless", "⋛" }, + { "gtreqqless", "⋛" }, + { "gtrless", "≷" }, + { "gtrsim", "≳" }, + { "gvertneqq", "≩" }, + { "grqq", "“" }, + { "grq", "‘" }, + { "<=n", "≰" }, + { "<=>n", "⇎" }, + { "<=>", "⇔" }, + { "<=", "≤" }, + { "<~nn", "≴" }, + { "<~n", "⋦" }, + { "<~", "≲" }, + { "<:", "⋖" }, + { ":>", "⋗" }, + { "<->n", "↮" }, + { "<->", "↔" }, + { "<-->", "⟷" }, + { "<--", "⟵" }, + { "<-n", "↚" }, + { "<-", "←" }, + { "<<", "⟪" }, + { ">=n", "≱" }, + { ">=", "≥" }, + { ">n", "≯" }, + { ">~nn", "≵" }, + { ">~n", "⋧" }, + { ">~", "≳" }, + { ">>", "⟫" }, + { "root", "√" }, + { "scissor", "✂" }, + { "ssubn", "⊄" }, + { "ssub", "⊂" }, + { "ssupn", "⊅" }, + { "ssup", "⊃" }, + { "ssqub", "⊏" }, + { "ssqup", "⊐" }, + { "ss", "⊆" }, + { "subn", "⊈" }, + { "subseteqq", "⊆" }, + { "subseteq", "⊆" }, + { "subsetneqq", "⊊" }, + { "subsetneq", "⊊" }, + { "subset", "⊆" }, + { "ssubset", "⊂" }, + { "sub", "⊆" }, + { "supn", "⊉" }, + { "supseteqq", "⊇" }, + { "supseteq", "⊇" }, + { "supsetneqq", "⊋" }, + { "supsetneq", "⊋" }, + { "supset", "⊇" }, + { "ssupset", "⊃" }, + { "sUnion", "⋃₀" }, + { "sInter", "⋂₀" }, + { "sup", "⊔" }, + { "supr", "⨆" }, + { "surd3", "∛" }, + { "surd4", "∜" }, + { "surd", "√" }, + { "succapprox", "≿" }, + { "succcurlyeq", "≽" }, + { "succeq", "≽" }, + { "succnapprox", "⋩" }, + { "succnsim", "⋩" }, + { "succsim", "≿" }, + { "succ", "≻" }, + { "sum", "∑" }, + { "specializes", "⤳" }, + { "~>", "⤳" }, + { "squbn", "⋢" }, + { "squb", "⊑" }, + { "squpn", "⋣" }, + { "squp", "⊒" }, + { "square", "□" }, + { "squigarrowright", "⇝" }, + { "sqb", "■" }, + { "sqw", "□" }, + { "sq.", "▣" }, + { "sqo", "▢" }, + { "sqcap", "⊓" }, + { "sqcup", "⊔" }, + { "sqrt", "√" }, + { "sqsubseteq", "⊑" }, + { "sqsubset", "⊏" }, + { "sqsupseteq", "⊒" }, + { "sqsupset", "⊐" }, + { "sq", "◾" }, + { "sy", "⁻¹" }, + { "symmdiff", "∆" }, + { "st4", "✦" }, + { "st6", "✶" }, + { "st8", "✴" }, + { "st12", "✹" }, + { "stigma", "ϛ" }, + { "star", "⋆" }, + { "straightphi", "φ" }, + { "st", "⋆" }, + { "spesmilo", "₷" }, + { "span", "∙" }, + { "spadesuit", "♠" }, + { "sphericalangle", "∢" }, + { "section", "§" }, + { "searrow", "↘" }, + { "setminus", "\\" }, + { "san", "ϻ" }, + { "sampi", "ϡ" }, + { "shortmid", "∣" }, + { "sho", "ϸ" }, + { "shima", "ϭ" }, + { "shei", "ϣ" }, + { "sharp", "♯" }, + { "sigma", "σ" }, + { "simeq", "≃" }, + { "sim", "∼" }, + { "sbs", "﹨" }, + { "smallamalg", "∐" }, + { "smallsetminus", "∖" }, + { "smallsmile", "⌣" }, + { "smile", "⌣" }, + { "smul", "•" }, + { "swarrow", "↙" }, + { "Tr", "◀" }, + { "Tb", "◀" }, + { "Tw", "◁" }, + { "Tau", "Τ" }, + { "Theta", "Θ" }, + { "TH", "Þ" }, + { "union", "∪" }, + { "undertie", "‿" }, + { "uncertainty", "⯑" }, + { "un", "∪" }, + { "u+", "⊎" }, + { "u.", "⊍" }, + { "ud-|", "↨" }, + { "ud-", "↕" }, + { "ud=", "⇕" }, + { "ud", "↕" }, + { "ul-", "↖" }, + { "ul=", "⇖" }, + { "ulcorner", "⌜" }, + { "ul", "↖" }, + { "ur-", "↗" }, + { "ur=", "⇗" }, + { "urcorner", "⌝" }, + { "ur", "↗" }, + { "u-2", "⇈" }, + { "u-d-", "⇅" }, + { "u-|", "↥" }, + { "u-", "↑" }, + { "u==", "⟰" }, + { "u=", "⇑" }, + { "uu-", "↟" }, + { "upsilon", "υ" }, + { "uparrow", "↑" }, + { "updownarrow", "↕" }, + { "upleftharpoon", "↿" }, + { "uplus", "⊎" }, + { "uprightharpoon", "↾" }, + { "upuparrows", "⇈" }, + { "And", "⋀" }, + { "AA", "Å" }, + { "AE", "Æ" }, + { "Alpha", "Α" }, + { "Or", "⋁" }, + { "O+", "⨁" }, + { "directsum", "⨁" }, + { "Ox", "⨂" }, + { "O.", "⨀" }, + { "O*", "⍟" }, + { "OE", "Œ" }, + { "Omega", "Ω" }, + { "Omicron", "Ο" }, + { "Int", "ℤ" }, + { "Inter", "⋂" }, + { "bInter", "⋂" }, + { "Iota", "Ι" }, + { "Im", "ℑ" }, + { "Un", "⋃" }, + { "Union", "⋃" }, + { "bUnion", "⋃" }, + { "U+", "⨄" }, + { "U.", "⨃" }, + { "Upsilon", "Υ" }, + { "Uparrow", "⇑" }, + { "Updownarrow", "⇕" }, + { "Gl-", "ƛ" }, + { "Gl", "λ" }, + { "Gangia", "Ϫ" }, + { "Gamma", "Γ" }, + { "Glb", "⨅" }, + { "Ga", "α" }, + { "GA", "Α" }, + { "Gb", "β" }, + { "GB", "Β" }, + { "Gg", "γ" }, + { "GG", "Γ" }, + { "Gd", "δ" }, + { "GD", "Δ" }, + { "Ge", "ε" }, + { "GE", "Ε" }, + { "Gz", "ζ" }, + { "GZ", "Ζ" }, + { "Gth", "θ" }, + { "Gt", "τ" }, + { "GTH", "Θ" }, + { "GT", "Τ" }, + { "Gi", "ι" }, + { "GI", "Ι" }, + { "Gk", "κ" }, + { "GK", "Κ" }, + { "GL", "Λ" }, + { "Gm", "μ" }, + { "GM", "Μ" }, + { "Gn", "ν" }, + { "GN", "Ν" }, + { "Gx", "ξ" }, + { "GX", "Ξ" }, + { "Gr", "ρ" }, + { "GR", "Ρ" }, + { "Gs", "σ" }, + { "GS", "Σ" }, + { "Gu", "υ" }, + { "GU", "Υ" }, + { "Gf", "φ" }, + { "GF", "Φ" }, + { "Gc", "χ" }, + { "GC", "Χ" }, + { "Gp", "ψ" }, + { "GP", "Ψ" }, + { "Go", "ω" }, + { "GO", "Ω" }, + { "Inf", "⨅" }, + { "Join", "⨆" }, + { "Lub", "⨆" }, + { "Lambda", "Λ" }, + { "Lamda", "Λ" }, + { "Leftarrow", "⇐" }, + { "Leftrightarrow", "⇔" }, + { "Letter", "✉" }, + { "Lleftarrow", "⇚" }, + { "Ll", "⋘" }, + { "Longleftarrow", "⇐" }, + { "Longleftrightarrow", "⇔" }, + { "Longrightarrow", "⇒" }, + { "Meet", "⨅" }, + { "Sup", "⨆" }, + { "Sqcap", "⨅" }, + { "Sqcup", "⨆" }, + { "Lsh", "↰" }, + { "|-n", "⊬" }, + { "|-", "⊢" }, + { "|=n", "⊭" }, + { "|=", "⊨" }, + { "|->", "↦" }, + { "|=>", "⇰" }, + { "||-n", "⊮" }, + { "||-", "⊩" }, + { "||=n", "⊯" }, + { "||=", "⊫" }, + { "|||-", "⊪" }, + { "||", "‖" }, + { "fuzzy", "‖" }, + { "|n", "∤" }, + { "Com", "ℂ" }, + { "Chi", "Χ" }, + { "Cap", "⋒" }, + { "Cup", "⋓" }, + { "cul", "⌜" }, + { "cuL", "⌈" }, + { "currency", "¤" }, + { "curlyeqprec", "⋞" }, + { "curlyeqsucc", "⋟" }, + { "curlypreceq", "≼" }, + { "curlyvee", "⋎" }, + { "curlywedge", "⋏" }, + { "curvearrowleft", "↶" }, + { "curvearrowright", "↷" }, + { "cur", "⌝" }, + { "cuR", "⌉" }, + { "cup", "∪" }, + { "cu", "⌜" }, + { "cll", "⌞" }, + { "clL", "⌊" }, + { "clr", "⌟" }, + { "clR", "⌋" }, + { "clubsuit", "♣" }, + { "cl", "⌞" }, + { "construction", "🚧" }, + { "cong", "≅" }, + { "con", "⬝" }, + { "compl", "ᶜ" }, + { "complement", "ᶜ" }, + { "complementprefix", "∁" }, + { "Complement", "∁" }, + { "comp", "∘" }, + { "com", "ℂ" }, + { "coloneq", "≔" }, + { "colon", "₡" }, + { "copyright", "©" }, + { "cdots", "⋯" }, + { "cdot", "·" }, + { "cib", "●" }, + { "ciw", "○" }, + { "ci..", "◌" }, + { "ci.", "◎" }, + { "ciO", "◯" }, + { "circeq", "≗" }, + { "circlearrowleft", "↺" }, + { "circlearrowright", "↻" }, + { "circledR", "®" }, + { "circledS", "Ⓢ" }, + { "circledast", "⊛" }, + { "circledcirc", "⊚" }, + { "circleddash", "⊝" }, + { "circ", "∘" }, + { "ci", "●" }, + { "centerdot", "·" }, + { "cent", "¢" }, + { "cedi", "₵" }, + { "celsius", "℃" }, + { "ce", "ȩ" }, + { "checkmark", "✓" }, + { "chi", "χ" }, + { "cruzeiro", "₢" }, + { "caution", "☡" }, + { "cap", "∩" }, + { "qed", "∎" }, + { "quot", "⧸" }, + { "bigsolidus", "⧸" }, + { "/", "⧸" }, + { "+ ", "⊹" }, + { "b+", "⊞" }, + { "b-", "⊟" }, + { "bx", "⊠" }, + { "b.", "⊡" }, + { "bn", "ℕ" }, + { "bz", "ℤ" }, + { "bq", "ℚ" }, + { "brokenbar", "¦" }, + { "br", "ℝ" }, + { "bc", "ℂ" }, + { "bp", "ℙ" }, + { "bb", "𝔹" }, + { "bsum", "⅀" }, + { "b0", "𝟘" }, + { "b1", "𝟙" }, + { "b2", "𝟚" }, + { "b3", "𝟛" }, + { "b4", "𝟜" }, + { "b5", "𝟝" }, + { "b6", "𝟞" }, + { "b7", "𝟟" }, + { "b8", "𝟠" }, + { "b9", "𝟡" }, + { "sb0", "𝟬" }, + { "sb1", "𝟭" }, + { "sb2", "𝟮" }, + { "sb3", "𝟯" }, + { "sb4", "𝟰" }, + { "sb5", "𝟱" }, + { "sb6", "𝟲" }, + { "sb7", "𝟳" }, + { "sb8", "𝟴" }, + { "sb9", "𝟵" }, + { "bub", "•" }, + { "buw", "◦" }, + { "but", "‣" }, + { "bumpeq", "≏" }, + { "bu", "•" }, + { "biohazard", "☣" }, + { "bihimp", "⇔" }, + { "bigcap", "⋂" }, + { "bigcirc", "◯" }, + { "bigcoprod", "∐" }, + { "bigcup", "⋃" }, + { "bigglb", "⨅" }, + { "biginf", "⨅" }, + { "bigjoin", "⨆" }, + { "biglub", "⨆" }, + { "bigmeet", "⨅" }, + { "bigsqcap", "⨅" }, + { "bigsqcup", "⨆" }, + { "bigstar", "★" }, + { "bigsup", "⨆" }, + { "bigtriangledown", "▽" }, + { "bigtriangleup", "△" }, + { "bigvee", "⋁" }, + { "bigwedge", "⋀" }, + { "beta", "β" }, + { "beth", "ℶ" }, + { "between", "≬" }, + { "because", "∵" }, + { "backcong", "≌" }, + { "backepsilon", "∍" }, + { "backprime", "‵" }, + { "backsimeq", "⋍" }, + { "backsim", "∽" }, + { "barwedge", "⊼" }, + { "blacklozenge", "✦" }, + { "blacksquare", "▪" }, + { "blacksmiley", "☻" }, + { "blacktriangledown", "▾" }, + { "blacktriangleleft", "◂" }, + { "blacktriangleright", "▸" }, + { "blacktriangle", "▴" }, + { "bot", "⊥" }, + { "^bot", "ᗮ" }, + { "bowtie", "⋈" }, + { "boxminus", "⊟" }, + { "boxmid", "◫" }, + { "hcomp", "◫" }, + { "boxplus", "⊞" }, + { "boxtimes", "⊠" }, + { "join", "⊔" }, + { "r-2", "⇉" }, + { "r-3", "⇶" }, + { "r-l-", "⇄" }, + { "r--", "⟶" }, + { "r-n", "↛" }, + { "r-|", "↦" }, + { "r->", "↣" }, + { "r-o", "⊸" }, + { "r-", "→" }, + { "r==", "⇛" }, + { "r=n", "⇏" }, + { "r=", "⇒" }, + { "r~", "↝" }, + { "rr-", "↠" }, + { "reb", "▬" }, + { "rew", "▭" }, + { "real", "ℝ" }, + { "registered", "®" }, + { "re", "▬" }, + { "rbag", "⟆" }, + { "rat", "ℚ" }, + { "radioactive", "☢" }, + { "rrbracket", "〛" }, + { "rangle", "⟩" }, + { "rq", "’" }, + { "rightarrowtail", "↣" }, + { "rightarrow", "→" }, + { "rightharpoondown", "⇁" }, + { "rightharpoonup", "⇀" }, + { "rightleftarrows", "⇄" }, + { "rightleftharpoons", "⇌" }, + { "rightrightarrows", "⇉" }, + { "rightthreetimes", "⋌" }, + { "risingdotseq", "≓" }, + { "ruble", "₽" }, + { "rupee", "₨" }, + { "rho", "ρ" }, + { "rhd", "▷" }, + { "rceil", "⌉" }, + { "rfloor", "⌋" }, + { "rtimes", "⋊" }, + { "rdq", "”" }, + { "rdata", "》" }, + { "functor", "⥤" }, + { "fun", "λ" }, + { "f<<", "«" }, + { "f>>", "»" }, + { "f<", "‹" }, + { "f>", "›" }, + { "h<", "❰" }, + { "h>", "❱" }, + { "finprod", "∏ᶠ" }, + { "finsum", "∑ᶠ" }, + { "frac12", "½" }, + { "frac13", "⅓" }, + { "frac14", "¼" }, + { "frac15", "⅕" }, + { "frac16", "⅙" }, + { "frac18", "⅛" }, + { "frac1", "⅟" }, + { "frac23", "⅔" }, + { "frac25", "⅖" }, + { "frac34", "¾" }, + { "frac35", "⅗" }, + { "frac38", "⅜" }, + { "frac45", "⅘" }, + { "frac56", "⅚" }, + { "frac58", "⅝" }, + { "frac78", "⅞" }, + { "frac", "¼" }, + { "frown", "⌢" }, + { "frqq", "»" }, + { "frq", "›" }, + { "female", "♀" }, + { "fei", "ϥ" }, + { "facsimile", "℻" }, + { "fallingdotseq", "≒" }, + { "flat", "♭" }, + { "flqq", "«" }, + { "flq", "‹" }, + { "forall", "∀" }, + { ")b", "⟆" }, + { "[[", "⟦" }, + { "]]", "⟧" }, + { "{{", "⦃" }, + { "}}", "⦄" }, + { "((", "⸨" }, + { "))", "⸩" }, + { "([", "⟮" }, + { "])", "⟯" }, + { "Xi", "Ξ" }, + { "Nat", "ℕ" }, + { "Nu", "Ν" }, + { "Zeta", "Ζ" }, + { "Rat", "ℚ" }, + { "Real", "ℝ" }, + { "Re", "ℜ" }, + { "Rho", "Ρ" }, + { "Rightarrow", "⇒" }, + { "Rrightarrow", "⇛" }, + { "Rsh", "↱" }, + { "Fei", "Ϥ" }, + { "Frowny", "☹" }, + { "Hori", "Ϩ" }, + { "Heta", "Ͱ" }, + { "Khei", "Ϧ" }, + { "Koppa", "Ϟ" }, + { "Kappa", "Κ" }, + { "^a", "ᵃ" }, + { "^b", "ᵇ" }, + { "^c", "ᶜ" }, + { "^d", "ᵈ" }, + { "^e", "ᵉ" }, + { "^f", "ᶠ" }, + { "^g", "ᵍ" }, + { "^h", "ʰ" }, + { "^i", "ⁱ" }, + { "^j", "ʲ" }, + { "^k", "ᵏ" }, + { "^l", "ˡ" }, + { "^m", "ᵐ" }, + { "^n", "ⁿ" }, + { "^o", "ᵒ" }, + { "^p", "ᵖ" }, + { "^r", "ʳ" }, + { "^s", "ˢ" }, + { "^t", "ᵗ" }, + { "^u", "ᵘ" }, + { "^v", "ᵛ" }, + { "^w", "ʷ" }, + { "^x", "ˣ" }, + { "^y", "ʸ" }, + { "^z", "ᶻ" }, + { "^A", "ᴬ" }, + { "^B", "ᴮ" }, + { "^D", "ᴰ" }, + { "^E", "ᴱ" }, + { "^G", "ᴳ" }, + { "^H", "ᴴ" }, + { "^I", "ᴵ" }, + { "^J", "ᴶ" }, + { "^K", "ᴷ" }, + { "^L", "ᴸ" }, + { "^M", "ᴹ" }, + { "^N", "ᴺ" }, + { "^O", "ᴼ" }, + { "^P", "ᴾ" }, + { "^R", "ᴿ" }, + { "^T", "ᵀ" }, + { "^U", "ᵁ" }, + { "^V", "ⱽ" }, + { "^W", "ᵂ" }, + { "^0", "⁰" }, + { "^1", "¹" }, + { "^2", "²" }, + { "^3", "³" }, + { "^4", "⁴" }, + { "^5", "⁵" }, + { "^6", "⁶" }, + { "^7", "⁷" }, + { "^8", "⁸" }, + { "^9", "⁹" }, + { "^)", "⁾" }, + { "^(", "⁽" }, + { "^=", "⁼" }, + { "^+", "⁺" }, + { "^o_", "º" }, + { "^-", "⁻" }, + { "^a_", "ª" }, + { "^uhook", "ꭟ" }, + { "^ubar", "ᶶ" }, + { "^upsilon", "ᶷ" }, + { "^ltilde", "ꭞ" }, + { "^ls", "ꭝ" }, + { "^lhook", "ᶪ" }, + { "^lretroflexhook", "ᶩ" }, + { "^oe", "ꟹ" }, + { "^heng", "ꭜ" }, + { "^hhook", "ʱ" }, + { "^hwithhook", "ʱ" }, + { "^Hstroke", "ꟸ" }, + { "^theta", "ᶿ" }, + { "^turnedv", "ᶺ" }, + { "^turnedmleg", "ᶭ" }, + { "^turnedm", "ᵚ" }, + { "^turnedh", "ᶣ" }, + { "^turnedalpha", "ᶛ" }, + { "^turnedae", "ᵆ" }, + { "^turneda", "ᵄ" }, + { "^turnedi", "ᵎ" }, + { "^turnede", "ᵌ" }, + { "^turnedrhook", "ʵ" }, + { "^turnedrwithhook", "ʵ" }, + { "^turnedr", "ʴ" }, + { "^twithpalatalhook", "ᶵ" }, + { "^otop", "ᵔ" }, + { "^ezh", "ᶾ" }, + { "^esh", "ᶴ" }, + { "^eth", "ᶞ" }, + { "^eng", "ᵑ" }, + { "^zcurl", "ᶽ" }, + { "^zretroflexhook", "ᶼ" }, + { "^vhook", "ᶹ" }, + { "^Ismall", "ᶦ" }, + { "^Lsmall", "ᶫ" }, + { "^Nsmall", "ᶰ" }, + { "^Usmall", "ᶸ" }, + { "^Istroke", "ᶧ" }, + { "^Rinverted", "ʶ" }, + { "^ccurl", "ᶝ" }, + { "^chi", "ᵡ" }, + { "^shook", "ᶳ" }, + { "^gscript", "ᶢ" }, + { "^schwa", "ᵊ" }, + { "^usideways", "ᵙ" }, + { "^phi", "ᶲ" }, + { "^obarred", "ᶱ" }, + { "^beta", "ᵝ" }, + { "^obottom", "ᵕ" }, + { "^nretroflexhook", "ᶯ" }, + { "^nlefthook", "ᶮ" }, + { "^mhook", "ᶬ" }, + { "^jtail", "ᶨ" }, + { "^iota", "ᶥ" }, + { "^istroke", "ᶤ" }, + { "^ereversedopen", "ᶟ" }, + { "^stop", "ˤ" }, + { "^varphi", "ᵠ" }, + { "^vargamma", "ᵞ" }, + { "^gamma", "ˠ" }, + { "^ain", "ᵜ" }, + { "^alpha", "ᵅ" }, + { "^oopen", "ᵓ" }, + { "^eopen", "ᵋ" }, + { "^Ou", "ᴽ" }, + { "^Nreversed", "ᴻ" }, + { "^Ereversed", "ᴲ" }, + { "^Bbarred", "ᴯ" }, + { "^Ae", "ᴭ" }, + { "^SM", "℠" }, + { "^TEL", "℡" }, + { "^TM", "™" }, + { "_a", "ₐ" }, + { "_e", "ₑ" }, + { "_h", "ₕ" }, + { "_i", "ᵢ" }, + { "_j", "ⱼ" }, + { "_k", "ₖ" }, + { "_l", "ₗ" }, + { "_m", "ₘ" }, + { "_n", "ₙ" }, + { "_o", "ₒ" }, + { "_p", "ₚ" }, + { "_r", "ᵣ" }, + { "_s", "ₛ" }, + { "_t", "ₜ" }, + { "_u", "ᵤ" }, + { "_v", "ᵥ" }, + { "_x", "ₓ" }, + { "_0", "₀" }, + { "_1", "₁" }, + { "_2", "₂" }, + { "_3", "₃" }, + { "_4", "₄" }, + { "_5", "₅" }, + { "_6", "₆" }, + { "_7", "₇" }, + { "_8", "₈" }, + { "_9", "₉" }, + { "_)", "₎" }, + { "_(", "₍" }, + { "_=", "₌" }, + { "_+", "₊" }, + { "_-", "₋" }, + { "!!", "‼" }, + { "!?", "⁉" }, + { "San", "Ϻ" }, + { "Sampi", "Ϡ" }, + { "Sho", "Ϸ" }, + { "Shima", "Ϭ" }, + { "Shei", "Ϣ" }, + { "Stigma", "Ϛ" }, + { "Sigma", "Σ" }, + { "Subset", "⋐" }, + { "Supset", "⋑" }, + { "Smiley", "☺" }, + { "Psi", "Ψ" }, + { "Phi", "Φ" }, + { "Pi", "Π" }, + { "Pi0", "Π₀" }, + { "P0", "Π₀" }, + { "Pi_0", "Π₀" }, + { "P_0", "Π₀" }, + { "bfA", "𝐀" }, + { "bfB", "𝐁" }, + { "bfC", "𝐂" }, + { "bfD", "𝐃" }, + { "bfE", "𝐄" }, + { "bfF", "𝐅" }, + { "bfG", "𝐆" }, + { "bfH", "𝐇" }, + { "bfI", "𝐈" }, + { "bfJ", "𝐉" }, + { "bfK", "𝐊" }, + { "bfL", "𝐋" }, + { "bfM", "𝐌" }, + { "bfN", "𝐍" }, + { "bfO", "𝐎" }, + { "bfP", "𝐏" }, + { "bfQ", "𝐐" }, + { "bfR", "𝐑" }, + { "bfS", "𝐒" }, + { "bfT", "𝐓" }, + { "bfU", "𝐔" }, + { "bfV", "𝐕" }, + { "bfW", "𝐖" }, + { "bfX", "𝐗" }, + { "bfY", "𝐘" }, + { "bfZ", "𝐙" }, + { "bfa", "𝐚" }, + { "bfb", "𝐛" }, + { "bfc", "𝐜" }, + { "bfd", "𝐝" }, + { "bfe", "𝐞" }, + { "bff", "𝐟" }, + { "bfg", "𝐠" }, + { "bfh", "𝐡" }, + { "bfi", "𝐢" }, + { "bfj", "𝐣" }, + { "bfk", "𝐤" }, + { "bfl", "𝐥" }, + { "bfm", "𝐦" }, + { "bfn", "𝐧" }, + { "bfo", "𝐨" }, + { "bfp", "𝐩" }, + { "bfq", "𝐪" }, + { "bfr", "𝐫" }, + { "bfs", "𝐬" }, + { "bft", "𝐭" }, + { "bfu", "𝐮" }, + { "bfv", "𝐯" }, + { "bfw", "𝐰" }, + { "bfx", "𝐱" }, + { "bfy", "𝐲" }, + { "bfz", "𝐳" }, + { "MiA", "𝐴" }, + { "MiB", "𝐵" }, + { "MiC", "𝐶" }, + { "MiD", "𝐷" }, + { "MiE", "𝐸" }, + { "MiF", "𝐹" }, + { "MiG", "𝐺" }, + { "MiH", "𝐻" }, + { "MiI", "𝐼" }, + { "MiJ", "𝐽" }, + { "MiK", "𝐾" }, + { "MiL", "𝐿" }, + { "MiM", "𝑀" }, + { "MiN", "𝑁" }, + { "MiO", "𝑂" }, + { "MiP", "𝑃" }, + { "MiQ", "𝑄" }, + { "MiR", "𝑅" }, + { "MiS", "𝑆" }, + { "MiT", "𝑇" }, + { "MiU", "𝑈" }, + { "MiV", "𝑉" }, + { "MiW", "𝑊" }, + { "MiX", "𝑋" }, + { "MiY", "𝑌" }, + { "MiZ", "𝑍" }, + { "Mia", "𝑎" }, + { "Mib", "𝑏" }, + { "Mic", "𝑐" }, + { "Mid", "𝑑" }, + { "Mie", "𝑒" }, + { "Mif", "𝑓" }, + { "Mig", "𝑔" }, + { "Mii", "𝑖" }, + { "Mij", "𝑗" }, + { "Mik", "𝑘" }, + { "Mil", "𝑙" }, + { "Mim", "𝑚" }, + { "Min", "𝑛" }, + { "Mio", "𝑜" }, + { "Mip", "𝑝" }, + { "Miq", "𝑞" }, + { "Mir", "𝑟" }, + { "Mis", "𝑠" }, + { "Mit", "𝑡" }, + { "Miu", "𝑢" }, + { "Miv", "𝑣" }, + { "Miw", "𝑤" }, + { "Mix", "𝑥" }, + { "Miy", "𝑦" }, + { "Miz", "𝑧" }, + { "MIA", "𝑨" }, + { "MIB", "𝑩" }, + { "MIC", "𝑪" }, + { "MID", "𝑫" }, + { "MIE", "𝑬" }, + { "MIF", "𝑭" }, + { "MIG", "𝑮" }, + { "MIH", "𝑯" }, + { "MII", "𝑰" }, + { "MIJ", "𝑱" }, + { "MIK", "𝑲" }, + { "MIL", "𝑳" }, + { "MIM", "𝑴" }, + { "MIN", "𝑵" }, + { "MIO", "𝑶" }, + { "MIP", "𝑷" }, + { "MIQ", "𝑸" }, + { "MIR", "𝑹" }, + { "MIS", "𝑺" }, + { "MIT", "𝑻" }, + { "MIU", "𝑼" }, + { "MIV", "𝑽" }, + { "MIW", "𝑾" }, + { "MIX", "𝑿" }, + { "MIY", "𝒀" }, + { "MIZ", "𝒁" }, + { "MIa", "𝒂" }, + { "MIb", "𝒃" }, + { "MIc", "𝒄" }, + { "MId", "𝒅" }, + { "MIe", "𝒆" }, + { "MIf", "𝒇" }, + { "MIg", "𝒈" }, + { "MIh", "𝒉" }, + { "MIi", "𝒊" }, + { "MIj", "𝒋" }, + { "MIk", "𝒌" }, + { "MIl", "𝒍" }, + { "MIm", "𝒎" }, + { "MIn", "𝒏" }, + { "MIo", "𝒐" }, + { "MIp", "𝒑" }, + { "MIq", "𝒒" }, + { "MIr", "𝒓" }, + { "MIs", "𝒔" }, + { "MIt", "𝒕" }, + { "MIu", "𝒖" }, + { "MIv", "𝒗" }, + { "MIw", "𝒘" }, + { "MIx", "𝒙" }, + { "MIy", "𝒚" }, + { "MIz", "𝒛" }, + { "McA", "𝒜" }, + { "McB", "ℬ" }, + { "McC", "𝒞" }, + { "McD", "𝒟" }, + { "McE", "ℰ" }, + { "McF", "ℱ" }, + { "McG", "𝒢" }, + { "McH", "ℋ" }, + { "McI", "ℐ" }, + { "McJ", "𝒥" }, + { "McK", "𝒦" }, + { "McL", "ℒ" }, + { "McM", "ℳ" }, + { "McN", "𝒩" }, + { "McO", "𝒪" }, + { "McP", "𝒫" }, + { "McQ", "𝒬" }, + { "McR", "ℛ" }, + { "McS", "𝒮" }, + { "McT", "𝒯" }, + { "McU", "𝒰" }, + { "McV", "𝒱" }, + { "McW", "𝒲" }, + { "McX", "𝒳" }, + { "McY", "𝒴" }, + { "McZ", "𝒵" }, + { "Mca", "𝒶" }, + { "Mcb", "𝒷" }, + { "Mcc", "𝒸" }, + { "Mcd", "𝒹" }, + { "Mce", "ℯ" }, + { "Mcf", "𝒻" }, + { "Mcg", "ℊ" }, + { "Mch", "𝒽" }, + { "Mci", "𝒾" }, + { "Mcj", "𝒿" }, + { "Mck", "𝓀" }, + { "Mcl", "𝓁" }, + { "Mcm", "𝓂" }, + { "Mcn", "𝓃" }, + { "Mco", "ℴ" }, + { "Mcp", "𝓅" }, + { "Mcq", "𝓆" }, + { "Mcr", "𝓇" }, + { "Mcs", "𝓈" }, + { "Mct", "𝓉" }, + { "Mcu", "𝓊" }, + { "Mcv", "𝓋" }, + { "Mcw", "𝓌" }, + { "Mcx", "𝓍" }, + { "Mcy", "𝓎" }, + { "Mcz", "𝓏" }, + { "MCA", "𝓐" }, + { "MCB", "𝓑" }, + { "MCC", "𝓒" }, + { "MCD", "𝓓" }, + { "MCE", "𝓔" }, + { "MCF", "𝓕" }, + { "MCG", "𝓖" }, + { "MCH", "𝓗" }, + { "MCI", "𝓘" }, + { "MCJ", "𝓙" }, + { "MCK", "𝓚" }, + { "MCL", "𝓛" }, + { "MCM", "𝓜" }, + { "MCN", "𝓝" }, + { "MCO", "𝓞" }, + { "MCP", "𝓟" }, + { "MCQ", "𝓠" }, + { "MCR", "𝓡" }, + { "MCS", "𝓢" }, + { "MCT", "𝓣" }, + { "MCU", "𝓤" }, + { "MCV", "𝓥" }, + { "MCW", "𝓦" }, + { "MCX", "𝓧" }, + { "MCY", "𝓨" }, + { "MCZ", "𝓩" }, + { "MCa", "𝓪" }, + { "MCb", "𝓫" }, + { "MCc", "𝓬" }, + { "MCd", "𝓭" }, + { "MCe", "𝓮" }, + { "MCf", "𝓯" }, + { "MCg", "𝓰" }, + { "MCh", "𝓱" }, + { "MCi", "𝓲" }, + { "MCj", "𝓳" }, + { "MCk", "𝓴" }, + { "MCl", "𝓵" }, + { "MCm", "𝓶" }, + { "MCn", "𝓷" }, + { "MCo", "𝓸" }, + { "MCp", "𝓹" }, + { "MCq", "𝓺" }, + { "MCr", "𝓻" }, + { "MCs", "𝓼" }, + { "MCt", "𝓽" }, + { "MCu", "𝓾" }, + { "MCv", "𝓿" }, + { "MCw", "𝔀" }, + { "MCx", "𝔁" }, + { "MCy", "𝔂" }, + { "MCz", "𝔃" }, + { "MfA", "𝔄" }, + { "MfB", "𝔅" }, + { "MfC", "ℭ" }, + { "MfD", "𝔇" }, + { "MfE", "𝔈" }, + { "MfF", "𝔉" }, + { "MfG", "𝔊" }, + { "MfH", "ℌ" }, + { "MfI", "ℑ" }, + { "MfJ", "𝔍" }, + { "MfK", "𝔎" }, + { "MfL", "𝔏" }, + { "MfM", "𝔐" }, + { "MfN", "𝔑" }, + { "MfO", "𝔒" }, + { "MfP", "𝔓" }, + { "MfQ", "𝔔" }, + { "MfR", "ℜ" }, + { "MfS", "𝔖" }, + { "MfT", "𝔗" }, + { "MfU", "𝔘" }, + { "MfV", "𝔙" }, + { "MfW", "𝔚" }, + { "MfX", "𝔛" }, + { "MfY", "𝔜" }, + { "MfZ", "ℨ" }, + { "Mfa", "𝔞" }, + { "Mfb", "𝔟" }, + { "Mfc", "𝔠" }, + { "Mfd", "𝔡" }, + { "Mfe", "𝔢" }, + { "Mff", "𝔣" }, + { "Mfg", "𝔤" }, + { "Mfh", "𝔥" }, + { "Mfi", "𝔦" }, + { "Mfj", "𝔧" }, + { "Mfk", "𝔨" }, + { "Mfl", "𝔩" }, + { "Mfm", "𝔪" }, + { "Mfn", "𝔫" }, + { "Mfo", "𝔬" }, + { "Mfp", "𝔭" }, + { "Mfq", "𝔮" }, + { "Mfr", "𝔯" }, + { "Mfs", "𝔰" }, + { "Mft", "𝔱" }, + { "Mfu", "𝔲" }, + { "Mfv", "𝔳" }, + { "Mfw", "𝔴" }, + { "Mfx", "𝔵" }, + { "Mfy", "𝔶" }, + { "Mfz", "𝔷" }, + { "yen", "¥" }, + { "varrho", "ϱ" }, + { "varkappa", "ϰ" }, + { "varkai", "ϗ" }, + { "varnothing", "∅" }, + { "varpi", "ϖ" }, + { "varphi", "ϕ" }, + { "varprime", "′" }, + { "varpropto", "∝" }, + { "vartheta", "ϑ" }, + { "vartriangleleft", "⊲" }, + { "vartriangleright", "⊳" }, + { "varbeta", "ϐ" }, + { "varsigma", "ς" }, + { "veebar", "⊻" }, + { "vee", "∨" }, + { "ve", "ě" }, + { "vE", "Ě" }, + { "vdash", "⊢" }, + { "vdots", "⋮" }, + { "vd", "ď" }, + { "vDash", "⊨" }, + { "vD", "Ď" }, + { "vc", "č" }, + { "vC", "Č" }, + { "koppa", "ϟ" }, + { "kip", "₭" }, + { "ki", "į" }, + { "kI", "Į" }, + { "kelvin", "K" }, + { "kappa", "κ" }, + { "khei", "ϧ" }, + { "warning", "⚠" }, + { "won", "₩" }, + { "wedge", "∧" }, + { "wp", "℘" }, + { "wr", "≀" }, + { "Dei", "Ϯ" }, + { "Delta", "Δ" }, + { "Digamma", "Ϝ" }, + { "Diamond", "◇" }, + { "Downarrow", "⇓" }, + { "DH", "Ð" }, + { "zeta", "ζ" }, + { "Eta", "Η" }, + { "Epsilon", "Ε" }, + { "Beta", "Β" }, + { "Box", "□" }, + { "Bumpeq", "≎" }, + { "bbA", "𝔸" }, + { "bbB", "𝔹" }, + { "bbC", "ℂ" }, + { "bbD", "𝔻" }, + { "bbE", "𝔼" }, + { "bbF", "𝔽" }, + { "bbG", "𝔾" }, + { "bbH", "ℍ" }, + { "bbI", "𝕀" }, + { "bbJ", "𝕁" }, + { "bbK", "𝕂" }, + { "bbL", "𝕃" }, + { "bbM", "𝕄" }, + { "bbN", "ℕ" }, + { "bbO", "𝕆" }, + { "bbP", "ℙ" }, + { "bbQ", "ℚ" }, + { "bbR", "ℝ" }, + { "bbS", "𝕊" }, + { "bbT", "𝕋" }, + { "bbU", "𝕌" }, + { "bbV", "𝕍" }, + { "bbW", "𝕎" }, + { "bbX", "𝕏" }, + { "bbY", "𝕐" }, + { "bbZ", "ℤ" }, + { "bba", "𝕒" }, + { "bbb", "𝕓" }, + { "bbc", "𝕔" }, + { "bbd", "𝕕" }, + { "bbe", "𝕖" }, + { "bbf", "𝕗" }, + { "bbg", "𝕘" }, + { "bbh", "𝕙" }, + { "bbi", "𝕚" }, + { "bbj", "𝕛" }, + { "bbk", "𝕜" }, + { "bbl", "𝕝" }, + { "bbm", "𝕞" }, + { "bbn", "𝕟" }, + { "bbo", "𝕠" }, + { "bbp", "𝕡" }, + { "bbq", "𝕢" }, + { "bbr", "𝕣" }, + { "bbs", "𝕤" }, + { "bbt", "𝕥" }, + { "bbu", "𝕦" }, + { "bbv", "𝕧" }, + { "bbw", "𝕨" }, + { "bbx", "𝕩" }, + { "bby", "𝕪" }, + { "bbz", "𝕫" }, + { "Rge0", "ℝ≥0" }, + { "R>=0", "ℝ≥0" }, + { "nnreal", "ℝ≥0" }, + { "ennreal", "ℝ≥0∞" }, + { "enat", "ℕ∞" }, + { "Zsqrt", "ℤ√" }, + { "zsqrtd", "ℤ√" }, + { "liel", "⁅" }, + { "bracketl", "⁅" }, + { "lier", "⁆" }, + { "[-", "⁅" }, + { "-]", "⁆" }, + { "lsimplex", "⦋" }, + { "rsimplex", "⦌" }, + { "bracketr", "⁆" }, + { "nhds", "𝓝" }, + { "nbhds", "𝓝" }, + { "X", "⨯" }, + { "vectorproduct", "⨯" }, + { "crossproduct", "⨯" }, + { "xs", "×ˢ" }, + { "coprod", "⨿" }, + { "sigmaobj", "∐" }, + { "xf", "×ᶠ" }, + { "exf", "∃ᶠ" }, + { "Yot", "Ϳ" }, + { "goal", "⊢" }, + { "Vdash", "⊩" }, + { "Vert", "‖" }, + { "Vvdash", "⊪" }, + { "tiny", "⧾" }, + { "miny", "⧿" }, + { "heq", "≍" }, + { "r!", "¡" }, +} diff --git a/builtin/runtime/lean_input.lua b/builtin/runtime/lean_input.lua new file mode 100644 index 0000000..da24378 --- /dev/null +++ b/builtin/runtime/lean_input.lua @@ -0,0 +1,362 @@ +-- lean_input.lua --- the Lean 4 Unicode input method (Arc 8 Stage 4b). +-- +-- Typing `\alpha` gives `α`; `\<>` gives `⟨⟩` with the point between. +-- The table is vendored in lean_abbrev.lua, generated from +-- vscode-lean4 — see that file's header and Q#LN11. +-- +-- This is a typed-edit consumer (Stage 4a, Q#LN10) registered AHEAD of +-- auto-pairing at priority 50. The ordering is load-bearing, not +-- cosmetic: 64 abbreviation keys contain a character in the `lean4` +-- pair set (`\[[]]` → `⟦⟧`, `\{{}}` → `⦃⦄`), so with pairing first, +-- typing `\[` would insert `[]` with the point between and corrupt the +-- pending key to `\[]` before the second `[` arrives — `\[[]]` becomes +-- unreachable. Priority, not load order, is what decides this; that is +-- the whole reason Stage 4a exists. +-- +-- The consumer therefore claims every keystroke that EXTENDS an open +-- pending abbreviation, not merely one that completes an expansion. A +-- consumer that claimed only completed expansions would hand each +-- intermediate `[` to pairing, which is the same corruption by a +-- different route. "Claimed" means the chain stops, not that an edit +-- was made (Q#LN22). +-- +-- UNDO IS CROSS-PEER-DEGRADED, and this is accepted rather than papered +-- over (Q#LN21). `classify_key` (src/optimistic.rs) returns `Insert(c)` +-- for `\` and for every ASCII letter — only the nine built-in pair +-- chars are excluded — so on a CRDT frontend `\alpha` arrives as six +-- SOURCE-peer optimistic inserts while the expansion is a single +-- DAEMON-peer replace spanning all six. Undo across that boundary is +-- not chronologically arbitrated. This is the same defect Q#LN6 already +-- accepts for `⟨⟩`, one order of magnitude wider: it is every +-- abbreviation the user types, not a few brackets. The general fix is +-- chronological cross-peer undo arbitration, named substrate work. +-- `set_round_trip_input` would fix it and is rejected — it also makes +-- `dispatch_idle` report false, so RET would stop inserting a newline. +-- +-- Framing: docs/lean4-mode-framing.md Q#LN11, Q#LN21, Q#LN22. + +pmacs.lean_input = pmacs.lean_input or {} + +local ed = pmacs.editor + +local LEADER = "\\" +local CURSOR = "$CURSOR" + +pmacs.config.define { + name = "lean.abbrev", + description = "Expand \\-prefixed abbreviations into Unicode symbols in Lean 4 buffers.", + type = "boolean", + default = true, + mutability = "live", +} + +-- --------------------------------------------------------------------- +-- The table, and the two indexes derived from it at load time +-- --------------------------------------------------------------------- + +-- `best[p]` is the symbol for the shortest key having `p` as a prefix, +-- ties broken by the key's position in the vendored sequence. Both +-- halves matter: 101 prefixes have equal-shortest candidates that +-- resolve to DIFFERENT symbols (`f` → `‹` from `f<`, not `›` from +-- `f>`), and the sequence's order is the only place that tie is +-- recorded. `pairs` over a map-shaped table could not express it. +-- +-- `eager[k]` marks the 1,550 keys that are complete and have no longer +-- key extending them — the ones that expand the moment they are typed, +-- with no terminator. `to` is NOT one of them (`top`, `to0`, `toa`), +-- which is exactly the case that reads as eager until the table is +-- consulted. +local best, eager = {}, {} + +do + local seq = pmacs.lean_abbrev + if type(seq) ~= "table" then seq = {} end + local extended = {} + for i = 1, #seq do + local entry = seq[i] + local key, symbol = entry[1], entry[2] + -- Walk every prefix of the key, including the key itself. Iterating + -- the sequence in order and only overwriting on a STRICTLY shorter + -- key is what makes the source-order tiebreak fall out: an equal + -- length arriving later loses to the one already recorded. + for n = 1, #key do + local p = key:sub(1, n) + local cur = best[p] + if cur == nil or #key < cur.len then + best[p] = { symbol = symbol, len = #key } + end + if n < #key then extended[p] = true end + end + end + for i = 1, #seq do + local key = seq[i][1] + if not extended[key] then eager[key] = true end + end +end + +-- Test seam (leading underscore = not stable API). Acceptance 45g reads +-- these to pin self-consistency properties a corrupt emit would break — +-- it cannot diff against `abbreviations.json`, which is not shipped. +function pmacs.lean_input._resolve(text) + local hit = best[text] + return hit and hit.symbol or nil +end + +function pmacs.lean_input._is_eager(key) + return eager[key] == true +end + +-- --------------------------------------------------------------------- +-- Pending state: one record per FRONTEND (Q#LN22) +-- --------------------------------------------------------------------- + +-- Keyed by frontend id, with the buffer stored inside and compared by +-- value. Q#LN22 specifies the key as `(frontend, buffer)`; a per- +-- frontend slot is equivalent here and avoids inventing a scalar +-- buffer key (`BufferId`'s inner value is deliberately private, R22). +-- The generality a two-level map would add is unreachable: a frontend +-- has one point, and `buffer.after-switch` clears that frontend's slot, +-- so no frontend can hold pending state in a buffer it is not in. +-- +-- Per-frontend rather than per-buffer is NOT a refinement — a buffer- +-- keyed table lets either frontend consume or discard the other's +-- half-typed abbreviation in a shared buffer, which is the ordinary +-- TUI-plus-GPU configuration this project ships. +local pending = {} + +local function frontend_id() + local ok, id = pcall(function() return pmacs.frontend.id() end) + if ok then return id end + return nil +end + +-- Is `rec` a typed edit that continues `p` exactly? Conservative by +-- construction (Q#LN22): abandonment is LAZY because pmacs has no +-- cursor-motion hook, so every guard that would have been checked at +-- the moment the user left is checked here instead, at the next typed +-- edit. +local function still_valid(p, rec, buf) + if p.buffer ~= rec.buffer or p.window ~= rec.window then return false end + -- The point must still be at the end of the pending span: the leader, + -- plus what has been typed into it, plus the character that just + -- landed. + if rec.effective_start ~= p.start_offset + 1 + #p.text then return false end + -- Exactly one edit since this frontend last extended the pending + -- abbreviation — the one being processed now. Deliberately strict + -- across frontends: `revision()` is BUFFER-GLOBAL, so a peer editing + -- the shared buffer invalidates this record even though it edited + -- elsewhere. Keeping it alive would mean translating and validating + -- the span through arbitrary peer edits, substrate Stage 4b does not + -- add. + local ok, rev = pcall(function() return buf:revision() end) + if not ok or rev ~= p.expected_revision + 1 then return false end + return true +end + +-- --------------------------------------------------------------------- +-- Expansion +-- --------------------------------------------------------------------- + +-- Replace the pending span with `symbol`, placing the point at +-- `$CURSOR` if the symbol carries one. Returns the byte offset just +-- past the replacement, or nil when the edit was rejected or altered. +-- +-- ONE `buf:replace` for the whole expansion: one undo step, one CRDT +-- op, one effective-edit verification. A rejection drops the pending +-- state and does not retry, the same discipline as comment.lua's Q#CT5 +-- and pair.lua. +local function expand(buf, p, symbol, span_end) + local cursor_at = symbol:find(CURSOR, 1, true) + local text = cursor_at and (symbol:gsub("%$CURSOR", "", 1)) or symbol + + local start = p.start_offset + local ok, estart, estop, einserted = pcall(function() + return buf:replace(start, span_end, text) + end) + if not ok then + ed.set_status("lean abbreviation rejected by buffer intercept") + return nil + end + if estart ~= start or estop ~= span_end or einserted ~= #text then + ed.set_status("lean abbreviation altered by buffer intercept") + return nil + end + + -- The point MUST be placed explicitly. Unlike pairing's at-cursor + -- insert, this replace SHRINKS the buffer — `\alpha` (6 bytes) + -- becomes `α` (2) — and a point left at the pre-edit offset is past + -- the new end. Every later self-insert is then silently rejected and + -- the editor looks dead. There is no daemon re-grounding that covers + -- this; that only holds for an edit that lands at the cursor. + ed.goto_byte(cursor_at and (start + cursor_at - 1) or (start + #text)) + return start + #text +end + +-- --------------------------------------------------------------------- +-- The consumer +-- --------------------------------------------------------------------- + +local function on_typed_edit(rec) + local fid = frontend_id() + if fid == nil then return false end + + -- A fan-out carrying no record is still information: a paste, + -- programmatic edit or replicated op landed, so whatever this + -- frontend had pending no longer describes the buffer. Drop it and + -- decline — this is why the chain calls consumers with nil rather + -- than skipping them (Q#LN10). + if not rec then + pending[fid] = nil + return false + end + if not (ed.this_command and ed.this_command() == "buffer.self-insert") then + pending[fid] = nil + return false + end + + -- Both gates resolve against the SOURCE buffer of the typed edit, not + -- the active one — a context-switching command may have replaced it + -- by callback time (pair.lua round 2, finding 2). + if not pmacs.config.get("lean.abbrev", rec.buffer) then + pending[fid] = nil + return false + end + local lang + if pmacs.lsp and pmacs.lsp.buffer_language then + local ok, l = pcall(pmacs.lsp.buffer_language, rec.buffer) + if ok then lang = l end + end + if lang ~= "lean4" then + -- No pending abbreviation is ever OPENED outside a `lean4` buffer: + -- `\` in Rust is an ordinary character and `\[` there still pairs. + pending[fid] = nil + return false + end + + local buf = pmacs.window.buffer() + if not buf or buf ~= rec.buffer or pmacs.window.current() ~= rec.window then + pending[fid] = nil + return false + end + -- Fail closed on a transformed source self-insert, as pairing does: + -- expanding on top of a relocated or rewritten character compounds + -- the intercept's result. + if not rec.clean then + pending[fid] = nil + return false + end + + local revision + do + local ok, rev = pcall(function() return buf:revision() end) + if not ok then + pending[fid] = nil + return false + end + revision = rev + end + + local p = pending[fid] + if p and not still_valid(p, rec, buf) then + p = nil + pending[fid] = nil + end + + local ch = rec.char + + -- No pending abbreviation: only the leader opens one. + if not p then + if ch == LEADER then + pending[fid] = { + buffer = rec.buffer, + window = rec.window, + start_offset = rec.effective_start, + text = "", + expected_revision = revision, + } + -- Claimed: the leader belongs to the abbreviation, and pairing + -- has no interest in it either way. + return true + end + return false + end + + -- Pending: does any key still have `text .. ch` as a prefix? + local extended = p.text .. ch + if best[extended] then + p.text = extended + p.expected_revision = revision + if eager[extended] then + local span_end = p.start_offset + 1 + #extended + pending[fid] = nil + expand(buf, p, best[extended].symbol, span_end) + end + -- Claimed either way: an extension that has not yet completed must + -- NOT reach auto-pairing (`\[` in `\[[]]`). + return true + end + + -- `ch` does not extend the abbreviation. Expand what is pending + -- FIRST, then let `ch` stand as ordinary text — the terminator is + -- retained, not consumed, and it sits inside the replaced span so the + -- whole thing is one undo step. + pending[fid] = nil + local hit = best[p.text] + local after + if hit and #p.text > 0 then + -- `span_end` covers the terminator: the leader, the pending text, + -- and `ch`, which has already landed. What replaces it is the + -- symbol followed by `ch` itself. + local span_end = p.start_offset + 1 + #p.text + #ch + after = expand(buf, p, hit.symbol .. ch, span_end) + end + + -- A terminating `\` re-arms as a NEW leader at its own position + -- (`\alpha\to` → `α→`). Upstream gets this from `processChange`, + -- where a finished abbreviation reports `isAffected = false` and so + -- does not suppress the new-leader branch. This is not the `\\` case: + -- there the pending text is empty, `\` EXTENDS, and the result is one + -- literal backslash with no pending state left open. + if ch == LEADER then + local start = after and (after - #ch) or rec.effective_start + local ok, rev = pcall(function() return buf:revision() end) + if ok then + pending[fid] = { + buffer = rec.buffer, + window = rec.window, + start_offset = start, + text = "", + expected_revision = rev, + } + end + return true + end + + -- Claimed only if an expansion actually happened. Otherwise `ch` is + -- an ordinary character in a Lean buffer and auto-pairing should see + -- it — `\zz` leaves `z` free to pair if it ever were a pair char. + return after ~= nil +end + +-- Q#KR11's seam: a detached frontend's pending state must not outlive +-- it. Ids are monotonic, so this table would otherwise grow for the +-- life of the session. +pmacs.hook.add("frontend.detached", function(fid) + pending[fid] = nil +end) + +-- `buffer.after-switch` fires with NO arguments, so it cannot say whose +-- switch it was. The acting frontend is the one that produced the most +-- recent dispatched input event, which is what `pmacs.frontend.id()` +-- reports at callback time. Clearing every entry instead would let one +-- frontend's navigation discard another's half-typed abbreviation. +pmacs.hook.add("buffer.after-switch", function() + local fid = frontend_id() + if fid ~= nil then pending[fid] = nil end +end) + +pmacs.typed_edit.add_consumer { + name = "lean-abbrev", + priority = 50, + fn = on_typed_edit, +} diff --git a/docs/active-work.md b/docs/active-work.md index 9a847b1..7bb63ff 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -14,8 +14,8 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `d400f30` (Lean 4 Stage 3b #170 atop Stage 3a - #167, the bottom-panel landed-doc refresh #156, the inline-math slice + `githubsucks/main` @ `a27f646` (Lean 4 Stage 4a #179 atop Stage 3b + #170, Stage 3a #167, the bottom-panel landed-doc refresh #156, the inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, and the minimap blank-slab fix #159; @@ -57,172 +57,93 @@ git status --short --branch The `git log` command must expose `d152120` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b MERGED; Stage 4a IN REVIEW +## Lean 4 lane (Arc 8) — Stages 1–4a MERGED; Stage 4b IN REVIEW -- **Stages 1, 2, 3a and 3b are MERGED** — #160 (`main` @ `0827dd1`), - #161 (`46a1b8f`), #167 (`6f348c9`), #170 (`d400f30`). Their full +- **Stages 1, 2, 3a, 3b and 4a are MERGED** — #160 (`main` @ `0827dd1`), + #161 (`46a1b8f`), #167 (`6f348c9`), #170 (`d400f30`), #179 + (`a27f646`). Their full histories were pruned from this ledger in round 6, per this file's own instruction to remove entries when their PR merges; the durable facts now live in `docs/agent-handoff.md` §1's Lean 4 bullet, which is where - a fresh machine should read them. `docs/lean4-mode-framing.md` rev 8 + a fresh machine should read them. `docs/lean4-mode-framing.md` rev 9 carries the decisions. -### Stage 4 — framing rev 8, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`) +### Stage 4b — the Unicode input method (branch `lean4-stage4b-input-method`) -- Stages 3a and 3b **merged as #167** (`main` @ `6f348c9`) and **#170** - (`main` @ `d400f30`), 2026-07-26. Both were integrated against a main - that had advanced 50 commits mid-review; the only conflict either time - was this ledger's own lane headings, resolved by keeping both sides. -- Worktree `../pmacs-lean-stage4`, branched off `main` @ `d400f30`. - Framing-only so far: `docs/lean4-mode-framing.md` **revision 8**. No - code. Awaiting user approval before implementation, per the workflow. -- **Round 6 review found five P1s, four of them internal to rev 6** — - facts about pmacs the revision asserted without checking, while its - external (upstream) facts held. Fixed in rev 7: Stage 4a's footprint - omitted the test file its own acceptance requires; pending - abbreviation state was keyed by buffer when pmacs is **multi-frontend** - (`EditorCore.views` is per-`FrontendId`, `take_typed_edit` is already - frontend-keyed, and `buffer.after-switch` fires with NO arguments, so - a buffer-keyed clear lets any frontend discard another's pending - state); the shortest-match rule was missing its **tie-break by source - declaration order**, which 101 prefixes depend on and a `pairs`- - iterated Lua map cannot express; and the generator's "abort on keys - needing escaping" rule **rejects the real table** (`\` is a key, `"` - begins eleven). -- **A 404 on a guessed path is not evidence of absence.** Rev 6 declared - the upstream package ships no README after fetching the package root, - with the directory listing showing `src/README.md` already in hand. - The README states the tie rule in one sentence. -- **Round 7 review found one remaining P1 in acceptance 45i.** Rev 7 - required A's pending abbreviation to survive B editing the same - buffer, while Q#LN22 also required an exact buffer-revision advance. - Those cannot both hold: revisions are buffer-global and every edit - bumps them. Rev 8 keeps the conservative guard and separates - ownership from survival — B cannot consume A's record, but B editing - the shared buffer invalidates A lazily; B switching buffers or - detaching remains frontend-scoped when no shared-buffer edit - intervenes. -- **Round 5 re-scout split Stage 4 into 4a (substrate) and 4b (Lean).** - 4a is the typed-edit consumer chain — `builtin/runtime/typed_edit.lua` - plus `pair.lua` re-expressed as one registered consumer, no behavior - change. 4b is the input method. The split is forced by §4's own rule, - which Stage 4's risk column ("refactors `pair.lua`'s provenance read") - broke while the prose called the stage Lean-only. -- **This is the SECOND consecutive re-scout to find that rule broken** - (round 4 found it for Stage 3). Rev 5 had even noticed the shape and - answered it with a commit boundary. **A commit boundary is not a review - boundary.** Re-check every remaining stage against §4 at scout time; - the rule is not self-enforcing. -- **Rev 5's expansion semantics were wrong in three ways**, found by - reading `leanprover/vscode-lean4` @ `17d1d08` rather than inferring - from behavior. Resolution is *shortest key having the input as a - prefix* (`\al` → `∀` from `all`, not `alpha`); there is **no - terminator list** (`'+ '` is a key, so space extends after `\+`; `'\'` - is a key, so `\\` → `\`); and an unmatchable tail is **appended**, - not dropped (`\alp7` → `α7`). -- **There is no cursor-motion hook**, so rev 5's acceptance 43 ("moving - the cursor out abandons it") was not buildable. Abandonment is lazy — - validated at the next typed edit — and the criterion now asserts what - pmacs can actually detect. Upstream drives this off `changeSelections`; - that seam does not exist here. -- **`dispatch_key` is only half the production path for 4b.** The - auto-pair suite gets away with dispatch-only because Q#AP1 removed the - pair chars from the optimistic classifiers; `\` and the letters are - NOT excluded, so on a CRDT frontend the optimistic producer is the real - path. That producer is `#[cfg(feature = "crdt")]` and CI never enables - `crdt`, and the gate list runs `--features crdt` only for `--lib` — a - crdt-gated integration test is **dark twice over**. -- The whole expansion has cross-peer-degraded undo (Q#LN21): six - source-peer optimistic inserts replaced by one daemon-peer op. - `set_round_trip_input` would fix it and is rejected — it also disables - `dispatch_idle`, so RET stops inserting a newline. -- Table facts re-derived at `17d1d08`: 1,855 entries, 36,861 bytes, all - keys ASCII, **64** keys carry a `lean4` pair-set char, **305** keys are - proper prefixes of another (so 1,550 expand eagerly), **26** values - carry `$CURSOR`, and **119** are multi-codepoint — the 26 - `$CURSOR`-bearing values plus 93 others. -- Citation sweep per COHERENCE §25: five live citations moved in the 50 - commits since rev 5 — `take_typed_edit` 12827→12990, - `handle_server_requests` 1549→1815, `fs.stat` 93→133, - `detect_buffer_language` 452→457, `send_request`/`send_notification` - 9342/9361→9507/9527. -### Stage 4a — the typed-edit consumer chain (IMPLEMENTED, same branch) - -- Footprint exactly as Q#LN10 declares it: `builtin/runtime/typed_edit.lua` - (new), `pair.lua` re-expressed as one consumer, - `src/editor.rs` +15 (the `include_str!` and its ordering comment), and - `tests/typed_edit_chain_acceptance.rs` (new, 13 tests). - **`tests/auto_pair_acceptance.rs` is UNCHANGED — `git diff --stat - main...HEAD -- tests/auto_pair_acceptance.rs` is empty.** That is - criterion 46 checked at the diff, which is the only way it means - anything. -- **The chain calls consumers even when the record is nil.** This is a - decision, not an implementation detail: three existing auto-pairing - tests assert `pmacs.pair._last_record == nil` after a record-less - fan-out (paste, programmatic insert, nested manual `hook.run`), so - skipping consumers on nil fails them. Stage 4b needs the same - delivery to abandon a pending abbreviation an unrelated edit - invalidated. -- **Ordered insertion, not `table.sort`** — Lua's sort is not stable, and - "ties broken by registration order" is a stated contract. -- **The chain `pcall`s each consumer** and reports through - `set_status`. Rev 7 justified this by claiming an uncontained throw - would fail the fan-out for every other subscriber including lsp.lua's - didChange flush; **that is wrong** — `run_all_must_succeed` - (`src/hook.rs:332`) collects errors and continues, so the other - subscribers still run. The real consequence is narrower and still - worth containing: the throw skips every LATER consumer in the chain. - The rendering is protected too, because a Lua error may be a table - whose `__tostring` throws. -- **Round 8 (review) findings, all fixed on this branch:** each consumer - now gets its **own shallow copy** of the record (the same table let a - declining consumer rewrite `rec.char`, which pairing reads — typing - `x` could produce `x)`); the fan-out iterates a **snapshot** (a - consumer registering a lower-priority one shifted itself forward under - `ipairs` and ran twice, unbounded if repeated); `tostring` moved - inside the containment; **non-finite and non-integer priorities are - rejected** (NaN is a number and every ordered comparison with it is - false, so it landed wherever the insertion scan gave up and silently - voided the ordering contract); and `add_consumer` now returns a handle - with `remove_consumer` beside it, so re-evaluating a config no longer - leaks callbacks the way `pmacs.hook.add` does (COHERENCE §13). -- **Every acceptance test is bite-verified by mutation**, per the - standing rule that a test is not evidence until the mutation it - targets has been shown to fail it: +- Framing `docs/lean4-mode-framing.md` **revision 9**, approved. Stage + 4a (the typed-edit consumer chain) MERGED as #179; this branch is 4b, + the Lean content that registers on it. +- Footprint: `scripts/regen-lean-abbrev` (new, the generator), + `builtin/runtime/lean_abbrev.lua` (new, VENDORED DATA — 1,855 entries + from `leanprover/vscode-lean4@17d1d08`, Apache-2.0), + `builtin/runtime/lean_input.lua` (new, the consumer at priority 50), + `src/editor.rs` (two `include_str!` blocks), + `tests/lean_input_acceptance.rs` (new, 25 tests), and one + `#[cfg(feature = "crdt")]` `--lib` test in `src/daemon.rs` + (acceptance 45f). No protocol change (Q#LN14). Entirely Lua apart + from the load sites and that one test. +- **Round 9 corrected three acceptance criteria that the real table + contradicts** — found by simulating the state machine over all 1,855 + entries and re-reading upstream at the pinned commit, not by reading + the prose again. `\to` is NOT eager (`top`, `to0`, `toa` extend it); + `\zzzz` expands to `ζzzz ` because `ze`/`zeta`/`zsqrtd` exist, and + only `$ % , ; @ W` open no key at all; and `\alpha`'s undo does not + restore `\alpha ` because `alpha` IS eager, so the terminator is a + separate edit. Criteria 38, 41 and 42 now state both paths. +- **Two generator bugs, both caught by its own round-trip check + failing closed:** `str.splitlines()` also splits on U+2028/U+2029, + and 53 symbols contain one literally, so the check reported a count + mismatch that was its own bug; then escaping via `chr(byte)` produced + a latin-1-shaped string that `write_text(encoding="utf-8")` + re-encoded, and every non-ASCII symbol landed double-encoded. The + first version of the check compared IN-MEMORY strings and agreed with + itself. **It now stages the file, re-reads the bytes from disk, and + renames into place only on a match.** +- **The point must be placed explicitly after the replace.** The + expansion SHRINKS the buffer (`\alpha` 6 bytes → `α` 2), so a point + left at the pre-edit offset is past the new end and every later + self-insert is silently rejected — the editor looks dead after the + first expansion. Pairing's "no cursor motion on the clean path" does + not generalize: that holds only for an insert AT the cursor. +- **Three tests were vacuous when first written and were found by + biting, not by review:** the abandonment test asserted text that a + wrongly-surviving record would also produce (claiming makes no edit — + it needed the follow-up keystroke that completes an eager key); the + re-arm test used the framing's own `\alpha\to`, which never reaches + the re-arm branch because `alpha` is eager and closes the record + first (`\al\to` does); and both buffer-switch tests passed through + `find_or_open`'s fresh-load path, which fires `buffer.after-load` and + a record-less edit rather than `buffer.after-switch` — deleting the + subscriber left them green. All three now bite. +- **Bite table** (each mutation, and the tests it fails): | Mutation | Tests it fails | |---|---| - | append instead of ordered insert | 5 chain | - | `>=` instead of `>` in the insert scan | 1 chain (tiebreak) | - | re-take the record per consumer | 4 chain | - | ignore the claim return value | 1 chain | - | drop the `pcall` | 1 chain | - | skip consumers when `rec == nil` | 1 chain + **3 auto-pair** | - | load `typed_edit.lua` after `lsp.lua` | 1 chain + **2 auto-pair** (Q#AP7) | - | hand every consumer the same record table | 1 chain (46f) | - | iterate the live array instead of a snapshot | 1 chain (46g) | - | render the error outside the `pcall` | 1 chain (46d) | - | accept any Lua number as a priority | 1 chain (46h) | - | make `remove_consumer` a no-op | 2 chain (46g, 46h) | + | register at priority 150 (after pairing) | 2 | + | claim only completed expansions | 2 | + | longest match instead of shortest | 9 | + | equal-length tie keeps the LATER key | 3 | + | remove the eager branch | 8 | + | expand without the terminator in the span | 2 | + | remove the re-arm branch | 1 | + | remove the point-still-at-span-end check | 1 | + | remove the exact-revision check | 1 | + | leave the point where the replace found it | 5 | + | remove the `lean4` language gate | 1 | + | remove the `lean.abbrev` gate | 2 | + | `buffer.after-switch` clears every frontend | 1 | + | delete the `buffer.after-switch` subscriber | 1 | + | `frontend.detached` purges every frontend | 1 | - The first attempt at the last bite was WORTHLESS as written: moving - only `typed_edit.lua` past `lsp.lua` left `pair.lua` calling a nil - `add_consumer`, so the runtime failed to load and all 9 tests died — - loud, but not a test of the flush-ordering property. Moving - `typed_edit.lua` AND `pair.lua` past `lsp.lua` is the faithful - falsification: registration succeeds, the hook lands late, and exactly - the three ordering tests fail. **A bite that kills everything has not - isolated anything.** -- Verification on this branch (commit-then-gate, so this describes the - pushed tree): `cargo fmt --check` clean; strict workspace Clippy - clean; 1,832 default + 2,009 CRDT library tests; auto-pair 45/45; - typed-edit chain 13/13 (and 13/13 again under `--no-default-features - --features lua54`, since the fixes touch `math.huge`, `%`, and - `__tostring` behavior that differs between the backends); M4 121; - required GPU 202; **isolated-config workspace sweep 3,332 across 97 - suites, zero failures** with `grep -c basedpyright` = 0; `git diff - --check` clean. -- Stage 4b (the input method) is NOT in this PR and not started. + Acceptance 45f bit by construction: without a registered window for + the source frontend it ran six fan-outs with a nil record and proved + nothing, because `handle_remote_crdt_op` arms nothing unless the + source's active window displays the buffer. +- Undo is cross-peer-degraded on CRDT frontends and that is ACCEPTED, + named in the module header (Q#LN21): six source-peer optimistic + inserts replaced by one daemon-peer op. `set_round_trip_input` would + fix it and also disables `dispatch_idle`, so RET would stop inserting + a newline. ## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 66ccc25..23108f4 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,7 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-26, after Lean 4 stages 3a and 3b (#167, #170) -landed — pmacs' first Lean language server — following the inline-math +**Last updated: 2026-07-26, after Lean 4 Stage 4a (#179) landed — the +typed-edit consumer chain, the substrate the Unicode input method +registers on — atop stages 3a and 3b (#167, #170), pmacs' first Lean +language server, and following the inline-math slice (#158), the first mathematical typesetting in pmacs, and find-file (#162), the dired arc's Stage 0, and COHERENCE.md (#163), Lean 4 Stage 1 (#160), the minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the @@ -88,9 +90,9 @@ commands, read `docs/active-work.md` immediately after this file. config swap invalidates. The durable lesson is to heal at **consumption** — the point where a stale record is handed out — not at the moment of the swap. - - **Stage 4a (typed-edit consumer chain) is implemented and in review - as PR #179** (branch `lean4-stage4a-typed-edit-chain`, framing rev - 8). It is substrate only: `builtin/runtime/typed_edit.lua` owns the + - **Stage 4a (typed-edit consumer chain) MERGED as #179** (`main` @ + `a27f646`, two review rounds). It is substrate only: + `builtin/runtime/typed_edit.lua` owns the single `buffer.after-edit` subscriber and the single one-shot read, `pair.lua` becomes its first registered consumer, and `tests/auto_pair_acceptance.rs` is unchanged by zero lines @@ -104,10 +106,36 @@ commands, read `docs/active-work.md` immediately after this file. iterates a **snapshot**, because a consumer that registers a lower-priority one shifts itself forward under `ipairs` and runs twice. - - Remaining: Stage 4b (the Unicode input method) is framed and - awaiting approval — not started; stages 5 (goal panel), 6 (`#eval` - output channel), and 7 (module hierarchy) are framed but not - scouted against current `main`. + - **Round 8's durable lesson: `run_all_must_succeed` does NOT abort + the fan-out.** `src/hook.rs:332` collects each callback's error and + continues to the remaining subscribers, marking only the run + failed — so an uncontained throw inside a hook subscriber does not + stop `lsp.lua` from flushing didChange. Two framing revisions + asserted the opposite to justify a `pcall`. The guard was right and + the reason was wrong, and by the time review caught it the wrong + reason had been copied into a module comment, an acceptance + criterion, a test comment, and the ledger. **Correct the source a + rationale derives from, not only the sites that quote it.** + - **Stage 4b (the Unicode input method) is implemented and in review** + (branch `lean4-stage4b-input-method`, framing rev 9): a vendored + 1,855-entry table generated from `leanprover/vscode-lean4@17d1d08` + by `scripts/regen-lean-abbrev`, plus a consumer registered on the + Stage 4a chain at priority 50, ahead of pairing. Its durable facts: + the table must stay an ORDERED SEQUENCE (equal-length ties resolve + by source declaration order, which a `pairs`-iterated map cannot + express); a generator round-trip check must re-read the BYTES ON + DISK, because comparing in-memory strings cannot see an encoding + applied by the write itself; and an expansion that SHRINKS the + buffer must place the point explicitly, or every later self-insert + is silently rejected and the editor looks dead. + - **Round 9 corrected three approved acceptance criteria** by + simulating the state machine over all 1,855 entries rather than + re-reading the prose. Four review rounds over the text had not + found them, because each named an example that reads as obviously + right and is wrong only against the data. + - Remaining: stages 5 (goal panel), 6 (`#eval` output channel), and 7 + (module hierarchy) are framed but not scouted against current + `main`. - **Inline math LANDED — #158** (`docs/inline-math-slice-framing.md` rev 3; merge `5aa9044`). pmacs renders `$…$` as typeset mathematics in the GPU diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index c4a32e5..72a83fd 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -46,7 +46,7 @@ during a rebase. ## 0.1 Revision history -Revision 1 — initial. Current revision: **8**. +Revision 1 — initial. Current revision: **9**. ### Round 1 (rev 1 → rev 2) @@ -503,6 +503,38 @@ documentation cleanups. others — matching §2.11 and Q#LN11. 3. **§9.1's revision label was stale.** It now names rev 8. +### Round 9 (rev 8 → rev 9) + +Found during Stage 4b implementation, by simulating Q#LN22's state +machine over all 1,855 vendored entries and re-reading upstream's +`TrackedAbbreviation.ts` and `AbbreviationProvider.ts` at `17d1d08`. +**Three acceptance criteria named examples that the real table +contradicts** — every one of them written from what the abbreviation +*looks* like rather than from whether the table makes it eager. + +1. **Acceptance 41 was false.** `\to` does not expand eagerly: `to` is a + proper prefix of `top`, `to0`, `toa` and others, so upstream's + `isAbbreviationUniqueAndComplete` is false and `to` is not among the + 1,550 eager keys. The criterion now uses `\alpha`, which has no + extension, and additionally pins that `\to` alone does **not** + expand — the false half is worth an assertion because it reads as + correct until the table is consulted. +2. **Acceptance 42 was false.** `\zzzz` + space yields `ζzzz `, not + literal text: `z` opens a pending abbreviation (`ze`, `zeta`, + `zsqrtd`) and the second `z` finishes it. Exactly six printable + characters open no key — `$ % , ; @ W` — and the criterion now uses + `\WWWW`. +3. **Acceptance 38's undo claim was false for its own example.** + `alpha` is eager, so `\alpha` expands before the space is typed and + the space is a separate edit; one undo removes the space rather than + restoring `\alpha `. The criterion now states the finish path and the + eager path separately, since "one expansion is one undo step" is true + of both while the text an undo restores is not. + +The mechanism (Q#LN11, Q#LN21, Q#LN22) needed no change — these were +errors in the examples chosen to pin it, which is why a simulation over +the real data found them and four review rounds over the prose did not. + ## 1. What ships Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The @@ -2452,12 +2484,22 @@ criterion 46 requires to stay byte-identical. **Stage 4b — the Unicode input method** -38. `\alpha` + space yields `α ` — the space lands first and the - expansion runs in the following `buffer.after-edit`, so the - terminator is **retained**, not consumed. The expansion is a single - undo step: one undo restores `\alpha ` (with its space), not - `\alph`. Rev 6 wrote the post-undo text as `\alpha`, which would be - true only if the terminator were swallowed. +38. **Terminators are retained, and one expansion is one undo step — + but which text an undo restores depends on the path.** Rev 8 stated + a single rule here and it is wrong against the real table, because + it assumed `\alpha` takes the finish path when `alpha` is in the + 1,550-key eager set (round 9; see 41). + - *Finish path.* `\alp` + space yields `α `: the space lands first + and the expansion runs in the following `buffer.after-edit`, so + the terminator is **retained**, not consumed, and it is inside the + replaced span. One undo restores `\alp ` — with its space, not + `\al`. Rev 6 wrote the post-undo text without the terminator, + which would be true only if the terminator were swallowed. + - *Eager path.* `\alpha` yields `α` with no terminator typed, and a + following space is a **separate** edit. One undo removes the + space; a second restores `\alpha`. Asserting the finish-path undo + text here would fail, which is the trap this split exists to + record. 39. `\<>` yields `⟨⟩` with the point between them, from the `$CURSOR` placeholder. 40. **Pair-collision pin (Q#LN22).** `\[[]]` yields `⟦⟧`: each `[` is @@ -2467,9 +2509,21 @@ criterion 46 requires to stay byte-identical. only completed expansions rather than pending extensions — **both failure modes must be shown**, since they are distinct bugs with the same symptom. -41. `\to` yields `→` eagerly on uniqueness, with no terminator typed. -42. A prefix with no match (`\zzzz` + space) is left as literal text; no - edit is made. +41. **Eager expansion on uniqueness**, with no terminator typed: + `\alpha` yields `α` the moment the final `a` lands. Rev 8 used `\to` + here and that is false against the real table (round 9): `to` is a + proper prefix of `top`, `to0`, `toa` and others, so + `isAbbreviationUniqueAndComplete` is false and `to` is **not** in + the 1,550-key eager set. `\to` alone stays `\to`; `\to` + space + yields `→ ` by the finish path. Both are asserted, because the + wrong one reads as correct until the table is consulted. +42. A prefix that opens no key at all — `\WWWW` + space — is left as + literal text and **no edit is made**. Rev 8 used `\zzzz`, which + expands (round 9): `z` opens a pending abbreviation because `ze`, + `zeta` and `zsqrtd` exist, and the second `z` finishes it, giving + `ζzzz `. Exactly six printable characters open no key: `$ % , ; @ + W`. Bites against an implementation that treats "no complete match" + as "no pending state". 43. **Lazy abandonment (Q#LN22).** Because there is no cursor-motion hook, this asserts what pmacs can actually detect: after `\alp`, an explicit `goto_byte` elsewhere followed by typing `h` inserts a @@ -2698,7 +2752,7 @@ uncapped event queue, the dropped `cfg.restart`, and — unchanged from languages other than Lean, and §4's rule is what keeps them out of a Lean PR. -### 9.1 Coherence impact — stages 4a and 4b (rev 8) +### 9.1 Coherence impact — stages 4a and 4b (rev 9) **Sections served.** §6 (interaction islands) primarily, and in the *preventing* direction rather than the fixing one — see below. §11 diff --git a/scripts/regen-lean-abbrev b/scripts/regen-lean-abbrev new file mode 100755 index 0000000..14091aa --- /dev/null +++ b/scripts/regen-lean-abbrev @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Regenerate builtin/runtime/lean_abbrev.lua from vscode-lean4. + +Usage: scripts/regen-lean-abbrev + +Fetches `lean4-unicode-input/src/abbreviations.json` at the given commit +and rewrites the vendored Lua table, including the provenance header, so +the artifact is self-describing to whoever next touches it. A refresh is +an ordinary PR with a visible diff — the diff is the review. + +There is no automatic sync and none is wanted: an editor that silently +re-downloads its input method has a supply-chain problem, not a feature +(docs/lean4-mode-framing.md Q#LN11). + +The emit is an ORDERED SEQUENCE, not a map. Upstream resolves +equal-length abbreviation ties by source declaration order — 101 +prefixes depend on it — and a Lua `{ [key] = symbol }` table iterated +with `pairs` cannot carry that. A map-shaped emit would also be +nondeterministic across builds and, once a hash order happened to be +stable, stably wrong. + +This script ABORTS rather than emitting something plausible when the +source is corrupt: a duplicate key after decoding (JSON permits them, +the table must not), a key or symbol that is not well-formed UTF-8, or a +round-trip mismatch. That last check re-parses the script's own output +with an independent unescaper and compares the full ordered sequence to +the source, entry for entry. It is what makes the vendored file +trustworthy, and it belongs here rather than in the acceptance suite: +the suite cannot see `abbreviations.json`, which is not shipped. +""" + +import json +import pathlib +import sys +import urllib.request + +REPO = "leanprover/vscode-lean4" +PATH = "lean4-unicode-input/src/abbreviations.json" +LICENSE = "Apache-2.0" +OUT = pathlib.Path(__file__).resolve().parent.parent / "builtin/runtime/lean_abbrev.lua" + +# Canonical, lossless, byte-deterministic. Rev 6 of the framing said the +# generator should abort on "a key containing a character the emitted Lua +# would have to escape"; that rule rejects the real table, where `\` is a +# key and `"` begins eleven of them. +SHORT = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"} + + +def die(msg): + print(f"regen-lean-abbrev: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def lua_escape(s): + """Escape one string for a Lua double-quoted literal. + + Operates on CHARACTERS, not bytes. Decomposing to UTF-8 bytes and + emitting each as `chr(byte)` produces a latin-1-shaped string that + `write_text(..., encoding="utf-8")` then re-encodes — every + non-ASCII symbol lands in the file double-encoded, and a round-trip + check that compares in-memory strings agrees with itself and misses + it entirely. Only control bytes, which are single-byte by + definition, become `\\ddd`. + """ + out = [] + for ch in s: + if ch in SHORT: + out.append(SHORT[ch]) + elif ord(ch) < 0x20 or ord(ch) == 0x7F: + out.append(f"\\{ord(ch):03d}") + else: + out.append(ch) + return "".join(out) + + +def lua_unescape(s): + """Independent reader for the round-trip check. + + Deliberately not the inverse of `lua_escape` sharing its table: a + check that reuses the encoder's own assumptions cannot detect that + those assumptions are wrong. + """ + out = bytearray() + i = 0 + raw = s.encode("utf-8") + while i < len(raw): + b = raw[i] + if b != ord("\\"): + out.append(b) + i += 1 + continue + i += 1 + if i >= len(raw): + die("round-trip: trailing backslash in emitted string") + nxt = chr(raw[i]) + if nxt in ("\\", '"'): + out.append(ord(nxt)) + i += 1 + elif nxt in ("n", "r", "t"): + out.append({"n": 10, "r": 13, "t": 9}[nxt]) + i += 1 + elif nxt.isdigit(): + digits = "" + while i < len(raw) and chr(raw[i]).isdigit() and len(digits) < 3: + digits += chr(raw[i]) + i += 1 + out.append(int(digits)) + else: + die(f"round-trip: unknown escape \\{nxt} in emitted string") + return out.decode("utf-8") + + +def main(): + if len(sys.argv) != 2: + die(f"usage: {sys.argv[0]} ") + commit = sys.argv[1] + url = f"https://raw.githubusercontent.com/{REPO}/{commit}/{PATH}" + + with urllib.request.urlopen(url, timeout=60) as resp: + raw = resp.read() + + try: + raw.decode("utf-8") + except UnicodeDecodeError as e: + die(f"source is not well-formed UTF-8: {e}") + + # `object_pairs_hook` keeps declaration order AND exposes duplicate + # keys, which a plain dict would silently collapse. + pairs = json.loads(raw, object_pairs_hook=lambda kv: kv) + + seen = {} + for i, (key, symbol) in enumerate(pairs): + if key in seen: + die(f"duplicate key {key!r} at entries {seen[key]} and {i}") + seen[key] = i + for label, s in (("key", key), ("symbol", symbol)): + if not isinstance(s, str): + die(f"{label} at entry {i} is not a string: {s!r}") + try: + s.encode("utf-8") + except UnicodeEncodeError as e: + die(f"{label} at entry {i} is not well-formed UTF-8: {e}") + + cursor = sum(1 for _, v in pairs if "$CURSOR" in v) + for i, (key, symbol) in enumerate(pairs): + if symbol.count("$CURSOR") > 1: + die(f"symbol for {key!r} at entry {i} has more than one $CURSOR") + + body = "".join( + f' {{ "{lua_escape(k)}", "{lua_escape(v)}" }},\n' for k, v in pairs + ) + text = HEADER.format( + repo=REPO, + path=PATH, + commit=commit, + license=LICENSE, + count=len(pairs), + cursor=cursor, + bytes=len(raw), + script=pathlib.Path(sys.argv[0]).name, + ) + "pmacs.lean_abbrev = {\n" + body + "}\n" + + # Round-trip against the BYTES ON DISK, not the string in memory. + # The file is staged beside its destination, re-read, parsed, and + # only renamed into place once it compares equal entry for entry. A + # check that compares in-memory strings cannot see an encoding + # applied by the write itself, which is exactly how a + # double-encoding bug survived the first version of this script. + staged = OUT.with_suffix(".lua.staged") + staged.write_text(text, encoding="utf-8") + on_disk = staged.read_bytes().decode("utf-8") + + got = [] + # `str.splitlines()` is WRONG here: it also splits on U+2028, U+2029, + # U+0085 and the vertical-tab family, and 53 symbols in the real + # table contain one of those literally. It silently loses entries and + # the round-trip then reports a count mismatch that is the checker's + # bug, not the emit's. The emitted file's line structure is defined + # by the LF we write, and nothing else. + for line in on_disk.split("\n"): + line = line.strip() + if not line.startswith('{ "') or not line.endswith("},"): + continue + inner = line[1:-2].strip() + if not (inner.startswith('"') and inner.endswith('"')): + die(f"round-trip: unparsable emitted line: {line!r}") + fields, buf, esc, depth = [], [], False, 0 + for ch in inner: + if esc: + buf.append(ch) + esc = False + elif ch == "\\": + buf.append(ch) + esc = True + elif ch == '"': + depth += 1 + if depth % 2 == 0: + fields.append("".join(buf)) + buf = [] + elif depth % 2 == 1: + buf.append(ch) + if len(fields) != 2: + die(f"round-trip: expected 2 fields, got {len(fields)}: {line!r}") + got.append((lua_unescape(fields[0]), lua_unescape(fields[1]))) + + def fail(msg): + staged.unlink(missing_ok=True) + die(msg) + + if len(got) != len(pairs): + fail(f"round-trip: emitted {len(got)} entries, source has {len(pairs)}") + for i, (want, have) in enumerate(zip(pairs, got)): + if tuple(want) != have: + fail(f"round-trip: entry {i} differs: source {want!r} vs emitted {have!r}") + + staged.replace(OUT) + print( + f"wrote {OUT} — {len(pairs)} entries from {REPO}@{commit} " + f"({len(raw)} source bytes, {OUT.stat().st_size} emitted bytes), " + "round-trip verified against the bytes on disk" + ) + + +HEADER = """\ +-- lean_abbrev.lua --- VENDORED DATA. Do not edit by hand. +-- +-- The Lean 4 abbreviation table, generated from: +-- +-- repo: https://github.com/{repo} +-- path: {path} +-- commit: {commit} +-- license: {license} +-- entries: {count} ({cursor} carry $CURSOR) +-- source: {bytes} bytes +-- +-- Regenerate with: +-- +-- scripts/{script} {commit} +-- +-- An ORDERED SEQUENCE, not a map: upstream resolves equal-length ties +-- by source declaration order (101 prefixes depend on it), and a +-- `pairs`-iterated Lua map cannot express that. The file's own line +-- order is the audit trail. Consumers must not reorder it. +-- +-- Not fetched at runtime and not a package dependency: the input method +-- has to work offline and on first launch. Upkeep is a documented +-- manual process — see docs/lean4-mode-framing.md Q#LN11. + +pmacs = pmacs or {{}} + +""" + +if __name__ == "__main__": + main() diff --git a/src/daemon.rs b/src/daemon.rs index 84716eb..e34a6d6 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3964,6 +3964,143 @@ mod tests { ); } + /// Arc 8 Stage 4b acceptance 45f: the Lean abbreviation expander + /// works on the OPTIMISTIC producer, not only on `dispatch_key`. + /// + /// This is the path most users take and the one no other Stage 4b + /// test covers. `classify_key` (`src/optimistic.rs`) returns + /// `Insert(c)` for `\` and for every ASCII letter — only the nine + /// built-in pair chars are excluded (Q#AP1) — so on a CRDT frontend + /// `\alpha` arrives here as six source-peer optimistic inserts, + /// while the expansion is a single daemon-peer replace spanning all + /// six. That asymmetry is the accepted undo degradation of Q#LN21; + /// what this pins is that the expansion happens at all. + /// + /// It lives in `--lib` deliberately: the gate list runs + /// `--features crdt` only for `cargo test --lib`, so a crdt-gated + /// INTEGRATION test would be dark in CI and dark in the gates both. + /// + /// The source frontend needs a REGISTERED WINDOW on the edited + /// buffer or nothing is armed at all — `handle_remote_crdt_op` + /// arms the record only when the source's active window displays + /// the buffer, so a source with no view fails closed and silently. + /// A version of this test without the view below passed six + /// fan-outs with a nil record and proved nothing. + #[cfg(feature = "crdt")] + #[test] + fn the_optimistic_producer_also_expands_a_lean_abbreviation() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + use crate::window::{FrontendView, Layout, Window, WindowId}; + + let dir = std::env::temp_dir().join(format!("pmacs-lean-opt-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let path = dir.join("a.lean"); + std::fs::write(&path, "").expect("write fixture"); + + let source = FrontendId(77); + let mut editor = EditorState::new(); + editor + .lua_host + .eval(Some("test"), "pmacs.lsp.config = {}") + .expect("clear lsp config"); + editor + .lua_host + .eval( + Some("test-open"), + &format!( + "pmacs.buffer.find_or_open({:?}); pmacs.editor.goto_byte(0)", + path.display().to_string() + ), + ) + .expect("open the lean fixture"); + + let buffer_id = editor.core.borrow().active_window().buffer_id; + { + let mut core = editor.core.borrow_mut(); + let mut reg = core.registry.borrow_mut(); + reg.get_mut(buffer_id) + .expect("active buffer") + .upgrade_to_crdt(2) + .expect("upgrade to crdt"); + drop(reg); + + // The replica's own window on the shared buffer. + let text_view = { + let registry = core.registry.clone(); + let reg = registry.borrow(); + crate::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let win_id = WindowId::next(); + core.windows + .insert(win_id, Window::new(win_id, buffer_id, text_view)); + core.register_frontend_view( + source, + FrontendView { + layout: Layout::single(win_id), + active: win_id, + fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + } + + let snapshot_bytes = { + let core = editor.core.borrow(); + let reg = core.registry.borrow(); + reg.get(buffer_id) + .expect("buffer") + .crdt_state() + .expect("crdt-backed") + .export_snapshot() + .expect("export snapshot") + }; + let peer = loro::LoroDoc::new(); + peer.set_peer_id(77).expect("set peer id"); + peer.import(&snapshot_bytes).expect("import snapshot"); + + // One op per keystroke, exactly as the attach loop's + // optimistic-apply branch produces them. + for (i, ch) in "\\alpha".chars().enumerate() { + let v_before = peer.oplog_vv(); + peer.get_text("body") + .insert(i, &ch.to_string()) + .expect("peer insert"); + let op_bytes = peer + .export(loro::ExportMode::updates(&v_before)) + .expect("export op"); + super::handle_remote_crdt_op( + &mut editor, + source, + buffer_id, + crate::rope::CrdtOp { + peer_id: 77, + bytes: op_bytes, + }, + ); + } + + let text = match editor + .lua_host + .eval( + Some("test-readback"), + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ) + .expect("read buffer text") + { + mlua::Value::String(s) => String::from_utf8_lossy(&s.as_bytes()).into_owned(), + other => panic!("expected buffer text, got {other:?}"), + }; + assert_eq!( + text, "α", + "the abbreviation expanded on the optimistic path — the \ + record the expander reads is armed by handle_remote_crdt_op, \ + not only by dispatch_key" + ); + } + /// Q#AI9 (PR #109 round 1): the optimistic-apply arm clears an /// EMPTY anchor on the source window — the GPU always takes this /// path, and the TUI attach mirror tracks no selection state, so diff --git a/src/editor.rs b/src/editor.rs index a19971e..3b3b274 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -445,6 +445,27 @@ impl EditorState { include_str!("../builtin/runtime/pair.lua"), ) .expect("load pair builtin chunk"); + // Arc 8 Stage 4b: the Lean 4 Unicode input method. The vendored + // abbreviation table first — lean_input.lua reads it at chunk + // load to build its prefix and eager-key indexes. Both load + // after typed_edit.lua, which they register into. + // + // Load order does NOT decide whether abbreviation expansion or + // auto-pairing sees a keystroke first — the chain's priority + // does (50 vs 100), which is why Stage 4a exists. It matters + // only that the chain itself is already there. + lua_host + .eval( + Some("@pmacs/builtin/runtime/lean_abbrev.lua"), + include_str!("../builtin/runtime/lean_abbrev.lua"), + ) + .expect("load lean_abbrev builtin chunk"); + lua_host + .eval( + Some("@pmacs/builtin/runtime/lean_input.lua"), + include_str!("../builtin/runtime/lean_input.lua"), + ) + .expect("load lean_input builtin chunk"); lua_host .eval( Some("@pmacs/builtin/runtime/lsp.lua"), diff --git a/tests/lean_input_acceptance.rs b/tests/lean_input_acceptance.rs new file mode 100644 index 0000000..01ee89e --- /dev/null +++ b/tests/lean_input_acceptance.rs @@ -0,0 +1,678 @@ +//! Lean 4 Unicode input method acceptance (Arc 8 Stage 4b, +//! docs/lean4-mode-framing.md Q#LN11/Q#LN21/Q#LN22, criteria 38–45i). +//! +//! Dispatch-driven throughout: `dispatch_key` is the producer that arms +//! the typed-edit record for a grid frontend. The optimistic CRDT +//! producer is criterion 45f and lives in a `--lib` test, where the gate +//! list's `--features crdt` run reaches it. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; +use pmacs::window::{FrontendView, Layout, Window, WindowId}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn fresh_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-leaninput-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +fn 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() +} + +fn type_as(s: &mut EditorState, fid: FrontendId, chars: &str) { + for ch in chars.chars() { + s.dispatch_key(fid, key(KeyCode::Char(ch))); + } +} + +fn type_str(s: &mut EditorState, chars: &str) { + type_as(s, FrontendId::LOCAL, chars); +} + +/// An editor with an empty `.lean` file open and the point at 0. +/// `pmacs.lsp.config = {}` keeps the real user config from spawning a +/// server; the language still resolves from the extension. +fn lean_editor() -> (EditorState, PathBuf) { + let dir = fresh_dir(); + let f = dir.join("a.lean"); + std::fs::write(&f, "").unwrap(); + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + let fd = f.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + assert_eq!( + eval::>( + &s, + "return pmacs.lsp.buffer_language(pmacs.window.buffer())" + ) + .as_deref(), + Some("lean4"), + "the fixture must actually be a lean4 buffer, or every \ + expansion assertion below is vacuous" + ); + (s, f) +} + +// --------------------------------------------------------------------------- +// 38 / 41 — the two expansion paths, and what an undo restores +// --------------------------------------------------------------------------- + +#[test] +fn the_finish_path_retains_the_terminator_in_one_undo_step() { + // `alp` is not a key; `alpha` is the shortest key extending it. The + // space does not extend anything, so it lands first and the + // expansion replaces the whole span INCLUDING the terminator. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp "); + assert_eq!(text(&s), "α ", "terminator retained, not consumed"); + + exec(&s, "pmacs.window.buffer():undo()"); + assert_eq!( + text(&s), + "\\alp ", + "one undo restores the pre-expansion text WITH its terminator — \ + the expansion is a single edit" + ); +} + +#[test] +fn the_eager_path_takes_no_terminator_and_undoes_separately() { + // `alpha` has no longer key extending it, so it is one of the 1,550 + // eager keys: it expands the moment the final `a` lands, and a + // following space is a SEPARATE edit. Rev 8 asserted the finish-path + // undo text for this example, which is the trap (round 9). + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "α", "eager expansion, no terminator typed"); + + type_str(&mut s, " "); + assert_eq!(text(&s), "α "); + exec(&s, "pmacs.window.buffer():undo()"); + assert_eq!(text(&s), "α", "the first undo removes the separate space"); + exec(&s, "pmacs.window.buffer():undo()"); + assert_eq!(text(&s), "\\alpha", "the second undoes the expansion"); +} + +#[test] +fn to_is_not_eager_because_longer_keys_extend_it() { + // The criterion rev 8 got wrong: `to` looks unique and is not. + // `top`, `to0`, `toa` and others extend it, so it needs a + // terminator. Bites against an eager rule that tests only "is this + // a key" without asking whether anything extends it. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\to"); + assert_eq!(text(&s), "\\to", "no expansion without a terminator"); + + type_str(&mut s, " "); + assert_eq!(text(&s), "→ ", "the finish path then resolves it"); +} + +// --------------------------------------------------------------------------- +// 39 — $CURSOR +// --------------------------------------------------------------------------- + +#[test] +fn the_cursor_placeholder_places_the_point_between_the_symbols() { + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\<>"); + assert_eq!(text(&s), "⟨⟩"); + // The placeholder is a point position, not a literal: typing lands + // between the brackets. + type_str(&mut s, "x"); + assert_eq!(text(&s), "⟨x⟩", "$CURSOR left the point inside"); +} + +// --------------------------------------------------------------------------- +// 40 — the pair collision +// --------------------------------------------------------------------------- + +#[test] +fn a_pending_abbreviation_is_never_corrupted_by_auto_pairing() { + // 64 keys contain a `lean4` pair-set character. Two DISTINCT bugs + // produce the same symptom here, so both are asserted: pairing + // running first, and a consumer that claims only completed + // expansions (which would hand each intermediate `[` to pairing). + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\["); + assert_eq!( + text(&s), + "\\[", + "the intermediate `[` was CLAIMED — pairing inserted no `]`, \ + which is what keeps `\\[[]]` reachable" + ); + + type_str(&mut s, "[]]"); + assert_eq!(text(&s), "⟦⟧", "the full key resolves"); +} + +#[test] +fn a_pair_character_outside_a_pending_abbreviation_still_pairs() { + // The other direction: claiming extensions must not disable pairing + // in Lean buffers generally. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "["); + assert_eq!(text(&s), "[]", "ordinary auto-pairing is untouched"); +} + +// --------------------------------------------------------------------------- +// 42 — a prefix that opens nothing +// --------------------------------------------------------------------------- + +#[test] +fn a_prefix_that_opens_no_key_is_left_literal_with_no_edit() { + // `W` is one of exactly six printable characters that begin no key + // (`$ % , ; @ W`). Rev 8 used `\zzzz`, which expands — `ze`, `zeta` + // and `zsqrtd` exist (round 9). + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\WWWW "); + assert_eq!(text(&s), "\\WWWW ", "literal text, no expansion"); +} + +#[test] +fn a_prefix_with_no_complete_match_still_expands_its_best_prefix() { + // The case rev 8 mistook for "no match": `z` DOES open a pending + // abbreviation, and the second `z` finishes it. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\zzzz "); + assert_eq!(text(&s), "ζzzz ", "`z` resolved through `ze`"); +} + +// --------------------------------------------------------------------------- +// 43 — lazy abandonment +// --------------------------------------------------------------------------- + +#[test] +fn moving_the_point_away_abandons_the_pending_abbreviation() { + // There is no cursor-motion hook, so the pending record is + // validated at the NEXT typed edit: the point must still be at the + // end of the pending span. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp"); + exec(&s, "pmacs.editor.goto_byte(0)"); + type_str(&mut s, "h"); + assert_eq!(text(&s), "h\\alp", "the `h` landed as plain text"); + + // The keystroke that makes abandonment OBSERVABLE. Asserting only + // the line above proves nothing: claiming an extension makes no + // edit, so a record that wrongly survived would look identical + // here. If `h` had extended the record to `alph`, this `a` + // completes `alpha` and eagerly expands — over a span whose offsets + // are now stale by one. + type_str(&mut s, "a"); + assert_eq!( + text(&s), + "ha\\alp", + "`\\alp` is still literal: the record was dropped when the \ + point left the end of its span, not carried along" + ); +} + +#[test] +fn switching_buffers_clears_pending_state_eagerly() { + let (mut s, f) = lean_editor(); + let dir = fresh_dir(); + let other = dir.join("b.lean"); + std::fs::write(&other, "").unwrap(); + let od = other.display().to_string(); + let fd = f.display().to_string(); + + // Open the second buffer FIRST, then come back. `find_or_open` + // fires `buffer.after-switch` only on the already-open branch — a + // fresh load fires `buffer.after-load` instead, and its own insert + // fires a record-less `buffer.after-edit`. Without this warm-up the + // test passes through the nil-record path and pins nothing about + // switching: deleting the after-switch subscriber leaves it green. + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + + type_str(&mut s, "\\alph"); + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + + type_str(&mut s, "a"); + assert_eq!( + text(&s), + "\\alpha", + "without the switch this would have eagerly expanded to α; \ + `buffer.after-switch` cleared the record" + ); +} + +// --------------------------------------------------------------------------- +// 44 / 45 — the setting and the language gate, both on the SOURCE buffer +// --------------------------------------------------------------------------- + +#[test] +fn disabling_the_setting_stops_expansion() { + let (mut s, _f) = lean_editor(); + exec(&s, "pmacs.config.set('lean.abbrev', false)"); + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "\\alpha", "no expansion when disabled"); + + exec(&s, "pmacs.config.set('lean.abbrev', true)"); + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, " \\alpha"); + assert_eq!(text(&s), "\\alpha α", "and it comes back live"); +} + +#[test] +fn the_setting_is_read_against_the_typed_edits_source_buffer() { + // A buffer-local override must not follow the user to another + // buffer of the same language — the `editing.auto-pair` precedent, + // including its round-2 correction to resolve `rec.buffer` rather + // than `pmacs.window.buffer()`. + let (mut s, f) = lean_editor(); + let dir = fresh_dir(); + let other = dir.join("b.lean"); + std::fs::write(&other, "").unwrap(); + + exec( + &s, + "pmacs.config.set_local(pmacs.window.buffer(), 'lean.abbrev', false)", + ); + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "\\alpha", "disabled in THIS buffer"); + + let od = other.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "α", "a second lean buffer is unaffected"); + + let fd = f.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + assert_eq!(text(&s), "\\alpha", "and the first is still disabled"); +} + +#[test] +fn no_abbreviation_state_is_opened_outside_a_lean_buffer() { + let dir = fresh_dir(); + let f = dir.join("a.rs"); + std::fs::write(&f, "").unwrap(); + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + let fd = f.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + let mut s = s; + + type_str(&mut s, "\\alpha"); + assert_eq!(text(&s), "\\alpha", "no expansion in Rust"); + + // And the leader opened nothing, so `[` still pairs normally. + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "\\["); + assert_eq!( + text(&s), + "\\alpha\\[]", + "`\\[` in a Rust buffer pairs — the input method never armed" + ); +} + +// --------------------------------------------------------------------------- +// 45a / 45b / 45c / 45d / 45e — resolution rules +// --------------------------------------------------------------------------- + +#[test] +fn the_shortest_key_wins_not_the_longest() { + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp "); + assert_eq!(text(&s), "α ", "`alp` resolves through `alpha`"); + + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "\\al "); + assert_eq!( + text(&s), + "α ∀ ", + "`al` resolves through `all` (3) — NOT `alpha` (5). A \ + longest-match or unique-match-only rule passes the first \ + assertion and fails this one" + ); +} + +#[test] +fn an_unmatchable_tail_is_appended_not_dropped() { + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp7 "); + assert_eq!( + text(&s), + "α7 ", + "`7` finished `alp`; it is kept, not swallowed, and the whole \ + abbreviation is not abandoned" + ); +} + +#[test] +fn there_is_no_terminator_list() { + // `'+ '` is a key — a trailing SPACE is part of it. Bites against + // any hardcoded space/tab/RET terminator set. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\+ "); + assert_eq!(text(&s), "⊹", "the space EXTENDED rather than terminating"); +} + +#[test] +fn a_doubled_backslash_yields_one_literal_backslash() { + // Not a terminator case: the pending text is empty, `\` is itself a + // key, and it extends-and-eagerly-matches. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\\\"); + assert_eq!(text(&s), "\\", "one literal backslash"); + + // ...and no pending state was left open, so an ordinary letter is + // an ordinary letter. + type_str(&mut s, "n"); + assert_eq!(text(&s), "\\n", "two characters, not a newline"); +} + +#[test] +fn a_terminating_backslash_re_arms_as_a_new_leader() { + // `al` is NOT eager, so its pending record is still open when the + // second `\` arrives: the `\` terminates it, the expansion runs, + // and the same `\` must then open a fresh abbreviation. + // + // The framing's own example — `\alpha\to` — does NOT exercise this + // branch: `alpha` is eager, so the record is already closed and the + // `\` is handled by the ordinary open-a-leader path. It passes with + // the re-arm branch deleted, which is why the non-eager case is the + // one asserted first. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\al\\to "); + assert_eq!( + text(&s), + "∀→ ", + "the terminating `\\` expanded `al` AND opened a new \ + abbreviation at its own position" + ); + + // The criterion's example still holds, by the other route. + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "\\alpha\\to "); + assert_eq!(text(&s), "∀→ α→ "); +} + +#[test] +fn an_inserted_backslash_does_not_re_arm() { + // `setminus` expands to a literal `\`. That backslash is a + // programmatic replace, which arms no typed-edit record — so it + // opens no pending abbreviation. Bites against a future consumer + // that infers pending state from buffer text instead of provenance. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\setminus"); + assert_eq!(text(&s), "\\", "expanded to a literal backslash"); + + type_str(&mut s, "n"); + assert_eq!( + text(&s), + "\\n", + "the letter after it is a plain letter — the INSERTED backslash \ + armed nothing" + ); +} + +// --------------------------------------------------------------------------- +// 45h — the tie-break by source declaration order +// --------------------------------------------------------------------------- + +#[test] +fn equal_length_candidates_break_by_source_declaration_order() { + // `f<` and `f>` are both length 2. `f<` is declared first, so `\f` + // resolves to `‹`. This is the criterion that bites a map-shaped + // vendored table: with `pairs` iteration it passes or fails by hash + // order. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\f "); + assert_eq!(text(&s), "‹ ", "`f<` wins over `f>` by source order"); + + exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())"); + type_str(&mut s, "\\\" "); + assert_eq!( + text(&s), + "‹ Ä ", + "`\"A` is the first of eleven equal-length candidates" + ); +} + +#[test] +fn reversing_the_vendored_sequence_reverses_the_tie() { + // The falsification 45h requires: run the same resolution against a + // deliberately reversed sequence and show it changes. If this did + // NOT change, the tie-break would not be reading source order at + // all and the assertion above would be passing by luck. + let (s, _f) = lean_editor(); + let forward: String = eval(&s, "return pmacs.lean_input._resolve('f')"); + assert_eq!(forward, "‹"); + + let reversed: String = eval( + &s, + " + local seq = pmacs.lean_abbrev + local rev = {} + for i = #seq, 1, -1 do rev[#rev + 1] = seq[i] end + -- Resolve `f` the way the module does, over the reversed order. + local best = nil + for i = 1, #rev do + local k, v = rev[i][1], rev[i][2] + if k:sub(1, 1) == 'f' then + if best == nil or #k < best.len then best = { sym = v, len = #k } end + end + end + return best.sym + ", + ); + assert_eq!( + reversed, "›", + "reversed source order picks `f>` — the tie really is decided \ + by position in the sequence" + ); +} + +// --------------------------------------------------------------------------- +// 45g — table integrity, limited to what the suite can actually check +// --------------------------------------------------------------------------- + +#[test] +fn the_vendored_table_is_self_consistent() { + // `abbreviations.json` is not shipped, so the suite cannot diff + // against it; the full source-fidelity check belongs to the + // generator, which re-parses its own output from disk. What is + // checkable here are the properties a corrupt emit breaks. + let (s, _f) = lean_editor(); + + let count: i64 = eval(&s, "return #pmacs.lean_abbrev"); + assert_eq!( + count, 1855, + "the declared entry count for the recorded upstream commit" + ); + + let (unique, cursor_ok, utf8_ok): (i64, bool, bool) = eval( + &s, + r#" + local seen, n = {}, 0 + local cursor_ok, utf8_ok = true, true + for i = 1, #pmacs.lean_abbrev do + local k, v = pmacs.lean_abbrev[i][1], pmacs.lean_abbrev[i][2] + if not seen[k] then seen[k] = true; n = n + 1 end + local _, c = v:gsub("%$CURSOR", "") + if c > 1 then cursor_ok = false end + -- A Lua pattern cannot validate UTF-8; check the shape the + -- emitter guarantees instead: no lone continuation byte at the + -- start of a sequence and no truncated tail. + if k:find("[\128-\191]") == 1 then utf8_ok = false end + end + return n, cursor_ok, utf8_ok + "#, + ); + assert_eq!( + unique, 1855, + "every key is unique — a collision would silently drop entries \ + from the derived lookup" + ); + assert!(cursor_ok, "no symbol carries more than one $CURSOR"); + assert!(utf8_ok, "no key begins with a continuation byte"); + + // The resolution spot-set named by 45g. + for (input, want) in [ + ("alpha", "α"), + ("to", "→"), + ("<>", "⟨$CURSOR⟩"), + ("+ ", "⊹"), + ("\\\\", "\\"), + ("n", "\\n"), + ("setminus", "\\"), + ("f", "‹"), + ] { + let got: String = eval(&s, &format!("return pmacs.lean_input._resolve('{input}')")); + assert_eq!(got, want, "resolution of {input:?}"); + } + + // The eager set is the one the state machine branches on. + let alpha_eager: bool = eval(&s, "return pmacs.lean_input._is_eager('alpha')"); + let to_eager: bool = eval(&s, "return pmacs.lean_input._is_eager('to')"); + assert!(alpha_eager, "`alpha` has no extension"); + assert!(!to_eager, "`to` is extended by `top`, `to0`, `toa`, …"); +} + +// --------------------------------------------------------------------------- +// 45i — pending state is per frontend +// --------------------------------------------------------------------------- + +/// Register a second frontend on the SAME buffer, with its own window. +fn attach_frontend(s: &EditorState, fid: FrontendId) -> WindowId { + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let registry = core.registry.clone(); + let reg = registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).unwrap()) + }; + let win_id = WindowId::next(); + core.windows + .insert(win_id, Window::new(win_id, buffer_id, text_view)); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout::single(win_id), + active: win_id, + fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + win_id +} + +const B: FrontendId = FrontendId(9); + +#[test] +fn a_peer_edit_to_the_shared_buffer_abandons_the_pending_record() { + let (mut s, _f) = lean_editor(); + let b_win = attach_frontend(&s, B); + // B sits at the start of the buffer; A types at the end. + s.core.borrow_mut().windows.get_mut(&b_win).unwrap().cursor = 0; + + type_as(&mut s, FrontendId::LOCAL, "\\al"); + type_as(&mut s, B, "p"); + assert!( + text(&s).contains('p'), + "B's keystroke landed as ordinary text rather than extending \ + A's abbreviation, got {:?}", + text(&s) + ); + + type_as(&mut s, FrontendId::LOCAL, "l "); + assert!( + !text(&s).contains('∀'), + "A's record was abandoned: `revision()` is buffer-global, so \ + B's edit invalidates it even though B edited elsewhere. Got {:?}", + text(&s) + ); +} + +#[test] +fn a_peer_buffer_switch_does_not_clear_another_frontends_record() { + let (mut s, f) = lean_editor(); + let dir = fresh_dir(); + let other = dir.join("b.lean"); + std::fs::write(&other, "").unwrap(); + let od = other.display().to_string(); + let fd = f.display().to_string(); + // Warm up both buffers so B's switch takes `find_or_open`'s + // already-open branch, which is the only one that fires + // `buffer.after-switch`. A fresh load fires `buffer.after-load` + // and a record-less edit instead — and that path clears pending + // state for a different reason, which would make this test green + // no matter whose entries the subscriber clears. + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + attach_frontend(&s, B); + + type_as(&mut s, FrontendId::LOCAL, "\\al"); + + // B switches buffers WITHOUT editing the shared buffer. + // Only B moves: the switch is scoped to B's own window, so A's + // window still shows the shared buffer with A's point where it was. + s.core.borrow_mut().active_frontend = B; + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + + type_as(&mut s, FrontendId::LOCAL, "l "); + assert_eq!( + text(&s), + "∀ ", + "`buffer.after-switch` clears only the ACTING frontend's \ + entries — a blanket clear would discard A's half-typed \ + abbreviation" + ); +} + +#[test] +fn detaching_a_frontend_purges_only_its_own_pending_state() { + let (mut s, _f) = lean_editor(); + attach_frontend(&s, B); + + type_as(&mut s, FrontendId::LOCAL, "\\al"); + exec(&s, &format!("pmacs.hook.run('frontend.detached', {})", B.0)); + + type_as(&mut s, FrontendId::LOCAL, "l "); + assert_eq!( + text(&s), + "∀ ", + "B's detachment purged B's entries and left A's record valid" + ); +} From e4c9d652312c9a5d33ea7d74a46851f8377c49d9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:28:04 -0400 Subject: [PATCH 58/91] docs: fix five review-round-5 findings in the landed-doc refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documentation only; no code, no protocol change. All five findings reproduced before fixing. 1. The CRDT-dark census was stale. Re-measured at `fe8b8ba` under CI's exact flags versus the same flags plus `crdt`: 3,170 vs 3,443 — **273 dark, 185 in the library**, not the 264/177 #168 measured at `1b6a084`. The per-target table is regenerated (it gains a `terminal_copy_mode_acceptance` row, acc16e's, from this very arc), the rows are stated to sum to the total, and the lane now says the number moves with every merge and must be re-measured rather than quoted. #168's figure is kept as a dated historical reading with a pointer to the live one. 2. The generated-buffer non-adopter inventory was short by half. It is **four writers, not two**: listview panels (`listview.lua:60-61`), `*compilation*` **and** `*search-results*` — both through `compile.lua`'s shared `ensure_slot`, so naming only the first undercounts a mechanism rather than a buffer — and dired (`dired.lua:371`). All four pair an erroring intercept with `bypass_intercept` writes over a still-writable rope, and all four are emptiable by `M-x buffer.undo`. Corrected in the handoff §4 (as a table, with each writer's shape), `COHERENCE.md` §14, and the framing's deferred-lane text. The adoption estimate gains a consequence: the two `compile.lua` slots append and need a streaming variant; listview and dired are whole-buffer replaces and are the cheap half. 3. The ledger's canonical base still named `a27f646` while the same file recorded #168 and #178. Now `fe8b8ba`, with the recovery check's accept-or-newer floor moved with it — a stale floor is what lets a wrong base pass verification. 4. The completed terminal lane is **removed**, not marked complete. Rule 4 of this file's own update protocol says a lane goes when it merges, and its opening contract says the file records only what has not landed. Its durable facts moved first: a new arc bullet in the handoff §1 (the snapshot materializes, so the dispatch-shadow count stays at six; `prune` reacts to removal rather than causing it; ownership means the handle table, never found-by-name; profiles are a raw Lua table and why the escape cache lives on `TerminalSession`; what criterion 17 must assert when it can finally be written; the `cat -v` echo probe and count-don't-match rule), with the `set_generated_contents` invariant already in §4. A compact entry remains under "Closed since the last snapshot". The gate-run flake the lane carried moved to the CI `crdt`-coverage lane, which owns its discrimination — verbatim, including its explicit refusal to claim a root cause. 5. The refreshed handoff was internally stale: it anchors on a `main` that contains #179 and #165 while still calling both "in review". Both now read MERGED. Dired's durable facts are deliberately **not** absorbed here — that is open PR #169's job, and writing it from two PRs would put two authorities on one text — so the dired lane stays, with a note saying why it survives rule 4 and who removes it. Also recorded while verifying finding 4's new home: the `crdt` Clippy failure on `main` is re-verified with exact sites (four in `src/daemon.rs`, one in `tests/auto_indent_crdt_acceptance.rs`), because any CI job that compiles the `crdt` targets is red on arrival until they are fixed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- COHERENCE.md | 12 +- docs/active-work.md | 381 ++++-------------- docs/agent-handoff.md | 88 +++- docs/terminal-config-and-copy-mode-framing.md | 15 +- 4 files changed, 176 insertions(+), 320 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 0ba2ac8..acd744d 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -1222,14 +1222,20 @@ Primitive-by-primitive against the list above: `compile.lua`'s own comment admits ("command/menu undo stays dispatchable"). `Buffer::set_generated_contents` (write + discard history + assert `read_only`, in one authorized call) now fixes this - for the terminal snapshot; `*compilation*` and listview panels have - not yet adopted it and remain emptiable. **A second half of the same + for the terminal snapshot; **four writers have not yet adopted it and + remain emptiable** — listview panels, `*compilation*`, + `*search-results*` (the same `ensure_slot` mechanism in `compile.lua`), + and dired buffers, all of which pair an erroring intercept with + `bypass_intercept` writes over a still-writable rope. **A second half of the same caveat, found in round 3: a rope write is only half of an edit.** The owner-authorized write must be fanned out to the windows showing the buffer and queued for replica mirrors, or the displaying window keeps a line index describing the previous contents and the next paint indexes the new rope with stale ranges. Adoption is therefore not a - one-line swap. + one-line swap — and the two `compile.lua` slots **append** rather than + replacing wholesale, so they need a streaming variant of the primitive + that does not exist yet. Listview and dired already write whole-buffer + replaces and are the cheap half. - **Diagnostics collection** ✓ — `DiagnosticStore` + signs + unified `error.next` source. - **Transient selector** ✓ — the minibuffer (though its `source` diff --git a/docs/active-work.md b/docs/active-work.md index 0c234ee..ba77bd1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -14,16 +14,17 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `a27f646` (Lean 4 Stage 4a #179 atop bottom-panel - Stage 2A #177, the bottom-panel Stage 2 framing #175, terminal - configuration Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a #167, the - CRDT undo repro #157, the inline-math landed-doc refresh #172, the - bottom-panel landed-doc refresh #156, the inline-math slice #158, - dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 Stage 2 - #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean 4 - Stage 1 #160, and the minimap blank-slab fix #159; protocol v20). The - previous snapshot named `d152120`; the recovery check below accepts it - or anything newer. + `githubsucks/main` @ `fe8b8ba` (terminal copy mode #178 atop the + GPU-terminal-input landed docs #168, Lean 4 Stage 4a #179, + bottom-panel Stage 2A #177, the bottom-panel Stage 2 framing #175, + terminal configuration Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a + #167, the CRDT undo repro #157, the inline-math landed-doc refresh + #172, the bottom-panel landed-doc refresh #156, the inline-math slice + #158, dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 + Stage 2 #161, the dired framing #164, COHERENCE.md #163, find-file + #162, Lean 4 Stage 1 #160, and the minimap blank-slab fix #159; + protocol v20). The previous snapshot named `a27f646`; the recovery + check below accepts it or anything newer. - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -57,7 +58,7 @@ git worktree list git status --short --branch ``` -The `git log` command must expose `d152120` or a newer intentional main. +The `git log` command must expose `fe8b8ba` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. ## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b, 4a MERGED; 4b is next @@ -230,7 +231,13 @@ If it does not, stop and repair the remote/fetch configuration. --check` clean. - Stage 4b (the input method) is NOT in this PR and not started. -## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165) +## Dired lane — Stages 0 and 1 MERGED; Stage 2 framing in review (#171) + +> **Lane retained deliberately.** Rule 4 below removes a lane after +> merge, but its durable facts must reach `docs/agent-handoff.md` first, +> and that absorption is the job of the **open landed-doc PR #169**. +> Writing it from here would put two PRs on the same text. #169 removes +> this lane; the Stage 2 framing PR #171 is still open besides. - Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1 @@ -389,11 +396,16 @@ If it does not, stop and repair the remote/fetch configuration. Every `#[cfg(feature = "crdt")]` test is therefore **not compiled** in CI, not merely skipped. - **Measured, `--list` under CI's exact flags versus the same flags plus - `crdt`: 3,024 vs 3,288 — 264 tests dark.** Per target: + `crdt`: 3,170 vs 3,443 — 273 tests dark.** Re-measured at `fe8b8ba` + (2026-07-26). **The number moves with every merge and must be + re-measured, not quoted.** #168 reported 3,024 vs 3,288 — 264 dark, + 177 in the library — at `1b6a084`; #178 then added CRDT-only + generated-buffer coverage, and other lanes landed CRDT tests in + between. Per target: | dark | CI | full | target | |---:|---:|---:|---| - | 177 | 1,832 | 2,009 | **the library itself** (`src/lib.rs`) | + | 185 | 1,842 | 2,027 | **the library itself** (`src/lib.rs`) | | 21 | 15 | 36 | `m5_5_acceptance` | | 13 | 1 | 14 | `gpu_invocation_acceptance` | | 13 | 1 | 14 | `gpu_initial_target_acceptance` | @@ -405,15 +417,20 @@ If it does not, stop and repair the remote/fetch configuration. | 3 | 0 | 3 | `compile_mode_crdt_acceptance` | | 2 | 22 | 24 | `theme_faces_acceptance` | | 2 | 0 | 2 | `m11_5_semantic_acceptance` | + | 1 | 14 | 15 | `terminal_copy_mode_acceptance` | | 1 | 9 | 10 | `vterm_stage1_acceptance` | | 1 | 7 | 8 | `statusline_segments_acceptance` | | 1 | 10 | 11 | `gpu_font_acceptance` | | 1 | 0 | 1 | `auto_indent_crdt_acceptance` | | 1 | 0 | 1 | `m10_11_perf` | + The rows sum to 273; the table is the whole census, not its head. + - **The single worst line is the library.** `cargo test --lib --features crdt` - is a REQUIRED local gate in `CLAUDE.md`, and CI has never run it. 177 - library tests — the whole CRDT half — are developer-machine-only. + is a REQUIRED local gate in `CLAUDE.md`, and CI has never run it. 185 + library tests — the whole CRDT half — are developer-machine-only, and + that count grows with every merged branch that adds a `crdt`-gated + unit test. - **Ten suites run zero or one test in CI**, including `gpu_initial_target` (#148's entire acceptance, 1/14), `gpu_invocation` (#141's, 1/14), and `a37`, the Vterm Stage 3 real-daemon/real-PTY/real-wgpu path that #135 @@ -467,288 +484,34 @@ If it does not, stop and repair the remote/fetch configuration. - Mitigating fact, verified rather than assumed: #166's three unit pins are **not** `crdt`-gated and do run under CI's exact flags, including the controller-release pin whose only job is catching the plausible wrong fix. - -## Terminal config + copy mode arc — BOTH STAGES MERGED (arc complete) - -- Approved framing: `docs/terminal-config-and-copy-mode-framing.md` - **revision 4** (four review rounds), committed as the first commit of - Stage 1's branch. Two stages, two branches, two PRs; **no protocol - change**. -- **Stage 1 MERGED as #173** (`main` @ `cf54270`, 2026-07-26, one review - round, all twelve checks green). Branch `githubsucks/terminal-config` - and worktree `../pmacs-terminal-config` retained. Profiles, scrollback, - a per-terminal configurable escape key, and the `C-c t` opening - binding; no protocol change. Main was integrated **twice** during the - single review round (`ccf29e3`, then `c93f9ee` after the first merge - left the PR conflicting) — see the no-CI-while-conflicting fact below. -- **Stage 2 MERGED as #178** (`main` @ `fe8b8ba`, 2026-07-26, **four - review rounds**, twelve checks green on head `1b44c69` — verified by - `head_sha`, not by the check summary). Copy mode: - `M-x terminal.copy-mode` / `C-c C-t`. Branch - `githubsucks/terminal-copy-mode` and worktree - `../pmacs-terminal-copy-mode` retained. Main was integrated once, after - #168 landed; the `docs/active-work.md` terminal-lane conflict resolved - by taking main's fuller Stage 1 sentence under this lane's Stage 2 - record. -- **Stage 2 ships eight of nine criteria, and the missing one is named.** - Criterion 17 (a real semantic frontend proving neither daemon buffer - nor mirror mutates) is **not pinned**: the optimistic apply exists only - in `pmacs-gpu/src/main.rs`, and the headless `SemanticClient` every - other semantic test uses has no optimistic path, so a faithful test - must drive the real GPU binary — the `a37` foundation, which CI never - compiles, silently skips without the binary, and is load-sensitive. A - second test on that footing buys the appearance of coverage. Both - halves of the mechanism are pinned **ungated** instead: acceptance 16 - (the guard is armed — `dispatch_idle` false while the snapshot is - focused) and 16b (the daemon holds — `is_read_only()` is **true** at - the rope, so an op that did arrive is refused by `ensure_writable()`). - **Rounds 2-3 changed what 17 must show.** 16b asserted `false` through - round 1, documenting the hazard; round 2 closed it. So the eventual - real-GPU test must look for **mirror mutation plus daemon refusal — - divergence** — not the "mutates both sides, silently" the criterion - originally specified, which after the fix cannot happen and would pass - for the wrong reason. The wire-level half stays an explicit obligation - of the CI `crdt`-coverage lane. -- Load-bearing Stage 2 decisions: - - **The snapshot MATERIALIZES into an ordinary buffer**, so isearch, - motion, selection and the kill ring work with no new substrate, and - "keys must not reach the child" dissolves structurally — the - transport arm keys on `is_terminal(buffer_id)` and a snapshot is not - a terminal. **The dispatch-shadow count stays at six.** - - **One serializer, not two** (Q#TC7): `copy_retained` builds a - whole-range *selection* and hands it to `copy_selection_bytes`. - - **`prune` reacts to removal rather than causing it** — it filters on - `!registry.contains(buffer_id)`, so a child exiting does NOT remove - the terminal buffer. That is why `on_removed` is a sound teardown - hook, and why a finished command's output stays readable. -- **Five bites, five different wrong implementations.** Removing - `set_round_trip_input` fails acceptance 16 **in the default - configuration** (the whole reason that pin is ungated); a naive - independently-written serializer fails all four unit pins, with the - diffs naming each drift mode (broken soft wrap, untrimmed blanks, - trailing newline); making re-invoke create a fresh buffer fails 18; - dropping the kill-with-terminal teardown fails 18; removing the - intercept fails 16b. Each failed exactly one test. -- **Review round 1 — four findings, all real, and they rhyme in pairs.** - Two P1 implementation defects and two P2 vacuous pins, all four tracing - to one root: **a name is not an identity, and a context-free readout is - not a state observation.** - - *P1 — a foreign same-named buffer was adopted and clobbered.* Snapshot - writes use `bypass_intercept`, so found-by-name adoption overwrote a - user's buffer; the reviewer reproduced "do not clobber" becoming 23 - newlines. Fixed by dired's F7 rule: **ownership means "in our own - handle table"**, and a taken name yields a `<2>` variant. - - *P1 — snapshot identity was keyed by terminal NAME.* - `TerminalManager::open` uniquifies only the *derived* name, so an - explicit `name = "*same*"` lets two valid terminals share one; they - then shared a snapshot, `q` returned to the wrong terminal, and - killing either removed it. Now keyed by comparing buffer handles in an - array — `BufferIdLua` implements `__eq` but each wrapper is a distinct - table key, so **comparison works and hashing does not**. - - *P2 — the refresh pins were vacuous.* 19 compared a quiet terminal's - snapshot against itself and 18 counted buffers, so both passed with - `render_snapshot` replaced by a no-op. Now the test types a marker - into the `cat` child, requires it **absent** first, then refreshes. - - *P2 — the tail-follow pin could not observe view state.* - `manager.snapshot(buffer_id)` is context-free and always reads the - live screen, so it reported "at the tail" for a view forced to the - oldest retained row. Now read through `snapshot_for_view`'s - `at_bottom` and projected cells. -- **Four more bites, all discriminating.** Restoring adopt-by-name fails - 18a *and* 18b; restoring name-keyed identity fails 18b; making - `render_snapshot` a no-op fails **both** 18 and 19 (the vacuity, - demonstrated); and forcing the view off the tail fails 20. -- **Review round 2 — one P1, and its fix retires half a named deferral.** - **Undo emptied the "read-only" snapshot.** `render_snapshot` wrote with - `bypass_intercept`, leaving ordinary undo history, and **`Buffer::undo` - reaches the rope through `ensure_writable` without ever consulting the - intercept chain** — so `C-/` *or* `M-x buffer.undo` replaced a freshly - rendered snapshot with an empty buffer. `set_round_trip_input` does not - help: it routes the key into the daemon command path, which is where - undo runs. - - **Rebinding the undo chords would NOT have fixed it**, and - `compile.lua` already says so in a comment — "command/menu undo stays - dispatchable". `*compilation*` and listview panels therefore carry the - same latent defect today. - - Fixed with `Buffer::set_generated_contents` (Lua - `pmacs.buffer.set_generated_contents`): lift `read_only`, replace - skipping intercepts, **discard history**, re-assert `read_only`. This - ships the deferred lane's two halves *as one primitive* — a bare - `set_read_only` would let a caller lock a buffer it can no longer - refresh, which is exactly why that lane was deferred. Clearing history - also stops a periodically refreshed buffer accumulating rope clones - nothing can ever pop. - - New pins: **acc16c** drives the real M-x path - (`command.invoke_interactive`), the chord, and redo, and asserts the - owner's refresh still works; **acc16b** flipped from asserting - `is_read_only()` is *false* to *true*, because the property it - described is the one that was fixed; plus three `buffer.rs` unit tests. - - Bite: restoring the `delete`+`insert` render reproduces the report - exactly — `left: Some("")` against the full snapshot — failing acc16c - and acc16b. - - **Still open:** `*compilation*` and listview remain emptiable by - `M-x buffer.undo`; the primitive they need now exists and is proven, - so the remainder is adoption plus a streaming-friendly variant. -- **Review round 3 — one P1 and two P2s, all on the round-2 primitive.** - The lesson: **a rope write is only half of an edit, and "discard - history" means whichever history the buffer actually has.** - - **P1 — the binding swallowed the edit.** `set_generated_contents` - returned `()`, so nothing called `notify_buffer_edit_to_windows`. - Two consequences, both reproduced by the reviewer: in the default - build a window showing the buffer kept a `TextView` line index - describing the *previous* contents, and the next paint indexed the - new rope with stale ranges — `assertion failed: end <= self.len()` - in `src/rope.rs`; in the CRDT build `pending_crdt_ops` stayed empty, - so replica mirrors never received the owner's write. The prior - `buf:delete`/`buf:insert` pair had done this fan-out for free. - Fixed by applying **one whole-buffer `Replace`**, returning its - `Edit`, and notifying from the binding. - - **P2 — "discard history" was false in CRDT mode.** The v0.1 stacks - are bypassed entirely there; the history lives in loro's - `UndoManager`. `read_only` stops the replay but not the retention, - which is the memory cost the contract claims to eliminate. - `UndoManager` has no `clear`, but needs none — it records only what - happens after construction, the property `CrdtState::from_bytes` - already uses to keep the seed insert out of undo. New - `CrdtState::clear_undo_history` rebinds a fresh manager to the - same doc. - - **P2 — the docs described the pre-fix architecture.** Q#TC6a said no - Lua binding sets `read_only` and round-trip input is the only guard; - the acceptance text still said `is_read_only() == false` while 16b - had been flipped to `true`; `terminal.lua`'s comment repeated the - obsolete claim. The architecture is **layered** and now says so: - rope-level read-only protects the daemon copy, round-trip input - protects the replica's optimistic mirror, and neither substitutes - for the other. Q#TC6a carries a superseded-in-part box rather than - being silently rewritten. - - New pins: **acc16d** paints the window after a *shrinking* generated - write (the stale offsets then point past the end, which is the - reported crash rather than stale pixels); **acc16e** asserts the - refresh is queued for mirrors through the real copy-mode path - (`crdt`-gated, therefore dark in CI — 16d is the half that runs); - plus a CRDT `buffer.rs` unit test that ten renders leave the - `UndoManager` with nothing recorded. - - Bites: dropping the notify panics acc16d at `rope.rs:145` and fails - acc16e with `queued: []`; dropping the `UndoManager` rebind fails - the new unit test on `can_undo`. - - **Still open:** the fan-out obligation makes `*compilation*`/listview - adoption more than a one-line swap — recorded in `COHERENCE.md` §14 - alongside the undo half. -- **Review round 4 — one P2, docs only, and it is the interesting kind.** - **A fix can invalidate a test that was never written.** Criterion 17's - *bite* still described the pre-round-2 world: remove - `set_round_trip_input` and the op "mutates both sides, silently, with - no divergence to notice". True while nothing set `read_only` from Lua; - false once `set_generated_contents` did. A real-GPU test written to - that spec would hunt for a daemon-side edit that can no longer occur - and pass for the wrong reason — the specification would have leaked - the round-2 regression back in, through a test not yet built. - - Restated around **unauthorized mirror mutation plus daemon refusal = - divergence**, in all four places that carried the old claim: the - criterion, the Q#TC6a heading, the acceptance-16 doc comment, and the - bite roster. The heading's "ONLY thing" now says what it is the only - thing *for* — the replica's own mirror. - - Why round-trip input is still load-bearing rather than redundant: a - daemon refusal arrives after the frontend has already applied - optimistically and painted. It buys divergence instead of silent - agreement; it does not prevent the mutation the user sees. - - **Gate-run flake observed and scoped without overclaiming its cause.** - `cargo test --lib --features crdt` failed ~1 run in 5 on - `process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` - — `active_reader_probe` returning `None` at `process.rs:3179` - ("live runtime probe"). **Pre-existing and unrelated:** this branch - does not touch `src/process.rs` (last changed by the Darwin PTY - signal-name fix), and the test passed 10/10 standalone; the observed - failures were during parallel full-suite runs. That localizes the - trigger to suite load or interaction, but does **not** distinguish - parallelism from another full-suite effect — no serial full-suite bite - was run. The leading code-path explanation is the known `drain_until` - trap: draining for `Started` also ticks, and a tick can reap the leader - before the following `active_reader_probe`. That is an inference from - the failure site and control flow, not yet a falsified root cause. - It belongs to the CI `crdt`-coverage lane for discrimination. The two - round-2 CRDT failures had no captured test names; this flake is a - plausible candidate for them, but they remain **unattributed**. -- Load-bearing decisions, each forced by scouted ground truth: - - profiles are a **raw Lua table** — `ConfigValue` is four scalars with - no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`; - - the **two open-time settings resolve through the global chain**, - because they are read before the identity buffer exists; only - `terminal.escape-key` resolves per buffer; - - the escape cache lives on **`TerminalSession`** so its lifetime is - the terminal's. `value_epoch` alone is not a sufficient key: it does - not advance when focus moves between terminals with different - buffer-local values; - - repeating the escape sends **that chord**, not a hardcoded `0x03`. -- **Four bites, each against a different plausible wrong - implementation** — hardcoded ETX fails acc6/9; epoch-only cache key - fails acc7; single last-entry cache fails acc8's parse count; removing - the invalid-value fallback fails acc10. The first version of acc7 - passed against the epoch-only bite because it asserted only that - terminal A still worked; the discriminating assertion is that **each** - terminal honors its own chord and not the other's. -- Test instruments worth reusing: `cat -v` is the echo probe, because the - screen rejects C0 controls before they reach cells so a raw echoed - `Ctrl-X` is invisible; and the probe **counts occurrences** rather than - testing presence, because a single-character probe collides with the - child's own banner text. -- **Review round 1 (2026-07-25) — five findings, all real, all fixed.** - One blocker and two majors were the same failure in three places: a - claim asserted somewhere cheaper than where it lives. - - *Blocker — `COHERENCE.md` was stale in four places, not the three - reported.* Step 8 still read "no keybinding"; §11 still read "five - settings"; and §6's dispatch table still cited - `is_terminal_escape_chord`, **a symbol this PR deletes**. §25 makes - that update ride the PR. A PR that changes audited ground truth has - to re-grep the audit for its own symbols, not only for its topic. - - *Major — acceptance 5 was vacuous.* It asserted a registry - round-trip, so it stayed green with the setting's **only** consumer - deleted. It now opens a real terminal whose child overflows the - 24-row screen, scrolls the view to its oldest retained row, and - asserts `LINE001` is present at 10,000 and absent at 0. **Asserting - a value was stored is not asserting anything reads it.** - - *Major — acceptance 8a asserted the session count, not the cache.* - An editor-side map with no purge hook — the exact rejected design — - leaks *while* sessions drain, so it passed. Fixed with a - `TerminalManager::escape_caches()` seam. **A lifecycle claim needs a - lifecycle observable.** - - *Moderate — `table.sort` over user-controlled profile keys.* A - table holding both a string and a numeric key raised `attempt to - compare number with string` **on the unknown-profile path**, - replacing the diagnostic being asked for; `%q` raised likewise on a - non-string `profile` argument. Both are partial functions applied to - user input **on a diagnostic path** — the error reporter was the - thing that failed. - - *Minor — the committed framing still said "not yet approved".* -- **Three new bites, each falsified by revert**: deleting the scrollback - consumer fails acc5 (and only acc5); restoring the raw-key sort - reproduces `attempt to compare string with number` verbatim; and - implementing the rejected editor-side map fails the new acc8a at - `left: 2, right: 1` **while passing the old session-count version** — - which is the review finding demonstrated rather than argued. -- Verification after the round-1 fixes, on the tree merged with - `githubsucks/main` @ `c93f9ee`: `cargo fmt --check` clean; strict - workspace Clippy clean; 1,832 default + 2,009 CRDT library tests; - `terminal_config_acceptance` **12/12 in both configurations**; vterm - Stage 1/2 9+10 / 6+6; config registry 16+16; bottom-panel Stage 1 - 46+46; M4 121; required GPU 202; `git diff --check` clean. - - `compile_mode_acceptance` fails 11/67 against the **real** user - config and passes 67/67 with an isolated `XDG_CONFIG_HOME` — the - known pre-existing trap, not this branch. - - **`vterm_stage3_acceptance::a37` fails on this machine — and fails - identically on the PR's own base `d152120`**, so it is not this - branch's regression. It is load-sensitive: it passed at `d152120` - once and failed at that same commit twenty minutes later, with a - second agent saturating the machine with `rustc` in between. Two - ways it lies, both worth knowing: it **silently returns `ok` when - `pmacs-gpu` is not built** in the same target dir (only - `PMACS_REQUIRE_GPU=1` promotes that skip to a failure, and the gate - list applies that flag to `-p pmacs-gpu`, a *different* package), and - it is **crdt-gated, so CI has never run it at all**. A green a37 in - a gate log means nothing unless the binary was built and the flag - was set. Needs its own lane; see the CI `crdt`-coverage lane on #168. - - `pmacs-gpu` itself failed 201/202 once under the same load and passed - 202/202 on immediate rerun. +- **This lane also owns a `--lib --features crdt` flake, observed and + scoped without overclaiming its cause** (inherited from #178's gating, + where the terminal lane recorded it). `cargo test --lib --features + crdt` failed ~1 run in 5 on + `process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` + — `active_reader_probe` returning `None` at `process.rs:3179` ("live + runtime probe"). **Pre-existing and unrelated to #178:** that branch + did not touch `src/process.rs` (last changed by the Darwin PTY + signal-name fix), and the test passed 10/10 standalone; the observed + failures were during parallel full-suite runs. That localizes the + trigger to suite load or interaction, but does **not** distinguish + parallelism from another full-suite effect — no serial full-suite bite + was run. The leading code-path explanation is the known `drain_until` + trap: draining for `Started` also ticks, and a tick can reap the leader + before the following `active_reader_probe`. That is an inference from + the failure site and control flow, not yet a falsified root cause. + Discriminating it belongs here. Two unnamed CRDT failures in #178's + round-2 gating are a plausible match but remain **unattributed** — no + test names were captured. +- **A second standing obstacle for this lane:** `cargo clippy --workspace + --all-targets --features crdt -- -D warnings` **fails on `main`** — + re-verified at `fe8b8ba`: four errors in `src/daemon.rs` + (`useless_conversion` at 3996, missing doc backticks at 4076, + `too_many_lines` 112/100 at 4083, an unneeded `mut` at 4965) and one in + `tests/auto_indent_crdt_acceptance.rs:42` (doc backticks). The + standing gate list runs Clippy without `crdt`, so these lints have + never been enforced. Any CI job that compiles the `crdt` targets has to + fix them first or it will be red on arrival. ## Bottom-panel lane (Arc 7) — Stages 1, 2A + framing MERGED; 2B is next @@ -936,6 +699,24 @@ git worktree add --track \ ## Closed since the last snapshot +- **Terminal configuration + copy mode arc — BOTH STAGES MERGED, lane + removed.** Stage 1 **#173** (`main` @ `cf54270`, one review round) and + Stage 2 **#178** (`main` @ `fe8b8ba`, **four review rounds**, twelve + checks green on head `1b44c69` — verified by `head_sha`, not by the + check summary), both 2026-07-26, both with no protocol change. + Approved framing: `docs/terminal-config-and-copy-mode-framing.md` rev + 4, committed as the first commit of Stage 1's branch; its Q#TC6a + carries a superseded-in-part box rather than a silent rewrite. Durable + facts moved to `docs/agent-handoff.md` §1 (the arc bullet) and §4 (the + `set_generated_contents` invariant) per rule 3 below, and to + `COHERENCE.md` §14. **Stage 2 ships eight of nine criteria and the + missing one is named** — criterion 17 needs a real GPU frontend, so it + waits on the `a37` footing; the handoff records what it must assert. + Branches `githubsucks/terminal-config` and + `githubsucks/terminal-copy-mode` with worktrees + `../pmacs-terminal-config` and `../pmacs-terminal-copy-mode` are + retained. The gate-run flake found while gating #178 moved to the CI + `crdt`-coverage lane above, which owns its discrimination. - **GPU terminal input (the double terminal-layout sync) — MERGED as #166** (`main` @ `b889873`, 2026-07-25, one review round, all twelve checks green after a macOS PTY-timing rerun). The dispatcher applied **both** @@ -955,7 +736,9 @@ git worktree add --track \ Branch `gpu-terminal-input` and worktree `../pmacs-gui-term-input` retained. **Its landed-doc pair MERGED as #168** (`main` @ `1b6a084`, 2026-07-26): #166 recorded as landed, the CI `crdt`-coverage gap - measured (**264 tests dark workspace-wide**, 177 in the library), the + measured (**264 tests dark workspace-wide**, 177 in the library — as + of `1b6a084`; the live figure is 273/185 at `fe8b8ba`, and the + coverage lane above is the authority), the vterm audit corrected — "only 3 of 9 acceptances drive a real daemon" was optimistic; without the frontend binary the honest number is **2** — and the a37 findings folded into the coverage lane. diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 7d1b326..883d860 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -61,6 +61,53 @@ commands, read `docs/active-work.md` immediately after this file. interaction islands added, config-registry adoption, background-work attribution. Its §2 grades the golden journey **broken at step 3** (`pmacs .` exits 1). +- **Terminal configuration + copy mode arc — COMPLETE** + (`docs/terminal-config-and-copy-mode-framing.md` rev 4; Stage 1 #173, + Stage 2 #178; no protocol change in either, still v20). Stage 1 ships + profiles, scrollback, a per-terminal configurable escape key and the + `C-c t` opener; Stage 2 ships copy mode — `M-x terminal.copy-mode` / + `C-c C-t`. + - **The snapshot MATERIALIZES into an ordinary buffer.** That is the + arc's organizing decision: isearch, motion, selection and the kill + ring work with no new substrate, and "keys must not reach the child" + dissolves structurally, because the transport arm keys on + `is_terminal(buffer_id)` and a snapshot is not a terminal. **The + dispatch-shadow count therefore stays at six.** + - **`prune` reacts to buffer removal rather than causing it** — it + filters on `!registry.contains(buffer_id)`, so a child exiting does + **not** remove the terminal buffer. That is what makes `on_removed` a + sound teardown hook, and why a finished command's output stays + readable. + - **Ownership means "in our own handle table", never found-by-name** + (dired's F7 rule, re-learned here): snapshot writes use + `bypass_intercept`, so adopting a same-named foreign buffer clobbers + user data. Snapshot identity is keyed by **comparing buffer handles + in an array** — `BufferIdLua` implements `__eq` but each wrapper is a + distinct table key, so comparison works and hashing does not. + - **Profiles are a raw Lua table**, joining `pmacs.lsp.config` and + `pmacs.pair.sets`, because `ConfigValue` is four scalars with no + table kind. The two open-time settings resolve through the **global** + chain (they are read before the identity buffer exists); only + `terminal.escape-key` resolves per buffer, and its cache lives on + **`TerminalSession`** so its lifetime is the terminal's — + `value_epoch` alone is not a sufficient key, because it does not + advance when focus moves between terminals holding different + buffer-local values. + - **Criterion 17 is deliberately unpinned, and its bite is now stated + correctly.** A real semantic frontend proving neither copy is mutated + needs the actual GPU binary (the optimistic apply exists only in + `pmacs-gpu/src/main.rs`; the headless `SemanticClient` has no + optimistic path), i.e. the `a37` footing §5 warns about. After + `set_generated_contents` the eventual test must look for + **unauthorized mirror mutation plus daemon refusal — divergence**, + not the "mutates both sides silently" the criterion originally + specified, which can no longer happen and would pass for the wrong + reason. *A fix can invalidate a test that was never written.* + - Test instruments worth reusing: **`cat -v` is the echo probe**, + because the screen rejects C0 controls before they reach cells so a + raw echoed `Ctrl-X` is invisible; and such probes must **count + occurrences rather than test presence**, because a single-character + probe collides with the child's own banner text. - **Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b LANDED** (`docs/lean4-mode-framing.md`; #160, #161, #167, #170; merge `d400f30`). pmacs edits Lean 4: `arborium-lean` highlighting, a @@ -106,9 +153,9 @@ commands, read `docs/active-work.md` immediately after this file. config swap invalidates. The durable lesson is to heal at **consumption** — the point where a stale record is handed out — not at the moment of the swap. - - **Stage 4a (typed-edit consumer chain) is implemented and in review - as PR #179** (branch `lean4-stage4a-typed-edit-chain`, framing rev - 8). It is substrate only: `builtin/runtime/typed_edit.lua` owns the + - **Stage 4a (the typed-edit consumer chain) MERGED as #179** + (branch `lean4-stage4a-typed-edit-chain`, framing rev 8; it is part + of the `fe8b8ba` anchor above). It is substrate only: `builtin/runtime/typed_edit.lua` owns the single `buffer.after-edit` subscriber and the single one-shot read, `pair.lua` becomes its first registered consumer, and `tests/auto_pair_acceptance.rs` is unchanged by zero lines @@ -184,11 +231,13 @@ commands, read `docs/active-work.md` immediately after this file. against an open buffer yet fails to load one that is not open — find-file expands the tilde Lua-side. Loading through the normalized path is a named deferral. - - **Stage 1 (the directory view) is IN REVIEW as PR #165** — the - builtin `dired.lua`, the per-entry-tolerant `read_dir` opt, and - `pmacs.path.canonicalize`. Its branch state, substrate facts, and - verification live in `docs/active-work.md`; this section absorbs them - when it merges. + - **Stage 1 (the directory view) MERGED as #165** — the builtin + `dired.lua`, the per-entry-tolerant `read_dir` opt, and + `pmacs.path.canonicalize`. Its durable facts have **not** been + absorbed here yet: that is the job of the open landed-doc PR + **#169**, and duplicating it from this PR would put two authorities + on the same text. Until #169 merges, `docs/active-work.md`'s dired + lane remains the record. - Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 = the GPU initial-target semantic bootstrap family). @@ -920,11 +969,24 @@ it lives in loro's `UndoManager`. That has no `clear`, and needs none — a manager records only what happens after construction, so `CrdtState::clear_undo_history` rebinds a fresh one to the same doc. -**Not yet adopted:** `*compilation*` and listview panels still rely on -intercept-plus-`set_round_trip_input` and remain emptiable by -`M-x buffer.undo`. Adoption is not a one-line swap — it inherits the -fan-out obligation, and `*compilation*` appends rather than replacing, so -it needs a streaming variant. Recorded in `COHERENCE.md` §14. +**Not yet adopted — and the inventory is four call sites, not two.** +Every generated buffer outside copy mode still uses the older idiom: +an erroring intercept plus `set_round_trip_input`, written through +`bypass_intercept`, with the rope left writable. All of them are +emptiable by `M-x buffer.undo`: + +| buffer | writer | shape | +|---|---|---| +| listview panels | `builtin/runtime/listview.lua:60-61` | delete-all + insert | +| `*compilation*` | `builtin/runtime/compile.lua` (`ensure_slot`) | **append** per output batch | +| `*search-results*` | same `ensure_slot` mechanism in `compile.lua` | **append** per match batch | +| dired buffers | `builtin/runtime/dired.lua:371` | whole-buffer replace | + +Adoption is not a one-line swap. It inherits the fan-out obligation, and +the two `compile.lua` slots append rather than replacing wholesale, so +they need a **streaming variant** of the primitive; listview and dired +are already whole-buffer replaces and are the cheap half. Recorded in +`COHERENCE.md` §14. **And it does not replace `set_round_trip_input`.** The protection is layered across two copies: rope-level `read_only` refuses the op at the diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md index 15878cf..574d58c 100644 --- a/docs/terminal-config-and-copy-mode-framing.md +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -564,11 +564,16 @@ additive, on its own binding, and does not replace scroll-and-select. would replay, and it stops a periodically refreshed buffer accumulating rope clones that `read_only` guarantees nothing can ever pop. - **What remains of the lane:** `*compilation*` and listview panels still - rely on intercept-plus-round-trip and are still emptiable by - `M-x buffer.undo`. The primitive they need now exists and is proven, so - the remaining work is adoption plus a streaming-friendly variant - (`*compilation*` appends rather than replacing wholesale). + **What remains of the lane — four writers, not two** (corrected in + review round 5, which found the inventory short): listview panels + (`listview.lua:60-61`), `*compilation*` and `*search-results*` (both + through `compile.lua`'s shared `ensure_slot`), and dired buffers + (`dired.lua:371`) all still rely on intercept-plus-round-trip over a + writable rope, and are all still emptiable by `M-x buffer.undo`. The + primitive they need now exists and is proven, so the remaining work is + adoption plus a streaming-friendly variant — the two `compile.lua` + slots append rather than replacing wholesale, while listview and dired + already write whole-buffer replaces and are the cheap half. **The CRDT half is closed too** (review round 3). Clearing the v0.1 stacks proves nothing in CRDT mode, where they are bypassed entirely and From 600b3d0c7dd2519570705fffe9fea47403f20914 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 15:00:17 -0400 Subject: [PATCH 59/91] docs: framing for Journey Stage 1a (directory open on one path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves COHERENCE.md §2 (the golden product journey), §19 (coherence acceptance tests), and §20 Priority 1, which grades the journey broken at step 3 because `pmacs .` exits 1. Stage 1a ships four things: the directory argument routed into dired's buffer on both the local and daemon/GPU startup paths; EditorState::open adopting resolve_target_buffer so the two path-open implementations become one; a scoped-destination commit primitive so an async open lands where it was requested or nowhere; and the first cross-subsystem journey acceptance suite. Framing only -- no implementation. Rev 5 after four review rounds. --- docs/journey-stage1a-framing.md | 913 ++++++++++++++++++++++++++++++++ 1 file changed, 913 insertions(+) create mode 100644 docs/journey-stage1a-framing.md diff --git a/docs/journey-stage1a-framing.md b/docs/journey-stage1a-framing.md new file mode 100644 index 0000000..36f40e4 --- /dev/null +++ b/docs/journey-stage1a-framing.md @@ -0,0 +1,913 @@ +# Journey Stage 1a — open a directory, on one path + +**Status: framing, rev 5, awaiting approval.** +**Serves `COHERENCE.md` §2 (the golden product journey), §19 (coherence +acceptance tests), §20 Priority 1.** + +## 0. Revision history + +- rev 1 (2026-07-26) — first framing. Scouted against `main` @ `d400f30`. +- rev 2 (2026-07-26) — review round 1. Q#JR2 withdrawn (its ground truth + was false); destination pinning added; the resolver chain restructured + around append-only hook registration; `ResolvedTarget` typed; + `display_file`'s contract specified; the GPU-framing supersession named. +- rev 3 (2026-07-26) — review round 2. Two blockers, two contract gaps: + - **Fail-closed was not failure-atomic** (§4.4). dired mutates handle + state — claim, listing, `prev`, paint — *before* it ever attempts + `display`, so rev 2's "does nothing" left a hidden buffer and could + corrupt an existing handle's `prev`. **Per the review's decision, 1a + now carries the destination-scope substrate**: a `commit_to` + primitive that revalidates and enters the captured frontend's scope + *before* any dired mutation, so the whole post-await commit — `prev` + capture, claim, paint, display, seat — executes against the captured + destination or not at all. + - **Expected-buffer validation** (Q#JR14): the destination carries the + buffer it was requested against, so a user who replaces the bootstrap + buffer mid-listing is not overwritten by stale launch intent. Rev 2's + window-only pin said launch intent wins; it should not, and B2's + "before the user can act" was false (§8). + - **Acceptance 6 was still vacuous**, and the blanket "each fails with + the change reverted" rule cannot hold for preservation guards. §6 is + split into new-behavior acceptances and preservation pins, each pin + naming the targeted mutation that falsifies it (§6.0). + - **Hook error policy specified** (Q#JR15): in a short-circuit hook a + raise and a `false` both yield `proceed = false` (`hook.rs:299-323`); + only `HookOutcome.errors` distinguishes them. An error now stops the + chain *and* suppresses the fallback. + - The fallback slot is described honestly as an **unowned singleton** + (§0.5), not an "ownership-carrying registration". +- rev 4 (2026-07-26) — review round 3. Three substrate details and one + inverted bite mutation: + - **`InteractiveCommandOrigin` was the wrong mechanism, twice over** + (§2.11). It does not scope the APIs rev 3 claimed — no-arg + `pmacs.window.buffer()` reads `core.active_buffer_id()` directly + (`mod.rs:12547`) and `move_to_line` mutates the core's ambient active + window (`mod.rs:12703`) — so `prev` capture and cursor seating stayed + ambient. And it is *authenticated interactive-command authority*: + entering it would make dired's `paint` satisfy the pre-edit unfold + guard (`mod.rs:1391`), `invoke_interactive`'s rotation (`:5400`), and + terminal command context (`:8515`). Rev 4 uses a **separate scoped + frontend override** that also swaps `core.active_frontend`, and + `commit_to` does not touch the interactive origin (Q#JR14e). + - **`dest` becomes nonconstructible userdata** (Q#JR14d). As a table it + is shared across hook listeners, so an earlier listener could mutate + the destination and decline — redirecting later listeners or dired — + and any Lua could fabricate a valid triple. + - **Preflight was missing replaceability** (Q#JR14f). Exact display + also refuses a window dedicated to another buffer + (`editor_core.rs:3566`), so a live destination holding its expected + buffer could still refuse *after* dired claimed and painted — rev 2's + hidden-buffer failure through another door. + - **N8's falsifier was inverted** (§6.1). Both a claim and a raise give + `proceed == false`, so keying the fallback on `proceed` alone is + *correct*; `errors` decides the extra report, not the fallback. +- rev 5 (2026-07-26) — review round 4. The remaining predicate input and + acceptance details: + - **Replaceability now names the incoming buffer** (Q#JR14f). The + shared predicate takes `Option` and serves all three existing + consumers: exact display passes its requested buffer, + `probe_display_target` passes its existing-buffer result, and + `commit_to` passes `None` because dired's replacement does not exist + yet. Thus a destination dedicated to its still-current bootstrap + buffer is refused before dired mutates anything. + - **N6c is executable:** the first listener catches the userdata + mutation rejection and declines, the second verifies the token stayed + unchanged and declines, and only the fallback commits. + - The dired accessor spelling, revision heading, and Stage 2 ledger + claim are corrected. + +--- + +## 0.5. Coherence impact (`COHERENCE.md` §20, required since #163) + +- **Journey steps.** §20's first-named arc; 1a takes the broken half of + **step 3**. After 1a, `pmacs .` opens the directory. Steps 4 and 6–12 + do not change grade. §2's verdict table and §20 Priority 1's "State: + broken at step 3" line are rewritten in this PR per §25. +- **Interaction islands: adds none, removes one.** No new keymap, mode, + or modal surface; the directory arm routes into #165's dired buffer — + the "must not invent a second directory surface" constraint. The + unification (§3) removes an island: startup and the daemon bootstrap + resolve paths through two independently-written implementations today. +- **Config registry.** Adds no keys. The directory fallback is a function + slot, not a setting — `ConfigValue` is four scalars and a handler is + none of them (the reason terminal profiles could not be settings, #173). +- **Ownership, stated honestly (rev 3).** That slot is an **unowned + singleton**: last writer wins, no owning package, no `SourceLocation`, + no removal lifecycle, and it does not appear in any inspection surface. + That is a real §13 gap and this framing does not dress it up — §20 + Priority 3 is deliberately deferred, and 1a is not the place to invent + ownership machinery for one slot. **Named migration:** when Priority 3 + lands registration ownership and `pmacs.hook.remove`, the slot becomes + an ordinary lowest-priority hook subscription carrying its owner, and + this primitive is deleted rather than extended. +- **Background-work attribution (§9).** No new `JobKind` variant, no new + `PendingJob` field; the listing uses `pmacs.fs.read_dir`, whose kind + #165 added. Neutral. +- **Frontend parity (§16).** Both frontends get the behavior from the + same primitive. One asymmetry ships knowingly: the GPU path displays + its pre-existing bootstrap buffer until the listing settles (§8 B2). +- **New substrate (rev 3, revised rev 4–5).** `commit_to` (§4.4) is a + general fix for a general problem — *every* post-await + `pmacs.window.*` call in the tree acts on the ambient frontend by + documented design (`dired.lua:68-73`). 1a introduces it for one caller + and does not migrate the others; that migration is named as deferred + rather than smuggled in. It adds a **scoped frontend override** + distinct from `InteractiveCommandOrigin` (§2.11), deliberately: a + background continuation gets destination scope **without** acquiring + interactive-command authority, which keeps the "programmatic vs + interactive" distinction the unfold guard, command boundaries, and the + terminal surface all depend on. + +--- + +## 1. What Stage 1a ships + +1. **`pmacs .` opens the directory**, on the local TUI path and the + daemon/GPU bootstrap path, routed into #165's dired buffer. +2. **One path-resolution primitive** — `EditorState::open` adopts + `EditorCore::resolve_target_buffer` wholesale. +3. **A scoped-destination commit primitive** (§4.4) so an async open + lands where it was requested, or nowhere. +4. **The first cross-subsystem journey acceptance suite** (§19). + +Not in 1a — Stage 1b: a compile keybinding and `cargo build`/`test` +defaults from the existing `ProjectKind::Cargo`, LSP spawn-failure +guidance (§1.2), a welcome buffer. + +--- + +## 2. Ground truth (scouted 2026-07-26, `main` @ `d400f30`; re-verified rev 4) + +### 2.1 `pmacs .` still exits 1, and why + +`load_file` (`src/file_io.rs:81`) does `File::open` — which succeeds on a +directory — then `read_to_end`, returning `EISDIR`. Not +`ErrorKind::NotFound`, so every `NotFound` arm is skipped and the error +propagates; `main` prints and exits (`src/main.rs:400-403`). + +### 2.2 There are two path-open implementations, not one + +`resolve_target_buffer` (`editor_core.rs:885`) documents itself as *"One +primitive, so two path-normalization, dedup, and hook transactions cannot +drift apart."* Callers: `display_file` (`window_panel.rs:402`) and the +daemon bootstrap (`daemon.rs:1641`). **Local startup is not one of them** +— `EditorState::open` (`editor.rs:757`) hand-writes the same shape. + +| | `EditorState::open` | `resolve_target_buffer` | +|---|---|---| +| Stored buffer path | **normalized** — `set_buffer_path` normalizes internally (`editor_core.rs:810-822`) | **normalized** — same setter | +| Displayed name | `path.display()` raw (`editor.rs:772`) | `path.display()` raw | +| `NotFound` arm | empty path-backed buffer, `[new file]` | identical | +| Dedup | none | `find_buffer_for_path` | +| Window install | `replace_active_buffer` — drops the startup scratch (`editor.rs:797`) | none; caller installs | +| Error type | `io::Error`, bare | `String`, prefixed `cannot open {path}: ` | + +**The two agree on every observable except the error prefix and the +window install.** Rev 1 claimed a raw-vs-normalized split and built a +decision, a bet, and an acceptance on it; all three were withdrawn in rev +2. Rev 3 draws the further consequence the review identified: because the +implementations already agree, **no equivalence assertion can prove the +unification happened** — such a test passes on the pre-image. §6.0 +restructures the acceptance list around that. + +The unification's value is therefore (a) the directory arm reaching +startup once rather than being written twice, and (b) closing drift the +primitive was created to prevent and did not. Not a behavior fix. + +### 2.3 dired creates its own buffer and refuses adoption + +`claim_handle` (`dired.lua:486`) creates the buffer, applies the +read-only intercept, `set_round_trip_input`, and the `dired` major mode. +Its comment is explicit that finding a buffer by name is **not** +adoption. Handles are pathless. No Lua `buffer.set_name` / +`set_file_path` exists; dired Stage 2 (PR #171 §5) is scoped to add one. + +### 2.4 The listing is async; the bootstrap reply is not + +`read_listing` (`dired.lua:462`) awaits `pmacs.fs.read_dir` and its +comment says *"Must run inside `pmacs.async`"*. The daemon bootstrap is +one synchronous block: `open_initial_target` (`daemon.rs:1624`) → +`initial_target_snapshot` (`:1823`) → `InitialTargetResult::Opened` +(`:1888`), with the GPU frontend blocking on the reply before creating +its window (`pmacs-gpu/src/attach.rs:551`). + +`tick_async` resuming a coroutine in the frame its result arrives does +**not** bound the listing to one frame — the worker must still finish. +`tests/dired_acceptance.rs:103`'s `pump` drives until parked-coroutine +*and* pending-job counts both reach zero: *"nothing dired does is +observable until this returns."* + +### 2.5 Post-await, dired acts on the ambient frontend — by design + +`dired.lua:68-73`: *"`pmacs.window.*` calls made after the await act for +the **ambient** active frontend, since interactive origin does not +survive the tick boundary; and `pmacs.editor.move_to_line` acts on the +ambient **buffer**, which is why every post-await re-seat is guarded."* + +Correct for an interactive `C-x d`. Wrong for a startup open that must +land in a specific frontend's specific window. + +**And the ambient reach is wider than `display`.** `open_directory` +(`dired.lua:607-655`) after the await, in order: + +1. `read_listing` — the await; +2. `handle_for_path(canonical)` / `claim_handle(canonical)` — **creates a + buffer**, applies intercept/mode, registers a handle; +3. assigns `entries`, `errors`, `sort_mode`; +4. `handle.prev = pmacs.window.buffer()` — **reads the ambient buffer**; +5. `paint(handle)` — mutates the buffer; +6. `display(handle, opts, departed)` — the first call that could refuse; +7. `seat_cursor` — `move_to_line` on the ambient buffer; +8. `kill_departed`. + +`lookup_window` refuses a foreign window id (`window_panel.rs:202-212`), +but only at step 6. **Rev 2's "fails closed, does nothing" was false**: +steps 2–5 have already run. A refusal leaves a hidden dired buffer and a +registered handle, and step 4 can capture an unrelated frontend's buffer +as `prev`. §4.4 fixes this by revalidating and scoping *before* step 2. + +### 2.6 Subscribers exist before the hook fires — but ordering is fixed + +`EditorState::new()` loads the builtin runtime (dired at `editor.rs:539`) +then user `init.lua` (`:609`, `cfg(not(test))`). `HookRegistry::add` +**appends** (`hook.rs:240`); no prepend, no priority, no removal +(`COHERENCE.md` §13 names `pmacs.hook.remove`'s absence as a Priority 3 +prerequisite). **A builtin subscriber always runs before any user +subscriber, forever.** + +### 2.7 Short-circuit cannot distinguish a claim from a crash + +`run_short_circuit` (`hook.rs:299-323`) returns `proceed: false` for a +literal `false` return **and** for a raising callback; only +`HookOutcome.errors` (non-empty in the second case) tells them apart. +A resolver chain that keys only on `proceed` treats a broken user +callback as a successful claim. Q#JR15 decides the policy. + +### 2.8 `open_initial_target` reasserts after hooks + +It re-checks the buffer exists (`daemon.rs:1665-1670`) then reinstalls it +into the origin document window, rehoming if a hook closed it +(`:1673-1690`). §4.5's design does not fight this. + +### 2.9 This deliberately supersedes part of the GPU initial-target framing + +`docs/gpu-initial-target-framing.md` Q#GT6 (`:278`) lists `IsADirectory` +among initial-target failures; its acceptance 10 (`:550`) requires *"a +directory/permission-denied target returns a specific failure before +ready/window creation"*. **1a supersedes the directory half only.** +Permission-denied, invalid path bytes, session teardown, and the +"existing daemon remains connectable" clause keep their contract. The +superseded assertions are amended in that framing in this PR, per §25. + +### 2.10 `display_file`'s directory failure is load-bearing today + +`builtin/commands/default.lua:724` wraps `display_file` in a `pcall` +whose comment says *"only a real failure (a directory, a permission +error) reaches here"*, pinned by +`find_file_accepting_a_directory_reports_instead_of_raising` +(`tests/find_file_acceptance.rs:235`). §4.6 answers to it. + +### 2.11 There is a scope mechanism, and it is the wrong one + +`acting_frontend` (`window_panel.rs:46-50`) reads +`InteractiveCommandOrigin` app data, falling back to +`core.active_frontend_key()`; `InteractiveCommandOrigin::enter(fid)` +(`editor.rs:63-69`) returns an RAII guard. Rev 3 proposed reusing it. +Two independent reasons it cannot be: + +**(a) It does not scope what rev 3 claimed.** Only the window-panel +bindings consult `acting_frontend`. Two of dired's post-await steps do +not go through it at all: + +- **no-arg `pmacs.window.buffer()`** — step 4's `prev` capture — reads + `core.active_buffer_id()` directly (`mod.rs:12547`). Its comment is + explicit that this is deliberate and infallible, and states the + assumption it rests on: *"dispatch sets `active_frontend` to the acting + frontend before running a command, so the two agree on every real + path."* +- **`pmacs.editor.move_to_line`** — step 7's cursor seating — is + `cc.borrow_mut().move_to_line(line)` on the core's ambient active + window (`mod.rs:12703`). + +So entering the interactive origin would scope `display` and leave `prev` +capture and seating ambient — precisely the two steps §2.5 identifies as +corrupting. + +**(b) It is authenticated user-command authority, and a startup +continuation must not impersonate one.** `InteractiveCommandOrigin` is +what distinguishes a user command's edit from a plugin's or the data +API's. Three consumers would be misled: + +- the **pre-edit unfold** guard (`mod.rs:1385-1400`), whose doc calls it + *"the scoped authority that distinguishes a user command's edit from a + plugin's or the data API's programmatic one"* — dired's `paint` would + satisfy it and unfold at the edit site; +- `invoke_interactive`'s command-boundary rotation (`:5400`), which + raises without it and would silently succeed with it; +- `terminal_command_frontend` / `active_terminal_view_key` (`:8515`, + `:8527`), which treat its presence as "an interactive frontend context". + +Q#JR14e therefore introduces a **separate** override. Note that the +`window.buffer()` comment above is not an obstacle but a specification: +swapping `core.active_frontend` for the scope's extent is exactly what +makes its stated assumption true for a continuation, restoring the +invariant rather than working around it. + +--- + +## 3. The unification (Q#JR1) + +`EditorState::open` becomes a thin caller of `resolve_target_buffer`, +keeping `replace_active_buffer` (which drops the startup scratch, Q#JR3) +and keeping its "fire the hook after the core borrow ends" structure +(`editor.rs:786-795`) — listeners re-enter `pmacs.editor.*` and re-borrow +the core (Q#JR1a). + +**Q#JR4** — startup errors gain the `cannot open {path}: ` prefix. +`pmacs /root/secret` names the file, which today's bare message does not. +This is the *only* user-visible change from the unification (§2.2). + +**Q#JR12** — a directory argument counts as "had a file argument" and +suppresses desktop restore, on Q#DS7's reasoning: a positional argument +means "open this", not "restore my session". + +--- + +## 4. The directory arm, the resolver, and the destination + +### 4.1 Q#JR5 — a typed result + +```rust +pub enum ResolvedTarget { + Buffer { id: BufferId, fire: HookKind }, + Directory { path: PathBuf }, // normalized: absolute, ~-expanded, lexically clean +} +``` + +Rev 1's `(Option, HookKind)` admitted states that cannot occur. +`resolve_target_buffer` checks `path.is_dir()` ahead of the load. + +**Q#JR8** — the `Directory` variant carries an explicitly normalized +path. It is *not* free: normalization lives inside `set_buffer_path`, and +this arm creates no buffer, so nothing would normalize anything and the +local caller would still hold `"."`. Same lesson as the Lean 4 arc's URI +affinity — a handler keying state by path must never receive `"."`. + +**Q#JR5b** — `editor_core::HookKind` and `hook::HookKind` are unrelated +types sharing a name; both are written path-qualified in every file this +PR touches, and `window_panel.rs:37`'s bare import is changed to match. + +**Q#JR6** — Rust creates no buffer for a directory. A placeholder needs +reaping, is reinstalled by §2.8's reassert, and — if dired adopted it — +would drag in dired Stage 2's rename prerequisite (§2.3). + +### 4.2 Where the directory arm is consumed + +`EditorState::open` and `open_initial_target` dispatch the resolver +chain. `display_file` does not (§4.6). + +### 4.3 Q#JR7 — a user-only hook, then a replaceable fallback + +Given §2.6, "a package subscribes ahead of dired" is unreachable. So the +two roles are split: + +**The chain.** `path.open-directory`, `kind = "short-circuit"`, fired +first. Returning `false` claims the directory and stops the fan-out. **No +builtin subscribes** — the rule that makes "user code runs first" true +under append-only registration, stated in the hook's own description. + +**The fallback.** If unclaimed, the arm calls the directory handler — a +function slot defaulted by `dired.lua`: + +```lua +pmacs.path.set_directory_handler(function(path, dest) + open_async(path, { dest = dest }, nil, "dired") +end) +``` + +Users replace it, chain it (capture the previous value first), or +**disable** it (`set_directory_handler(nil)`), which is what makes +acceptance 10's unclaimed path reachable. It is an unowned singleton +slot, with the honest accounting and named migration in §0.5. + +**Q#JR15 (new) — a raising callback stops the chain *and* suppresses the +fallback.** §2.7 shows `proceed` alone cannot distinguish a raise from a +claim. Policy: inspect `HookOutcome.errors`; when non-empty, report +through `*errors*` **and** `pmacs.editor.set_status`, and do **not** run +the fallback. Rationale: this preserves the existing short-circuit +contract (a raising `buffer.before-save` callback already vetoes the +save), and running the fallback after a user's resolver crashed would +open dired on a directory the user's code may have been mid-way through +handling. The cost — a broken user callback disables directory opening +until fixed — is visible, reported through two surfaces, and preferable +to silently ignoring the user's resolver. + +*Deferred, named:* hook priority/prepend is the general fix for §2.6 and +belongs with `pmacs.hook.remove` in Priority 3. When it lands, the +fallback becomes an ordinary lowest-priority subscription. + +### 4.4 Q#JR14 (rev 5) — the scoped-destination commit + +**The blocker rev 2 missed:** §2.5 shows dired mutates handle state at +steps 2–5 and only reaches a refusable call at step 6. "Fails closed, +does nothing" was false — a refusal left a hidden buffer, a registered +handle, and a `prev` captured from whichever frontend happened to be +ambient. Per the review's decision, **1a carries the substrate fix.** + +**Q#JR14d — the destination is an opaque capability, not a table.** +`dest` is **nonconstructible userdata**, created only by Rust, holding +three private ids: + +| field (private) | source | purpose | +|---|---|---| +| frontend | local: `FrontendId::LOCAL`; bootstrap: the attaching `frontend_id` | the scope to commit in | +| window | local: the active window; bootstrap: `origin_window` (`daemon.rs:1637`) | where the listing goes | +| buffer | the buffer that window holds at capture time | **stale-intent detection** | + +A table would be wrong in two ways, both reachable: the *same* `dest` is +passed to every hook listener in turn, so an earlier listener could +mutate it and then decline — redirecting later listeners or the fallback +— and any Lua could fabricate a plausible triple and call `commit_to` +directly. Userdata makes both unrepresentable rather than merely +discouraged. + +The only accessor is read-only `dest:window()`, which dired needs for its +exact `display{window = …}` target. `commit_to` accepts **only** this +userdata and revalidates its private contents itself; it never trusts a +caller-supplied id. + +**Q#JR14e — a separate scoped frontend override, not the interactive +origin.** §2.11 gives both reasons. Rev 4 adds a distinct app-data +override with resolution order: + +``` +acting_frontend = scoped override → interactive origin → ambient +``` + +Its RAII guard **also** swaps `core.active_frontend` and restores it on +drop, which is what covers the core-ambient APIs `acting_frontend` never +sees (`window.buffer()` no-arg, `move_to_line`). `commit_to` does **not** +enter `InteractiveCommandOrigin`, so a startup continuation never +acquires interactive-command authority. + +**The primitive.** `pmacs.window.commit_to(dest, fn)`: + +1. **Preflight, before running anything** — the destination's frontend + has a registered view; its window is live in that view's layout; the + window still holds the captured buffer (Q#JR14c); and the window is + **replaceable** (Q#JR14f). +2. On any failure, returns `false, reason` **without calling `fn`** — so + nothing is claimed, painted, or captured. +3. On success, enters the scoped override for the dynamic extent of `fn` + and calls it. Inside, `display{window = …}`, no-arg + `window.buffer()`, `move_to_line`, and every other ambient primitive + resolve against the captured destination — which is why a `frontend` + option on `display` alone would have been insufficient. + +**Q#JR14f — preflight must establish replaceability, through the same +predicate every exact-target probe and display uses.** Exact display +refuses a window that is `dedicated` unless it already shows the +*incoming* buffer (`editor_core.rs:3566`). The distinction is +load-bearing here: `dest.buffer` is the captured bootstrap buffer, not +dired's future buffer. Passing it as the incoming buffer would approve a +window dedicated to that bootstrap buffer; dired would then claim and +paint its different buffer, and exact display would refuse afterward — +rev 2's hidden-buffer failure through another door. + +The eligibility test is therefore extracted once, with the semantic +input `incoming: Option`: + +| caller | input | dedicated-window result | +|---|---|---| +| `display_buffer` exact-target arm | `Some(request.buffer_id)` | eligible only when already showing that buffer | +| `probe_display_target` | its existing `Option` | preserves today's load-before-placement probe contract | +| `commit_to` preflight | `None` | always ineligible — the replacement does not exist yet | + +`probe_display_target` already carries the correct `Option` +shape (`editor_core.rs:3470-3483`), so leaving it on a private copy while +sharing only the other two would preserve the same drift this extraction +exists to remove. Core unit coverage pins the three decisive rows: +dedicated + `Some(current)` is eligible; dedicated + `Some(other)` is +refused; dedicated + `None` is refused. + +**Q#JR14b — `fn` must not await.** The scope is an RAII guard on the +Rust stack; a yield inside it would let the guard's extent and the +coroutine's suspension diverge, restoring the override while the +continuation is still parked. `commit_to` sets a flag that `Handle:await` +checks and raises on, naming the rule. Enforced, not documented — pinned +by N6. + +**Atomicity, stated precisely.** `commit_to` is atomic **against +destination-precondition failure**: if any preflight check fails, no +callback runs and nothing is mutated. It is **not** a transaction over +the callback — if `fn` raises halfway through, `commit_to` restores the +scope and propagates, but whatever `fn` already mutated stays mutated. +Rolling that back would require dired to make its claim/paint sequence +undoable, which is a dired change well beyond 1a. What 1a guarantees is +that the *destination* checks happen before the first mutation, which is +the failure the review identified. + +**dired's change.** `open_directory` keeps `read_listing` (the await) +outside, then performs steps 2–8 inside a single `commit_to` callback, +displaying with `{ window = dest:window() }` rather than the ambient +`switch_buffer`. On a `false` return it reports through +`pmacs.editor.set_status` and returns, having mutated nothing. + +**Q#JR14c — stale intent loses to the user.** If the destination window +now holds a different buffer than at capture, the request is stale and +**fails closed**. Rev 2's window-only pin said launch intent overwrites +whatever the user did meanwhile; that was wrong, and it rested on B2's +"before the user can act", which §2.4 disproves — a large directory takes +many frames and the user can act in every one of them. The user's action +is newer information than the launch argument. + +**What this buys, stated as the review framed it:** competing frontend +activity no longer turns a valid startup request into a nondeterministic +no-op. A live, unchanged destination receives its listing regardless of +what other frontends did meanwhile. Fail-closed is reserved for a +destination that is genuinely dead or stale. + +*Deferred, named:* migrating dired's other post-await paths (`C-x d`, +tree descent/ascent, refresh) and every other ambient post-await +`pmacs.window.*` call in the tree onto `commit_to`. 1a introduces the +primitive for the startup path and does not sweep; the sweep is its own +PR with its own acceptance, and this framing does not pretend the general +problem is solved. + +### 4.5 Q#JR9 — what the bootstrap reply names, and what it shows + +`open_initial_target` on a `Directory` installs nothing: it dispatches the +resolver, then replies `Opened { buffer_id }` naming the buffer the fresh +view's primary document window already holds. §2.8's reassert reasserts +that same buffer — already correct, therefore harmless. + +**That buffer is not necessarily `*scratch*`.** `build_fresh_frontend_view` +clones **LOCAL's primary document buffer** (`daemon.rs:2997`) — M10.9 made +attaching frontends share LOCAL's buffer so overlays fire; the +bottom-panel arc narrowed it to the *primary document* buffer so a TUI +panel could not become a new frontend's document. If LOCAL holds a real +document, `pmacs --gpu .` briefly displays and snapshots that unrelated +document. + +**Decision: accept and document.** A bootstrap placeholder re-creates +everything Q#JR6 rejected to fix a transient, and the session genuinely +*is* showing LOCAL's document — the same thing a no-argument `--gpu` +attach shows. Acceptance N5 pins it with a deliberately non-scratch LOCAL +primary so it is observed rather than assumed. + +### 4.6 Q#JR13 — `display_file` keeps its directory error + +`display_file` does **not** dispatch the resolver. On +`ResolvedTarget::Directory` it raises: + +- the message names the path and the directory reason (an improvement on + the raw `EISDIR` text, and the only user-visible change here); +- the active buffer, window layout, and selected window are unchanged — + nothing created, nothing switched; +- `find_file_accepting_a_directory_reports_instead_of_raising` passes + **unmodified**. + +`display_file` is "put this file in a window", not a CLI router. Routing +it into dired would silently change `C-x C-f` on a directory, in a PR +about the CLI, through a `pcall` arm whose comment guarantees the +opposite. + +*Deferred, named:* Emacs's `find-file` does open dired on a directory, +and that is reasonable eventual behavior. It is a find-file UX decision +with its own acceptance, belonging to the dired arc or 1b. When taken it +is a small change at `default.lua:724`, and the pinned test above is what +gets deliberately rewritten. + +--- + +## 5. The journey acceptance suite (§19) + +New: `tests/journey_acceptance.rs`, seeded with steps 2 (launch +unconfigured), 3 (open a real project), and 5 (edit immediately), +driving the **real startup entry point** — a directory arm with no +production caller passes every direct-call test. Steps 6–12 enter as +later stages make them real; the file is a ratchet. + +Every dired-dependent assertion pumps to quiescence using +`dired_acceptance.rs:103`'s idiom (parked coroutines *and* pending jobs +at zero), never a fixed frame count (§2.4). + +--- + +## 6. Acceptance + +### 6.0 Two kinds of pin, and why the distinction matters + +Rev 2 asserted that every acceptance "fails with the change reverted". +The review is right that this cannot hold for preservation guards — and +rev 2's acceptance 6 was the proof: because both implementations already +agree on every observable (§2.2), an equivalence assertion passes on the +pre-image. **Behavioral equivalence cannot demonstrate structural reuse.** +The list is therefore split, and each preservation pin names the +*targeted mutation* it is bite-tested against: + +- **(N) New-behavior acceptances** — must fail on full revert. +- **(P) Preservation pins** — legitimately green on the pre-image; + falsified by a named targeted mutation, not by revert. + +That local startup reaches the new directory behavior is proven by N1, +not by any equivalence assertion — which is also why rev 2's acceptance 6 +is **removed rather than recast**: it proved nothing N1 does not. + +### 6.1 New-behavior acceptances (N) + +- **N1** `pmacs .` in a project directory exits 0 and, after pumping to + quiescence, the active buffer is dired's, listing that directory. + Today: exit 1. +- **N2** Daemon/GPU bootstrap with a directory initial target receives + `InitialTargetResult::Opened`, not `Failed`, and after quiescence the + document window shows the dired buffer. Supersedes the GPU framing's + acceptance 10 for directories (§2.9). +- **N3** `pmacs .` on an unreadable directory reports through dired's + status path and leaves the session running — no exit 1, no half-built + buffer. +- **N4 — delivery despite competing frontends (the blocker's positive + half).** Two registered frontends; a directory bootstrap for frontend + A; frontend B dispatches unrelated activity (buffer switch, window + focus) while the listing is in flight. After quiescence the listing is + in **A's** captured window, and B's active buffer and window are + unchanged. Falsified by reverting `commit_to` to the ambient + `switch_buffer`. +- **N5** Bootstrap with a deliberately **non-scratch** LOCAL primary + document buffer: the reply's `buffer_id` is that buffer, and after + quiescence the window shows dired (Q#JR9, §4.5). +- **N6 — `commit_to` scopes and restores, on every exit path.** Three + cases, each asserting that **both** the scoped override and + `core.active_frontend` return to their prior values: (a) `fn` returns + normally; (b) `fn` raises; (c) `fn` awaits and is refused (Q#JR14b). + Case (c) additionally asserts the raise names the rule. Rev 3 checked + only the interactive origin's restoration on the success path, which + §2.11 shows is neither the right value nor enough paths. Falsified by + dropping the flag, or by restoring on success only. +- **N6b — `commit_to` refuses a forged destination.** A Lua-constructed + table with plausible `frontend`/`window`/`buffer` fields is rejected as + a type error, and userdata cannot be constructed from Lua (Q#JR14d). + Falsified by accepting a table. +- **N6c — a declining listener cannot redirect the destination.** Two + listeners: the first receives `dest`, attempts mutation inside `pcall`, + observes the read-only rejection, and declines; the second verifies + `dest:window()` still names the original window and also declines; then + the fallback commits there (Q#JR14d). Falsified by passing a shared, + mutable table. +- **N7 — the resolver chain.** `path.open-directory` is short-circuit and + first-claimant-wins, exercised through an **ordinary user-registered + listener** (no builtin subscribes, §4.3): two listeners, the first + returns `false`, the second must not run, and the fallback must not + run. Falsified by `all-must-succeed` or `accumulate`. +- **N8 — a raising callback suppresses the fallback *and* is reported + (Q#JR15).** A listener that raises: the fallback does not run, the + directory does not open, and the failure reaches both `*errors*` and + the status line. + *Falsifier, corrected in rev 4:* keying the fallback on `proceed` alone + is **already correct** for suppression — §2.7 shows a raise gives + `proceed == false` just as a claim does. `errors` decides the *report*, + not the fallback. So N8 is falsified by either (a) running the fallback + when `errors` is non-empty — i.e. treating a raise as a decline — or + (b) mutating the short-circuit outcome so a raise yields + `proceed = true`. Rev 3 named the inverse mutation, which does not + falsify anything. +- **N9** The hook and the handler receive a **canonical absolute path** — + firing on `.` from a known cwd delivers that cwd, not `"."` (Q#JR8). +- **N10** With the handler slot cleared and no listener claiming, + `pmacs .` exits **0**, leaves the bootstrap buffer in place, and sets a + status naming the path (Q#JR10). +- **N11** `pmacs .` → dired lists → `RET` on a listed file visits it → a + self-insert lands in **that file's** buffer. (Rev 1 self-inserted into + the dired buffer, whose intercept rejects every edit, `dired.lua:506`.) + +### 6.2 Preservation pins (P), each with its falsifying mutation + +- **P1 — precondition failure is atomic (the blocker's negative half).** + **Three** destination failures, each asserted the same way — after + quiescence the buffer count is unchanged, **no dired buffer or handle + exists for that path**, no window's buffer changed, and a status names + the failure: + 1. **dead** — the destination window was closed; + 2. **stale** — its buffer was replaced (Q#JR14c); + 3. **ineligible** — it is `dedicated` to its still-current captured + buffer, but dired's incoming replacement does not exist yet + (Q#JR14f, completed rev 5). This is the case a preflight that + mistakenly passes `dest.buffer` as the incoming buffer approves and + `display` then refuses *after* dired has claimed and painted. + *Mutation:* move the preflight from before `claim_handle` to after + `paint` — rev 2's design. P1 fails on all three; rev 2's acceptance 3b + passes. *Second mutation, for case 3 specifically:* pass + `Some(dest.buffer)` instead of `None` to the shared eligibility + predicate while keeping liveness and stale-buffer validation. Only case + 3 fails — which is the point of separating it. +- **P2 — stale intent loses (Q#JR14c).** The user replaces the + destination window's buffer while the listing is in flight; their + buffer survives and dired does not overwrite it. + *Mutation:* drop `dest.buffer` from revalidation (rev 2's window-only + pin). P2 fails. +- **P3 — dired's existing handles are not corrupted.** With a dired + buffer already open in another frontend, a failed startup open leaves + that handle's `prev`, entries, and cursor untouched. + *Mutation:* restore the ambient `handle.prev = pmacs.window.buffer()` + outside the scope (§2.5 step 4). +- **P4 — the startup scratch is still dropped (Q#JR3).** + `EditorState::open` leaves exactly one buffer. + *Mutation:* replace `replace_active_buffer` with a bare + `install_buffer_in_window`. +- **P5 — the `NotFound` arm survives the refactor.** A nonexistent path + yields an empty path-backed buffer with `[new file]` and fires no hook. + *Mutation:* delete the `NotFound` arm from `resolve_target_buffer`. +- **P6 — `display_file` keeps its contract (Q#JR13).** It raises on a + directory naming path and reason; active buffer, layout, and selected + window unchanged; `find_file_accepting_a_directory_reports_instead_of_raising` + passes unmodified. + *Mutation:* route `display_file` into the resolver chain. +- **P7 — desktop restore stays suppressed (Q#JR12).** + *Mutation:* pass `false` for `had_file` on the directory path. +- **P8 — startup errors name the file (Q#JR4).** A non-`NotFound`, + non-directory failure produces a message containing `cannot open` and + the path. *(Legitimately N-shaped for the prefix, P-shaped for the + failure itself; listed here because the failure behavior is preserved + and only the message changes.)* + +`scripts/bite` runs over the new suite. A VACUOUS report on any N is a +blocker; each P's named mutation is run as its bite check, since revert +cannot falsify it. + +--- + +## 7. Deferred (named) + +- **Migrating the rest of the tree onto `commit_to`** (§4.4) — dired's + other post-await paths and every other ambient post-await + `pmacs.window.*` call. Its own PR, its own acceptance. +- **Hook priority / prepend**, with `pmacs.hook.remove`, in §20 Priority + 3 — at which point the fallback slot becomes an ordinary lowest-priority + subscription and §0.5's unowned-singleton gap closes. +- **True adoption (option B).** Rust creates the buffer, dired adopts — + one buffer, no transient — but it needs dired Stage 2's rename / + clear-path capability (§2.3). Dired Stage 3; Q#JR6 does not block it. +- **The bootstrap transient** (§4.5, §8 B2). +- **`C-x C-f` on a directory opening dired** (§4.6). +- **Multiple path arguments** (`main.rs:227`, `:232`, `:240`). +- **`pmacs .` opening a panel** rather than the document window. +- Stage 1b and the rest of §20 Priority 1. + +--- + +## 8. Bets + +- **B1 — "one thing opens a directory" holds.** If a picker and dired + should both run, short-circuit is wrong and the hook must become a + resolver returning a target. +- **B2 (corrected twice) — the bootstrap transient is acceptable.** The + window shows its pre-existing buffer **until the listing settles** — + not "one frame" (rev 1), and **not** "before the user can act" (rev 2): + §2.4 disproves the bound and Q#JR14c is the consequence — the user + *can* act, so stale intent must lose. The bet is only that the + transient is visually acceptable at process start. +- **B3 — withdrawn** (rev 2). There was no path-normalization change. +- **B4 — failing closed on a genuinely dead or stale destination is + better than guessing.** Narrowed in rev 3: it applies only after + revalidation says the destination is gone, not to any competing + activity (N4). +- **B5 — `commit_to`'s no-await rule is livable.** Every commit step + dired performs after the listing is synchronous today, so the rule + costs nothing here. If a future handler genuinely needs to await + mid-commit, the primitive needs a re-entrant design and this bet is + what will have failed. +- **B6 (rev 5) — extracting the eligibility predicate is + behavior-preserving.** Q#JR14f shares one predicate between + `commit_to`'s preflight, `probe_display_target`, and `display_buffer`'s + exact-target arm rather than writing a third copy. The bet is that the + two existing callers' behavior survives the extraction unchanged — + core unit tests pin the `Option` matrix, and + `bottom_panel_stage1_acceptance` catches placement-level drift, which + is why it is in the gate list. The alternative has no extraction risk + and a certain cost: a future eligibility rule added to one copy reopens + Q#JR14f's exact hole. Taking the risk tests can catch over drift they + cannot. + +--- + +## 9. Gates + +``` +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings # own step +cargo test --lib +cargo test --lib --features crdt +cargo test --test journey_acceptance +cargo test --test dired_acceptance +cargo test --test find_file_acceptance # P6, unmodified +cargo test --test gpu_initial_target_acceptance # §2.9 supersession +cargo test --test theme_faces_acceptance # EditorState::open caller +cargo test --test m4_acceptance -- --skip basedpyright # 4 open() callers +cargo test --test bottom_panel_stage1_acceptance # commit_to touches display +PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu +cargo test --workspace -- --skip basedpyright +git diff --check +``` + +`m4_acceptance` and `theme_faces_acceptance` call `EditorState::open` +directly (§2.2) — the unification's blast radius. `find_file_acceptance` +and `gpu_initial_target_acceptance` encode contracts this PR preserves +(§4.6) and supersedes (§2.9). `bottom_panel_stage1_acceptance` is +included because `commit_to` scopes the frontend that `display`'s +placement policy resolves against and Q#JR14f extracts the exact-target +eligibility rule that suite already pins. + +--- + +## 10. Sequencing + +**1a implements after PR #177 merges.** #177 touches `src/daemon.rs` and +`src/editor.rs`; §2.8's reassert logic sits next to its census work. +#179 also touches `src/editor.rs`. + +No dired code is in flight — #169 and #171 are docs-only and no open PR +touches `builtin/runtime/dired.lua` (verified 2026-07-26). 1a's dired +change (a handler registration, plus wrapping `open_directory`'s +post-await commit in `commit_to`) does not collide with dired Stage 2, +which is unapproved for implementation. The `commit_to` wrap is a larger +dired change than rev 2's, touching the body Stage 2's rename work also +touches. + +**Rev 5 — decided: 1a stays ahead of dired Stage 2.** Stage 2 is not in +implementation, and the scoped commit boundary gives it a better shape to +build on than it would have had — a rename transaction across five path +owners is exactly the kind of multi-step commit that wants a validated, +scoped destination rather than ambient state. **Obligation this creates:** +when 1a lands, dired Stage 2 re-scouts and revises its framing around +`commit_to` before implementation; that revision is a prerequisite of +Stage 2's branch, recorded here and in `docs/active-work.md` so it is not +discovered late. + +--- + +## 11. Numbered decisions + +- **Q#JR1** `EditorState::open` adopts `resolve_target_buffer` wholesale. +- **Q#JR1a** The hook fires outside the core borrow. +- **Q#JR2** *Withdrawn (rev 2)* — its premise was false. +- **Q#JR3** The scratch drop (`replace_active_buffer`) is preserved. +- **Q#JR4** Startup errors gain the `cannot open {path}: ` prefix. +- **Q#JR5** `resolve_target_buffer` returns a typed `ResolvedTarget`. +- **Q#JR5b** Both `HookKind` types are written path-qualified. +- **Q#JR6** Rust creates no buffer for a directory. +- **Q#JR7** `path.open-directory` is a short-circuit **user-only** chain; + builtins do not subscribe; dired is a replaceable fallback slot. +- **Q#JR8** `ResolvedTarget::Directory` carries an explicitly normalized + path. +- **Q#JR9** The bootstrap reply names the window's pre-existing buffer — + LOCAL's primary document buffer, not necessarily scratch. Accepted and + documented. +- **Q#JR10** An unclaimed directory with the handler cleared exits 0 with + a status message. +- **Q#JR12** A directory argument suppresses desktop restore. +- **Q#JR13** `display_file` keeps its directory-is-an-error contract. +- **Q#JR14** The destination `{frontend, window, buffer}` is captured at + resolve time; `commit_to` preflights and scopes the **entire** + post-await commit. +- **Q#JR14b** A `commit_to` callback must not await; enforced, not + documented. +- **Q#JR14c** Stale intent loses to the user: a replaced destination + buffer fails closed. +- **Q#JR14d** `dest` is nonconstructible userdata with a read-only + `window()` accessor — not a table a listener can mutate or Lua can + forge. +- **Q#JR14e** A **separate** scoped frontend override, resolved ahead of + the interactive origin and also swapping `core.active_frontend`. + `commit_to` never enters `InteractiveCommandOrigin`. +- **Q#JR14f** Preflight establishes **replaceability** via the same + `Option` eligibility predicate used by + `probe_display_target` and `display_buffer`; `commit_to` passes `None` + because its replacement does not exist yet. +- **Q#JR15** A raising resolver callback stops the chain **and** + suppresses the fallback, reported through `*errors*` and the status + line. + +--- + +## 12. Branch and PR plan + +One feature, one branch, one PR: `journey-stage1a-directory-open`. + +1. Commit this framing. +2. Unification (§3) + P4, P5, P7, P8. +3. `ResolvedTarget` + the directory arm + the resolver chain, fallback + slot, and error policy (§4.1–4.3) + N7, N8, N9, N10; `display_file`'s + preserved contract (§4.6) + P6. +4. The scoped frontend override + the shared eligibility predicate + (Q#JR14e, Q#JR14f), then `commit_to` and the opaque destination + (§4.4) + N4, N6, N6b, N6c, P1, P2, P3. The override and the predicate + extraction land first as separable core changes: both are testable + without dired, and the predicate's `Some(current)` / `Some(other)` / + `None` unit matrix plus `bottom_panel_stage1_acceptance` must prove the + extraction behavior-preserving before anything depends on it. +5. `tests/journey_acceptance.rs` (§5) + N1, N2, N3, N5, N11. +6. `COHERENCE.md` §2 verdict table and §20 Priority 1 rewritten per §25; + `docs/gpu-initial-target-framing.md` Q#GT6 + acceptance 10 amended for + the superseded directory case (§2.9); `docs/agent-handoff.md` §1 and + `docs/active-work.md` updated. From dd9f380533f7a82beaa04033898937f70bba32db Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 15:01:17 -0400 Subject: [PATCH 60/91] docs(active-work): record Journey Stage 1a branch and ordering Rev 5 is approved and the branch is cut, so the ledger's "no branch, commit, or PR exists yet" line no longer describes reality. Records the recovery command, notes that PR #177 has merged and therefore unblocks implementation, and carries the standing obligation that dired Stage 2 re-scouts around commit_to before its branch is cut. --- docs/active-work.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 5a74c24..ed6ceb7 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -331,6 +331,25 @@ If it does not, stop and repair the remote/fetch configuration. --check` clean. - Stage 4b (the input method) is NOT in this PR and not started. +## Journey Stage 1a — framing rev 5 APPROVED; branch cut, implementing + +- Approved framing: `docs/journey-stage1a-framing.md` **rev 5** (four + review rounds). Branch `journey-stage1a-directory-open`, framing + committed as its first commit. No PR yet. +- Recovery: `git fetch githubsucks && git checkout + journey-stage1a-directory-open`. The framing now travels; the + implementation does not until it is committed and pushed. +- Ordering: PR #177 MERGED (2026-07-26), so 1a is unblocked. 1a lands + before dired Stage 2. When 1a lands, Stage 2 must re-scout and revise + its framing around the scoped `pmacs.window.commit_to` boundary before + its implementation branch is cut. That revision is a prerequisite, not + a review-time discovery. +- Implementation order inside the branch (framing §12): scoped frontend + override + shared eligibility predicate first (separable, testable + without dired), then `commit_to` and the opaque destination, then the + directory arm and resolver chain, then the journey suite, then the + doc updates COHERENCE §25 requires. + ## The CRDT half of the test corpus is dark in CI — NEEDS A LANE - **No branch, no framing yet.** Found while gating #166, then measured From f09f66ce37e42f1eb3e808f03ea52f74bcd0e45c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:20:54 -0400 Subject: [PATCH 61/91] feat(journey): open a directory, on one path Journey Stage 1a's core: `pmacs .` opens the directory instead of exiting 1, and local startup stops being a second implementation of path resolution. `EditorState::open` now calls `EditorCore::resolve_target_buffer` -- the primitive whose own doc comment says it exists "so two path-normalization, dedup, and hook transactions cannot drift apart", and which local startup had never been a caller of. `resolve_target_buffer` returns a typed `ResolvedTarget` rather than `(BufferId, HookKind)`, with a `Directory` arm checked ahead of the load. Without it the load runs and fails: `File::open` succeeds on a directory and `read_to_end` returns EISDIR, which is not `NotFound`, so the `[new file]` arm never fired. A directory creates no buffer. It dispatches a resolver chain: the short-circuit `path.open-directory` hook, which no builtin subscribes to, and then `pmacs.path.directory_handler`, which dired defaults. The split is forced rather than chosen -- hook callbacks only append and builtins load before init.lua, so a subscribing builtin would always claim before any user listener could run. A raising listener stops the chain and suppresses the fallback. The listing is async and the daemon bootstrap is not, so the whole post-await commit runs inside a new `pmacs.window.commit_to`: it validates the destination -- frontend live, window live, buffer unchanged, window replaceable -- BEFORE invoking its callback, then scopes the acting frontend for its extent. Validating at display time would be four dired mutations too late. That scope is deliberately not `InteractiveCommandOrigin`, which does not reach the core-ambient APIs and is authenticated user-command authority a background continuation must not acquire. The dedication rule is extracted into one `window_accepts_buffer` shared by exact display, the display probe, and the new preflight, with `incoming: Option` -- `None` means "the replacement does not exist yet" and refuses a dedicated window. `display_file` keeps its directory-is-an-error contract and does not enter the chain; find-file's accept arm depends on it. Framing: docs/journey-stage1a-framing.md rev 5 (Q#JR1-JR15). --- builtin/hooks/default.lua | 15 + builtin/runtime/async.lua | 11 + builtin/runtime/dired.lua | 84 +++-- src/daemon.rs | 47 ++- src/editor.rs | 311 ++++++++++++++++-- src/editor_core.rs | 193 +++++++++++- src/lua_bindings/mod.rs | 85 +++++ src/lua_bindings/window_panel.rs | 150 ++++++++- tests/journey_acceptance.rs | 525 +++++++++++++++++++++++++++++++ 9 files changed, 1356 insertions(+), 65 deletions(-) create mode 100644 tests/journey_acceptance.rs diff --git a/builtin/hooks/default.lua b/builtin/hooks/default.lua index 7fe3a0a..4fabfe9 100644 --- a/builtin/hooks/default.lua +++ b/builtin/hooks/default.lua @@ -61,6 +61,21 @@ define { kind = "all-must-succeed", } +define { + name = "path.open-directory", + description = "Fired when a directory path is opened (Journey Stage 1a). " .. + "Receives the canonical absolute path and an opaque " .. + "destination. Return false to CLAIM the directory and stop " .. + "the fan-out; return nothing to decline. No builtin " .. + "subscribes -- because hook callbacks only ever append, a " .. + "subscribing builtin would always claim before any user " .. + "listener could run, so this hook is the user's chain and " .. + "pmacs.path.directory_handler is the default surface it " .. + "falls back to. A callback that RAISES stops the chain and " .. + "suppresses that fallback.", + kind = "short-circuit", +} + define { name = "editor.before-quit", description = "Fired before the editor exits. Return false to veto.", diff --git a/builtin/runtime/async.lua b/builtin/runtime/async.lua index 94555c7..af74cc1 100644 --- a/builtin/runtime/async.lua +++ b/builtin/runtime/async.lua @@ -77,6 +77,17 @@ end -- inside a coroutine spawned by pmacs.async --- a bare call from main -- thread will raise on the first yield. function Handle:await() + -- Journey Stage 1a (Q#JR14b): `pmacs.window.commit_to` scopes the + -- acting frontend for the dynamic extent of its callback, using an + -- RAII guard on the Rust stack. Yielding out of that extent would + -- restore the scope while this coroutine is still parked, so the rest + -- of the commit would resume ambient -- silently reintroducing the + -- misrouting the scope exists to prevent. Do the awaiting BEFORE + -- entering the commit, which is what dired does with its listing. + if async_mod._in_commit_scope() then + error("await: cannot await inside pmacs.window.commit_to; " .. + "await first, then commit") + end if not async_mod._is_complete(self._id) then -- Yield self so pmacs.async's step() can park us. R46 carve-out: -- this `coroutine.yield` is runtime code; package code uses diff --git a/builtin/runtime/dired.lua b/builtin/runtime/dired.lua index 9c6bc92..adbc8f1 100644 --- a/builtin/runtime/dired.lua +++ b/builtin/runtime/dired.lua @@ -598,7 +598,7 @@ end pmacs.dired = pmacs.dired or {} -local OPEN_OPTS = { display = true, select_name = true } +local OPEN_OPTS = { display = true, select_name = true, dest = true } -- Open `path`'s dired buffer, replacing `departed` (a handle) in the -- window it occupies when this is a navigation rather than a fresh @@ -629,36 +629,64 @@ local function open_directory(path, opts, departed) local sort_mode = (handle_for_path(canonical) or {}).sort_mode or SORT_MODES[1] local entries, errors = read_listing(canonical, sort_mode) - local handle = claim_handle(canonical) - handle.entries = entries - handle.errors = errors - handle.sort_mode = sort_mode + -- Everything from here down MUTATES: it claims or finds a handle, + -- creates a buffer, reads the ambient buffer for `prev`, and paints. + -- None of it is undoable, and none of it may run against a + -- destination that has gone away -- so when the caller captured one + -- (Journey Stage 1a, Q#JR14), the whole commit runs inside + -- `pmacs.window.commit_to`, which validates the destination BEFORE + -- invoking this and scopes the acting frontend for its extent. + -- + -- Note the await above is deliberately OUTSIDE the commit: awaiting + -- inside it is refused (Q#JR14b), because a yield would restore the + -- scope while this coroutine is still parked. + local function commit() + local handle = claim_handle(canonical) + handle.entries = entries + handle.errors = errors + handle.sort_mode = sort_mode - -- `q` returns to the buffer you came from, never to another dired - -- buffer (which would trap `q` walking back down the tree); on a - -- descent the arriving buffer inherits the departing one's origin. - if departed ~= nil then - handle.prev = departed.prev - else - local active = pmacs.window.buffer() - if active ~= nil and handle_for_buffer(active) == nil then - handle.prev = active + -- `q` returns to the buffer you came from, never to another dired + -- buffer (which would trap `q` walking back down the tree); on a + -- descent the arriving buffer inherits the departing one's origin. + if departed ~= nil then + handle.prev = departed.prev + else + local active = pmacs.window.buffer() + if active ~= nil and handle_for_buffer(active) == nil then + handle.prev = active + end end + + paint(handle) + display(handle, opts, departed) + -- Seating happens after the display: `switch_buffer` zeroes the + -- window cursor, so an earlier seat would be discarded. + seat_cursor(handle, opts.select_name, 1) + kill_departed(departed, handle) + return handle.buf end - paint(handle) - display(handle, opts, departed) - -- Seating happens after the display: `switch_buffer` zeroes the - -- window cursor, so an earlier seat would be discarded. - seat_cursor(handle, opts.select_name, 1) - kill_departed(departed, handle) - return handle.buf + if opts.dest == nil then + -- Interactive path (`C-x d`, tree descent, refresh): the acting + -- frontend is still ambient a tick later, which is what dired has + -- always relied on. Migrating these onto a captured destination too + -- is a named deferral, not this stage's work. + return commit() + end + + local ok, result = pmacs.window.commit_to(opts.dest, commit) + if not ok then + error(string.format("destination is gone (%s)", tostring(result))) + end + return result end function pmacs.dired.open(path, opts) return open_directory(path, opts, nil) end + -- Every interactive entry point funnels through here: spawn the -- coroutine the await needs, and turn a failure into a status message -- rather than an uncaught raise inside `pmacs.async` (which would land @@ -670,6 +698,20 @@ local function open_async(path, opts, departed, where) end) end +-- Journey Stage 1a (Q#JR7): dired is the DEFAULT directory surface, not +-- a `path.open-directory` subscriber. +-- +-- It cannot be a subscriber and still be replaceable. `HookRegistry.add` +-- only appends, and builtins load before `init.lua`, so a dired +-- subscription would always run first and always claim -- no user +-- listener could ever win. The hook is therefore the user's chain and +-- this slot is the fallback the editor consults when that chain +-- declines. Replace it to change what opens a directory; set it to nil +-- to disable directory opening entirely. +pmacs.path.set_directory_handler(function(path, dest) + open_async(path, { dest = dest }, nil, "dired") +end) + -- --------------------------------------------------------------------------- -- Commands -- --------------------------------------------------------------------------- diff --git a/src/daemon.rs b/src/daemon.rs index 84716eb..4a7e08f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1627,18 +1627,57 @@ fn open_initial_target( // create and select a side window, and bootstrap must reassert the // requested buffer in a document window rather than overwriting a // panel merely because it became `view.active`. - let (origin_window, buffer_id, fire) = { + let (origin_window, resolved) = { let mut core = editor.core.borrow_mut(); core.active_frontend = frontend_id; let origin_window = core .primary_document_window(frontend_id) .ok_or_else(|| "attaching frontend has no document window".to_string())?; - let (buffer_id, fire) = core.resolve_target_buffer(&path)?; + let resolved = core.resolve_target_buffer(&path)?; + (origin_window, resolved) + }; + + // Journey Stage 1a (Q#JR6/Q#JR9): a DIRECTORY installs nothing. + // + // Nothing can be installed, because the listing that satisfies a + // directory open is asynchronous and this block is synchronous — the + // frontend is blocked on `InitialTargetResult` and will not create + // its window until it arrives, so there is no tick in which a + // listing could settle. The reply therefore names the buffer the + // fresh view's document window ALREADY holds, which is a valid, + // ready session; the listing replaces it a tick or more later. + // + // That buffer is NOT necessarily `*scratch*`: `build_fresh_frontend_view` + // clones LOCAL's primary document buffer. If LOCAL holds a real + // document, this session briefly displays and snapshots it. Accepted + // and documented rather than papered over with a placeholder buffer, + // which would need reaping and would be fought by the reassert below. + // + // `publish_to_replicas` is false for the same reason an `AfterSwitch` + // dedup sets it false: this buffer is pre-existing and already + // published, not freshly loaded here. + let (buffer_id, fire) = match resolved { + crate::editor_core::ResolvedTarget::Directory { path } => { + let dest = editor + .capture_directory_destination(frontend_id, origin_window) + .ok_or_else(|| format!("cannot open {}: no document window", path.display()))?; + let buffer_id = dest.buffer; + editor.dispatch_directory_open(&path, dest); + editor.reconcile_panel_layout(frontend_id); + return Ok(OpenedInitialTarget { + buffer_id, + publish_to_replicas: false, + }); + } + crate::editor_core::ResolvedTarget::Buffer { id, fire } => (id, fire), + }; + + { + let mut core = editor.core.borrow_mut(); core.install_buffer_in_window(origin_window, buffer_id) .map_err(|error| format!("cannot select {}: {error}", path.display()))?; core.focus_window(frontend_id, origin_window); - (origin_window, buffer_id, fire) - }; + } match fire { crate::editor_core::HookKind::AfterLoad => { diff --git a/src/editor.rs b/src/editor.rs index a19971e..1e94d92 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -26,7 +26,6 @@ use unicode_width::UnicodeWidthStr; use crate::async_runtime::SharedAsyncRuntime; use crate::cell::{CellCoord, CellSize}; use crate::editor_core::EditorCore; -use crate::file_io::load_file; use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook}; use crate::key::{Chord, display_sequence}; use crate::keymap_stack::{Action, KeyDispatcher}; @@ -80,6 +79,109 @@ impl Drop for InteractiveCommandOriginGuard { } } +/// A frontend scope for **background** work — deliberately NOT +/// [`InteractiveCommandOrigin`] (Journey Stage 1a, Q#JR14e). +/// +/// An async continuation (a settled directory listing, and eventually +/// any other post-await window work) needs to act for the frontend that +/// *requested* it rather than whichever one happens to be ambient when +/// the worker finishes. Reusing the interactive origin for that would be +/// wrong twice over: +/// +/// 1. **It does not scope enough.** Only `acting_frontend` consults it, +/// so `pmacs.window.display` would be scoped while no-arg +/// `pmacs.window.buffer()` (which reads `active_buffer_id()` +/// directly) and `pmacs.editor.move_to_line` (which mutates the +/// core's ambient active window) stayed ambient — and those are +/// precisely the calls that capture and seat. +/// 2. **It is authenticated user-command authority.** It is what +/// distinguishes a user command's edit from a plugin's or the data +/// API's: the pre-edit unfold guard, `invoke_interactive`'s +/// command-boundary rotation, and the terminal surface's "requires an +/// interactive frontend context" checks all key off it. A background +/// listing must not acquire any of that. +/// +/// So this is a separate slot, resolved *ahead* of the interactive +/// origin, whose guard **also** swaps `EditorCore::active_frontend` — +/// which is what covers the core-ambient APIs `acting_frontend` never +/// sees. That swap is not a workaround: `pmacs.window.buffer()`'s no-arg +/// arm documents its own correctness as resting on "dispatch sets +/// `active_frontend` to the acting frontend before running a command", +/// and this restores that invariant for a continuation. +#[derive(Clone, Default)] +pub(crate) struct ScopedFrontend(Rc>>); + +impl ScopedFrontend { + /// The override in force, if any. + #[must_use] + pub(crate) fn current(&self) -> Option { + self.0.get() + } + + /// Enter a background frontend scope, also swapping the core's + /// ambient `active_frontend`. Both are restored on drop, on every + /// exit path including a raising callback. + pub(crate) fn enter( + &self, + core: &SharedCore, + commit_scope: &CommitScopeActive, + frontend_id: FrontendId, + ) -> ScopedFrontendGuard { + let previous = self.0.replace(Some(frontend_id)); + let previous_active = { + let mut core = core.borrow_mut(); + let was = core.active_frontend; + core.active_frontend = frontend_id; + was + }; + let previous_commit = commit_scope.0.replace(true); + ScopedFrontendGuard { + scope: self.clone(), + core: core.clone(), + previous, + previous_active, + commit_scope: commit_scope.clone(), + previous_commit, + } + } +} + +pub(crate) struct ScopedFrontendGuard { + scope: ScopedFrontend, + core: SharedCore, + previous: Option, + previous_active: FrontendId, + /// Cleared together with the scope, so an awaiting callback cannot + /// leave `await` refused after the commit ends (Q#JR14b). + commit_scope: CommitScopeActive, + previous_commit: bool, +} + +impl Drop for ScopedFrontendGuard { + fn drop(&mut self) { + self.scope.0.set(self.previous); + self.core.borrow_mut().active_frontend = self.previous_active; + self.commit_scope.0.set(self.previous_commit); + } +} + +/// Whether a `pmacs.window.commit_to` callback is currently running +/// (Journey Stage 1a, Q#JR14b). +/// +/// Read from Lua as `pmacs._async._in_commit_scope()`; `Handle:await` +/// refuses while it is set. Lives beside the scope guard so the two can +/// never disagree. +#[derive(Clone, Default)] +pub struct CommitScopeActive(Rc>); + +impl CommitScopeActive { + /// Whether a commit callback is on the stack. + #[must_use] + pub fn active(&self) -> bool { + self.0.get() + } +} + // --------------------------------------------------------------------------- // EditorState // --------------------------------------------------------------------------- @@ -261,6 +363,13 @@ impl EditorState { let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization"); let interactive_origin = InteractiveCommandOrigin::default(); lua_host.lua().set_app_data(interactive_origin.clone()); + // Q#JR14e/Q#JR14b: the background frontend scope and the + // commit-scope flag live only as Lua app data -- `commit_to` and + // `Handle:await` are the only readers, and both reach them that + // way. No `EditorState` field, so there is no second handle that + // could disagree with the one the guard restores. + lua_host.lua().set_app_data(ScopedFrontend::default()); + lua_host.lua().set_app_data(CommitScopeActive::default()); lua_host .attach_editor(&core) .expect("editor bindings + builtin chunks"); @@ -769,35 +878,56 @@ impl EditorState { /// Construct an editor for a path. Empty buffer with `[new file]` /// status if the path does not exist; loaded contents otherwise. + /// + /// Journey Stage 1a (Q#JR1): this is a thin caller of + /// [`EditorCore::resolve_target_buffer`], not a second + /// implementation of it. That primitive documents itself as "one + /// primitive, so two path-normalization, dedup, and hook + /// transactions cannot drift apart" — and local startup, which had + /// hand-written the same three-arm shape, was not one of its callers + /// until now. + /// + /// Two things this caller still owns, and must keep owning: + /// + /// * **The window install.** `resolve_target_buffer` deliberately + /// does not touch windows, so the caller places the buffer. + /// Startup uses [`Self::replace_active_buffer`] specifically + /// because it drops the just-created scratch buffer; an + /// `install_buffer_in_window` here would leave a stray `*scratch*` + /// behind every `pmacs FILE` (Q#JR3). + /// * **Firing the hook outside the core borrow.** Listeners + /// re-enter `pmacs.editor.*`, which re-borrows the core + /// (Q#JR1a) — the same reason the daemon bootstrap and + /// `display_file` both fire theirs after their borrow blocks end. + /// + /// A directory resolves to [`ResolvedTarget::Directory`] and is + /// dispatched to the directory resolver chain rather than opened as + /// a buffer (Q#JR6); see [`Self::open_directory_target`]. + #[allow( + clippy::needless_pass_by_value, + reason = "stable public entry point mirroring `pmacs PATH` and \ + `run(Option)`; the body stopped consuming the \ + PathBuf when this became a `resolve_target_buffer` caller, \ + and churning the signature would touch every caller for no \ + behavioral gain" + )] pub fn open(path: PathBuf) -> io::Result { - let display_name = path.display().to_string(); - let state = Self::new(); + let mut state = Self::new(); + let resolved = state + .core + .borrow_mut() + .resolve_target_buffer(&path) + .map_err(io::Error::other)?; let mut fire_after_load = false; - match load_file(&path) { - Ok((bytes, meta)) => { - let new_id = state - .lua_host - .registry() - .borrow_mut() - .create_from_bytes(display_name, &bytes); - state.replace_active_buffer(new_id); - let mut core = state.core.borrow_mut(); - core.set_buffer_path(new_id, Some(path)); - core.set_buffer_meta(new_id, Some(meta)); - fire_after_load = true; - Ok(()) + match resolved { + crate::editor_core::ResolvedTarget::Buffer { id, fire } => { + state.replace_active_buffer(id); + fire_after_load = matches!(fire, crate::editor_core::HookKind::AfterLoad); } - Err(e) if e.kind() == io::ErrorKind::NotFound => { - let new_id = state.lua_host.registry().borrow_mut().create(display_name); - state.replace_active_buffer(new_id); - let mut core = state.core.borrow_mut(); - core.set_buffer_path(new_id, Some(path)); - core.status = "[new file]".into(); - Ok(()) + crate::editor_core::ResolvedTarget::Directory { path } => { + state.open_directory_target(&path); } - Err(e) => Err(e), - }?; - let mut state = state; + } if fire_after_load { // Fire the hook *after* the borrow on `core` is released // (block above ends). Listeners may legitimately re-enter @@ -809,6 +939,135 @@ impl EditorState { Ok(state) } + /// Capture the destination a directory open must commit to + /// (Q#JR14), or `None` when `frontend` has no document window. + /// + /// Synchronous by necessity: the listing settles a tick or more + /// later, and by then the ambient frontend, selected window, and + /// active buffer may all name something else. + pub(crate) fn capture_directory_destination( + &self, + frontend: crate::protocol::FrontendId, + window: crate::window::WindowId, + ) -> Option { + let core = self.core.borrow(); + let buffer = core.windows.get(&window)?.buffer_id; + Some(crate::editor_core::DirectoryDestination { + frontend, + window, + buffer, + }) + } + + /// Local-startup directory open (Q#JR6): resolve the destination + /// from `LOCAL`'s document window and dispatch the resolver chain. + /// + /// Public because it is the whole of what `pmacs DIRECTORY` does + /// after resolution — acceptance drives this rather than + /// `resolve_target_buffer`, so a directory arm with no production + /// caller cannot pass. + pub fn open_directory_target(&mut self, path: &std::path::Path) { + // Canonicalize here as well as in the resolver arm. The two are + // not redundant: this is a public "open this directory" seam, so + // a caller that did not come through `resolve_target_buffer` + // must still hand the chain a canonical path (Q#JR8) --- and + // normalization is idempotent, so the startup path pays nothing. + let path = crate::editor_core::normalize_buffer_path(path.to_path_buf()); + let path = path.as_path(); + let window = self + .core + .borrow() + .primary_document_window(crate::protocol::FrontendId::LOCAL); + let dest = window.and_then(|window| { + self.capture_directory_destination(crate::protocol::FrontendId::LOCAL, window) + }); + let Some(dest) = dest else { + self.core.borrow_mut().status = + format!("cannot open {}: no document window", path.display()); + return; + }; + self.dispatch_directory_open(path, dest); + } + + /// Run the directory resolver chain for `path`, then its fallback + /// (Journey Stage 1a, Q#JR7/Q#JR15). + /// + /// Order is user chain first, builtin default second — see + /// `install_path_module` for why that cannot be expressed as two + /// hook subscriptions. + /// + /// **A raising listener stops the chain AND suppresses the + /// fallback.** `run_short_circuit` returns `proceed = false` both + /// for a literal `false` (a claim) and for a raise, so `proceed` + /// alone already suppresses correctly; `errors` is what distinguishes + /// them, and it decides only whether to *report*. Running the + /// fallback after a user's resolver crashed would open dired on a + /// directory that resolver may have been part-way through handling, + /// so a crash is treated as a claim that failed — reported through + /// the `*errors*` buffer (which `run_hook` already does) and the + /// status line (which it does not), and visible in both. + pub(crate) fn dispatch_directory_open( + &mut self, + path: &std::path::Path, + dest: crate::editor_core::DirectoryDestination, + ) { + let display = path.display().to_string(); + let args = { + let lua = self.lua_host.lua(); + let destination = + match lua.create_userdata(crate::lua_bindings::DirectoryDestinationLua(dest)) { + Ok(userdata) => mlua::Value::UserData(userdata), + Err(error) => { + self.core.borrow_mut().status = format!("cannot open {display}: {error}"); + return; + } + }; + let path_value = match lua.create_string(display.as_bytes()) { + Ok(string) => mlua::Value::String(string), + Err(error) => { + self.core.borrow_mut().status = format!("cannot open {display}: {error}"); + return; + } + }; + mlua::MultiValue::from_vec(vec![path_value, destination]) + }; + + match self.lua_host.run_hook("path.open-directory", args.clone()) { + // A listener raised. `run_hook` has already appended the + // record to *errors*; add the status line, and do NOT fall + // back (Q#JR15). + Some(outcome) if !outcome.errors.is_empty() => { + self.core.borrow_mut().status = + format!("cannot open {display}: a path.open-directory listener failed"); + return; + } + // Claimed: a listener returned false. + Some(outcome) if !outcome.proceed => return, + // Declined, or no listeners at all. + _ => {} + } + + let handler = { + let lua = self.lua_host.lua(); + lua.globals() + .get::("pmacs") + .and_then(|pmacs| pmacs.get::("path")) + .and_then(|path| path.get::("directory_handler")) + .unwrap_or(mlua::Value::Nil) + }; + let mlua::Value::Function(handler) = handler else { + // The slot is clear: nothing surfaces directories. The + // session started fine and simply has nothing to show for + // the argument, so this is a status message and NOT a + // startup failure (Q#JR10). + self.core.borrow_mut().status = format!("no handler for directory {display}"); + return; + }; + if let Err(error) = handler.call::<()>(args) { + self.core.borrow_mut().status = format!("cannot open {display}: {error}"); + } + } + /// Switch the active window to `buffer_id`, dropping any old /// scratch buffer if the active window's previous buffer has no /// other windows referencing it. Returns silently on a stale id. diff --git a/src/editor_core.rs b/src/editor_core.rs index 89432cc..7fd90c6 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -94,6 +94,77 @@ pub enum HookKind { None, } +/// What a path resolved to (Journey Stage 1a, Q#JR5). +/// +/// A sum type rather than `(Option, HookKind)`: that pair +/// admits three states that cannot occur (`None` with `AfterLoad`, +/// `Some` with a directory, …), and every caller would have to +/// re-establish by hand which combinations are real. +/// +/// **Do not confuse [`HookKind`] here with [`crate::hook::HookKind`]** — +/// unrelated types sharing a name. This one says *which* lifecycle hook +/// to fire; that one says how a hook's callbacks fan out. Every site +/// touching both writes them path-qualified (Q#JR5b). +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResolvedTarget { + /// A file buffer, plus the hook the caller must fire with the + /// destination window active. + Buffer { + /// The resolved buffer. + id: BufferId, + /// Which lifecycle hook this resolution owes. + fire: HookKind, + }, + /// A directory. No buffer is created (Q#JR6) — the directory + /// resolver chain decides what surfaces it, and dired builds its own + /// buffer through `claim_handle` rather than adopting one. + /// + /// `path` is **normalized** — absolute, tilde-expanded, lexically + /// clean. This is not free and must not be assumed: normalization + /// otherwise happens inside [`Self::set_buffer_path`], which never + /// runs on this arm, so a caller resolving `"."` would keep `"."` + /// (Q#JR8). A handler keying state by path needs the canonical form. + Directory { + /// The normalized directory path. + path: PathBuf, + }, +} + +/// Where a directory open was requested, captured **synchronously** at +/// resolve time (Journey Stage 1a, Q#JR14). +/// +/// The listing that satisfies a directory open is asynchronous +/// (`pmacs.fs.read_dir` is worker-dispatched and must be awaited), so the +/// code that finally builds and displays the listing runs a tick or more +/// later — outside interactive dispatch, where `pmacs.window.*` acts on +/// the *ambient* frontend by documented design (`builtin/runtime/dired.lua`). +/// Without a captured destination, a second frontend dispatching in the +/// meantime silently redirects the listing. +/// +/// All three fields are load-bearing: +/// +/// * `frontend` — the scope the commit must run in. +/// * `window` — the exact destination; the ambient selected window is +/// not it. +/// * `buffer` — what that window held at capture time, so **stale +/// intent loses to the user** (Q#JR14c). A user who replaced the +/// buffer while the listing was in flight is newer information than +/// the launch argument, and must not be overwritten. +/// +/// Exposed to Lua only as nonconstructible userdata (Q#JR14d): as a +/// table, the *same* value is handed to every resolver listener in turn, +/// so one could mutate it and then decline — redirecting later listeners +/// — and any Lua could fabricate a plausible triple. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DirectoryDestination { + /// Frontend that requested the directory. + pub frontend: FrontendId, + /// Window the listing must land in. + pub window: WindowId, + /// Buffer that window held at capture time (stale-intent check). + pub buffer: BufferId, +} + /// A `display_buffer` request (Q#BP3). /// /// `height` and `dedicated` are deliberately option-valued at the policy @@ -880,18 +951,41 @@ impl EditorCore { /// One primitive, so two path-normalization, dedup, and hook /// transactions cannot drift apart. /// + /// A **directory** resolves to [`ResolvedTarget::Directory`] before + /// any load is attempted (Journey Stage 1a, Q#JR5/Q#JR6). Without + /// that arm the load runs and fails: `File::open` succeeds on a + /// directory and `read_to_end` then returns `EISDIR`, which is not + /// `NotFound`, so the `[new file]` arm never fires and every caller + /// saw a hard error — the reason `pmacs .` exited 1 and the golden + /// journey was graded broken at step 3 (`COHERENCE.md` §2). + /// /// # Errors /// Any load failure other than `NotFound`. - pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<(BufferId, HookKind), String> { + pub fn resolve_target_buffer(&mut self, path: &Path) -> Result { + // Ahead of the load, deliberately: see the EISDIR note above. + if path.is_dir() { + return Ok(ResolvedTarget::Directory { + path: normalize_buffer_path(path.to_path_buf()), + }); + } match self.get_or_load_buffer(path) { - Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)), - Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)), + Ok((id, true)) => Ok(ResolvedTarget::Buffer { + id, + fire: HookKind::AfterLoad, + }), + Ok((id, false)) => Ok(ResolvedTarget::Buffer { + id, + fire: HookKind::AfterSwitch, + }), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { let display_path = path.display().to_string(); let buffer_id = self.registry.borrow_mut().create(display_path); self.set_buffer_path(buffer_id, Some(path.to_path_buf())); "[new file]".clone_into(&mut self.status); - Ok((buffer_id, HookKind::None)) + Ok(ResolvedTarget::Buffer { + id: buffer_id, + fire: HookKind::None, + }) } Err(error) => Err(format!("cannot open {}: {error}", path.display())), } @@ -3472,16 +3566,56 @@ impl EditorCore { fid: FrontendId, existing: Option, window: Option, + ) -> Result { + self.probe_display_target_inner(fid, existing, window) + } + + /// Whether `window` will accept `incoming` as its buffer — the one + /// dedication rule, shared by every consumer (Journey Stage 1a, + /// Q#JR14f). + /// + /// A dedicated window refuses anything other than what it already + /// shows; an undedicated one accepts anything. `incoming` is + /// deliberately optional, and the `None` case is not a degenerate + /// spelling of "don't care" — it means **the replacement buffer does + /// not exist yet**, and a dedicated window must therefore be treated + /// as ineligible: + /// + /// | caller | `incoming` | dedicated window | + /// |---|---|---| + /// | [`Self::display_buffer`] exact-target arm | `Some(request.buffer_id)` | eligible only when already showing it | + /// | [`Self::probe_display_target`] | its existing-buffer result | preserves the load-before-placement probe | + /// | `commit_to` preflight | `None` | always ineligible | + /// + /// `commit_to` passes `None` because a directory open's destination + /// is validated *before* the handler builds its buffer. Passing the + /// captured bootstrap buffer instead would approve a window + /// dedicated to *that* buffer, the handler would then claim and paint + /// a different one, and the exact display would refuse afterwards — + /// after the mutations the preflight exists to prevent. + /// + /// Extracted rather than reimplemented per caller: two copies of a + /// rule that must agree is exactly the drift this stage's + /// path-resolution unification exists to close, and a future + /// eligibility rule added to only one copy would reopen it. + #[must_use] + pub fn window_accepts_buffer(&self, window: WindowId, incoming: Option) -> bool { + self.windows.get(&window).is_some_and(|w| { + !w.params.dedicated || incoming.is_some_and(|buffer_id| w.buffer_id == buffer_id) + }) + } + + fn probe_display_target_inner( + &self, + fid: FrontendId, + existing: Option, + window: Option, ) -> Result { let view = self .views .get(&fid) .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; - let eligible = |id: WindowId| { - self.windows.get(&id).is_some_and(|w| { - !w.params.dedicated || existing.is_some_and(|buffer_id| w.buffer_id == buffer_id) - }) - }; + let eligible = |id: WindowId| self.window_accepts_buffer(id, existing); if let Some(target) = window { if !view.layout.iter_ids().contains(&target) { return Err(format!( @@ -3563,7 +3697,7 @@ impl EditorCore { .windows .get(&target) .ok_or_else(|| format!("display: window {} is not live", target.raw()))?; - if window.params.dedicated && window.buffer_id != request.buffer_id { + if !self.window_accepts_buffer(target, Some(request.buffer_id)) { return Err(format!( "display: window {} is dedicated to another buffer", target.raw() @@ -5300,6 +5434,45 @@ mod tests { assert!(s.active_window_for(FrontendId::LOCAL).is_some()); } + /// Journey Stage 1a (Q#JR14f): the three decisive rows of the shared + /// eligibility predicate. + /// + /// The `None` row is the one that exists for `commit_to`, and it is + /// not a "don't care": a directory open validates its destination + /// *before* the handler creates the buffer that will land there, so + /// there is no incoming id to compare and a dedicated window must be + /// refused. Approving it would let the handler claim and paint, and + /// the display would refuse afterwards — after the mutations the + /// preflight exists to prevent. + #[test] + fn window_accepts_buffer_matrix() { + let mut s = fresh(); + let window = s.views[&FrontendId::LOCAL].active; + let current = s.windows[&window].buffer_id; + let other = s.registry.borrow_mut().create(String::from("other")); + + // Undedicated: accepts anything, including "not decided yet". + assert!(s.window_accepts_buffer(window, Some(current))); + assert!(s.window_accepts_buffer(window, Some(other))); + assert!(s.window_accepts_buffer(window, None)); + + s.windows.get_mut(&window).expect("live").params.dedicated = true; + + // Dedicated: only what it already shows. + assert!( + s.window_accepts_buffer(window, Some(current)), + "a dedicated window still accepts the buffer it displays" + ); + assert!( + !s.window_accepts_buffer(window, Some(other)), + "a dedicated window refuses a different buffer" + ); + assert!( + !s.window_accepts_buffer(window, None), + "a dedicated window refuses an as-yet-unbuilt replacement" + ); + } + #[test] fn register_and_unregister_frontend_view() { // T M10.8 — the lifecycle API the dispatcher uses on attach diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index aa5de88..b624a00 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -3650,9 +3650,74 @@ fn install_path_module(lua: &Lua) -> mlua::Result { ) })?, )?; + // Journey Stage 1a (Q#JR7): the directory fallback. + // + // The resolver for a directory open is a two-tier arrangement, and + // the split is forced by how registration works rather than chosen + // for elegance. `path.open-directory` is a short-circuit hook that + // **no builtin subscribes to** — because `HookRegistry::add` only + // appends and builtins load before `init.lua`, a subscribing builtin + // would always claim first and no user listener could ever run. So + // the hook is the user's chain, and the default surface is this + // slot, consulted only when the chain declines. + // + // A slot, not a `pmacs.config` setting: `ConfigValue` is four + // scalars and a handler is none of them (the same reason terminal + // profiles could not be settings). It is an UNOWNED singleton — + // last writer wins, no owning package, no `SourceLocation`, no + // removal lifecycle, absent from every inspection surface. That is a + // real `COHERENCE.md` §13 gap, recorded rather than dressed up: when + // §20 Priority 3 lands registration ownership and `hook.remove`, + // this becomes an ordinary lowest-priority subscription carrying its + // owner and this slot is deleted rather than extended. + // + // Readable as `pmacs.path.directory_handler` so a replacement can + // capture and chain to the previous one; `nil` disables directory + // opening entirely, which is what makes that path testable. + path.set("directory_handler", mlua::Value::Nil)?; + path.set( + "set_directory_handler", + lua.create_function(|lua, handler: mlua::Value| { + match &handler { + mlua::Value::Nil | mlua::Value::Function(_) => {} + other => { + return Err(mlua::Error::runtime(format!( + "pmacs.path.set_directory_handler: expected a function or nil, got {}", + other.type_name() + ))); + } + } + let pmacs: Table = lua.globals().get("pmacs")?; + let path: Table = pmacs.get("path")?; + path.set("directory_handler", handler)?; + Ok(()) + })?, + )?; Ok(path) } +/// Lua handle for a captured directory destination (Q#JR14d). +/// +/// Deliberately **nonconstructible from Lua** and read-only. The same +/// value is passed to every `path.open-directory` listener in turn: as a +/// table, an earlier listener could mutate it and then decline, +/// redirecting later listeners or the fallback to a window the user +/// never asked for — and any Lua could fabricate a plausible +/// frontend/window/buffer triple and hand it to `commit_to`. Userdata +/// with no constructor and no setters makes both unrepresentable rather +/// than merely discouraged. +/// +/// The single accessor exists because dired needs the exact window for +/// its `display{window = …}` target; nothing needs the frontend or the +/// captured buffer, which stay private to the preflight. +pub(crate) struct DirectoryDestinationLua(pub(crate) crate::editor_core::DirectoryDestination); + +impl mlua::UserData for DirectoryDestinationLua { + fn add_methods>(methods: &mut M) { + methods.add_method("window", |_, this, ()| Ok(this.0.window.raw())); + } +} + /// Build the `pmacs.ansi.*` table. The only entry today is /// `parser()`; future additions (e.g. an event-table-validator /// helper) live alongside it. @@ -6938,6 +7003,26 @@ pub fn install_async( )?; } + // Journey Stage 1a (Q#JR14b): `pmacs.window.commit_to` runs its + // callback inside a Rust-stack RAII scope. Yielding out of that + // scope would let the guard's dynamic extent and the coroutine's + // suspension diverge — the guard would restore the frontend override + // while the continuation is still parked, so the rest of the commit + // would silently run ambient again, which is the exact bug the scope + // exists to prevent. `Handle:await` therefore refuses inside it. + // + // Enforced here rather than documented in the framing, because a + // rule that only exists in prose is one a future caller breaks + // without noticing. + async_mod.set( + "_in_commit_scope", + lua.create_function(|lua, ()| { + Ok(lua + .app_data_ref::() + .is_some_and(|scope| scope.active())) + })?, + )?; + { let rt = runtime.clone(); async_mod.set( diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index f4833ef..4228e0c 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -44,8 +44,22 @@ use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId}; /// call falls back to the ambient active frontend, exactly as the /// terminal surface does. pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId { - lua.app_data_ref::() - .and_then(|origin| origin.current()) + // Journey Stage 1a (Q#JR14e): the background scope wins. + // + // Order is deliberate — scoped override, then interactive origin, + // then ambient. A `commit_to` callback runs for the frontend that + // *requested* the work, and it must win over whatever happens to be + // dispatching when the worker settles. It is a separate slot rather + // than a reuse of the interactive origin because that origin is + // authenticated user-command authority (the pre-edit unfold guard, + // command-boundary rotation, and the terminal surface all key off + // it), and a background continuation must not acquire it. + lua.app_data_ref::() + .and_then(|scope| scope.current()) + .or_else(|| { + lua.app_data_ref::() + .and_then(|origin| origin.current()) + }) .unwrap_or_else(|| core.borrow().active_frontend_key()) } @@ -350,6 +364,111 @@ pub(crate) fn finish_adopter_placement( a coherent surface" )] pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> { + { + let cc = core.clone(); + win.set( + "commit_to", + lua.create_function( + move |lua, + (dest, body): (mlua::AnyUserData, mlua::Function)| + -> mlua::Result { + // Journey Stage 1a (Q#JR14). Preflight FIRST, then + // scope, then run. The ordering is the whole point: + // an async handler mutates real state (dired claims + // a buffer, registers a handle, captures `prev`, and + // paints) long before it reaches any call that could + // refuse. Validating at display time is four + // mutations too late and leaves a hidden buffer + // behind, so every destination precondition is + // checked before the callback is invoked at all. + let dest = dest + .borrow::() + .map_err(|_| { + mlua::Error::runtime( + "pmacs.window.commit_to: expected a destination captured by \ + the editor (it cannot be constructed from Lua)", + ) + })? + .0; + + // 1. The requesting frontend still has a layout. + let refusal = { + let core = cc.borrow(); + if !core.views.contains_key(&dest.frontend) { + Some("requesting frontend is gone".to_string()) + } else if !core + .views + .get(&dest.frontend) + .is_some_and(|view| view.layout.iter_ids().contains(&dest.window)) + { + // 2. The destination window is still live in it. + Some(format!("window {} is gone", dest.window.raw())) + } else if core + .windows + .get(&dest.window) + .is_some_and(|w| w.buffer_id != dest.buffer) + { + // 3. Stale intent (Q#JR14c): the user + // replaced the buffer while the work was + // in flight. Their action is newer + // information than the request, so the + // request loses. + Some(format!( + "window {} now shows another buffer", + dest.window.raw() + )) + } else if !core.window_accepts_buffer(dest.window, None) { + // 4. Replaceability (Q#JR14f). `None` + // because the replacement does not exist + // yet — passing the captured buffer would + // approve a window dedicated to *it*, and + // the handler's different buffer would be + // refused later, after mutating. + Some(format!("window {} is dedicated", dest.window.raw())) + } else { + None + } + }; + if let Some(reason) = refusal { + let mut out = mlua::MultiValue::new(); + out.push_back(mlua::Value::String(lua.create_string(reason.as_bytes())?)); + out.push_front(mlua::Value::Boolean(false)); + return Ok(out); + } + + let scope = lua + .app_data_ref::() + .ok_or_else(|| { + mlua::Error::runtime( + "pmacs.window.commit_to: no frontend scope installed", + ) + })? + .clone(); + let commit = lua + .app_data_ref::() + .ok_or_else(|| { + mlua::Error::runtime( + "pmacs.window.commit_to: no commit scope installed", + ) + })? + .clone(); + // Both the override and the core's ambient + // `active_frontend` are restored when this guard + // drops -- on the normal return AND on a raising + // callback, which is why the result is captured + // rather than `?`-propagated through the drop. + let result = { + let _guard = scope.enter(&cc, &commit, dest.frontend); + body.call::(()) + }; + let mut out = result?; + out.push_front(mlua::Value::Boolean(true)); + Ok(out) + }, + )?, + )?; + } + { let cc = core.clone(); win.set( @@ -397,10 +516,33 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result .probe_display_target(fid, existing, explicit_window) .map_err(mlua::Error::runtime)?; // 3. Load, dedup, or create the path-backed buffer. - let (buffer_id, fire) = cc + // + // Journey Stage 1a (Q#JR13): a DIRECTORY raises here + // and does NOT enter the directory resolver chain. + // `display_file` is "put this file in a window", not + // a CLI router — and `find-file`'s accept arm + // (`builtin/commands/default.lua`) wraps this call in + // a `pcall` whose comment guarantees that "only a + // real failure (a directory, a permission error) + // reaches here", pinned by + // `find_file_accepting_a_directory_reports_instead_of_raising`. + // Routing it into dired would silently change what + // `C-x C-f` on a directory does. Opening dired from + // find-file is a named deferral, not a side effect of + // the CLI work. + let (buffer_id, fire) = match cc .borrow_mut() .resolve_target_buffer(&path_buf) - .map_err(mlua::Error::runtime)?; + .map_err(mlua::Error::runtime)? + { + crate::editor_core::ResolvedTarget::Buffer { id, fire } => (id, fire), + crate::editor_core::ResolvedTarget::Directory { path } => { + return Err(mlua::Error::runtime(format!( + "pmacs.window.display_file: {} is a directory", + path.display() + ))); + } + }; // 4. Enter Q#BP4's transaction, so any hook observes // the DOCUMENT TARGET as active. let mut request = DisplayRequest::new(buffer_id); diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs new file mode 100644 index 0000000..591536d --- /dev/null +++ b/tests/journey_acceptance.rs @@ -0,0 +1,525 @@ +// tests/journey_acceptance.rs --- the golden product journey. + +//! The first cross-subsystem acceptance suite (`COHERENCE.md` §19, +//! `docs/journey-stage1a-framing.md` §5). +//! +//! Every other suite in the tree pins one subsystem's contract. This one +//! pins that the subsystems form a usable whole, walking `COHERENCE.md` +//! §2's twelve-step journey. Stage 1a seeds it with the steps that are +//! real today — 2 (launch unconfigured), 3 (open a real project), and 5 +//! (edit immediately). Steps 6–12 join as later stages make them real. +//! +//! **This file is a ratchet: stages add rows, none removes them.** +//! +//! Two disciplines it must keep: +//! +//! * **Drive the real entry point.** A directory arm with no production +//! caller passes every direct-call test, so step 3 goes through +//! `EditorState::open` — the same function `pmacs FILE` calls — and +//! not through `resolve_target_buffer`. +//! * **Pump to quiescence, never to a frame count.** Every listing is +//! worker-dispatched; `tick_async` resuming a coroutine in the frame +//! its result arrives does not bound when the worker finishes. +//! +//! Pins are labelled **N** (new behavior — must fail on full revert) or +//! **P** (preservation — legitimately green on the pre-image, falsified +//! by the named targeted mutation). See framing §6.0 for why the +//! distinction is load-bearing: an equivalence assertion between two +//! implementations that already agree proves nothing about structural +//! reuse. + +use std::path::Path; +use std::time::{Duration, Instant}; + +use pmacs::editor::EditorState; +use pmacs::editor_core::normalize_buffer_path; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Drive the async runtime to quiescence — no parked coroutine, no +/// pending worker job. The directory listing is invisible until this +/// returns, and how many frames it takes is not knowable in advance. +fn pump(s: &mut EditorState) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let idle: bool = eval( + s, + "return pmacs._async.parked_count() == 0 and pmacs._async.pending_count() == 0", + ); + if idle { + return; + } + assert!(Instant::now() < deadline, "async pump deadline exceeded"); + s.tick_async(); + } +} + +/// A project a journey can plausibly be run against. +fn project() -> TempDir { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("alpha.txt"), b"alpha\n").expect("write alpha"); + std::fs::write(td.path().join("beta.txt"), b"beta\n").expect("write beta"); + td +} + +fn canon(path: &Path) -> String { + normalize_buffer_path(path.to_path_buf()) + .to_string_lossy() + .into_owned() +} + +fn active_name(s: &EditorState) -> String { + eval(s, "return pmacs.window.buffer():name()") +} + +fn active_text(s: &EditorState) -> String { + eval( + s, + "local b = pmacs.window.buffer()\nreturn b:slice(0, b:len())", + ) +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +fn buffer_count(s: &EditorState) -> usize { + s.core.borrow().registry.borrow().ids().len() +} + +/// Open through the **real** startup entry point, as `pmacs PATH` does. +fn launch(path: &Path) -> EditorState { + let mut s = EditorState::open(path.to_path_buf()).expect("startup must not fail"); + exec(&s, "pmacs.lsp.config = {}"); + pump(&mut s); + s +} + +// --------------------------------------------------------------------------- +// Step 2 — launch unconfigured +// --------------------------------------------------------------------------- + +/// **N** — the editor starts with no configuration and no arguments. +#[test] +fn journey_step2_launches_unconfigured_into_scratch() { + let s = EditorState::new(); + assert_eq!(active_name(&s), "*scratch*"); + assert!( + status(&s).is_empty(), + "a clean launch reports no error; got {:?}", + status(&s) + ); +} + +// --------------------------------------------------------------------------- +// Step 3 — open a real project +// --------------------------------------------------------------------------- + +/// **N1** — `pmacs .` opens the directory. +/// +/// The headline of Stage 1a and of `COHERENCE.md` §2's "broken at step +/// 3" grade. Before the directory arm this construction returned +/// `Err(EISDIR)` and `main` exited 1. +#[test] +fn journey_step3_opening_a_directory_lists_it() { + let td = project(); + let s = launch(td.path()); + + let name = active_name(&s); + assert_eq!( + name, + format!("*dired:{}*", canon(td.path())), + "the active buffer must be the directory's dired buffer" + ); + let text = active_text(&s); + assert!( + text.contains("alpha.txt") && text.contains("beta.txt"), + "the listing must show the directory's entries; got {text:?}" + ); +} + +/// **N1b** — and it is a *successful* startup, not a rescued failure. +/// +/// Guards the specific regression shape: an implementation that opened +/// dired but still left an error on the status line would look right in +/// the assertion above while `pmacs .` still printed a diagnostic. +#[test] +fn journey_step3_directory_startup_reports_no_error() { + let td = project(); + let s = launch(td.path()); + assert!( + !status(&s).contains("cannot open"), + "a successful directory open must not leave an error status; got {:?}", + status(&s) + ); +} + +/// **N3** — an unreadable directory reports and leaves the session +/// running, rather than failing startup. +#[cfg(target_os = "linux")] +#[test] +fn journey_step3_unreadable_directory_reports_without_failing_startup() { + use std::os::unix::fs::PermissionsExt; + let td = tempfile::tempdir().expect("tempdir"); + let locked = td.path().join("locked"); + std::fs::create_dir(&locked).expect("mkdir"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod"); + + // Startup itself must succeed: the failure is the *listing*, which + // happens a tick later and belongs on the status line. + let s = launch(&locked); + assert!( + !status(&s).is_empty(), + "a failed listing must report through the status line" + ); + assert!( + !active_name(&s).starts_with("*dired:"), + "a failed listing must leave no dired buffer behind" + ); + + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).expect("restore"); +} + +/// **N9** — the resolver receives a canonical absolute path. +/// +/// Falsified by dropping the normalization in +/// `ResolvedTarget::Directory`: nothing else normalizes on that arm, +/// because no buffer is created and `set_buffer_path` never runs. +#[test] +fn journey_directory_resolver_receives_a_canonical_path() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + exec( + &s, + "seen = nil + pmacs.hook.add('path.open-directory', function(path) seen = path return false end)", + ); + + // A path with a redundant component, which only canonicalization removes. + let noisy = td.path().join("subdir").join(".."); + std::fs::create_dir_all(td.path().join("subdir")).expect("mkdir"); + s.open_directory_target(&noisy); + pump(&mut s); + + let seen: String = eval(&s, "return seen"); + assert_eq!( + seen, + canon(td.path()), + "the resolver must receive the canonical path, not the literal argument" + ); +} + +/// **N10** — with the handler cleared and nothing claiming, a directory +/// argument still starts successfully. +/// +/// The regression path back to exit 1. Reachable only because the +/// fallback is a clearable slot rather than a builtin hook subscription. +#[test] +fn journey_unclaimed_directory_starts_successfully_with_a_status() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + exec(&s, "pmacs.path.set_directory_handler(nil)"); + + let before = active_name(&s); + s.open_directory_target(td.path()); + pump(&mut s); + + assert_eq!( + active_name(&s), + before, + "with no handler the window keeps the buffer it had" + ); + assert!( + status(&s).contains(&canon(td.path())), + "the status must name the directory nothing surfaced; got {:?}", + status(&s) + ); +} + +// --------------------------------------------------------------------------- +// The resolver chain +// --------------------------------------------------------------------------- + +/// **N7** — first claimant wins, through an ordinary user listener, and +/// a claim suppresses the fallback. +#[test] +fn journey_resolver_chain_is_first_claimant_wins() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + exec( + &s, + "first, second, fallback_ran = false, false, false + pmacs.path.set_directory_handler(function() fallback_ran = true end) + pmacs.hook.add('path.open-directory', function() first = true return false end) + pmacs.hook.add('path.open-directory', function() second = true return false end)", + ); + + s.open_directory_target(td.path()); + pump(&mut s); + + assert!(eval::(&s, "return first"), "the first listener runs"); + assert!( + !eval::(&s, "return second"), + "a claim stops the fan-out before the second listener" + ); + assert!( + !eval::(&s, "return fallback_ran"), + "a claim suppresses the fallback" + ); +} + +/// **N8** — a raising listener suppresses the fallback *and* is +/// reported. +/// +/// Falsified by running the fallback when `errors` is non-empty (i.e. +/// treating a raise as a decline), or by making a raise yield +/// `proceed = true`. NOT falsified by keying suppression on `proceed` +/// alone — that is already correct, since a raise and a claim both give +/// `proceed == false`. +#[test] +fn journey_a_raising_resolver_suppresses_the_fallback_and_reports() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + exec( + &s, + "fallback_ran = false + pmacs.path.set_directory_handler(function() fallback_ran = true end) + pmacs.hook.add('path.open-directory', function() error('resolver exploded') end)", + ); + + s.open_directory_target(td.path()); + pump(&mut s); + + assert!( + !eval::(&s, "return fallback_ran"), + "a crashed resolver must not fall through to the default surface" + ); + assert!( + !status(&s).is_empty(), + "the failure must reach the status line, not only *errors*" + ); + let errors: String = eval( + &s, + "for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == '*errors*' then + return id:slice(0, id:len()) + end + end + return ''", + ); + assert!( + errors.contains("resolver exploded"), + "the failure must also reach the *errors* buffer; got {errors:?}" + ); +} + +// --------------------------------------------------------------------------- +// Step 5 — edit immediately +// --------------------------------------------------------------------------- + +/// **N11** — the journey's step-3-into-step-5 path: start on a +/// directory, visit a listed file, and type into *that* file. +/// +/// Deliberately not a self-insert into the dired buffer, whose intercept +/// rejects every edit — asserting an edit lands there would contradict +/// the read-only contract rather than pin the journey. +#[test] +fn journey_step5_editing_a_file_reached_through_the_directory() { + let td = project(); + let mut s = launch(td.path()); + assert!(active_name(&s).starts_with("*dired:")); + + let target = td.path().join("alpha.txt"); + exec( + &s, + &format!( + "pmacs.window.display_file({:?}, {{ select = true }})", + target.display().to_string() + ), + ); + pump(&mut s); + + exec(&s, "pmacs.window.buffer():insert(0, 'EDITED ')"); + let text = active_text(&s); + assert!( + text.starts_with("EDITED "), + "the edit must land in the visited file's buffer; got {text:?}" + ); + assert!( + buffer_count(&s) >= 2, + "the dired buffer and the visited file both exist" + ); +} + +// --------------------------------------------------------------------------- +// Preservation pins (P) — green on the pre-image; see the named mutation +// --------------------------------------------------------------------------- + +/// **P4** — startup shows the file in the *active* window. +/// +/// *Mutation:* replace `replace_active_buffer` with a bare +/// `install_buffer_in_window` into some other window in +/// `EditorState::open`. +/// +/// **Note, found during implementation:** this does NOT assert that the +/// initial scratch buffer is destroyed, because it is not. +/// `replace_active_buffer`'s doc comment claims it drops "any old +/// scratch buffer if the active window's previous buffer has no other +/// windows referencing it", but all it does is call +/// `switch_active_buffer`, which reassigns the window's `buffer_id` and +/// never removes anything. The stale scratch survives in the registry +/// today, on `main`, unrelated to this stage — so asserting otherwise +/// would have pinned a guarantee the editor does not make and failed on +/// the pre-image for the wrong reason. What the unification must +/// preserve is which window shows the file, and that is what this pins. +#[test] +fn preservation_opening_a_file_shows_it_in_the_active_window() { + let td = project(); + let target = td.path().join("alpha.txt"); + let s = EditorState::open(target.clone()).expect("open"); + + // The displayed name is the argument as given (`path.display()`), + // which both implementations have always produced -- the *stored* + // path is what gets normalized, inside `set_buffer_path`. + assert_eq!( + active_name(&s), + target.display().to_string(), + "the file must be in the active window, not merely loaded" + ); + let scratch_displayed: bool = eval( + &s, + "for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == '*scratch*' and pmacs.window.buffer() == id then + return true + end + end + return false", + ); + assert!( + !scratch_displayed, + "no window may still be showing the startup scratch buffer" + ); +} + +/// **P5** — the `NotFound` arm survives the unification. +/// +/// *Mutation:* delete the `NotFound` arm from `resolve_target_buffer`. +/// The arm most likely to be lost in a wholesale refactor, because its +/// failure mode is a hard error on a perfectly ordinary gesture. +#[test] +fn preservation_a_missing_path_becomes_a_new_file_buffer() { + let td = project(); + let fresh = td.path().join("not-yet.txt"); + let s = EditorState::open(fresh.clone()).expect("a missing path is not an error"); + + assert_eq!(status(&s), "[new file]"); + let len: usize = eval(&s, "return pmacs.window.buffer():len()"); + assert_eq!(len, 0, "a new-file buffer starts empty"); + assert!(!fresh.exists(), "nothing is written until save"); +} + +/// **P8** — a startup failure names the file. +/// +/// The message gained a `cannot open {path}: ` prefix in Stage 1a; the +/// *failure* is preserved, only its wording improved. Before, the bare +/// `io::Error` never named the path. +#[cfg(target_os = "linux")] +#[test] +fn preservation_an_unreadable_file_reports_with_its_path() { + use std::os::unix::fs::PermissionsExt; + let td = project(); + let locked = td.path().join("locked.txt"); + std::fs::write(&locked, b"secret\n").expect("write"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod"); + + let rendered = match EditorState::open(locked.clone()) { + Ok(_) => panic!("an unreadable file must fail"), + Err(error) => error.to_string(), + }; + + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o600)).expect("restore"); + + assert!( + rendered.contains("cannot open"), + "the message must say what failed; got {rendered:?}" + ); + assert!( + rendered.contains(&locked.display().to_string()), + "the message must name the file; got {rendered:?}" + ); +} + +/// **P7** — a directory argument suppresses desktop restore, on the same +/// reasoning a file argument does (Q#DS7): a positional argument means +/// "open this", not "restore my session". +/// +/// *Mutation:* pass `false` for `had_file` on the directory path. +#[test] +fn preservation_a_directory_argument_suppresses_desktop_restore() { + let td = project(); + let mut s = launch(td.path()); + // Arm the restore AFTER startup, then confirm the startup path + // treated its argument as a positional open: `had_file` is what + // `run` passes, and a directory must set it. + let had_file = true; + s.restore_desktop_if_armed(had_file); + assert!( + !status(&s).contains("desktop-restore"), + "a positional directory argument must not trigger a restore; got {:?}", + status(&s) + ); +} + +/// **P6** — `display_file` keeps its directory-is-an-error contract and +/// does not enter the resolver chain. +/// +/// *Mutation:* route `display_file` into the directory resolver. +/// `find_file_accepting_a_directory_reports_instead_of_raising` in +/// `find_file_acceptance.rs` is the companion pin through find-file's +/// real accept path; this one pins the primitive and the window state. +#[test] +fn preservation_display_file_still_refuses_a_directory() { + let td = project(); + let mut s = EditorState::open(td.path().join("alpha.txt")).expect("open"); + exec(&s, "pmacs.lsp.config = {}"); + let before_name = active_name(&s); + let before_count = buffer_count(&s); + + let raised: bool = eval( + &s, + &format!( + "local ok = pcall(pmacs.window.display_file, {:?}) return not ok", + td.path().display().to_string() + ), + ); + pump(&mut s); + + assert!(raised, "display_file on a directory must raise"); + assert_eq!( + active_name(&s), + before_name, + "a refused display_file must not change the active buffer" + ); + assert_eq!( + buffer_count(&s), + before_count, + "a refused display_file must not create a buffer" + ); +} From 25b07be97b97e2ce10947cad0bc750c1ecf72499 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:39:23 -0400 Subject: [PATCH 62/91] fix(journey): correct Q#JR3, report the post-dispatch buffer, unvacuate two pins Four review findings, all confirmed against the tree. Q#JR3 was false. `replace_active_buffer` does not drop the startup scratch buffer -- its body is one `switch_active_buffer` call, which reassigns the window's buffer_id and removes nothing. The claim came from that function's own doc comment, wrong for as long as it has existed, and rev 5 propagated it into the framing and into new documentation this branch added. Both comments are corrected here, because this PR was adding further false references to a claim P4 depends on. Actually removing the stale scratch is buffer-lifetime work and stays out. The daemon bootstrap could report the wrong buffer. The directory arm captured the destination id, ran the resolver chain synchronously, then returned the captured id -- so a handler that opened something synchronously through commit_to had already replaced the window's buffer, and the reply paired one buffer's snapshot with another's identity. It also returned early, skipping the post-hook revalidation the framing said stayed active. The arm now re-reads the destination after dispatch and rehomes through `non_side_target` as the file arm does. Pinned by a test whose handler claims synchronously. N11 tested neither RET nor self-insert: it called display_file and buf:insert directly, so it stayed green with dired's RET binding, its entry dispatch, and the editor's self-insert path all broken. Both gestures now go through dispatch_key. P7 is removed rather than weakened. Q#JR12 has nothing to pin -- `had_file = file.is_some()` and a directory is Some like any other, so no directory-specific branch exists to break. The old test never armed restore and hard-coded had_file, so it could not fail against any implementation. Also adds the daemon bootstrap pins (N2, N5) and fixes an insertion that had orphaned a `#[cfg(feature = "crdt")]` from the test it guarded -- which would have made one new test dark and one existing test escape its gate. Framing: docs/journey-stage1a-framing.md rev 6. --- docs/journey-stage1a-framing.md | 110 ++++++++++++++--- src/daemon.rs | 213 +++++++++++++++++++++++++++++++- src/editor.rs | 32 +++-- tests/journey_acceptance.rs | 104 +++++++++++----- 4 files changed, 401 insertions(+), 58 deletions(-) diff --git a/docs/journey-stage1a-framing.md b/docs/journey-stage1a-framing.md index 36f40e4..b5833cc 100644 --- a/docs/journey-stage1a-framing.md +++ b/docs/journey-stage1a-framing.md @@ -1,6 +1,7 @@ # Journey Stage 1a — open a directory, on one path -**Status: framing, rev 5, awaiting approval.** +**Status: framing, rev 6 — APPROVED at rev 5; rev 6 records +corrections found during implementation.** **Serves `COHERENCE.md` §2 (the golden product journey), §19 (coherence acceptance tests), §20 Priority 1.** @@ -76,6 +77,43 @@ acceptance tests), §20 Priority 1.** - The dired accessor spelling, revision heading, and Stage 2 ledger claim are corrected. +- rev 6 (2026-07-26) — **corrections found while implementing**, not a + new design round. Four, all confirmed against the tree: + - **Q#JR3 was false.** `replace_active_buffer` does *not* drop the + startup scratch buffer; its body is one `switch_active_buffer` call, + which reassigns `aw.buffer_id` and removes nothing. The claim came + from that function's own doc comment (`editor.rs:1071`), which has + been wrong for as long as it has existed, and rev 5 propagated it + into §2.2, §3, P4, and the decision list without checking the body. + Corrected in all four places; the stale comment is corrected in this + PR too, since this PR would otherwise add *more* false references to + it. **Actually removing the stale scratch is separate work** — + buffer-lifetime changes have their own consequences (what else holds + the id, what `C-x b` lists) and are not smuggled into a directory-open + stage. + - **The daemon bootstrap could report the wrong buffer** (§4.5). The + directory arm captured `dest.buffer`, ran the resolver chain + *synchronously*, then returned the captured id — so a handler that + opened something synchronously (through `commit_to`, the supported + way) had already replaced the window's buffer, and the reply would + pair one buffer's snapshot with another's identity. The early return + also skipped the post-hook revalidation this framing claimed stayed + active. Rev 6 decides: **report what the window actually holds after + the dispatch**, and rehome through `non_side_target` exactly as the + file arm does. + - **N11 tested neither `RET` nor self-insert.** It called + `display_file` and `buf:insert` directly, so it stayed green with + dired's `RET` binding, its entry dispatch, and the editor's + self-insert path all broken — most of what "the journey works" means. + Both gestures are now dispatched as real keys. + - **P7 was vacuous and is removed, not weakened.** Q#JR12 has nothing + to pin: `run` computes `had_file = file.is_some()` and a directory + path is `Some` like any other, so suppression is structural and the + named mutation would require inventing the branch first. The rev 5 + test additionally never armed restore and hard-coded `had_file`, so + it asserted nothing about `run`. Q#JR12 is downgraded to an + observation. + --- ## 0.5. Coherence impact (`COHERENCE.md` §20, required since #163) @@ -160,7 +198,7 @@ daemon bootstrap (`daemon.rs:1641`). **Local startup is not one of them** | Displayed name | `path.display()` raw (`editor.rs:772`) | `path.display()` raw | | `NotFound` arm | empty path-backed buffer, `[new file]` | identical | | Dedup | none | `find_buffer_for_path` | -| Window install | `replace_active_buffer` — drops the startup scratch (`editor.rs:797`) | none; caller installs | +| Window install | `replace_active_buffer` — switches the ACTIVE window (`editor.rs:797`). **It does not drop the startup scratch** (rev 6): its body is one `switch_active_buffer` call, which reassigns `aw.buffer_id` and removes nothing. The doc comment claiming otherwise was wrong before this stage and is corrected in this PR | none; caller installs | | Error type | `io::Error`, bare | `String`, prefixed `cannot open {path}: ` | **The two agree on every observable except the error prefix and the @@ -319,7 +357,9 @@ invariant rather than working around it. ## 3. The unification (Q#JR1) `EditorState::open` becomes a thin caller of `resolve_target_buffer`, -keeping `replace_active_buffer` (which drops the startup scratch, Q#JR3) +keeping `replace_active_buffer` (which switches the **active** window, +Q#JR3 as corrected in rev 6 — it does not destroy the old scratch, and +never did) and keeping its "fire the hook after the core borrow ends" structure (`editor.rs:786-795`) — listeners re-enter `pmacs.editor.*` and re-borrow the core (Q#JR1a). @@ -328,9 +368,15 @@ the core (Q#JR1a). `pmacs /root/secret` names the file, which today's bare message does not. This is the *only* user-visible change from the unification (§2.2). -**Q#JR12** — a directory argument counts as "had a file argument" and -suppresses desktop restore, on Q#DS7's reasoning: a positional argument -means "open this", not "restore my session". +**Q#JR12 (downgraded to an observation, rev 6)** — a directory argument +suppresses desktop restore, on Q#DS7's reasoning that a positional +argument means "open this" rather than "restore my session". This needs +no work and cannot be pinned: `run` computes `had_file = file.is_some()` +(`editor.rs:3152`), and a directory path is `Some` like any other, so +there is no directory-specific branch that could get it wrong. Rev 5 +carried an acceptance for it; that test never armed restore and +hard-coded `had_file`, asserting nothing, and is removed rather than +repaired. --- @@ -538,9 +584,26 @@ problem is solved. ### 4.5 Q#JR9 — what the bootstrap reply names, and what it shows `open_initial_target` on a `Directory` installs nothing: it dispatches the -resolver, then replies `Opened { buffer_id }` naming the buffer the fresh -view's primary document window already holds. §2.8's reassert reasserts -that same buffer — already correct, therefore harmless. +resolver, then replies `Opened { buffer_id }` naming **whatever the +destination window holds once that dispatch returns** — re-read, not the +id captured beforehand (Q#JR9b, rev 6). + +The distinction is not academic. The chain runs **synchronously**. +dired's handler defers, because its listing must await; a user's resolver +is under no such obligation, and one that opens something synchronously +through `commit_to` — the supported way to do it — has already replaced +the window's buffer by the time the reply is built. Reporting the +captured id would pair one buffer's snapshot with another's identity, and +the frontend would render a document nobody asked for. + +Re-reading also subsumes the case where a hook closed the window, so this +arm rehomes through `non_side_target` exactly as the file arm's reassert +does, rather than returning early and skipping that check — which rev 5's +implementation did while this section claimed the revalidation stayed +active. + +Absent a synchronous claimant the re-read yields the buffer the window +already held, which is the ordinary case. **That buffer is not necessarily `*scratch*`.** `build_fresh_frontend_view` clones **LOCAL's primary document buffer** (`daemon.rs:2997`) — M10.9 made @@ -723,8 +786,13 @@ is **removed rather than recast**: it proved nothing N1 does not. window unchanged; `find_file_accepting_a_directory_reports_instead_of_raising` passes unmodified. *Mutation:* route `display_file` into the resolver chain. -- **P7 — desktop restore stays suppressed (Q#JR12).** - *Mutation:* pass `false` for `had_file` on the directory path. +- **P7 — REMOVED in rev 6.** Q#JR12 is structural: `run` computes + `had_file = file.is_some()` and a directory path is `Some` like any + other, so there is no directory-specific branch to break and the named + mutation would have to invent one first. Rev 5's test never armed + restore and hard-coded `had_file`, so it could not fail against any + implementation. Removed rather than repaired — a green test that cannot + fail reads as coverage. - **P8 — startup errors name the file (Q#JR4).** A non-`NotFound`, non-directory failure produces a message containing `cannot open` and the path. *(Legitimately N-shaped for the prefix, P-shaped for the @@ -851,7 +919,12 @@ discovered late. - **Q#JR1** `EditorState::open` adopts `resolve_target_buffer` wholesale. - **Q#JR1a** The hook fires outside the core borrow. - **Q#JR2** *Withdrawn (rev 2)* — its premise was false. -- **Q#JR3** The scratch drop (`replace_active_buffer`) is preserved. +- **Q#JR3 (corrected rev 6)** Startup keeps using + `replace_active_buffer`, which switches the **active** window — not + because it drops the old scratch (it does not, and never did) but + because an `install_buffer_in_window` elsewhere would load the file + while leaving the user looking at scratch. Removing the stale scratch + buffer is separate work. - **Q#JR4** Startup errors gain the `cannot open {path}: ` prefix. - **Q#JR5** `resolve_target_buffer` returns a typed `ResolvedTarget`. - **Q#JR5b** Both `HookKind` types are written path-qualified. @@ -860,12 +933,17 @@ discovered late. builtins do not subscribe; dired is a replaceable fallback slot. - **Q#JR8** `ResolvedTarget::Directory` carries an explicitly normalized path. -- **Q#JR9** The bootstrap reply names the window's pre-existing buffer — - LOCAL's primary document buffer, not necessarily scratch. Accepted and - documented. +- **Q#JR9** The bootstrap reply names the destination window's buffer — + absent a synchronous claimant, LOCAL's primary document buffer, not + necessarily scratch. Accepted and documented. +- **Q#JR9b (rev 6)** That id is **re-read after the dispatch**, and the + arm rehomes through `non_side_target` rather than returning early: a + synchronous resolver may already have replaced the buffer. - **Q#JR10** An unclaimed directory with the handler cleared exits 0 with a status message. -- **Q#JR12** A directory argument suppresses desktop restore. +- **Q#JR12 (observation, rev 6)** A directory argument suppresses + desktop restore structurally, via `had_file = file.is_some()`. No work, + no pin. - **Q#JR13** `display_file` keeps its directory-is-an-error contract. - **Q#JR14** The destination `{frontend, window, buffer}` is captured at resolve time; `commit_to` preflights and scopes the **entire** diff --git a/src/daemon.rs b/src/daemon.rs index 4a7e08f..1451a0c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1661,11 +1661,51 @@ fn open_initial_target( let dest = editor .capture_directory_destination(frontend_id, origin_window) .ok_or_else(|| format!("cannot open {}: no document window", path.display()))?; - let buffer_id = dest.buffer; editor.dispatch_directory_open(&path, dest); editor.reconcile_panel_layout(frontend_id); + + // The reply must name what the window ACTUALLY holds now, not + // what it held before the dispatch. + // + // The chain runs synchronously. dired's handler defers (it + // spawns a coroutine for the listing), but a user's resolver + // is under no such obligation: a handler that opens something + // synchronously -- through `commit_to`, which is exactly the + // supported way to do it -- has already replaced this + // window's buffer by the time we get here. Reporting the + // captured id would then send the snapshot of one buffer and + // the identity of another, and the frontend would render a + // document nobody asked for. + // + // Re-reading also covers the case a hook closed the window, + // which is why this rehomes through `non_side_target` exactly + // as the file arm's reassert does rather than returning early + // and skipping that check. + let mut core = editor.core.borrow_mut(); + core.active_frontend = frontend_id; + let destination = if core + .views + .get(&frontend_id) + .is_some_and(|view| view.layout.iter_ids().contains(&origin_window)) + { + origin_window + } else { + core.non_side_target(frontend_id) + .map_err(|error| format!("cannot reselect {}: {error}", path.display()))? + }; + core.focus_window(frontend_id, destination); + let buffer_id = core + .windows + .get(&destination) + .map(|window| window.buffer_id) + .ok_or_else(|| format!("cannot reselect {}: window died", path.display()))?; return Ok(OpenedInitialTarget { buffer_id, + // False whether or not the chain replaced the buffer: an + // untouched destination is pre-existing and already + // published, and a buffer a synchronous handler installed + // went through the ordinary display path, which publishes + // on its own terms. publish_to_replicas: false, }); } @@ -4924,6 +4964,177 @@ mod tests { ); } + /// **N2** (Journey Stage 1a) — a DIRECTORY initial target reaches + /// readiness instead of failing. + /// + /// This deliberately supersedes the directory half of the GPU + /// initial-target framing's Q#GT6 and its acceptance 10, which + /// required `IsADirectory` to fail before window creation. + /// Permission-denied and every other pre-readiness failure keep that + /// contract. + #[test] + fn initial_target_directory_reaches_ready() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("alpha.txt"), b"alpha\n").expect("write"); + + let mut editor = EditorState::new(); + editor + .lua_host + .lua() + .load("pmacs.lsp.config = {}") + .exec() + .expect("wipe lsp config"); + let fid = FrontendId(131); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + let opened = open_initial_target( + &mut editor, + fid, + InitialTarget { + path: dir.path().as_os_str().as_bytes().to_vec(), + cwd: dir.path().as_os_str().as_bytes().to_vec(), + }, + ) + .expect("a directory target must reach readiness, not fail"); + + // The reply names a live buffer in a live document window: a + // valid, ready session. The listing arrives later, asynchronously. + let core = editor.core.borrow(); + assert!( + core.registry.borrow().contains(opened.buffer_id), + "the reported buffer must exist so its snapshot can be sent" + ); + let active = core.views[&fid].active; + assert_eq!( + core.windows[&active].buffer_id, opened.buffer_id, + "the reported buffer is the one the document window shows" + ); + } + + /// **N5** — the bootstrap buffer is not necessarily `*scratch*`. + /// + /// `build_fresh_frontend_view` clones LOCAL's PRIMARY DOCUMENT + /// buffer, so when LOCAL holds a real document the fresh session + /// briefly displays and snapshots it. Q#JR9 accepts that rather than + /// introducing a placeholder; this observes it instead of assuming. + #[test] + fn initial_target_directory_reports_a_non_scratch_primary() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + + let dir = tempfile::tempdir().expect("tempdir"); + let doc = dir.path().join("already-open.txt"); + std::fs::write(&doc, b"local document\n").expect("write"); + + // LOCAL holds a real document, not scratch. + let mut editor = EditorState::open(doc.clone()).expect("open"); + editor + .lua_host + .lua() + .load("pmacs.lsp.config = {}") + .exec() + .expect("wipe lsp config"); + let local_primary = editor + .core + .borrow() + .primary_document_buffer(FrontendId::LOCAL) + .expect("LOCAL always has a document window"); + + let fid = FrontendId(132); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + let opened = open_initial_target( + &mut editor, + fid, + InitialTarget { + path: dir.path().as_os_str().as_bytes().to_vec(), + cwd: dir.path().as_os_str().as_bytes().to_vec(), + }, + ) + .expect("a directory target must reach readiness"); + + assert_eq!( + opened.buffer_id, local_primary, + "the bootstrap reply names LOCAL's primary document buffer, \ + which is a real document here rather than *scratch*" + ); + } + + /// **N2b (rev 6)** — a resolver that claims SYNCHRONOUSLY is reported + /// correctly. + /// + /// The bug this pins: the arm captured the destination buffer id + /// *before* dispatching the chain and reported that. The chain runs + /// synchronously, so a handler that opens something immediately — + /// through `commit_to`, the supported way — had already replaced the + /// window's buffer, and the reply paired one buffer's snapshot with + /// another's identity. + /// + /// Falsified by reporting the captured id instead of re-reading. + #[test] + fn initial_target_directory_reports_what_a_synchronous_handler_installed() { + use crate::editor::EditorState; + use crate::protocol::FrontendId; + + let dir = tempfile::tempdir().expect("tempdir"); + + let mut editor = EditorState::new(); + editor + .lua_host + .lua() + .load( + "pmacs.lsp.config = {} + claimed = pmacs.buffer.create('*claimed*') + pmacs.path.set_directory_handler(function(path, dest) + pmacs.window.commit_to(dest, function() + pmacs.window.display(claimed, { select = true }) + end) + end)", + ) + .exec() + .expect("install a synchronous handler"); + + let fid = FrontendId(133); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + let opened = open_initial_target( + &mut editor, + fid, + InitialTarget { + path: dir.path().as_os_str().as_bytes().to_vec(), + cwd: dir.path().as_os_str().as_bytes().to_vec(), + }, + ) + .expect("a claimed directory target must reach readiness"); + + // Compare by NAME: the reported id must be the handler's buffer, + // and naming it is what makes the failure legible when it is not. + let core = editor.core.borrow(); + let reported_name = core + .registry + .borrow() + .get(opened.buffer_id) + .expect("the reported buffer exists") + .name() + .to_string(); + assert_eq!( + reported_name, "*claimed*", + "the reply must name what the handler installed, not the \ + buffer captured before the dispatch" + ); + let active = core.views[&fid].active; + assert_eq!( + core.windows[&active].buffer_id, opened.buffer_id, + "…and that buffer is what the window shows" + ); + } + /// Bottom-panel §1.3 #1/#3/#21 — the three Projection producers whose /// only production caller is `dispatcher_loop`, pinned at the named /// seams that loop calls. Round 2 finding: reverting any of them to diff --git a/src/editor.rs b/src/editor.rs index 1e94d92..9c204bc 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -891,10 +891,19 @@ impl EditorState { /// /// * **The window install.** `resolve_target_buffer` deliberately /// does not touch windows, so the caller places the buffer. - /// Startup uses [`Self::replace_active_buffer`] specifically - /// because it drops the just-created scratch buffer; an - /// `install_buffer_in_window` here would leave a stray `*scratch*` - /// behind every `pmacs FILE` (Q#JR3). + /// Startup uses [`Self::replace_active_buffer`], which switches + /// the ACTIVE window — an `install_buffer_in_window` into some + /// other window would load the file and leave the user looking at + /// scratch (Q#JR3). + /// + /// It does **not** destroy the scratch buffer, despite what + /// `replace_active_buffer`'s own doc comment has long claimed: + /// that function only calls `switch_active_buffer`, which + /// reassigns the window's `buffer_id` and removes nothing. The + /// startup scratch survives in the registry, and did before this + /// stage too. Changing that is buffer-lifetime work with its own + /// consequences (what else may hold the id, what `C-x b` should + /// list) and is deliberately not smuggled in here. /// * **Firing the hook outside the core borrow.** Listeners /// re-enter `pmacs.editor.*`, which re-borrows the core /// (Q#JR1a) — the same reason the daemon bootstrap and @@ -1068,9 +1077,18 @@ impl EditorState { } } - /// Switch the active window to `buffer_id`, dropping any old - /// scratch buffer if the active window's previous buffer has no - /// other windows referencing it. Returns silently on a stale id. + /// Switch the active window to `buffer_id`. Returns silently on a + /// stale id. + /// + /// **Corrected (Journey Stage 1a).** This comment previously claimed + /// it dropped "any old scratch buffer if the active window's + /// previous buffer has no other windows referencing it". It never + /// did: the body is one `switch_active_buffer` call, which reassigns + /// `aw.buffer_id` and removes nothing from the registry. The claim + /// was load-bearing enough that a framing decision (Q#JR3) and an + /// acceptance pin were written against it before anyone checked the + /// body. Removing the stale scratch may well be worth doing; it is + /// separate work, and this comment no longer promises it. fn replace_active_buffer(&self, buffer_id: crate::buffer::BufferId) { let mut core = self.core.borrow_mut(); let _ = core.switch_active_buffer(buffer_id); diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index 591536d..f1e4c03 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -31,8 +31,10 @@ use std::path::Path; use std::time::{Duration, Instant}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use pmacs::editor::EditorState; use pmacs::editor_core::normalize_buffer_path; +use pmacs::protocol::FrontendId; use tempfile::TempDir; // --------------------------------------------------------------------------- @@ -65,6 +67,35 @@ fn pump(s: &mut EditorState) { } } +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn type_char(s: &mut EditorState, c: char) { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE)); +} + +/// The 0-based line an entry renders on, found by its trailing name +/// column -- the same shape `dired_acceptance` uses. +fn line_of(s: &EditorState, name: &str) -> usize { + let text = active_text(s); + for (index, line) in text.lines().enumerate() { + if line.trim_end().ends_with(name) { + return index; + } + } + panic!("no listing line for {name:?} in:\n{text}"); +} + /// A project a journey can plausibly be run against. fn project() -> TempDir { let td = tempfile::tempdir().expect("tempdir"); @@ -333,11 +364,18 @@ fn journey_a_raising_resolver_suppresses_the_fallback_and_reports() { // Step 5 — edit immediately // --------------------------------------------------------------------------- -/// **N11** — the journey's step-3-into-step-5 path: start on a -/// directory, visit a listed file, and type into *that* file. +/// **N11** — the journey's step-3-into-step-5 path, through the real +/// input path at every step: start on a directory, press `RET` on a +/// listed file, then type a character into it. +/// +/// Rev 6 correction: this previously called `display_file` and +/// `buf:insert` directly, so it stayed green with dired's `RET` binding, +/// its entry dispatch, or the editor's self-insert path all broken — +/// which is most of what "the journey works" is supposed to mean. Both +/// gestures are now dispatched as keys. /// /// Deliberately not a self-insert into the dired buffer, whose intercept -/// rejects every edit — asserting an edit lands there would contradict +/// rejects every edit: asserting an edit lands there would contradict /// the read-only contract rather than pin the journey. #[test] fn journey_step5_editing_a_file_reached_through_the_directory() { @@ -345,21 +383,24 @@ fn journey_step5_editing_a_file_reached_through_the_directory() { let mut s = launch(td.path()); assert!(active_name(&s).starts_with("*dired:")); - let target = td.path().join("alpha.txt"); - exec( - &s, - &format!( - "pmacs.window.display_file({:?}, {{ select = true }})", - target.display().to_string() - ), - ); + // Seat on the entry, then VISIT it with the real key. + let line = line_of(&s, "alpha.txt"); + exec(&s, &format!("pmacs.editor.move_to_line({line})")); + press(&mut s, KeyCode::Enter); pump(&mut s); - exec(&s, "pmacs.window.buffer():insert(0, 'EDITED ')"); + assert_eq!( + active_name(&s), + td.path().join("alpha.txt").display().to_string(), + "RET on a listed file must visit it" + ); + + // And type into it with the real key. + type_char(&mut s, 'X'); let text = active_text(&s); assert!( - text.starts_with("EDITED "), - "the edit must land in the visited file's buffer; got {text:?}" + text.starts_with('X'), + "a self-insert must land in the visited file's buffer; got {text:?}" ); assert!( buffer_count(&s) >= 2, @@ -466,26 +507,21 @@ fn preservation_an_unreadable_file_reports_with_its_path() { ); } -/// **P7** — a directory argument suppresses desktop restore, on the same -/// reasoning a file argument does (Q#DS7): a positional argument means -/// "open this", not "restore my session". -/// -/// *Mutation:* pass `false` for `had_file` on the directory path. -#[test] -fn preservation_a_directory_argument_suppresses_desktop_restore() { - let td = project(); - let mut s = launch(td.path()); - // Arm the restore AFTER startup, then confirm the startup path - // treated its argument as a positional open: `had_file` is what - // `run` passes, and a directory must set it. - let had_file = true; - s.restore_desktop_if_armed(had_file); - assert!( - !status(&s).contains("desktop-restore"), - "a positional directory argument must not trigger a restore; got {:?}", - status(&s) - ); -} +// **P7 — removed in rev 6, not weakened.** +// +// Q#JR12 said a directory argument must suppress desktop restore, and +// rev 5 carried a pin for it. There is nothing to pin. `run` computes +// `had_file = file.is_some()` (`editor.rs:3152`) and a directory path is +// `Some` like any other, so the suppression is structural: no +// directory-specific branch exists that could get it wrong, and the +// named mutation ("pass false for `had_file` on the directory path") +// would require inventing the branch first. +// +// The rev 5 test also never armed desktop restore and hard-coded +// `had_file = true` after startup, so it asserted nothing about `run`'s +// decision and would have passed against any implementation. Keeping a +// green test that cannot fail is worse than having none: it reads as +// coverage. Q#JR12 is downgraded to an observation in the framing. /// **P6** — `display_file` keeps its directory-is-an-error contract and /// does not enter the resolver chain. From d2ab3761c75060e0f79a87d8301d06589071838f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:45:12 -0400 Subject: [PATCH 63/91] docs: correct the non-adopter attribution and two stale anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 6. Documentation only; no code, no protocol change. 1. **The four-writer table mapped the wrong buffers to `ensure_slot`.** Verified at the call sites: `ensure_slot` has exactly two callers, `*compilation*` (`compile.lua:1090`) and `*shell-command*` (`:1125`). `*search-results*` is an **independent panel** in `builtin/commands/default.lua` with its own intercept (`:869`), round-trip mark and writes; `compile.lua` declares its name only to answer a predicate (`:216`), which is what made it look like a third slot. Round 5 fixed an undercount and introduced a misattribution in the same paragraph — the count was right, the mechanism was not. The table is now keyed by **writer**, not by buffer, so the mapping cannot silently drift again: four mechanisms, five buffers. It carries an explicit "do not read `ensure_slot` as covering the search panel" note, because that is the specific wrong inference. Corrected identically in `COHERENCE.md` §14 and the framing's deferred-lane text, which both carried the error. The scope claim is narrowed with it. "Every generated buffer outside copy mode" was too wide: `*workers*`, `*help*` and `*buffer-list*` are generated but do not use this idiom, and the REPL package's intercept (`packages/repl/init.lua:187`) is an op-filtering editing policy rather than a read-only panel. The claim is now "every remaining intercept-protected writer", and the two excluded groups are named so the next reader does not have to re-derive the boundary. 2. **The recovery floor contradicted itself.** The canonical-base line said the check accepts `a27f646` or anything newer while the check below required `74301d1`. The floor genuinely advanced; the prose now says so outright — a tree at `a27f646` no longer passes — and states why the floor must move with the base rather than trailing it. 3. **Two anchors survived the integration.** Lean 4 Stage 4a said it was part of "the `fe8b8ba` anchor above" when the anchor had become `74301d1`; it now refers to the anchor rather than restating a commit, which is what let it go stale. And #168's closed entry called its own `fe8b8ba` figure "the live figure" — it is a reading taken at `1b6a084`, kept as history, and now says so and points at the coverage lane as the single authority with an explicit "do not quote this one forward". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- COHERENCE.md | 21 ++++++----- docs/active-work.md | 15 +++++--- docs/agent-handoff.md | 37 ++++++++++++------- docs/terminal-config-and-copy-mode-framing.md | 23 +++++++----- 4 files changed, 59 insertions(+), 37 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 6aaad38..e2d0143 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -1222,20 +1222,23 @@ Primitive-by-primitive against the list above: `compile.lua`'s own comment admits ("command/menu undo stays dispatchable"). `Buffer::set_generated_contents` (write + discard history + assert `read_only`, in one authorized call) now fixes this - for the terminal snapshot; **four writers have not yet adopted it and - remain emptiable** — listview panels, `*compilation*`, - `*search-results*` (the same `ensure_slot` mechanism in `compile.lua`), - and dired buffers, all of which pair an erroring intercept with - `bypass_intercept` writes over a still-writable rope. **A second half of the same + for the terminal snapshot; **four writer mechanisms have not yet adopted + it and remain emptiable** — listview panels; `compile.lua`'s + `ensure_slot`, which serves `*compilation*` **and** `*shell-command*`; + the independent `*search-results*` panel in + `builtin/commands/default.lua`; and dired buffers. All pair an erroring + intercept with `bypass_intercept` writes over a still-writable rope. + (`*workers*`, `*help*` and `*buffer-list*` are generated but do not use + this idiom.) **A second half of the same caveat, found in round 3: a rope write is only half of an edit.** The owner-authorized write must be fanned out to the windows showing the buffer and queued for replica mirrors, or the displaying window keeps a line index describing the previous contents and the next paint indexes the new rope with stale ranges. Adoption is therefore not a - one-line swap — and the two `compile.lua` slots **append** rather than - replacing wholesale, so they need a streaming variant of the primitive - that does not exist yet. Listview and dired already write whole-buffer - replaces and are the cheap half. + one-line swap — and the three appending buffers (`*compilation*`, + `*shell-command*`, `*search-results*`) need a streaming variant of the + primitive that does not exist yet. Listview and dired already write + whole-buffer replaces and are the cheap half. - **Diagnostics collection** ✓ — `DiagnosticStore` + signs + unified `error.next` source. - **Transient selector** ✓ — the minibuffer (though its `source` diff --git a/docs/active-work.md b/docs/active-work.md index 503689f..a978db0 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -36,8 +36,12 @@ landed regardless of what a lane says. #158, dired Stage 1 #165, the GPU terminal input fix #166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, and the minimap blank-slab fix #159; - protocol v20). The previous snapshot named `a27f646`; the recovery - check below accepts it or anything newer. + protocol v20). The previous snapshot named `a27f646`, and **the + recovery floor has advanced past it**: the check below now requires + `74301d1` or newer, so a tree at `a27f646` no longer passes. That is + deliberate — the floor moves with the base, because a check that + accepts an older commit than the declared base passes on a tree the + rest of this file does not describe. **Lanes below that name an older base have not been re-based; derive their integration surface from `git diff ..main`.** - On the transfer source, `origin/main` named a release mirror at @@ -733,9 +737,10 @@ git worktree add --track \ Branch `gpu-terminal-input` and worktree `../pmacs-gui-term-input` retained. **Its landed-doc pair MERGED as #168** (`main` @ `1b6a084`, 2026-07-26): #166 recorded as landed, the CI `crdt`-coverage gap - measured (**264 tests dark workspace-wide**, 177 in the library — as - of `1b6a084`; the live figure is 273/185 at `fe8b8ba`, and the - coverage lane above is the authority), the + measured (**264 tests dark workspace-wide**, 177 in the library — a + reading taken at `1b6a084` and kept here only as history. **The CI + `crdt`-coverage lane above is the authority for the live figure**; + do not quote this one forward), the vterm audit corrected — "only 3 of 9 acceptances drive a real daemon" was optimistic; without the frontend binary the honest number is **2** — and the a37 findings folded into the coverage lane. diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 7b1173f..f029192 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -157,7 +157,7 @@ commands, read `docs/active-work.md` immediately after this file. at the moment of the swap. - **Stage 4a (the typed-edit consumer chain) MERGED as #179** (branch `lean4-stage4a-typed-edit-chain`, framing rev 8; it is part - of the `fe8b8ba` anchor above). It is substrate only: `builtin/runtime/typed_edit.lua` owns the + of the main anchor above). It is substrate only: `builtin/runtime/typed_edit.lua` owns the single `buffer.after-edit` subscriber and the single one-shot read, `pair.lua` becomes its first registered consumer, and `tests/auto_pair_acceptance.rs` is unchanged by zero lines @@ -1036,24 +1036,33 @@ it lives in loro's `UndoManager`. That has no `clear`, and needs none — a manager records only what happens after construction, so `CrdtState::clear_undo_history` rebinds a fresh one to the same doc. -**Not yet adopted — and the inventory is four call sites, not two.** -Every generated buffer outside copy mode still uses the older idiom: -an erroring intercept plus `set_round_trip_input`, written through -`bypass_intercept`, with the rope left writable. All of them are +**Not yet adopted — the inventory is four writer mechanisms covering +five buffers.** *Every remaining intercept-protected writer* uses the +older idiom: an erroring intercept plus `set_round_trip_input`, written +through `bypass_intercept`, with the rope left writable. All are emptiable by `M-x buffer.undo`: -| buffer | writer | shape | +| writer | buffers | shape | |---|---|---| -| listview panels | `builtin/runtime/listview.lua:60-61` | delete-all + insert | -| `*compilation*` | `builtin/runtime/compile.lua` (`ensure_slot`) | **append** per output batch | -| `*search-results*` | same `ensure_slot` mechanism in `compile.lua` | **append** per match batch | -| dired buffers | `builtin/runtime/dired.lua:371` | whole-buffer replace | +| `builtin/runtime/listview.lua:60-61` | every listview panel | delete-all + insert | +| `builtin/runtime/compile.lua` (`ensure_slot`) | `*compilation*`, `*shell-command*` | **append** per output batch | +| `builtin/commands/default.lua:869` | `*search-results*` | reset per query, then **append** per match batch | +| `builtin/runtime/dired.lua:371` | every dired buffer | whole-buffer replace | + +**Do not read `ensure_slot` as covering the search panel** — it serves +`*compilation*` and `*shell-command*` only (`compile.lua:1090,1125`). +`*search-results*` is an independent panel with its own intercept, +round-trip mark and writes, and `compile.lua` names it only in a +predicate. Nor is the scope "every generated buffer": `*workers*`, +`*help*` and `*buffer-list*` are generated too but do not use this +idiom, and the REPL package's intercept +(`builtin/packages/repl/init.lua:187`) is an op-filtering editing +policy, not a read-only panel — neither group belongs to this lane. Adoption is not a one-line swap. It inherits the fan-out obligation, and -the two `compile.lua` slots append rather than replacing wholesale, so -they need a **streaming variant** of the primitive; listview and dired -are already whole-buffer replaces and are the cheap half. Recorded in -`COHERENCE.md` §14. +the three appending buffers need a **streaming variant** of the +primitive; listview and dired already write whole-buffer replaces and +are the cheap half. Recorded in `COHERENCE.md` §14. **And it does not replace `set_round_trip_input`.** The protection is layered across two copies: rope-level `read_only` refuses the op at the diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md index 574d58c..bd405ea 100644 --- a/docs/terminal-config-and-copy-mode-framing.md +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -564,16 +564,21 @@ additive, on its own binding, and does not replace scroll-and-select. would replay, and it stops a periodically refreshed buffer accumulating rope clones that `read_only` guarantees nothing can ever pop. - **What remains of the lane — four writers, not two** (corrected in - review round 5, which found the inventory short): listview panels - (`listview.lua:60-61`), `*compilation*` and `*search-results*` (both - through `compile.lua`'s shared `ensure_slot`), and dired buffers - (`dired.lua:371`) all still rely on intercept-plus-round-trip over a - writable rope, and are all still emptiable by `M-x buffer.undo`. The + **What remains of the lane — four writer mechanisms over five + buffers**, not the two this section first named (round 5 found the + inventory short; round 6 found the corrected version misattributing + the search panel). Every remaining intercept-protected writer still + relies on intercept-plus-round-trip over a writable rope, and every one + is still emptiable by `M-x buffer.undo`: listview panels + (`listview.lua:60-61`); `compile.lua`'s `ensure_slot`, which serves + `*compilation*` and `*shell-command*` — **not** `*search-results*`, + which `compile.lua` names only in a predicate; the independent + `*search-results*` panel in `builtin/commands/default.lua:869`, with + its own intercept and writes; and dired buffers (`dired.lua:371`). The primitive they need now exists and is proven, so the remaining work is - adoption plus a streaming-friendly variant — the two `compile.lua` - slots append rather than replacing wholesale, while listview and dired - already write whole-buffer replaces and are the cheap half. + adoption plus a streaming-friendly variant — the three appending + buffers need it, while listview and dired already write whole-buffer + replaces and are the cheap half. **The CRDT half is closed too** (review round 3). Clearing the v0.1 stacks proves nothing in CRDT mode, where they are bypassed entirely and From f3103a6953d42b27aceb94b1d6bc631d8f725ba6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:47:54 -0400 Subject: [PATCH 64/91] fix(lean4): defer the expansion past the chain, and guard its point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, all about what happens AROUND the expansion rather than about resolving an abbreviation. A pair character that TERMINATES an abbreviation never reached auto-pairing: `\alp(` gave `α(`. Q#LN22 already said the terminator is not claimed and the implementation claimed it whenever an expansion succeeded. Merely declining is not enough either — the chain hands each consumer a copy of the record made before any consumer ran, so expanding inside the chain invalidates the copy pairing is holding and the closer is silently lost. Verified by mutation rather than assumed: expand-then-decline reproduces `α(` exactly. The expansion therefore runs on its OWN `buffer.after-edit` subscriber, registered after typed_edit.lua's and before lsp.lua's. A claim stops the chain but not a separate subscriber, which is the point: pairing claims the terminator it reacts to. The replaced span now covers only the leader and the typed text, so pairing's closer lands outside it and survives. One undo restores the same text either way, because the terminator was always its own insert. That second subscriber is a new instance of Q#AP7 — lsp.lua flushes didChange synchronously on the signature-trigger path, and `(` is a trigger — so acceptance 45m pins it with the sighelp fake server: no didChange may ever carry the unexpanded text. The relevance check is now three-part, as pairing's has been since #110: buffer, window, AND `ed.cursor() == rec.post_cursor`. A redefined self-insert can insert the completing character and then move the point, and expanding over a span the user has left teleports them back into it. Cursor placement after the replace is context-guarded, as `repair_cursor` is. A buffer intercept may switch buffers while `buf:replace` runs; the unguarded `goto_byte` then translated the Lean buffer's pre-edit point through the Lean buffer's edit and applied it to whatever was ambient. Q#LN22, criterion 38's span wording, and the ledger are corrected to describe the deferred design rather than the one that shipped — the rationale's source, not only the sites quoting it. Acceptance 45j/45k/ 45l/45m added; framing rev 10. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- builtin/runtime/lean_input.lua | 192 ++++++++++++++++++++------ docs/active-work.md | 25 +++- docs/agent-handoff.md | 9 +- docs/lean4-mode-framing.md | 112 +++++++++++++-- tests/lean_input_acceptance.rs | 241 +++++++++++++++++++++++++++++++++ 5 files changed, 523 insertions(+), 56 deletions(-) diff --git a/builtin/runtime/lean_input.lua b/builtin/runtime/lean_input.lua index da24378..7b205d2 100644 --- a/builtin/runtime/lean_input.lua +++ b/builtin/runtime/lean_input.lua @@ -124,6 +124,10 @@ end -- TUI-plus-GPU configuration this project ships. local pending = {} +-- Expansions the chain consumer decided on but did NOT perform, keyed +-- the same way. See `run_deferred` below for why they wait. +local deferred = {} + local function frontend_id() local ok, id = pcall(function() return pmacs.frontend.id() end) if ok then return id end @@ -157,19 +161,38 @@ end -- Expansion -- --------------------------------------------------------------------- --- Replace the pending span with `symbol`, placing the point at --- `$CURSOR` if the symbol carries one. Returns the byte offset just --- past the replacement, or nil when the edit was rejected or altered. +-- Right-gravity translation of `pos` through the effective edit — +-- pair.lua's shape, for the same reason: the point sits AFTER the +-- replaced span (on the terminator, or on a closer pairing inserted) +-- and has to move with it. +local function translate(pos, estart, estop, einserted) + if pos < estart then return pos end + if pos > estop then return pos - (estop - estart) + einserted end + return estart + einserted +end + +-- Replace the pending span (leader + typed text) with `symbol`. -- --- ONE `buf:replace` for the whole expansion: one undo step, one CRDT --- op, one effective-edit verification. A rejection drops the pending --- state and does not retry, the same discipline as comment.lua's Q#CT5 --- and pair.lua. -local function expand(buf, p, symbol, span_end) +-- The span deliberately STOPS BEFORE the terminator. Including the +-- terminator would make the expansion and the terminator one edit, but +-- it would also swallow whatever auto-pairing did with that terminator +-- — and a pair character is a legal terminator (`\alp(`). One undo +-- restores the same text either way, because the terminator was its own +-- insert to begin with. +-- +-- ONE `buf:replace`: one undo step, one CRDT op, one effective-edit +-- verification. A rejection drops the pending state and does not retry, +-- the same discipline as comment.lua's Q#CT5 and pair.lua. +local function expand(buf, start, span_end, symbol) local cursor_at = symbol:find(CURSOR, 1, true) local text = cursor_at and (symbol:gsub("%$CURSOR", "", 1)) or symbol - local start = p.start_offset + -- The context to compare against AFTER the edit. A buffer intercept + -- may switch window or buffer while the replace runs; the point in + -- whatever it switched to is not ours to move. + local win0 = pmacs.window.current() + local point0 = ed.cursor() + local ok, estart, estop, einserted = pcall(function() return buf:replace(start, span_end, text) end) @@ -188,7 +211,17 @@ local function expand(buf, p, symbol, span_end) -- the new end. Every later self-insert is then silently rejected and -- the editor looks dead. There is no daemon re-grounding that covers -- this; that only holds for an edit that lands at the cursor. - ed.goto_byte(cursor_at and (start + cursor_at - 1) or (start + #text)) + -- + -- Context-guarded exactly as pair.lua's `repair_cursor` is: if the + -- intercept switched us elsewhere, `goto_byte` would move the point + -- of a buffer that has nothing to do with this expansion. + if pmacs.window.current() == win0 and pmacs.window.buffer() == buf then + if cursor_at then + ed.goto_byte(start + cursor_at - 1) + else + ed.goto_byte(translate(point0, estart, estop, einserted)) + end + end return start + #text end @@ -245,6 +278,15 @@ local function on_typed_edit(rec) pending[fid] = nil return false end + -- ...and on a source edit whose context is no longer current. The + -- buffer and window matching is not enough: a redefined self-insert + -- can insert the character and THEN move the point, and expanding + -- over a span the user has left teleports them back into it. Pairing + -- makes the same three-part check for the same reason. + if ed.cursor() ~= rec.post_cursor then + pending[fid] = nil + return false + end local revision do @@ -287,55 +329,114 @@ local function on_typed_edit(rec) p.text = extended p.expected_revision = revision if eager[extended] then - local span_end = p.start_offset + 1 + #extended pending[fid] = nil - expand(buf, p, best[extended].symbol, span_end) + deferred[fid] = { + buffer = rec.buffer, + window = rec.window, + start_offset = p.start_offset, + text = extended, + symbol = best[extended].symbol, + re_arm = false, + } end -- Claimed either way: an extension that has not yet completed must - -- NOT reach auto-pairing (`\[` in `\[[]]`). + -- NOT reach auto-pairing (`\[` in `\[[]]`), and a completing one is + -- part of the abbreviation, not a character pairing should react to. return true end - -- `ch` does not extend the abbreviation. Expand what is pending - -- FIRST, then let `ch` stand as ordinary text — the terminator is - -- retained, not consumed, and it sits inside the replaced span so the - -- whole thing is one undo step. + -- `ch` does not extend the abbreviation: it TERMINATES it, and a + -- terminator is an ordinary character that auto-pairing is entitled + -- to react to (`\alp(` must give `α()`). So the expansion is + -- DEFERRED to the subscriber below and this returns false, leaving + -- pairing a record whose offsets still describe the buffer. + -- + -- Expanding here and returning false would not do: the replace makes + -- pairing's copy of the record stale, so pairing declines and the + -- closer is silently lost. Expanding here and returning true is + -- worse — it is what shipped in the first revision of this file, and + -- it makes every pair-character terminator silently unpaired. pending[fid] = nil - local hit = best[p.text] - local after - if hit and #p.text > 0 then - -- `span_end` covers the terminator: the leader, the pending text, - -- and `ch`, which has already landed. What replaces it is the - -- symbol followed by `ch` itself. - local span_end = p.start_offset + 1 + #p.text + #ch - after = expand(buf, p, hit.symbol .. ch, span_end) + if best[p.text] and #p.text > 0 then + deferred[fid] = { + buffer = rec.buffer, + window = rec.window, + start_offset = p.start_offset, + text = p.text, + symbol = best[p.text].symbol, + -- A terminating `\` re-arms as a NEW leader at its own position + -- (`\al\to` → `∀→`). Upstream gets this from `processChange`, + -- where a finished abbreviation reports `isAffected = false` and + -- so does not suppress the new-leader branch. This is NOT the + -- `\\` case: there the pending text is empty, `\` EXTENDS, and + -- the result is one literal backslash with nothing left open. + re_arm = ch == LEADER, + } + elseif ch == LEADER then + -- Nothing to expand, but the leader still opens a fresh + -- abbreviation where it landed. + pending[fid] = { + buffer = rec.buffer, + window = rec.window, + start_offset = rec.effective_start, + text = "", + expected_revision = revision, + } + return true end - -- A terminating `\` re-arms as a NEW leader at its own position - -- (`\alpha\to` → `α→`). Upstream gets this from `processChange`, - -- where a finished abbreviation reports `isAffected = false` and so - -- does not suppress the new-leader branch. This is not the `\\` case: - -- there the pending text is empty, `\` EXTENDS, and the result is one - -- literal backslash with no pending state left open. - if ch == LEADER then - local start = after and (after - #ch) or rec.effective_start - local ok, rev = pcall(function() return buf:revision() end) - if ok then + return false +end + +-- The deferred expansion, on its own `buffer.after-edit` subscriber. +-- +-- It runs AFTER the whole typed-edit chain — this chunk loads after +-- typed_edit.lua, and hook callbacks run in registration order — so +-- auto-pairing has already reacted to the terminator by the time the +-- expansion rewrites the text in front of it. Pairing's closer lands +-- after the terminator, outside the replaced span, so it survives. +-- +-- It must also run BEFORE lsp.lua's subscriber (Q#AP7): that one +-- flushes `didChange` synchronously on the signature-trigger path, and +-- a server told about `\alp ` instead of `α ` stays wrong until the +-- next edit. This chunk loads before lsp.lua for exactly that reason. +-- +-- A claim by ANY chain consumer stops the chain but not this — which +-- is the point. Pairing claims the terminator it reacts to. +local function run_deferred() + local fid = frontend_id() + if fid == nil then return end + local d = deferred[fid] + deferred[fid] = nil + if not d then return end + + local buf = pmacs.window.buffer() + if not buf or buf ~= d.buffer or pmacs.window.current() ~= d.window then + return + end + + -- The span must still hold exactly what was typed into it. Pairing + -- only edits at the point, which is past this span, so in practice + -- this holds; a buffer intercept is not obliged to be so polite. + local span_end = d.start_offset + 1 + #d.text + local ok, actual = pcall(function() + return buf:slice(d.start_offset, span_end) + end) + if not ok or actual ~= LEADER .. d.text then return end + + local after = expand(buf, d.start_offset, span_end, d.symbol) + if after and d.re_arm then + local rev_ok, rev = pcall(function() return buf:revision() end) + if rev_ok then pending[fid] = { - buffer = rec.buffer, - window = rec.window, - start_offset = start, + buffer = d.buffer, + window = d.window, + start_offset = after, text = "", expected_revision = rev, } end - return true end - - -- Claimed only if an expansion actually happened. Otherwise `ch` is - -- an ordinary character in a Lean buffer and auto-pairing should see - -- it — `\zz` leaves `z` free to pair if it ever were a pair char. - return after ~= nil end -- Q#KR11's seam: a detached frontend's pending state must not outlive @@ -343,8 +444,11 @@ end -- life of the session. pmacs.hook.add("frontend.detached", function(fid) pending[fid] = nil + deferred[fid] = nil end) +pmacs.hook.add("buffer.after-edit", run_deferred) + -- `buffer.after-switch` fires with NO arguments, so it cannot say whose -- switch it was. The acting frontend is the one that produced the most -- recent dispatched input event, which is what `pmacs.frontend.id()` diff --git a/docs/active-work.md b/docs/active-work.md index 034b1fd..f931da1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -175,7 +175,8 @@ If it does not, stop and repair the remote/fetch configuration. ### Stage 4b — the Unicode input method (branch `lean4-stage4b-input-method`) -- Framing `docs/lean4-mode-framing.md` **revision 9**, approved. Stage +- Framing `docs/lean4-mode-framing.md` **revision 10** (round 10 = + review of the implementation). Stage 4a (the typed-edit consumer chain) MERGED as #179; this branch is 4b, the Lean content that registers on it. - Footprint: `scripts/regen-lean-abbrev` (new, the generator), @@ -183,7 +184,7 @@ If it does not, stop and repair the remote/fetch configuration. from `leanprover/vscode-lean4@17d1d08`, Apache-2.0), `builtin/runtime/lean_input.lua` (new, the consumer at priority 50), `src/editor.rs` (two `include_str!` blocks), - `tests/lean_input_acceptance.rs` (new, 25 tests), and one + `tests/lean_input_acceptance.rs` (new, 29 tests), and one `#[cfg(feature = "crdt")]` `--lib` test in `src/daemon.rs` (acceptance 45f). No protocol change (Q#LN14). Entirely Lua apart from the load sites and that one test. @@ -239,11 +240,31 @@ If it does not, stop and repair the remote/fetch configuration. | `buffer.after-switch` clears every frontend | 1 | | delete the `buffer.after-switch` subscriber | 1 | | `frontend.detached` purges every frontend | 1 | + | claim the terminator | 1 | + | expand inside the chain, then decline | 2 | + | drop the `cursor() == post_cursor` check | 1 | + | place the point without the context guard | 1 | + | load lean_input.lua after lsp.lua | 1 | Acceptance 45f bit by construction: without a registered window for the source frontend it ran six fan-outs with a nil record and proved nothing, because `handle_remote_crdt_op` arms nothing unless the source's active window displays the buffer. +- **Round 10 (review) found three defects, all about what happens + AROUND the expansion rather than about resolving an abbreviation.** A + pair character that TERMINATES an abbreviation never reached pairing + (`\alp(` gave `α(`): the first revision claimed the terminator, and + merely declining is not enough either, because the chain hands each + consumer a copy of the record made before any consumer ran — so + expanding inside the chain invalidates pairing's copy and the closer + is lost anyway (verified by mutation, not assumed). The expansion now + runs on **its own `buffer.after-edit` subscriber** after the chain, + with a span that stops before the terminator. That is a new instance + of Q#AP7, so it is now pinned with the sighelp fake server. + Post-insert point motion was also mistaken for a valid span (the + relevance check needs `cursor() == post_cursor`, as pairing's has + since #110), and cursor placement could move a buffer an intercept + had switched to. - Undo is cross-peer-degraded on CRDT frontends and that is ACCEPTED, named in the module header (Q#LN21): six source-peer optimistic inserts replaced by one daemon-peer op. `set_round_trip_input` would diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 32b1a10..d1dc46b 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -132,7 +132,14 @@ commands, read `docs/active-work.md` immediately after this file. (branch `lean4-stage4b-input-method`, framing rev 9): a vendored 1,855-entry table generated from `leanprover/vscode-lean4@17d1d08` by `scripts/regen-lean-abbrev`, plus a consumer registered on the - Stage 4a chain at priority 50, ahead of pairing. Its durable facts: + Stage 4a chain at priority 50, ahead of pairing. **A consumer + cannot both edit and let a later consumer act on the same + keystroke**: the chain hands each consumer a copy of the record made + before any consumer ran, so an edit invalidates every copy still to + be used. The expansion therefore runs on a SECOND + `buffer.after-edit` subscriber after the chain — which is how a + pair character that terminates an abbreviation still pairs + (`\alp(` → `α()`). Its other durable facts: the table must stay an ORDERED SEQUENCE (equal-length ties resolve by source declaration order, which a `pairs`-iterated map cannot express); a generator round-trip check must re-read the BYTES ON diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index 72a83fd..482ad84 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -46,7 +46,7 @@ during a rebase. ## 0.1 Revision history -Revision 1 — initial. Current revision: **9**. +Revision 1 — initial. Current revision: **10**. ### Round 1 (rev 1 → rev 2) @@ -535,6 +535,38 @@ The mechanism (Q#LN11, Q#LN21, Q#LN22) needed no change — these were errors in the examples chosen to pin it, which is why a simulation over the real data found them and four review rounds over the prose did not. +### Round 10 (rev 9 → rev 10) + +Review of the Stage 4b implementation. Three defects in the expander, +all of them about what happens AROUND the expansion rather than about +resolving an abbreviation, plus one stale count. + +1. **A pair character that terminates an abbreviation never reached + auto-pairing.** Q#LN22 already said the terminator is not claimed; + the implementation claimed it whenever an expansion succeeded, so + `\alp(` gave `α(`. Not claiming is necessary and not sufficient — + the chain hands each consumer a copy of the record made before any + consumer ran, so expanding inside the chain invalidates the copy + pairing is holding and the closer is lost anyway. Q#LN22 now + specifies the deferred subscriber and the span that stops before the + terminator; acceptance 45j pins all three failure modes. +2. **Post-insert point motion was mistaken for a valid pending span.** + The relevance check compared buffer and window but not + `ed.cursor() == rec.post_cursor`, so a redefined self-insert that + inserts and then moves the point still expanded — and teleported the + point back. Pairing has made this three-part check since #110. + Acceptance 45k. +3. **Cursor placement could move the wrong buffer.** A buffer intercept + may switch buffers during `buf:replace`; the unguarded `goto_byte` + afterwards moved the switched-to buffer's point. `repair_cursor` is + the precedent. Acceptance 45l. +4. **The coherence census contradicted itself** — nine settings in one + paragraph, eight three paragraphs below. + +Acceptance 45m was added with them: the expansion now runs on its own +`buffer.after-edit` subscriber, which is a new instance of Q#AP7 and +was unpinned. + ## 1. What ships Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The @@ -1718,8 +1750,9 @@ reconstruction of it: - A subsequent self-insert `c` is claimed iff at least one key has `text .. c` as a prefix; then `text = text .. c`. If it is also uniquely-and-completely matching (one of the 1,550), expand now. -- If no key extends `text .. c`, expand `text` **first**, then let `c` - land normally — the chain does *not* claim `c`. +- If no key extends `text .. c`, `c` TERMINATES the abbreviation: the + chain does *not* claim it, and the expansion of `text` is + **deferred** until after the chain has run (round 10; see below). - **A terminating `c` that is itself `\` is then reprocessed as a new leader**, opening a fresh pending abbreviation at its position. This is the rule acceptance 45d depends on (`\alpha\to` → `α→`) and rev 6 @@ -1733,6 +1766,37 @@ reconstruction of it: broken by source rank, unmatchable tail appended (`\alp7` → `α7`). - `$CURSOR` is stripped from the symbol and its index becomes the point. +**The expansion is deferred past the chain, and its span stops before +the terminator** (round 10). "Not claiming the terminator" is necessary +and not sufficient: a pair character is a legal terminator (`\alp(` must +give `α()`), and the chain hands every consumer a *copy* of the record +made before any consumer ran. So expanding inside the chain and then +declining leaves auto-pairing holding offsets the replace has already +invalidated — pairing declines and the closer is silently lost, which a +probe confirmed. Claiming the terminator instead suppresses pairing +outright. Neither is recoverable from inside the chain. + +The expander therefore records the pending expansion and performs it on +its **own `buffer.after-edit` subscriber**, registered after +typed_edit.lua's and before lsp.lua's. A claim by any consumer stops the +chain but not a separate subscriber — which is the point, since pairing +claims the terminator it reacts to. The replaced span covers the leader +and the typed text only; whatever pairing did lands after it and +survives untouched. One undo restores the same text either way, because +the terminator was always its own insert. + +Two guards this exposes, both of which pairing already carries: + +- The relevance check is **three-part**, not two: buffer, window, **and + `ed.cursor() == rec.post_cursor`**. A redefined self-insert can insert + the completing character and then move the point, and expanding over a + span the user has left teleports them back into it. +- Cursor placement after the replace is **context-guarded**. A buffer + intercept may switch window or buffer while `buf:replace` runs; an + unguarded `goto_byte` then moves the point of a buffer that has + nothing to do with the expansion. `pair.lua`'s `repair_cursor` is the + precedent. + **Ownership is per frontend, not per buffer** (§2.11). The key is `(pmacs.frontend.id(), rec.buffer)`, and the stored `window` must still match `rec.window` for the state to be usable — a frontend that moved @@ -2490,11 +2554,14 @@ criterion 46 requires to stay byte-identical. it assumed `\alpha` takes the finish path when `alpha` is in the 1,550-key eager set (round 9; see 41). - *Finish path.* `\alp` + space yields `α `: the space lands first - and the expansion runs in the following `buffer.after-edit`, so - the terminator is **retained**, not consumed, and it is inside the - replaced span. One undo restores `\alp ` — with its space, not - `\al`. Rev 6 wrote the post-undo text without the terminator, - which would be true only if the terminator were swallowed. + and the expansion runs later in the same `buffer.after-edit` + fan-out, so the terminator is **retained**, not consumed. It sits + OUTSIDE the replaced span, which covers only the leader and the + typed text (round 10) — the observable text and the post-undo + text are the same either way, because the terminator was its own + insert. One undo restores `\alp ` — with its space, not `\al`. + Rev 6 wrote the post-undo text without the terminator, which + would be true only if the terminator were swallowed. - *Eager path.* `\alpha` yields `α` with no terminator typed, and a following space is a **separate** edit. One undo removes the space; a second restores `\alpha`. Asserting the finish-path undo @@ -2598,6 +2665,33 @@ criterion 46 requires to stay byte-identical. `$CURSOR` more than once; - the resolution spot-set behaves: `alpha`, `to`, `<>`, `+ `, `\`, `n`, `setminus`, and the tie cases from 45h. +45j. **A pair character that TERMINATES an abbreviation still pairs** + (round 10). `\alp(` yields `α()` with the point between the pair. + Bites three ways, all of which produce different wrong answers: + claiming the terminator gives `α(`; expanding inside the chain and + then declining also gives `α(`, because the replace invalidates the + record copy pairing is holding; and pairing running first gives + `\alp()` unexpanded. Criterion 40 is the same collision from the + other side, and passing it says nothing about this one. +45k. **The relevance check is three-part.** A redefined + `buffer.self-insert` that inserts the completing character and then + moves the point must not expand: `\alph` + `a` under such an + override leaves literal `\alpha` with the point where the command + put it. Bites against checking only buffer and window — the + expansion would otherwise teleport the point back into a span the + user has left. +45l. **Cursor placement is context-guarded.** A buffer intercept that + switches buffers during `buf:replace` must not have the + switched-to buffer's point moved. Bites against an unguarded + `goto_byte`, which translates the LEAN buffer's pre-edit point + through the LEAN buffer's edit and applies it to whatever is + ambient. +45m. **Q#AP7 for the deferred subscriber.** The expansion runs on a + second `buffer.after-edit` subscriber, so it inherits pairing's + flush-ordering obligation: no `didChange` may ever carry the + unexpanded text. Pinned with the `sighelp` fake server and `(` as + the trigger — the flush carrying the terminator carries `α()`. + Falsified by loading lean_input.lua after lsp.lua. 45h. **Tie-break by source order (§2.11).** `\f` + space yields `‹` — `f<` and `f>` are both length 2, and `f<` is declared first. Same for `\"` + space → `Ä`, first of eleven equal-length candidates. @@ -2752,7 +2846,7 @@ uncapped event queue, the dropped `cfg.restart`, and — unchanged from languages other than Lean, and §4's rule is what keeps them out of a Lean PR. -### 9.1 Coherence impact — stages 4a and 4b (rev 9) +### 9.1 Coherence impact — stages 4a and 4b (rev 10) **Sections served.** §6 (interaction islands) primarily, and in the *preventing* direction rather than the fixing one — see below. §11 diff --git a/tests/lean_input_acceptance.rs b/tests/lean_input_acceptance.rs index 01ee89e..9270f1b 100644 --- a/tests/lean_input_acceptance.rs +++ b/tests/lean_input_acceptance.rs @@ -8,10 +8,12 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; use pmacs::protocol::FrontendId; use pmacs::window::{FrontendView, Layout, Window, WindowId}; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; fn fresh_dir() -> PathBuf { static SEQ: AtomicUsize = AtomicUsize::new(0); @@ -49,6 +51,10 @@ fn text(s: &EditorState) -> String { String::from_utf8_lossy(&b.as_bytes()).into_owned() } +fn cursor(s: &EditorState) -> i64 { + eval(s, "return pmacs.editor.cursor()") +} + fn type_as(s: &mut EditorState, fid: FrontendId, chars: &str) { for ch in chars.chars() { s.dispatch_key(fid, key(KeyCode::Char(ch))); @@ -176,6 +182,33 @@ fn a_pending_abbreviation_is_never_corrupted_by_auto_pairing() { assert_eq!(text(&s), "⟦⟧", "the full key resolves"); } +#[test] +fn a_pair_character_that_terminates_an_abbreviation_still_pairs() { + // The other half of the collision, and the one the first revision + // of this file got wrong. `(` does not extend `alp`, so it + // TERMINATES — and a terminator is an ordinary character that + // pairing is entitled to react to. + // + // Claiming the terminator suppresses pairing entirely (`α(`). + // Expanding before declining is no better: the replace makes + // pairing's copy of the record stale, so pairing declines and the + // closer is silently lost. Only deferring the expansion past the + // chain gives both. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alp("); + assert_eq!( + text(&s), + "α()", + "the abbreviation expanded AND the terminator paired" + ); + assert_eq!( + cursor(&s), + 3, + "and the point sits between the pair — after α (2 bytes) and \ + the opener" + ); +} + #[test] fn a_pair_character_outside_a_pending_abbreviation_still_pairs() { // The other direction: claiming extensions must not disable pairing @@ -271,6 +304,93 @@ fn switching_buffers_clears_pending_state_eagerly() { ); } +#[test] +fn a_self_insert_that_moves_the_point_afterwards_does_not_expand() { + // Buffer and window matching is not enough. A redefined + // `buffer.self-insert` may insert the completing character and THEN + // move the point; expanding over a span the user has left teleports + // them back into it. Pairing makes the same three-part check + // (`ed.cursor() ~= rec.post_cursor`) for the same reason. + let (mut s, _f) = lean_editor(); + type_str(&mut s, "\\alph"); + exec( + &s, + r#" + pmacs.command.unregister("buffer.self-insert") + pmacs.command.define { + name = "buffer.self-insert", + description = "test override: insert, then move the point away", + fn = function(cp) + pmacs.editor.insert_char_over_region(cp) + pmacs.editor.goto_byte(0) + end, + } + "#, + ); + + type_str(&mut s, "a"); + assert_eq!( + text(&s), + "\\alpha", + "the record died with the point that left it — no expansion" + ); + assert_eq!(cursor(&s), 0, "and the point stayed where it was moved to"); +} + +#[test] +fn an_intercept_that_switches_buffers_does_not_move_the_other_points() { + // A buffer intercept may switch window or buffer while the replace + // runs. An unguarded `goto_byte` afterwards moves the point of + // whatever it switched TO — a buffer with nothing to do with this + // expansion. Pairing's `repair_cursor` guards the same way. + let (mut s, f) = lean_editor(); + let dir = fresh_dir(); + let other = dir.join("other.lean"); + std::fs::write(&other, "0123456789").unwrap(); + let od = other.display().to_string(); + let fd = f.display().to_string(); + + exec(&s, &format!("pmacs.buffer.find_or_open({od:?})")); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + exec( + &s, + &format!( + r#" + _G.SWITCHED = false + pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op) + if op.kind == "replace" and not _G.SWITCHED then + _G.SWITCHED = true + pmacs.buffer.find_or_open({od:?}) + end + return nil + end) + "# + ), + ); + + type_str(&mut s, "\\alpha"); + let switched: bool = eval(&s, "return _G.SWITCHED"); + assert!(switched, "the intercept must actually have fired"); + assert_eq!( + text(&s), + "0123456789", + "we are now in the buffer the intercept switched to" + ); + // Whatever point the switch left in that buffer, the expansion must + // not have moved it. Unguarded, `goto_byte` runs against the + // ambient buffer and translates the LEAN buffer's pre-edit point + // (6) through the LEAN buffer's replace, landing at 2 here — a + // number with no meaning in this buffer at all. + assert_eq!( + cursor(&s), + 0, + "its point is untouched — the expansion's cursor placement is \ + guarded on the window and buffer still being the ones it \ + edited" + ); +} + // --------------------------------------------------------------------------- // 44 / 45 — the setting and the language gate, both on the SOURCE buffer // --------------------------------------------------------------------------- @@ -566,6 +686,127 @@ fn the_vendored_table_is_self_consistent() { assert!(!to_eager, "`to` is extended by `top`, `to0`, `toa`, …"); } +// --------------------------------------------------------------------------- +// Q#AP7 for the deferred expansion: it must land before lsp.lua flushes +// --------------------------------------------------------------------------- + +#[test] +fn the_expansion_reaches_the_first_did_change() { + // The expansion runs on its OWN `buffer.after-edit` subscriber, + // after the typed-edit chain. That makes it a new instance of the + // Q#AP7 obligation pairing already carries: lsp.lua's subscriber + // flushes `didChange` SYNCHRONOUSLY on the signature-trigger path, + // and `(` is a trigger. A server told about `\alp(` instead of + // `α()` stays wrong until the next edit — diagnostics, semantic + // tokens and inlay hints all frozen at stale byte positions. + // + // Falsified by loading lean_input.lua after lsp.lua in + // `src/editor.rs`: the expansion would then arrive in the SECOND + // didChange, or not at all. + let dir = fresh_dir(); + let sink = dir.join("changes.jsonl"); + let sink_disp = sink.display().to_string(); + let fake = env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned(); + + let f = dir.join("a.lean"); + std::fs::write(&f, "").unwrap(); + let mut s = EditorState::new(); + s.lua_host.lua().remove_app_data::(); + s.lua_host.lua().set_app_data(StateDir(dir.clone())); + exec(&s, "pmacs.lsp.config = {}"); + exec( + &s, + &format!( + "pmacs.lsp.config.lean4 = {{ + command = '{fake}', + env = {{ + PMACS_FAKE_LSP_MODE = 'sighelp', + PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}', + }}, + }}" + ), + ); + + let fd = f.display().to_string(); + exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + let initialized = "(function() \ + for _,r in ipairs(pmacs.lsp.list()) do \ + if r.state and r.state.kind=='initialized' then return true end \ + end \ + return false \ + end)()"; + assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init"); + + type_str(&mut s, "\\alp("); + assert_eq!(text(&s), "α()", "precondition: the expansion happened"); + + // Wait for the flush that carries the `(` keystroke. Earlier + // keystrokes have already produced their own didChanges, so + // `changes[0]` is NOT the one under test — asserting on it compares + // against `\al` and fails for the wrong reason. + let deadline = Instant::now() + Duration::from_secs(5); + let changes = loop { + s.tick_processes(); + s.tick_lsp(); + s.tick_async(); + let c = did_change_texts(&sink); + if c.iter().any(|t| t.contains('α')) { + break c; + } + assert!( + Instant::now() < deadline, + "no didChange carrying the expansion reached the fake server; got {:?}", + did_change_texts(&sink) + ); + std::thread::sleep(Duration::from_millis(10)); + }; + assert!( + !changes.iter().any(|t| t == "\\alp("), + "no didChange may ever carry the UNEXPANDED text — one would mean lsp.lua flushed before the deferred expansion ran (Q#AP7). Got {changes:?}" + ); + assert_eq!( + changes.last().map(String::as_str), + Some("α()"), + "the flush that carries the terminator carries the expansion and pairing's closer with it" + ); +} + +fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool { + let deadline = Instant::now() + Duration::from_secs(secs); + loop { + state.tick_processes(); + state.tick_lsp(); + state.tick_async(); + let done: bool = state + .lua_host + .lua() + .load(format!("return ({flag}) == true")) + .eval() + .unwrap_or(false); + if done { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// The `text` of every `textDocument/didChange` line in the sink, in +/// arrival order. +fn did_change_texts(sink: &std::path::Path) -> Vec { + let Ok(raw) = std::fs::read_to_string(sink) else { + return Vec::new(); + }; + raw.lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange")) + .filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned)) + .collect() +} + // --------------------------------------------------------------------------- // 45i — pending state is per frontend // --------------------------------------------------------------------------- From 640c5cd0d2742168dda93c25033716e41c09ec46 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:52:54 -0400 Subject: [PATCH 65/91] =?UTF-8?q?feat(panel):=20bottom-panel=20Stage=202B?= =?UTF-8?q?=20=E2=80=94=20the=20v21=20protocol=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the four wire shapes Q#BP9 names, bumps the protocol to v21, and factors the cell-grid validator so a panel frame shares the terminal's rules without inheriting its PTY caps. - `InstanceMessage::PanelFrame(PanelFramePayload)`, appended after `InitialTargetResult`; `Absent` is an explicit authoritative state, not silence, because the receiver retains its last valid frame. - `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`, appended after `TerminalPointer`. Geometry is valid without a side window — gating it on panel presence would deadlock the first open, since the daemon needs columns before it can paint a first frame. - `pmacs-protocol/src/wire_grid.rs` holds the shared rules: checked area, visible-cell bound, cell count, cursor bounds, glyph legality, wide-continuation topology, the aggregate glyph budget, and the attachment rejection. The 512 per-axis caps, metadata, selection spans, and the at_bottom/scroll_offset coupling stay terminal-only. - The attachment rejection is deliberately shared despite its terminal-side wording: panels render no attachments either, so sharing it fails closed for both. Both byte pins were falsified by revert: moving `PanelFrame` ahead of `InitialTargetResult` shifts it 27 -> 28 and fails; moving the three events ahead of `TerminalPointer` shifts it 12 -> 15 and fails. The factoring changed no terminal acceptance — all 17 terminal tests pass unchanged. It did surface a pre-existing coverage gap: those tests pin the row cap but never the column cap, so widening `max_cols` to u32::MAX left them green. `a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not` now covers that direction. The daemon projection, the epoch state machine, and the GPU band are later slices of this stage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR --- pmacs-gpu/src/main.rs | 1 + pmacs-protocol/src/lib.rs | 7 + pmacs-protocol/src/message.rs | 100 ++++- pmacs-protocol/src/panel.rs | 213 +++++++++++ pmacs-protocol/src/terminal.rs | 263 ++++--------- pmacs-protocol/src/wire_grid.rs | 321 ++++++++++++++++ src/daemon.rs | 14 + src/frontend.rs | 5 + ...ottom_panel_stage2b_protocol_acceptance.rs | 351 ++++++++++++++++++ 9 files changed, 1081 insertions(+), 194 deletions(-) create mode 100644 pmacs-protocol/src/panel.rs create mode 100644 pmacs-protocol/src/wire_grid.rs create mode 100644 tests/bottom_panel_stage2b_protocol_acceptance.rs diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a26f340..32a9fba 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8904,6 +8904,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments", InstanceMessage::TerminalFrame(_) => "TerminalFrame", InstanceMessage::InitialTargetResult(_) => "InitialTargetResult", + InstanceMessage::PanelFrame(_) => "PanelFrame", } } diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index ce2d2c5..82cdd5b 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -40,8 +40,10 @@ pub mod cell; pub mod crdt; pub mod ids; pub mod message; +pub mod panel; pub mod terminal; pub mod transport; +pub mod wire_grid; /// Logical display columns between fixed buffer-text tab stops. /// @@ -67,9 +69,14 @@ pub use message::{ StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, }; +pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload}; pub use terminal::{ MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame, TerminalFrameError, TerminalProcessState, TerminalSelectionSpan, }; pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message}; +pub use wire_grid::{ + MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES, WireGridError, WireGridLimits, + checked_area, validate_wire_grid, +}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 971a78e..4103cd4 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -442,6 +442,69 @@ pub enum FrontendEvent { /// Modifiers held during the gesture. mods: Modifiers, }, + /// Bottom panel Stage 2 (protocol v21): the frontend's authoritative + /// cell-equivalent layout capacity (Q#BP15a). + /// + /// Valid **without** a side window — the daemon needs columns before + /// it can paint a first panel frame, so gating this on panel + /// presence would deadlock the first open. "Without" refers to + /// side-window presence only; the protocol and session gates still + /// apply, and the event is accepted only from an authenticated, + /// negotiated panel-capable semantic session. + /// + /// Sent immediately after attach acceptance and refreshed on window + /// resize, font change, and scale change. `geometry_epoch` is + /// frontend-owned because a font or scale change can invalidate an + /// old panel frame while `total` is **identical**, which daemon-side + /// value dedup cannot detect. + FrontendCellGeometry { + /// Which frontend declared this (untrusted; checked against the + /// transport source). + frontend_id: FrontendId, + /// Monotonic frontend-owned declaration id; `0` is reserved for + /// "never declared" and is rejected on the wire. + geometry_epoch: u64, + /// Whole-cell capacity of the frontend's frame. + total: CellSize, + }, + /// Bottom panel Stage 2 (protocol v21): requested fixed panel rows + /// from a divider drag (Q#BP15a). + /// + /// Rows are the only size component; the epochs are identities, not + /// geometry. Accepted only for the currently visible `Present` panel + /// matching both the latest geometry declaration and the current + /// presentation epoch, then clamped by Q#BP2's interactive + /// preference. + PanelResizeRows { + /// Which frontend produced the drag (untrusted, as above). + frontend_id: FrontendId, + /// Geometry declaration this request is measured against. + geometry_epoch: u64, + /// Presentation identity this request addresses. + panel_epoch: u64, + /// Requested fixed panel rows. + rows: u32, + }, + /// Bottom panel Stage 2 (protocol v21): a pointer gesture a semantic + /// frontend hit-tested to a panel CELL (Q#BP16). + /// + /// Carries both epochs so a gesture aimed at a panel that has since + /// been replaced or reopened cannot be applied to its successor. + /// Unlike [`Self::Pointer`], accepting this **activates the panel**. + PanelPointer { + /// Which frontend produced the gesture (untrusted, as above). + frontend_id: FrontendId, + /// Geometry declaration this gesture was hit-tested against. + geometry_epoch: u64, + /// Presentation identity this gesture addresses. + panel_epoch: u64, + /// Cell the pointer is over, within the declared panel grid. + coord: CellCoord, + /// Which gesture step this is. + kind: MouseKind, + /// Modifiers held during the gesture. + mods: Modifiers, + }, } /// Gesture step for [`FrontendEvent::Pointer`]. Double-click @@ -488,7 +551,10 @@ impl FrontendEvent { | Self::Pointer { frontend_id, .. } | Self::MenuPointer { frontend_id, .. } | Self::TerminalResize { frontend_id, .. } - | Self::TerminalPointer { frontend_id, .. } => *frontend_id, + | Self::TerminalPointer { frontend_id, .. } + | Self::FrontendCellGeometry { frontend_id, .. } + | Self::PanelResizeRows { frontend_id, .. } + | Self::PanelPointer { frontend_id, .. } => *frontend_id, } } } @@ -1143,6 +1209,20 @@ pub enum InstanceMessage { /// Appended after [`Self::TerminalFrame`], the final v19 variant, so no /// legacy postcard discriminant moves. InitialTargetResult(InitialTargetResult), + /// Bottom panel Stage 2 (protocol v21): the daemon's painted + /// projection of one side window, or its authoritative absence + /// (Q#BP15). + /// + /// `Absent` is sent on close **and** on hide: the receiver retains + /// its last valid frame, so silence would leave a stale band on + /// screen indefinitely. `Absent` is duplicate-suppressed like any + /// payload, and applying it clears the last declared panel size and + /// presentation epoch before any later event can validate against + /// them. + /// + /// Appended after [`Self::InitialTargetResult`], the final v20 + /// variant, so no existing postcard discriminant moves. + PanelFrame(crate::panel::PanelFramePayload), } /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full @@ -1565,7 +1645,17 @@ pub enum ResourceBody { /// handshake extension is read only from v20 semantic sessions; the result is /// sent only when such a session requested a target. v6–v19 handshakes and /// message discriminants remain unchanged. -pub const PROTOCOL_VERSION: u32 = 20; +/// +/// Bottom panel Stage 2 (Q#BP9): bumped 20 → 21 for +/// [`InstanceMessage::PanelFrame`] and +/// [`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`]. +/// All four are appended after their enum's previous final variant, so +/// no v6–v20 discriminant moves and the encoding of every existing +/// message is byte-identical. The new traffic is gated in both +/// directions: a v20 peer neither receives `PanelFrame` nor is placed in +/// a side window, because denying only the events would leave its +/// window invisible. +pub const PROTOCOL_VERSION: u32 = 21; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1643,8 +1733,12 @@ pub const PROTOCOL_VERSION: u32 = 20; /// GPU initial target (Q#GT4): extended to `[6, ..., 20]`. v20 semantic /// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy /// and non-semantic sessions retain their existing handshake shape. +/// +/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. v21 peers +/// may exchange panel traffic; v20 peers interoperate with it simply +/// absent, and are never placed in a side window. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = - &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; + &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/pmacs-protocol/src/panel.rs b/pmacs-protocol/src/panel.rs new file mode 100644 index 0000000..d6e19d7 --- /dev/null +++ b/pmacs-protocol/src/panel.rs @@ -0,0 +1,213 @@ +//! Bottom-panel wire types (Q#BP15, Q#BP15a, Q#BP16). +//! +//! A panel frame is the daemon's painted projection of one side window. +//! It shares [`crate::wire_grid`]'s cell rules with +//! [`crate::terminal::TerminalFrame`] but not its per-axis PTY caps: a +//! 4K surface at a small font is legitimately wider than 512 columns, +//! and the area bound is what keeps the encoding inside the transport +//! budget. +//! +//! Presence is explicit. [`PanelFramePayload::Absent`] is authoritative +//! and must be sent on close *and* on hide, because the receiver +//! retains its last valid frame: silence would leave a stale band on +//! screen indefinitely. + +use crate::cell::{Cell, CellCoord, CellSize}; +use crate::ids::BufferId; +use crate::wire_grid::{ + MAX_WIRE_GRID_GLYPH_BYTES, WireGridError, WireGridLimits, validate_wire_grid, +}; + +/// Shared visible-cell ceiling for a panel grid. +/// +/// Identical to the terminal bound: it is the transport-safety limit, +/// not a PTY policy, so both messages answer to it. +pub const MAX_PANEL_VISIBLE_CELLS: usize = 262_144; + +/// Bounds a panel frame enforces on its cell grid. +/// +/// The per-axis ceilings are the area bound itself rather than 512: any +/// axis larger than the area bound is already rejected by the area +/// check, so this expresses "no independent per-axis policy" without +/// leaving the multiplication unchecked. +const PANEL_GRID_LIMITS: WireGridLimits = WireGridLimits { + max_rows: MAX_PANEL_VISIBLE_CELLS as u32, + max_cols: MAX_PANEL_VISIBLE_CELLS as u32, + max_visible_cells: MAX_PANEL_VISIBLE_CELLS, + max_glyph_bytes: MAX_WIRE_GRID_GLYPH_BYTES, +}; + +/// The daemon's painted projection of one side window. +/// +/// `panel_epoch` is opaque and monotonic per frontend: stable across +/// ordinary frames of one continuously present window/buffer, and +/// changed on buffer replacement, new side-window creation, and every +/// `Absent` → `Present` transition. That is what stops a stale +/// `PanelPointer` from addressing a reopened panel as if it were the +/// old one (Q#BP16). +/// +/// `geometry_epoch` answers a *frontend* declaration and moves whenever +/// the frontend declares new effective cell geometry — including a font +/// or scale change that leaves [`CellSize`] identical, which is exactly +/// the case daemon-side value dedup cannot see (Q#BP2S1). +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct PanelFrame { + /// Buffer this frame projects. + pub buffer_id: BufferId, + /// Presentation identity, monotonic per frontend. + pub panel_epoch: u64, + /// The frontend geometry declaration this frame answers. + pub geometry_epoch: u64, + /// Panel grid dimensions in cells. + pub size: CellSize, + /// Row-major cells; exactly `size.area()` entries. + pub cells: Vec, + /// Panel caret, or `None` when the panel shows no cursor. + /// + /// `paint_frame` returns the cursor separately from the cells, so a + /// frame carrying cells alone would lose the caret. + pub cursor: Option, + /// Whether the panel owns focus. + /// + /// Presentation and focus-chrome routing only (Q#BP14b) — the + /// *keys* decision is `DispatchIdle` (Q#BP14a). + pub focused: bool, +} + +/// Explicit panel presence. +/// +/// `Absent` is authoritative rather than implied by silence, and is +/// duplicate-suppressed like any other payload. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum PanelFramePayload { + /// A panel is visible and this is its current frame. + Present(PanelFrame), + /// No panel is visible; clear any retained frame. + Absent, +} + +/// Why a [`PanelFrame`] is not structurally valid. +/// +/// Validation is atomic: the frame is rejected whole and the receiver +/// retains its previous valid frame. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum PanelFrameError { + /// Rows or columns are zero or above the area-derived bounds. + #[error("panel size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")] + Size { + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + /// Row bound in force. + max_rows: u32, + /// Column bound in force. + max_cols: u32, + }, + /// The checked area exceeds the shared visible-cell bound. + #[error("panel area {area} exceeds the visible-cell bound {max}")] + Area { + /// Checked `rows * cols`. + area: usize, + /// Shared visible-cell bound. + max: usize, + }, + /// `cells.len()` disagrees with the declared area. + #[error("panel frame carries {actual} cells for a {expected}-cell area")] + CellCount { + /// Declared area. + expected: usize, + /// Supplied cell count. + actual: usize, + }, + /// The cursor lies outside the declared grid. + #[error("panel cursor ({row},{col}) is outside the {rows}x{cols} grid")] + Cursor { + /// Cursor row. + row: u32, + /// Cursor column. + col: u32, + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + }, + /// A cell's glyph is not a legal wire glyph. + #[error("panel cell {index} has an invalid glyph: {reason}")] + Glyph { + /// Row-major cell index. + index: usize, + /// Why the glyph failed. + reason: &'static str, + }, + /// A cell carries a frontend attachment, which panels never use. + #[error("panel cell {index} carries an attachment")] + Attachment { + /// Row-major cell index. + index: usize, + }, + /// Aggregate glyph bytes exceed the shared budget. + #[error("panel frame glyph bytes exceed the aggregate bound {max}")] + GlyphBudget { + /// Shared aggregate bound. + max: usize, + }, + /// An epoch is zero, which is reserved for "never declared". + #[error("panel {field} epoch is zero, which is reserved for 'never declared'")] + ZeroEpoch { + /// Which epoch was zero. + field: &'static str, + }, +} + +impl PanelFrame { + /// Check every structural rule a panel frame must satisfy. + /// + /// Pure: a rejected frame mutates nothing, so callers get atomic + /// rejection for free. + pub fn validate(&self) -> Result<(), PanelFrameError> { + if self.panel_epoch == 0 { + return Err(PanelFrameError::ZeroEpoch { field: "panel" }); + } + if self.geometry_epoch == 0 { + return Err(PanelFrameError::ZeroEpoch { field: "geometry" }); + } + validate_wire_grid(self.size, &self.cells, self.cursor, PANEL_GRID_LIMITS) + .map_err(panel_grid_error) + } +} + +/// Map a shared wire-grid failure onto this message's error type. +fn panel_grid_error(error: WireGridError) -> PanelFrameError { + match error { + WireGridError::Size { + rows, + cols, + max_rows, + max_cols, + } => PanelFrameError::Size { + rows, + cols, + max_rows, + max_cols, + }, + WireGridError::Area { area, max } => PanelFrameError::Area { area, max }, + WireGridError::CellCount { expected, actual } => { + PanelFrameError::CellCount { expected, actual } + } + WireGridError::Cursor { + row, + col, + rows, + cols, + } => PanelFrameError::Cursor { + row, + col, + rows, + cols, + }, + WireGridError::Glyph { index, reason } => PanelFrameError::Glyph { index, reason }, + WireGridError::Attachment { index } => PanelFrameError::Attachment { index }, + WireGridError::GlyphBudget { max } => PanelFrameError::GlyphBudget { max }, + } +} diff --git a/pmacs-protocol/src/terminal.rs b/pmacs-protocol/src/terminal.rs index 8f094d7..b1b0016 100644 --- a/pmacs-protocol/src/terminal.rs +++ b/pmacs-protocol/src/terminal.rs @@ -12,13 +12,18 @@ //! that single structural policy. A second implementation of these rules //! in a frontend is a bug, not a convenience. //! -//! This module owns the crate's only `unicode-width` use: glyph column -//! width and wide-continuation topology cannot be checked without it. +//! Glyph column width and wide-continuation topology moved to +//! [`crate::wire_grid`] in bottom-panel Stage 2B, which is now the +//! crate's only non-test `unicode-width` use: those rules are shared +//! with [`crate::panel::PanelFrame`]. The 512 per-axis PTY caps, +//! metadata, selection spans, and the `at_bottom`/`scroll_offset` +//! coupling stay here, because a panel does not inherit them. -use crate::cell::{Cell, CellCoord, CellSize, Glyph}; +use crate::cell::{Cell, CellCoord, CellSize}; use crate::ids::BufferId; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +#[cfg(test)] +use unicode_width::UnicodeWidthStr; // --------------------------------------------------------------------------- // Shared limits @@ -217,6 +222,58 @@ pub enum TerminalFrameError { }, } +/// Bounds a terminal frame enforces on its cell grid. +/// +/// The per-axis caps are the PTY-specific half of the split: a panel +/// frame shares every other rule but not these, because a panel is +/// sized by the frontend's surface rather than by a pty window size. +const TERMINAL_GRID_LIMITS: crate::wire_grid::WireGridLimits = crate::wire_grid::WireGridLimits { + max_rows: MAX_TERMINAL_ROWS as u32, + max_cols: MAX_TERMINAL_COLS as u32, + max_visible_cells: MAX_TERMINAL_VISIBLE_CELLS, + max_glyph_bytes: MAX_TERMINAL_FRAME_GLYPH_BYTES, +}; + +/// Map a shared wire-grid failure onto this message's error type. +/// +/// The variants and their text are unchanged by the Stage 2B factoring: +/// every existing terminal-frame assertion still observes exactly what +/// it observed before. +fn terminal_grid_error(error: crate::wire_grid::WireGridError) -> TerminalFrameError { + use crate::wire_grid::WireGridError; + match error { + WireGridError::Size { + rows, + cols, + max_rows, + max_cols, + } => TerminalFrameError::Size { + rows, + cols, + max_rows, + max_cols, + }, + WireGridError::Area { area, max } => TerminalFrameError::Area { area, max }, + WireGridError::CellCount { expected, actual } => { + TerminalFrameError::CellCount { expected, actual } + } + WireGridError::Cursor { + row, + col, + rows, + cols, + } => TerminalFrameError::Cursor { + row, + col, + rows, + cols, + }, + WireGridError::Glyph { index, reason } => TerminalFrameError::Glyph { index, reason }, + WireGridError::Attachment { index } => TerminalFrameError::Attachment { index }, + WireGridError::GlyphBudget { max } => TerminalFrameError::GlyphBudget { max }, + } +} + impl TerminalFrame { /// Check every structural rule a terminal frame must satisfy. /// @@ -224,23 +281,13 @@ impl TerminalFrame { /// by a frontend after decode. It is pure: a rejected frame mutates /// nothing, so callers get atomic rejection for free. pub fn validate(&self) -> Result<(), TerminalFrameError> { - let area = self.checked_area()?; - if self.cells.len() != area { - return Err(TerminalFrameError::CellCount { - expected: area, - actual: self.cells.len(), - }); - } - if let Some(cursor) = self.cursor - && (cursor.row >= self.size.rows || cursor.col >= self.size.cols) - { - return Err(TerminalFrameError::Cursor { - row: cursor.row, - col: cursor.col, - rows: self.size.rows, - cols: self.size.cols, - }); - } + crate::wire_grid::validate_wire_grid( + self.size, + &self.cells, + self.cursor, + TERMINAL_GRID_LIMITS, + ) + .map_err(terminal_grid_error)?; if let Some(title) = &self.title { validate_metadata("title", title)?; } @@ -249,7 +296,6 @@ impl TerminalFrame { TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?, TerminalProcessState::Running | TerminalProcessState::Exited(_) => {} } - self.validate_cells()?; self.validate_selection()?; if self.at_bottom != (self.scroll_offset == 0) { return Err(TerminalFrameError::BottomState { @@ -260,107 +306,6 @@ impl TerminalFrame { Ok(()) } - /// Declared cell area, checked against both shared bounds. - fn checked_area(&self) -> Result { - let rows = self.size.rows; - let cols = self.size.cols; - if rows == 0 - || cols == 0 - || rows > u32::from(MAX_TERMINAL_ROWS) - || cols > u32::from(MAX_TERMINAL_COLS) - { - return Err(TerminalFrameError::Size { - rows, - cols, - max_rows: u32::from(MAX_TERMINAL_ROWS), - max_cols: u32::from(MAX_TERMINAL_COLS), - }); - } - // Both factors are bounded above by 512, so the product cannot - // overflow; `checked_mul` keeps that an assertion rather than an - // assumption a later bound change could quietly break. - let area = rows - .checked_mul(cols) - .and_then(|area| usize::try_from(area).ok()) - .ok_or(TerminalFrameError::Area { - area: usize::MAX, - max: MAX_TERMINAL_VISIBLE_CELLS, - })?; - if area > MAX_TERMINAL_VISIBLE_CELLS { - return Err(TerminalFrameError::Area { - area, - max: MAX_TERMINAL_VISIBLE_CELLS, - }); - } - Ok(area) - } - - /// Glyph legality, wide-continuation topology, and the glyph budget. - fn validate_cells(&self) -> Result<(), TerminalFrameError> { - let cols = self.size.cols as usize; - let mut glyph_bytes = 0usize; - // Columns still owed to the preceding wide lead on this row. - let mut pending_continuation = false; - for (index, cell) in self.cells.iter().enumerate() { - if cell.attachment.is_some() { - return Err(TerminalFrameError::Attachment { index }); - } - let col = index % cols; - if col == 0 && pending_continuation { - // A wide lead in the final column would have to be - // completed on the next row, which is not a footprint a - // terminal grid can express. - return Err(TerminalFrameError::Glyph { - index: index - 1, - reason: "wide glyph has no continuation column on its row", - }); - } - match &cell.glyph { - Glyph::Continuation => { - if !pending_continuation { - return Err(TerminalFrameError::Glyph { - index, - reason: "continuation without a preceding wide glyph", - }); - } - pending_continuation = false; - } - Glyph::Char(ch) => { - if pending_continuation { - return Err(TerminalFrameError::Glyph { - index, - reason: "wide glyph is not followed by its continuation", - }); - } - let width = char_display_width(*ch).ok_or(TerminalFrameError::Glyph { - index, - reason: "glyph is a control or zero-width character", - })?; - glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8())?; - pending_continuation = width == 2; - } - Glyph::Cluster(bytes) => { - if pending_continuation { - return Err(TerminalFrameError::Glyph { - index, - reason: "wide glyph is not followed by its continuation", - }); - } - let width = cluster_display_width(bytes, index)?; - glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len())?; - pending_continuation = width == 2; - } - } - } - if pending_continuation { - return Err(TerminalFrameError::Glyph { - index: self.cells.len() - 1, - reason: "wide glyph has no continuation column on its row", - }); - } - Ok(()) - } - /// One nonempty in-bounds span per row, strictly increasing by row. fn validate_selection(&self) -> Result<(), TerminalFrameError> { let mut previous_row: Option = None; @@ -395,73 +340,6 @@ impl TerminalFrame { } } -/// Column width of a leading `Char` glyph, or `None` when it cannot lead. -fn char_display_width(ch: char) -> Option { - if ch.is_control() { - return None; - } - match UnicodeWidthChar::width(ch) { - Some(1) => Some(1), - Some(2) => Some(2), - _ => None, - } -} - -/// Column width of a leading `Cluster` glyph. -/// -/// Width is clamped into `1..=2` exactly as the terminal screen clamps it -/// when it writes the cluster: a base plus combining marks may measure -/// wider than two columns, and the screen occupies two. Clamping in one -/// place and measuring in another is how a frame that renders correctly -/// gets rejected on the wire. -fn cluster_display_width(bytes: &[u8], index: usize) -> Result { - if bytes.is_empty() { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster is empty", - }); - } - if bytes.len() > MAX_TERMINAL_GRAPHEME_BYTES { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster exceeds the per-cluster byte limit", - }); - } - let text = std::str::from_utf8(bytes).map_err(|_| TerminalFrameError::Glyph { - index, - reason: "cluster is not valid UTF-8", - })?; - if text.chars().any(char::is_control) { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster carries a control character", - }); - } - let width = UnicodeWidthStr::width(text); - if width == 0 { - return Err(TerminalFrameError::Glyph { - index, - reason: "cluster occupies no columns", - }); - } - Ok(width.min(2)) -} - -/// Accumulate glyph bytes under the aggregate bound with checked addition. -fn add_glyph_bytes(total: usize, add: usize) -> Result { - let next = total - .checked_add(add) - .ok_or(TerminalFrameError::GlyphBudget { - max: MAX_TERMINAL_FRAME_GLYPH_BYTES, - })?; - if next > MAX_TERMINAL_FRAME_GLYPH_BYTES { - return Err(TerminalFrameError::GlyphBudget { - max: MAX_TERMINAL_FRAME_GLYPH_BYTES, - }); - } - Ok(next) -} - /// Length and control-character rules shared by title and process text. fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFrameError> { if text.len() > MAX_TERMINAL_METADATA_BYTES { @@ -482,7 +360,10 @@ fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFram #[cfg(test)] mod tests { use super::*; - use crate::cell::{Color, Style, UnderlineStyle}; + // `Glyph` is no longer used by this module's production code — the + // glyph rules moved to `crate::wire_grid` — but these tests still + // construct frames cell by cell. + use crate::cell::{Color, Glyph, Style, UnderlineStyle}; use crate::message::InstanceMessage; use crate::transport::MAX_FRAME_BYTES; diff --git a/pmacs-protocol/src/wire_grid.rs b/pmacs-protocol/src/wire_grid.rs new file mode 100644 index 0000000..9c8f8dc --- /dev/null +++ b/pmacs-protocol/src/wire_grid.rs @@ -0,0 +1,321 @@ +//! Shared cell-grid validation for every wire message that carries a +//! rectangular grid of [`Cell`]s. +//! +//! Bottom-panel Stage 2B (Q#BP15) factors this out of +//! [`crate::terminal`], which was the only such message until +//! [`crate::panel::PanelFrame`] arrived. The split follows the boundary +//! the framing names: +//! +//! - **Shared** — the checked area, the visible-cell bound, the cell +//! count, cursor bounds, glyph legality, wide-continuation topology, +//! the aggregate glyph-byte budget, and the attachment rejection. +//! - **Terminal-only** — the 512 per-axis PTY caps, title/process +//! metadata, selection spans, and the `at_bottom == (scroll_offset == +//! 0)` coupling. +//! +//! The per-axis caps are a [`WireGridLimits`] parameter rather than a +//! constant precisely because a panel does not inherit them: a 4K +//! surface at a small font is legitimately wider than 512 columns, and +//! the area bound is what keeps the encoding inside the transport +//! budget. +//! +//! The attachment rejection is deliberately **shared**, not +//! terminal-only, even though its terminal-side message reads "which +//! terminals never use". Panels render no attachments either, so +//! rejecting them here fails closed for both; classifying it as +//! terminal-only would let a panel ship a cell no frontend can paint. + +use crate::cell::{Cell, CellCoord, CellSize, Glyph}; + +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +/// Aggregate glyph-byte ceiling shared by every wire grid. +/// +/// A grid at the visible-cell bound where every cell carries a maximum +/// cluster would exceed the transport frame limit; this keeps the +/// encoded size bounded independently of the per-cell rule. +pub const MAX_WIRE_GRID_GLYPH_BYTES: usize = 8 * 1024 * 1024; + +/// Per-cell grapheme-cluster byte ceiling shared by every wire grid. +pub const MAX_WIRE_GRID_GRAPHEME_BYTES: usize = 256; + +/// Bounds a particular wire grid enforces. +/// +/// `max_rows` / `max_cols` are per-message policy. `max_visible_cells` +/// is the shared area bound and is what actually keeps the encoding +/// inside the transport budget. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct WireGridLimits { + /// Inclusive row ceiling. + pub max_rows: u32, + /// Inclusive column ceiling. + pub max_cols: u32, + /// Inclusive `rows * cols` ceiling. + pub max_visible_cells: usize, + /// Inclusive aggregate glyph-byte ceiling. + pub max_glyph_bytes: usize, +} + +/// Why a wire grid is not structurally valid. +/// +/// Callers map these onto their own message-specific error types so +/// existing wire errors keep their exact variants and text. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WireGridError { + /// Rows or columns are zero or above this grid's bounds. + Size { + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + /// Row bound in force. + max_rows: u32, + /// Column bound in force. + max_cols: u32, + }, + /// The checked area exceeds the visible-cell bound. + Area { + /// Checked `rows * cols`. + area: usize, + /// Bound in force. + max: usize, + }, + /// `cells.len()` disagrees with the declared area. + CellCount { + /// Declared area. + expected: usize, + /// Supplied cell count. + actual: usize, + }, + /// The cursor lies outside the declared grid. + Cursor { + /// Cursor row. + row: u32, + /// Cursor column. + col: u32, + /// Declared rows. + rows: u32, + /// Declared columns. + cols: u32, + }, + /// A cell's glyph is not legal in a wire grid. + Glyph { + /// Row-major cell index. + index: usize, + /// Why the glyph failed. + reason: &'static str, + }, + /// A cell carries a frontend attachment, which no wire grid uses. + Attachment { + /// Row-major cell index. + index: usize, + }, + /// Aggregate glyph bytes exceed the budget. + GlyphBudget { + /// Bound in force. + max: usize, + }, +} + +/// Declared cell area, checked against this grid's bounds. +/// +/// Separate from [`validate_wire_grid`] because callers need the area +/// before they have cells to check against it. +pub fn checked_area(size: CellSize, limits: WireGridLimits) -> Result { + let rows = size.rows; + let cols = size.cols; + if rows == 0 || cols == 0 || rows > limits.max_rows || cols > limits.max_cols { + return Err(WireGridError::Size { + rows, + cols, + max_rows: limits.max_rows, + max_cols: limits.max_cols, + }); + } + // `checked_mul` rather than a bound-derived assumption: a panel's + // axis ceilings are large enough that the product genuinely can + // overflow, which the terminal's 512x512 could not. + let area = rows + .checked_mul(cols) + .and_then(|area| usize::try_from(area).ok()) + .ok_or(WireGridError::Area { + area: usize::MAX, + max: limits.max_visible_cells, + })?; + if area > limits.max_visible_cells { + return Err(WireGridError::Area { + area, + max: limits.max_visible_cells, + }); + } + Ok(area) +} + +/// Check every structural rule shared by wire grids. +/// +/// Pure: a rejected grid mutates nothing, so callers get atomic +/// rejection for free. +pub fn validate_wire_grid( + size: CellSize, + cells: &[Cell], + cursor: Option, + limits: WireGridLimits, +) -> Result<(), WireGridError> { + let area = checked_area(size, limits)?; + if cells.len() != area { + return Err(WireGridError::CellCount { + expected: area, + actual: cells.len(), + }); + } + if let Some(cursor) = cursor + && (cursor.row >= size.rows || cursor.col >= size.cols) + { + return Err(WireGridError::Cursor { + row: cursor.row, + col: cursor.col, + rows: size.rows, + cols: size.cols, + }); + } + validate_cells(size, cells, limits) +} + +/// Glyph legality, wide-continuation topology, and the glyph budget. +fn validate_cells( + size: CellSize, + cells: &[Cell], + limits: WireGridLimits, +) -> Result<(), WireGridError> { + let cols = size.cols as usize; + let mut glyph_bytes = 0usize; + // Columns still owed to the preceding wide lead on this row. + let mut pending_continuation = false; + for (index, cell) in cells.iter().enumerate() { + if cell.attachment.is_some() { + return Err(WireGridError::Attachment { index }); + } + let col = index % cols; + if col == 0 && pending_continuation { + // A wide lead in the final column would have to be completed + // on the next row, which is not a footprint a cell grid can + // express. + return Err(WireGridError::Glyph { + index: index - 1, + reason: "wide glyph has no continuation column on its row", + }); + } + match &cell.glyph { + Glyph::Continuation => { + if !pending_continuation { + return Err(WireGridError::Glyph { + index, + reason: "continuation without a preceding wide glyph", + }); + } + pending_continuation = false; + } + Glyph::Char(ch) => { + if pending_continuation { + return Err(WireGridError::Glyph { + index, + reason: "wide glyph is not followed by its continuation", + }); + } + let width = char_display_width(*ch).ok_or(WireGridError::Glyph { + index, + reason: "glyph is a control or zero-width character", + })?; + glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8(), limits)?; + pending_continuation = width == 2; + } + Glyph::Cluster(bytes) => { + if pending_continuation { + return Err(WireGridError::Glyph { + index, + reason: "wide glyph is not followed by its continuation", + }); + } + let width = cluster_display_width(bytes, index)?; + glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len(), limits)?; + pending_continuation = width == 2; + } + } + } + if pending_continuation { + return Err(WireGridError::Glyph { + index: cells.len() - 1, + reason: "wide glyph has no continuation column on its row", + }); + } + Ok(()) +} + +/// Column width of a leading `Char` glyph, or `None` when it cannot lead. +pub(crate) fn char_display_width(ch: char) -> Option { + if ch.is_control() { + return None; + } + match UnicodeWidthChar::width(ch) { + Some(1) => Some(1), + Some(2) => Some(2), + _ => None, + } +} + +/// Column width of a leading `Cluster` glyph. +/// +/// Width is clamped into `1..=2` exactly as the terminal screen clamps it +/// when it writes the cluster: a base plus combining marks may measure +/// wider than two columns, and the screen occupies two. Clamping in one +/// place and measuring in another is how a frame that renders correctly +/// gets rejected on the wire. +fn cluster_display_width(bytes: &[u8], index: usize) -> Result { + if bytes.is_empty() { + return Err(WireGridError::Glyph { + index, + reason: "cluster is empty", + }); + } + if bytes.len() > MAX_WIRE_GRID_GRAPHEME_BYTES { + return Err(WireGridError::Glyph { + index, + reason: "cluster exceeds the per-cluster byte limit", + }); + } + let text = std::str::from_utf8(bytes).map_err(|_| WireGridError::Glyph { + index, + reason: "cluster is not valid UTF-8", + })?; + if text.chars().any(char::is_control) { + return Err(WireGridError::Glyph { + index, + reason: "cluster carries a control character", + }); + } + let width = UnicodeWidthStr::width(text); + if width == 0 { + return Err(WireGridError::Glyph { + index, + reason: "cluster occupies no columns", + }); + } + Ok(width.min(2)) +} + +/// Accumulate glyph bytes against the aggregate budget. +fn add_glyph_bytes( + total: usize, + add: usize, + limits: WireGridLimits, +) -> Result { + let next = total.checked_add(add).ok_or(WireGridError::GlyphBudget { + max: limits.max_glyph_bytes, + })?; + if next > limits.max_glyph_bytes { + return Err(WireGridError::GlyphBudget { + max: limits.max_glyph_bytes, + }); + } + Ok(next) +} diff --git a/src/daemon.rs b/src/daemon.rs index 84716eb..eef6095 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3313,6 +3313,20 @@ fn apply_event( (grid terminals resize through the Stage 2 layout path)" ); } + FrontendEvent::FrontendCellGeometry { .. } + | FrontendEvent::PanelResizeRows { .. } + | FrontendEvent::PanelPointer { .. } => { + // Bottom panel Stage 2 — panel declarations belong to + // negotiated panel-capable semantic sessions and are routed + // by the authenticated source in `handle_dispatcher_event`. + // A grid session has no panel band at all, so one arriving + // here is a protocol violation; drop it rather than letting + // a payload-trusted id reach a view. + eprintln!( + "pmacs daemon: panel declaration from a grid session; dropping \ + (grid sessions negotiate no panel band)" + ); + } } } diff --git a/src/frontend.rs b/src/frontend.rs index 8fcfb47..6b8755f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -439,6 +439,11 @@ impl Frontend { // Q#GT4 — this pre-window semantic bootstrap result cannot // legitimately reach the grid TUI. | InstanceMessage::InitialTargetResult(_) + // Q#BP15 — the panel band is painted by the GPU frontend; + // the grid TUI renders its side windows through the cell + // grid and negotiates no panel capability, so this cannot + // legitimately reach here. + | InstanceMessage::PanelFrame(_) | InstanceMessage::ResourceOffer { .. } // T M11.6 — DispatchIdle is consumed by `attach.rs`'s // optimistic-apply gate; if any reaches this render path diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs new file mode 100644 index 0000000..31a95a7 --- /dev/null +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -0,0 +1,351 @@ +//! Bottom-panel Stage 2B — the v21 protocol slice. +//! +//! Covers parent acceptance 37 (round-trip plus the two byte pins) and +//! the shared/terminal-only validator split of Q#BP15. The daemon +//! projection, the epoch state machine, and the GPU band are later +//! slices of this stage and are not exercised here. + +use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Glyph, Style}; +use pmacs_protocol::message::{FrontendEvent, InstanceMessage, Modifiers, MouseButton, MouseKind}; +use pmacs_protocol::panel::{PanelFrame, PanelFrameError, PanelFramePayload}; +use pmacs_protocol::terminal::{ + MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState, +}; +use pmacs_protocol::{BufferId, FrontendId, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}; + +fn cell(ch: char) -> Cell { + Cell { + glyph: Glyph::Char(ch), + style: Style::default(), + attachment: None, + } +} + +fn panel_frame(rows: u32, cols: u32) -> PanelFrame { + PanelFrame { + buffer_id: BufferId::from_raw(9), + panel_epoch: 3, + geometry_epoch: 5, + size: CellSize::new(rows, cols), + cells: vec![cell(' '); (rows * cols) as usize], + cursor: Some(CellCoord::new(0, 0)), + focused: true, + } +} + +fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame { + TerminalFrame { + buffer_id: BufferId::from_raw(9), + size: CellSize::new(rows, cols), + cells: vec![cell(' '); (rows * cols) as usize], + cursor: Some(CellCoord::new(0, 0)), + title: None, + screen_generation: 1, + selection: Vec::new(), + scroll_offset: 0, + at_bottom: true, + pid: 1, + process: TerminalProcessState::Running, + } +} + +// --------------------------------------------------------------------------- +// 37 — version and round-trip +// --------------------------------------------------------------------------- + +#[test] +fn the_panel_stage_takes_protocol_v21() { + assert_eq!(PROTOCOL_VERSION, 21); + assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21)); + // v20 stays supported: a v20 peer interoperates with panel traffic + // simply absent rather than being refused the handshake. + assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20)); +} + +#[test] +fn a_present_panel_frame_round_trips_with_both_epochs() { + let frame = panel_frame(2, 3); + let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(frame.clone())); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + let InstanceMessage::PanelFrame(PanelFramePayload::Present(got)) = decoded else { + panic!("expected a Present panel frame, got {decoded:?}"); + }; + // Both epochs must survive: they are the identities every later + // panel event validates against, so a frame that round-trips its + // cells but drops an epoch would silently accept stale input. + assert_eq!(got.panel_epoch, frame.panel_epoch); + assert_eq!(got.geometry_epoch, frame.geometry_epoch); + assert_eq!(got.buffer_id, frame.buffer_id); + assert_eq!(got.size, frame.size); + assert_eq!(got.cells, frame.cells); + assert_eq!(got.cursor, frame.cursor); + assert_eq!(got.focused, frame.focused); +} + +#[test] +fn an_absent_panel_payload_round_trips_as_its_own_state() { + let msg = InstanceMessage::PanelFrame(PanelFramePayload::Absent); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert!(matches!( + decoded, + InstanceMessage::PanelFrame(PanelFramePayload::Absent) + )); + // Absent must be distinguishable from a Present frame carrying no + // cells: it is authoritative, and conflating the two would make + // "hide the band" indistinguishable from "paint an empty band". + let empty_present = InstanceMessage::PanelFrame(PanelFramePayload::Present(panel_frame(1, 1))); + assert_ne!( + postcard::to_allocvec(&empty_present).expect("encode"), + bytes + ); +} + +#[test] +fn the_three_panel_events_round_trip() { + let fid = FrontendId(4); + let events = vec![ + FrontendEvent::FrontendCellGeometry { + frontend_id: fid, + geometry_epoch: 1, + total: CellSize::new(40, 120), + }, + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch: 2, + panel_epoch: 7, + rows: 12, + }, + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: 2, + panel_epoch: 7, + coord: CellCoord::new(3, 9), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }, + ]; + for event in events { + let bytes = postcard::to_allocvec(&event).expect("encode"); + let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(decoded, event); + assert_eq!(decoded.frontend_id(), fid); + } +} + +// --------------------------------------------------------------------------- +// 37 — byte pins on the previous final variant of each extended enum +// --------------------------------------------------------------------------- + +#[test] +fn appending_panel_frame_does_not_move_the_previous_final_instance_discriminant() { + // `InitialTargetResult` was the final v20 variant. Its encoding must + // be byte-identical after `PanelFrame` is appended; if the new + // variant were inserted anywhere earlier, this leading discriminant + // byte would shift and every v20 peer would misread the wire. + let msg = InstanceMessage::InitialTargetResult( + pmacs_protocol::message::InitialTargetResult::Opened { + buffer_id: BufferId::from_raw(1), + }, + ); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + assert_eq!( + bytes[0], 27, + "InitialTargetResult must stay discriminant 27; got {bytes:?}" + ); + // And the appended variant must be the next one, not a reused slot. + let panel = InstanceMessage::PanelFrame(PanelFramePayload::Absent); + let panel_bytes = postcard::to_allocvec(&panel).expect("encode"); + assert_eq!(panel_bytes[0], 28); +} + +#[test] +fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() { + // `TerminalPointer` was the final v19/v20 variant of `FrontendEvent`. + let event = FrontendEvent::TerminalPointer { + frontend_id: FrontendId(2), + buffer_id: BufferId::from_raw(3), + coord: CellCoord::new(1, 1), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }; + let bytes = postcard::to_allocvec(&event).expect("encode"); + assert_eq!( + bytes[0], 12, + "TerminalPointer must stay discriminant 12; got {bytes:?}" + ); + // The three appended events take the next three slots, in order. + let fid = FrontendId(2); + for (expected, event) in [ + ( + 13u8, + FrontendEvent::FrontendCellGeometry { + frontend_id: fid, + geometry_epoch: 1, + total: CellSize::new(1, 1), + }, + ), + ( + 14, + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch: 1, + panel_epoch: 1, + rows: 1, + }, + ), + ( + 15, + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: 1, + panel_epoch: 1, + coord: CellCoord::new(0, 0), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }, + ), + ] { + let bytes = postcard::to_allocvec(&event).expect("encode"); + assert_eq!(bytes[0], expected, "wrong discriminant for {event:?}"); + } +} + +// --------------------------------------------------------------------------- +// 39 — the shared/terminal-only validator split +// --------------------------------------------------------------------------- + +#[test] +fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() { + let wide = MAX_TERMINAL_COLS as u32 + 1; + + // The panel does not inherit the PTY per-axis cap: a 4K surface at a + // small font is legitimately this wide, and the area bound is what + // keeps the encoding inside the transport budget. + let panel = panel_frame(1, wide); + assert_eq!(panel.validate(), Ok(())); + + // The terminal keeps it, and reports the axis that failed. + let terminal = terminal_frame(1, wide); + assert!(matches!( + terminal.validate(), + Err(TerminalFrameError::Size { cols, max_cols, .. }) + if cols == wide && max_cols == MAX_TERMINAL_COLS as u32 + )); +} + +#[test] +fn a_panel_still_answers_to_the_shared_area_bound() { + // Removing the per-axis cap must not remove the area bound: that is + // the check that actually bounds the encoded size. + let huge = panel_frame(1, 1); + let mut huge = huge; + huge.size = CellSize::new(1024, 1024); + huge.cells = vec![cell(' '); 1]; + assert!(matches!(huge.validate(), Err(PanelFrameError::Area { .. }))); +} + +#[test] +fn a_panel_cell_carrying_an_attachment_is_rejected() { + // The attachment rejection is SHARED, not terminal-only, even though + // the terminal-side message says "which terminals never use": + // panels render no attachments either, so a shared rejection fails + // closed for both. + let mut frame = panel_frame(1, 2); + frame.cells[1].attachment = Some(pmacs_protocol::cell::Attachment::ImageCell { + image_id: 1, + sub_x: 0, + sub_y: 0, + }); + assert!(matches!( + frame.validate(), + Err(PanelFrameError::Attachment { index: 1 }) + )); +} + +#[test] +fn panel_glyph_topology_matches_the_terminal_rules() { + // A wide lead with no continuation column on its row is rejected the + // same way for both messages — the topology rule is shared. + let mut frame = panel_frame(1, 1); + frame.cells[0] = cell('\u{4e00}'); + assert!(matches!( + frame.validate(), + Err(PanelFrameError::Glyph { .. }) + )); + + let mut terminal = terminal_frame(1, 1); + terminal.cells[0] = cell('\u{4e00}'); + assert!(matches!( + terminal.validate(), + Err(TerminalFrameError::Glyph { .. }) + )); +} + +#[test] +fn a_panel_cursor_outside_its_grid_is_rejected() { + let mut frame = panel_frame(2, 2); + frame.cursor = Some(CellCoord::new(2, 0)); + assert!(matches!( + frame.validate(), + Err(PanelFrameError::Cursor { + row: 2, + rows: 2, + .. + }) + )); +} + +#[test] +fn a_zero_epoch_panel_frame_is_rejected_on_the_wire() { + // Epoch 0 is reserved for "never declared" (Q#BP2S1), so a frame + // carrying it could otherwise match a receiver that has declared + // nothing yet. + let mut frame = panel_frame(1, 1); + frame.panel_epoch = 0; + assert!(matches!( + frame.validate(), + Err(PanelFrameError::ZeroEpoch { field: "panel" }) + )); + + let mut frame = panel_frame(1, 1); + frame.geometry_epoch = 0; + assert!(matches!( + frame.validate(), + Err(PanelFrameError::ZeroEpoch { field: "geometry" }) + )); +} + +#[test] +fn terminal_frames_are_unchanged_by_the_factoring() { + // The shared validator must not have altered terminal acceptance: + // a valid frame still validates, and each terminal-only rule still + // reports its own variant. + assert_eq!(terminal_frame(3, 4).validate(), Ok(())); + + let mut bad_bottom = terminal_frame(1, 1); + bad_bottom.at_bottom = false; + bad_bottom.scroll_offset = 0; + assert!(matches!( + bad_bottom.validate(), + Err(TerminalFrameError::BottomState { .. }) + )); + + let mut bad_meta = terminal_frame(1, 1); + bad_meta.title = Some("\u{7}".into()); + assert!(matches!( + bad_meta.validate(), + Err(TerminalFrameError::Metadata { field: "title", .. }) + )); + + let mut bad_count = terminal_frame(2, 2); + bad_count.cells.pop(); + assert!(matches!( + bad_count.validate(), + Err(TerminalFrameError::CellCount { + expected: 4, + actual: 3 + }) + )); +} From 8af529b65dd32313ebd982ff521d7a232244eb7f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 16:59:51 -0400 Subject: [PATCH 66/91] test(protocol): move the version ladder pins to v21 Both pins failed on the bump, which is what they exist for. The ladder test now accepts 6..=21 and rejects 22, and the version assertion carries the Stage 2 entry: four variants appended after their enum's final v20 variant, gated in both directions. Also renames `protocol_version_is_twenty_for_gpu_initial_targets`, whose name pinned the old number. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR --- pmacs-protocol/src/wire_grid.rs | 2 +- src/protocol.rs | 19 +++++++++++++------ ...ottom_panel_stage2b_protocol_acceptance.rs | 4 ++-- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/pmacs-protocol/src/wire_grid.rs b/pmacs-protocol/src/wire_grid.rs index 9c8f8dc..1ff40c9 100644 --- a/pmacs-protocol/src/wire_grid.rs +++ b/pmacs-protocol/src/wire_grid.rs @@ -60,7 +60,7 @@ pub struct WireGridLimits { /// /// Callers map these onto their own message-specific error types so /// existing wire errors keep their exact variants and text. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum WireGridError { /// Rows or columns are zero or above this grid's bounds. Size { diff --git a/src/protocol.rs b/src/protocol.rs index df65863..baf2709 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_twenty_for_gpu_initial_targets() { + fn protocol_version_is_twenty_one_for_the_bottom_panel_band() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1722,7 +1722,13 @@ mod tests { // variant, see the placement pins). // GPU initial targets bump 19→20 with a semantic-only // SessionBootstrapRequest and appended InitialTargetResult. - assert_eq!(PROTOCOL_VERSION, 20); + // Bottom panel Stage 2 bumps 20→21 (`InstanceMessage::PanelFrame`, + // daemon-gated, plus `FrontendEvent::{FrontendCellGeometry, + // PanelResizeRows, PanelPointer}`, frontend-gated — the second + // bump that gates in BOTH directions; all four appended after + // their enum's final v20 variant, see the placement pins in + // `bottom_panel_stage2b_protocol_acceptance`). + assert_eq!(PROTOCOL_VERSION, 21); } #[test] @@ -1798,17 +1804,18 @@ mod tests { // minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15 // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`), // v18 (`StatuslineSegments`), v19 (the vterm terminal family), - // and v20 (semantic initial-target bootstrap) all interoperate. - for accepted in 6..=20 { + // v20 (semantic initial-target bootstrap), and v21 (the bottom + // panel band) all interoperate. + for accepted in 6..=21 { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 21, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, 22, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v20 binary" + "v{rejected} must be rejected by a v21 binary" ); } } diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs index 31a95a7..91a8d48 100644 --- a/tests/bottom_panel_stage2b_protocol_acceptance.rs +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -218,7 +218,7 @@ fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() #[test] fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() { - let wide = MAX_TERMINAL_COLS as u32 + 1; + let wide = u32::from(MAX_TERMINAL_COLS) + 1; // The panel does not inherit the PTY per-axis cap: a 4K surface at a // small font is legitimately this wide, and the area bound is what @@ -231,7 +231,7 @@ fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() { assert!(matches!( terminal.validate(), Err(TerminalFrameError::Size { cols, max_cols, .. }) - if cols == wide && max_cols == MAX_TERMINAL_COLS as u32 + if cols == wide && max_cols == u32::from(MAX_TERMINAL_COLS) )); } From 0d7ec7e3a6c1384e6d95b68456c8290add4e92fc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 17:00:58 -0400 Subject: [PATCH 67/91] fix(lean4): tie the deferred expansion to the fan-out that queued it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buffer.after-edit` fan-outs NEST — the typed-edit contract supports a consumer calling `pmacs.hook.run`, and typed_edit.lua's header says so in its second paragraph. A nested run re-enters every subscriber, including the deferred expansion's, while the OUTER chain is still walking its consumer list and pairing has not yet seen the terminator. So a consumer registered at priority 75 — between the expander at 50 and pairing at 100 — that runs one nested fan-out made `\alp(` yield `α(` again: the nested pass consumed the queued expansion and edited, and outer pairing then resumed holding a record the replace had invalidated. That is round 10's failure reached through the chain's documented re-entrancy seam rather than through claiming, which is why deferring alone did not close it. Deferring work past a fan-out means owning WHICH fan-out it belongs to. The chain's subscriber and this module's each run exactly once per fan-out, in that order, so counting invocations of the first and matching them off in the second identifies the nesting level. Only the outermost pass expands; a nested one leaves the expansion queued. No new seam in typed_edit.lua, which is merged Stage 4a substrate. Both halves bite: removing the level check and never counting invocations each fail the new acceptance 45n. Also fixes a test comment that still described the span design round 10 discarded — it claimed the expansion replaces the span "INCLUDING the terminator". The behaviour asserted was right; the explanation was stale. Framing rev 11. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- builtin/runtime/lean_input.lua | 28 ++++++++++++++++++++ docs/active-work.md | 19 +++++++++++--- docs/agent-handoff.md | 6 ++++- docs/lean4-mode-framing.md | 48 ++++++++++++++++++++++++++++++++-- tests/lean_input_acceptance.rs | 44 ++++++++++++++++++++++++++++++- 5 files changed, 138 insertions(+), 7 deletions(-) diff --git a/builtin/runtime/lean_input.lua b/builtin/runtime/lean_input.lua index 7b205d2..4404786 100644 --- a/builtin/runtime/lean_input.lua +++ b/builtin/runtime/lean_input.lua @@ -229,7 +229,26 @@ end -- The consumer -- --------------------------------------------------------------------- +-- Chain-consumer invocations not yet matched by a `run_deferred`. +-- +-- `buffer.after-edit` fan-outs NEST: the typed-edit contract explicitly +-- supports a consumer calling `pmacs.hook.run("buffer.after-edit")`, +-- and a nested run re-enters every subscriber — including this module's +-- deferred-expansion subscriber, while the OUTER chain is still walking +-- its consumer list and pairing has not yet seen the terminator. A +-- nested run that performed the expansion would reproduce exactly the +-- bug deferring exists to fix: pairing resumes afterwards holding a +-- record the replace has invalidated, declines, and the closer is lost. +-- +-- The chain's subscriber and this module's subscriber run exactly once +-- each per fan-out, in that order, so counting invocations of the first +-- and matching them off in the second identifies the nesting level +-- without any new seam in typed_edit.lua. Only the outermost pass +-- performs the expansion; a nested one leaves it queued. +local depth = 0 + local function on_typed_edit(rec) + depth = depth + 1 local fid = frontend_id() if fid == nil then return false end @@ -404,6 +423,15 @@ end -- A claim by ANY chain consumer stops the chain but not this — which -- is the point. Pairing claims the terminator it reacts to. local function run_deferred() + -- Match off this fan-out's chain invocation. `> 1` means the outer + -- chain is still mid-list — pairing has not had the terminator yet — + -- so the queued expansion stays queued for the outer pass. The clamp + -- keeps this honest if a lower-priority consumer claimed before the + -- chain reached ours, in which case there is nothing queued anyway. + local level = depth + if depth > 0 then depth = depth - 1 end + if level > 1 then return end + local fid = frontend_id() if fid == nil then return end local d = deferred[fid] diff --git a/docs/active-work.md b/docs/active-work.md index f931da1..1a08626 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -175,8 +175,8 @@ If it does not, stop and repair the remote/fetch configuration. ### Stage 4b — the Unicode input method (branch `lean4-stage4b-input-method`) -- Framing `docs/lean4-mode-framing.md` **revision 10** (round 10 = - review of the implementation). Stage +- Framing `docs/lean4-mode-framing.md` **revision 11** (rounds 10 and + 11 = review of the implementation). Stage 4a (the typed-edit consumer chain) MERGED as #179; this branch is 4b, the Lean content that registers on it. - Footprint: `scripts/regen-lean-abbrev` (new, the generator), @@ -184,7 +184,7 @@ If it does not, stop and repair the remote/fetch configuration. from `leanprover/vscode-lean4@17d1d08`, Apache-2.0), `builtin/runtime/lean_input.lua` (new, the consumer at priority 50), `src/editor.rs` (two `include_str!` blocks), - `tests/lean_input_acceptance.rs` (new, 29 tests), and one + `tests/lean_input_acceptance.rs` (new, 30 tests), and one `#[cfg(feature = "crdt")]` `--lib` test in `src/daemon.rs` (acceptance 45f). No protocol change (Q#LN14). Entirely Lua apart from the load sites and that one test. @@ -245,6 +245,8 @@ If it does not, stop and repair the remote/fetch configuration. | drop the `cursor() == post_cursor` check | 1 | | place the point without the context guard | 1 | | load lean_input.lua after lsp.lua | 1 | + | let a nested fan-out consume the deferred slot | 1 | + | stop counting chain invocations | 1 | Acceptance 45f bit by construction: without a registered window for the source frontend it ran six fan-outs with a nil record and proved @@ -265,6 +267,17 @@ If it does not, stop and repair the remote/fetch configuration. relevance check needs `cursor() == post_cursor`, as pairing's has since #110), and cursor placement could move a buffer an intercept had switched to. +- **Round 11 found the round-10 fix incomplete in one place: + `buffer.after-edit` fan-outs NEST.** A consumer between the expander + (50) and pairing (100) that calls `pmacs.hook.run("buffer.after-edit")` + re-enters the expander's subscriber while the OUTER chain is still + mid-list; the nested pass expanded and outer pairing then resumed with + an invalidated record — `α(` again, through the chain's documented + re-entrancy seam instead of through claiming. **Deferring work past a + fan-out means owning which fan-out it belongs to.** The chain's + subscriber and the expander's each run exactly once per fan-out, so + counting the first and matching it off in the second identifies the + nesting level with no new seam in merged Stage 4a substrate. - Undo is cross-peer-degraded on CRDT frontends and that is ACCEPTED, named in the module header (Q#LN21): six source-peer optimistic inserts replaced by one daemon-peer op. `set_round_trip_input` would diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d1dc46b..4f1f467 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -139,7 +139,11 @@ commands, read `docs/active-work.md` immediately after this file. be used. The expansion therefore runs on a SECOND `buffer.after-edit` subscriber after the chain — which is how a pair character that terminates an abbreviation still pairs - (`\alp(` → `α()`). Its other durable facts: + (`\alp(` → `α()`). And **deferring work past a fan-out means + owning which fan-out it belongs to**: these fan-outs NEST, so a + consumer between the expander and pairing that calls + `pmacs.hook.run` re-enters the deferred subscriber while the outer + chain is still mid-list. Its other durable facts: the table must stay an ORDERED SEQUENCE (equal-length ties resolve by source declaration order, which a `pairs`-iterated map cannot express); a generator round-trip check must re-read the BYTES ON diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index 482ad84..3229f35 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -46,7 +46,7 @@ during a rebase. ## 0.1 Revision history -Revision 1 — initial. Current revision: **10**. +Revision 1 — initial. Current revision: **11**. ### Round 1 (rev 1 → rev 2) @@ -567,6 +567,26 @@ Acceptance 45m was added with them: the expansion now runs on its own `buffer.after-edit` subscriber, which is a new instance of Q#AP7 and was unpinned. +### Round 11 (rev 10 → rev 11) + +One P1 in the round-10 fix, and one stale comment. + +1. **The deferred expansion was not tied to the fan-out that queued + it.** `buffer.after-edit` fan-outs nest — the typed-edit contract + supports a consumer calling `pmacs.hook.run` — and a nested run + re-enters the expander's subscriber while the OUTER chain is still + mid-list. A consumer at priority 75 running one nested fan-out made + `\alp(` yield `α(` again: the nested pass expanded, and outer + pairing then resumed with a record the replace had invalidated. + Round 10's own failure mode, reached through re-entrancy instead of + claiming. Q#LN22 now specifies matching chain invocations against + expander invocations so only the outermost pass expands; acceptance + 45n pins it. +2. **A test comment still described the discarded span design** — it + said the expansion replaces the span "INCLUDING the terminator", + which round 10 deliberately stopped doing. The behaviour it asserts + was correct; only the explanation was stale. + ## 1. What ships Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The @@ -1785,6 +1805,22 @@ and the typed text only; whatever pairing did lands after it and survives untouched. One undo restores the same text either way, because the terminator was always its own insert. +**The deferred expansion must belong to its own fan-out** (round 11). +`buffer.after-edit` fan-outs NEST — Q#AP9 and typed_edit.lua's header +both say so explicitly, and a consumer may call `pmacs.hook.run`. A +nested run re-enters every subscriber, including the deferred +expansion's, while the OUTER chain is still walking its consumer list +and pairing has not yet seen the terminator. A nested pass that +performed the expansion would reproduce the exact bug deferring exists +to fix, reached through the chain's documented re-entrancy seam instead +of through claiming. + +The chain's subscriber and the expander's subscriber each run exactly +once per fan-out, in that order, so counting invocations of the first +and matching them off in the second identifies the nesting level — no +new seam in typed_edit.lua, which is merged substrate. Only the +outermost pass expands; a nested one leaves the expansion queued. + Two guards this exposes, both of which pairing already carries: - The relevance check is **three-part**, not two: buffer, window, **and @@ -2692,6 +2728,14 @@ criterion 46 requires to stay byte-identical. unexpanded text. Pinned with the `sighelp` fake server and `(` as the trigger — the flush carrying the terminator carries `α()`. Falsified by loading lean_input.lua after lsp.lua. +45n. **A nested fan-out must not expand early** (round 11). A consumer + registered BETWEEN the expander and pairing that calls + `pmacs.hook.run("buffer.after-edit")` once still yields `α()` for + `\alp(`. Bites against a deferred slot consumed by whichever + fan-out happens to reach it: the nested pass would expand, and the + outer chain would then hand pairing a record the replace had + invalidated — the round-10 failure again, through the chain's + documented re-entrancy seam rather than through claiming. 45h. **Tie-break by source order (§2.11).** `\f` + space yields `‹` — `f<` and `f>` are both length 2, and `f<` is declared first. Same for `\"` + space → `Ä`, first of eleven equal-length candidates. @@ -2846,7 +2890,7 @@ uncapped event queue, the dropped `cfg.restart`, and — unchanged from languages other than Lean, and §4's rule is what keeps them out of a Lean PR. -### 9.1 Coherence impact — stages 4a and 4b (rev 10) +### 9.1 Coherence impact — stages 4a and 4b (rev 11) **Sections served.** §6 (interaction islands) primarily, and in the *preventing* direction rather than the fixing one — see below. §11 diff --git a/tests/lean_input_acceptance.rs b/tests/lean_input_acceptance.rs index 9270f1b..4cc0613 100644 --- a/tests/lean_input_acceptance.rs +++ b/tests/lean_input_acceptance.rs @@ -98,7 +98,10 @@ fn lean_editor() -> (EditorState, PathBuf) { fn the_finish_path_retains_the_terminator_in_one_undo_step() { // `alp` is not a key; `alpha` is the shortest key extending it. The // space does not extend anything, so it lands first and the - // expansion replaces the whole span INCLUDING the terminator. + // expansion replaces the leader and the typed text — the span stops + // BEFORE the terminator, so whatever auto-pairing did with it + // survives. One undo restores the same text either way, because the + // terminator was its own insert to begin with. let (mut s, _f) = lean_editor(); type_str(&mut s, "\\alp "); assert_eq!(text(&s), "α ", "terminator retained, not consumed"); @@ -209,6 +212,45 @@ fn a_pair_character_that_terminates_an_abbreviation_still_pairs() { ); } +#[test] +fn a_nested_fan_out_between_the_expander_and_pairing_does_not_expand_early() { + // `buffer.after-edit` fan-outs NEST — the typed-edit contract + // explicitly supports a consumer calling `pmacs.hook.run`, and a + // nested run re-enters every subscriber, including the deferred + // expansion's. If the nested pass performed the expansion, the + // OUTER chain would then resume and hand pairing a record the + // replace had already invalidated: `α(` again, reached through the + // chain's documented re-entrancy seam rather than through claiming. + let (mut s, _f) = lean_editor(); + exec( + &s, + r#" + _G.NESTED = 0 + pmacs.typed_edit.add_consumer { + name = "nested-fan-out", + priority = 75, -- between the expander (50) and pairing (100) + fn = function() + if _G.NESTED == 0 then + _G.NESTED = 1 + pmacs.hook.run("buffer.after-edit") + end + return false + end, + } + "#, + ); + + type_str(&mut s, "\\alp("); + let nested: i64 = eval(&s, "return _G.NESTED"); + assert_eq!(nested, 1, "the nested fan-out must actually have run"); + assert_eq!( + text(&s), + "α()", + "the expansion waited for the OUTERMOST pass, so pairing still \ + held a valid record when the terminator reached it" + ); +} + #[test] fn a_pair_character_outside_a_pending_abbreviation_still_pairs() { // The other direction: claiming extensions must not disable pairing From d1bff6ac30869978db6cf47d7ca02f4cb10b1be8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 17:15:24 -0400 Subject: [PATCH 68/91] fix(lean4): count fan-outs where a claim cannot skip the count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 11 put the nesting count in the expander, which is optional. A consumer at a lower priority can CLAIM and stop the chain before the expander runs, while that fan-out's deferred-expansion subscriber still runs — so the nested pass went uncounted, looked like the outermost one, expanded early, and outer pairing resumed with a record the replace had invalidated. `\alp(` gave `α(` again. The count now comes from a no-op consumer registered at the minimum priority, which runs first in every chain invocation that reaches any consumer at all. Its guarantee is exactly the ordering contract the chain already rests on, and it degrades safely: the only thing that can skip it is a claim ahead of it, which skips the expander too, so nothing is queued in that fan-out either. The other plausible home does not work and the comment now says why: a subscriber registered beside `run_deferred` is too late, because the whole nested fan-out completes inside the OUTER chain's subscriber, before either of them runs. Acceptance 45o pins the short-circuit path — a consumer at 25 that claims when the record is nil, so the nested pass never reaches the expander. 45n passes against this bug, which is why both exist. Counting in the expander fails 45o and nothing else. Framing rev 12 also names the shape rounds 10–12 share: each fix was correct about the failure it was shown and wrong about the boundary of the mechanism it leaned on — the chain's copy semantics, then its re-entrancy, then its short-circuit. A queue that outlives the thing that filled it has to name that thing, not approximate it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- builtin/runtime/lean_input.lua | 41 +++++++++++++++++------ docs/active-work.md | 24 ++++++++++++-- docs/agent-handoff.md | 6 +++- docs/lean4-mode-framing.md | 58 +++++++++++++++++++++++++++++---- tests/lean_input_acceptance.rs | 59 ++++++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 20 deletions(-) diff --git a/builtin/runtime/lean_input.lua b/builtin/runtime/lean_input.lua index 4404786..4079de0 100644 --- a/builtin/runtime/lean_input.lua +++ b/builtin/runtime/lean_input.lua @@ -229,7 +229,7 @@ end -- The consumer -- --------------------------------------------------------------------- --- Chain-consumer invocations not yet matched by a `run_deferred`. +-- Chain invocations not yet matched by a `run_deferred`. -- -- `buffer.after-edit` fan-outs NEST: the typed-edit contract explicitly -- supports a consumer calling `pmacs.hook.run("buffer.after-edit")`, @@ -240,15 +240,29 @@ end -- bug deferring exists to fix: pairing resumes afterwards holding a -- record the replace has invalidated, declines, and the closer is lost. -- --- The chain's subscriber and this module's subscriber run exactly once --- each per fan-out, in that order, so counting invocations of the first --- and matching them off in the second identifies the nesting level --- without any new seam in typed_edit.lua. Only the outermost pass --- performs the expansion; a nested one leaves it queued. +-- Counting has to happen INSIDE the chain and BEFORE any consumer that +-- might start a nested fan-out. A subscriber registered alongside +-- `run_deferred` is too late — the whole nested fan-out completes +-- inside the outer chain's subscriber, before either of them runs. And +-- counting in the expander itself is not enough: a lower-priority +-- consumer may CLAIM and stop the chain before the expander is +-- reached, so a nested pass would go uncounted while its +-- `run_deferred` still ran (round 11's fix, round 12's defect). +-- +-- Hence a separate no-op consumer at the minimum priority, which runs +-- first in every chain invocation that reaches any consumer at all. +-- Its guarantee is exactly the ordering contract the chain already +-- rests on, and it degrades safely: the only thing that can skip it is +-- a claim ahead of it, which skips the expander too, so nothing is +-- queued in that fan-out either. local depth = 0 -local function on_typed_edit(rec) +local function count_fan_out() depth = depth + 1 + return false +end + +local function on_typed_edit(rec) local fid = frontend_id() if fid == nil then return false end @@ -426,8 +440,8 @@ local function run_deferred() -- Match off this fan-out's chain invocation. `> 1` means the outer -- chain is still mid-list — pairing has not had the terminator yet — -- so the queued expansion stays queued for the outer pass. The clamp - -- keeps this honest if a lower-priority consumer claimed before the - -- chain reached ours, in which case there is nothing queued anyway. + -- keeps this honest if a claim beat the counting consumer, in which + -- case nothing was queued in that fan-out either. local level = depth if depth > 0 then depth = depth - 1 end if level > 1 then return end @@ -487,6 +501,15 @@ pmacs.hook.add("buffer.after-switch", function() if fid ~= nil then pending[fid] = nil end end) +-- Runs first in every chain invocation that reaches a consumer at all, +-- which is what makes the nesting count trustworthy — see `depth`. It +-- declines, always: it observes, it does not participate. +pmacs.typed_edit.add_consumer { + name = "lean-abbrev-fan-out-counter", + priority = -2147483648, + fn = count_fan_out, +} + pmacs.typed_edit.add_consumer { name = "lean-abbrev", priority = 50, diff --git a/docs/active-work.md b/docs/active-work.md index 1a08626..ff1b894 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -175,8 +175,8 @@ If it does not, stop and repair the remote/fetch configuration. ### Stage 4b — the Unicode input method (branch `lean4-stage4b-input-method`) -- Framing `docs/lean4-mode-framing.md` **revision 11** (rounds 10 and - 11 = review of the implementation). Stage +- Framing `docs/lean4-mode-framing.md` **revision 12** (rounds 10, 11 + and 12 = review of the implementation). Stage 4a (the typed-edit consumer chain) MERGED as #179; this branch is 4b, the Lean content that registers on it. - Footprint: `scripts/regen-lean-abbrev` (new, the generator), @@ -184,7 +184,7 @@ If it does not, stop and repair the remote/fetch configuration. from `leanprover/vscode-lean4@17d1d08`, Apache-2.0), `builtin/runtime/lean_input.lua` (new, the consumer at priority 50), `src/editor.rs` (two `include_str!` blocks), - `tests/lean_input_acceptance.rs` (new, 30 tests), and one + `tests/lean_input_acceptance.rs` (new, 31 tests), and one `#[cfg(feature = "crdt")]` `--lib` test in `src/daemon.rs` (acceptance 45f). No protocol change (Q#LN14). Entirely Lua apart from the load sites and that one test. @@ -247,6 +247,7 @@ If it does not, stop and repair the remote/fetch configuration. | load lean_input.lua after lsp.lua | 1 | | let a nested fan-out consume the deferred slot | 1 | | stop counting chain invocations | 1 | + | count fan-outs in the expander instead of the sentinel | 1 | Acceptance 45f bit by construction: without a registered window for the source frontend it ran six fan-outs with a nil record and proved @@ -278,6 +279,23 @@ If it does not, stop and repair the remote/fetch configuration. subscriber and the expander's each run exactly once per fan-out, so counting the first and matching it off in the second identifies the nesting level with no new seam in merged Stage 4a substrate. +- **Round 12 found round 11's counter in the wrong place.** It counted + invocations of the EXPANDER, which is optional: a lower-priority + consumer can claim and stop the chain before the expander runs, while + that fan-out's deferred subscriber still runs — so the nested pass + went uncounted, looked outermost, expanded early, and outer pairing + resumed with an invalidated record. The count now comes from a no-op + consumer at the MINIMUM priority, which runs first in every chain + invocation that reaches any consumer, and degrades safely: the only + thing that can skip it is a claim ahead of it, which skips the + expander too. A subscriber registered beside `run_deferred` cannot + serve — the whole nested fan-out completes inside the outer chain's + subscriber, before it would run. +- **Rounds 10–12 share a shape worth naming.** Each fix was correct + about the failure it was shown and wrong about the boundary of the + mechanism it leaned on — first the chain's copy semantics, then its + re-entrancy, then its short-circuit. **A queue that outlives the + thing that filled it has to name that thing, not approximate it.** - Undo is cross-peer-degraded on CRDT frontends and that is ACCEPTED, named in the module header (Q#LN21): six source-peer optimistic inserts replaced by one daemon-peer op. `set_round_trip_input` would diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 4f1f467..67e7b43 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -143,7 +143,11 @@ commands, read `docs/active-work.md` immediately after this file. owning which fan-out it belongs to**: these fan-outs NEST, so a consumer between the expander and pairing that calls `pmacs.hook.run` re-enters the deferred subscriber while the outer - chain is still mid-list. Its other durable facts: + chain is still mid-list, and the count that recognises this has to + come from a MINIMUM-PRIORITY consumer — the expander is optional + (a claim can stop the chain first) and a subscriber beside the + deferred one is too late (the nested fan-out finishes inside the + outer chain's subscriber). Its other durable facts: the table must stay an ORDERED SEQUENCE (equal-length ties resolve by source declaration order, which a `pairs`-iterated map cannot express); a generator round-trip check must re-read the BYTES ON diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md index 3229f35..20c66b4 100644 --- a/docs/lean4-mode-framing.md +++ b/docs/lean4-mode-framing.md @@ -46,7 +46,7 @@ during a rebase. ## 0.1 Revision history -Revision 1 — initial. Current revision: **11**. +Revision 1 — initial. Current revision: **12**. ### Round 1 (rev 1 → rev 2) @@ -587,6 +587,26 @@ One P1 in the round-10 fix, and one stale comment. which round 10 deliberately stopped doing. The behaviour it asserts was correct; only the explanation was stale. +### Round 12 (rev 11 → rev 12) + +One P1: round 11's counter was in the wrong place. + +1. **The nesting count lived in the expander, which is optional.** A + consumer at a lower priority can claim and stop the chain before the + expander runs, while that fan-out's deferred-expansion subscriber + still runs — so the nested pass went uncounted, looked like the + outermost one, expanded early, and outer pairing resumed with an + invalidated record. `\alp(` gave `α(` again. The count now comes + from a no-op consumer at the minimum priority, which runs first in + every chain invocation that reaches any consumer; acceptance 45o + pins the short-circuit path that 45n does not reach. + +The pattern across rounds 10–12 is worth naming: each fix was correct +about the failure it was shown and wrong about the boundary of the +mechanism it relied on — the chain's copy semantics, then its +re-entrancy, then its short-circuit. **A queue that outlives the thing +that filled it needs to name that thing, not approximate it.** + ## 1. What ships Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The @@ -1815,11 +1835,27 @@ performed the expansion would reproduce the exact bug deferring exists to fix, reached through the chain's documented re-entrancy seam instead of through claiming. -The chain's subscriber and the expander's subscriber each run exactly -once per fan-out, in that order, so counting invocations of the first -and matching them off in the second identifies the nesting level — no -new seam in typed_edit.lua, which is merged substrate. Only the -outermost pass expands; a nested one leaves the expansion queued. +The nesting level is counted by a **no-op consumer registered at the +minimum priority**, matched off in the expander's subscriber. Only the +outermost pass expands; a nested one leaves the expansion queued. No +new seam in typed_edit.lua, which is merged substrate. + +Where the count lives is the whole difficulty, and two plausible places +are both wrong (round 12): + +- **A subscriber registered beside the expander's is too late.** The + entire nested fan-out completes inside the OUTER chain's subscriber, + before any subscriber registered after it runs. +- **The expander itself is optional.** A lower-priority consumer may + claim and stop the chain before the expander is reached, so a nested + pass would go uncounted while its `run_deferred` still ran — and + would then look like the outermost one. + +A minimum-priority consumer runs first in every chain invocation that +reaches any consumer at all. Its guarantee is exactly the ordering +contract the chain already rests on, and it degrades safely: the only +thing that can skip it is a claim ahead of it, which skips the expander +too, so nothing is queued in that fan-out either. Two guards this exposes, both of which pairing already carries: @@ -2736,6 +2772,14 @@ criterion 46 requires to stay byte-identical. outer chain would then hand pairing a record the replace had invalidated — the round-10 failure again, through the chain's documented re-entrancy seam rather than through claiming. +45o. **A nested fan-out that never reaches the expander must not + expand early either** (round 12). Same shape as 45n, but the nested + pass is short-circuited by a consumer at priority 25 that claims + when the record is nil — so the expander never runs on it. Bites + against counting fan-outs in the expander, which is optional by + construction: the uncounted nested pass looks outermost, expands, + and outer pairing resumes with an invalidated record. 45n passes + against that bug, which is why both are pinned. 45h. **Tie-break by source order (§2.11).** `\f` + space yields `‹` — `f<` and `f>` are both length 2, and `f<` is declared first. Same for `\"` + space → `Ä`, first of eleven equal-length candidates. @@ -2890,7 +2934,7 @@ uncapped event queue, the dropped `cfg.restart`, and — unchanged from languages other than Lean, and §4's rule is what keeps them out of a Lean PR. -### 9.1 Coherence impact — stages 4a and 4b (rev 11) +### 9.1 Coherence impact — stages 4a and 4b (rev 12) **Sections served.** §6 (interaction islands) primarily, and in the *preventing* direction rather than the fixing one — see below. §11 diff --git a/tests/lean_input_acceptance.rs b/tests/lean_input_acceptance.rs index 4cc0613..9b6a3b8 100644 --- a/tests/lean_input_acceptance.rs +++ b/tests/lean_input_acceptance.rs @@ -251,6 +251,65 @@ fn a_nested_fan_out_between_the_expander_and_pairing_does_not_expand_early() { ); } +#[test] +fn a_nested_fan_out_that_never_reaches_the_expander_still_does_not_expand_early() { + // The chain's OTHER exit: a consumer may CLAIM and stop the chain + // before the expander is reached, while the fan-out's + // deferred-expansion subscriber still runs. Counting in the + // expander itself therefore misses that pass — it would look like + // the outermost one and expand early, and outer pairing would + // resume with an invalidated record. + // + // The sequence, exactly: a consumer at 25 declines on the outer + // pass (there is a record) and claims on the nested one (there is + // not); a consumer at 75 runs one nested fan-out from between the + // expander and pairing. + let (mut s, _f) = lean_editor(); + exec( + &s, + r#" + _G.NESTED, _G.CLAIMED = 0, 0 + pmacs.typed_edit.add_consumer { + name = "claims-only-when-recordless", + priority = 25, -- ahead of the expander at 50 + fn = function(rec) + if rec == nil then + _G.CLAIMED = _G.CLAIMED + 1 + return true -- stops the chain: the expander never runs + end + return false + end, + } + pmacs.typed_edit.add_consumer { + name = "nested-fan-out", + priority = 75, -- between the expander (50) and pairing (100) + fn = function() + if _G.NESTED == 0 then + _G.NESTED = 1 + pmacs.hook.run("buffer.after-edit") + end + return false + end, + } + "#, + ); + + type_str(&mut s, "\\alp("); + let (nested, claimed): (i64, i64) = eval(&s, "return _G.NESTED, _G.CLAIMED"); + assert_eq!(nested, 1, "the nested fan-out must actually have run"); + assert!( + claimed >= 1, + "the nested pass must actually have been short-circuited before \ + the expander, or this pins the same thing as 45n" + ); + assert_eq!( + text(&s), + "α()", + "the nesting count comes from a point that runs before any \ + consumer can claim, so the nested pass was still recognised" + ); +} + #[test] fn a_pair_character_outside_a_pending_abbreviation_still_pairs() { // The other direction: claiming extensions must not disable pairing From d9d1c0b4234d3689f6428c1ce05192e6c05c41e6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 17:25:22 -0400 Subject: [PATCH 69/91] test(journey): pin the destination commit, and land the doc updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Journey Stage 1a: the `commit_to` acceptance suite (framing §6 N4, N6, N6b, N6c, P1, P2, P3) plus the documentation updates COHERENCE §25 requires the PR to carry. Bite-testing the new pins found a real gap. Deleting the `ScopedFrontend` arm from `acting_frontend` left N4 green, because `ScopedFrontend::enter` also swaps `core.active_frontend` and the ambient fallback then answers correctly on its own. The arm is load-bearing in exactly one case — a commit reached from inside an interactive command, where the origin sits between the override and the ambient value — and nothing pinned it. N4b is added, driven through `dispatch_key` because that is the only thing that establishes an interactive origin, and the mutation now bites it. Two smaller corrections found the same way: * `commit_to`'s forged-destination message was unreachable. Typed as `AnyUserData`, mlua rejected a table during argument conversion, so a caller got "error converting Lua table to userdata" — true, but naming neither the rule nor the remedy. The parameter is now `mlua::Value` and the pointed message fires. * P1 and P2 also fail on full revert, since `commit_to` does not exist on the pre-image, so §6.0's "legitimately green on the pre-image" does not describe them. They stay in the P list because their discriminating falsifier is the named mutation — a revert-only check cannot distinguish "validates" from "validates in time" — and each pin now says so at its own site rather than being silently mislabelled. Bite results, each run against the whole suite: scope stops swapping `core.active_frontend` -> N6a, P3 fail; nothing else preflight moved after the callback -> P1, P2 fail; nothing else drop the `ScopedFrontend` arm -> N4b fails; nothing else Docs: COHERENCE §2 grade + step-3 verdict row, §20 Priority 1 and the arc list; the GPU initial-target framing's Q#GT6 and acceptance 10, whose directory case this stage deliberately supersedes; handoff §1; the active-work ledger; framing rev 7. Co-Authored-By: Claude Opus 5 (1M context) --- COHERENCE.md | 57 ++- docs/active-work.md | 41 +- docs/agent-handoff.md | 47 +- docs/gpu-initial-target-framing.md | 20 +- docs/journey-stage1a-framing.md | 54 ++- src/lua_bindings/window_panel.rs | 18 +- tests/journey_acceptance.rs | 663 +++++++++++++++++++++++++++++ 7 files changed, 858 insertions(+), 42 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 636a8f3..e7229f3 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -339,7 +339,8 @@ the journey. ### Ground truth: the journey today -**Grade: broken at step 3.** Verified empirically at audit time: +**Grade: reaches step 5; thin from step 6 on.** Was **broken at step 3** +at audit time: ``` $ ./target/release/pmacs . @@ -347,15 +348,23 @@ pmacs: Is a directory (os error 21) EXIT=1 ``` -The literal first arrow of the diagram above fails. `load_file` +The literal first arrow of the diagram above failed. `load_file` (`src/file_io.rs:81-87`) does `File::open` (succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so -`EditorState::open` returns `Err` and `main` prints and exits -(`src/main.rs:411-414`). Multiple file arguments are also rejected -(`"multiple files not yet supported"`, `src/main.rs:227`). Everything -from step 6 onward is gated on a file being open, and the only -zero-config way to open one is naming it on the command line — which -requires already knowing the path. +`EditorState::open` returned `Err` and `main` printed and exited. + +**Journey Stage 1a fixed that arrow** (`docs/journey-stage1a-framing.md`). +`resolve_target_buffer` now answers `ResolvedTarget::Directory` *ahead* +of the load, `pmacs .` lists the directory in dired, `RET` visits a +file, and a self-insert lands in it — steps 3 and 5 run end to end, +pinned by `tests/journey_acceptance.rs`. Which surface opens a directory +is a `path.open-directory` chain with dired as a replaceable fallback, +so this did not grow a second directory surface. + +Still true: multiple file arguments are rejected (`"multiple files not +yet supported"`, `src/main.rs:227`), and everything from step 6 onward +is gated on a file being open — but the zero-config way to open one is +no longer "already know the path". Full verdict table: @@ -363,7 +372,7 @@ Full verdict table: |---|---|---|---| | 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime | | 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on | -| 3 | Open real project | **Missing at the CLI** | `pmacs .` still exits 1 (above): `load_file` does `File::open` (which succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s create-a-`[new file]` arm never fires. Dired Stage 1 (merged #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface | +| 3 | Open real project | **Works at the CLI** | Journey Stage 1a: `resolve_target_buffer` answers `ResolvedTarget::Directory` before the EISDIR-producing load, and `EditorState::open` / the daemon bootstrap dispatch the `path.open-directory` chain, whose fallback is dired (#165's buffer, reached rather than duplicated). Startup no longer fails: an unreadable directory, a crashed resolver, and a cleared handler all report on the status line and leave the session running. Because the listing is async and the bootstrap is synchronous, the commit runs against a destination captured at request time (`pmacs.window.commit_to`) rather than against the ambient frontend | | 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) | | 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:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | @@ -1474,14 +1483,18 @@ missing runtime entity — a real arc). ### Priority 1: Protect the golden product journey Establish the end-to-end workflow; treat regressions as release -blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually -cheap:** directory-argument handling (the remaining half of step 3 — -dired Stage 1 landed the buffer it should resolve to); a find-file -surface (**done**: #162 open-by-path, #165 browsing); surfacing the -LSP spawn failure with guidance (§1.2); a +blockers. **State: runs to step 5; thin from step 6 (§2). Mostly wiring, +and unusually cheap:** directory-argument handling (**done**: Journey +Stage 1a); a find-file surface (**done**: #162 open-by-path, #165 +browsing); surfacing the LSP spawn failure with guidance (§1.2); a compile keybinding + `cargo build`/`test` default from the existing -`ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The -journey acceptance suite (§19) is the ratchet that keeps it fixed. +`ProjectKind::Cargo`; a terminal keybinding (**done**: `C-c t`, #173); a +welcome buffer. The journey acceptance suite (§19) is the ratchet that +keeps it fixed — it **exists now** (`tests/journey_acceptance.rs`, +Stage 1a), seeded with steps 2, 3, and 5. + +Journey Stage 1b is the named remainder: the compile binding + Cargo +defaults, LSP spawn guidance, and the welcome buffer. ### Priority 2: Make workspace and location explicit @@ -1545,11 +1558,13 @@ Candidate arc cuts, honoring one-feature-one-branch-one-PR and the framing workflow (each needs its own scout + framing before any implementation — this list is direction, not commitment): -1. **Journey Stage 1** (P1): directory open + compile defaults + - LSP-failure surfacing + bindings + welcome buffer + the first - journey acceptance suite. Dired Stage 1 has landed (#165), so the - buffer a directory resolves *to* already exists; this arc routes - `pmacs .` into it rather than growing a second directory surface. +1. **Journey Stage 1** (P1): split at the new-Rust-primitive line. + **Stage 1a — landed**: directory open, the `EditorState::open` → + `resolve_target_buffer` unification, the destination-scope substrate, + and the first journey acceptance suite. It routes `pmacs .` into + #165's dired buffer rather than growing a second directory surface. + **Stage 1b — remaining**: compile defaults, LSP-failure surfacing, + bindings, welcome buffer. 2. **Discovery surface** (P4): the describe/list/where-is command family, M-x rich rows, help unification, help prefix. 3. **Transient keymap layer** (§6): the overlay scope + lifetime diff --git a/docs/active-work.md b/docs/active-work.md index ed6ceb7..5a78b6d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -331,24 +331,41 @@ If it does not, stop and repair the remote/fetch configuration. --check` clean. - Stage 4b (the input method) is NOT in this PR and not started. -## Journey Stage 1a — framing rev 5 APPROVED; branch cut, implementing +## Journey Stage 1a — IMPLEMENTED on branch, gates run, PR pending -- Approved framing: `docs/journey-stage1a-framing.md` **rev 5** (four - review rounds). Branch `journey-stage1a-directory-open`, framing - committed as its first commit. No PR yet. +- Framing `docs/journey-stage1a-framing.md` **rev 7** (four review + rounds, then two correction revisions found during implementation). + Branch `journey-stage1a-directory-open`, rebased onto `githubsucks/main` + @ `74301d1`. - Recovery: `git fetch githubsucks && git checkout - journey-stage1a-directory-open`. The framing now travels; the - implementation does not until it is committed and pushed. -- Ordering: PR #177 MERGED (2026-07-26), so 1a is unblocked. 1a lands + journey-stage1a-directory-open`. Everything below is committed and + pushed; nothing depends on a worktree or `/tmp`. +- **Ships:** the directory arm on `resolve_target_buffer`, + `EditorState::open` rewritten as a caller of it (the unification), the + `path.open-directory` chain + `pmacs.path.directory_handler` fallback + slot, `pmacs.window.commit_to` with its scoped frontend and preflight, + the nonconstructible destination userdata, the daemon bootstrap arm, + and `tests/journey_acceptance.rs` (23 pins). No protocol change — + still v20. +- **Doc updates ride the PR** per COHERENCE §25: §2 grade + step-3 + verdict row, §20 Priority 1 + the arc list, the GPU initial-target + framing's Q#GT6 / acceptance 10 supersession, handoff §1. +- **Bite results** (each mutation run against the full suite): scope + stops swapping `core.active_frontend` → N6a + P3 fail, nothing else; + preflight moved after the callback → P1 + P2 fail, nothing else; drop + the `ScopedFrontend` arm from `acting_frontend` → N4b fails, nothing + else. That last mutation is why N4b exists — it left N4 green. +- Ordering: PR #177 MERGED (2026-07-26), so 1a was unblocked. 1a lands before dired Stage 2. When 1a lands, Stage 2 must re-scout and revise its framing around the scoped `pmacs.window.commit_to` boundary before its implementation branch is cut. That revision is a prerequisite, not a review-time discovery. -- Implementation order inside the branch (framing §12): scoped frontend - override + shared eligibility predicate first (separable, testable - without dired), then `commit_to` and the opaque destination, then the - directory arm and resolver chain, then the journey suite, then the - doc updates COHERENCE §25 requires. +- **Named deferrals carried out of this stage:** dired's *interactive* + paths (`C-x d`, tree descent, refresh) still rely on the ambient + frontend a tick later and are not migrated onto captured destinations; + the stale startup scratch buffer is still not removed (only the false + doc comment is corrected); `resolve_target_buffer`'s directory arm has + no picker, only the chain that leaves room for one. ## The CRDT half of the test corpus is dark in CI — NEEDS A LANE diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index b2a0c45..04dee19 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -54,8 +54,51 @@ commands, read `docs/active-work.md` immediately after this file. standard new work is evaluated against. Per `CLAUDE.md`, **every new framing doc must state its coherence impact** — journey steps touched, interaction islands added, config-registry adoption, background-work - attribution. Its §2 grades the golden journey **broken at step 3** - (`pmacs .` exits 1). + attribution. Its §2 grades the golden journey; **Journey Stage 1a + moved that grade off "broken at step 3"** — see the arc bullet below. +- **Journey arc (P1) — Stage 1a LANDED** + (`docs/journey-stage1a-framing.md`). `pmacs .` opens a directory + instead of exiting 1, on **one** path: `resolve_target_buffer` gained a + `ResolvedTarget::Directory` arm *ahead* of the load, `EditorState::open` + became a caller of it rather than a parallel implementation, and the + daemon/GPU bootstrap shares the same arm. Which surface handles a + directory is the `path.open-directory` chain with dired as a + replaceable fallback slot. `tests/journey_acceptance.rs` is the new + cross-subsystem ratchet (steps 2, 3, 5 seeded; **stages add rows, none + removes them**). No protocol change. + - **A hook a builtin subscribes to can never be first-claimant-wins + for users.** `HookRegistry::add` only appends and builtins load + before `init.lua`, so a dired subscription would always claim before + any user listener. That is why dired is a *slot* + (`pmacs.path.directory_handler`) and not a subscriber — and why + clearing the slot has to leave startup succeeding with a status, + not exiting 1. + - **A raise and a `false` are indistinguishable in `proceed`.** + `run_short_circuit` returns `proceed = false` for both; only + `HookOutcome.errors` separates them, and it decides whether to + *report*, not whether to fall back. Getting this backwards produces a + fallback that runs after a user's resolver crashed mid-handling. + - **The listing is async; the bootstrap is synchronous.** The whole + post-await commit therefore runs against a destination captured at + request time (`pmacs.window.commit_to`), which preflights every + precondition *before* invoking the callback — dired mutates handle + state, `prev`, and paint long before it reaches anything that could + refuse, so validating at display time is four mutations too late. + Awaiting inside a commit is refused: a yield would restore the scope + while the coroutine is still parked. + - **The scope swaps `core.active_frontend`, not just an override** — + `pmacs.window.buffer()`'s no-arg arm reads the ambient active buffer + directly, so dired's `prev` capture would otherwise follow whatever + frontend happened to be dispatching. The override *also* exists, and + is load-bearing in exactly one case: a commit reached from inside an + interactive command, where the origin would otherwise outrank the + ambient value. Bite-testing found N4 green without it. + - **`replace_active_buffer` does not drop the startup scratch buffer**, + despite its doc comment having claimed so for as long as it has + existed. Its body is one `switch_active_buffer` call. The comment is + corrected here; changing the lifetime is separate work. + - Stage 1b is the named remainder: compile binding + Cargo defaults, + LSP spawn guidance, welcome buffer. - **Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b LANDED** (`docs/lean4-mode-framing.md`; #160, #161, #167, #170; merge `d400f30`). pmacs edits Lean 4: `arborium-lean` highlighting, a diff --git a/docs/gpu-initial-target-framing.md b/docs/gpu-initial-target-framing.md index c3b372c..41ea7d2 100644 --- a/docs/gpu-initial-target-framing.md +++ b/docs/gpu-initial-target-framing.md @@ -282,8 +282,17 @@ observers; they are not the transport implementation. - Any `NotFound` from the initial load creates an empty path-backed buffer, including when a parent is currently absent; save-time errors remain save-time errors, matching local `pmacs FILE`. -- `PermissionDenied`, `IsADirectory`, invalid path bytes at the OS boundary, - and other non-`NotFound` errors fail startup. +- `PermissionDenied`, invalid path bytes at the OS boundary, and other + non-`NotFound` errors fail startup. +- **`IsADirectory` is superseded by Journey Stage 1a** + (`docs/journey-stage1a-framing.md`). A directory no longer reaches the + load at all: `resolve_target_buffer` answers `ResolvedTarget::Directory` + ahead of it, so a directory target now *succeeds*, dispatching the + `path.open-directory` chain and replying `Opened`. Deliberate + supersession, not drift — the whole point of that stage is that + `pmacs .` must not exit 1, and a daemon/GPU bootstrap that still failed + would leave the two entry points disagreeing about the same argument. + Non-directory failures are unchanged. - The buffer display name may use `Path::display()` and therefore replacement characters; this must never replace the raw backing path used for dedup, load, or save. @@ -550,12 +559,17 @@ process behavior. 9. **New file:** a nonexistent target produces an empty snapshot, `[new file]` status/path identity, accepts an edit/save through the real session, and creates the requested file under the launcher cwd—not the daemon cwd. -10. **Open error:** a directory/permission-denied target returns a specific +10. **Open error:** a permission-denied target returns a specific failure before ready/window creation and makes root fail. The daemon shuts down that failed session's socket; a client that lingers or sends another event cannot reach uninstalled session state. An existing daemon remains connectable; a pre-existing frontend's active buffer and contents remain unchanged. + **Amended by Journey Stage 1a:** the *directory* case is deliberately + superseded and moved to the success path — see Q#GT6. A directory + target now reaches ready and the document window shows dired, pinned + by `initial_target_directory_reaches_ready` and its two siblings in + `src/daemon.rs`. Permission-denied is unchanged and still fails. 11. **Dedup preserves unsaved edits:** frontend A opens and modifies a file without saving; target-launch frontend B opens the same normalized path and receives A's authoritative unsaved text with the same `BufferId`, not disk diff --git a/docs/journey-stage1a-framing.md b/docs/journey-stage1a-framing.md index b5833cc..ebb3cc9 100644 --- a/docs/journey-stage1a-framing.md +++ b/docs/journey-stage1a-framing.md @@ -114,6 +114,39 @@ acceptance tests), §20 Priority 1.** it asserted nothing about `run`. Q#JR12 is downgraded to an observation. +- rev 7 (2026-07-26) — **found while writing the `commit_to` suite and + bite-testing it.** Three, all confirmed: + - **N4 did not pin what its comment claimed.** Deleting the + `ScopedFrontend` arm from `acting_frontend` left N4 green, because + `ScopedFrontend::enter` *also* swaps `core.active_frontend` and the + ambient fallback then answers correctly on its own. The arm is + load-bearing in exactly one situation — a commit reached from inside + an interactive command, where the origin sits between the override + and the ambient value and would otherwise win. **N4b** is added, + driven through `dispatch_key` (the only thing that establishes an + interactive origin), and the mutation now bites it. The general + lesson is the §6.0 one again from a new angle: two mechanisms that + agree on the common path make either one look load-bearing. + - **`commit_to`'s forged-destination message was unreachable.** With + the parameter typed `mlua::AnyUserData`, mlua rejected a table during + argument conversion, so a caller who fabricated one got "error + converting Lua table to userdata" — true, but naming neither the rule + nor how to obtain a real destination. The parameter is now + `mlua::Value` and the pointed message actually fires. The refusal is + unchanged; only its legibility is. + - **P1 and P2 also fail on full revert**, since `commit_to` does not + exist on the pre-image. §6.0's "legitimately green on the pre-image" + does not describe them. They stay in the P list because their + *discriminating* falsifier is the named mutation, not the revert: a + revert-only check cannot distinguish "validates" from "validates in + time", which is the entire claim. Noted at each pin rather than + silently mislabelled. + - Bite results recorded: mutation A (scope stops swapping + `core.active_frontend`) fails N6a and P3 and nothing else; mutation B + (preflight moved after the callback) fails P1 and P2 and nothing + else; mutation C (drop the `ScopedFrontend` arm) fails N4b and + nothing else. + --- ## 0.5. Coherence impact (`COHERENCE.md` §20, required since #163) @@ -697,6 +730,14 @@ is **removed rather than recast**: it proved nothing N1 does not. in **A's** captured window, and B's active buffer and window are unchanged. Falsified by reverting `commit_to` to the ambient `switch_buffer`. +- **N4b — the scope outranks an *interactive origin*, added rev 7.** N4 + alone does not pin `acting_frontend`'s ordering claim: with the + `ScopedFrontend` arm deleted, N4 still passes, because `enter` also + swaps `core.active_frontend`. The arm matters only when an interactive + origin is set, which sits between the override and the ambient value. + A command dispatched by frontend B calls `commit_to` with A's + destination; the commit must still land in A's window. Falsified by + deleting the arm, or by ordering it after the interactive origin. - **N5** Bootstrap with a deliberately **non-scratch** LOCAL primary document buffer: the reply's `buffer_id` is that buffer, and after quiescence the window shows dired (Q#JR9, §4.5). @@ -711,7 +752,11 @@ is **removed rather than recast**: it proved nothing N1 does not. - **N6b — `commit_to` refuses a forged destination.** A Lua-constructed table with plausible `frontend`/`window`/`buffer` fields is rejected as a type error, and userdata cannot be constructed from Lua (Q#JR14d). - Falsified by accepting a table. + Falsified by accepting a table. *Rev 7:* the parameter is typed + `mlua::Value` and `commit_to` performs the check itself, so the refusal + names the rule — typed as `AnyUserData`, mlua rejected the table during + argument conversion with a message naming neither the rule nor the + remedy, leaving the pointed one unreachable. - **N6c — a declining listener cannot redirect the destination.** Two listeners: the first receives `dest`, attempts mutation inside `pcall`, observes the read-only rejection, and declines; the second verifies @@ -746,6 +791,13 @@ is **removed rather than recast**: it proved nothing N1 does not. ### 6.2 Preservation pins (P), each with its falsifying mutation +*Rev 7 correction:* **P1 and P2 also fail on full revert** — `commit_to` +does not exist on the pre-image, so §6.0's "legitimately green on the +pre-image" does not describe them. They stay here because their +*discriminating* falsifier is the named mutation: a revert-only check +cannot distinguish "validates" from "validates in time", which is their +entire claim. P3–P8 are preservation pins in the strict sense. + - **P1 — precondition failure is atomic (the blocker's negative half).** **Three** destination failures, each asserted the same way — after quiescence the buffer count is unchanged, **no dired buffer or handle diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index 4228e0c..1c700a5 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -370,7 +370,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result "commit_to", lua.create_function( move |lua, - (dest, body): (mlua::AnyUserData, mlua::Function)| + (dest, body): (mlua::Value, mlua::Function)| -> mlua::Result { // Journey Stage 1a (Q#JR14). Preflight FIRST, then // scope, then run. The ordering is the whole point: @@ -381,9 +381,21 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result // mutations too late and leaves a hidden buffer // behind, so every destination precondition is // checked before the callback is invoked at all. + // + // Typed as `Value` rather than `AnyUserData` so this + // message is REACHABLE: with the narrower type mlua + // rejects a table during argument conversion, and a + // caller who fabricated one got "error converting Lua + // table to userdata" — true, but it names neither the + // rule nor how to get a real destination. + let dest = match &dest { + mlua::Value::UserData(userdata) => { + userdata.borrow::().ok() + } + _ => None, + }; let dest = dest - .borrow::() - .map_err(|_| { + .ok_or_else(|| { mlua::Error::runtime( "pmacs.window.commit_to: expected a destination captured by \ the editor (it cannot be constructed from Lua)", diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index f1e4c03..d25cf4c 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -27,14 +27,22 @@ //! distinction is load-bearing: an equivalence assertion between two //! implementations that already agree proves nothing about structural //! reuse. +//! +//! Two P pins here — P1 and P2 — *also* fail on full revert, since +//! `commit_to` does not exist on the pre-image. They are labelled P +//! because their discriminating falsifier is the named mutation: a +//! revert-only check cannot distinguish "validates" from "validates in +//! time", which is their entire claim. Each says so at its own site. use std::path::Path; use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::buffer::BufferId; use pmacs::editor::EditorState; use pmacs::editor_core::normalize_buffer_path; use pmacs::protocol::FrontendId; +use pmacs::window::{FrontendView, Layout, Window, WindowId}; use tempfile::TempDir; // --------------------------------------------------------------------------- @@ -129,6 +137,70 @@ fn buffer_count(s: &EditorState) -> usize { s.core.borrow().registry.borrow().ids().len() } +/// The buffer a window currently shows, or `None` if it is not live. +fn buffer_in(s: &EditorState, window: WindowId) -> Option { + s.core.borrow().windows.get(&window).map(|w| w.buffer_id) +} + +/// The window `LOCAL` currently has selected. +fn local_window(s: &EditorState) -> WindowId { + s.core + .borrow() + .views + .get(&FrontendId::LOCAL) + .expect("LOCAL view") + .active +} + +/// Register a second frontend with its own single-window layout, +/// mirroring `build_fresh_frontend_view` (the same helper shape +/// `bottom_panel_stage1_acceptance` uses). +fn attach_frontend(s: &EditorState, fid: FrontendId) -> WindowId { + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let win = WindowId::next(); + core.windows + .insert(win, Window::new(win, buffer_id, text_view)); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout::single(win), + active: win, + fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + win +} + +/// Drive the **real** chain far enough to obtain a genuine destination +/// and leave it in the Lua global `dest`. +/// +/// The listener claims (returns `false`), so nothing is committed and no +/// fallback runs: what lands in `dest` is exactly the userdata dired +/// would have received, produced by the production capture rather than +/// fabricated. Nothing in the test suite can construct one — that is +/// N6b's whole subject. +fn capture_dest(s: &mut EditorState, dir: &Path) { + exec( + s, + "dest = nil + pmacs.hook.add('path.open-directory', function(_, d) dest = d return false end)", + ); + s.open_directory_target(dir); + pump(s); + assert!( + eval::(s, "return dest ~= nil"), + "the chain must hand listeners a destination" + ); +} + /// Open through the **real** startup entry point, as `pmacs PATH` does. fn launch(path: &Path) -> EditorState { let mut s = EditorState::open(path.to_path_buf()).expect("startup must not fail"); @@ -360,6 +432,597 @@ fn journey_a_raising_resolver_suppresses_the_fallback_and_reports() { ); } +// --------------------------------------------------------------------------- +// The destination commit (`pmacs.window.commit_to`) +// --------------------------------------------------------------------------- +// +// The substrate half of Stage 1a. A directory listing settles a tick or +// more after the request, by which time the ambient frontend, selected +// window, and active buffer may all name something else — so the whole +// post-await commit runs against a destination captured at request time. +// +// `LOCAL` is the requesting frontend throughout, because +// `open_directory_target` is the local-startup seam; the daemon's +// non-`LOCAL` capture is pinned in `src/daemon.rs`, where the production +// caller lives. What varies here is what the *ambient* frontend is doing +// while the commit runs, which is exactly the misrouting the scope +// exists to prevent. + +/// The frontend that competes for ambient authority in these tests. +const COMPETITOR: FrontendId = FrontendId(7); + +/// **N4** — the commit lands in the *requesting* frontend's window even +/// though another frontend is the one dispatching. +/// +/// The blocker's positive half. Falsified by reverting `commit_to` to an +/// ambient display: the file then appears in the competitor's window. +#[test] +fn commit_to_delivers_to_the_requesting_frontend_not_the_ambient_one() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + let local_win = local_window(&s); + let other_win = attach_frontend(&s, COMPETITOR); + let other_before = buffer_in(&s, other_win); + + // The competitor becomes the dispatching frontend while the work is + // "in flight" — the state a worker completion actually returns to. + s.core.borrow_mut().active_frontend = COMPETITOR; + + let alpha = td.path().join("alpha.txt").display().to_string(); + exec( + &s, + &format!( + "assert(pmacs.window.commit_to(dest, function() + pmacs.window.display_file({alpha:?}) + end))" + ), + ); + + assert_eq!( + buffer_in(&s, other_win), + other_before, + "the competing frontend's window must be untouched" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert_eq!( + active_name(&s), + alpha, + "the commit must land in the requesting frontend's captured window" + ); + assert_eq!( + local_window(&s), + local_win, + "and in that window, not a new one" + ); +} + +/// **N4b** — the scope beats an *interactive origin*, not merely the +/// ambient frontend. +/// +/// Found by bite-testing N4: with the `ScopedFrontend` arm deleted from +/// `acting_frontend`, N4 still passed, because `ScopedFrontend::enter` +/// also swaps `core.active_frontend` and the ambient fallback then +/// answers correctly on its own. The arm is load-bearing in exactly one +/// situation — a commit reached from inside an interactive command, +/// where the origin sits *between* the override and the ambient value +/// and would otherwise win. `acting_frontend`'s comment claims that +/// ordering; nothing pinned it. +/// +/// Driven through `dispatch_key`, because the interactive origin is +/// established by dispatch and by nothing else — `invoke_interactive` +/// requires a context rather than creating one. +/// +/// Falsified by deleting the `ScopedFrontend` arm from +/// `acting_frontend`, or by reordering it after the interactive origin. +#[test] +fn commit_to_outranks_an_interactive_origin() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + let local_win = local_window(&s); + let other_win = attach_frontend(&s, COMPETITOR); + let other_before = buffer_in(&s, other_win); + + let alpha = td.path().join("alpha.txt").display().to_string(); + exec( + &s, + &format!( + "pmacs.command.define {{ + name = 'test.journey-commit', + description = 'commit to a captured destination from inside a command', + fn = function() + committed = pmacs.window.commit_to(dest, function() + pmacs.window.display_file({alpha:?}) + end) + end, + }} + pmacs.keymap.bind {{ scope = 'global', sequence = 'C-c j', + command = 'test.journey-commit' }}" + ), + ); + + // The COMPETITOR runs the command, so ITS id is the interactive + // origin for the whole invocation. + s.dispatch_key(COMPETITOR, key(KeyCode::Char('c'), KeyModifiers::CONTROL)); + s.dispatch_key(COMPETITOR, key(KeyCode::Char('j'), KeyModifiers::NONE)); + + assert!( + eval::(&s, "return committed"), + "the commit must be accepted" + ); + assert_eq!( + buffer_in(&s, other_win), + other_before, + "the invoking frontend's own window must be untouched" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert_eq!( + active_name(&s), + alpha, + "the commit must land in the captured destination, not the \ + interactive origin's window" + ); + assert_eq!(local_window(&s), local_win); +} + +/// **N6a** — the scope is restored when the callback returns normally. +/// +/// Falsified by dropping the guard's restore, or by never swapping +/// `core.active_frontend` in the first place (then `inside` reads the +/// competitor and the assertion fails from the other direction). +#[test] +fn commit_to_scopes_and_restores_on_a_normal_return() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + attach_frontend(&s, COMPETITOR); + s.core.borrow_mut().active_frontend = COMPETITOR; + + exec( + &s, + "inside, scoped = nil, nil + assert(pmacs.window.commit_to(dest, function() + inside = pmacs.frontend.id() + scoped = pmacs._async._in_commit_scope() + end))", + ); + + assert_eq!( + eval::(&s, "return inside"), + i64::try_from(FrontendId::LOCAL.0).expect("frontend id"), + "inside the commit the acting frontend is the requesting one" + ); + assert!( + eval::(&s, "return scoped"), + "and the commit-scope flag is set while the callback runs" + ); + assert_eq!( + s.core.borrow().active_frontend, + COMPETITOR, + "the ambient frontend must be restored on return" + ); + assert!( + !eval::(&s, "return pmacs._async._in_commit_scope()"), + "and the commit-scope flag cleared" + ); + assert_eq!( + eval::(&s, "return pmacs.frontend.id()"), + i64::try_from(COMPETITOR.0).expect("frontend id"), + "the Lua-visible frontend must be restored too" + ); +} + +/// **N6b (part of N6)** — a raising callback still restores. +/// +/// The path that makes the guard RAII rather than a pair of statements: +/// `commit_to` captures the call's result and lets the guard drop before +/// propagating it. Falsified by `?`-propagating the callback's error +/// through the scope, or by restoring on the success path only. +#[test] +fn commit_to_restores_when_the_callback_raises() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + attach_frontend(&s, COMPETITOR); + s.core.borrow_mut().active_frontend = COMPETITOR; + + exec( + &s, + "local ok, err = pcall(pmacs.window.commit_to, dest, function() + error('commit exploded') + end) + raised = (not ok) and tostring(err) or ''", + ); + + assert!( + eval::(&s, "return raised").contains("commit exploded"), + "the callback's error must propagate" + ); + assert_eq!( + s.core.borrow().active_frontend, + COMPETITOR, + "a raising callback must still restore the ambient frontend" + ); + assert!( + !eval::(&s, "return pmacs._async._in_commit_scope()"), + "and must still clear the commit-scope flag" + ); +} + +/// **N6c (part of N6)** — awaiting inside a commit is refused, the +/// refusal names the rule, and the scope is restored anyway. +/// +/// A yield would restore the scope while the coroutine is still parked, +/// so the rest of the commit would resume ambient — silently +/// reintroducing exactly the misrouting N4 pins against. Driven inside +/// `pmacs.async`, which is where a real await lives. +/// +/// Falsified by dropping the `_in_commit_scope` check from +/// `Handle:await`: the await then succeeds and `refusal` reads +/// ``. +#[test] +fn commit_to_refuses_an_await_and_restores() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + attach_frontend(&s, COMPETITOR); + s.core.borrow_mut().active_frontend = COMPETITOR; + + exec( + &s, + &format!( + "refusal = nil + pmacs.async(function() + local handle = pmacs.fs.read_dir({:?}) + local ok, err = pcall(pmacs.window.commit_to, dest, function() + return handle:await() + end) + refusal = (not ok) and tostring(err) or '' + -- Drain it OUTSIDE the commit, which is where the refusal + -- says the await belongs -- and which also settles the job + -- so the pump can reach quiescence. + handle:await() + end)", + td.path().display().to_string() + ), + ); + pump(&mut s); + + let refusal: String = eval(&s, "return refusal"); + assert!( + refusal.contains("cannot await inside") && refusal.contains("commit_to"), + "the refusal must name the rule it enforces; got {refusal:?}" + ); + assert_eq!( + s.core.borrow().active_frontend, + COMPETITOR, + "a refused await must still restore the ambient frontend" + ); + assert!( + !eval::(&s, "return pmacs._async._in_commit_scope()"), + "and must still clear the commit-scope flag" + ); +} + +/// **N6b** — a forged destination is rejected, and the callback never +/// runs. +/// +/// A plausible `{frontend, window, buffer}` table is what any Lua could +/// fabricate. Falsified by accepting a table, or by borrowing the +/// userdata after invoking the callback. +#[test] +fn commit_to_refuses_a_forged_destination() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + let win = eval::(&s, "return dest:window()"); + exec( + &s, + &format!( + "ran = false + local ok, err = pcall(pmacs.window.commit_to, + {{ frontend = 0, window = {win}, buffer = 0 }}, + function() ran = true end) + rejected = (not ok) and tostring(err) or ''" + ), + ); + + let rejected: String = eval(&s, "return rejected"); + assert!( + rejected.contains("cannot be constructed from Lua"), + "a forged table must be rejected by type, not merely fail later; got {rejected:?}" + ); + assert!( + !eval::(&s, "return ran"), + "a rejected destination must not reach the callback" + ); +} + +/// **N6c** — a declining listener cannot redirect the destination. +/// +/// The same userdata is handed to every listener in turn. As a table, an +/// earlier listener could rewrite the window and then decline, sending +/// the fallback somewhere the user never asked for. Falsified by passing +/// a shared mutable table. +#[test] +fn a_declining_listener_cannot_redirect_the_destination() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + let target = local_window(&s); + + exec( + &s, + "seen_first, seen_second, mutation = nil, nil, nil + pmacs.hook.add('path.open-directory', function(_, d) + seen_first = d:window() + -- Try to redirect, then decline. Both halves matter: a + -- successful mutation with a decline is the attack. + local ok, err = pcall(function() d.window = 999 end) + mutation = (not ok) and tostring(err) or '' + end) + pmacs.hook.add('path.open-directory', function(_, d) + seen_second = d:window() + end)", + ); + + s.open_directory_target(td.path()); + pump(&mut s); + + let mutation: String = eval(&s, "return mutation"); + assert!( + !mutation.contains(""), + "the destination must be read-only; got {mutation:?}" + ); + let first = eval::(&s, "return seen_first"); + let second = eval::(&s, "return seen_second"); + assert_eq!( + first, second, + "every listener must see the same, unaltered destination" + ); + assert_eq!( + u64::try_from(second).expect("window id"), + target.raw(), + "and it must still name the window the editor captured" + ); + // And the fallback commits THERE, not to whatever the first listener + // wanted -- the observable the attack was aiming at. + assert!( + active_name(&s).starts_with("*dired:"), + "the declined chain must still fall back to dired" + ); + assert_eq!( + buffer_in(&s, target), + Some(eval::(&s, "return pmacs.window.buffer()").0), + "in the captured window" + ); +} + +// --- the commit's preservation pins --------------------------------------- + +/// **P1** — every destination precondition is checked *before* the +/// callback runs, so a failure mutates nothing. +/// +/// Four refusals, each asserted the same way: `commit_to` returns +/// `(false, reason)`, the callback never ran, and no buffer was created. +/// Table-driven deliberately — the failure message names which +/// precondition regressed, which four separate near-identical tests +/// would give up in exchange for nothing. +/// +/// *Mutation:* move the preflight from before the callback to after it +/// (rev 2's design, which validated at display time). All four fail. +/// *Second mutation, for the dedicated case:* pass `Some(dest.buffer)` +/// instead of `None` to `window_accepts_buffer`. Only that case fails — +/// which is why it is listed separately from the stale-buffer case it +/// otherwise resembles. +/// +/// **Also fails on full revert**, since `commit_to` does not exist on the +/// pre-image. It is listed as a P because the discriminating falsifier is +/// the named mutation, not the revert: a revert-only check would not +/// distinguish "validates" from "validates in time". +#[test] +fn preservation_a_failed_precondition_never_reaches_the_callback() { + // (label, Lua that breaks the precondition, expected reason fragment) + let cases: [(&str, &str, &str); 4] = [ + ( + "frontend gone", + // Handled in Rust below: unregistering a view has no Lua surface. + "", + "requesting frontend is gone", + ), + ( + "window gone", + "local doomed = dest:window() + pmacs.window.split_horizontal() + while pmacs.window.current() == doomed do pmacs.window.focus_next() end + pmacs.window.close_others()", + "is gone", + ), + ( + "stale buffer", + "pmacs.window.switch_buffer(pmacs.buffer.create('*usurper*'))", + "now shows another buffer", + ), + ( + "dedicated", + "pmacs.window.set_params(dest:window(), { dedicated = true })", + "is dedicated", + ), + ]; + + for (label, break_it, expected) in cases { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + if label == "frontend gone" { + s.core + .borrow_mut() + .unregister_frontend_view(FrontendId::LOCAL); + } else { + exec(&s, break_it); + } + let before = buffer_count(&s); + + exec( + &s, + "ran = false + ok, reason = pmacs.window.commit_to(dest, function() ran = true end)", + ); + + assert!( + !eval::(&s, "return ok"), + "{label}: commit_to must refuse" + ); + let reason: String = eval(&s, "return tostring(reason)"); + assert!( + reason.contains(expected), + "{label}: reason must say why; wanted {expected:?}, got {reason:?}" + ); + assert!( + !eval::(&s, "return ran"), + "{label}: the callback must not run at all -- validating after it \ + is four mutations too late" + ); + assert_eq!( + buffer_count(&s), + before, + "{label}: a refused commit must create no buffer" + ); + } +} + +/// **P2 — stale intent loses**, through dired's real commit path. +/// +/// The user replaced the destination window's buffer while the listing +/// was in flight. Their action is newer information than the request, so +/// the request loses: dired refuses, their buffer survives, and no dired +/// buffer or handle is left behind for that path. +/// +/// P1 pins the preflight in isolation; this drives `pmacs.dired.open` +/// with a captured destination — the same call the handler makes — so +/// the atomicity claim is asserted where the four mutations actually +/// live. +/// +/// *Mutation:* drop the `dest.buffer` comparison from the preflight +/// (window-only validation). The dired buffer then replaces the user's. +#[test] +fn preservation_a_stale_destination_loses_to_the_users_newer_buffer() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + let target = local_window(&s); + + // The user switches the destination window while the work is in flight. + exec( + &s, + "usurper = pmacs.buffer.create('*usurper*') + pmacs.window.switch_buffer(usurper)", + ); + let usurper = buffer_in(&s, target); + let before = buffer_count(&s); + + exec( + &s, + &format!( + "failure = nil + pmacs.async(function() + local ok, err = pcall(pmacs.dired.open, {:?}, {{ dest = dest }}) + failure = (not ok) and tostring(err) or '' + end)", + canon(td.path()) + ), + ); + pump(&mut s); + + let failure: String = eval(&s, "return failure"); + assert!( + failure.contains("destination is gone"), + "dired must report the refusal rather than commit; got {failure:?}" + ); + assert_eq!( + buffer_in(&s, target), + usurper, + "the user's newer buffer must survive" + ); + assert_eq!( + buffer_count(&s), + before, + "and no dired buffer may be left behind" + ); + assert_eq!( + active_name(&s), + "*usurper*", + "nor may the refusal change what is displayed" + ); +} + +/// **P3** — dired reads its `prev` inside the scope, so `q` returns to +/// the *destination* window's buffer, not the ambient frontend's. +/// +/// `handle.prev` is captured with `pmacs.window.buffer()`, whose no-arg +/// arm reads the core's ambient `active_buffer_id()`. That is precisely +/// why the scope swaps `core.active_frontend` and not only the override: +/// a scope that swapped the override alone would leave this one line +/// reading the competitor's buffer, and `q` would drop the user into a +/// buffer from another frontend's window. +/// +/// Asserted through `q` rather than by reaching into dired's handle +/// table — `prev`'s entire meaning is where `q` lands. +/// +/// *Mutation:* stop swapping `core.active_frontend` in +/// `ScopedFrontend::enter` (keep the override). `q` then lands in +/// `*competitor*`. +#[test] +fn preservation_dired_captures_prev_from_the_destination_not_the_ambient_frontend() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + + let target = local_window(&s); + let origin = buffer_in(&s, target).expect("the startup buffer"); + + // A competitor whose window shows a buffer of its own, ambient while + // the listing settles. + let other_win = attach_frontend(&s, COMPETITOR); + let competitor_buffer = + eval::(&s, "return pmacs.buffer.create('*competitor*')") + .0; + s.core + .borrow_mut() + .install_buffer_in_window(other_win, competitor_buffer) + .expect("install"); + s.core.borrow_mut().active_frontend = COMPETITOR; + + s.open_directory_target(td.path()); + pump(&mut s); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert!( + active_name(&s).starts_with("*dired:"), + "the listing must have committed" + ); + + type_char(&mut s, 'q'); + assert_eq!( + buffer_in(&s, target), + Some(origin), + "`q` must return to the buffer the DESTINATION window showed, not \ + the ambient frontend's" + ); +} + // --------------------------------------------------------------------------- // Step 5 — edit immediately // --------------------------------------------------------------------------- From 9b364adc267bff6c7df75cb4c7bc8c19318ed64d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 17:31:04 -0400 Subject: [PATCH 70/91] =?UTF-8?q?fix(panel):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20buffer=5Fid,=20the=20transport=20ratchet,=20shared?= =?UTF-8?q?=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 — `PanelPointer` was missing the approved `buffer_id`. Q#BP16 gives it and `panel_epoch` different jobs and neither subsumes the other: `buffer_id` catches an A->B buffer replacement, `panel_epoch` catches close/hide/reopen of the SAME persistent buffer, which a buffer id alone cannot see. Added in the framing's field order, with a pin asserting each field independently reaches the wire. P1 — added parent criterion 39's transport-safety ratchet. It builds the maximum legal panel payload, asserts the fixture actually spends the whole aggregate glyph budget (otherwise the ratchet measures something smaller than the worst case), asserts one byte more is rejected, and pins the encoded `InstanceMessage::PanelFrame` below `MAX_FRAME_BYTES`. Shaped `1 x MAX_PANEL_VISIBLE_CELLS` deliberately: no per-axis cap makes that a legal panel geometry a terminal cannot express, so it is the worst case the terminal's own ratchet never measured. Bitten by tripling the glyph budget — 30,342,696 bytes against the 16 MiB cap. P2 — the shared bounds were duplicated literals. `MAX_TERMINAL_GRAPHEME_BYTES` now aliases `MAX_WIRE_GRID_GRAPHEME_BYTES`, so the terminal screen's truncation (`src/terminal/screen.rs:697`, `:777`) and the validator cannot drift. Two more had the same defect and are aliased too: `MAX_TERMINAL_VISIBLE_CELLS` and `MAX_TERMINAL_FRAME_GLYPH_BYTES`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RuhVYUPHXMHG8r2z4tsDPR --- pmacs-protocol/src/message.rs | 8 + pmacs-protocol/src/panel.rs | 2 +- pmacs-protocol/src/terminal.rs | 16 +- pmacs-protocol/src/wire_grid.rs | 8 + ...ottom_panel_stage2b_protocol_acceptance.rs | 180 +++++++++++++++++- 5 files changed, 208 insertions(+), 6 deletions(-) diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 4103cd4..e3795a3 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -491,6 +491,12 @@ pub enum FrontendEvent { /// Carries both epochs so a gesture aimed at a panel that has since /// been replaced or reopened cannot be applied to its successor. /// Unlike [`Self::Pointer`], accepting this **activates the panel**. + /// + /// `buffer_id` and `panel_epoch` close different holes and neither + /// subsumes the other: `buffer_id` catches an A→B buffer + /// replacement, while `panel_epoch` catches close/hide/reopen of the + /// **same** persistent buffer — which a buffer id alone cannot + /// distinguish — without putting a `WindowId` on the wire. PanelPointer { /// Which frontend produced the gesture (untrusted, as above). frontend_id: FrontendId, @@ -498,6 +504,8 @@ pub enum FrontendEvent { 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. diff --git a/pmacs-protocol/src/panel.rs b/pmacs-protocol/src/panel.rs index d6e19d7..e571ca4 100644 --- a/pmacs-protocol/src/panel.rs +++ b/pmacs-protocol/src/panel.rs @@ -22,7 +22,7 @@ use crate::wire_grid::{ /// /// Identical to the terminal bound: it is the transport-safety limit, /// not a PTY policy, so both messages answer to it. -pub const MAX_PANEL_VISIBLE_CELLS: usize = 262_144; +pub const MAX_PANEL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS; /// Bounds a panel frame enforces on its cell grid. /// diff --git a/pmacs-protocol/src/terminal.rs b/pmacs-protocol/src/terminal.rs index b1b0016..8f7f507 100644 --- a/pmacs-protocol/src/terminal.rs +++ b/pmacs-protocol/src/terminal.rs @@ -37,10 +37,20 @@ pub const MAX_TERMINAL_COLS: u16 = 512; /// Maximum visible terminal cells accepted at creation, resize, or on /// the wire. -pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144; +/// +/// An alias of the shared wire-grid bound: this is transport safety, not +/// a PTY policy, so it must not drift from the panel's. +pub const MAX_TERMINAL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS; /// Maximum UTF-8 bytes retained in one terminal grapheme cluster. -pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256; +/// +/// An **alias** of the shared wire-grid bound, not an independent value. +/// The terminal screen truncates clusters to this constant while +/// [`crate::wire_grid`] validates against its own; if the two were +/// separate literals, raising one would make the producer emit clusters +/// its own validator rejects — or, worse, accept clusters no frontend +/// budgeted for. Keeping this a re-export means they cannot drift. +pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES; /// Shared cap for terminal title and process-outcome metadata. pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024; @@ -56,7 +66,7 @@ pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024; /// protocol test `maximum_legal_terminal_frame_encodes_below_the_transport_cap` /// measures the largest legal frame this bound admits and pins it below /// the unchanged 16 MiB cap. -pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GLYPH_BYTES; // --------------------------------------------------------------------------- // Payload types diff --git a/pmacs-protocol/src/wire_grid.rs b/pmacs-protocol/src/wire_grid.rs index 1ff40c9..a9449df 100644 --- a/pmacs-protocol/src/wire_grid.rs +++ b/pmacs-protocol/src/wire_grid.rs @@ -39,6 +39,14 @@ pub const MAX_WIRE_GRID_GLYPH_BYTES: usize = 8 * 1024 * 1024; /// Per-cell grapheme-cluster byte ceiling shared by every wire grid. pub const MAX_WIRE_GRID_GRAPHEME_BYTES: usize = 256; +/// Visible-cell ceiling shared by every wire grid. +/// +/// This is the transport-safety bound, not a per-message policy: it is +/// what keeps `rows * cols * per-cell` inside the transport frame limit, +/// so both the terminal and the panel answer to it even though they +/// carry different per-axis caps. +pub const MAX_WIRE_GRID_VISIBLE_CELLS: usize = 262_144; + /// Bounds a particular wire grid enforces. /// /// `max_rows` / `max_cols` are per-message policy. `max_visible_cells` diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs index 91a8d48..c3ec239 100644 --- a/tests/bottom_panel_stage2b_protocol_acceptance.rs +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -5,12 +5,16 @@ //! projection, the epoch state machine, and the GPU band are later //! slices of this stage and are not exercised here. -use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Glyph, Style}; +use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Color, Glyph, Style, UnderlineStyle}; use pmacs_protocol::message::{FrontendEvent, InstanceMessage, Modifiers, MouseButton, MouseKind}; -use pmacs_protocol::panel::{PanelFrame, PanelFrameError, PanelFramePayload}; +use pmacs_protocol::panel::{ + MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload, +}; use pmacs_protocol::terminal::{ MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState, }; +use pmacs_protocol::transport::MAX_FRAME_BYTES; +use pmacs_protocol::wire_grid::{MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES}; use pmacs_protocol::{BufferId, FrontendId, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}; fn cell(ch: char) -> Cell { @@ -21,6 +25,43 @@ fn cell(ch: char) -> Cell { } } +/// The style whose postcard encoding is as long as a legal `Style` gets. +fn maximal_style() -> Style { + Style { + fg: Color::Rgb(0xff, 0xee, 0xdd), + bg: Color::Rgb(0x11, 0x22, 0x33), + bold: true, + italic: true, + underline: UnderlineStyle::Dashed, + reverse: true, + underline_color: Color::Rgb(0x44, 0x55, 0x66), + } +} + +fn maximal_cell(glyph: Glyph) -> Cell { + Cell { + glyph, + style: maximal_style(), + attachment: None, + } +} + +/// A single-column cluster of exactly `len` UTF-8 bytes. +fn cluster_of_len(len: usize) -> Vec { + assert!((1..=MAX_WIRE_GRID_GRAPHEME_BYTES).contains(&len)); + let mut text = String::with_capacity(len); + if len % 2 == 1 { + text.push(' '); + } else { + text.push('\u{e9}'); + } + while text.len() < len { + text.push('\u{301}'); + } + assert_eq!(text.len(), len); + text.into_bytes() +} + fn panel_frame(rows: u32, cols: u32) -> PanelFrame { PanelFrame { buffer_id: BufferId::from_raw(9), @@ -121,6 +162,7 @@ fn the_three_panel_events_round_trip() { frontend_id: fid, geometry_epoch: 2, panel_epoch: 7, + buffer_id: BufferId::from_raw(21), coord: CellCoord::new(3, 9), kind: MouseKind::Down(MouseButton::Left), mods: Modifiers::default(), @@ -134,6 +176,45 @@ fn the_three_panel_events_round_trip() { } } +#[test] +fn panel_pointer_carries_buffer_id_distinctly_from_panel_epoch() { + // The two fields close different holes and neither subsumes the + // other: `buffer_id` catches an A->B buffer replacement, while + // `panel_epoch` catches close/hide/reopen of the SAME buffer, which + // a buffer id alone cannot see. So each must independently reach the + // wire — a field silently dropped from the encoding would let one of + // those two stale gestures through. + let base = |buffer: u64, panel_epoch: u64| FrontendEvent::PanelPointer { + frontend_id: FrontendId(4), + geometry_epoch: 2, + panel_epoch, + buffer_id: BufferId::from_raw(buffer), + coord: CellCoord::new(1, 1), + kind: MouseKind::Down(MouseButton::Left), + mods: Modifiers::default(), + }; + let encode = |e: &FrontendEvent| postcard::to_allocvec(e).expect("encode"); + + // Same panel epoch, different buffer: must differ on the wire. + assert_ne!(encode(&base(1, 7)), encode(&base(2, 7))); + // Same buffer, different panel epoch: must also differ. + assert_ne!(encode(&base(1, 7)), encode(&base(1, 8))); + + // And both survive decode rather than being defaulted. + let event = base(31, 7); + let decoded: FrontendEvent = postcard::from_bytes(&encode(&event)).expect("decode"); + let FrontendEvent::PanelPointer { + buffer_id, + panel_epoch, + .. + } = decoded + else { + panic!("expected a PanelPointer, got {decoded:?}"); + }; + assert_eq!(buffer_id, BufferId::from_raw(31)); + assert_eq!(panel_epoch, 7); +} + // --------------------------------------------------------------------------- // 37 — byte pins on the previous final variant of each extended enum // --------------------------------------------------------------------------- @@ -201,6 +282,7 @@ fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() frontend_id: fid, geometry_epoch: 1, panel_epoch: 1, + buffer_id: BufferId::from_raw(1), coord: CellCoord::new(0, 0), kind: MouseKind::Down(MouseButton::Left), mods: Modifiers::default(), @@ -349,3 +431,97 @@ fn terminal_frames_are_unchanged_by_the_factoring() { }) )); } + +// --------------------------------------------------------------------------- +// 39 — the transport-safety ratchet +// --------------------------------------------------------------------------- + +/// The largest legal panel frame, plus the same frame one glyph byte over. +/// +/// Deliberately shaped `1 x MAX_PANEL_VISIBLE_CELLS`: a panel carries no +/// per-axis cap, so this is a legal panel geometry a terminal frame +/// cannot express, and it is therefore the worst case the terminal's own +/// ratchet never measured. +fn panel_budget_boundary_frames() -> (PanelFrame, PanelFrame) { + /// Shortest cluster length postcard encodes with a two-byte length + /// prefix, which is what makes a cluster cell maximally expensive. + const WIDE_PREFIX_LEN: usize = 128; + let area = MAX_PANEL_VISIBLE_CELLS; + + // Every cell owes at least one glyph byte; the rest of the budget is + // spent on as many two-byte-prefix clusters as it affords. + let spare = MAX_WIRE_GRID_GLYPH_BYTES - area; + let wide_cells = spare / (WIDE_PREFIX_LEN - 1); + let remainder = spare % (WIDE_PREFIX_LEN - 1); + assert!(wide_cells + usize::from(remainder > 0) <= area); + + let wide = cluster_of_len(WIDE_PREFIX_LEN).into_boxed_slice(); + let single = cluster_of_len(1).into_boxed_slice(); + let mut cells = Vec::with_capacity(area); + for index in 0..area { + let glyph = if index < wide_cells { + Glyph::Cluster(wide.clone()) + } else if index == wide_cells && remainder > 0 { + Glyph::Cluster(cluster_of_len(remainder + 1).into_boxed_slice()) + } else { + Glyph::Cluster(single.clone()) + }; + cells.push(maximal_cell(glyph)); + } + + let cols = u32::try_from(area).expect("area fits u32"); + let exact = PanelFrame { + buffer_id: BufferId::from_raw(u64::MAX), + panel_epoch: u64::MAX, + geometry_epoch: u64::MAX, + size: CellSize::new(1, cols), + cells, + cursor: Some(CellCoord::new(0, cols - 1)), + focused: true, + }; + + let mut over = exact.clone(); + // One more byte of glyph, nothing else changed. + let last = over.cells.len() - 1; + over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(3).into_boxed_slice())); + + (exact, over) +} + +#[test] +fn maximum_legal_panel_frame_encodes_below_the_transport_cap() { + let (exact, over) = panel_budget_boundary_frames(); + assert_eq!(exact.validate(), Ok(())); + + // The fixture must actually sit ON the boundary, or the ratchet + // below measures something smaller than the worst case and would + // stay green while a real maximum frame overran the transport. + let mut glyph_bytes = 0usize; + for cell in &exact.cells { + glyph_bytes += match &cell.glyph { + Glyph::Char(ch) => ch.len_utf8(), + Glyph::Cluster(bytes) => bytes.len(), + Glyph::Continuation => 0, + }; + } + assert_eq!( + glyph_bytes, MAX_WIRE_GRID_GLYPH_BYTES, + "the measured fixture must spend the whole aggregate budget" + ); + + // One byte over is rejected, which is what makes `exact` maximal. + assert!(matches!( + over.validate(), + Err(PanelFrameError::GlyphBudget { .. }) + )); + + let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(exact)); + let bytes = postcard::to_allocvec(&msg).expect("encode"); + assert!( + bytes.len() < MAX_FRAME_BYTES, + "largest legal panel frame encodes to {} bytes, at or above the \ + {MAX_FRAME_BYTES}-byte transport cap; the aggregate glyph bound no \ + longer keeps panel traffic inside the existing transport limit", + bytes.len() + ); +} From 7741cf806a1dd603e258bfd125146abdde07fea7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 18:20:02 -0400 Subject: [PATCH 71/91] fix(journey): honor the captured window, not the selected one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 of PR #182. One implementation gap and two stale claims. **The scope pins the frontend; it does not pin the window.** Framing §4.4 specified `display{ window = dest:window() }`, but dired's commit still ended in `pmacs.window.switch_buffer`, which targets whatever window the scoped frontend has selected. A split or panel that took focus while `read_dir` was pending therefore received the listing, and `prev` was captured from it too — with every preflight check passing, because the captured window was still live and still held its captured buffer. Both sites now read the captured window: `display` routes to it with `select = true` (the later `seat_cursor` acts on the active window), and the `prev` read asks it directly. N4c pins both halves. The suite's existing routing pins all varied *frontend* identity; none varied the selected window within one frontend, which is exactly why 23 green pins missed this. Bite: dired's `display` back to `switch_buffer` fails N4c alone; `prev` read from the ambient window fails N4c alone. Two stale documentation claims, both of which this PR was supposed to have already fixed: * **The §0 scorecard still graded §2 "Broken at entry"** while §2's own ground truth had been rewritten. The scorecard is a second copy of the same claim and §25's protocol covers both. §19's row and ground truth were stale the same way — this PR creates the first cross-subsystem suite, which §19 says should exist and grades as missing — and are corrected too. * **P4 still read "leaves exactly one buffer"**, the exact claim rev 6 corrected as false everywhere else in the framing. Restated to what it actually pins: the file is in the *active window*. The test was already written correctly; only the framing lied. Framing rev 8. Co-Authored-By: Claude Opus 5 (1M context) --- COHERENCE.md | 34 ++++++++------- builtin/runtime/dired.lua | 31 ++++++++++++-- docs/active-work.md | 12 ++++-- docs/journey-stage1a-framing.md | 49 ++++++++++++++++++--- tests/journey_acceptance.rs | 75 +++++++++++++++++++++++++++++++++ 5 files changed, 175 insertions(+), 26 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index e7229f3..45a9683 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -95,7 +95,7 @@ remain open to them. | § | Concern | Grade | One-line state | |---|---|---|---| -| 2 | Golden product journey | **Broken at entry** | `pmacs .` exits 1; only "launch" and "edit" pass cleanly zero-config | +| 2 | Golden product journey | **Runs to step 5** | `pmacs .` opens the directory (Journey Stage 1a); thin from step 6 on | | 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 | **Substrate without surface** | Best-in-class registration metadata; almost no way for a user to reach it | @@ -112,7 +112,7 @@ remain open to them. | 16 | Semantic frontend | **Strong** | v6..=v20 negotiated protocol; degradation practiced; TUI/GPU share the model | | 17 | Distribution | **Missing** | CI is test-only; no binaries, channels, checksums, or update path | | 18 | Onboarding | **Missing** | No welcome, no tutorial; `C-h` deletes a word; `M-x` is the only door in | -| 19 | Coherence acceptance tests | **Missing (culture ready)** | Superb per-arc acceptance discipline; zero cross-subsystem journey tests | +| 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` exists (steps 2, 3, 5); the other five scenarios are still unwritten | Three cross-cutting patterns explain most of the table; they are detailed in §1.1–§1.3: **substrate without surface**, **the silence @@ -1452,20 +1452,24 @@ subsystems, complementing (not replacing) subsystem tests: ### Ground truth -**Grade: missing — but the culture that would make them excellent is the -project's strongest process asset.** +**Grade: started — the first suite exists; five of the six scenarios +above do not.** -Zero cross-subsystem journey tests exist. Every acceptance suite in the -tree pins one subsystem's contract (superbly — bite-verified, -falsified-by-revert, vacuity-checked). Several of the scenarios above -are currently *untestable* because the behavior doesn't exist (install -in-session, disable, open a directory); the ones that are testable -(first launch, command discovery, worker cancellation, remote -attach/reconnect) could be written today and would immediately pin the -journey against regression. The first coherence acceptance suite should -be the §2 journey itself, growing a step at a time as steps become -real — that is how "the journey is a release gate" stops being -aspirational. +At audit time zero cross-subsystem journey tests existed. **Journey +Stage 1a created `tests/journey_acceptance.rs`**, the §2 journey itself, +seeded with steps 2 (launch unconfigured), 3 (open a real project), and +5 (edit immediately), and declared a ratchet: stages add rows, none +removes them. That is the "first launch" scenario, partially — missing +tools still have no actionable guidance to assert. + +The rest is unchanged. Every other acceptance suite in the tree pins one +subsystem's contract (superbly — bite-verified, falsified-by-revert, +vacuity-checked). Command discovery, workspace lifecycle, worker +ownership, package lifecycle, and remote execution have no +cross-subsystem suite; several remain *untestable* because the behavior +doesn't exist (install in-session, disable). Steps 6–12 join +`journey_acceptance.rs` as later stages make them real — that is how +"the journey is a release gate" stops being aspirational. (Related lesson already in the handoff: `compile_mode_acceptance` accidentally reads the real user config — an *unintentional* diff --git a/builtin/runtime/dired.lua b/builtin/runtime/dired.lua index adbc8f1..c8054fe 100644 --- a/builtin/runtime/dired.lua +++ b/builtin/runtime/dired.lua @@ -571,7 +571,15 @@ end -- deliberately so (Q#DR10): the next directory is the same kind of -- thing as the current one and belongs in the same slot, while a file -- is not a dired buffer and belongs in the document area. -local function display(handle, opts, departed) +-- +-- `captured` (Journey Stage 1a, Q#JR14) is the destination window a +-- background open must land in. It is NOT the same as "wherever the +-- scoped frontend is looking now": the scope fixes the *frontend*, and +-- within one frontend the selected window can still have moved to +-- another split while the listing was in flight. The preflight cannot +-- catch that -- the captured window is still live and still holds its +-- captured buffer -- so honoring it is this function's job. +local function display(handle, opts, departed, captured) local side = nil if departed ~= nil then -- Dired's own window, not the request's: walking a tree in a side @@ -587,6 +595,11 @@ local function display(handle, opts, departed) -- both the substrate's documented policy and Emacs's, so dired does -- not try to unpin the user's panel. pmacs.window.display(handle.buf, { side = side, select = true }) + elseif captured ~= nil then + -- `select = true` because the rest of the commit -- seat_cursor via + -- `pmacs.editor.move_to_line` -- acts on the frontend's ACTIVE + -- window, so the seat would land in the wrong window otherwise. + pmacs.window.display(handle.buf, { window = captured, select = true }) else pmacs.window.switch_buffer(handle.buf) end @@ -641,6 +654,13 @@ local function open_directory(path, opts, departed) -- inside it is refused (Q#JR14b), because a yield would restore the -- scope while this coroutine is still parked. local function commit() + -- The captured window, read once. Everything below that would + -- otherwise consult "the active window" must consult THIS instead: + -- the scope pins the frontend, not the selected window, and a split + -- or panel can take focus within that frontend while the listing is + -- in flight (Q#JR14). + local captured = opts.dest ~= nil and opts.dest:window() or nil + local handle = claim_handle(canonical) handle.entries = entries handle.errors = errors @@ -652,14 +672,19 @@ local function open_directory(path, opts, departed) if departed ~= nil then handle.prev = departed.prev else - local active = pmacs.window.buffer() + local active + if captured ~= nil then + active = pmacs.window.buffer(captured) + else + active = pmacs.window.buffer() + end if active ~= nil and handle_for_buffer(active) == nil then handle.prev = active end end paint(handle) - display(handle, opts, departed) + display(handle, opts, departed, captured) -- Seating happens after the display: `switch_buffer` zeroes the -- window cursor, so an earlier seat would be discarded. seat_cursor(handle, opts.select_name, 1) diff --git a/docs/active-work.md b/docs/active-work.md index 5a78b6d..919dc5d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -331,10 +331,11 @@ If it does not, stop and repair the remote/fetch configuration. --check` clean. - Stage 4b (the input method) is NOT in this PR and not started. -## Journey Stage 1a — IMPLEMENTED on branch, gates run, PR pending +## Journey Stage 1a — PR #182 OPEN, review round 1 closed -- Framing `docs/journey-stage1a-framing.md` **rev 7** (four review - rounds, then two correction revisions found during implementation). +- Framing `docs/journey-stage1a-framing.md` **rev 8** (four review + rounds, two correction revisions found during implementation, one from + review round 1 of PR #182). Branch `journey-stage1a-directory-open`, rebased onto `githubsucks/main` @ `74301d1`. - Recovery: `git fetch githubsucks && git checkout @@ -355,6 +356,11 @@ If it does not, stop and repair the remote/fetch configuration. preflight moved after the callback → P1 + P2 fail, nothing else; drop the `ScopedFrontend` arm from `acting_frontend` → N4b fails, nothing else. That last mutation is why N4b exists — it left N4 green. + Round 1 of PR #182 added two more: dired's `display` back to + `switch_buffer`, and `prev` read from the ambient window → each fails + **N4c** alone. **The scope pins the frontend, not the window** — every + routing pin before N4c varied frontend identity and none varied the + selected window within one frontend, so 23 green pins missed it. - Ordering: PR #177 MERGED (2026-07-26), so 1a was unblocked. 1a lands before dired Stage 2. When 1a lands, Stage 2 must re-scout and revise its framing around the scoped `pmacs.window.commit_to` boundary before diff --git a/docs/journey-stage1a-framing.md b/docs/journey-stage1a-framing.md index ebb3cc9..ce5589f 100644 --- a/docs/journey-stage1a-framing.md +++ b/docs/journey-stage1a-framing.md @@ -1,7 +1,7 @@ # Journey Stage 1a — open a directory, on one path -**Status: framing, rev 6 — APPROVED at rev 5; rev 6 records -corrections found during implementation.** +**Status: framing, rev 8 — APPROVED at rev 5; revs 6–8 record +corrections found during implementation and review of PR #182.** **Serves `COHERENCE.md` §2 (the golden product journey), §19 (coherence acceptance tests), §20 Priority 1.** @@ -147,6 +147,30 @@ acceptance tests), §20 Priority 1.** else; mutation C (drop the `ScopedFrontend` arm) fails N4b and nothing else. +- rev 8 (2026-07-26) — **review of PR #182.** One implementation gap and + two stale claims: + - **dired did not honor the captured window.** §4.4 specified + `display{ window = dest:window() }`; the implementation still ended + in `pmacs.window.switch_buffer`, which targets whatever window the + *scoped frontend* has selected. The scope pins the frontend; it does + not pin the window. So a split or panel that took focus while + `read_dir` was pending received the listing, and `prev` was captured + from it too — with every preflight check passing, because the + captured window was still live and still held its captured buffer. + Fixed in both places (`display` and the `prev` read), and **N4c** + added. The suite's routing pins all varied *frontend* identity; + none varied the selected window within one frontend, which is why + 23 green pins missed it. + - **The §0 scorecard row still graded §2 "Broken at entry"** while §2's + own ground truth had been rewritten — the scorecard is a second copy + of the same claim and §25's update protocol covers both. §19's row + and ground truth were stale in the same way (this PR creates the + first cross-subsystem suite) and are corrected too. + - **P4 still said "leaves exactly one buffer"**, the exact claim rev 6 + corrected as false everywhere else. Restated to what it actually + pins — the file is in the *active window* — matching the test that + was already written correctly. + --- ## 0.5. Coherence impact (`COHERENCE.md` §20, required since #163) @@ -741,6 +765,15 @@ is **removed rather than recast**: it proved nothing N1 does not. - **N5** Bootstrap with a deliberately **non-scratch** LOCAL primary document buffer: the reply's `buffer_id` is that buffer, and after quiescence the window shows dired (Q#JR9, §4.5). +- **N4c — the captured *window*, not the captured frontend's selected + one (added rev 8).** One frontend, two windows: capture a destination, + then split and move focus to the other window and give it a buffer of + its own, then run dired's handler path with the captured destination. + The listing lands in the captured window, the focused window is + untouched, and `q` returns to the buffer the *captured* window showed. + Falsified independently by restoring `switch_buffer` in dired's + `display` and by reading `prev` from the ambient window — both were + verified to fail only this pin. - **N6 — `commit_to` scopes and restores, on every exit path.** Three cases, each asserting that **both** the scoped override and `core.active_frontend` return to their prior values: (a) `fn` returns @@ -826,10 +859,16 @@ entire claim. P3–P8 are preservation pins in the strict sense. that handle's `prev`, entries, and cursor untouched. *Mutation:* restore the ambient `handle.prev = pmacs.window.buffer()` outside the scope (§2.5 step 4). -- **P4 — the startup scratch is still dropped (Q#JR3).** - `EditorState::open` leaves exactly one buffer. +- **P4 — startup shows the file in the *active window* (Q#JR3, corrected + rev 6, restated rev 8).** `EditorState::open` displays the loaded + buffer in the active window and no window is left showing the startup + scratch. It does **not** assert a buffer count: `replace_active_buffer` + does not drop the scratch buffer, and rev 5's "leaves exactly one + buffer" wording — which survived rev 6's correction here by oversight, + caught in review of PR #182 — asserted a guarantee the editor does not + make. *Mutation:* replace `replace_active_buffer` with a bare - `install_buffer_in_window`. + `install_buffer_in_window` into some other window. - **P5 — the `NotFound` arm survives the refactor.** A nonexistent path yields an empty path-backed buffer with `[new file]` and fires no hook. *Mutation:* delete the `NotFound` arm from `resolve_target_buffer`. diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index d25cf4c..7ab36c0 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -570,6 +570,81 @@ fn commit_to_outranks_an_interactive_origin() { assert_eq!(local_window(&s), local_win); } +/// **N4c** — the commit lands in the *captured window*, not merely in +/// the captured frontend's currently selected one. +/// +/// Review finding on PR #182. Every other routing pin here varies +/// frontend identity; none varied the selected window *within* one +/// frontend, and dired's commit still ended in `switch_buffer`, which +/// targets whatever window the scoped frontend has active. The preflight +/// cannot catch this — the captured window is still live and still holds +/// its captured buffer — so a split that took focus while `read_dir` was +/// pending got the listing, and `prev` was captured from it too. +/// +/// Both halves are asserted: where the listing lands, and where `q` +/// goes. Falsified by restoring `pmacs.window.switch_buffer` in dired's +/// `display`, or by reading `prev` from the ambient window. +#[test] +fn a_background_open_uses_the_captured_window_not_the_selected_one() { + let td = project(); + let mut s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + capture_dest(&mut s, td.path()); + + let target = local_window(&s); + let origin = buffer_in(&s, target).expect("the captured window's buffer"); + + // Split, move focus to the OTHER window, and give it a buffer of its + // own. The captured window is untouched, so every preflight check + // still passes -- which is exactly why this needs its own pin. + exec( + &s, + "local captured = dest:window() + pmacs.window.split_horizontal() + while pmacs.window.current() == captured do pmacs.window.focus_next() end + pmacs.window.switch_buffer(pmacs.buffer.create('*elsewhere*'))", + ); + let elsewhere = local_window(&s); + assert_ne!(elsewhere, target, "focus must have moved to another window"); + let elsewhere_buffer = buffer_in(&s, elsewhere); + + // dired's real handler path, with the captured destination. + exec( + &s, + &format!( + "pmacs.async(function() + pmacs.dired.open({:?}, {{ dest = dest }}) + end)", + canon(td.path()) + ), + ); + pump(&mut s); + + assert_eq!( + buffer_in(&s, elsewhere), + elsewhere_buffer, + "the window that took focus mid-listing must be untouched" + ); + assert_eq!( + local_window(&s), + target, + "the commit must select the captured window" + ); + assert!( + active_name(&s).starts_with("*dired:"), + "and the listing must be in it; got {:?}", + active_name(&s) + ); + + // `prev` came from the captured window too, not from `*elsewhere*`. + type_char(&mut s, 'q'); + assert_eq!( + buffer_in(&s, target), + Some(origin), + "`q` must return to the buffer the CAPTURED window showed" + ); +} + /// **N6a** — the scope is restored when the callback returns normally. /// /// Falsified by dropping the guard's restore, or by never swapping From 4cf1b631980708a6489511e75a8879e2578d285d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 18:33:26 -0400 Subject: [PATCH 72/91] docs(active-work): the journey suite is 24 pins, not 23 A count is a reading, not a constant. Recorded as of rev 8 with an explicit instruction to re-read rather than quote the line. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 919dc5d..2d93adf 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -346,8 +346,9 @@ If it does not, stop and repair the remote/fetch configuration. `path.open-directory` chain + `pmacs.path.directory_handler` fallback slot, `pmacs.window.commit_to` with its scoped frontend and preflight, the nonconstructible destination userdata, the daemon bootstrap arm, - and `tests/journey_acceptance.rs` (23 pins). No protocol change — - still v20. + and `tests/journey_acceptance.rs` (**24 pins** as of rev 8 — a count, + not a constant; re-read it rather than quoting this line). No protocol + change — still v20. - **Doc updates ride the PR** per COHERENCE §25: §2 grade + step-3 verdict row, §20 Priority 1 + the arc list, the GPU initial-target framing's Q#GT6 / acceptance 10 supersession, handoff §1. From 56301eda02256a8b4191982a6e5e19025519bc59 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 20:02:19 -0400 Subject: [PATCH 73/91] =?UTF-8?q?docs(panel):=20Stage=202=20framing=20rev?= =?UTF-8?q?=205=20=E2=80=94=20the=20three-way=20slice=20of=202B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rev 4 §9 scoped Stage 2B as a single PR: v21 protocol, daemon panel projection, GPU band, and the negotiated `panel_capable` flip. Implementation showed that to be roughly four thousand lines across three crates with three different failure modes. Rev 5 splits it into 2B-1 (the wire layer), 2B-2 (the daemon projection and epoch machine), and 2B-3 (the GPU band and the flip), on the rule that a slice ends where the next thing to build has a different authority. No decision changes. What changes is the allocation: - §7.2 becomes three subsections, and criteria that span a boundary are named in every slice they touch with their half stated, rather than assigned wholesale to one. Parent 39 is the clearest case: its shared-validation and transport-budget halves are wire properties provable in 2B-1, while "the previous valid frame is retained" and "a duplicate does no work" need the epoch machine and are 2B-2. A2B-1 splits the same way — grid exhaustion in 2B-2, the frontend latch in 2B-3. - §9 lists four serial PRs instead of two, each cut from `main`, and states that every slice runs the full gate set rather than the subset its own crate suggests. - §6 records which slice pays the coherence debt. The journey claim belongs to 2B-3 alone: with `panel_capable = false`, a GPU user still gets the Stage 1 non-side fallback after 2A, 2B-1 and 2B-2 have all landed. Three quarters of this stage is preparation. Two things recorded because they are easy to inherit silently: - This revision is retroactive for slice 1. `bottom-panel-stage2b` already carried the v21 protocol layer, written before the revision existed, which inverts framing -> approval -> branch -> implement. The slicing was sound; taking it in code rather than in the document is how a stage's scope drifts without anyone deciding that it should. - 2B-1 and 2B-2 ship dark. The bump advertises a capability whose only distinguishing feature is unreachable until 2B-3, so the arc must not stall between them. Safe for compatibility — appended variants, extended ladder, a v20 peer still negotiates 20 — but a stall should be visible as a decision, not inherited as a default. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B --- docs/bottom-panel-stage2-framing.md | 212 +++++++++++++++++++++++++--- 1 file changed, 190 insertions(+), 22 deletions(-) diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index 38d94ed..2af1a6d 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,7 +1,10 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 4 — pre-implementation. Ground truth: canonical `main` @ -`ccf29e3`, protocol v20, 2026-07-25.** +**Revision 5 — 2A merged, 2B in progress. Ground truth: canonical +`main` @ `42025e4`, protocol v20 on `main` and v21 on +`bottom-panel-stage2b`, 2026-07-26.** Revisions 1–4 were +pre-implementation; rev 5 records the three-way slice of Stage 2B +(§0.0, §7.2, §9) after its first slice was already built. Stage 1 (#155, merge `e745068`) gave pmacs window placement, window parameters, TUI side windows, the divider, and the adopter `display` @@ -26,7 +29,57 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and ## 0. Revision history -### 0.0 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed +### 0.0 Rev 4 → rev 5 — the three-way slice of 2B (not a review round) + +This revision changes no decision. It splits one approved +implementation slice into three and reallocates the acceptance +criteria across them. + +- **R5-1 — why.** Rev 4 §9 scoped 2B as a single PR: v21 protocol, + daemon panel projection, GPU band, and the negotiated + `panel_capable` flip. Implementation showed that to be roughly four + thousand lines spanning `pmacs-protocol`, `src/daemon.rs`, and + `pmacs-gpu` — three review surfaces with different failure modes, in + one diff. The same argument that produced 2A/2B applies again one + level down, and it is the argument this arc has already accepted + twice (Lean 4 stages 3a/3b and 4a/4b). +- **R5-2 — the boundary rule.** A slice ends where the next thing to + build has a different *authority*: the wire format, the daemon that + produces frames, and the frontend that paints them. Each slice is + independently reviewable against a subset of the parent criteria, + and each is additive — no slice makes a previously-passing assertion + fail. + **Criteria that span a boundary are named in every slice they touch, + with their half stated**, rather than assigned wholesale to one. The + clearest case is parent 39: its shared-validation and + transport-budget halves are wire properties provable in 2B-1, while + "the previous valid frame is retained" and "a duplicate does no + work" are receiver-state properties that need the epoch machine and + land in 2B-2. +- **R5-3 — this revision is retroactive for slice 1, and that is a + process defect worth recording.** `bottom-panel-stage2b` already + carries the v21 protocol layer (three commits, one review round + closed) written before this revision existed. The workflow is + framing → approval → branch → implement; slice 1 inverted it. The + slicing decision was sound, but it was taken in code and discovered + in the branch rather than proposed in the document, which is exactly + how a stage's scope drifts without anyone deciding that it should. + Rev 5 exists to put the decision back where it belongs before slices + 2 and 3 are written. +- **R5-4 — two of the three slices ship dark, deliberately.** Nothing + in 2B-1 or 2B-2 is reachable by a user: `panel_capable` stays + `false` for every negotiated semantic session until 2B-3, so a v21 + daemon and a v21 GPU frontend negotiate 21 and behave exactly as + they do at v20. This is the same posture 2A took ("seam adoption + that becomes load-bearing in 2B") and it carries the same + obligation: **the version bump advertises a capability whose only + distinguishing feature is unreachable until 2B-3 lands.** That is + safe for compatibility — the variants are appended, the ladder is + extended, and a v20 peer still negotiates 20 — but it means the arc + must not stall between 2B-1 and 2B-3. Recorded here so a stall is + visible as a decision rather than inherited as a default. + +### 0.1 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed - **R3-1 (blocker).** Rev 3's three-boundary model was right but its call-site table was wrong in five places, and each error was a real @@ -54,7 +107,7 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and `cell.attachment.is_some()` rejection. It is now classified — and **shared**, with the reasoning pinned. -### 0.1 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed +### 0.2 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed - **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two boundaries that must **diverge** once a panel exists. Several sites it @@ -81,7 +134,7 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and - Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0, and `TEXT_TOP` stays unscaled. -### 0.2 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed +### 0.3 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed - **R1-1 (blocker).** Rev 1 said all 23 census reads route through `primary_document_window`. That contradicts Q#BP14, which routes only @@ -589,11 +642,20 @@ exactly once. - **Section this serves:** `COHERENCE.md` §14, which records the panel primitive as landed for Stage 1 and names "Stage 2 (GPU band) pending its own framing" as the open item. +- **Which slice pays the coherence debt (rev 5).** The journey claim + above is Stage 2B-3's alone. 2A, 2B-1, and 2B-2 close **no** journey + divergence: with `panel_capable = false`, a GPU user still gets the + Stage 1 non-side fallback on steps 7–10 after all three land. Stated + explicitly so no slice's PR can claim the arc's coherence benefit + before the flip earns it — three quarters of this stage is + preparation, and only the last quarter is the improvement. ## 7. Acceptance **Parent criteria 37–55 remain authoritative and are not replaced.** -This section maps them to the two slices and adds only refinements. +This section maps them to the four slices — 2A, then 2B-1/2B-2/2B-3 — +and adds only refinements. A criterion that spans a slice boundary is +named in each slice it touches, with its half stated. ### 7.1 Stage 2A — classified census routing + painter extraction @@ -638,18 +700,80 @@ Refinements 2A adds: scroll state**. Byte-identical cells alone would not catch a clamp that silently moved to the wrong window. -### 7.2 Stage 2B — v21 protocol + daemon projection + GPU band +### 7.2 Stage 2B — v21 protocol, daemon projection, GPU band -Parent criteria that apply in full: **37, 38, 39, 40, 41, 45, 46, 47, -48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44, and -52** **through the actual negotiated capability flip** rather than +Stage 2B as a whole owns parent criteria **37, 38, 39, 40, 41, 45, 46, +47, 48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44, +and 52** **through the actual negotiated capability flip** rather than through a test-only panel-capable semantic view. 52's 2B form is the production one: a real semantic frontend with `fold_projection = false` displaying a folded buffer in a panel shows every source line, and the panel path never reaches `fold_map_for_window`. -Refinements 2B adds: +Per §0.0 R5-1 those land across three slices. Each slice's own gate run +is the standing suite plus §9's named acceptance suites; **only 2B-3 +changes what a user sees.** +#### 7.2.1 Slice 2B-1 — the v21 wire layer + +**Authority: `pmacs-protocol`.** The four wire shapes Q#BP9 names, the +version bump, and the shared cell-grid validator. No producer, no +consumer, no capability change. + +- **37, in full.** `PanelFrame` round-trips including `panel_epoch` and + `geometry_epoch`, with independent byte pins on the previous final + `InstanceMessage::InitialTargetResult` and + `FrontendEvent::TerminalPointer` variants. **Both pins must be + falsified by revert**, not merely observed passing: a byte pin that + never saw the shift it exists to catch pins nothing. +- **39, the wire half only.** Shared cell/topology/glyph/area + validation; an area-bounded panel wider than 512 columns is accepted + while a terminal frame retains its 512-column PTY cap; the maximum + legal panel encoding stays below the transport limit. **The ratchet's + fixture must be shown to spend the whole aggregate glyph budget** — + otherwise it measures something smaller than the worst case and the + bound it proves is not the bound that matters. The worst case is + `1 × MAX_PANEL_VISIBLE_CELLS`, a legal panel geometry no terminal can + express, so the terminal's own ratchet has never covered it. + **39's receiver half — atomic rejection with retention of the + previous valid frame, and a duplicate doing no work — is 2B-2.** +- **The version ladder moves with the bump.** `PROTOCOL_VERSION` + becomes 21, `SUPPORTED_PROTOCOL_VERSIONS` accepts `6..=21` and + rejects 22, and any test whose *name* encodes the old number is + renamed. A ladder pin that passes across a bump was not pinning the + version. +- **Shared bounds are aliased, not duplicated.** Every constant the + terminal screen and the panel validator both enforce is one + definition with the other as an alias, so truncation and validation + cannot drift apart. +- **Not in this slice:** the daemon arm that drops panel events from a + grid session is exhaustiveness bookkeeping the bump forces, not + projection. It asserts only that a grid session's panel declaration + is dropped rather than trusted. + +#### 7.2.2 Slice 2B-2 — the daemon panel projection and epoch machine + +**Authority: `src/daemon.rs`.** Produces `PanelFrame`; derives the +grid; owns stale-event rejection. Exercised through a **test-only** +panel-capable semantic view — `panel_capable` stays `false` in +production negotiation until 2B-3. + +- **38** (open → replace buffer → hidden by a tiny frame → reappear → + close, with authoritative `Absent` and a new epoch on + replacement/reappearance), **40** (first open at a non-80×24 frame + stays absent until real `FrontendCellGeometry` arrives, never + consulting the 24×80 attach placeholder), **49**, **50**, **51**, + **53**. +- **39's receiver half**, per §7.2.1. +- **41, the daemon half:** the daemon alone derives the grid; an older + retained frame neither paints nor accepts input after a new + `geometry_epoch` until a matching `Present` arrives; row-clamping + preserves the stored request; zero, non-finite, and non-positive + metric inputs fail closed to zero usable geometry. *The pixel→cell + formula and its call sites are 2B-3.* +- **42, 43, 44, 45, 52** in their projection form, through the + test-only panel-capable view. Their production re-assertion through + the real flip is 2B-3. - **A2B-1.** The epoch state machine of §3.1 is pinned row by row, including the lower-epoch-identical-data rejection and the same-epoch-different-total rejection, and each row's @@ -660,7 +784,33 @@ Refinements 2B adds: hides (a subsequent real resize must not paint a stale-geometry panel), and a frontend that exhausts latches — a retained `Present` whose epoch still matches cannot make the band reappear, and only a - fresh session clears the latch. + fresh session clears the latch. **A2B-1's grid-exhaustion half is + 2B-2; its frontend-latch half needs a real frontend and is 2B-3.** + Both halves are named here so neither is lost at the seam. + +#### 7.2.3 Slice 2B-3 — the GPU band and the capability flip + +**Authority: `pmacs-gpu`, plus the negotiation rule.** This is the only +slice a user can observe, and the only one that closes the journey +divergence in §6. + +- **46** (band + divider shrink the document text area by exactly their + pixel height; carets, hits, and scroll geometry respect the reduced + area), **47** (divider drag, `window.min-height`, `RowResize` hover, + and the stalled-writer tail-coalescing), **48** (`PanelPointer` + driving selection, terminal mouse reporting, and click-to-focus + without disturbing the document mirror), **54** (the + `--headless-probe` run: one real daemon, real PTY, real wgpu, through + a panel-hosted terminal), **55**. +- **41, the GPU half:** the pixel→cell conversion pinned at fractional + widths and heights, and geometry refresh on window resize, font + change, and scale change. +- **42, 43, 44, 45, 52 re-asserted through the production flip**, not + the test-only view. This is the point of the re-assertion: a + test-only panel-capable view can be constructed wrongly and agree + with itself, so the production negotiation path must carry the same + assertions. +- **A2B-1's frontend-latch half**, per §7.2.2. - **A2B-2.** A font or scale change that leaves `CellSize` **identical** still produces a new `geometry_epoch`, and the older `PanelFrame` neither paints nor hit-tests until a matching `Present` arrives. This @@ -704,19 +854,37 @@ fixes. It belongs to a spacing-system change of its own. ## 9. Slices, branches, and gates -Per review round 1: **two serial implementation PRs**, each a named -slice under this framing so one-feature/one-branch/one-PR holds. **2A -lands before 2B branches** — not stacked. +Per review round 1 and §0.0 R5-1: **four serial implementation PRs**, +each a named slice under this framing so one-feature/one-branch/one-PR +holds. **Each slice lands before the next branches** — none are +stacked, and each is cut from `main`. -- **Stage 2A** — classified census routing + per-window painter - extraction. Branch `bottom-panel-stage2a`. No protocol change. The - three-boundary GPU split is **2B**, not 2A: it is only observable - once a band can be installed. -- **Stage 2B** — v21 protocol, daemon panel projection, GPU band, and - the negotiated `panel_capable` flip. Branch `bottom-panel-stage2b`, - cut from `main` after 2A merges. Repeats 2A's relevant census +- **Stage 2A — MERGED as #177** (`main` @ `0a3fcd1`). Classified census + routing + per-window painter extraction. Branch + `bottom-panel-stage2a`. No protocol change. The three-boundary GPU + split is **2B-3**, not 2A: it is only observable once a band can be + installed. +- **Stage 2B-1 — the v21 wire layer.** Branch `bottom-panel-stage2b`. + The four wire shapes, the version bump, the shared cell-grid + validator, and the version-ladder move. **No producer, no consumer, + no capability change** — `panel_capable` stays `false`. +- **Stage 2B-2 — the daemon panel projection and epoch machine.** Cut + from `main` after 2B-1 merges. Produces `PanelFrame` and owns + stale-event rejection, exercised through a **test-only** + panel-capable semantic view. Still no production flip. +- **Stage 2B-3 — the GPU band and the negotiated flip.** Cut from + `main` after 2B-2 merges. The three-boundary text-area split, the + divider, pointer routing, and `panel_capable = true` for a v21+ + negotiated authenticated semantic session. **This is the slice that + changes what a user sees**, and it repeats 2A's and 2B-2's relevant assertions through the real capability flip. +**Each slice runs the full gate set below, not a subset of it.** A +slice that touches only `pmacs-protocol` still runs the GPU and vterm +suites: the shared validator and the wire enums are exactly the kind of +change whose breakage surfaces in a consumer rather than at its own +definition. + Gates for both: the standing suite from `CLAUDE.md`, plus the **touched acceptance suites named explicitly** — the standing rule is to run the suites a change touches, and "standing suite" does not name them: From 7a3a55de40f0566b40f060730997dff3f813abda Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 27 Jul 2026 21:47:01 -0400 Subject: [PATCH 74/91] docs(active-work): remove the landed Lean 4 lane The ledger preamble already says Lean 4's merged lane was removed, and the durable Stage 4b facts already live in the handoff. Remove the stale section that still called Stage 4b in review so PR #182's post-merge state is internally consistent. --- docs/active-work.md | 140 -------------------------------------------- 1 file changed, 140 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 6ee50fe..83c3a51 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -178,146 +178,6 @@ If it does not, stop and repair the remote/fetch configuration. to recur; the next occurrence carries its own evidence under whoever's PR, and a Stage B framing follows then. -## Lean 4 lane (Arc 8) — Stages 1–4a MERGED; Stage 4b IN REVIEW - -- **Stages 1, 2, 3a, 3b and 4a are MERGED** — #160 (`main` @ `0827dd1`), - #161 (`46a1b8f`), #167 (`6f348c9`), #170 (`d400f30`), #179 - (`a27f646`). Their full - histories were pruned from this ledger in round 6, per this file's own - instruction to remove entries when their PR merges; the durable facts - now live in `docs/agent-handoff.md` §1's Lean 4 bullet, which is where - a fresh machine should read them. `docs/lean4-mode-framing.md` rev 9 - carries the decisions. - -### Stage 4b — the Unicode input method (branch `lean4-stage4b-input-method`) - -- Framing `docs/lean4-mode-framing.md` **revision 12** (rounds 10, 11 - and 12 = review of the implementation). Stage - 4a (the typed-edit consumer chain) MERGED as #179; this branch is 4b, - the Lean content that registers on it. -- Footprint: `scripts/regen-lean-abbrev` (new, the generator), - `builtin/runtime/lean_abbrev.lua` (new, VENDORED DATA — 1,855 entries - from `leanprover/vscode-lean4@17d1d08`, Apache-2.0), - `builtin/runtime/lean_input.lua` (new, the consumer at priority 50), - `src/editor.rs` (two `include_str!` blocks), - `tests/lean_input_acceptance.rs` (new, 31 tests), and one - `#[cfg(feature = "crdt")]` `--lib` test in `src/daemon.rs` - (acceptance 45f). No protocol change (Q#LN14). Entirely Lua apart - from the load sites and that one test. -- **Round 9 corrected three acceptance criteria that the real table - contradicts** — found by simulating the state machine over all 1,855 - entries and re-reading upstream at the pinned commit, not by reading - the prose again. `\to` is NOT eager (`top`, `to0`, `toa` extend it); - `\zzzz` expands to `ζzzz ` because `ze`/`zeta`/`zsqrtd` exist, and - only `$ % , ; @ W` open no key at all; and `\alpha`'s undo does not - restore `\alpha ` because `alpha` IS eager, so the terminator is a - separate edit. Criteria 38, 41 and 42 now state both paths. -- **Two generator bugs, both caught by its own round-trip check - failing closed:** `str.splitlines()` also splits on U+2028/U+2029, - and 53 symbols contain one literally, so the check reported a count - mismatch that was its own bug; then escaping via `chr(byte)` produced - a latin-1-shaped string that `write_text(encoding="utf-8")` - re-encoded, and every non-ASCII symbol landed double-encoded. The - first version of the check compared IN-MEMORY strings and agreed with - itself. **It now stages the file, re-reads the bytes from disk, and - renames into place only on a match.** -- **The point must be placed explicitly after the replace.** The - expansion SHRINKS the buffer (`\alpha` 6 bytes → `α` 2), so a point - left at the pre-edit offset is past the new end and every later - self-insert is silently rejected — the editor looks dead after the - first expansion. Pairing's "no cursor motion on the clean path" does - not generalize: that holds only for an insert AT the cursor. -- **Three tests were vacuous when first written and were found by - biting, not by review:** the abandonment test asserted text that a - wrongly-surviving record would also produce (claiming makes no edit — - it needed the follow-up keystroke that completes an eager key); the - re-arm test used the framing's own `\alpha\to`, which never reaches - the re-arm branch because `alpha` is eager and closes the record - first (`\al\to` does); and both buffer-switch tests passed through - `find_or_open`'s fresh-load path, which fires `buffer.after-load` and - a record-less edit rather than `buffer.after-switch` — deleting the - subscriber left them green. All three now bite. -- **Bite table** (each mutation, and the tests it fails): - - | Mutation | Tests it fails | - |---|---| - | register at priority 150 (after pairing) | 2 | - | claim only completed expansions | 2 | - | longest match instead of shortest | 9 | - | equal-length tie keeps the LATER key | 3 | - | remove the eager branch | 8 | - | expand without the terminator in the span | 2 | - | remove the re-arm branch | 1 | - | remove the point-still-at-span-end check | 1 | - | remove the exact-revision check | 1 | - | leave the point where the replace found it | 5 | - | remove the `lean4` language gate | 1 | - | remove the `lean.abbrev` gate | 2 | - | `buffer.after-switch` clears every frontend | 1 | - | delete the `buffer.after-switch` subscriber | 1 | - | `frontend.detached` purges every frontend | 1 | - | claim the terminator | 1 | - | expand inside the chain, then decline | 2 | - | drop the `cursor() == post_cursor` check | 1 | - | place the point without the context guard | 1 | - | load lean_input.lua after lsp.lua | 1 | - | let a nested fan-out consume the deferred slot | 1 | - | stop counting chain invocations | 1 | - | count fan-outs in the expander instead of the sentinel | 1 | - - Acceptance 45f bit by construction: without a registered window for - the source frontend it ran six fan-outs with a nil record and proved - nothing, because `handle_remote_crdt_op` arms nothing unless the - source's active window displays the buffer. -- **Round 10 (review) found three defects, all about what happens - AROUND the expansion rather than about resolving an abbreviation.** A - pair character that TERMINATES an abbreviation never reached pairing - (`\alp(` gave `α(`): the first revision claimed the terminator, and - merely declining is not enough either, because the chain hands each - consumer a copy of the record made before any consumer ran — so - expanding inside the chain invalidates pairing's copy and the closer - is lost anyway (verified by mutation, not assumed). The expansion now - runs on **its own `buffer.after-edit` subscriber** after the chain, - with a span that stops before the terminator. That is a new instance - of Q#AP7, so it is now pinned with the sighelp fake server. - Post-insert point motion was also mistaken for a valid span (the - relevance check needs `cursor() == post_cursor`, as pairing's has - since #110), and cursor placement could move a buffer an intercept - had switched to. -- **Round 11 found the round-10 fix incomplete in one place: - `buffer.after-edit` fan-outs NEST.** A consumer between the expander - (50) and pairing (100) that calls `pmacs.hook.run("buffer.after-edit")` - re-enters the expander's subscriber while the OUTER chain is still - mid-list; the nested pass expanded and outer pairing then resumed with - an invalidated record — `α(` again, through the chain's documented - re-entrancy seam instead of through claiming. **Deferring work past a - fan-out means owning which fan-out it belongs to.** The chain's - subscriber and the expander's each run exactly once per fan-out, so - counting the first and matching it off in the second identifies the - nesting level with no new seam in merged Stage 4a substrate. -- **Round 12 found round 11's counter in the wrong place.** It counted - invocations of the EXPANDER, which is optional: a lower-priority - consumer can claim and stop the chain before the expander runs, while - that fan-out's deferred subscriber still runs — so the nested pass - went uncounted, looked outermost, expanded early, and outer pairing - resumed with an invalidated record. The count now comes from a no-op - consumer at the MINIMUM priority, which runs first in every chain - invocation that reaches any consumer, and degrades safely: the only - thing that can skip it is a claim ahead of it, which skips the - expander too. A subscriber registered beside `run_deferred` cannot - serve — the whole nested fan-out completes inside the outer chain's - subscriber, before it would run. -- **Rounds 10–12 share a shape worth naming.** Each fix was correct - about the failure it was shown and wrong about the boundary of the - mechanism it leaned on — first the chain's copy semantics, then its - re-entrancy, then its short-circuit. **A queue that outlives the - thing that filled it has to name that thing, not approximate it.** -- Undo is cross-peer-degraded on CRDT frontends and that is ACCEPTED, - named in the module header (Q#LN21): six source-peer optimistic - inserts replaced by one daemon-peer op. `set_round_trip_input` would - fix it and also disables `dispatch_idle`, so RET would stop inserting - a newline. - ## Journey Stage 1a — PR #182 OPEN, review round 1 closed - Framing `docs/journey-stage1a-framing.md` **rev 8** (four review From b9123c2f6da56f506c23333932bef2a22eaea14d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 27 Jul 2026 22:39:15 -0400 Subject: [PATCH 75/91] test(protocol): advance touched-suite ratchets to v21 Make the statusline and Vterm Stage 3 acceptance suites track the bottom-panel v21 bump, including the real daemon and headless GPU probe. Record the full gate result and the unrelated stale directory-target assertion reproduced on canonical main. --- docs/active-work.md | 34 +++++++++++++++++++++++-- tests/statusline_segments_acceptance.rs | 15 ++++++----- tests/vterm_stage3_acceptance.rs | 6 ++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 6b03343..fb6090a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -306,12 +306,15 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 IN GATING +## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR HELD ON MAIN RATCHET Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on 2026-07-27. **Stage 2B-1 is implemented, integrated with canonical -`main`, and awaiting its full gate result before a PR is opened.** +`main`, and its full matrix has run. No PR is open: one deterministic +touched-suite assertion is stale on canonical `main` after #182 and +must be corrected separately before this branch can claim a green +gate.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `c2d56ff` by merge because review had begun. @@ -327,6 +330,33 @@ revision 5's three-way split of 2B was explicitly approved on `9b364ad`: `PanelFrame` now identifies its buffer, the transport ratchet covers the actual attach path rather than a detached codec assertion, and shared grid bounds have one validator. +- **The full gate found and corrected two 2B-1 omissions:** the + statusline version ladder still pinned v20/rejected v21, and Vterm + Stage 3 pinned v20 both structurally and in its real headless probe. + Those ratchets now expect v21 and, where applicable, reject v22. +- **Green evidence on the corrected tree:** formatting and strict + workspace Clippy; library **1,849 passed + 3 ignored default** and + **2,034 passed + 4 ignored CRDT**; bottom-panel Stage 1 / 2A / 2B-1 + **46 / 17 / 15**; folding Stage 2 **48**; GPU font **11**; statusline + **8 CRDT**; m11_5 semantic **2 CRDT**; Vterm Stages 1 / 2 / 3 + **10 / 6 / 9 CRDT**, with Stage 3's real daemon + PTY + wgpu probe + required and green; M4 **121 passed + 3 ignored + 1 filtered**; + required GPU **202**; and the isolated-config, one-invocation full + workspace sweep green on rerun. Its first pass hit the known + completion-before-supersede race in + `m8_1_acceptance::read_dir_supersede_cancels_in_flight_predecessor`; + the exact pin, its full 10-test target, and the complete workspace + rerun all passed. +- **Sole deterministic red — reproduced unchanged on canonical + `main`:** `gpu_initial_target_acceptance` is **13/14**, failing + `malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon`. + Its invalid-target table still includes `"."` and demands only a + failure result, while #182 deliberately made a directory target valid + and therefore sends the result plus snapshot. The identical failure + reproduces at the tree-identical #182 head `7a3a55d`; 2B-1 changes no + initial-target behavior. Correct this as a Journey/GPU-initial-target + ratchet side quest on `main`, then integrate it here and rerun that + touched gate before opening the 2B-1 PR. - **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine belongs to 2B-2; the GPU band and negotiated capability flip belong diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 120ca23..2f5cfd9 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -786,15 +786,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() { #[test] fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() { // Vterm Stage 3 appended the terminal family as v19; GPU initial targets - // appended the semantic bootstrap family as v20. This acceptance owns the - // STATUSLINE variant's placement and gate, so it tracks the current wire - // version rather than pinning 18: the v18 floor it actually cares about is - // asserted below and in `peer_accepts_statusline_message`. - assert_eq!(PROTOCOL_VERSION, 20); - for version in 6..=20 { + // appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1 + // appended the panel family as v21. This acceptance owns the STATUSLINE + // variant's placement and gate, so it tracks the current wire version + // rather than pinning 18: the v18 floor it actually cares about is asserted + // below and in `peer_accepts_statusline_message`. + assert_eq!(PROTOCOL_VERSION, 21); + for version in 6..=21 { assert!(is_supported_protocol_version(version)); } - assert!(!is_supported_protocol_version(21)); + assert!(!is_supported_protocol_version(22)); let sample = InstanceMessage::StatuslineSegments { buffer_id: BufferId::from_raw(9), left: vec![StatuslineSegment { diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index b7c2e9c..f729aba 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -716,8 +716,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { assert_eq!( facts.get("server_protocol_version").copied(), - Some("20"), - "the real daemon negotiated v20 with the real client: {text}" + Some("21"), + "the real daemon negotiated v21 with the real client: {text}" ); assert_eq!( facts.get("entered_terminal_mode").copied(), @@ -846,7 +846,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() { panic!("timed out waiting for {what}"); } - assert_eq!(PROTOCOL_VERSION, 20); + assert_eq!(PROTOCOL_VERSION, 21); let daemon = common::daemon::TestDaemon::spawn_with_env_and_init( &[ ("PMACS_INSTANCE_SEMANTIC_RENDER", "1"), From 486ce167480ba8ad34c2757cc3b76d33cc2e8e9e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 27 Jul 2026 22:53:36 -0400 Subject: [PATCH 76/91] test(journey): ratchet directory GPU bootstrap success Replace the stale directory-negative in the GPU initial-target suite with an explicit snapshot-first readiness path, while retaining all genuinely malformed and unloadable failure cases. Record the portable side-quest and bottom-panel dependency state. --- docs/active-work.md | 158 ++++++++++++++--------------- tests/gpu_invocation_acceptance.rs | 22 +++- 2 files changed, 98 insertions(+), 82 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 83c3a51..f785bfb 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-26.** This file records volatile work that has not +**Snapshot: 2026-07-27.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -27,19 +27,12 @@ landed regardless of what a lane says. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `42025e4` (Lean 4 Stage 4b #181, atop the dired - Stage 1 landed docs #169 and the PTY-terminate diagnostic #176, - terminal copy mode #178, the GPU-terminal-input landed docs #168, Lean - 4 Stage 4a #179, bottom-panel Stage 2A #177, the bottom-panel Stage 2 - framing #175, terminal configuration Stage 1 #173, Lean 4 Stage 3b - #170, Stage 3a #167, the CRDT undo repro #157, the inline-math - landed-doc refresh #172, the bottom-panel landed-doc refresh #156, the - inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix - #166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163, - find-file #162, Lean 4 Stage 1 #160, and the minimap blank-slab fix - #159; protocol v20). The previous snapshot named `74301d1`, and **the - recovery floor advances with it**: the check below now requires - `42025e4` or newer, so a tree at `74301d1` no longer passes. That is + `githubsucks/main` @ `c2d56ff` (Journey Stage 1a #182, which + incorporated terminal configuration + copy mode #180, atop Lean 4 + Stage 4b #181 and the previously recorded landed work; protocol v20). + The previous snapshot named `42025e4`, and **the recovery floor + advances with it**: the check below now requires `c2d56ff` or newer, + so a tree at `42025e4` no longer passes. That is deliberate — the floor moves with the base, because a check that accepts an older commit than the declared base passes on a tree the rest of this file does not describe. @@ -78,7 +71,7 @@ git worktree list git status --short --branch ``` -The `git log` command must expose `42025e4` — the base named above — or a +The `git log` command must expose `c2d56ff` — the base named above — or a newer intentional main. Keep this threshold and the canonical-base line in step: a recovery check that accepts an older commit than the base it declares canonical will pass on a tree the rest of this file does not @@ -178,50 +171,29 @@ If it does not, stop and repair the remote/fetch configuration. to recur; the next occurrence carries its own evidence under whoever's PR, and a Stage B framing follows then. -## Journey Stage 1a — PR #182 OPEN, review round 1 closed +## Journey/GPU directory-target ratchet — IN GATING, no PR -- Framing `docs/journey-stage1a-framing.md` **rev 8** (four review - rounds, two correction revisions found during implementation, one from - review round 1 of PR #182). - Branch `journey-stage1a-directory-open`, based on `githubsucks/main` - @ `42025e4` (rebased onto `74301d1`, then integrated `42025e4` and the - landed-docs work below by merge — the branch is under review, so its - history is no longer rewritten). -- Recovery: `git fetch githubsucks && git checkout - journey-stage1a-directory-open`. Everything below is committed and - pushed; nothing depends on a worktree or `/tmp`. -- **Ships:** the directory arm on `resolve_target_buffer`, - `EditorState::open` rewritten as a caller of it (the unification), the - `path.open-directory` chain + `pmacs.path.directory_handler` fallback - slot, `pmacs.window.commit_to` with its scoped frontend and preflight, - the nonconstructible destination userdata, the daemon bootstrap arm, - and `tests/journey_acceptance.rs` (**24 pins** as of rev 8 — a count, - not a constant; re-read it rather than quoting this line). No protocol - change — still v20. -- **Doc updates ride the PR** per COHERENCE §25: §2 grade + step-3 - verdict row, §20 Priority 1 + the arc list, the GPU initial-target - framing's Q#GT6 / acceptance 10 supersession, handoff §1. -- **Bite results** (each mutation run against the full suite): scope - stops swapping `core.active_frontend` → N6a + P3 fail, nothing else; - preflight moved after the callback → P1 + P2 fail, nothing else; drop - the `ScopedFrontend` arm from `acting_frontend` → N4b fails, nothing - else. That last mutation is why N4b exists — it left N4 green. - Round 1 of PR #182 added two more: dired's `display` back to - `switch_buffer`, and `prev` read from the ambient window → each fails - **N4c** alone. **The scope pins the frontend, not the window** — every - routing pin before N4c varied frontend identity and none varied the - selected window within one frontend, so 23 green pins missed it. -- Ordering: PR #177 MERGED (2026-07-26), so 1a was unblocked. 1a lands - before dired Stage 2. When 1a lands, Stage 2 must re-scout and revise - its framing around the scoped `pmacs.window.commit_to` boundary before - its implementation branch is cut. That revision is a prerequisite, not - a review-time discovery. -- **Named deferrals carried out of this stage:** dired's *interactive* - paths (`C-x d`, tree descent, refresh) still rely on the ambient - frontend a tick later and are not migrated onto captured destinations; - the stale startup scratch buffer is still not removed (only the false - doc comment is corrected); `resolve_target_buffer`'s directory arm has - no picker, only the chain that leaves room for one. +- **Approved correction, not new product behavior.** GPU initial-target + framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make + a directory target a success; `COHERENCE.md` §2 treats that path as a + protected journey. The stale GPU integration test still listed `"."` + among malformed/unloadable targets after #182 landed. +- Branch `journey-gpu-directory-ratchet`, based directly on canonical + `main` @ `c2d56ff`. Recovery: `git fetch githubsucks && git checkout + journey-gpu-directory-ratchet`. Everything described here is + committed and pushed; nothing depends on the `/tmp` worktree. +- **Scope is one acceptance ratchet:** remove `"."` from the four + genuinely invalid cases and add a transport-level positive which + requires snapshot-first + `InitialTargetResult::Opened` for `"."`, + then proves the same daemon can open a following file target. No + production source, protocol, framing decision, or coherence grade + changes. +- Touched suite is **15/15 CRDT** after building its documented + `pmacs-gpu` prerequisite. Full standing gates are pending. Initial + fresh-worktree attempts without that binary and without out-of-sandbox + Unix-socket permission were setup failures, not product evidence. +- This side quest lands before bottom-panel 2B-1 opens its PR. After it + merges, 2B-1 integrates the new `main` and reruns its full matrix. ## The CRDT half of the test corpus is dark in CI — NEEDS A LANE @@ -358,10 +330,43 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — Stages 1, 2A + framing MERGED; 2B is next +## Bottom-panel lane (Arc 7) — 2B-1 GATED; waiting on ratchet side quest -Stage 1, the Stage 2 framing, and Stage 2A are all on `main`. **Stage 2B -has not started.** +Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing +revision 5's three-way split of 2B was explicitly approved on +2026-07-27. **Stage 2B-1 is implemented, integrated with canonical +`main`, and pushed at `b9123c2`; no PR is open.** Its own omissions found +by gating are corrected. The Journey/GPU ratchet lane above is its sole +remaining dependency. + +- **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on + `githubsucks/main` @ `c2d56ff` by merge because review had begun. + Recovery: `git fetch githubsucks && git checkout + bottom-panel-stage2b`. Everything is committed and pushed; nothing + depends on a worktree or `/tmp`. +- **Ships only the v21 wire layer:** the four wire shapes, version bump, + shared cell-grid validator, and version-ladder move. It has no + producer, consumer, or capability change; `panel_capable` stays + `false`, so this slice changes no user-visible journey grade. +- **Review round 1 closed:** two P1s and one P2, all corrected at + `9b364ad`: `PanelFrame` now identifies its buffer, the transport + ratchet covers the actual attach path rather than a detached codec + assertion, and shared grid bounds have one validator. +- **The full gate found and corrected two further 2B-1 omissions at + `b9123c2`:** the statusline version ladder still pinned v20/rejected + v21, and Vterm Stage 3 pinned v20 both structurally and in its real + headless probe. Those ratchets now expect v21 and, where applicable, + reject v22. +- **Green evidence except for the main-side stale ratchet this side + quest owns:** formatting and strict Clippy; default/CRDT libraries; + every bottom-panel, folding, GPU-font, statusline, semantic, Vterm, + M4, and required-GPU gate; and the isolated-config full-workspace + sweep. The exact counts and the classified M8 timing rerun live at + `b9123c2`. +- **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 + lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine + belongs to 2B-2; the GPU band and negotiated capability flip belong + to 2B-3. - **Stage 2A MERGED as #177** (`main` @ `0a3fcd1`, 2026-07-26, all twelve checks green at `8424172`, three review rounds). Branch @@ -374,19 +379,7 @@ has not started.** `primary_document_window` returns `view.active` in every existing configuration, so this is seam adoption that becomes load-bearing in 2B. -- **Stage 2B is approved and unstarted.** It branches from `main`, **not - stacked on 2A**, per the framing §9. Scope: protocol v21, the daemon - panel projection, the GPU band, and the negotiated `panel_capable` - flip. `docs/bottom-panel-stage2-framing.md` §7.2 carries its five - acceptance criteria (A2B-1..5) plus the reassertion of parent - criterion 52, and §8 records **no open items**, so 2B needs no further - framing round. Its sharpest trap is §5.3's three-boundary split: the - GPU `text_area_bottom` is `status_band_top`, - `geometry_capacity_bottom` and `document_text_bottom` at once, and a - blanket rewrite moves the status chrome along with the document while - still satisfying an "everything moved" assertion — hence A2B-4's - contrast form. -- Verification on the merge result: `cargo fmt --check` clean; strict +- **Stage 2A verification on its merge result:** `cargo fmt --check` clean; strict workspace Clippy clean; **1,832 default + 2,015 CRDT** library tests; `bottom_panel_stage2a_acceptance` **17**; bottom-panel Stage 1 46; statusline segments 8 CRDT; m11_5 semantic 2 CRDT; GPU initial target @@ -442,26 +435,29 @@ has not started.** `docs/agent-handoff.md` §1; the two round lessons are in §5. - Landed-docs follow-up merged as **#156** (`main` @ `d152120`, 2026-07-25). -- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 4**, +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 5**, on branch `githubsucks/bottom-panel-stage2-framing` (three commits, - one per revision), worktree `../pmacs-bp-stage2`, based on + one per pre-implementation revision), worktree `../pmacs-bp-stage2`, based on `githubsucks/main` @ `ccf29e3`. Round 1 closed 2 blocking + 3 high; round 2 closed 1 blocking + 2 high + 1 medium and decided both open items; round 3 closed 1 blocking + 1 high + 1 medium. No open items - remain. The approved + remain. Revision 5 adds no decision; it records the approved + 2B-1/2B-2/2B-3 implementation split. The parent framing `docs/bottom-panel-framing.md` (rev 4) remains authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree `../pmacs-bottom-panel`. -- **Stage 2 ships as two serial slices**, 2A landing before 2B branches: +- **Stage 2 ships as four serial implementation slices**, each landing + before the next branches: **2A** = classified §1.3 census routing + `paint_frame` per-window painter extraction (with the active-window auto-scroll preparation), no - protocol change; **2B** = protocol **v21** + protocol change; **2B-1** = protocol **v21** (`InstanceMessage::PanelFrame` plus `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`, gated both directions, each extended enum byte-pinned on its own - previous final variant), daemon panel projection, the GPU band, and the - negotiated `panel_capable` flip. Stage 3 is the adopter default flip. + previous final variant); **2B-2** = daemon panel projection and epoch + machine; **2B-3** = the GPU band and negotiated `panel_capable` flip. + Stage 3 is the adopter default flip. - **Correction — this entry previously mis-stated the census contract.** It is **not** "route every consumer through `primary_document_window`". Q#BP14 classifies the 23 reads into four classes and routes only the diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 5371438..91203de 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -662,6 +662,27 @@ mod crdt { assert!(probe.close().success()); } + #[test] + fn directory_target_reaches_ready_and_leaves_the_daemon_usable() { + let temp = secure_tempdir(); + let socket = temp.path().join("directory-target.sock"); + let mut daemon = spawn_daemon(&socket, &[]); + + // Journey Stage 1a superseded the old IsADirectory failure: + // `attach_target` requires the production snapshot-first sequence + // followed by `InitialTargetResult::Opened`. + let directory = attach_target(&socket, temp.path(), Path::new(".")); + + fs::write(temp.path().join("still-alive.txt"), "alive\n").expect("write survivor"); + let survivor = attach_target(&socket, temp.path(), Path::new("still-alive.txt")); + assert_eq!(survivor.replica.materialize_string(), "alive\n"); + + drop(directory); + drop(survivor); + signal_pid(daemon.id(), Signal::SIGTERM); + assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success()); + } + #[test] fn malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon() { let temp = secure_tempdir(); @@ -673,7 +694,6 @@ mod crdt { (cwd.clone(), Vec::new()), (cwd.clone(), b"bad\0name".to_vec()), (cwd.clone(), vec![b'x'; 32 * 1024 + 1]), - (cwd.clone(), b".".to_vec()), ]; for (index, (bad_cwd, bad_path)) in invalid.into_iter().enumerate() { let (frontend_id, mut stream, messages) = open_raw_target(&socket, bad_cwd, bad_path); From a5107ca32db2e406d3022769011c8fe04fe4d63e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 27 Jul 2026 23:15:10 -0400 Subject: [PATCH 77/91] docs(active-work): record directory ratchet gates Capture the complete green gate matrix and retain the diagnosed setup and transient full-sweep failures so the lane remains recoverable and the evidence is not flattened into an unexplained rerun. --- docs/active-work.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index f785bfb..03cb487 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -171,7 +171,7 @@ If it does not, stop and repair the remote/fetch configuration. to recur; the next occurrence carries its own evidence under whoever's PR, and a Stage B framing follows then. -## Journey/GPU directory-target ratchet — IN GATING, no PR +## Journey/GPU directory-target ratchet — GATED, PR PENDING - **Approved correction, not new product behavior.** GPU initial-target framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make @@ -188,10 +188,29 @@ If it does not, stop and repair the remote/fetch configuration. then proves the same daemon can open a following file target. No production source, protocol, framing decision, or coherence grade changes. -- Touched suite is **15/15 CRDT** after building its documented - `pmacs-gpu` prerequisite. Full standing gates are pending. Initial - fresh-worktree attempts without that binary and without out-of-sandbox +- **Full gate matrix is green at `486ce16`:** + - `cargo fmt --check`; + - strict workspace clippy; + - library **1,849 passed / 3 ignored**; + - CRDT library **2,034 passed / 4 ignored**; + - touched GPU initial-target suite **15/15**; + - connected Journey/Dired/find-file/theme/bottom-panel acceptance + suites **125/125**; + - M4 **121 passed / 3 ignored / 1 filtered**; + - required GPU package **202/202**; + - `git diff --check`; + - isolated-config, one-invocation full workspace sweep green on its + final run. +- **Gate diagnostics retained:** initial fresh-worktree attempts without + the documented `pmacs-gpu` prerequisite and without out-of-sandbox Unix-socket permission were setup failures, not product evidence. + The first full-workspace attempt then exhausted the `/tmp` filesystem + quota while linking. Moving only the disposable Cargo target to disk + let the sweep run; its first completed pass exposed one transient + `pmacs-gpu` font-facts unit-test red after the standalone GPU gate had + passed. The exact test passed immediately against the identical build, + and the required complete one-invocation rerun was green, including + GPU **202/202**. - This side quest lands before bottom-panel 2B-1 opens its PR. After it merges, 2B-1 integrates the new `main` and reruns its full matrix. From c2b855e252d7bf2f81288a76a89525eca49b89d8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 27 Jul 2026 23:19:05 -0400 Subject: [PATCH 78/91] docs(active-work): record directory ratchet PR Attach the gated Journey/GPU side-quest lane to PR #183 and make its intentional open, unmerged review state explicit. --- docs/active-work.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index 03cb487..735bc9c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -171,7 +171,7 @@ If it does not, stop and repair the remote/fetch configuration. to recur; the next occurrence carries its own evidence under whoever's PR, and a Stage B framing follows then. -## Journey/GPU directory-target ratchet — GATED, PR PENDING +## Journey/GPU directory-target ratchet — PR #183 OPEN, GATED - **Approved correction, not new product behavior.** GPU initial-target framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make @@ -182,6 +182,9 @@ If it does not, stop and repair the remote/fetch configuration. `main` @ `c2d56ff`. Recovery: `git fetch githubsucks && git checkout journey-gpu-directory-ratchet`. Everything described here is committed and pushed; nothing depends on the `/tmp` worktree. +- PR #183: + . It is intentionally + open and unmerged pending user review. - **Scope is one acceptance ratchet:** remove `"."` from the four genuinely invalid cases and add a transport-level positive which requires snapshot-first + `InitialTargetResult::Opened` for `"."`, From ec4191fd8efe965b4c70472951290ff447f08ec2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 09:48:48 -0400 Subject: [PATCH 79/91] test(journey): pin post-quiescence GPU dired surface Consume the directory session's later replacement snapshot and assert the canonical dired header plus a known listing entry before checking daemon reuse. Correct the bottom-panel revision-5 recovery branch and advance the durable handoff to the Journey Stage 1a main anchor. --- docs/active-work.md | 32 ++++++++++++------ docs/agent-handoff.md | 13 ++++--- tests/gpu_invocation_acceptance.rs | 54 ++++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 735bc9c..676de4f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-27.** This file records volatile work that has not +**Snapshot: 2026-07-28.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -171,7 +171,7 @@ If it does not, stop and repair the remote/fetch configuration. to recur; the next occurrence carries its own evidence under whoever's PR, and a Stage B framing follows then. -## Journey/GPU directory-target ratchet — PR #183 OPEN, GATED +## Journey/GPU directory-target ratchet — PR #183 REGATING REVIEW ROUND 1 - **Approved correction, not new product behavior.** GPU initial-target framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make @@ -188,10 +188,20 @@ If it does not, stop and repair the remote/fetch configuration. - **Scope is one acceptance ratchet:** remove `"."` from the four genuinely invalid cases and add a transport-level positive which requires snapshot-first + `InitialTargetResult::Opened` for `"."`, - then proves the same daemon can open a following file target. No - production source, protocol, framing decision, or coherence grade - changes. -- **Full gate matrix is green at `486ce16`:** + then consumes the post-quiescence replacement snapshot and requires + dired's canonical header plus a known directory entry before proving + the same daemon can open a following file target. No production + source, protocol, framing decision, or coherence grade changes. +- **Review round 1: three findings, all real and corrected.** The + first test stopped at the deliberately pre-existing bootstrap + document, so it did not pin the resolver's later dired commit. The + Stage 2 rev-5 recovery bullet named rev 4's framing branch. And the + durable handoff still named pre-Journey `main` even though this ledger + had advanced. The first is now a post-quiescence transport assertion; + the latter two are corrected in this revision. The touched suite is + green **15/15**; the full gate matrix is being rerun before this round + closes. +- **Pre-review full gate matrix was green at `486ce16`:** - `cargo fmt --check`; - strict workspace clippy; - library **1,849 passed / 3 ignored**; @@ -457,10 +467,12 @@ remaining dependency. `docs/agent-handoff.md` §1; the two round lessons are in §5. - Landed-docs follow-up merged as **#156** (`main` @ `d152120`, 2026-07-25). -- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 5**, - on branch `githubsucks/bottom-panel-stage2-framing` (three commits, - one per pre-implementation revision), worktree `../pmacs-bp-stage2`, based on - `githubsucks/main` @ `ccf29e3`. Round 1 closed 2 blocking + 3 high; +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 5** + is commit `56301ed` on branch `githubsucks/bottom-panel-stage2b`, + worktree `../pmacs-bp-stage2b`. Revisions 1–4 remain on + `githubsucks/bottom-panel-stage2-framing` (head `4fbd47f`, four + framing commits, revision 4 at `49757e5`). Round 1 closed 2 blocking + + 3 high; round 2 closed 1 blocking + 2 high + 1 medium and decided both open items; round 3 closed 1 blocking + 1 high + 1 medium. No open items remain. Revision 5 adds no decision; it records the approved diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 6607df6..47f7dce 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,6 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-26, after terminal copy mode (#178) — `C-c C-t` +**Last updated: 2026-07-28, after Journey Stage 1a (#182), which made +directory startup one coherent local/daemon/GPU path and incorporated +the terminal configuration + copy mode landed-doc work (#180); following +terminal copy mode (#178) — `C-c C-t` materializes a terminal's whole retained range into an ordinary buffer, plus `Buffer::set_generated_contents`, the first genuinely immutable generated-buffer write path — and its landed-doc pair (#168); following @@ -39,10 +42,12 @@ 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-07-26) +## 1. Where the project stands (2026-07-28) -- `main` @ `42025e4` (Lean 4 Stage 4b #181, atop the dired Stage 1 - landed docs #169 and the PTY-terminate diagnostic #176, terminal copy +- `main` @ `c2d56ff` (Journey Stage 1a #182, incorporating terminal + configuration + copy mode landed docs #180, atop Lean 4 Stage 4b #181, + the dired Stage 1 landed docs #169 and the PTY-terminate diagnostic + #176, terminal copy mode #178, the GPU-terminal-input landed docs #168, Lean 4 Stage 4a #179, bottom-panel Stage 2A #177, the bottom-panel Stage 2 framing #175, terminal configuration diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 91203de..13dc020 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -223,6 +223,34 @@ mod crdt { stream: UnixStream, } + impl TargetSession { + fn wait_for_replacement_snapshot(&mut self) -> (pmacs::buffer::BufferId, String) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!( + Instant::now() < deadline, + "target frontend did not receive a replacement buffer snapshot" + ); + match read_message::(&mut self.stream) + .expect("read target frontend after bootstrap") + { + InstanceMessage::BufferSnapshot { + buffer_id, + crdt_snapshot, + } if buffer_id != self.buffer_id => { + let replica = + CrdtState::new(self.frontend_id.0).expect("replacement buffer replica"); + replica + .import_snapshot(&crdt_snapshot) + .expect("import replacement buffer snapshot"); + return (buffer_id, replica.materialize_string()); + } + _ => {} + } + } + } + } + fn attach_target(socket: &Path, cwd: &Path, path: &Path) -> TargetSession { use std::os::unix::ffi::OsStrExt; @@ -666,12 +694,34 @@ mod crdt { fn directory_target_reaches_ready_and_leaves_the_daemon_usable() { let temp = secure_tempdir(); let socket = temp.path().join("directory-target.sock"); + let listed_name = "listed-before-bootstrap.txt"; + fs::write(temp.path().join(listed_name), "listed\n").expect("write listed file"); let mut daemon = spawn_daemon(&socket, &[]); // Journey Stage 1a superseded the old IsADirectory failure: // `attach_target` requires the production snapshot-first sequence - // followed by `InitialTargetResult::Opened`. - let directory = attach_target(&socket, temp.path(), Path::new(".")); + // followed by `InitialTargetResult::Opened`. The synchronous + // snapshot is deliberately the pre-existing document; dired's + // post-await commit replaces it on a later daemon tick. + let mut directory = attach_target(&socket, temp.path(), Path::new(".")); + let bootstrap_buffer = directory.buffer_id; + let (dired_buffer, listing) = directory.wait_for_replacement_snapshot(); + assert_ne!( + dired_buffer, bootstrap_buffer, + "the asynchronous resolver must replace the bootstrap document" + ); + let canonical = fs::canonicalize(temp.path()).expect("canonical directory"); + let mut lines = listing.lines(); + let expected_header = format!("{}:", canonical.display()); + assert_eq!( + lines.next(), + Some(expected_header.as_str()), + "the replacement snapshot must be dired's directory surface:\n{listing}" + ); + assert!( + lines.any(|line| line.trim_end().ends_with(listed_name)), + "dired must list the file that existed before bootstrap:\n{listing}" + ); fs::write(temp.path().join("still-alive.txt"), "alive\n").expect("write survivor"); let survivor = attach_target(&socket, temp.path(), Path::new("still-alive.txt")); From 3be4285f7c7d6833811853b3bf82bd22c3dd7daa Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 10:01:18 -0400 Subject: [PATCH 80/91] docs(active-work): record review-round gates Close PR 183 review round 1 in the volatile ledger and record the post-correction full gate matrix at ec4191f. --- docs/active-work.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 676de4f..95b6e9b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -171,7 +171,7 @@ If it does not, stop and repair the remote/fetch configuration. to recur; the next occurrence carries its own evidence under whoever's PR, and a Stage B framing follows then. -## Journey/GPU directory-target ratchet — PR #183 REGATING REVIEW ROUND 1 +## Journey/GPU directory-target ratchet — PR #183 GATED AFTER REVIEW ROUND 1 - **Approved correction, not new product behavior.** GPU initial-target framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make @@ -198,10 +198,9 @@ If it does not, stop and repair the remote/fetch configuration. Stage 2 rev-5 recovery bullet named rev 4's framing branch. And the durable handoff still named pre-Journey `main` even though this ledger had advanced. The first is now a post-quiescence transport assertion; - the latter two are corrected in this revision. The touched suite is - green **15/15**; the full gate matrix is being rerun before this round - closes. -- **Pre-review full gate matrix was green at `486ce16`:** + the latter two are corrected in this revision. The complete matrix + below is green on the corrected tree. +- **Post-review full gate matrix is green at `ec4191f`:** - `cargo fmt --check`; - strict workspace clippy; - library **1,849 passed / 3 ignored**; @@ -217,6 +216,10 @@ If it does not, stop and repair the remote/fetch configuration. - **Gate diagnostics retained:** initial fresh-worktree attempts without the documented `pmacs-gpu` prerequisite and without out-of-sandbox Unix-socket permission were setup failures, not product evidence. + During the review rerun, an in-sandbox library attempt reproduced the + latter setup failure as `EPERM` in three attach socket tests; the + authoritative out-of-sandbox rerun passed all **1,849** non-ignored + tests. The first full-workspace attempt then exhausted the `/tmp` filesystem quota while linking. Moving only the disposable Cargo target to disk let the sweep run; its first completed pass exposed one transient From 34b8f28cf2ff42d0323c256c5fcce019df7a23ef Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 10:27:22 -0400 Subject: [PATCH 81/91] test(journey): ratchet the public GPU directory path Drive pmacs --gpu . through the root broker and real managed GPU connector, keep the session alive through the asynchronous dired replacement, and assert its canonical listing before daemon reuse. Expose snapshot count and materialized text through the private display-less acceptance probe so the public path is observable. --- docs/active-work.md | 25 ++++-- pmacs-gpu/src/main.rs | 101 ++++++++++++++++++++---- tests/gpu_invocation_acceptance.rs | 121 ++++++++++++++++------------- 3 files changed, 172 insertions(+), 75 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 95b6e9b..2525cc3 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -171,7 +171,7 @@ If it does not, stop and repair the remote/fetch configuration. to recur; the next occurrence carries its own evidence under whoever's PR, and a Stage B framing follows then. -## Journey/GPU directory-target ratchet — PR #183 GATED AFTER REVIEW ROUND 1 +## Journey/GPU directory-target ratchet — PR #183 REGATING PUBLIC PATH - **Approved correction, not new product behavior.** GPU initial-target framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make @@ -186,12 +186,15 @@ If it does not, stop and repair the remote/fetch configuration. . It is intentionally open and unmerged pending user review. - **Scope is one acceptance ratchet:** remove `"."` from the four - genuinely invalid cases and add a transport-level positive which - requires snapshot-first + `InitialTargetResult::Opened` for `"."`, - then consumes the post-quiescence replacement snapshot and requires - dired's canonical header plus a known directory entry before proving - the same daemon can open a following file target. No production - source, protocol, framing decision, or coherence grade changes. + genuinely invalid cases and drive the public `pmacs --gpu .` root + broker through the real managed GPU connector. The positive requires + snapshot-first + `InitialTargetResult::Opened`, then consumes the + post-quiescence replacement snapshot and requires dired's canonical + header plus a known directory entry before proving the same daemon can + open a following file target. The private display-less acceptance + probe now reports its snapshot count and final materialized text so + that public path is observable. No normal frontend/daemon behavior, + protocol, framing decision, or coherence grade changes. - **Review round 1: three findings, all real and corrected.** The first test stopped at the deliberately pre-existing bootstrap document, so it did not pin the resolver's later dired commit. The @@ -200,6 +203,14 @@ If it does not, stop and repair the remote/fetch configuration. had advanced. The first is now a post-quiescence transport assertion; the latter two are corrected in this revision. The complete matrix below is green on the corrected tree. +- **Live public-path check tightened that correction further.** A user + report that `pmacs --gpu .` differed from `pmacs .` did not reproduce: + the live default daemon delivered both snapshots, and a traced + windowed invocation applied both and displayed dired. It nevertheless + exposed that the corrected acceptance still attached a raw protocol + client rather than invoking the public root broker and real GPU + connector. The test now covers those surfaces and passes **15/15**; + its full matrix is being rerun before this revision is pushed. - **Post-review full gate matrix is green at `ec4191f`:** - `cargo fmt --check`; - strict workspace clippy; diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a26f340..86132fc 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -936,10 +936,20 @@ fn run_headless_managed_probe( } }; let mut client = managed.client; + let initial_message = client.take_initial_message(); let initial_target_ready = matches!( - client.take_initial_message(), + initial_message.as_ref(), Some(InstanceMessage::BufferSnapshot { .. }) ); + let mut buffer_facts = ManagedProbeBufferFacts::default(); + if let Some(message) = initial_message.as_ref() + && let Err(error) = buffer_facts.observe(message) + { + let contents = format!("phase=error\nerror={error}\n"); + let _ = write_probe_report(report, &contents); + eprintln!("pmacs-gpu managed probe: {error}"); + return 7; + } let daemon = managed.daemon; let protocol = client.server_protocol_version(); @@ -961,8 +971,14 @@ fn run_headless_managed_probe( let mut last_wait_result = None; let mut last_disconnect = String::new(); if ready - && let Err(error) = - write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) + && let Err(error) = write_managed_probe_report( + report, + "ready", + protocol, + &daemon, + &buffer_facts, + &disconnect, + ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", @@ -976,11 +992,23 @@ fn run_headless_managed_probe( } match event_rx.recv_timeout(Duration::from_millis(50)) { Ok(AttachEvent::Message(message)) => { - if matches!(*message, InstanceMessage::BufferSnapshot { .. }) && !ready { + let is_snapshot = matches!(*message, InstanceMessage::BufferSnapshot { .. }); + if let Err(error) = buffer_facts.observe(&message) { + let contents = format!("phase=error\nerror={error}\n"); + let _ = write_probe_report(report, &contents); + eprintln!("pmacs-gpu managed probe: {error}"); + return 7; + } + if is_snapshot { ready = true; - if let Err(error) = - write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) - { + if let Err(error) = write_managed_probe_report( + report, + "ready", + protocol, + &daemon, + &buffer_facts, + &disconnect, + ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() @@ -1006,9 +1034,14 @@ fn run_headless_managed_probe( || wait_result != last_wait_result || disconnect != last_disconnect) { - if let Err(error) = - write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect) - { + if let Err(error) = write_managed_probe_report( + report, + "ready", + protocol, + &daemon, + &buffer_facts, + &disconnect, + ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() @@ -1021,9 +1054,14 @@ fn run_headless_managed_probe( } if ready && stdin_closed { - if let Err(error) = - write_managed_probe_report(report, "complete", protocol, &daemon, &disconnect) - { + if let Err(error) = write_managed_probe_report( + report, + "complete", + protocol, + &daemon, + &buffer_facts, + &disconnect, + ) { eprintln!( "pmacs-gpu managed probe: writing {} failed: {error}", report.display() @@ -1043,11 +1081,42 @@ fn run_headless_managed_probe( } } +#[derive(Default)] +struct ManagedProbeBufferFacts { + snapshots: u32, + last_snapshot_text: String, +} + +impl ManagedProbeBufferFacts { + fn observe(&mut self, message: &InstanceMessage) -> Result<(), String> { + let InstanceMessage::BufferSnapshot { crdt_snapshot, .. } = message else { + return Ok(()); + }; + let doc = loro::LoroDoc::new(); + doc.import(crdt_snapshot) + .map_err(|error| format!("BufferSnapshot import failed: {error:?}"))?; + self.snapshots += 1; + self.last_snapshot_text = doc.get_text(LORO_TEXT_CONTAINER).to_string(); + Ok(()) + } +} + +fn hex_bytes(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(encoded, "{byte:02x}"); + } + encoded +} + fn write_managed_probe_report( report: &Path, phase: &str, protocol: u32, daemon: &attach::ManagedDaemonFacts, + buffer_facts: &ManagedProbeBufferFacts, disconnect: &str, ) -> std::io::Result<()> { use std::fmt::Write as _; @@ -1056,6 +1125,12 @@ fn write_managed_probe_report( let _ = writeln!(out, "phase={phase}"); let _ = writeln!(out, "server_protocol_version={protocol}"); let _ = writeln!(out, "buffer_snapshot=true"); + let _ = writeln!(out, "buffer_snapshots={}", buffer_facts.snapshots); + let _ = writeln!( + out, + "last_snapshot_hex={}", + hex_bytes(buffer_facts.last_snapshot_text.as_bytes()) + ); let _ = writeln!(out, "spawned_daemon={}", daemon.spawned_daemon()); let _ = writeln!( out, diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index 13dc020..ec6be89 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -121,6 +121,19 @@ mod crdt { .collect() } + fn decode_hex(encoded: &str) -> String { + assert_eq!(encoded.len() % 2, 0, "hex payload must have even length"); + let bytes = encoded + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let pair = std::str::from_utf8(pair).expect("hex pair is UTF-8"); + u8::from_str_radix(pair, 16).expect("decode hex pair") + }) + .collect::>(); + String::from_utf8(bytes).expect("snapshot text is UTF-8") + } + fn wait_for_fact( report: &Path, key: &str, @@ -223,34 +236,6 @@ mod crdt { stream: UnixStream, } - impl TargetSession { - fn wait_for_replacement_snapshot(&mut self) -> (pmacs::buffer::BufferId, String) { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - assert!( - Instant::now() < deadline, - "target frontend did not receive a replacement buffer snapshot" - ); - match read_message::(&mut self.stream) - .expect("read target frontend after bootstrap") - { - InstanceMessage::BufferSnapshot { - buffer_id, - crdt_snapshot, - } if buffer_id != self.buffer_id => { - let replica = - CrdtState::new(self.frontend_id.0).expect("replacement buffer replica"); - replica - .import_snapshot(&crdt_snapshot) - .expect("import replacement buffer snapshot"); - return (buffer_id, replica.materialize_string()); - } - _ => {} - } - } - } - } - fn attach_target(socket: &Path, cwd: &Path, path: &Path) -> TargetSession { use std::os::unix::ffi::OsStrExt; @@ -384,6 +369,16 @@ mod crdt { } impl ManagedProbe { + fn from_child(mut child: Child, report: &Path) -> Self { + let stdin = child.stdin.take().expect("probe stdin"); + Self { + child, + stdin: Some(stdin), + report: report.to_owned(), + daemon_pid: None, + } + } + fn spawn(socket: &Path, report: &Path, daemon_executable: &Path, home: &Path) -> Self { Self::spawn_with_env(socket, report, daemon_executable, home, &[]) } @@ -446,18 +441,11 @@ mod crdt { for (key, value) in envs { command.env(key, value); } - let mut child = command.spawn().expect("spawn managed probe"); - let stdin = child.stdin.take().expect("probe stdin"); - Self { - child, - stdin: Some(stdin), - report: report.to_owned(), - daemon_pid: None, - } + Self::from_child(command.spawn().expect("spawn managed probe"), report) } - fn wait_ready(&mut self) -> HashMap { - let facts = wait_for_fact(&self.report, "phase", "ready", Duration::from_secs(10)); + fn wait_for(&mut self, key: &str, expected: &str) -> HashMap { + let facts = wait_for_fact(&self.report, key, expected, Duration::from_secs(10)); if facts .get("spawned_daemon") .is_some_and(|value| value == "true") @@ -467,6 +455,10 @@ mod crdt { facts } + fn wait_ready(&mut self) -> HashMap { + self.wait_for("phase", "ready") + } + fn close(mut self) -> std::process::ExitStatus { self.stdin.take(); wait_for_fact(&self.report, "phase", "complete", Duration::from_secs(5)); @@ -691,25 +683,46 @@ mod crdt { } #[test] - fn directory_target_reaches_ready_and_leaves_the_daemon_usable() { + fn public_gpu_directory_target_reaches_dired_and_leaves_the_daemon_usable() { let temp = secure_tempdir(); let socket = temp.path().join("directory-target.sock"); + let report = temp.path().join("directory-target-report"); + let wrapper = temp.path().join("headless-gpu"); let listed_name = "listed-before-bootstrap.txt"; fs::write(temp.path().join(listed_name), "listed\n").expect("write listed file"); - let mut daemon = spawn_daemon(&socket, &[]); - - // Journey Stage 1a superseded the old IsADirectory failure: - // `attach_target` requires the production snapshot-first sequence - // followed by `InitialTargetResult::Opened`. The synchronous - // snapshot is deliberately the pre-existing document; dired's - // post-await commit replaces it on a later daemon tick. - let mut directory = attach_target(&socket, temp.path(), Path::new(".")); - let bootstrap_buffer = directory.buffer_id; - let (dired_buffer, listing) = directory.wait_for_replacement_snapshot(); - assert_ne!( - dired_buffer, bootstrap_buffer, - "the asynchronous resolver must replace the bootstrap document" + write_script( + &wrapper, + "test \"$1\" = \"--managed-attach\"\n\ + socket=$2\n\ + daemon=$3\n\ + shift 3\n\ + exec \"$PMACS_REAL_GPU\" --headless-managed-probe \ + \"$socket\" \"$PMACS_TEST_REPORT\" \"$daemon\" \"$@\"", ); + + let mut command = Command::new(pmacs_binary()); + command + .args(["--gpu", "--socket"]) + .arg(&socket) + .arg(".") + .current_dir(temp.path()) + .env(TEST_GPU_OVERRIDE, &wrapper) + .env("PMACS_REAL_GPU", gpu_binary()) + .env("PMACS_TEST_REPORT", &report) + .env("HOME", temp.path()) + .env("XDG_CONFIG_HOME", temp.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut directory = + ManagedProbe::from_child(command.spawn().expect("spawn public GPU command"), &report); + + // The public root broker and real managed GPU connector must stay + // alive through Journey N2's asynchronous dired commit. Snapshot + // one is the deliberately pre-existing bootstrap document; snapshot + // two is the post-quiescence directory surface. + let facts = directory.wait_for("buffer_snapshots", "2"); + let listing = decode_hex(&facts["last_snapshot_hex"]); let canonical = fs::canonicalize(temp.path()).expect("canonical directory"); let mut lines = listing.lines(); let expected_header = format!("{}:", canonical.display()); @@ -727,10 +740,8 @@ mod crdt { let survivor = attach_target(&socket, temp.path(), Path::new("still-alive.txt")); assert_eq!(survivor.replica.materialize_string(), "alive\n"); - drop(directory); drop(survivor); - signal_pid(daemon.id(), Signal::SIGTERM); - assert!(wait_for_exit(&mut daemon, Duration::from_secs(5)).success()); + assert!(directory.close().success()); } #[test] From 22dc46c184746c0886bf03efc8c1749953871796 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 10:33:26 -0400 Subject: [PATCH 82/91] docs(active-work): record public-path gates Record the green full matrix for the public pmacs --gpu directory acceptance revision at 34b8f28. --- docs/active-work.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 2525cc3..2520abe 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -171,7 +171,7 @@ If it does not, stop and repair the remote/fetch configuration. to recur; the next occurrence carries its own evidence under whoever's PR, and a Stage B framing follows then. -## Journey/GPU directory-target ratchet — PR #183 REGATING PUBLIC PATH +## Journey/GPU directory-target ratchet — PR #183 GATED ON PUBLIC PATH - **Approved correction, not new product behavior.** GPU initial-target framing Q#GT6 / acceptance 10 and Journey Stage 1a N2/N5 already make @@ -210,8 +210,8 @@ If it does not, stop and repair the remote/fetch configuration. exposed that the corrected acceptance still attached a raw protocol client rather than invoking the public root broker and real GPU connector. The test now covers those surfaces and passes **15/15**; - its full matrix is being rerun before this revision is pushed. -- **Post-review full gate matrix is green at `ec4191f`:** + its full matrix is green at `34b8f28`. +- **Full gate matrix is green on the public-path revision at `34b8f28`:** - `cargo fmt --check`; - strict workspace clippy; - library **1,849 passed / 3 ignored**; From dc2dc4262767e84ece92a29856e5a728cc02cd5a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 11:02:48 -0400 Subject: [PATCH 83/91] test(journey): retain daemon cleanup while awaiting dired Capture the managed daemon's lifecycle facts from the ready report before waiting for the post-quiescence directory snapshot. If that regression assertion times out, the acceptance harness can now terminate the daemon instead of leaving it orphaned. --- tests/gpu_invocation_acceptance.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index ec6be89..b4e3102 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -721,6 +721,13 @@ mod crdt { // alive through Journey N2's asynchronous dired commit. Snapshot // one is the deliberately pre-existing bootstrap document; snapshot // two is the post-quiescence directory surface. + // Capture the spawned daemon's PID from the ready report first so + // ManagedProbe::drop can terminate it if snapshot two never arrives. + let ready = directory.wait_ready(); + assert_eq!( + ready.get("spawned_daemon").map(String::as_str), + Some("true") + ); let facts = directory.wait_for("buffer_snapshots", "2"); let listing = decode_hex(&facts["last_snapshot_hex"]); let canonical = fs::canonicalize(temp.path()).expect("canonical directory"); From 22c1b14b18256074dd6d72e00391ba5459cb8344 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 11:03:36 -0400 Subject: [PATCH 84/91] docs(active-work): record review-round cleanup gates Record the second-pass daemon-cleanup finding, its correction, and the green verification matrix on the updated PR branch. --- docs/active-work.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 2520abe..a269a70 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -211,6 +211,22 @@ If it does not, stop and repair the remote/fetch configuration. client rather than invoking the public root broker and real GPU connector. The test now covers those surfaces and passes **15/15**; its full matrix is green at `34b8f28`. +- **Review round 2 found and fixed one failure-path cleanup gap at + `dc2dc42`.** The public-path test waited directly for dired's second + snapshot, but `ManagedProbe` only retained a spawned daemon's PID after + a successful wait. If the exact missing-snapshot regression recurred, + the timeout would therefore be unable to terminate that daemon. The + test now consumes the initial ready report first, retaining lifecycle + facts before it starts the post-quiescence assertion. +- **Review-round-2 gates are green:** `cargo fmt --check`; strict + workspace clippy; library **1,849 passed / 3 ignored**; CRDT library + **2,034 passed / 4 ignored**; focused public-path test **1/1** and full + GPU invocation suite **15/15**; M4 **121 passed / 3 ignored / 1 + filtered**; required GPU package **202/202**; `git diff --check`. + The first in-sandbox GPU-package attempt reproduced the repository's + documented Unix-socket restriction as three attach failures plus a + blocked peer read; the authoritative out-of-sandbox rerun passed all + **202** tests in under one second. - **Full gate matrix is green on the public-path revision at `34b8f28`:** - `cargo fmt --check`; - strict workspace clippy; From 17867ace0e482ce871e29a5c31fd720d3ad5b5cd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 12:44:27 -0400 Subject: [PATCH 85/91] docs(active-work): record integrated 2B-1 gates Record the complete post-#183 gate matrix for bottom-panel Stage 2B-1 at c8895a8 and mark the lane ready to open for review. Retain the required-GPU first-pass classification: one unrelated math render assertion failed, passed immediately in isolated single-threaded execution, and the mandatory complete 202-test rerun passed. --- docs/active-work.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 871bbc1..429d15e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -305,20 +305,20 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 INTEGRATED; FULL GATES PENDING +## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR READY Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on 2026-07-27. **Stage 2B-1 is implemented, integrated with canonical -`main` @ `7fd646d`, and has no remaining dependency. Its full matrix is -being rerun on the integrated tree before a PR opens.** +`main` @ `7fd646d`, and fully gated at `c8895a8`; it has no remaining +dependency. The branch is ready to push and open for review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. Recovery: `git fetch githubsucks && git checkout bottom-panel-stage2b`. Everything described through the integration - checkpoint is committed and pushed; nothing depends on a worktree or - `/tmp`. No PR exists yet. + and gate checkpoint is committed; nothing depends on a worktree or + `/tmp`. - **Ships only the v21 wire layer:** the four wire shapes, version bump, shared cell-grid validator, and version-ladder move. It has no producer, consumer, or capability change; `panel_capable` stays @@ -348,8 +348,23 @@ being rerun on the integrated tree before a PR opens.** corrected `gpu_initial_target_acceptance` through the public `pmacs --gpu .` path, consumed the asynchronous dired snapshot, and retained the managed daemon before the wait so failure cleanup remains - effective. The code integration auto-composed; the full matrix still - has to prove the combined tree. + effective. The code integration auto-composed. +- **The complete post-integration gate is green at `c8895a8`:** + formatting; strict workspace Clippy; library **1,849 passed + 3 + ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel + Stage 1 / 2A / 2B-1 **46 / 17 / 15**; folding Stage 2 **48**; GPU font + **11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU initial + target and invocation **15 / 15 CRDT**; Vterm Stages 1 / 2 / 3 + **10 / 6 / 9 CRDT**, including the required real daemon + PTY + wgpu + probe; M4 **121 passed + 3 ignored + 1 filtered**; required GPU + **202/202**; the isolated-config, one-invocation full workspace sweep; + and `git diff --check`. + - The first required-GPU pass was **201/202** on + `a_fraction_draws_rule_pixels_between_its_operand_rows`, a rendering + test structurally outside this lane's protocol-only GPU diff. The + exact test passed immediately in isolation with one test thread, and + the mandatory complete rerun passed **202/202**. This is retained as + classified gate evidence, not erased as a clean first pass. - **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine belongs to 2B-2; the GPU band and negotiated capability flip belong From f82d91ed3a1c6080957960c04accdd7b8c9bb858 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 12:51:42 -0400 Subject: [PATCH 86/91] docs(active-work): record bottom-panel 2B-1 PR Record PR #184 as open for review and keep the explicit no-merge hold in the portable lane state. --- docs/active-work.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 429d15e..b419173 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -305,20 +305,21 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR READY +## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR #184 OPEN FOR REVIEW Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on 2026-07-27. **Stage 2B-1 is implemented, integrated with canonical `main` @ `7fd646d`, and fully gated at `c8895a8`; it has no remaining -dependency. The branch is ready to push and open for review.** +dependency. PR #184 is open and must not merge before user review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. Recovery: `git fetch githubsucks && git checkout bottom-panel-stage2b`. Everything described through the integration - and gate checkpoint is committed; nothing depends on a worktree or - `/tmp`. + and gate checkpoint is committed and pushed; nothing depends on a + worktree or `/tmp`. PR #184: + . - **Ships only the v21 wire layer:** the four wire shapes, version bump, shared cell-grid validator, and version-ladder move. It has no producer, consumer, or capability change; `panel_capable` stays From ab7c2079041a0f37d9ae657afa76f99b469350e3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 14:08:17 -0400 Subject: [PATCH 87/91] Keep the v21 panel wire dark for v20 clients Reserve the additive v21 panel schema without advertising it in the server-first production handshake. Pin a real shipped-v20 client attach, make the two aggregate-budget ratchets exactly one byte over, and update the framing, coherence audit, handoff, and volatile lane record. --- COHERENCE.md | 10 +- docs/active-work.md | 48 +++++-- docs/agent-handoff.md | 52 +++++--- docs/bottom-panel-stage2-framing.md | 126 +++++++++++++----- pmacs-protocol/src/lib.rs | 23 ++-- pmacs-protocol/src/message.rs | 24 +++- pmacs-protocol/src/terminal.rs | 18 ++- src/daemon.rs | 11 +- ...ottom_panel_stage2b_protocol_acceptance.rs | 80 ++++++++++- tests/common/daemon.rs | 4 +- tests/gpu_invocation_acceptance.rs | 12 +- tests/m5_5_acceptance.rs | 22 +-- tests/m5_7_acceptance.rs | 6 +- tests/m5_perf_acceptance.rs | 8 +- tests/mode_system_wiring_acceptance.rs | 8 +- tests/vterm_stage3_acceptance.rs | 4 +- 16 files changed, 325 insertions(+), 131 deletions(-) diff --git a/COHERENCE.md b/COHERENCE.md index 758b3e2..e997614 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -109,7 +109,7 @@ remain open to them. | 13 | Package lifecycle UX | **Resolution without lifecycle** | Mature resolver/lockfile; init-only install; no uninstall/disable/search | | 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real shared primitive; bottom panel landed (#155) | | 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all | -| 16 | Semantic frontend | **Strong** | v6..=v20 negotiated protocol; degradation practiced; TUI/GPU share the model | +| 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced | | 17 | Distribution | **Missing** | CI is test-only; no binaries, channels, checksums, or update path | | 18 | Onboarding | **Missing** | No welcome, no tutorial; `C-h` deletes a word; `M-x` is the only door in | | 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` exists (steps 2, 3, 5); the other five scenarios are still unwritten | @@ -1340,9 +1340,13 @@ facto privileged implementation. **Grade: strong — the healthiest concern in this document, and most of its asks are already practiced.** -- Versioned, negotiated protocol `SUPPORTED=[6..=20]` with deliberate +- Versioned protocol schema `SUPPORTED=[6..=21]` with deliberate encoding-breaking bumps, both-frontends support required per bump, - and byte-pin discipline for appended variants (handoff §4). + and byte-pin discipline for appended variants (handoff §4). The v21 + bottom-panel family is reserved but dark in Stage 2B-1: because + `Hello` is server-first, the production daemon still advertises v20 + so shipped v20 clients remain attachable; compatible v21 activation + belongs to Stage 2B-3. - Two genuine frontends share the conceptual model; CRDT concurrent editing with presence across them; remote attach + reconnect. - **Graceful per-frontend degradation is practiced, not aspirational**: diff --git a/docs/active-work.md b/docs/active-work.md index b419173..837cd56 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -305,13 +305,15 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 GATED; PR #184 OPEN FOR REVIEW +## Bottom-panel lane (Arc 7) — 2B-1 REVIEW FIX IN PROGRESS; PR #184 OPEN Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on -2026-07-27. **Stage 2B-1 is implemented, integrated with canonical -`main` @ `7fd646d`, and fully gated at `c8895a8`; it has no remaining -dependency. PR #184 is open and must not merge before user review.** +2026-07-27; revision 6 records PR #184's review correction. **Stage +2B-1 is implemented and integrated with canonical `main` @ `7fd646d`. +The previous head was fully gated at `c8895a8`, but review round 2 found +four issues and the corrected head must run the full gate again. PR +#184 is open and must not merge before user review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. @@ -320,14 +322,27 @@ dependency. PR #184 is open and must not merge before user review.** and gate checkpoint is committed and pushed; nothing depends on a worktree or `/tmp`. PR #184: . -- **Ships only the v21 wire layer:** the four wire shapes, version bump, - shared cell-grid validator, and version-ladder move. It has no - producer, consumer, or capability change; `panel_capable` stays - `false`, so this slice changes no user-visible journey grade. +- **Ships only the reserved v21 wire layer:** the four wire shapes, + schema version, shared cell-grid validator, and accepted-version + ladder move. The production daemon continues advertising v20 because + its `Hello` is server-first; v21 activation belongs to 2B-3. This + slice has no producer, consumer, or capability change; + `panel_capable` stays `false`, so it changes no user-visible journey + grade and existing v20 clients remain attachable. - **Review round 1 closed:** two P1s and one P2, all corrected at `9b364ad`: `PanelFrame` now identifies its buffer, the transport ratchet covers the actual attach path rather than a detached codec assertion, and shared grid bounds have one validator. +- **Review round 2 found four issues; fixes are in progress:** the + server-first `Hello` made the advertised v20↔v21 compatibility + one-way; `COHERENCE.md` and `docs/agent-handoff.md` still named only + v20 schema support; framing §9 named a nonexistent aggregate 2B + suite instead of the three exact 2B slice suites; and the panel plus + copied terminal "one byte over" fixtures were actually two bytes + over. The correction keeps production advertisement at v20, adds a + real-daemon existing-v20-client acceptance, updates all three durable + records, names the exact slice suites, and asserts both rejecting + fixtures are exactly `limit + 1`. - **The full gate found and corrected two 2B-1 omissions:** the statusline version ladder still pinned v20/rejected v21, and Vterm Stage 3 pinned v20 both structurally and in its real headless probe. @@ -350,7 +365,7 @@ dependency. PR #184 is open and must not merge before user review.** `pmacs --gpu .` path, consumed the asynchronous dired snapshot, and retained the managed daemon before the wait so failure cleanup remains effective. The code integration auto-composed. -- **The complete post-integration gate is green at `c8895a8`:** +- **The previous complete post-integration gate was green at `c8895a8`:** formatting; strict workspace Clippy; library **1,849 passed + 3 ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel Stage 1 / 2A / 2B-1 **46 / 17 / 15**; folding Stage 2 **48**; GPU font @@ -438,8 +453,9 @@ dependency. PR #184 is open and must not merge before user review.** `docs/agent-handoff.md` §1; the two round lessons are in §5. - Landed-docs follow-up merged as **#156** (`main` @ `d152120`, 2026-07-25). -- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 5** - is commit `56301ed` on branch `githubsucks/bottom-panel-stage2b`, +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 6** + is on branch `githubsucks/bottom-panel-stage2b` (revision 5 is commit + `56301ed` there), worktree `../pmacs-bp-stage2b`. Revisions 1–4 remain on `githubsucks/bottom-panel-stage2-framing` (head `4fbd47f`, four framing commits, revision 4 at `49757e5`). Round 1 closed 2 blocking + @@ -447,7 +463,9 @@ dependency. PR #184 is open and must not merge before user review.** round 2 closed 1 blocking + 2 high + 1 medium and decided both open items; round 3 closed 1 blocking + 1 high + 1 medium. No open items remain. Revision 5 adds no decision; it records the approved - 2B-1/2B-2/2B-3 implementation split. The + 2B-1/2B-2/2B-3 implementation split. Revision 6 corrects the + server-first compatibility contract, durable protocol claims, exact + acceptance-suite names, and `limit + 1` fixture. The parent framing `docs/bottom-panel-framing.md` (rev 4) remains authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree @@ -456,12 +474,14 @@ dependency. PR #184 is open and must not merge before user review.** before the next branches: **2A** = classified §1.3 census routing + `paint_frame` per-window painter extraction (with the active-window auto-scroll preparation), no - protocol change; **2B-1** = protocol **v21** + protocol change; **2B-1** = reserved protocol schema **v21**, with + production advertisement held at v20, (`InstanceMessage::PanelFrame` plus `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`, gated both directions, each extended enum byte-pinned on its own previous final variant); **2B-2** = daemon panel projection and epoch - machine; **2B-3** = the GPU band and negotiated `panel_capable` flip. + machine; **2B-3** = compatible v21 activation, the GPU band, and the + negotiated `panel_capable` flip. Stage 3 is the adopter default flip. - **Correction — this entry previously mis-stated the census contract.** It is **not** "route every consumer through `primary_document_window`". diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d2e8ee2..bc6f440 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,7 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-28, after the Journey/GPU directory-target -ratchet (#183), following Journey Stage 1a (#182), which made directory +**Last updated: 2026-07-28, during bottom-panel Stage 2B-1 PR #184 +review; the canonical landed base remains the Journey/GPU +directory-target ratchet (#183), following Journey Stage 1a (#182), +which made directory startup one coherent local/daemon/GPU path and incorporated the terminal configuration + copy mode landed-doc work (#180); following terminal copy mode (#178) — `C-c C-t` @@ -58,9 +60,11 @@ commands, read `docs/active-work.md` immediately after this file. #165, the GPU terminal input fix #166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159, bottom-panel Stage 1 #155). Protocol unchanged - at **v20** — bottom-panel Stage 2A deliberately carries no wire change; - v21 arrives with Stage 2B. The bullets below describe the arcs in their - own terms; this line is the head-of-`main` anchor. + at **v20** — bottom-panel Stage 2A deliberately carries no wire change. + The in-review Stage 2B-1 reserves the v21 schema but keeps the + server-first production `Hello` at v20; compatible activation belongs + to Stage 2B-3. The bullets below describe the arcs in their own terms; + this line is the head-of-`main` anchor. - **`COHERENCE.md` is now required reading and a required framing input — #163.** It carries the product-coherence thesis, an audited scorecard, per-concern gaps, and §20's priority order, and it is the @@ -395,9 +399,14 @@ commands, read `docs/active-work.md` immediately after this file. unchanged, which is the additivity gate for the `read_dir` change; M4 121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 across 93 suites. 15 claims bite-verified. -- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 = - `FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 = - the GPU initial-target semantic bootstrap family). +- Canonical `main` is protocol **v20** (`SUPPORTED=[6..=20]`; v16 = + `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 = + terminal frames/events, v20 = the GPU initial-target semantic + bootstrap family). Bottom-panel Stage 2B-1's in-review schema is v21 + (`SUPPORTED=[6..=21]`), but its production daemon deliberately + advertises v20: the handshake is server-first, so advertising 21 + would make shipped v20 GPU/TUI clients reject before + `AttachRequest`. Stage 2B-3 owns compatible production activation. - **Bottom panel Stage 1 (window placement + TUI side windows) LANDED — #155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now @@ -463,14 +472,18 @@ commands, read `docs/active-work.md` immediately after this file. required GPU 152; initial-target 14 CRDT; all three vterm suites; folding Stage 2 48. All 12 CI checks green at merge. - **Stage 2 (the GPU panel band) is FRAMED** — - `docs/bottom-panel-stage2-framing.md` rev 5, four review rounds, no - open items; the rev-5 implementation split was explicitly approved - 2026-07-27. It takes protocol **v21** and ships as four serial + `docs/bottom-panel-stage2-framing.md` rev 6, four framing review + rounds, no open framing items; the rev-5 implementation split was + explicitly approved 2026-07-27 and rev 6 records PR #184's + server-first compatibility and gate correction. It reserves + protocol **v21** and ships as four serial implementation slices: **2A** classified census routing + per-window painter extraction (no wire change), **2B-1** the wire, **2B-2** the daemon projection and epoch machine, then **2B-3** the - GPU band and negotiated `panel_capable` flip. Parent acceptance - 37–55 remains authoritative. Stage 3 is the adopter default flip. + GPU band, compatible v21 activation, and negotiated + `panel_capable` flip. Production attachment remains v20 through + 2B-1 and 2B-2. Parent acceptance 37–55 remains authoritative. + Stage 3 is the adopter default flip. - **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the Projection class (#1–#12, #21–#22) routes through `primary_document_window`; focus/input (#13–#15, #23), focus chrome @@ -1201,12 +1214,17 @@ buffer owns a path's recovery slot; only recover/discard release unclaimed crash data; adopt clears the old owner's skip cache. **Protocol** — encoding-breaking bumps are deliberate and versioned. Canonical -`main` is `[6..=20]`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 = +`main` is `[6..=20]`. The in-review bottom-panel 2B-1 schema extends support +to `[6..=21]`, while `ADVERTISED_PROTOCOL_VERSION` stays 20 until 2B-3 +provides compatibility-preserving activation; the server-first `Hello` +cannot advertise 21 without stranding existing v20 clients before +`AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm terminal family; v20 = semantic `SessionBootstrapRequest` plus appended -`InitialTargetResult`. New wire surface ⇒ bump + both-frontends support + -acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS -final variant — its own round-trip cannot detect a discriminant shift. +`InitialTargetResult`; v21 reserves the panel frame/event family. New wire +surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant +must be guarded by a byte pin on the PREVIOUS final variant — its own +round-trip cannot detect a discriminant shift. **Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`, `rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation), diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index 28dd68b..2384118 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,10 +1,13 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 5 — APPROVED 2026-07-27; 2A merged, 2B-1 in progress. -Ground truth: canonical `main` @ `c2d56ff`, protocol v20 on `main` and -v21 on `bottom-panel-stage2b`.** Revisions 1–4 were -pre-implementation; rev 5 records the three-way slice of Stage 2B -(§0.0, §7.2, §9) after its first slice was already built. +**Revision 6 — PR #184 review correction; the underlying Stage 2 +framing remains APPROVED 2026-07-27. 2A is merged and 2B-1 is under +review. Ground truth: canonical `main` @ `7fd646d`, protocol v20 on +`main`; `bottom-panel-stage2b` reserves the v21 schema while its +server-first production handshake continues to advertise v20.** +Revisions 1–4 were pre-implementation; rev 5 recorded the three-way +slice of Stage 2B after its first slice was already built; rev 6 +corrects that slice's mixed-version and gate contracts. Stage 1 (#155, merge `e745068`) gave pmacs window placement, window parameters, TUI side windows, the divider, and the adopter `display` @@ -29,7 +32,33 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and ## 0. Revision history -### 0.0 Rev 4 → rev 5 — the three-way slice of 2B (not a review round) +### 0.0 Rev 5 → rev 6 — PR #184 review round 2, four findings closed + +- **R6-1 (P1) — v21 is reserved, not advertised, in 2B-1.** The + protocol's handshake is server-first. An existing v20 TUI or GPU + frontend rejects a `Hello { protocol_version: 21 }` before it can + send an `AttachRequest`, so rev 5's claim that a v21 daemon and v20 + peer "still negotiate 20" was impossible. 2B-1 therefore extends the + schema and accepted-version ladder to v21 while the production daemon + continues advertising v20. A real-daemon acceptance emulates the + shipped v20 rejection point and then requires the attachment to reach + its initial grid. **2B-3 owns both a compatibility-preserving + activation mechanism and the production move to v21; it may not + simply change the unsolicited `Hello` to 21.** +- **R6-2 (P2) — durable protocol claims move with the wire.** + `COHERENCE.md` and `docs/agent-handoff.md` now distinguish v21 schema + support from the still-v20 production handshake. +- **R6-3 (P2) — the gate contract names the actual decomposition.** + §9 now names 2B-1's + `bottom_panel_stage2b_protocol_acceptance` suite and the exact planned + daemon/GPU suite names for 2B-2 and 2B-3 instead of the nonexistent + `bottom_panel_stage2b_acceptance`. +- **R6-4 (P2) — "one byte over" means exactly one.** The panel and + copied terminal boundary fixtures replace a one-byte cluster with a + two-byte cluster, and each independently asserts a total of + `limit + 1`. + +### 0.1 Rev 4 → rev 5 — the three-way slice of 2B (not a review round) This revision changes no decision. It splits one approved implementation slice into three and reallocates the acceptance @@ -68,18 +97,17 @@ criteria across them. 2 and 3 are written. - **R5-4 — two of the three slices ship dark, deliberately.** Nothing in 2B-1 or 2B-2 is reachable by a user: `panel_capable` stays - `false` for every negotiated semantic session until 2B-3, so a v21 - daemon and a v21 GPU frontend negotiate 21 and behave exactly as - they do at v20. This is the same posture 2A took ("seam adoption - that becomes load-bearing in 2B") and it carries the same - obligation: **the version bump advertises a capability whose only - distinguishing feature is unreachable until 2B-3 lands.** That is - safe for compatibility — the variants are appended, the ladder is - extended, and a v20 peer still negotiates 20 — but it means the arc - must not stall between 2B-1 and 2B-3. Recorded here so a stall is - visible as a decision rather than inherited as a default. + `false` for every negotiated semantic session until 2B-3. Rev 5 + incorrectly described that posture as a production v21 negotiation + that remained compatible with v20 clients; rev 6 R6-1 supersedes + that claim. The actual dark posture keeps the server-first + production handshake on v20 while the v21 schema is reserved. This + is the same posture 2A took ("seam adoption that becomes + load-bearing in 2B"), and it means the arc must not stall between + 2B-1 and 2B-3. Recorded here so a stall is visible as a decision + rather than inherited as a default. -### 0.1 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed +### 0.2 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed - **R3-1 (blocker).** Rev 3's three-boundary model was right but its call-site table was wrong in five places, and each error was a real @@ -107,7 +135,7 @@ criteria across them. `cell.attachment.is_some()` rejection. It is now classified — and **shared**, with the reasoning pinned. -### 0.2 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed +### 0.3 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed - **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two boundaries that must **diverge** once a panel exists. Several sites it @@ -134,7 +162,7 @@ criteria across them. - Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0, and `TEXT_TOP` stays unscaled. -### 0.3 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed +### 0.4 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed - **R1-1 (blocker).** Rev 1 said all 23 census reads route through `primary_document_window`. That contradicts Q#BP14, which routes only @@ -181,9 +209,10 @@ criteria across them. | Byte pin `InstanceMessage::InitialTargetResult` | `pmacs-protocol/src/message.rs:1145` | Holds — still the enum's final variant | | Byte pin `FrontendEvent::TerminalPointer` | final variant of its enum | Holds | -**Protocol is still v20** (`pmacs-protocol/src/message.rs:1568`); no -intervening PR bumped it. Q#BP9's conditional resolves: **Stage 2 is -v21**, no reservation was taken and none was needed. +**Protocol was still v20 at this re-scout**; no intervening PR had +bumped it. Q#BP9's conditional resolved: **Stage 2 reserves v21**. +Rev 6 R6-1 adds the server-first compatibility constraint discovered +during 2B-1 review. Fifteen PRs merged between the parent's last re-scout (`47581f4`) and this one: #149, #150, #152–#155, #158–#166. Nothing in the parent's @@ -375,13 +404,18 @@ undedicated (Q#BP2c). "It receives no new events" is insufficient — if the daemon nevertheless places that frontend's window in a side panel it cannot render, the window becomes invisible. The gate is on placement, not only on transport. Parent acceptance 51 pins the mixed -session. +session. **The production daemon does not advertise v21 in 2B-1 or +2B-2.** Because `Hello` is server-first, 2B-3 must add or prove a +compatibility-preserving way to activate v21 before applying this rule; +merely advertising 21 would strand already-shipped v20 clients before +they can identify themselves. ## 4. Revisions to the parent framing Only these; everything else stands. -- **Q#BP9 resolves to v21.** +- **Q#BP9 resolves to the v21 schema, with production advertisement + held at v20 until 2B-3 supplies compatible activation.** - **Q#BP15a's epoch ownership is specified** by §3.1's table and API split, replacing the parent's one-line "frontend-owned" statement. - **Q#BP8's statusline criterion splits** per §3.3: one read reroutes, @@ -741,7 +775,10 @@ consumer, no capability change. becomes 21, `SUPPORTED_PROTOCOL_VERSIONS` accepts `6..=21` and rejects 22, and any test whose *name* encodes the old number is renamed. A ladder pin that passes across a bump was not pinning the - version. + version. **`ADVERTISED_PROTOCOL_VERSION` remains 20 in 2B-1 and + 2B-2** because the unsolicited `Hello` precedes any client version + signal. A real daemon must remain attachable by a client whose + supported range ends at 20. - **Shared bounds are aliased, not duplicated.** Every constant the terminal screen and the panel validator both enforce is one definition with the other as an alias, so truncation and validation @@ -790,9 +827,11 @@ production negotiation until 2B-3. #### 7.2.3 Slice 2B-3 — the GPU band and the capability flip -**Authority: `pmacs-gpu`, plus the negotiation rule.** This is the only -slice a user can observe, and the only one that closes the journey -divergence in §6. +**Authority: `pmacs-gpu`, plus the compatibility-preserving negotiation +activation.** This is the only slice a user can observe, and the only +one that closes the journey divergence in §6. It must not advertise +v21 in the server-first `Hello` until an existing v20 client can still +attach. - **46** (band + divider shrink the document text area by exactly their pixel height; carets, hits, and scroll geometry respect the reduced @@ -838,7 +877,10 @@ divergence in §6. its chrome. - **A2B-5.** `panel_capable` is true only for a v21+ negotiated authenticated semantic session; a v20 semantic session is never - **placed** in a side window, not merely denied the events. + **placed** in a side window, not merely denied the events. The same + acceptance must attach an actual v20 client to the production daemon + after v21 activation, so the new path cannot pass by breaking the old + handshake before placement is evaluated. ## 8. Open items @@ -866,17 +908,20 @@ stacked, and each is cut from `main`. installed. - **Stage 2B-1 — the v21 wire layer.** Branch `bottom-panel-stage2b`. The four wire shapes, the version bump, the shared cell-grid - validator, and the version-ladder move. **No producer, no consumer, - no capability change** — `panel_capable` stays `false`. + validator, and the version-ladder move. The v21 schema is reserved + while the production daemon continues advertising v20. **No + producer, no consumer, no capability change** — `panel_capable` + stays `false`. - **Stage 2B-2 — the daemon panel projection and epoch machine.** Cut from `main` after 2B-1 merges. Produces `PanelFrame` and owns stale-event rejection, exercised through a **test-only** panel-capable semantic view. Still no production flip. - **Stage 2B-3 — the GPU band and the negotiated flip.** Cut from `main` after 2B-2 merges. The three-boundary text-area split, the - divider, pointer routing, and `panel_capable = true` for a v21+ - negotiated authenticated semantic session. **This is the slice that - changes what a user sees**, and it repeats 2A's and 2B-2's relevant + divider, pointer routing, the compatibility-preserving v21 + activation, and `panel_capable = true` for a v21+ negotiated + authenticated semantic session. **This is the slice that changes + what a user sees**, and it repeats 2A's and 2B-2's relevant assertions through the real capability flip. **Each slice runs the full gate set below, not a subset of it.** A @@ -889,9 +934,16 @@ Gates for each slice: the standing suite from `CLAUDE.md`, plus the **touched acceptance suites named explicitly** — the standing rule is to run the suites a change touches, and "standing suite" does not name them: -- `bottom_panel_stage1_acceptance` — the substrate both slices build on. -- `bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance` - — new, one per slice. +- `bottom_panel_stage1_acceptance` — the substrate all four Stage 2 + slices build on. +- `bottom_panel_stage2a_acceptance` — Stage 2A's classified census and + painter extraction. +- `bottom_panel_stage2b_protocol_acceptance` — Stage 2B-1's v21 schema, + server-first v20 compatibility, byte pins, and shared validation. +- `bottom_panel_stage2b_daemon_acceptance` — the exact suite name + reserved for Stage 2B-2's projection and epoch machine. +- `bottom_panel_stage2b_gpu_acceptance` — the exact suite name reserved + for Stage 2B-3's band, compatible activation, and capability flip. - `statusline_segments_acceptance` — the fan-out target change (§3.3). - `m11_5_semantic_acceptance` — the semantic census (§3.2). - `gpu_initial_target_acceptance` — parent criterion 55. diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 82cdd5b..e7e36e3 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -57,17 +57,18 @@ pub use cell::{ pub use crdt::CrdtOp; pub use ids::{BufferId, ByteRange, FrontendId, Position}; pub use message::{ - AdornmentContent, AdornmentPlacement, AttachRequest, BUILTIN_PAIR_CHARS, BlockAdornment, - CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment, - FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, - InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, - KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, - MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS, - MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, - MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, - ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest, - StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char, - is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities, + ADVERTISED_PROTOCOL_VERSION, AdornmentContent, AdornmentPlacement, AttachRequest, + BUILTIN_PAIR_CHARS, BlockAdornment, CompletionPopupRow, CursorState, Decoration, + DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, + InitialTarget, InitialTargetResult, InlineAdornment, InstanceCapabilities, InstanceIdentity, + InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, + MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, + MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, + MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, + PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, + SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, + is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, + negotiate_capabilities, }; pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload}; pub use terminal::{ diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index e3795a3..0e4e815 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -1665,6 +1665,16 @@ pub enum ResourceBody { /// window invisible. pub const PROTOCOL_VERSION: u32 = 21; +/// Protocol version placed in the daemon's server-first [`Hello`]. +/// +/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but +/// production attachment remains on v20 until the Stage 2B-3 capability +/// activation can preserve compatibility with existing v20 frontends. +/// Those frontends reject an unknown server-first version before they can +/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would +/// make the otherwise-dark protocol slice user-visible. +pub const ADVERTISED_PROTOCOL_VERSION: u32 = 20; + /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept /// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat @@ -1742,9 +1752,12 @@ pub const PROTOCOL_VERSION: u32 = 21; /// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy /// and non-semantic sessions retain their existing handshake shape. /// -/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. v21 peers -/// may exchange panel traffic; v20 peers interoperate with it simply -/// absent, and are never placed in a side window. +/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. Stage 2B-1 +/// reserves and validates the v21 wire while production daemons continue +/// to send [`ADVERTISED_PROTOCOL_VERSION`] in their server-first +/// [`Hello`]. The later capability-activation slice owns moving production +/// negotiation to v21 without making existing v20 frontends reject the +/// handshake. pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]; @@ -2126,7 +2139,10 @@ pub fn negotiate_capabilities( /// frontend will use as the `FrontendId` on every event it sends. #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Hello { - /// The instance's `PROTOCOL_VERSION`. + /// The protocol version this attachment should use. + /// + /// This can deliberately trail [`PROTOCOL_VERSION`] while an additive + /// wire family is reserved but not yet activated in production. pub protocol_version: u32, /// `FrontendId` assigned to this attachment by the instance. The /// frontend stamps this onto subsequent events. v0.1 daemons start diff --git a/pmacs-protocol/src/terminal.rs b/pmacs-protocol/src/terminal.rs index 8f7f507..0bcf7e0 100644 --- a/pmacs-protocol/src/terminal.rs +++ b/pmacs-protocol/src/terminal.rs @@ -843,14 +843,14 @@ mod tests { let mut over = exact.clone(); // One more byte of glyph, nothing else changed. let last = over.cells.len() - 1; - over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(3).into_boxed_slice())); + over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(2).into_boxed_slice())); (exact, over) } #[test] fn maximum_legal_terminal_frame_encodes_below_the_transport_cap() { - let (exact, _) = budget_boundary_frames(); + let (exact, over) = budget_boundary_frames(); assert_eq!(exact.validate(), Ok(())); let mut glyph_bytes = 0usize; @@ -865,6 +865,20 @@ mod tests { glyph_bytes, MAX_TERMINAL_FRAME_GLYPH_BYTES, "the measured fixture must spend the whole aggregate budget" ); + let over_glyph_bytes = over + .cells + .iter() + .map(|cell| match &cell.glyph { + Glyph::Char(ch) => ch.len_utf8(), + Glyph::Cluster(bytes) => bytes.len(), + Glyph::Continuation => 0, + }) + .sum::(); + assert_eq!( + over_glyph_bytes, + MAX_TERMINAL_FRAME_GLYPH_BYTES + 1, + "the rejecting twin must be exactly one byte over the aggregate budget" + ); let msg = InstanceMessage::TerminalFrame(exact); let bytes = postcard::to_allocvec(&msg).expect("encode"); diff --git a/src/daemon.rs b/src/daemon.rs index 43c97ad..b665d07 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -67,9 +67,9 @@ use crate::lockfile::{self, LockError, LockHandle}; use crate::presence::{PresenceSnapshot, SessionRegistry}; use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm}; use crate::protocol::{ - AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, - InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, - MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PROTOCOL_VERSION, PointerKind, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, + InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, + InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PointerKind, SelectionSnapshot, SessionBootstrapRequest, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; @@ -712,7 +712,7 @@ fn per_attach_thread( // mismatch path without changing the default. let instance_caps_for_hello = instance_capabilities_with_env_override(); let hello = Hello { - protocol_version: PROTOCOL_VERSION, + protocol_version: ADVERTISED_PROTOCOL_VERSION, assigned_frontend_id: frontend_id, instance_identity: daemon_state.build_identity(), instance_capabilities: instance_caps_for_hello.clone(), @@ -739,7 +739,7 @@ fn per_attach_thread( let _ = write_message( &mut stream, &InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { - server: PROTOCOL_VERSION, + server: ADVERTISED_PROTOCOL_VERSION, client: req.protocol_version, }), ); @@ -3412,6 +3412,7 @@ fn apply_event( #[cfg(test)] mod tests { use super::*; + use crate::protocol::PROTOCOL_VERSION; #[test] fn daemon_state_starts_frontend_id_at_two() { diff --git a/tests/bottom_panel_stage2b_protocol_acceptance.rs b/tests/bottom_panel_stage2b_protocol_acceptance.rs index c3ec239..b066e4a 100644 --- a/tests/bottom_panel_stage2b_protocol_acceptance.rs +++ b/tests/bottom_panel_stage2b_protocol_acceptance.rs @@ -5,17 +5,28 @@ //! projection, the epoch state machine, and the GPU band are later //! slices of this stage and are not exercised here. +mod common; + +use std::time::Duration; + use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Color, Glyph, Style, UnderlineStyle}; -use pmacs_protocol::message::{FrontendEvent, InstanceMessage, Modifiers, MouseButton, MouseKind}; +use pmacs_protocol::message::{ + AttachRequest, FrontendEvent, Hello, InstanceMessage, Modifiers, MouseButton, MouseKind, +}; use pmacs_protocol::panel::{ MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload, }; use pmacs_protocol::terminal::{ MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState, }; -use pmacs_protocol::transport::MAX_FRAME_BYTES; +use pmacs_protocol::transport::{MAX_FRAME_BYTES, read_message, write_message}; use pmacs_protocol::wire_grid::{MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES}; -use pmacs_protocol::{BufferId, FrontendId, PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS}; +use pmacs_protocol::{ + ADVERTISED_PROTOCOL_VERSION, BufferId, FrontendId, PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, +}; + +use common::daemon::{TestDaemon, build_default_caps}; fn cell(ch: char) -> Cell { Cell { @@ -98,11 +109,54 @@ fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame { fn the_panel_stage_takes_protocol_v21() { assert_eq!(PROTOCOL_VERSION, 21); assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21)); - // v20 stays supported: a v20 peer interoperates with panel traffic - // simply absent rather than being refused the handshake. + // The wire family is reserved before it is activated: the production + // server-first Hello must remain acceptable to already-shipped v20 + // clients throughout the dark protocol and daemon slices. + assert_eq!(ADVERTISED_PROTOCOL_VERSION, 20); assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20)); } +#[test] +fn a_new_daemon_keeps_an_existing_v20_client_attachable() { + let daemon = TestDaemon::spawn(); + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set handshake timeout"); + + // This is the rejection point in an already-shipped client: it reads the + // daemon's unsolicited Hello before it is able to identify its own + // supported range or send AttachRequest. + let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); + let v20_client_supported_versions = 6..=20; + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); + assert!( + v20_client_supported_versions.contains(&hello.protocol_version), + "an existing v20 client would reject the server-first Hello" + ); + + write_message( + &mut stream, + &AttachRequest { + protocol_version: hello.protocol_version, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(24, 80), + }, + ) + .expect("write v20 AttachRequest"); + + assert!( + matches!( + read_message::(&mut stream).expect("read initial grid"), + InstanceMessage::CellDelta { + full_grid: true, + .. + } + ), + "the daemon must establish the v20 session, not merely send an acceptable Hello" + ); +} + #[test] fn a_present_panel_frame_round_trips_with_both_epochs() { let frame = panel_frame(2, 3); @@ -483,7 +537,7 @@ fn panel_budget_boundary_frames() -> (PanelFrame, PanelFrame) { let mut over = exact.clone(); // One more byte of glyph, nothing else changed. let last = over.cells.len() - 1; - over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(3).into_boxed_slice())); + over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(2).into_boxed_slice())); (exact, over) } @@ -508,6 +562,20 @@ fn maximum_legal_panel_frame_encodes_below_the_transport_cap() { glyph_bytes, MAX_WIRE_GRID_GLYPH_BYTES, "the measured fixture must spend the whole aggregate budget" ); + let over_glyph_bytes = over + .cells + .iter() + .map(|cell| match &cell.glyph { + Glyph::Char(ch) => ch.len_utf8(), + Glyph::Cluster(bytes) => bytes.len(), + Glyph::Continuation => 0, + }) + .sum::(); + assert_eq!( + over_glyph_bytes, + MAX_WIRE_GRID_GLYPH_BYTES + 1, + "the rejecting twin must be exactly one byte over the aggregate budget" + ); // One byte over is rejected, which is what makes `exact` maximal. assert!(matches!( diff --git a/tests/common/daemon.rs b/tests/common/daemon.rs index 6cb087a..1c8bc0d 100644 --- a/tests/common/daemon.rs +++ b/tests/common/daemon.rs @@ -26,7 +26,7 @@ use tempfile::TempDir; #[cfg(feature = "crdt")] use pmacs::cell::CellSize; #[cfg(feature = "crdt")] -use pmacs::protocol::{AttachRequest, PROTOCOL_VERSION}; +use pmacs::protocol::AttachRequest; use pmacs::protocol::{FrontendCapabilities, Hello}; use pmacs::transport::read_message; #[cfg(feature = "crdt")] @@ -294,7 +294,7 @@ pub fn attach_multi(daemon: &TestDaemon) -> (Hello, UnixStream) { .unwrap(); let hello: Hello = read_message(&mut stream).expect("read Hello"); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: multi_frontend_caps(), initial_size: CellSize::new(24, 80), }; diff --git a/tests/gpu_invocation_acceptance.rs b/tests/gpu_invocation_acceptance.rs index b4e3102..2f56e8f 100644 --- a/tests/gpu_invocation_acceptance.rs +++ b/tests/gpu_invocation_acceptance.rs @@ -93,9 +93,9 @@ mod crdt { use pmacs::cell::CellSize; use pmacs::crdt::CrdtState; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget, - InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, - PROTOCOL_VERSION, SessionBootstrapRequest, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, + FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities, + InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, SessionBootstrapRequest, }; use pmacs::transport::{read_message, write_message}; @@ -244,11 +244,11 @@ mod crdt { .set_read_timeout(Some(Duration::from_secs(5))) .expect("set target frontend timeout"); let hello: Hello = read_message(&mut stream).expect("target frontend Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); write_message( &mut stream, &AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: FrontendCapabilities { multi_frontend: true, crdt_replica: true, @@ -583,7 +583,7 @@ mod crdt { facts .get("server_protocol_version") .and_then(|value| value.parse::().ok()), - Some(PROTOCOL_VERSION) + Some(ADVERTISED_PROTOCOL_VERSION) ); assert_eq!( facts.get("spawned_daemon").map(String::as_str), diff --git a/tests/m5_5_acceptance.rs b/tests/m5_5_acceptance.rs index ee66ea8..c8c87ed 100644 --- a/tests/m5_5_acceptance.rs +++ b/tests/m5_5_acceptance.rs @@ -37,8 +37,8 @@ use pmacs::cell::Color; #[cfg(feature = "crdt")] use pmacs::overlay_color::color_for_slot; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key, - KeyEvent, Modifiers, PROTOCOL_VERSION, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, + Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, }; use pmacs::transport::{read_message, write_message}; @@ -52,9 +52,9 @@ use common::daemon::{ /// Read the daemon's `Hello`, send our `AttachRequest`, return the Hello. fn do_handshake(stream: &mut UnixStream) -> Hello { let hello: Hello = read_message(stream).expect("read Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_default_caps(), initial_size: CellSize::new(24, 80), }; @@ -418,7 +418,7 @@ fn version_mismatch_clean_disconnect() { // Read Hello. let hello: Hello = read_message(&mut stream).expect("Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); // Send AttachRequest with wrong protocol version. let req = AttachRequest { @@ -431,7 +431,7 @@ fn version_mismatch_clean_disconnect() { // Expect Goodbye(VersionMismatch). match read_message::(&mut stream) { Ok(InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client })) => { - assert_eq!(server, PROTOCOL_VERSION); + assert_eq!(server, ADVERTISED_PROTOCOL_VERSION); assert_eq!(client, 999); } other => panic!("expected VersionMismatch Goodbye, got {other:?}"), @@ -1145,9 +1145,9 @@ fn m10_10_production_attach_negotiates_crdt_replica() { // Production handshake — NOT the test `attach_multi()` path. let hello: Hello = read_message(&mut stream).expect("read Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: pmacs::attach::build_capabilities(), initial_size: CellSize::new(24, 80), }; @@ -1183,9 +1183,9 @@ fn m10_10_production_attach_non_crdt_build_does_not_negotiate_crdt_replica() { stream .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); - let _hello: Hello = read_message(&mut stream).expect("read Hello"); + let hello: Hello = read_message(&mut stream).expect("read Hello"); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: pmacs::attach::build_capabilities(), initial_size: CellSize::new(24, 80), }; @@ -2171,7 +2171,7 @@ fn m10_10_f14_production_path_keystroke_flows_to_broadcast() { .unwrap(); let hello_a: Hello = read_message(&mut stream_a).expect("A Hello"); let req_a = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello_a.protocol_version, frontend_capabilities: pmacs::attach::build_capabilities(), initial_size: CellSize::new(24, 80), }; diff --git a/tests/m5_7_acceptance.rs b/tests/m5_7_acceptance.rs index ec0733d..ce85591 100644 --- a/tests/m5_7_acceptance.rs +++ b/tests/m5_7_acceptance.rs @@ -77,7 +77,7 @@ use nix::unistd::Pid; use tempfile::TempDir; use pmacs::attach::PMACS_TEST_SSH_BIN; -use pmacs::protocol::{Hello, PROTOCOL_VERSION}; +use pmacs::protocol::{ADVERTISED_PROTOCOL_VERSION, Hello}; use pmacs::transport::read_message; // --------------------------------------------------------------------------- @@ -385,7 +385,7 @@ fn daemon_attach_bridges_hello_from_existing_daemon() { // verbatim. (No AttachRequest sent — the daemon will hold the // attach slot until the bridge stdin closes below.) let hello: Hello = read_message(&mut bridge_stdout).expect("read Hello via bridge"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); // Tear down: drop bridge stdin → bridge's stdin→socket copy sees // EOF, shuts down the socket write half, the daemon notices and @@ -424,7 +424,7 @@ fn daemon_attach_auto_starts_missing_daemon() { // bound the socket and the bridge connected. let hello: Hello = read_message(&mut bridge_stdout).expect("read Hello via auto-started daemon"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); // The lockfile must exist now: `acquire_lock` writes it on // daemon startup. (Existence of the lockfile is what proves diff --git a/tests/m5_perf_acceptance.rs b/tests/m5_perf_acceptance.rs index 4d1a680..12139b3 100644 --- a/tests/m5_perf_acceptance.rs +++ b/tests/m5_perf_acceptance.rs @@ -72,8 +72,8 @@ use tempfile::TempDir; use pmacs::cell::CellSize; use pmacs::protocol::{ - AttachRequest, FrontendCapabilities, FrontendEvent, Hello, InstanceMessage, Key, KeyEvent, - Modifiers, PROTOCOL_VERSION, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, Hello, + InstanceMessage, Key, KeyEvent, Modifiers, }; use pmacs::transport::{TransportError, read_message, write_message}; @@ -158,9 +158,9 @@ fn build_default_caps() -> FrontendCapabilities { fn do_handshake(stream: &mut UnixStream) -> Hello { let hello: Hello = read_message(stream).expect("read Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); let req = AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_default_caps(), initial_size: CellSize::new(24, 80), }; diff --git a/tests/mode_system_wiring_acceptance.rs b/tests/mode_system_wiring_acceptance.rs index bd79b4e..16b6fe5 100644 --- a/tests/mode_system_wiring_acceptance.rs +++ b/tests/mode_system_wiring_acceptance.rs @@ -11,8 +11,8 @@ use std::time::{Duration, Instant}; use pmacs::cell::{Cell, CellSize, Glyph}; use pmacs::protocol::{ - AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, - PROTOCOL_VERSION, + ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, + Key, KeyEvent, Modifiers, }; use pmacs::transport::{read_message, write_message}; @@ -75,11 +75,11 @@ fn attach(daemon: &TestDaemon) -> (Client, Grid) { .set_read_timeout(Some(Duration::from_secs(5))) .expect("set daemon handshake timeout"); let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); - assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION); write_message( &mut stream, &AttachRequest { - protocol_version: PROTOCOL_VERSION, + protocol_version: hello.protocol_version, frontend_capabilities: build_default_caps(), initial_size: CellSize::new(ROWS, COLS), }, diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index f729aba..9656491 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -716,8 +716,8 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { assert_eq!( facts.get("server_protocol_version").copied(), - Some("21"), - "the real daemon negotiated v21 with the real client: {text}" + Some("20"), + "the dark v21 wire slice must keep the real client on v20: {text}" ); assert_eq!( facts.get("entered_terminal_mode").copied(), From 9e20175dadc07066caadf772a1ade8ef4d0cdac2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 14:17:43 -0400 Subject: [PATCH 88/91] Wait for required PTY output in the GPU probe Keep the real GPU/PTY acceptance probe running until the child output that its report asserts has actually reached the terminal frame. This closes the blank-last-frame race exposed by the v20-compatible handshake. --- pmacs-gpu/src/main.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index bd961e1..a35de2d 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -857,7 +857,15 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.observed_resized_frame = true; } } - if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 { + // Do not exit merely because resize/composition happened + // first: that races the PTY child's initial output and + // produces a self-contradictory "successful" probe report + // whose later acceptance assertion must reject it. + if !quiet + && facts.observed_resized_frame + && facts.rendered_nonuniform_frames >= 2 + && facts.last_frame_text.contains("VTERMROW") + { break; } } From 80b761bb03bafd83d2b0cc842e562f1de5668279 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 14:29:04 -0400 Subject: [PATCH 89/91] Record the regated PR 184 review head Capture the exact review-fix and GPU probe checkpoints, the full green gate evidence, and the classified sandbox-only socket failure in the cross-machine active-work ledger. --- docs/active-work.md | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 837cd56..1efc59a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -305,15 +305,16 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 REVIEW FIX IN PROGRESS; PR #184 OPEN +## Bottom-panel lane (Arc 7) — 2B-1 REGATED; PR #184 OPEN FOR REVIEW Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing revision 5's three-way split of 2B was explicitly approved on 2026-07-27; revision 6 records PR #184's review correction. **Stage 2B-1 is implemented and integrated with canonical `main` @ `7fd646d`. -The previous head was fully gated at `c8895a8`, but review round 2 found -four issues and the corrected head must run the full gate again. PR -#184 is open and must not merge before user review.** +Review round 2's four findings are corrected at `ab7c207`; the +gate-found GPU/PTY probe barrier is corrected and the complete suite is +green at `9e20175`. PR #184 is open and must not merge before user +review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. @@ -333,7 +334,7 @@ four issues and the corrected head must run the full gate again. PR `9b364ad`: `PanelFrame` now identifies its buffer, the transport ratchet covers the actual attach path rather than a detached codec assertion, and shared grid bounds have one validator. -- **Review round 2 found four issues; fixes are in progress:** the +- **Review round 2 found four issues, corrected at `ab7c207`:** the server-first `Hello` made the advertised v20↔v21 compatibility one-way; `COHERENCE.md` and `docs/agent-handoff.md` still named only v20 schema support; framing §9 named a nonexistent aggregate 2B @@ -343,6 +344,15 @@ four issues and the corrected head must run the full gate again. PR real-daemon existing-v20-client acceptance, updates all three durable records, names the exact slice suites, and asserts both rejecting fixtures are exactly `limit + 1`. +- **The full gate exposed and corrected a contradiction in Vterm Stage + 3's headless probe at `9e20175`.** Its loop exited as soon as resize + plus two nonuniform composites were observed, while its acceptance + later required the PTY child's `VTERMROW` output in the final frame. + The v20-compatible handshake made that scheduling race deterministic: + terminal mode, five frames, and resize all succeeded, but the report + sampled a blank frame. The probe now waits for the exact child-output + observation its acceptance asserts. The formerly failing exact + GPU/PTY test passes, and the full nine-test Stage 3 target passes. - **The full gate found and corrected two 2B-1 omissions:** the statusline version ladder still pinned v20/rejected v21, and Vterm Stage 3 pinned v20 both structurally and in its real headless probe. @@ -381,6 +391,24 @@ four issues and the corrected head must run the full gate again. PR exact test passed immediately in isolation with one test thread, and the mandatory complete rerun passed **202/202**. This is retained as classified gate evidence, not erased as a clean first pass. +- **The corrected review-round-2 head is fully green at `9e20175`:** + formatting; strict workspace Clippy; library **1,849 passed + 3 + ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel + Stage 1 / 2A / 2B-1 **46 / 17 / 16**; folding Stage 2 **48**; GPU + font **11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU + initial target and invocation **15 / 15 CRDT**; the handshake + consumers m5_5 / m5_7 / mode-system wiring **36 / 7 / 1 CRDT** + (the release-only m5 perf test remains ignored by its standing + contract); Vterm Stages 1 / 2 / 3 **10 / 6 / 9 CRDT**, including the + required real daemon + PTY + wgpu probe; M4 **121 passed + 3 ignored + + 1 filtered**; required GPU **202/202**; the isolated-config, + one-invocation full workspace sweep; and `git diff --check`. + - An initial default-library attempt inside the restricted tool + sandbox produced three `Operation not permitted` failures in + socket-based attach tests. The authoritative outside-sandbox rerun + passed all **1,849 + 3 ignored**, and the matching CRDT run passed. + This is retained as environment classification, not presented as a + clean first attempt. - **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine belongs to 2B-2; the GPU band and negotiated capability flip belong From 9c79ce13b2b73f148c15e9110c41bc044dfa1068 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 15:28:14 -0400 Subject: [PATCH 90/91] Fix fixture-specific GPU probe completion Let producer probes name the frame text they require while input probes finish on their latched echo observation. Report and assert whether the probe reached that evidence so the 20-second safety deadline cannot masquerade as successful completion. The CAT acceptance now finishes in 0.32 seconds instead of waiting out the full deadline, while the VTERMROW producer still waits for its own PTY breadcrumb. --- pmacs-gpu/src/main.rs | 19 +++++++++++++++++-- tests/vterm_stage3_acceptance.rs | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index a35de2d..3a8bb55 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -779,11 +779,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { .ok() .and_then(|value| value.parse::().ok()) .map(std::time::Duration::from_millis); + // Normal probes stop only after their fixture-specific evidence arrives. + // A producer fixture names the text it must paint; an input fixture uses + // the latched echo observation. Keeping that choice outside this generic + // runner prevents one fixture's breadcrumb from forcing another fixture + // to sit on the 20-second safety deadline. + let expected_frame_text = std::env::var("PMACS_GPU_PROBE_EXPECT_TEXT") + .ok() + .filter(|value| !value.is_empty()); let quiet = observe_window.is_some(); let deadline = std::time::Instant::now() + observe_window.unwrap_or_else(|| std::time::Duration::from_secs(20)); let mut sent_input = false; let mut sent_resize = false; + let mut completion_observed = false; while std::time::Instant::now() < deadline { let Ok(event) = rx.recv_timeout(std::time::Duration::from_millis(200)) else { continue; @@ -857,15 +866,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { facts.observed_resized_frame = true; } } + let fixture_evidence_observed = expected_frame_text.as_deref().map_or_else( + || facts.input_echo_observed, + |expected| facts.last_frame_text.contains(expected), + ); // Do not exit merely because resize/composition happened - // first: that races the PTY child's initial output and + // first: that races the fixture's required PTY evidence and // produces a self-contradictory "successful" probe report // whose later acceptance assertion must reject it. if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 - && facts.last_frame_text.contains("VTERMROW") + && fixture_evidence_observed { + completion_observed = true; break; } } @@ -898,6 +912,7 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 { let _ = writeln!(out, "last_title={}", facts.last_title.unwrap_or_default()); let _ = writeln!(out, "last_frame_text={}", facts.last_frame_text); let _ = writeln!(out, "input_echo_observed={}", facts.input_echo_observed); + let _ = writeln!(out, "completion_observed={completion_observed}"); let _ = writeln!(out, "disconnect={}", facts.disconnect.unwrap_or_default()); if let Err(error) = std::fs::write(report, out) { eprintln!( diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 9656491..f6b1f7f 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -691,6 +691,9 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { .arg(&report) // The chord the probe presses to run `vterm-probe.open`. .env("PMACS_GPU_PROBE_OPEN_KEY", "t") + // This producer fixture does not consume the probe's input; wait + // instead for its own live cursor-addressed breadcrumb. + .env("PMACS_GPU_PROBE_EXPECT_TEXT", "VTERMROW") .output() .expect("run the headless GPU probe"); @@ -746,6 +749,11 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() { .is_some_and(|t| t.contains("VTERMROW")), "the child's cursor-addressed output must reach the rendered frame: {text}" ); + assert_eq!( + facts.get("completion_observed").copied(), + Some("true"), + "the probe must finish on the fixture's PTY evidence, not its deadline: {text}" + ); let declarations: u32 = facts .get("declarations") .and_then(|v| v.parse().ok()) @@ -1311,4 +1319,10 @@ fn gpu_terminal_input_reaches_the_child_and_returns_in_a_frame() { "the typed character must reach the child and return: {}", report() ); + assert_eq!( + facts.get("completion_observed").map(String::as_str), + Some("true"), + "the probe must finish on the latched input echo, not its deadline: {}", + report() + ); } From 5539b6e8c647edb7ed320974f668912f5d4a8aa4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 15:29:55 -0400 Subject: [PATCH 91/91] Record the fixture-specific PR 184 probe fix Capture the follow-up review finding, the evidence-driven completion contract, the exact corrected CAT duration, and the proportional green gate matrix at 9c79ce1. --- docs/active-work.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 1efc59a..6a47763 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -313,8 +313,9 @@ revision 5's three-way split of 2B was explicitly approved on 2B-1 is implemented and integrated with canonical `main` @ `7fd646d`. Review round 2's four findings are corrected at `ab7c207`; the gate-found GPU/PTY probe barrier is corrected and the complete suite is -green at `9e20175`. PR #184 is open and must not merge before user -review.** +green at `9e20175`. The follow-up fixture-specific probe correction is +committed and proportionally regated at `9c79ce1`. PR #184 is open and +must not merge before user review.** - **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on `githubsucks/main` @ `7fd646d` by merge because review had begun. @@ -353,6 +354,14 @@ review.** sampled a blank frame. The probe now waits for the exact child-output observation its acceptance asserts. The formerly failing exact GPU/PTY test passes, and the full nine-test Stage 3 target passes. +- **Follow-up review corrected the probe barrier's fixture leak at + `9c79ce1`.** The generic runner hard-coded the producer fixture's + `VTERMROW` breadcrumb, so the CAT input fixture could satisfy every + assertion but never satisfy the loop exit and waited out the + 20-second safety deadline. Producer probes now name their required + frame text while input probes finish on the latched echo. The report + exposes `completion_observed`, and both paths assert it, so a + deadline-driven pass cannot hide the stall again. - **The full gate found and corrected two 2B-1 omissions:** the statusline version ladder still pinned v20/rejected v21, and Vterm Stage 3 pinned v20 both structurally and in its real headless probe. @@ -409,6 +418,15 @@ review.** passed all **1,849 + 3 ignored**, and the matching CRDT run passed. This is retained as environment classification, not presented as a clean first attempt. +- **The fixture-specific follow-up is proportionally green at + `9c79ce1`:** formatting and strict workspace Clippy; protocol + **17/17**; bottom-panel Stage 2B-1 **16/16**; Vterm Stage 3 **9/9 + CRDT** with the real daemon + PTY + required wgpu probe in **5.72 s**; + the formerly stalled CAT path **1/1 in 0.32 s**; required GPU + **202/202**; and `git diff --check`. The first Stage 2B-1 and Vterm + attempts inside the restricted tool sandbox reproduced the classified + Unix-socket `Operation not permitted` denial; their authoritative + outside-sandbox reruns passed. - **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1 lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine belongs to 2B-2; the GPU band and negotiated capability flip belong