Skip to content

Fix flaky presentation compiler crash on lazy accessor completion - #190

Open
sebaciv wants to merge 3 commits into
masterfrom
fix-pc-parse-interrupt-race
Open

sebaciv wants to merge 3 commits into
masterfrom
fix-pc-parse-interrupt-race

Conversation

@sebaciv

@sebaciv sebaciv commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

ScopeAndTypeCompletionTest ("simple scope completion test") fails intermittently on CI with:

scala.reflect.internal.Types$TypeError: Internal error: no synthetic tree found for bean accessor value utilStuff

Despite the wording, this has nothing to do with Java/bean getters. Scalac's AccessorTypeCompleter uses that message for every val getter; here it is the getter of val utilStuff in the profile utils. It also still fails with -SCEXno-getter-adapters.

Root cause

A race between scex's presentation compiler requests and the PC's background compile:

  1. Each completion loads the expression into the PC (askLoadedTyped) and finishes with askFilesDeleted. Both bump minRunId, so every loaded shared unit (utils, profile object, adapters) becomes stale and is re-parsed, re-entered and re-typechecked by backgroundCompile.
  2. A background pass aborted by FreshRunReq can leave the utils unit entered but not typechecked. Its getter symbol stays lazy, and its type is completed from a tree in unit.synthetics.
  3. On restart, backgroundCompile calls ensureUpToDate, which runs reset(unit). That clears synthetics before the unit is re-parsed. The parser calls signalParseProgress, which goes through pollForWork and runs pending interrupts.
  4. Scex's askForResponse work (scopeMembers / translateMember) can run as one of those interrupts. It sees the old lazy getter, forces it, and finds no synthetic tree.

Instrumentation at the moment of failure confirmed this every time. The utils unit was NotLoaded with empty synthetics, and the PC thread was in backgroundCompile → parseAndEnter → Parser → signalParseProgress → pollForWork.

Fix

Override signalParseProgress in IGlobal as a no-op, so the presentation compiler doesn't poll for work while parsing. That makes reset, parse and re-enter of a unit atomic with respect to scex's requests. Polling still happens during typechecking, but by then every loaded unit has been entered and its synthetic trees are back.

The override applies to every parse on the compiler thread. It defers everything pollForWork does: interrupts, queued work items, cancellation checks, shutdown and fresh-run requests, and yielding. All of it resumes at the next typer check point. Scex sources are small, so the delay is negligible. askShutdown is fire-and-forget, so reset() never blocks on it.

ScexPresentationCompiler gets a protected def createIGlobal(...) factory so tests can supply their own IGlobal. Production behaviour is unchanged.

Testing

Regression test. ParsePollingTest installs an IGlobal that records any checkForMoreWork call made while Global.parseAndEnter is on the stack, then runs a scope completion and a type completion. It also asserts the hook was actually called, so it can't pass vacuously. Unlike the original flake, it doesn't depend on timing. With the override removed it fails every time, reporting signalParseProgress → checkForMoreWork inside the parser. It would also catch a scalac upgrade that adds another check point inside parse/enter.

Stress test. I also ran a temporary stress test (not committed) that repeats the suite's first three tests on fresh compilers, pinned to one CPU with taskset -c 0 to approximate a slow CI runner:

Failures
Before 5–13 / 60 (4 runs)
After 0 / 60, 0 / 150

The full sbt test run and scalafmtCheckAll pass.

Follow-up

The underlying churn stays: shared units (utils, profile objects, adapters) are kept loaded as sources, so every request makes them stale. The background pass then re-parses and re-typechecks them, and the request's askFilesDeleted often aborts that pass partway through. Avoiding it means not keeping shared units loaded as sources, which is a larger change outside this PR. It is tracked in #191.

A related race with the same root cause, getErrors losing errors because backgroundCompile resets the reporter, is still open as #17. This PR doesn't fix it.

🤖 Generated with Claude Code

Sebastian Haracz added 3 commits October 28, 2026 11:47
Background compile resets a stale unit (clearing its synthetics) before
re-parsing and re-entering it, and the parser polls for interrupts via
signalParseProgress. A scex askForResponse interrupt serviced there
could force an accessor symbol left lazy by an aborted pass, failing with
"no synthetic tree found for bean accessor" (seen in
ScopeAndTypeCompletionTest on CI). Disable interrupts while parsing.
Add a createIGlobal factory to ScexPresentationCompiler and a test that
installs an IGlobal recording any checkForMoreWork call made while
Global.parseAndEnter is on the stack. It fails deterministically if the
signalParseProgress override is dropped or a scalac upgrade adds another
check point inside parse/enter, unlike the timing-dependent original flake.

Also make the signalParseProgress comment accurate: it defers everything
pollForWork does (interrupts, work items, cancellation, shutdown and
fresh-run requests, yielding) for every parse on the compiler thread.
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.

1 participant