Repository navigation
External-PHP loader ownership is acquired but never released — a discarded buffer serves phantom text after did_close - #367
Conversation
Watson-Branch: #365
`did_open`/`did_change` stamp `ExternalPhpText::PushedByClient` so the backing-class loader never reads disk over an unsaved edit, but nothing ever handed that ownership back. Close a buffer with its changes DISCARDED and the editor reverts in memory without writing to disk, so no `did_change_watched_files` event fires either — leaving the loader to serve text that exists neither on disk nor in any open buffer until the file happens to be reopened. `did_close` now downgrades the closed path to unowned, so the next resolution re-reads disk. It evicts nothing: the `SourceFile` input, the per-file caches and the resolved magic-member entries all survive, which keeps the ownership edge separate from the eviction question `did_close` deliberately declines to reopen. Fixes: #365
There was a problem hiding this comment.
🔄 Changes Requested
All four acceptance criteria are met, and the craft here is genuinely high — this is a well-shaped fix with unusually honest tests. One blocker, and it lives in the new edge itself.
Issues Found
🔴 The release can clobber a live buffer's ownership on a close→reopen race — main.rs:24173
did_close now sends ReleaseExternalPhpOwnership to the Salsa actor. If a didOpen for the same URI follows closely (reopen / revert / Zed's multibuffer lifecycle), the reopen's UpdateFile can reach the actor's mpsc before the close's release. The actor then does the right things in the wrong order: handle_update_file stamps PushedByClient (salsa_impl.rs:9830), and the late release removes it (salsa_impl.rs:9917) — leaving a live, open buffer unowned.
That is the exact state this PR exists to prevent, arrived at from the other side.
Why the reordering is reachable — verified, not assumed. tower-lsp 0.20 does not serialize notification handlers:
transport.rs:22—const DEFAULT_MAX_CONCURRENCY: usize = 4;transport.rs:142—service.call(req)only constructs the handler future and pushes it unawaited ontoserver_tasks_tx.transport.rs:117—.buffer_unordered(self.max_concurrency)drives up to 4 of them concurrently; they complete in await-resolution order, not arrival order.main.rs:28428—Server::new(...).serve(service)takes that default; no.concurrency_level(1).
The Salsa actor is strictly FIFO by enqueue order, so whichever handler reaches .send() first wins. did_close awaits three locks before its Salsa call (main.rs:24134, 24138, 24141); did_open awaits one (main.rs:23927) before update_file (main.rs:23940). The shorter path can win.
Why the blast radius is not contained. ensure_external_php_source_loaded does not merely serve a cross-file query — with the stamp gone it takes the _ => true arm (salsa_impl.rs:10104-10109) and mutates the shared input: existing.set_text(&mut self.db).to(text) (salsa_impl.rs:10112-10121). That is the one SourceFile every other handler reads via self.files.get(path) — loop-blocks (9925), document-symbols (10157), php-assignments (10136), patterns (10212). Your own comment at salsa_impl.rs:10108-10110 says it plainly: this write "replaces the text every per-file cache was populated from," which would otherwise "answer goto and completion out of the previous text."
So the trigger is a hover in one file and the corruption surfaces in another: an ordinary $this->member hover in any co-open Blade view backed by this class (main.rs:20442 → 20104) silently reverts the reopened buffer's text to disk. It self-heals only on the next did_change for that path — not the one that triggered it.
The property to establish (not prescribing the expression — your call which is cleanest): the release must not be able to drop an ownership stamp installed by a later open of the same path. Gating on the closed document's version/text, or on the path's absence from self.documents at actor time, both get there. Please pin it with a regression test in the same style as the existing four.
Honest calibration: the chain is compound — it needs a reopen whose text diverges from disk and a cross-file query inside the window before the next keystroke. Both links are ordinary; the chain is probabilistic rather than guaranteed. I'm blocking rather than tracking it because the defect is in the edge this PR adds (it was unreachable before — did_close previously made no Salsa call touching external_php_text), and it is the same acquire/release asymmetry the issue was filed to close.
What's Good
- The design is right. Downgrade-without-eviction keeps the ownership question and the eviction question separate, which is exactly what #365 asked for and what makes it compatible with the existing
RemoveFilerationale. - The tests are honest, and rare in being so. They drive the real
did_closehandler on theLspService::newharness and assert throughlocate_in_backing_class_files— the actual production goto/hover path (main.rs:11491,24802), not a proxy. The buffer renamesincrement→decrementso the disk/buffer distinction cannot pass vacuously, andclosing_a_document_keeps_its_resolved_magic_memberspins the magic-member survival AC #2 asks for, including the dependent's resolution through it. - The unconditional drop is reasoned, not lazy (
salsa_impl.rs:9896-9902) — "aLoadedFromDiskguard would be a branch nodidClosecan reach, and therefore one no test can honestly pin" is the right instinct. The race above is the one case that reasoning doesn't cover. - Path-keyed release covers all three acquire sites uniformly — the buffer push (
9830) and both Blade live-text stamps (10055,10063). - Error handling matches convention, and the comment justifies it correctly: the only failure is an unreachable actor, and the reader lives inside that actor.
- The comments are excellent and, checked claim by claim, accurate. CI is green on all three platforms.
📋 Non-blocking follow-ups
- None. (#364's
path_within_rootcontainment gap on this same function is correctly left out of scope — verified untouched by this diff.)
Please address the race and re-request review.
The `didClose` release added on this branch is path-keyed and fires unconditionally. tower-lsp does not serialize notification handlers (`DEFAULT_MAX_CONCURRENCY = 4` in 0.20.0, and this server takes the default), so the `didOpen` of a REOPENED buffer can reach the Salsa actor before the `didClose` that preceded it at the client. The actor then stamps `PushedByClient` for the new buffer and the late release removes it, leaving a live buffer unowned — the state the release exists to prevent, reached from the other side. That is a write, not a stale read: the loader's next pass takes the reload arm and calls `set_text` on the shared `SourceFile` every per-file query reads, so a `$this->member` hover in a Blade view could silently revert the reopened PHP buffer's text to disk. `did_open` now counts its buffer in before pushing it, and the release hands the path back only once that count runs out. Counting rather than flagging is the whole point: increments and decrements commute, so a close/reopen pair settles at one buffer whichever order it reaches the actor in. Acquire-before-push is equally deliberate — push-first leaves one window where the late close lands between the stamp and the count. A path with no count still releases. That is what a `didChangeWatchedFiles` push leaves behind — ownership with no buffer to close it — and it is the safe direction besides: every divergence falls toward consulting disk, never toward serving a buffer nobody holds. Three regression tests drive the real `did_open`/`did_close` handlers: the reopen that overtakes its close, the reopened buffer releasing on its own close, and the uncounted push. The existing tests now drive the real `did_open` too, so no helper re-spells what the handler does. Fixes: #365
|
Blocker fixed — the release is now counted, not path-keyed and unconditional.
I did not use either suggested expression, and the PR body says why in full. In short: a text/version gate passes in the one case that matters, because a reopen can carry the same unsaved bytes (and the same version, if the client keeps its buffer's counter across the close). A Four mutations run, each reddening its own test: the count guard removed (round 1's behaviour), No non-blocking follow-ups were listed. Full suite green — 3602 tests; clippy and fmt clean. |
GitHub did not dispatch the CI workflow for the previous push — only CodeQL ran on 14e278f. No source change here; this exists to fire the pull_request event the Rust matrix gates on.
|
What happened. Two pushes — What I ruled out. The workflow is What green looks like locally, on
One platform, not three. The matrix exists because Windows and macOS diverge, so treat this as unverified there. I am leaving the item in
|
`main` now carries the #364 containment guard (#366), which the ownership release work predates. Two conflicts, both mechanical: - `salsa_impl.rs`: main extracted the actor's struct literal into `SalsaActor::new`; this branch added an `external_php_open_buffers` field to that literal. Resolved by taking `SalsaActor::new` and carrying the new field into the constructor. - `tests/mod.rs`: two modules registered on the same line. Kept both. The merge then failed five of the seven ownership-release tests. `backend_for` primed the backend's `root_path` but never the ACTOR's `config_root`, and the guard from #364 fails closed without one — so every load returned `None` for want of a root rather than for the reason the test was about. `register_project_files` does not set `config_root`; only `register_config_files` and the cached-config request do, and production always registers config first. The same fixture drift was corrected in three other harnesses when #364 landed; this is the fourth. Verified: 3616 tests pass, clippy clean, fmt clean.
Releasing ownership un-blocks the LOADER. It is not the only reader of what a discarded buffer installed: `handle_get_patterns`, `handle_get_document_symbols`, `handle_get_loop_blocks` and `handle_get_php_assignments` read `files[path]` directly, and `pattern_cache` is served with no version comparison — so an entry derived from the discarded buffer is served forever rather than once. Find-references answering out of it names a symbol in no file. The final release now drops that path's Salsa input and its per-file caches along with the stamp, so every reader re-derives from disk on its next question. Still lazy: nothing is read at close time. Nothing else is evicted — the symbol index, the reverse component-usage index, the class-hierarchy index and the resolved magic-member entries all stand, which is what keeps this compatible with what `did_close` refuses to do. Dropping the input also masks the ownership release from every behavioural test: with the input gone, the loader's pushed-but-vanished arm reaches disk whether or not the stamp was handed back. A stamp left behind is not inert — it re-arms the original defect as soon as `ensure_file_registered` re-registers the path on the next hover. Two tests assert the actor's own `external_php_text` and `external_php_open_buffers` directly, through the `SalsaActor::new` constructor #364 extracted; both fail if either half of the release goes.
…rship-is-acquired-but-neve' into fix/365-external-php-loader-ownership-is-acquired-but-neve
Dropping `files[path]` and the per-file caches does not reach
`symbol_index`, `component_usage_index` or `class_hierarchy_index`: they
answer find-references and the code lenses from their own maps, having
already copied the data out of the text. A query run WHILE the buffer was
open drains the dirty queue and clears the flag, so a `view('…')` the
discarded buffer introduced kept answering find-references forever,
pointing at a file that never contained it.
`handle_update_file` marks all three dirty on every text change, and this
is one — the path's text just went from the buffer's back to disk's.
Re-queueing is not eviction: the drain runs `remove_literal_entries` +
`insert_file`, which preserves the resolved magic-member entries only a
warm or save pass can rebuild — the entries `did_close` refuses
`RemoveFile` to protect. A test pins that, and fails if the re-queue is
made destructive.
Both new tests force the deferred drain after the close; without it they
would pass whether or not the release re-queued anything.
Summary
Implements #365.
did_open/did_changestampExternalPhpText::PushedByClientso the backing-class loader never reads disk over an unsaved edit. Nothing ever handed that ownership back. Close a buffer with its changes discarded and the editor reverts in memory without writing to disk, so nodid_change_watched_filesevent fires either. The stamp survived both, and the loader went on serving text that existed neither on disk nor in any open buffer — until the file happened to be reopened.did_closenow releases the closed path, and the release is counted:did_opencounts its buffer in,did_closecounts it out, and ownership goes back to the loader only when the last buffer for that path is gone. The next resolution then re-reads disk.The release evicts nothing.
RemoveFileis not called: theSourceFileinput, the per-file caches, and the resolved magic-member entries all survive. That keeps the ownership edge separate from the eviction questiondid_closedeliberately declines to reopen.Changes
salsa_impl.rs—SalsaRequest::ReleaseExternalPhpOwnershipand itsAcquireExternalPhpOwnershipcounterpart, bothSalsaHandlemethods, and the two actor arms.SalsaActor::release_external_php_ownershipdrops the path fromexternal_php_textonce its buffer count runs out;acquire_external_php_ownershipis the count going up. Nothing else is touched.salsa_impl.rs—external_php_open_buffers, the per-path count of open editor buffers. Absent means zero.main.rs—did_openacquires before itsupdate_filepush;did_closereleases, and its comment now states the ownership-release rule beside the existing eviction rationale.salsa_impl.rs— theExternalPhpText::PushedByClientdoc said the pusher owns invalidation, without saying how long. It now names the release edge and the discarded-buffer case that needs one.tests/external_php_ownership_release.rs— seven tests, driving the realdid_openanddid_closehandlers.Round 2 — the close/reopen race
Round 1's release was path-keyed and unconditional, and that could strip a live buffer's ownership. tower-lsp does not serialize notification handlers (
DEFAULT_MAX_CONCURRENCY = 4in 0.20.0; this server takes the default), so thedidOpenof a reopened buffer can reach the Salsa actor before thedidClosethat preceded it at the client. The actor stampsPushedByClientfor the new buffer and the late release removes it — the very state the release exists to prevent, reached from the other side. It is a write, not a stale read: the loader's next pass takes the reload arm andset_texts the sharedSourceFileevery per-file query reads, so a$this->memberhover in a Blade view could silently revert the reopened PHP buffer's text to disk.The property established: the release cannot drop an ownership stamp installed by a later open of the same path.
Counting is what establishes it. Increments and decrements commute, so a close/reopen pair settles at one buffer whichever order it reaches the actor in. A flag, or any "is this the buffer I closed?" test, has to answer a question about ordering; a count does not.
Both of the expressions suggested in review were tried first, and both leave the property unproven:
self.documentsat actor time.did_closeremoves the URI fromdocumentsbefore it sends the release, so when the reopen's insert lands first, that removal erases the live entry and the check answers "not open" for a buffer that is. It closes one interleaving and not the other.The acquire goes before the push, not after. A concurrent
didClosecan land anywhere indid_open's sequence. Acquire-first is safe at every landing point: before the acquire it releases the earlier buffer's stamp, which the push then re-installs; between or after, it decrements to a non-zero count and drops nothing. Push-first opens one window — the close landing between the stamp and the count — where a live buffer is left unowned.Honest limit: the mutation table below pins what the count decides, and a sequential handler test cannot pin the ordering of two sends inside
did_open. Moving the acquire after the push leaves every test green. That ordering rests on the argument above and on the comment at the call site, not on coverage.Other decisions worth naming
A path with no count still releases. That is the state a
didChangeWatchedFilespush leaves behind — ownership with no buffer to close it — and it is the safe direction for the original defect: every divergence falls toward consulting disk, never toward serving a buffer nobody holds. Pinned bya_close_releases_a_path_no_open_ever_counted.RemoveFiledeliberately does not clear the count. Deleting a path on disk does not close its buffer, so the buffer's claim outlives the file — and its owndidClosestill balances the books.The release is fallible where
mark_pushed_by_clientis not, which is the shape #361 was bounced for. It differs here. The call fails only if the Salsa actor is unreachable, andensure_external_php_source_loaded— the reader the release exists to unblock — runs inside that same actor. A failed release cannot leave phantom text for anything to serve, because no live reader is left to serve it. The acquire logs the same way, at the leveldid_openalready uses for its own Salsa push.Sweep
The defect class is an acquire with no matching release.
mark_pushed_by_clienthas three call sites:handle_update_file, and two inensure_blade_source_registered. All three acquire on behalf of a client push, anddid_closeis the release edge for every one — it fires for the closed path whether that path is a.phpbacking class or a.blade.phptemplate.The round-2 fix adds its own pair, so the same question applies to it:
did_openis the only handler that opens a buffer,did_closethe only one that closes one (it holds the soledocuments.removein the server), and both are guarded by the sameto_file_path()check. One acquire site, one release site, no third.One acquirer holds ownership permanently and correctly:
did_change_watched_filespushes the watcher's own fresh read for files that are not open. The watcher never goes away and pushes again on every change, so that ownership needs no release. The enum doc says so explicitly.external_php_texthas exactly one reader (ensure_external_php_source_loaded), and it is unchanged by this PR.Acceptance Criteria
did_closerelinquishesExternalPhpText::PushedByClientfor the closed path, so the nextensure_external_php_source_loadedre-reads disk.RemoveFileis not called on close — the magic-member entriesdid_close's existing comment protects survive, with a test asserting they do (closing_a_document_keeps_its_resolved_magic_members).a_discarded_buffer_stops_answering_once_its_document_closes): disk declaresincrement(), an unsaved buffer renames it todecrement(),did_closearrives with no disk write, and the disk method resolves again while the buffer's does not.did_closecomment states the ownership-release rule alongside the existing eviction rationale.Test Plan
cargo clippy --all-targetsclean;cargo fmt --checkclean.did_closea_discarded_buffer_stops_answering_once_its_document_closesclosing_one_document_leaves_another_buffers_ownership_intactdid_closecallsremove_fileinstead of releasingclosing_a_document_keeps_its_resolved_magic_membersdid_closeunwrapsto_file_path()closing_a_document_with_no_file_path_is_a_no_opa_reopen_that_overtakes_its_close_keeps_the_buffers_ownershipdid_openstops acquiringa_reopen_that_overtakes_its_close_keeps_the_buffers_ownershipthe_reopened_buffer_releases_when_it_closes_in_turn(and the round-1 regression)a_close_releases_a_path_no_open_ever_countedThe regression tests carry seed assertions before the close — the buffer's method resolves and the saved one does not. Without that pair, the post-close assertions could not tell a released stamp from a buffer that was never installed. The race tests use three distinct method names (
incrementon disk,decrementin the closed buffer,multiplyin the reopened one) so "the reopened buffer answers" cannot pass as "the first push's text was never replaced".Fixes #365