Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The server reuses the parent `xphp` package's AST, generic-instantiation
`Registry`, and `TypeHierarchy` directly -- no second parser, no duplicated
language semantics.

Targets **xphp 0.2.x**, including the turbofish call-site syntax
Targets **xphp 0.3.x**, including the turbofish call-site syntax
(`new Box::<T>()`, `Foo::method::<T>(...)`), variance markers, default type
arguments, and composite (intersection / union) bounds.

Expand Down
22 changes: 22 additions & 0 deletions docs/features/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,28 @@ cache directory resolved from `$XPHP_LSP_CACHE_DIR` -> XDG ->
fallback. Survives reboots and `/tmp` reaping, so cold-start cost
is paid once per machine, not once per session.

The stub map is also **warmed off the `initialize` handshake**: on a
cold machine, building it walks the entire phpstorm-stubs tree, so
paying it on the first hover would push that hover past the editor's
hover-cancel window and the popup would never paint. The warmer moves
the build to a background task at startup, so the first hover over a
variable whose type involves native functions (`strlen`, `array_map`,
…) responds promptly.

### Reflection cache (per-session, edit-invalidated)

A single hover on a typed variable makes worse-reflection reflect the
same symbols many times over -- both the receiver class (repeatedly)
and every native symbol on the value's right-hand side. Those
reflections are memoized per session so the repeats within one hover,
and across the rapid repeat hovers an editor issues as the cursor
settles, collapse to one reflection each -- the difference between a
multi-second hover and one that lands inside the hover-cancel window.
The cache is keyed by symbol name, so it is flushed on every
`didChange`: an edit to an open buffer is always re-reflected on the
next hover, never served stale. Flushes land between user actions, so
they never undo the intra-hover memoization.

### Tolerant-parse fallback

In-memory locators recover from trailing parse errors so mid-edit
Expand Down
22 changes: 19 additions & 3 deletions src/LspDispatcherFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,16 @@ public function create(MessageTransmitter $transmitter, InitializeParams $initia
// map, WorkspaceSymbols' open-doc walk, WorkspaceClassLikeLookup's
// open-doc walk). Phase-0 of the LSP follow-up roadmap.
$fqnIndex = new FqnIndex($workspace, $cache, $xphpParser, $rootPath, $extraSourceRoots, $excludedDirs);
$reflector = (new ReflectorFactory(
$reflectorFactory = new ReflectorFactory(
$workspace,
$cache,
$xphpParser,
$rootPath,
ReflectorFactory::defaultStubPath(),
ReflectorFactory::defaultCacheDir(),
$fqnIndex,
))->build();
);
$reflector = $reflectorFactory->build();
// Per-session registry of (namespace, paramName) pairs harvested from
// generic ClassLike declarations in open documents. Resolvers query
// this when formatting type names so a post-strip placeholder
Expand Down Expand Up @@ -248,6 +249,21 @@ public function create(MessageTransmitter $transmitter, InitializeParams $initia
// pay the ~500ms filesystem-walk cost in-band. Async via
// Amp\asyncCall -- doesn't block the initialize handshake.
new \XPHP\Lsp\Reflection\FqnIndexWarmer($fqnIndex),
// Hover-latency fix (downstream ticket
// hover-latency-unindexed-native-symbols): force the cold
// phpstorm-stubs map build off the `Initialized` event so the
// first hover on a typed variable -- which fans out into stdlib
// reflections -- doesn't pay the multi-second build in-band and
// miss PhpStorm's hover-cancel window. Async, like the FQN
// warmer; no-ops gracefully when stubs aren't bundled.
new \XPHP\Lsp\Reflection\StubCacheWarmer($reflector),
// Correctness guard for the reflection cache enabled above: the
// cache is keyed by symbol name with no source-version, so an
// edit to an open buffer must flush it or hover/completion would
// serve pre-edit reflections. Purges on every didChange, which
// lands between user actions and so never undoes the intra-hover
// memoization that makes hover fast.
new \XPHP\Lsp\Reflection\ReflectionCachePurger($reflectorFactory->reflectionCache()),
// Multi-root (Track A): when a file from a project OUTSIDE the
// workspace root is opened, fold that project's source roots into the
// FQN index so navigation resolves its symbols. Keys purely off the
Expand Down Expand Up @@ -418,7 +434,7 @@ public function create(MessageTransmitter $transmitter, InitializeParams $initia
new ErrorHandlingMiddleware($this->logger),
new InitializeMiddleware($handlers, $eventDispatcher, [
'name' => 'xphp-lsp',
'version' => '0.3.0',
'version' => '0.3.1',
]),
new ShutdownMiddleware($eventDispatcher),
new ResponseHandlingMiddleware($responseWatcher),
Expand Down
50 changes: 50 additions & 0 deletions src/Reflection/ReflectionCachePurger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace XPHP\Lsp\Reflection;

use Phpactor\LanguageServer\Event\TextDocumentUpdated;
use Phpactor\WorseReflection\Core\Cache;
use Psr\EventDispatcher\ListenerProviderInterface;

/**
* Flushes the shared worse-reflection {@see Cache} whenever an open
* document changes.
*
* The reflection cache (enabled in {@see ReflectorFactory} to collapse the
* heavy intra-hover reflection fan-out -- see that class's docblock) is
* keyed by symbol NAME with no source-version component. Without this
* listener, a reflection of a class the user just edited in an open buffer
* would keep being served from the cache until its TTL lapsed, so hover /
* completion / go-to-definition would show pre-edit members.
*
* A `TextDocumentUpdated` event fires on every `didChange` (dispatched by
* {@see \XPHP\Lsp\Handler\XphpTextDocumentHandler}). Purging the whole
* cache on it is correct and cheap: the purge lands BETWEEN user actions,
* never inside a single hover's reflection fan-out, so the intra-hover
* memoization that makes hover fast is fully preserved -- only cross-action
* reuse of an edited symbol is (correctly) discarded.
*/
final class ReflectionCachePurger implements ListenerProviderInterface
{
public function __construct(private readonly Cache $cache)
{
}

/**
* {@inheritDoc}
*/
public function getListenersForEvent(object $event): iterable
{
if ($event instanceof TextDocumentUpdated) {
return [[$this, 'purge']];
}
return [];
}

public function purge(TextDocumentUpdated $event): void
{
$this->cache->purge();
}
}
55 changes: 49 additions & 6 deletions src/Reflection/ReflectorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Phpactor\LanguageServer\Core\Workspace\Workspace as PhpactorWorkspace;
use Phpactor\WorseReflection\Reflector;
use Phpactor\WorseReflection\ReflectorBuilder;
use Phpactor\WorseReflection\Core\Cache;
use Phpactor\WorseReflection\Core\Cache\TtlCache;
use Phpactor\WorseReflection\Core\SourceCodeLocator\StubSourceLocator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
Expand Down Expand Up @@ -35,12 +37,29 @@
* (Iterator, Generator, ArrayAccess, ...) for which worse-reflection
* ships purpose-built reflections.
*
* Why no caching on the Reflector itself: worse-reflection's `enableCache()`
* uses a TTL cache (default 5s) keyed by reflection class -- useful when
* one logical user action triggers many reflection lookups (completion
* after `$obj->|` queries the class repeatedly). For GTD / hover (one
* lookup per action) it's wasted complexity. Revisit if a profile shows
* repeated lookups in completion.
* Reflection cache: `enableCache()` wraps class reflection in a memoizing
* cache keyed by reflection name. We enable it because the premise of "one
* lookup per action" is false: worse-reflection reflects the same symbol
* many times within a SINGLE `reflectOffset` -- ~12 lookups of one
* `ReflectionMethod` per hover per the {@see FilesystemSourceLocator} note,
* plus a fan-out across every stdlib symbol on a typed variable's
* right-hand side, each of which consults the `StubSourceLocator` and
* unserializes the whole phpstorm-stubs map. Uncached, that work is
* redone every time and dominates hover latency. Memoizing collapses the
* intra-hover repeats to one reflection each -- the dominant win, and the
* reason a hover lands inside PhpStorm's ~200-300ms cancel window.
* Downstream ticket: phpstorm-plugin hover-latency-unindexed-native-symbols.
*
* Invalidation: the cache is keyed by NAME with no source-version
* component, so a stale reflection of a class the user just edited in an
* open buffer would otherwise survive. We inject a shared {@see Cache}
* (exposed via {@see reflectionCache}) that `ReflectionCachePurger` flushes
* on every `TextDocumentUpdated` -- purges happen BETWEEN hovers, never
* during one, so the intra-hover memoization above is fully preserved while
* an edit always forces re-reflection on the next hover.
*
* The throwaway bootstrap reflector below is deliberately left uncached:
* it exists only to build the on-disk stub map once and is discarded.
*
* Bootstrap caveat: `StubSourceLocator` needs a `Reflector` in its
* constructor (it uses it to discover FQNs in stub files when building
Expand All @@ -62,6 +81,15 @@ final class ReflectorFactory
* workspace / filesystem lookup still works.
* @param string $cacheDir Writable dir for the stub map cache.
*/
/**
* Shared reflection cache handed to the built reflector via
* `withCache`. Held here so the caller can wire it to
* `ReflectionCachePurger` for edit-driven invalidation -- see the
* class docblock. Always created (even for the stubs-less reflector)
* so the purger wiring is unconditional.
*/
private readonly Cache $reflectionCache;

public function __construct(
private readonly PhpactorWorkspace $workspace,
private readonly ParsedDocumentCache $cache,
Expand All @@ -71,6 +99,17 @@ public function __construct(
private readonly string $cacheDir,
private readonly FqnIndex $fqnIndex,
) {
$this->reflectionCache = new TtlCache();
}

/**
* The reflection cache backing the reflector this factory builds.
* Flush it (via {@see Cache::purge}) when an open document changes so
* name-keyed reflections of edited classes don't go stale.
*/
public function reflectionCache(): Cache
{
return $this->reflectionCache;
}

public function build(): Reflector
Expand All @@ -85,6 +124,8 @@ public function build(): Reflector
return ReflectorBuilder::create()
->addLocator($workspaceLocator, priority: 100)
->addLocator($filesystemLocator, priority: 50)
->enableCache()
->withCache($this->reflectionCache)
->build();
}

Expand All @@ -106,6 +147,8 @@ public function build(): Reflector
->addLocator($workspaceLocator, priority: 100)
->addLocator($filesystemLocator, priority: 50)
->addLocator($stubLocator, priority: 25)
->enableCache()
->withCache($this->reflectionCache)
->build();
}

Expand Down
88 changes: 88 additions & 0 deletions src/Reflection/StubCacheWarmer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace XPHP\Lsp\Reflection;

use Phpactor\LanguageServer\Event\Initialized;
use Phpactor\WorseReflection\Reflector;
use Psr\EventDispatcher\ListenerProviderInterface;
use Throwable;
use XPHP\Lsp\Stderr;

use function Amp\asyncCall;

/**
* Listens for phpactor's `Initialized` event and asynchronously forces the
* worse-reflection stub map (phpstorm-stubs) to build so the first
* user-facing hover / go-to-definition doesn't pay the cold build in-band.
*
* Why this matters (downstream ticket
* phpstorm-plugin/hover-latency-unindexed-native-symbols): a hover on a
* typed variable fans out into reflections of the stdlib symbols on its
* assignment's right-hand side. The very first stub reflection triggers
* {@see \Phpactor\WorseReflection\Core\SourceCodeLocator\StubSourceLocator}
* to walk ~3000 phpstorm-stubs files and serialize an FQN -> file map to
* disk -- a multi-second, ~512M-peak operation (see the Makefile note on
* the behat stub-cache pre-warm). Paid in-band, it lands the hover
* response well outside PhpStorm's ~200-300ms hover-cancel window and the
* popup never paints. Building it off the `Initialized` event moves that
* cost off the first hover; once the map is serialized on disk subsequent
* sessions skip the build entirely.
*
* Warm path: reflect a single global stdlib function (`strlen`). Functions
* resolve only through the stub locator (not the InternalLocator, which
* covers a handful of fundamental interfaces), so this reliably drives the
* stub map build. Any failure (stubs genuinely absent, reflection error)
* is swallowed -- warming is best-effort and must never destabilise the
* session.
*
* Mirrors {@see FqnIndexWarmer}: same `Initialized` hook, same
* `Amp\asyncCall` dispatch so the warm-up runs off the synchronous
* initialize handshake. Registered in `LspDispatcherFactory::create`
* unconditionally; when stubs aren't bundled the warm reflection simply
* throws and is swallowed (logged as "skipped"), so there's no need to
* gate the registration.
*/
class StubCacheWarmer implements ListenerProviderInterface
{
/**
* A global stdlib function guaranteed to live in phpstorm-stubs and to
* resolve exclusively via the stub locator, so reflecting it forces the
* stub map build.
*/
private const WARM_FUNCTION = 'strlen';

public function __construct(private readonly Reflector $reflector)
{
}

/**
* {@inheritDoc}
*/
public function getListenersForEvent(object $event): iterable
{
if ($event instanceof Initialized) {
return [[$this, 'warm']];
}
return [];
}

public function warm(Initialized $initialized): void
{
asyncCall(function (): void {
try {
$this->reflector->reflectFunction(self::WARM_FUNCTION);
Stderr::write("[xphp-lsp warmer] stub-cache warmed\n");
} catch (Throwable $e) {
// Best-effort: a stubs-less build or a reflection hiccup must
// not take the session down. The first real hover will still
// work (just paying the cold cost it would have paid anyway).
Stderr::write(sprintf(
"[xphp-lsp warmer] stub-cache warm skipped: %s\n",
$e->getMessage(),
));
}
});
}
}
Loading
Loading