fix(compile): PR #113 round 7 — validated overlay attachment, registry-only dispose

Finding-by-finding (framing revision 13; bites via scripts/bite
against fe04aa4):

1. attach_style_overlay validates the handle. A handle's translator
   follows edits to ITS buffer only, so attaching it to another
   buffer created a render view showing spans nobody maintains —
   rejected now, with the message naming the recorded owner and
   pointing at add_style_overlay for the target buffer. A disposed
   handle's translator is gone, so re-attachment resurrected
   rendering with frozen coordinates — the disposed state is shared
   across handle clones (FromLua clones) via Arc<AtomicBool> and
   attachment after dispose() fails, pointing at add_style_overlay
   for a fresh handle. Bite: r7f1 pins cross-buffer rejection,
   same-buffer acceptance, dispose-then-attach rejection, and both
   message shapes.
2. dispose() detaches the translator through the always-registered
   SharedRegistry; only the window cleanup rides the optional
   SharedCore. Pre-fix all cleanup lived inside the SharedCore
   branch, so an install-only/headless host got success with the
   translator left attached — paying on every edit for the buffer's
   lifetime. Registry-only unit asserts the buffer's view count
   returns to baseline (and stays there on double dispose); the
   acceptance-crate twin r7f2 builds the same install-only host and
   bites via the mod.rs swap (the in-crate unit vanishes with it).

Gates: fmt; clippy workspace all-targets; lib 1535; crdt lib 1709;
compile acceptance 65; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2526/0; git diff --check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-14 10:48:13 +01:00
parent fe04aa481b
commit b6e44f21d6
3 changed files with 187 additions and 8 deletions

View File

@ -1,7 +1,24 @@
# Compile-mode — framing (Arc 5 stage 1, terminal) # Compile-mode — framing (Arc 5 stage 1, terminal)
**Revision 12 — 2026-07-14. Status: implemented on branch **Revision 13 — 2026-07-14. Status: implemented on branch
`compile-mode` (PR #113); revisions 712 fold in PR rounds 16.** `compile-mode` (PR #113); revisions 713 fold in PR rounds 17.**
Revision 13 (PR #113 round 7, findings 12): overlay handle
attachment is validated — `attach_style_overlay` rejects a handle
whose recorded buffer differs from the target (its translator
follows edits to ITS buffer only; a cross-buffer render view showed
spans nobody maintains) and rejects a disposed handle (re-attachment
resurrected rendering without the translator); the disposed state is
shared across handle clones and both error messages point at
`add_style_overlay` as the fix. And `dispose()` no longer performs
the translator detach inside the optional `SharedCore` branch: the
window cleanup uses the core when present, while the detach goes
through the always-registered `SharedRegistry` — an
install-only/headless host previously got success with the
translator left attached (and paying per edit) for the buffer's
lifetime. Bites: cross-buffer + dispose-then-attach acceptance, and
a headless-host twin in the acceptance crate (the in-crate
registry-only unit vanishes under a mod.rs swap; the twin bites).
Revision 12 (PR #113 round 6, findings 13): render-view attachment Revision 12 (PR #113 round 6, findings 13): render-view attachment
is idempotent and split-complete. Overlays expose an is idempotent and split-complete. Overlays expose an
@ -268,10 +285,14 @@ Everything below was verified by reading the code, not the roadmap.
switches clear window overlays; `attach_style_overlay(buf, switches clear window overlays; `attach_style_overlay(buf,
handle)` re-attaches the render view, idempotently per window via handle)` re-attaches the render view, idempotently per window via
the store identity, and same-buffer splits copy the render view to the store identity, and same-buffer splits copy the render view to
the new pane (Revision 12). The handle has `add`, `clear`, the new pane (Revision 12). Attachment validates the handle:
`clear_before`, `spans`, and idempotent `dispose` (teardown of the wrong-buffer and disposed handles are rejected with messages
translator + every window render view; one handle per buffer pointing at `add_style_overlay` (Revision 13). The handle has
incarnation needs no disposal). `add`, `clear`, `clear_before`, `spans`, and idempotent `dispose`
(teardown of the translator + every window render view; the
translator detach rides the always-registered registry, not the
optional editor core; one handle per buffer incarnation needs no
disposal).
- **Buffer-switch hooks**: `buffer.after-switch` exists and fires on - **Buffer-switch hooks**: `buffer.after-switch` exists and fires on
the ordinary switch paths (recentf subscribes, the ordinary switch paths (recentf subscribes,
`builtin/runtime/recentf.lua:54`). **`pmacs.editor.jump_back` does `builtin/runtime/recentf.lua:54`). **`pmacs.editor.jump_back` does

View File

@ -1754,11 +1754,19 @@ pub struct InterceptHandleLua {
pub struct StyleOverlayHandleLua { pub struct StyleOverlayHandleLua {
/// Shared style spans rendered by every attached overlay view. /// Shared style spans rendered by every attached overlay view.
spans: crate::overlay::SharedBufferStyleSpans, spans: crate::overlay::SharedBufferStyleSpans,
/// Buffer the translator was attached to. /// Buffer the translator was attached to. Attachment is
/// validated against this (round-7 finding 1): a render view on
/// any OTHER buffer would show coordinates translated only by
/// edits to this one.
buffer: BufferId, buffer: BufferId,
/// The buffer-attached translator's view id — retained so /// The buffer-attached translator's view id — retained so
/// `dispose()` can detach it. /// `dispose()` can detach it.
translator: crate::buffer::ViewId, translator: crate::buffer::ViewId,
/// Shared across handle clones (`FromLua` clones): set by
/// `dispose()`, checked by attachment — re-attaching a disposed
/// handle would resurrect rendering without its translator
/// (round-7 finding 1).
disposed: Arc<std::sync::atomic::AtomicBool>,
} }
impl FromLua for StyleOverlayHandleLua { impl FromLua for StyleOverlayHandleLua {
@ -1843,13 +1851,23 @@ impl UserData for StyleOverlayHandleLua {
// repeated creation on a long-lived buffer. Safe to call // repeated creation on a long-lived buffer. Safe to call
// twice; safe after the buffer is gone. // twice; safe after the buffer is gone.
methods.add_method("dispose", |lua, this, ()| { methods.add_method("dispose", |lua, this, ()| {
this.disposed
.store(true, std::sync::atomic::Ordering::Relaxed);
let id = crate::overlay::style_store_identity(&this.spans); let id = crate::overlay::style_store_identity(&this.spans);
// Window cleanup needs the editor core, which is
// optional app data...
if let Some(core) = lua.app_data_ref::<SharedCore>() { if let Some(core) = lua.app_data_ref::<SharedCore>() {
let mut core = core.borrow_mut(); let mut core = core.borrow_mut();
for win in core.windows.values_mut() { for win in core.windows.values_mut() {
win.overlays.retain(|v| v.overlay_identity() != Some(id)); win.overlays.retain(|v| v.overlay_identity() != Some(id));
} }
let registry = core.registry.clone(); }
// ...but the translator detach must not go through it:
// an install-only/headless host registers the registry
// WITHOUT a core, and returning success while the
// translator stays attached would leak per-edit work for
// the buffer's lifetime (round-7 finding 2).
if let Some(registry) = lua.app_data_ref::<SharedRegistry>() {
let mut r = registry.borrow_mut(); let mut r = registry.borrow_mut();
if let Ok(buf) = r.get_mut(this.buffer) { if let Ok(buf) = r.get_mut(this.buffer) {
buf.detach_view(this.translator); buf.detach_view(this.translator);
@ -2977,6 +2995,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
spans: Arc::clone(&spans), spans: Arc::clone(&spans),
buffer: id.0, buffer: id.0,
translator, translator,
disposed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}; };
attach_style_overlay_to_visible_windows(lua, id.0, &spans); attach_style_overlay_to_visible_windows(lua, id.0, &spans);
Ok(handle) Ok(handle)
@ -2990,6 +3009,26 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
"attach_style_overlay", "attach_style_overlay",
lua.create_function( lua.create_function(
move |lua, (id, handle): (BufferIdLua, StyleOverlayHandleLua)| { move |lua, (id, handle): (BufferIdLua, StyleOverlayHandleLua)| {
// Round-7 finding 1: a disposed handle's
// translator is gone — re-attaching would
// resurrect rendering with frozen coordinates —
// and a handle's translator follows edits to ITS
// buffer only, so attaching to any other buffer
// shows unmaintained spans.
if handle.disposed.load(std::sync::atomic::Ordering::Relaxed) {
return Err(mlua::Error::external(
"this style overlay handle was disposed; create a fresh \
one with pmacs.buffer.add_style_overlay",
));
}
if id.0 != handle.buffer {
return Err(mlua::Error::external(format!(
"this style overlay handle belongs to buffer {:?}; its \
spans are not translated by edits to {:?} create an \
overlay for that buffer with pmacs.buffer.add_style_overlay",
handle.buffer, id.0
)));
}
attach_style_overlay_to_visible_windows(lua, id.0, &handle.spans); attach_style_overlay_to_visible_windows(lua, id.0, &handle.spans);
Ok(()) Ok(())
}, },
@ -14848,4 +14887,42 @@ mod tests {
id.uptime_secs id.uptime_secs
); );
} }
#[test]
fn style_overlay_dispose_detaches_translator_without_a_core() {
// PR #113 round-7 finding 2: `fresh()` is the install-only /
// headless host shape — the registry is registered as app
// data, SharedCore is NOT. dispose() must detach the
// buffer-attached translator through the registry alone;
// pre-fix it returned success having done nothing, leaving
// the translator attached (and paying per edit) for the
// buffer's lifetime.
let (lua, reg, _cmds, _kms, _hks) = fresh();
lua.load(r#"_G.hbuf = pmacs.buffer.create("headless")"#)
.exec()
.unwrap();
let id = reg
.borrow()
.find_by_name("headless")
.expect("buffer exists");
let baseline = reg.borrow().get(id).unwrap().view_count();
lua.load(r"_G.hov = pmacs.buffer.add_style_overlay(_G.hbuf)")
.exec()
.unwrap();
assert_eq!(
reg.borrow().get(id).unwrap().view_count(),
baseline + 1,
"add_style_overlay attaches the translator"
);
lua.load("_G.hov:dispose()").exec().unwrap();
assert_eq!(
reg.borrow().get(id).unwrap().view_count(),
baseline,
"dispose must detach the translator with no core registered"
);
// Idempotent: a second dispose neither errors nor
// over-detaches.
lua.load("_G.hov:dispose()").exec().unwrap();
assert_eq!(reg.borrow().get(id).unwrap().view_count(), baseline);
}
} }

View File

@ -2513,6 +2513,87 @@ fn r6f3_handle_dispose_detaches_translator_and_render_views() {
assert_eq!(render_views, 0, "dispose removes the window render views"); assert_eq!(render_views, 0, "dispose removes the window render views");
} }
// ---------------------------------------------------------------------------
// PR #113 round 7 — bite tests
// ---------------------------------------------------------------------------
#[test]
fn r7f1_attach_validates_buffer_identity_and_disposed_state() {
// Round-7 finding 1: a handle's translator follows edits to ITS
// buffer only, so attaching the handle to another buffer showed
// spans nobody maintains; attaching after dispose() resurrected
// rendering without the translator. Both now fail clearly, with
// the message pointing at add_style_overlay.
let s = editor();
let (ok_cross, err_cross, ok_same, ok_after, err_after): (bool, String, bool, bool, String) =
eval(
&s,
r#"
local a = pmacs.buffer.create("*ov-a*")
local b = pmacs.buffer.create("*ov-b*")
local ov = pmacs.buffer.add_style_overlay(a)
local ok_cross, err_cross = pcall(pmacs.buffer.attach_style_overlay, b, ov)
local ok_same = pcall(pmacs.buffer.attach_style_overlay, a, ov)
ov:dispose()
local ok_after, err_after = pcall(pmacs.buffer.attach_style_overlay, a, ov)
return ok_cross, tostring(err_cross), ok_same, ok_after, tostring(err_after)
"#,
);
assert!(!ok_cross, "cross-buffer attachment must be rejected");
assert!(
err_cross.contains("belongs to buffer") && err_cross.contains("add_style_overlay"),
"pointed message naming the fix; got: {err_cross}"
);
assert!(ok_same, "same-buffer attachment stays valid");
assert!(!ok_after, "attachment after dispose must be rejected");
assert!(
err_after.contains("disposed") && err_after.contains("add_style_overlay"),
"pointed message naming the fix; got: {err_after}"
);
}
#[test]
fn r7f2_dispose_detaches_translator_in_a_headless_host() {
// Round-7 finding 2, acceptance twin (the in-crate unit vanishes
// with a mod.rs swap; this one bites): an install-only host
// registers the buffer registry as app data but NO editor core.
// Pre-fix, dispose() did all cleanup inside the optional
// SharedCore branch — returning success while the translator
// stayed attached for the buffer's lifetime.
use pmacs::lua_bindings::{
SharedCommandRegistry, SharedHookRegistry, SharedKeymapStack, SharedMenuRegistry,
SharedRegistry, install,
};
use std::cell::RefCell;
use std::rc::Rc;
let lua = mlua::Lua::new();
let reg: SharedRegistry = Rc::new(RefCell::new(pmacs::buffer_registry::BufferRegistry::new()));
let cmds: SharedCommandRegistry = Rc::new(RefCell::new(pmacs::command::CommandRegistry::new()));
let kms: SharedKeymapStack = Rc::new(RefCell::new(pmacs::keymap_stack::KeymapStack::new()));
let mns: SharedMenuRegistry = Rc::new(RefCell::new(pmacs::menu::MenuRegistry::new()));
let hks: SharedHookRegistry = Rc::new(RefCell::new(pmacs::hook::HookRegistry::new()));
install(&lua, &reg, &cmds, &kms, &mns, &hks).expect("install-only host");
lua.load(
r#"
_G.hbuf = pmacs.buffer.create("headless")
_G.hov = pmacs.buffer.add_style_overlay(_G.hbuf)
"#,
)
.exec()
.expect("create + overlay");
let id = reg
.borrow()
.find_by_name("headless")
.expect("buffer exists");
let with_translator = reg.borrow().get(id).unwrap().view_count();
lua.load("_G.hov:dispose()").exec().expect("dispose");
assert_eq!(
reg.borrow().get(id).unwrap().view_count(),
with_translator - 1,
"dispose must detach the translator without a core registered"
);
}
#[test] #[test]
fn r5f3_tracked_line_start_matches_the_scan_across_transitions() { fn r5f3_tracked_line_start_matches_the_scan_across_transitions() {
// Round-5 finding 3 is a performance fix — the per-CR/BS/erase // Round-5 finding 3 is a performance fix — the per-CR/BS/erase