fix(lean4): tie the deferred expansion 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 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LFvC4FQtux4y32KuevZ7B
This commit is contained in:
Levi Neuwirth 2026-07-26 17:00:58 -04:00
parent f3103a6953
commit 0d7ec7e3a6
5 changed files with 138 additions and 7 deletions

View File

@ -229,7 +229,26 @@ end
-- The consumer -- 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) local function on_typed_edit(rec)
depth = depth + 1
local fid = frontend_id() local fid = frontend_id()
if fid == nil then return false end 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 -- A claim by ANY chain consumer stops the chain but not this — which
-- is the point. Pairing claims the terminator it reacts to. -- is the point. Pairing claims the terminator it reacts to.
local function run_deferred() 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() local fid = frontend_id()
if fid == nil then return end if fid == nil then return end
local d = deferred[fid] local d = deferred[fid]

View File

@ -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`) ### Stage 4b — the Unicode input method (branch `lean4-stage4b-input-method`)
- Framing `docs/lean4-mode-framing.md` **revision 10** (round 10 = - Framing `docs/lean4-mode-framing.md` **revision 11** (rounds 10 and
review of the implementation). Stage 11 = review of the implementation). Stage
4a (the typed-edit consumer chain) MERGED as #179; this branch is 4b, 4a (the typed-edit consumer chain) MERGED as #179; this branch is 4b,
the Lean content that registers on it. the Lean content that registers on it.
- Footprint: `scripts/regen-lean-abbrev` (new, the generator), - 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), from `leanprover/vscode-lean4@17d1d08`, Apache-2.0),
`builtin/runtime/lean_input.lua` (new, the consumer at priority 50), `builtin/runtime/lean_input.lua` (new, the consumer at priority 50),
`src/editor.rs` (two `include_str!` blocks), `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` `#[cfg(feature = "crdt")]` `--lib` test in `src/daemon.rs`
(acceptance 45f). No protocol change (Q#LN14). Entirely Lua apart (acceptance 45f). No protocol change (Q#LN14). Entirely Lua apart
from the load sites and that one test. 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 | | drop the `cursor() == post_cursor` check | 1 |
| place the point without the context guard | 1 | | place the point without the context guard | 1 |
| load lean_input.lua after lsp.lua | 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 Acceptance 45f bit by construction: without a registered window for
the source frontend it ran six fan-outs with a nil record and proved 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 relevance check needs `cursor() == post_cursor`, as pairing's has
since #110), and cursor placement could move a buffer an intercept since #110), and cursor placement could move a buffer an intercept
had switched to. 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, - Undo is cross-peer-degraded on CRDT frontends and that is ACCEPTED,
named in the module header (Q#LN21): six source-peer optimistic named in the module header (Q#LN21): six source-peer optimistic
inserts replaced by one daemon-peer op. `set_round_trip_input` would inserts replaced by one daemon-peer op. `set_round_trip_input` would

View File

@ -139,7 +139,11 @@ commands, read `docs/active-work.md` immediately after this file.
be used. The expansion therefore runs on a SECOND be used. The expansion therefore runs on a SECOND
`buffer.after-edit` subscriber after the chain — which is how a `buffer.after-edit` subscriber after the chain — which is how a
pair character that terminates an abbreviation still pairs 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 the table must stay an ORDERED SEQUENCE (equal-length ties resolve
by source declaration order, which a `pairs`-iterated map cannot by source declaration order, which a `pairs`-iterated map cannot
express); a generator round-trip check must re-read the BYTES ON express); a generator round-trip check must re-read the BYTES ON

View File

@ -46,7 +46,7 @@ during a rebase.
## 0.1 Revision history ## 0.1 Revision history
Revision 1 — initial. Current revision: **10**. Revision 1 — initial. Current revision: **11**.
### Round 1 (rev 1 → rev 2) ### 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 `buffer.after-edit` subscriber, which is a new instance of Q#AP7 and
was unpinned. 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 ## 1. What ships
Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The 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 survives untouched. One undo restores the same text either way, because
the terminator was always its own insert. 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: Two guards this exposes, both of which pairing already carries:
- The relevance check is **three-part**, not two: buffer, window, **and - 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 unexpanded text. Pinned with the `sighelp` fake server and `(` as
the trigger — the flush carrying the terminator carries `α()`. the trigger — the flush carrying the terminator carries `α()`.
Falsified by loading lean_input.lua after lsp.lua. 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 `` 45h. **Tie-break by source order (§2.11).** `\f` + space yields ``
`f<` and `f>` are both length 2, and `f<` is declared first. Same `f<` and `f>` are both length 2, and `f<` is declared first. Same
for `\"` + space → `Ä`, first of eleven equal-length candidates. 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 languages other than Lean, and §4's rule is what keeps them out of a Lean
PR. 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 **Sections served.** §6 (interaction islands) primarily, and in the
*preventing* direction rather than the fixing one — see below. §11 *preventing* direction rather than the fixing one — see below. §11

View File

@ -98,7 +98,10 @@ fn lean_editor() -> (EditorState, PathBuf) {
fn the_finish_path_retains_the_terminator_in_one_undo_step() { fn the_finish_path_retains_the_terminator_in_one_undo_step() {
// `alp` is not a key; `alpha` is the shortest key extending it. The // `alp` is not a key; `alpha` is the shortest key extending it. The
// space does not extend anything, so it lands first and 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(); let (mut s, _f) = lean_editor();
type_str(&mut s, "\\alp "); type_str(&mut s, "\\alp ");
assert_eq!(text(&s), "α ", "terminator retained, not consumed"); 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] #[test]
fn a_pair_character_outside_a_pending_abbreviation_still_pairs() { fn a_pair_character_outside_a_pending_abbreviation_still_pairs() {
// The other direction: claiming extensions must not disable pairing // The other direction: claiming extensions must not disable pairing