Skip to content

fix(diagnostics): stop a superseded discardedReturn pass instead of r… - #583

Merged
msarson merged 1 commit into
msarson:version-1.0.4from
geircodes:fix/validation-stale-pass-abort
Sep 17, 2026
Merged

msarson merged 1 commit into
msarson:version-1.0.4from
geircodes:fix/validation-stale-pass-abort

Conversation

@geircodes

Copy link
Copy Markdown
Contributor

fix(diagnostics): stop a superseded discardedReturn pass instead of running it to completion

Branch fix/validation-stale-pass-abort, targets version-1.0.4.

What happens

In a large module, member completion after a dot (obj., then obj.L, obj.Li) becomes slow — 1.9–3.4 s per keystroke on a 770 KB / 14,512-line file — while the same requests take 140–150 ms when nothing else is running. Bare-word completion on the same file stays fast. Instrumenting the server showed the member walk itself was unchanged (11–28 ms); the time went to sharing the single thread with background validation.

Root cause

validateTextDocument runs its async validators 500 ms after the last edit. It has a stale-version guard that discards the pass's answer when the document version has moved on — but the guard ran after all ten validators, so a superseded pass still did every bit of its work first.

The discardedReturn validator (ReturnValueDiagnostics.validateDiscardedReturnValues) resolves the receiver type of every dot-call in the document and enumerates each receiver class cross-file. Its memos are keyed by document version, so each pass during typing is cold: on the file above, 67 class enumerations and 98 receiver-type resolutions per pass. A cold pass ran 6.6–9.8 s with up to five superseded passes overlapping (about 1 s when running alone). Every await inside a completion request let one of those passes advance, which is why member completion — many await points — stretched from 24 ms to 2.5 s while bare-word completion, with almost none, did not.

Checking staleness only between validators does not help: discardedReturn is the second of ten and runs uninterrupted, so that check always fired after it had finished.

The fix

server/src/server.ts, in validateTextDocument:

const isStale = () => documents.get(document.uri)?.version !== startVersion;
const validatorThunks: [string, () => Promise<Diagnostic[]>][] = [
    ...
    ['discardedReturn', () => DiagnosticProvider.validateDiscardedReturnValues(tokens, document, memberLocator, getOpenDocumentContent, isStale)],
    ...
];
...
for (const [name, thunk] of validatorThunks) {
    if (isStale()) break;
    validatorResults.push(await timeIt(name, thunk()));
    ...

server/src/providers/DiagnosticProvider.ts forwards the new optional isStale?: () => boolean parameter, and server/src/providers/diagnostics/ReturnValueDiagnostics.ts checks it at the top of its per-line loop, at the time-slice yield that already exists there:

for (let lineIdx = 0; lineIdx < docLines.length; lineIdx++) {
    await timeSlice();
    if (isStale?.()) {
        perfLogger.perf("validateDiscardedReturnValues aborted (document changed)", { ... });
        return [];
    }

The pre-existing guard after the validators is unchanged and still discards the (now empty) result of a superseded pass.

Measured

With the in-loop check, superseded passes end in 42–526 ms instead of 6.6–9.8 s, only the pass for the final version runs all ten validators, and the same member-completion requests take 140–152 ms.

Testing

New server/src/test/ReturnValueDiagnostics.StaleAbort.test.ts (3 tests) on a fixture with four discarded obj.Method() calls and a MemberLocatorService whose receiver-type resolutions are counted:

  • control (no callback): all four warnings are reported;
  • stale from the start: the result is empty and the locator is never called;
  • superseded mid-pass (the callback flips after the first resolution completes): the result is empty and no further resolution is started.

Without the fix the two stale-path tests fail — the callback is ignored and all four warnings come back — while the control test passes either way. With the fix the full suite is 3071 passing, 0 failing, 4 pending.

Scope

Only discardedReturn receives the callback; it is the validator whose per-pass cost made superseded passes matter. The other async validators are short enough (tens of ms, undeclaredVar and missingImpl a few hundred) that the between-validator check covers them. Two related costs are deliberately left for their own changes: the version-keyed memos that make every pass cold in the first place (a warm pass of the same validator measures ~0.5 s against ~1 s cold and 6–10 s under contention), and the ~0.55 s of synchronous validators that run at the start of every pass. Sibling change in the same investigation: fix/member-scan-use-loaded-text.

…unning it to completion

validateTextDocument runs its async validators 500 ms after the last edit. Its stale-version
guard discards the answer when the document version has moved on, but the guard sat after
all ten validators, so a superseded pass still did every bit of its work first. The
discardedReturn validator resolves the receiver type of every dot-call in the document and
enumerates each receiver class cross-file; its memos are keyed by document version, so each
pass while typing is cold. On a 770 KB / 14,512-line module a cold pass ran 6.6-9.8 s with
five superseded passes overlapping (about 1 s uncontended), all sharing the single thread
with interactive requests: a member-completion request measured 24 ms alone and 1.9-3.4 s
beside abandoned passes.

Fix: validateTextDocument derives `isStale` (a newer document version exists) once, checks
it before each validator, and passes it to validateDiscardedReturnValues, which checks it
at the top of its per-line loop (at the existing time-slice yield) and returns an empty
result as soon as the pass is superseded. Checking only between validators was not enough:
discardedReturn is the second of ten and ran uninterrupted, so that check always fired
after it had finished.

Measured after the change: superseded passes end in 42-526 ms, and the same member-
completion requests take 140-152 ms.

Tests: ReturnValueDiagnostics.StaleAbort.test.ts (3). Without the fix the two stale-path
tests fail (the callback is ignored and all four discarded-call warnings are returned);
the control test passes both ways. With the fix the full suite is 3071 passing /
0 failing / 4 pending.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
msarson added a commit that referenced this pull request Sep 17, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@msarson
msarson merged commit 25d9fdf into msarson:version-1.0.4 Sep 17, 2026
1 check passed
@geircodes
geircodes deleted the fix/validation-stale-pass-abort branch September 18, 2026 04:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants