fix(lsp): carry the GlobPattern form so plain-string globs can match, and cancel superseded watchers (#233)
Implements D1 and D2 from docs/lsp-file-watcher-framing.md (revision 1, approved 2026-08-10). The user ruled that the walking survives this lane; D3 gets its own framing. D1. resolve_watcher now returns (base, pattern, form) and the watch record carries the form. Per LSP, a plain-string glob matches the file's ABSOLUTE path while a RelativePattern's pattern is relative to its base --- and resolve_watcher discarded the distinction, so every downstream consumer matched relatively. Real servers send absolute globs: rust-analyzer was never told about any file change (all six of its globs absolute), and gopls saw go.mod but never a .go edit. The match subject is chosen in start_file_watcher --- form "absolute" matches base .. "/" .. rel, form "relative" matches rel unchanged. scan_tree still walks in relative terms; only the string handed to the matcher changes. D2. register_file_watchers now cancels the outgoing record list before file_watchers[skey][reg.id] = recs drops the only reference to it. The cancel loop is factored into cancel_watch_records, shared with unregister_file_watchers, so the two paths cannot diverge. rust-analyzer registers the same id twice with no unregister between --- previously 12 concurrent pollers, 6 permanently uncancellable. Verification, per the framing's plan. Three new fake-LSP modes and tests beside m4_24, each mutation-tested against the defect it names: - filewatchabs registers a plain-string absolute glob whose relative reading matches nothing. Red before D1 (reverting the match subject fails exactly this test), green after. - filewatchflat registers a RelativePattern without a leading **/ (*.txt at the base) --- F2's guard. Matching every form absolutely fails exactly this test, so the obvious wrong fix cannot land green. It also pins that a base-level pattern does not match into subdirectories. - filewatchrereg registers the same id twice (rust-analyzer's shape). The witness is observable polling, not table shape: f.old exists on disk before either .new event lands, so a leaked watcher at the same 250ms cadence reports it before the second positive. Reverting D2 fails exactly this test, the leaked .old event visible in .received. m4_24 stayed green under all three mutations --- the framing's F1 finding (the existing test is insensitive to D1 in both directions), confirmed rather than assumed. It is kept unchanged. The framing doc records the approval and the answered ruling; the active-work lane moves to IMPLEMENTED with the verification results. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5cbcb1cf03
commit
ed3033c1fb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -332,6 +332,80 @@ fn main() {
|
|||
});
|
||||
write_frame(&mut stdout, &req);
|
||||
}
|
||||
// Issue #233 D1: `filewatchabs` registers the same watcher
|
||||
// as a PLAIN-STRING glob — `<base>/**/*.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 `<base>/.received` as a test
|
||||
|
|
|
|||
|
|
@ -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 `<base>/**/*.txt` as a bare string —
|
||||
/// the form rust-analyzer and gopls actually send. Its relative
|
||||
/// reading matches nothing (an anchored `^<base>/…` 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`
|
||||
|
|
|
|||
Loading…
Reference in New Issue