Skip to content

Commit ac3b3f6

Browse files
committed
Eliminate recursion guards in class resolution
1 parent 4cf7628 commit ac3b3f6

9 files changed

Lines changed: 215 additions & 199 deletions

File tree

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535

3636
### Changed
3737

38+
- **Classes are pre-resolved for the whole workspace after startup.** Once the background index completes, every known class is resolved in dependency order even when workspace diagnostics are disabled, so the first completion, hover, or go-to-definition against any class reads a warm cache instead of resolving on demand. Edits still re-resolve only the affected classes. Contributed by @AJenbo.
3839
- **Continuous progress reporting.** The indexing progress bar now advances file by file with live counts (e.g. "Scanning vendor packages (3201/8544 files)") instead of jumping between a few fixed milestones. This covers single-project, monorepo, and non-Composer workspaces. Go to Implementation, Find References, and Type Hierarchy show the same live progress while they scan, including when one of them triggers the first full workspace index.
3940
- **Property hover now shows effective types as a `var` detail line.** Property hovers now mirror method hovers by displaying the resolved/effective property type above the PHP snippet as `**var**`, while the snippet itself shows only the native PHP property declaration. This keeps docblock-inferred, virtual, and schema-derived property types out of the generated signature block. Contributed by @calebdw.
4041
- **Updated the bundled mago toolchain to 1.43.0.** The parser, docblock parser, formatter, and supporting crates are refreshed to the latest upstream release. Contributed by @enwi in https://github.com/PHPantom-dev/phpantom_lsp/pull/234.
@@ -45,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4546

4647
### Fixed
4748

49+
- **Member resolution no longer degrades on deeply nested class dependencies.** Class resolution previously cut off at internal nesting limits, silently returning incomplete member sets (missing completions, spurious unknown-member diagnostics) on projects with deeply intertwined hierarchies and virtual members. Those limits are gone: only genuine dependency cycles, such as two Eloquent models whose relationships reference each other, fall back to a partial view, and they now do so deterministically instead of depending on which class happened to resolve first on a given thread. Contributed by @AJenbo.
4850
- **Completion works after closing a multi-line closure argument on the same line as the next chain operator.** Typing `->` immediately after a call like `->map(function (...) { ... })->` now resolves the full receiver instead of returning no member suggestions until the operator is moved to a new line. Contributed by @calebdw.
4951
- **Conditional return types recognize interpolated strings as strings.** A function with a conditional return type like `($key is string ? mixed : null)` now correctly resolves the `string` branch when called with an interpolated string argument (e.g. `config("{$prefix}.host")`). Previously the interpolated string was not recognized as a string literal, causing the return type to fall through to the else branch and resolve as `null`. Contributed by @calebdw.
5052
- **Renaming a constructor-promoted property parameter now cascades to `$this->prop` usages.** Renaming `private int $someField` in a constructor's parameter list previously only updated the parameter declaration itself, leaving every `$this->someField` reference elsewhere in the class stale. Rename, find references, document highlight, and linked editing now treat a promoted property parameter the same as an ordinary property declaration.

docs/todo/eager-resolution.md

Lines changed: 24 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -254,74 +254,30 @@ inside the type resolution pipeline, not by resolution logic.
254254

255255
---
256256

257-
#### Phase 4e: Eliminate recursion guards in class resolution (absorbs P21, P22)
258-
259-
**Impact: Medium-High. Effort: Medium.**
260-
261-
Once Phase 4d (or earlier) makes the resolved codebase metadata
262-
immutable after population, the re-entrant resolution that currently
263-
requires thread-local recursion guards cannot occur. This phase
264-
removes those guards and the depth caps they protect:
265-
266-
1. **`RESOLVING` set and `MAX_RESOLVE_DEPTH` (30) in
267-
`virtual_members/mod.rs`.** Thread-local set of class FQNs
268-
currently being resolved. When a class is already in the set,
269-
re-entrant calls return a partial result (base inheritance only,
270-
no virtual members). This produces non-deterministic results:
271-
whichever class in a mutual dependency (e.g. Model/Builder) is
272-
resolved first gets full virtual members, the other gets degraded.
273-
After eager population, all classes are resolved before any
274-
consumer queries them, so re-entry cannot happen.
275-
276-
2. **`RESOLVE_DEPTH` and `MAX_RESOLVE_TARGET_DEPTH` (60) in
277-
`type_engine/resolver/`.** Thread-local depth counter for
278-
`resolve_target_classes_expr_inner`. Guards against mutual
279-
recursion between subject resolution, call-return resolution,
280-
and variable resolution. The limit of 60 (vs typical chain
281-
depth of 5-10) indicates the recursion is caused by accidental
282-
re-entry into class resolution, not by the problem size. Once
283-
class resolution is a cache lookup, this re-entry path vanishes.
284-
285-
3. **LSP server eager population.** The `analyse.rs` CLI already
286-
runs `populate_from_sorted` before diagnostics. The LSP server's
287-
Tokio threads do not. Extend eager population to run on file
288-
change in the LSP server (incrementally, not full re-population)
289-
so that interactive requests also benefit from pre-resolved
290-
metadata.
291-
292-
**How the reference projects avoid this problem:**
293-
294-
- **Mago:** topologically sorts classes (`codex/src/populator/
295-
sorter.rs`) using a DFS with `visited` + `visiting` sets. Cycles
296-
are broken silently when `visiting.contains(&class_like)`. Each
297-
class is then populated exactly once by
298-
`populate_class_like_metadata_iterative` (non-recursive, assumes
299-
dependencies are done). No re-entrant resolution is possible.
300-
301-
- **PHPStan:** member lookup on `ClassReflection` delegates to
302-
`PhpClassReflectionExtension`, which calls PHP's native
303-
`ReflectionClass` (already resolved by the runtime). Each class
304-
has a single canonical instance via `MemoizingReflectionProvider`
305-
(keyed by lowercase name), so re-entrant lookups hit the same
306-
cached object. Explicit cycle guards exist only for specific
307-
features: `$resolvingTypeAliasImports` for `@type-import` cycles,
308-
`$inferClassConstructorPropertyTypesInProcess` for constructor
309-
property inference.
310-
311-
- **Phpactor:** three independent cycle-protection layers.
312-
(1) `ClassHierarchyResolver::doResolve()` passes a `$resolved`
313-
map by value; if a class name is already a key, recursion stops.
314-
(2) Every reflection object carries a `$visited` array through its
315-
constructor; `reflectClassLike()` throws `CycleDetected` on
316-
re-entry. (3) Direct self-reference guards in `parent()` and
317-
`ancestors()`.
318-
319-
**Success criteria:**
320-
- `RESOLVING`, `RESOLVE_DEPTH`, `MAX_RESOLVE_DEPTH`, and
321-
`MAX_RESOLVE_TARGET_DEPTH` are removed from the codebase.
322-
- `mark_resolving` / `unmark_resolving` functions are removed.
323-
- No test regressions.
324-
- LSP server runs eager population incrementally on file change.
257+
#### Phase 4e: Eliminate recursion guards in class resolution ✓
258+
259+
Removed the thread-local `RESOLVING` set, both depth counters, and
260+
both depth caps (`MAX_RESOLVE_DEPTH` 30, `MAX_RESOLVE_TARGET_DEPTH`
261+
60). Genuine dependency cycles still exist (e.g. two Eloquent models
262+
whose relationship providers resolve each other, and classes loaded
263+
on demand mid-resolution), so the cycle-break itself cannot be
264+
deleted: it moved into `ResolvedCacheInner` as an in-flight set keyed
265+
by `(thread, FQN)` with an RAII release. Re-entry on the same class
266+
returns a base-inheritance partial exactly as before, but resolution
267+
is no longer cut off at an arbitrary nesting depth, other threads'
268+
concurrent resolutions are unaffected, and the state is per-`Backend`
269+
instead of a process-wide thread-local. Cache-less
270+
`resolve_class_fully` callers get a throwaway cache scoped to their
271+
call tree, so cycle-breaking applies uniformly (and nested provider
272+
work within the tree is deduplicated). The depth cap in
273+
`resolve_target_classes_expr_inner` was removed outright: its
274+
recursion follows the finite subject expression plus variable
275+
resolution's own keyed guards, and class resolution can no longer
276+
feed unbounded re-entry into it. The LSP server now runs eager
277+
population at the tail of the full background index even when
278+
workspace diagnostics are disabled; incremental re-population of
279+
evicted classes on file change was already in place in
280+
`parser/ast_update.rs`.
325281

326282
#### Phase 4f: Remove inflated stack sizes (absorbs P25)
327283

src/backend.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
//! `Backend` behaviour that doesn't belong in the LSP-dispatch layer
22
//! (`server.rs`) or in a specific feature module.
33
4+
pub(crate) mod eager_population;
45
pub(crate) mod file_access;

src/backend/eager_population.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//! Eager resolved-class population for the LSP server.
2+
//!
3+
//! Mirrors the CLI analyse pipeline: after the workspace is fully
4+
//! indexed, every known class is resolved in dependency-first order so
5+
//! that interactive requests (completion, hover, diagnostics,
6+
//! go-to-definition) read pre-resolved metadata from the cache instead
7+
//! of recursing into class resolution. Incremental re-population on
8+
//! file change is handled separately in `parser/ast_update.rs`, which
9+
//! re-resolves only the classes evicted by the edit.
10+
11+
use std::sync::atomic::Ordering;
12+
use std::time::Duration;
13+
14+
use crate::Backend;
15+
16+
impl Backend {
17+
/// Wait until `initialized` has finished, returning `false` when
18+
/// the server shuts down first.
19+
///
20+
/// Startup tasks that populate resolution caches must wait for
21+
/// this: `initialized` clears those caches after the startup scan,
22+
/// which would discard any population that ran earlier.
23+
pub(crate) async fn wait_for_init_complete(&self) -> bool {
24+
while !self.init_complete.load(Ordering::Acquire) {
25+
if self.shutdown_flag.load(Ordering::Acquire) {
26+
return false;
27+
}
28+
tokio::time::sleep(Duration::from_millis(200)).await;
29+
}
30+
true
31+
}
32+
33+
/// Resolve every class in `uri_classes_index` in topological
34+
/// (dependency-first) order, populating `resolved_class_cache`.
35+
///
36+
/// Skips classes that are already cached, so calling this again
37+
/// (e.g. from the workspace diagnostics pass after the full-index
38+
/// task already populated) is cheap.
39+
pub(crate) async fn eager_populate_resolved_classes(&self) {
40+
let backend = self.clone_for_blocking();
41+
crate::server::run_blocking_cancel_safe(move || {
42+
let sorted_fqns = {
43+
let uri_classes_index = backend.symbols.uri_classes_index.read();
44+
crate::toposort::toposort_from_uri_classes_index(&uri_classes_index)
45+
};
46+
// Dedicated large-stack thread: class resolution walks
47+
// parsed ASTs and can nest when the toposort misses
48+
// dependencies (stubs, on-demand vendor loads).
49+
std::thread::scope(|s| {
50+
let backend = &backend;
51+
let sorted_fqns = &sorted_fqns;
52+
std::thread::Builder::new()
53+
.name("eager-populate".into())
54+
.stack_size(crate::PARSE_WORKER_STACK_SIZE)
55+
.spawn_scoped(s, move || {
56+
let class_loader = |name: &str| backend.find_or_load_class(name);
57+
crate::virtual_members::populate_from_sorted(
58+
sorted_fqns,
59+
&backend.resolved_class_cache,
60+
&class_loader,
61+
);
62+
})
63+
.expect("failed to spawn eager-populate thread");
64+
});
65+
})
66+
.await;
67+
}
68+
}

src/diagnostics/workspace.rs

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,8 @@ impl Backend {
195195
// Wait for `initialized` to finish (it clears resolution caches
196196
// after the startup scan; starting before that would waste the
197197
// eager class population below).
198-
while !self.init_complete.load(Ordering::Acquire) {
199-
if self.shutdown_flag.load(Ordering::Acquire) {
200-
return;
201-
}
202-
tokio::time::sleep(Duration::from_millis(200)).await;
198+
if !self.wait_for_init_complete().await {
199+
return;
203200
}
204201

205202
let progress_token = self.progress_create("phpantom/workspace-diagnostics").await;
@@ -241,37 +238,12 @@ impl Backend {
241238
// ── Eager class population ──────────────────────────────────
242239
// Resolve every known class in dependency-first order so the
243240
// per-file collectors below hit a warm cache instead of
244-
// recursing into class resolution. Same approach as the CLI
245-
// analyse pipeline.
241+
// recursing into class resolution. The full-index task already
242+
// ran this; classes it populated are skipped, so this only
243+
// resolves classes loaded since (or everything, if the pass was
244+
// triggered another way).
246245
progress.set_percentage(1, "Resolving classes");
247-
{
248-
let backend = self.clone_for_blocking();
249-
crate::server::run_blocking_cancel_safe(move || {
250-
let sorted_fqns = {
251-
let uri_classes_index = backend.symbols.uri_classes_index.read();
252-
crate::toposort::toposort_from_uri_classes_index(&uri_classes_index)
253-
};
254-
// Dedicated large-stack thread: class resolution can
255-
// nest deeply when the toposort misses dependencies.
256-
std::thread::scope(|s| {
257-
let backend = &backend;
258-
let sorted_fqns = &sorted_fqns;
259-
std::thread::Builder::new()
260-
.name("ws-diag-populate".into())
261-
.stack_size(crate::PARSE_WORKER_STACK_SIZE)
262-
.spawn_scoped(s, move || {
263-
let class_loader = |name: &str| backend.find_or_load_class(name);
264-
crate::virtual_members::populate_from_sorted(
265-
sorted_fqns,
266-
&backend.resolved_class_cache,
267-
&class_loader,
268-
);
269-
})
270-
.expect("failed to spawn ws-diag-populate thread");
271-
});
272-
})
273-
.await;
274-
}
246+
self.eager_populate_resolved_classes().await;
275247

276248
// ── Per-file diagnostics, batched ───────────────────────────
277249
let mut uris = self.workspace_diagnostic_target_uris();

src/server.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1754,11 +1754,21 @@ impl Backend {
17541754
let _ = client.workspace_diagnostic_refresh().await;
17551755
}
17561756

1757-
// With the whole workspace parsed, run the background
1758-
// workspace diagnostics pass (native collectors over every
1759-
// unopened user file, then project-wide external tools).
1760-
// Deliberately chained after the index so it never competes
1761-
// with startup for CPU.
1757+
// With the whole workspace parsed, eagerly resolve every
1758+
// class so interactive requests hit a warm cache. This
1759+
// runs even when workspace diagnostics are disabled — it
1760+
// serves completion, hover, and go-to-definition too.
1761+
// Populating before `initialized` finishes would be wasted
1762+
// work: it clears the resolution caches after the startup
1763+
// scan.
1764+
if progress_backend.wait_for_init_complete().await {
1765+
progress_backend.eager_populate_resolved_classes().await;
1766+
}
1767+
1768+
// Then run the background workspace diagnostics pass
1769+
// (native collectors over every unopened user file, then
1770+
// project-wide external tools). Deliberately chained after
1771+
// the index so it never competes with startup for CPU.
17621772
progress_backend.run_workspace_diagnostics().await;
17631773
});
17641774
}

src/type_engine/resolver/mod.rs

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -162,37 +162,15 @@ pub(crate) fn resolve_target_classes_expr(
162162

163163
/// Inner implementation of [`resolve_target_classes_expr`] without
164164
/// chain caching. The outer function handles cache lookup/store.
165+
///
166+
/// Recursion here follows the finite structure of the subject
167+
/// expression plus variable resolution, which carries its own keyed
168+
/// re-entry guards; class resolution cannot re-enter it (cycles are
169+
/// broken inside `resolve_class_fully`), so no depth cap is needed.
165170
fn resolve_target_classes_expr_inner(
166171
expr: &SubjectExpr,
167172
access_kind: AccessKind,
168173
ctx: &ResolutionCtx<'_>,
169-
) -> Vec<ResolvedType> {
170-
thread_local! {
171-
static RESOLVE_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
172-
}
173-
let depth = RESOLVE_DEPTH.with(|d| {
174-
let v = d.get() + 1;
175-
d.set(v);
176-
v
177-
});
178-
// Maximum nesting depth for `resolve_target_classes_expr_inner`.
179-
// Breaks infinite recursion between subject resolution, call-return
180-
// resolution, and variable resolution that can occur on files with
181-
// deeply intertwined class hierarchies and virtual members.
182-
const MAX_RESOLVE_TARGET_DEPTH: u32 = 60;
183-
if depth > MAX_RESOLVE_TARGET_DEPTH {
184-
RESOLVE_DEPTH.with(|d| d.set(depth - 1));
185-
return vec![];
186-
}
187-
let result = resolve_target_classes_expr_inner_impl(expr, access_kind, ctx);
188-
RESOLVE_DEPTH.with(|d| d.set(depth - 1));
189-
result
190-
}
191-
192-
fn resolve_target_classes_expr_inner_impl(
193-
expr: &SubjectExpr,
194-
access_kind: AccessKind,
195-
ctx: &ResolutionCtx<'_>,
196174
) -> Vec<ResolvedType> {
197175
let current_class = ctx.current_class;
198176
let all_classes = ctx.all_classes;

src/virtual_members/cache.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ pub struct ResolvedCacheInner {
7070
/// per-edit cache contents.
7171
is_laravel: bool,
7272
schema_index: SchemaIndex,
73+
/// Classes currently being resolved by `resolve_class_fully_inner`,
74+
/// keyed per thread so one thread's in-progress resolution never
75+
/// degrades another thread's independent resolution of the same
76+
/// class. When resolution of a class re-enters itself on the same
77+
/// thread (a genuine dependency cycle, e.g. two Eloquent models
78+
/// whose relationship providers resolve each other), the re-entrant
79+
/// call detects its own marker here and returns a base-inheritance
80+
/// partial result instead of recursing forever. Entries live only
81+
/// for the duration of one synchronous resolution call tree; they
82+
/// are removed by an RAII guard, so a panic cannot leak them.
83+
in_flight: HashSet<(std::thread::ThreadId, Atom)>,
7384
}
7485

7586
impl Default for ResolvedCacheInner {
@@ -80,6 +91,7 @@ impl Default for ResolvedCacheInner {
8091
reverse_deps: HashMap::new(),
8192
is_laravel: true,
8293
schema_index: SchemaIndex::default(),
94+
in_flight: HashSet::new(),
8395
}
8496
}
8597
}
@@ -110,6 +122,19 @@ impl ResolvedCacheInner {
110122
self.is_laravel = is_laravel;
111123
}
112124

125+
/// Mark `fqn` as being resolved on the current thread. Returns
126+
/// `true` when freshly marked (caller should proceed with full
127+
/// resolution) and `false` on re-entry (caller should break the
128+
/// cycle with a partial result).
129+
pub fn mark_in_flight(&mut self, fqn: Atom) -> bool {
130+
self.in_flight.insert((std::thread::current().id(), fqn))
131+
}
132+
133+
/// Remove the current thread's in-flight marker for `fqn`.
134+
pub fn unmark_in_flight(&mut self, fqn: Atom) {
135+
self.in_flight.remove(&(std::thread::current().id(), fqn));
136+
}
137+
113138
pub fn schema_index(&self) -> &SchemaIndex {
114139
&self.schema_index
115140
}
@@ -153,6 +178,10 @@ impl ResolvedCacheInner {
153178
}
154179

155180
/// Remove all entries and indices.
181+
///
182+
/// `in_flight` is left untouched: its entries belong to resolution
183+
/// call trees currently running on other threads, not to the cached
184+
/// contents being invalidated.
156185
pub fn clear(&mut self) {
157186
self.map.clear();
158187
self.fqn_keys.clear();

0 commit comments

Comments
 (0)