diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua
index 9ace580..d247d06 100644
--- a/builtin/runtime/lsp.lua
+++ b/builtin/runtime/lsp.lua
@@ -1924,7 +1924,8 @@ end
local FILE_WATCH_INTERVAL_MS = 250
-- file_watchers[tostring(sid)][registrationId] = list of watch records
--- ({ cancelled = bool, _sleep = handle? }), one per glob watcher.
+-- ({ cancelled = bool, form = "relative"|"absolute", _sleep = handle? }),
+-- one per glob watcher.
local file_watchers = {}
-- WatchKind is a bitmask (Create=1, Change=2, Delete=4); test it
@@ -2058,7 +2059,18 @@ end
local FC_CREATED, FC_CHANGED, FC_DELETED = 1, 2, 3
local function start_file_watcher(sid, base, glob, kind_mask, record)
- local matches = glob_matcher(glob)
+ -- Per LSP, a plain-string glob matches the file's ABSOLUTE path,
+ -- while a RelativePattern's pattern is relative to its base — the
+ -- record's `form` (from resolve_watcher) picks the match subject.
+ -- scan_tree always walks in relative terms; only the string handed
+ -- to the matcher changes.
+ local match_glob = glob_matcher(glob)
+ local matches = match_glob
+ if record.form == "absolute" then
+ matches = function(rel)
+ return match_glob(base .. "/" .. rel)
+ end
+ end
pmacs.async(function()
local prev = scan_tree(base, matches)
while not record.cancelled and server_is_live(sid) do
@@ -2097,22 +2109,33 @@ local function start_file_watcher(sid, base, glob, kind_mask, record)
end
-- Resolve a GlobPattern (string | { baseUri, pattern }) to
--- (base_dir, pattern). A bare string with no base falls back to the
--- directory of an attached file on `sid` (best effort).
+-- (base_dir, pattern, form). The form must travel with the pair: a
+-- RelativePattern's pattern is relative to its baseUri, but a bare
+-- string matches the file's ABSOLUTE path (real servers send absolute
+-- globs), and dropping the distinction here is what made those globs
+-- unable to match anything. A bare string with no base falls back to
+-- the directory of an attached file on `sid` (best effort).
local function resolve_watcher(sid, gp)
if type(gp) == "table" and gp.baseUri then
- return pmacs.lsp.path_for_uri(gp.baseUri), gp.pattern or "**"
+ return pmacs.lsp.path_for_uri(gp.baseUri), gp.pattern or "**", "relative"
end
if type(gp) == "string" then
for _, rec in pairs(attachments) do
if rec.server == sid and rec.uri then
local p = pmacs.lsp.path_for_uri(rec.uri)
local dir = p and p:match("^(.*)/[^/]*$")
- if dir then return dir, gp end
+ if dir then return dir, gp, "absolute" end
end
end
end
- return nil, nil
+ return nil, nil, nil
+end
+
+local function cancel_watch_records(recs)
+ for _, r in ipairs(recs or {}) do
+ r.cancelled = true
+ if r._sleep then pcall(function() r._sleep:cancel() end) end
+ end
end
local function register_file_watchers(sid, registrations)
@@ -2120,11 +2143,16 @@ local function register_file_watchers(sid, registrations)
file_watchers[skey] = file_watchers[skey] or {}
for _, reg in ipairs(registrations or {}) do
if reg.method == "workspace/didChangeWatchedFiles" then
+ -- Re-registering a live id supersedes it (rust-analyzer does
+ -- this): cancel the outgoing records first, because the table
+ -- write below drops the only reference to them and an
+ -- uncancelled record polls until the server dies.
+ cancel_watch_records(file_watchers[skey][reg.id])
local recs = {}
for _, w in ipairs((reg.registerOptions or {}).watchers or {}) do
- local base, pat = resolve_watcher(sid, w.globPattern)
+ local base, pat, form = resolve_watcher(sid, w.globPattern)
if base and pat then
- local r = { cancelled = false }
+ local r = { cancelled = false, form = form }
recs[#recs + 1] = r
start_file_watcher(sid, base, pat, w.kind or 7, r)
end
@@ -2139,10 +2167,7 @@ local function unregister_file_watchers(sid, unregs)
if not byid then return end
for _, u in ipairs(unregs or {}) do
if u.method == "workspace/didChangeWatchedFiles" and byid[u.id] then
- for _, r in ipairs(byid[u.id]) do
- r.cancelled = true
- if r._sleep then pcall(function() r._sleep:cancel() end) end
- end
+ cancel_watch_records(byid[u.id])
byid[u.id] = nil
end
end
diff --git a/docs/active-work.md b/docs/active-work.md
index c325c73..d32a95b 100644
--- a/docs/active-work.md
+++ b/docs/active-work.md
@@ -210,7 +210,7 @@ hazard in a shape that looks committed. **A documented error message
that never appears is worse than no documentation**, because the reader
waits for a signal that is not coming.
-## LSP file watcher (issue #233) — branch OPEN, framing revision 1 AWAITING REVIEW
+## LSP file watcher (issue #233) — IMPLEMENTED, awaiting PR review
**Issue #233** — https://github.com/levineuwirth/pmacs/issues/233.
**URGENT by user ruling 2026-08-10: PR #227 is held unmerged until this
@@ -220,12 +220,27 @@ is resolved**, even though #227 is green and cleared.
**`githubsucks/lsp-file-watcher` is the authoritative tip** — the ref,
not a SHA. Recover with
`git fetch githubsucks && git checkout lsp-file-watcher`.
-- **Framing `docs/lsp-file-watcher-framing.md`, revision 1, DRAFT.**
- Committed at the branch's first commit so it is portable during
- review — the standing lesson from the GUI arc framing, which spent two
- review rounds as an untracked file in one worktree.
-- **NO IMPLEMENTATION MAY START** until revision 1 is approved *and* the
- open ruling below is answered.
+- **Framing `docs/lsp-file-watcher-framing.md`, revision 1, APPROVED
+ 2026-08-10.** The ruling that blocked implementation is ANSWERED (see
+ below); D1 and D2 are implemented on the branch atop the framing
+ commit. Committed at the branch's first commit so it is portable
+ during review — the standing lesson from the GUI arc framing, which
+ spent two review rounds as an untracked file in one worktree.
+- **What the implementation is**: `resolve_watcher` returns
+ `(base, pattern, form)` and the watch record carries the form —
+ `"absolute"` (plain string) matches `base .. "/" .. rel`,
+ `"relative"` (RelativePattern) matches `rel`; and
+ `register_file_watchers` cancels the outgoing record list through the
+ new `cancel_watch_records` (shared with `unregister_file_watchers`)
+ before the table write drops the only reference to it.
+- **Verification**: three new discriminating tests beside `m4_24`
+ (`filewatchabs` / `filewatchflat` / `filewatchrereg` fake-LSP modes),
+ each mutation-tested against the defect it names: reverting D1 fails
+ only the plain-string test; matching every form absolutely fails only
+ the RelativePattern guard; reverting D2 fails only the
+ re-registration test, with the leaked watcher's `.old` event visible
+ in `.received`. `m4_24` stayed green under all three mutations —
+ its recorded insensitivity, confirmed.
**Not a #232 regression.** The statusline activity indicator is correct;
it renders real in-flight jobs. What changed on 2026-08-09 is
@@ -251,21 +266,27 @@ that found this, and is explicitly not the fix.
`resolve_watcher` returns `(base, pattern)` and discards which form
it came from, so its contract has to change — not just the match
subject.
-- **OPEN RULING, blocking the start of implementation:** D1 and D2 fix
- correctness and the leak but **do not stop the walking** — `walk`
- recurses unconditionally and `matches` gates only recording. After
- this lane rust-analyzer still walks the whole tree every 250 ms, six
- times per tick instead of twelve. If the acceptance bar is "the
- modeline stops flipping", this lane does not meet it and **D3 must be
- framed first**.
+- **RULING ANSWERED 2026-08-10:** D1 and D2 fix correctness and the
+ leak but **do not stop the walking** — `walk` recurses
+ unconditionally and `matches` gates only recording. After this lane
+ rust-analyzer still walks the whole tree every 250 ms, six times per
+ tick instead of twelve. **The user accepted that scope**: the
+ acceptance bar for this lane is correctness and the leak, not "the
+ flipping stops". The walking is D3's, framed separately.
- **D3 deferred** with what was checked: there is **no `notify`/inotify
dependency in the tree**, so a real filesystem-notification primitive
is a new crate plus a Rust primitive plus its binding; and there is
**no ignore-list infrastructure** to reuse (`src/project.rs` knows
`.git` as a marker, not as something to skip). D3 is a
`COHERENCE.md` §9 concern and needs its own framing.
-- **Gates:** `./scripts/gate --acceptance m4_acceptance` plus the
- touched LSP acceptance suites. No `--protocol` — no wire change.
+- **Gates:** `./scripts/gate --acceptance m4_acceptance` — **all nine
+ steps green, 2026-08-10** (lib 1,928; lib-crdt 2,113; m4 154 with the
+ standing `basedpyright` skip; GPU 243 under `PMACS_REQUIRE_GPU=1`;
+ full-workspace sweep 3,891 passed / 0 failed across 118 targets;
+ `diff --check` clean). The sweep covers the eleven other acceptance
+ suites that spawn the fake LSP; the three new modes are additive and
+ no existing mode's behaviour changes. No `--protocol` — no wire
+ change.
## `scripts/gate` — PR #225 OPEN (build tooling)
diff --git a/docs/lsp-file-watcher-framing.md b/docs/lsp-file-watcher-framing.md
index 04eae7c..2fe8e0d 100644
--- a/docs/lsp-file-watcher-framing.md
+++ b/docs/lsp-file-watcher-framing.md
@@ -1,7 +1,9 @@
# LSP file watcher — framing
-**Status: revision 1 — DRAFT, awaiting review. No implementation may
-begin from this document.**
+**Status: revision 1 — APPROVED 2026-08-10.** The user ruled that D1
+and D2 proceed with the walking explicitly surviving this lane; D3 gets
+its own framing. The acceptance bar for this lane is correctness and
+the leak, not "the flipping stops".
Answers issue #233. **Scope is D1 and D2 only** — the two bug-shaped
defects. D3 (the polling cost) is named here, deferred with reasons, and
@@ -138,6 +140,9 @@ the acceptance bar for this lane is "the flipping stops", this lane does
not meet it** and should not be started until D3 is framed. That is a
ruling for the user, not an assumption to make quietly.
+**Answered 2026-08-10: the user accepted this scope.** D1 and D2
+proceed; the walking is D3's problem, framed separately.
+
## D3 — deferred, with what was checked
Options named in the issue: coalesce a server's watchers into one scan;
diff --git a/src/bin/pmacs_fake_lsp.rs b/src/bin/pmacs_fake_lsp.rs
index 19dc0b6..9a051ac 100644
--- a/src/bin/pmacs_fake_lsp.rs
+++ b/src/bin/pmacs_fake_lsp.rs
@@ -332,6 +332,80 @@ fn main() {
});
write_frame(&mut stdout, &req);
}
+ // Issue #233 D1: `filewatchabs` registers the same watcher
+ // as a PLAIN-STRING glob — `/**/*.txt`, the form
+ // rust-analyzer and gopls actually send. Per LSP it matches
+ // the file's ABSOLUTE path; its relative reading matches
+ // nothing, so the mode discriminates the match subject.
+ ("initialized", _) if mode == "filewatchabs" => {
+ let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
+ let req = serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": 9301,
+ "method": "client/registerCapability",
+ "params": { "registrations": [{
+ "id": "watch-abs",
+ "method": "workspace/didChangeWatchedFiles",
+ "registerOptions": { "watchers": [{
+ "globPattern": format!("{base}/**/*.txt"),
+ "kind": 7
+ }] }
+ }] }
+ });
+ write_frame(&mut stdout, &req);
+ }
+ // Issue #233 F2 guard: `filewatchflat` registers a
+ // RelativePattern whose pattern has no leading `**/`
+ // (`*.txt` at the base). It matches base-level files
+ // RELATIVELY and no absolute path at all, so a fix that
+ // matches every form against the absolute path goes red.
+ ("initialized", _) if mode == "filewatchflat" => {
+ let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
+ let req = serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": 9302,
+ "method": "client/registerCapability",
+ "params": { "registrations": [{
+ "id": "watch-flat",
+ "method": "workspace/didChangeWatchedFiles",
+ "registerOptions": { "watchers": [{
+ "globPattern": {
+ "baseUri": format!("file://{base}"),
+ "pattern": "*.txt"
+ },
+ "kind": 7
+ }] }
+ }] }
+ });
+ write_frame(&mut stdout, &req);
+ }
+ // Issue #233 D2: `filewatchrereg` registers the SAME id
+ // twice with no unregister between — `**/*.old` then
+ // `**/*.new` — exactly rust-analyzer's shape. The second
+ // registration must supersede the first: only `.new`
+ // events may ever reach `.received`.
+ ("initialized", _) if mode == "filewatchrereg" => {
+ let base = std::env::var("PMACS_FAKE_LSP_WATCH_BASE").unwrap_or_default();
+ for (rid, pattern) in [(9303, "**/*.old"), (9304, "**/*.new")] {
+ let req = serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": rid,
+ "method": "client/registerCapability",
+ "params": { "registrations": [{
+ "id": "watch-re",
+ "method": "workspace/didChangeWatchedFiles",
+ "registerOptions": { "watchers": [{
+ "globPattern": {
+ "baseUri": format!("file://{base}"),
+ "pattern": pattern
+ },
+ "kind": 7
+ }] }
+ }] }
+ });
+ write_frame(&mut stdout, &req);
+ }
+ }
("initialized", _) => {}
// T M4.5: the client's file-watch notifications. Append
// `type uri` lines to `/.received` as a test
diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs
index d706305..b082738 100644
--- a/tests/m4_acceptance.rs
+++ b/tests/m4_acceptance.rs
@@ -5351,6 +5351,248 @@ fn m4_24_workspace_did_change_watched_files() {
);
}
+/// Issue #233 D1 — a PLAIN-STRING `GlobPattern` matches the file's
+/// ABSOLUTE path (LSP 3.17), not the walk's relative path. The
+/// `filewatchabs` fake registers `/**/*.txt` as a bare string —
+/// the form rust-analyzer and gopls actually send. Its relative
+/// reading matches nothing (an anchored `^/…` can never match
+/// `foo.txt`), so before the fix no event could ever be reported.
+/// The watcher's base is guessed from the attached file's directory —
+/// the tempdir here, and the production path for bare-string globs.
+#[test]
+fn m4_24_plain_string_glob_matches_absolute_path() {
+ use pmacs::editor::EditorState;
+
+ let dir = tempfile::tempdir().expect("tempdir");
+ let base = dir.path().to_path_buf();
+ let base_disp = base.display().to_string();
+ let a_path = base.join("a.rs");
+ std::fs::write(&a_path, b"fn a() {}\n").expect("write a");
+ let a_disp = a_path.display().to_string();
+ let received = base.join(".received");
+ let foo_uri = format!("file://{}", base.join("foo.txt").display());
+
+ let mut state = EditorState::new_with_roots(&crate::iso::roots());
+ let fake = fake_lsp_path();
+ state
+ .lua_host
+ .lua()
+ .load(format!(
+ "pmacs.lsp.config.rust = {{ command = '{fake}',
+ env = {{ PMACS_FAKE_LSP_MODE = 'filewatchabs',
+ PMACS_FAKE_LSP_WATCH_BASE = '{base_disp}' }} }}"
+ ))
+ .exec()
+ .expect("override rust config");
+ state
+ .lua_host
+ .lua()
+ .load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
+ .exec()
+ .expect("open a.rs");
+ assert!(
+ pump_lua_flag(
+ &mut state,
+ "(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)()",
+ 5,
+ ),
+ "fake never initialized"
+ );
+
+ // Same warm-up as m4_24: let registerCapability land and the
+ // watcher take its empty baseline before files appear.
+ let warm = Instant::now() + Duration::from_millis(900);
+ while Instant::now() < warm {
+ state.tick_processes();
+ state.tick_lsp();
+ state.tick_async();
+ std::thread::sleep(Duration::from_millis(15));
+ }
+
+ std::fs::write(base.join("foo.txt"), b"one\n").expect("write foo.txt");
+ std::fs::write(base.join("bar.md"), b"md\n").expect("write bar.md");
+ assert!(
+ pump_until_file_contains(&mut state, &received, &format!("1 {foo_uri}"), 6),
+ "CREATED for foo.txt never reported under a plain-string glob; \
+ .received = {:?}",
+ std::fs::read_to_string(&received).unwrap_or_default()
+ );
+ assert!(
+ !std::fs::read_to_string(&received)
+ .unwrap_or_default()
+ .contains("bar.md"),
+ "non-matching .md must be filtered out"
+ );
+}
+
+/// Issue #233 F2 guard — a `RelativePattern` stays relative to its
+/// base. The `filewatchflat` fake registers `{ baseUri, pattern =
+/// "*.txt" }`, whose pattern has no leading `**/`: it matches
+/// base-level files RELATIVELY and cannot match any absolute path
+/// (`[^/]*` spans no `/`). Green before and after D1's fix; red
+/// against the obvious wrong fix that matches every form absolutely.
+/// `sub/nested.txt` pins the other half of the same contract: a
+/// base-level pattern must not match into subdirectories.
+#[test]
+fn m4_24_relative_pattern_without_globstar_stays_relative() {
+ use pmacs::editor::EditorState;
+
+ let dir = tempfile::tempdir().expect("tempdir");
+ let base = dir.path().to_path_buf();
+ let base_disp = base.display().to_string();
+ let a_path = base.join("a.rs");
+ std::fs::write(&a_path, b"fn a() {}\n").expect("write a");
+ let a_disp = a_path.display().to_string();
+ let received = base.join(".received");
+ let foo_uri = format!("file://{}", base.join("foo.txt").display());
+ std::fs::create_dir(base.join("sub")).expect("mkdir sub");
+
+ let mut state = EditorState::new_with_roots(&crate::iso::roots());
+ let fake = fake_lsp_path();
+ state
+ .lua_host
+ .lua()
+ .load(format!(
+ "pmacs.lsp.config.rust = {{ command = '{fake}',
+ env = {{ PMACS_FAKE_LSP_MODE = 'filewatchflat',
+ PMACS_FAKE_LSP_WATCH_BASE = '{base_disp}' }} }}"
+ ))
+ .exec()
+ .expect("override rust config");
+ state
+ .lua_host
+ .lua()
+ .load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
+ .exec()
+ .expect("open a.rs");
+ assert!(
+ pump_lua_flag(
+ &mut state,
+ "(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)()",
+ 5,
+ ),
+ "fake never initialized"
+ );
+
+ let warm = Instant::now() + Duration::from_millis(900);
+ while Instant::now() < warm {
+ state.tick_processes();
+ state.tick_lsp();
+ state.tick_async();
+ std::thread::sleep(Duration::from_millis(15));
+ }
+
+ // nested.txt is written BEFORE foo.txt, so a watcher that wrongly
+ // matched it would report it no later than foo.txt's event — the
+ // negative assertion after the positive one is race-free.
+ std::fs::write(base.join("sub").join("nested.txt"), b"deep\n").expect("write nested.txt");
+ std::fs::write(base.join("foo.txt"), b"one\n").expect("write foo.txt");
+ assert!(
+ pump_until_file_contains(&mut state, &received, &format!("1 {foo_uri}"), 6),
+ "CREATED for base-level foo.txt never reported under a \
+ RelativePattern without `**/`; .received = {:?}",
+ std::fs::read_to_string(&received).unwrap_or_default()
+ );
+ assert!(
+ !std::fs::read_to_string(&received)
+ .unwrap_or_default()
+ .contains("nested.txt"),
+ "a base-level `*.txt` RelativePattern must not match into \
+ subdirectories"
+ );
+}
+
+/// Issue #233 D2 — re-registering a live id supersedes it. The
+/// `filewatchrereg` fake registers `watch-re` TWICE with no
+/// unregister between — `**/*.old`, then `**/*.new` — exactly the
+/// shape rust-analyzer sends. The superseded watchers must STOP,
+/// asserted on observable polling rather than on table shape (the
+/// defect is precisely that the replaced records become unreachable
+/// while still polling): `f.old` exists on disk before either `.new`
+/// event lands, so a leaked first-registration watcher, polling at
+/// the same 250 ms cadence, would have reported it by the time the
+/// second `.new` positive arrives.
+#[test]
+fn m4_24_reregistration_supersedes_previous_watchers() {
+ use pmacs::editor::EditorState;
+
+ let dir = tempfile::tempdir().expect("tempdir");
+ let base = dir.path().to_path_buf();
+ let base_disp = base.display().to_string();
+ let a_path = base.join("a.rs");
+ std::fs::write(&a_path, b"fn a() {}\n").expect("write a");
+ let a_disp = a_path.display().to_string();
+ let received = base.join(".received");
+ let f_old_uri = format!("file://{}", base.join("f.old").display());
+ let f_new_uri = format!("file://{}", base.join("f.new").display());
+ let g_new_uri = format!("file://{}", base.join("g.new").display());
+
+ let mut state = EditorState::new_with_roots(&crate::iso::roots());
+ let fake = fake_lsp_path();
+ state
+ .lua_host
+ .lua()
+ .load(format!(
+ "pmacs.lsp.config.rust = {{ command = '{fake}',
+ env = {{ PMACS_FAKE_LSP_MODE = 'filewatchrereg',
+ PMACS_FAKE_LSP_WATCH_BASE = '{base_disp}' }} }}"
+ ))
+ .exec()
+ .expect("override rust config");
+ state
+ .lua_host
+ .lua()
+ .load(format!("pmacs.buffer.find_or_open('{a_disp}')"))
+ .exec()
+ .expect("open a.rs");
+ assert!(
+ pump_lua_flag(
+ &mut state,
+ "(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)()",
+ 5,
+ ),
+ "fake never initialized"
+ );
+
+ let warm = Instant::now() + Duration::from_millis(900);
+ while Instant::now() < warm {
+ state.tick_processes();
+ state.tick_lsp();
+ state.tick_async();
+ std::thread::sleep(Duration::from_millis(15));
+ }
+
+ std::fs::write(base.join("f.old"), b"old\n").expect("write f.old");
+ std::fs::write(base.join("f.new"), b"new\n").expect("write f.new");
+ assert!(
+ pump_until_file_contains(&mut state, &received, &format!("1 {f_new_uri}"), 6),
+ "CREATED for f.new never reported by the superseding watcher; \
+ .received = {:?}",
+ std::fs::read_to_string(&received).unwrap_or_default()
+ );
+ // A second positive puts at least one more full poll cycle between
+ // f.old appearing on disk and the negative assertion below.
+ std::fs::write(base.join("g.new"), b"new\n").expect("write g.new");
+ assert!(
+ pump_until_file_contains(&mut state, &received, &format!("1 {g_new_uri}"), 6),
+ "CREATED for g.new never reported by the superseding watcher"
+ );
+ assert!(
+ !std::fs::read_to_string(&received)
+ .unwrap_or_default()
+ .contains(&f_old_uri),
+ "the superseded `**/*.old` watcher is still polling after \
+ re-registration under the same id; .received = {:?}",
+ std::fs::read_to_string(&received).unwrap_or_default()
+ );
+}
+
/// Tier 1 single-binary language servers ship pre-configured in the
/// default bundle. Binary-independent: we don't spawn anything, just
/// assert the `pmacs.lsp.config` tables and the `pmacs.lsp.filetypes`