From 5306c2cd7bfebfef4c7c4f86452aacddfa6a225b Mon Sep 17 00:00:00 2001 From: Sebastian Haracz Date: Wed, 28 Oct 2026 11:47:40 +0100 Subject: [PATCH 1/3] Fix flaky presentation compiler crash on lazy accessor completion 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. --- .../com/avsystem/scex/compiler/presentation/IGlobal.scala | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/IGlobal.scala b/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/IGlobal.scala index 3d34ee6..92af315 100644 --- a/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/IGlobal.scala +++ b/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/IGlobal.scala @@ -18,6 +18,11 @@ class IGlobal(settings: Settings, reporter: Reporter, val classLoader: ClassLoad import definitions._ + // Don't service interrupts (askForResponse) while parsing. Background compile resets a stale unit (clearing its + // synthetics) and only then re-parses and re-enters it, so an interrupt run from the parser could force a lazy + // accessor symbol left over from an aborted pass and crash with "no synthetic tree found for bean accessor". + override def signalParseProgress(pos: Position): Unit = () + abstract class ScexMember extends Member { def prefix: Type def ownerTpe: Type = prefix From f4868b697cd26fa510b64d4388c56008680007ef Mon Sep 17 00:00:00 2001 From: Sebastian Haracz Date: Wed, 28 Oct 2026 12:05:19 +0100 Subject: [PATCH 2/3] Guard against polling during parse/enter in presentation compiler 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. --- .../scex/compiler/presentation/IGlobal.scala | 9 ++- .../ScexPresentationCompiler.scala | 5 +- .../presentation/ParsePollingTest.scala | 60 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 scex-core/src/test/scala/com/avsystem/scex/compiler/presentation/ParsePollingTest.scala diff --git a/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/IGlobal.scala b/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/IGlobal.scala index 92af315..0b0b29a 100644 --- a/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/IGlobal.scala +++ b/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/IGlobal.scala @@ -18,9 +18,12 @@ class IGlobal(settings: Settings, reporter: Reporter, val classLoader: ClassLoad import definitions._ - // Don't service interrupts (askForResponse) while parsing. Background compile resets a stale unit (clearing its - // synthetics) and only then re-parses and re-enters it, so an interrupt run from the parser could force a lazy - // accessor symbol left over from an aborted pass and crash with "no synthetic tree found for bean accessor". + // Don't poll for work while parsing. Background compile resets a stale unit (clearing its synthetics) and only then + // re-parses and re-enters it, so work run from the parser (e.g. our askForResponse calls) could force a lazy accessor + // symbol left over from an aborted pass and crash with "no synthetic tree found for bean accessor". + // This applies to every parse on the compiler thread and 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. Guarded by ParsePollingTest. override def signalParseProgress(pos: Position): Unit = () abstract class ScexMember extends Member { diff --git a/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/ScexPresentationCompiler.scala b/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/ScexPresentationCompiler.scala index 1d88af3..d281986 100644 --- a/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/ScexPresentationCompiler.scala +++ b/scex-core/src/main/scala/com/avsystem/scex/compiler/presentation/ScexPresentationCompiler.scala @@ -27,6 +27,9 @@ trait ScexPresentationCompiler extends ScexCompiler { compiler => protected def isEnabled: Boolean = !settings.noPresentation.value protected def presentationCompilerSettings: Settings = settings + protected def createIGlobal(settings: Settings, reporter: Reporter, classLoader: ClassLoader): IGlobal = + new IGlobal(settings, reporter, classLoader) + private var reporter: Reporter = _ private var global: IGlobal = _ @@ -35,7 +38,7 @@ trait ScexPresentationCompiler extends ScexCompiler { compiler => if (isEnabled) { logger.info("Initializing Scala presentation compiler") reporter = new Reporter(presentationCompilerSettings) - global = new IGlobal(presentationCompilerSettings, reporter, getSharedClassLoader) + global = createIGlobal(presentationCompilerSettings, reporter, getSharedClassLoader) } } diff --git a/scex-core/src/test/scala/com/avsystem/scex/compiler/presentation/ParsePollingTest.scala b/scex-core/src/test/scala/com/avsystem/scex/compiler/presentation/ParsePollingTest.scala new file mode 100644 index 0000000..b0a921e --- /dev/null +++ b/scex-core/src/test/scala/com/avsystem/scex/compiler/presentation/ParsePollingTest.scala @@ -0,0 +1,60 @@ +package com.avsystem.scex.compiler.presentation + +import com.avsystem.scex.compiler.presentation.ScopeAndTypeCompletionTest.Root +import com.avsystem.scex.compiler.{CompilationTest, ScexSettings} +import com.avsystem.scex.japi.{DefaultJavaScexCompiler, JavaScexCompiler} +import com.avsystem.scex.util.SimpleContext +import org.scalatest.funsuite.AnyFunSuite + +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicInteger +import scala.jdk.CollectionConverters._ +import scala.tools.nsc.Settings + +/** Background compile resets a stale unit (clearing its synthetics) before re-parsing and re-entering it. If the + * presentation compiler polled for work in between, a scex request could force an accessor symbol whose synthetic + * tree is already gone ("no synthetic tree found for bean accessor"). This test fails deterministically if any + * polling happens while a unit is being parsed and entered, regardless of whether the race actually triggers. + */ +class ParsePollingTest extends AnyFunSuite with CompilationTest { + + private val pollCount = new AtomicInteger + private val pollsDuringParseAndEnter = new ConcurrentLinkedQueue[String] + + override protected def createCompiler: JavaScexCompiler = { + val settings = new ScexSettings + settings.classfileDirectory.value = "testClassfileCache" + new DefaultJavaScexCompiler(settings) { + override protected def createIGlobal(settings: Settings, reporter: Reporter, classLoader: ClassLoader) = + new IGlobal(settings, reporter, classLoader) { + override protected def checkForMoreWork(pos: Position): Unit = { + pollCount.incrementAndGet() + val inParseAndEnter = StackWalker.getInstance.walk { frames => + frames.anyMatch(f => f.getClassName == "scala.tools.nsc.interactive.Global" && f.getMethodName == "parseAndEnter") + } + if (inParseAndEnter) { + pollsDuringParseAndEnter.add(Thread.currentThread.getStackTrace.mkString("\n ")) + } + super.checkForMoreWork(pos) + } + } + } + } + + val utils = + """ + |def utilMethod: Int = 42 + |val utilStuff = 5 + """.stripMargin + + val profile = createProfile(utils = utils) + + test("presentation compiler does not poll for work while parsing and entering a unit") { + val completer = compiler.getCompleter[SimpleContext[Root], Any](profile, template = false) + completer.getScopeCompletion + completer.getTypeCompletion("_root.", 5) + + assert(pollCount.get > 0, "custom IGlobal was not used") + assert(pollsDuringParseAndEnter.isEmpty, pollsDuringParseAndEnter.asScala.headOption.getOrElse("")) + } +} From bb8a61026659d1e6c7131db0e263285186d603f6 Mon Sep 17 00:00:00 2001 From: Sebastian Haracz Date: Wed, 28 Oct 2026 12:17:38 +0100 Subject: [PATCH 3/3] Note ParsePollingTest's reliance on parseAndEnter frame name; apply scalafmt --- .../compiler/presentation/ParsePollingTest.scala | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/scex-core/src/test/scala/com/avsystem/scex/compiler/presentation/ParsePollingTest.scala b/scex-core/src/test/scala/com/avsystem/scex/compiler/presentation/ParsePollingTest.scala index b0a921e..d66129b 100644 --- a/scex-core/src/test/scala/com/avsystem/scex/compiler/presentation/ParsePollingTest.scala +++ b/scex-core/src/test/scala/com/avsystem/scex/compiler/presentation/ParsePollingTest.scala @@ -12,9 +12,9 @@ import scala.jdk.CollectionConverters._ import scala.tools.nsc.Settings /** Background compile resets a stale unit (clearing its synthetics) before re-parsing and re-entering it. If the - * presentation compiler polled for work in between, a scex request could force an accessor symbol whose synthetic - * tree is already gone ("no synthetic tree found for bean accessor"). This test fails deterministically if any - * polling happens while a unit is being parsed and entered, regardless of whether the race actually triggers. + * presentation compiler polled for work in between, a scex request could force an accessor symbol whose synthetic tree + * is already gone ("no synthetic tree found for bean accessor"). This test fails deterministically if any polling + * happens while a unit is being parsed and entered, regardless of whether the race actually triggers. */ class ParsePollingTest extends AnyFunSuite with CompilationTest { @@ -29,8 +29,13 @@ class ParsePollingTest extends AnyFunSuite with CompilationTest { new IGlobal(settings, reporter, classLoader) { override protected def checkForMoreWork(pos: Position): Unit = { pollCount.incrementAndGet() + // Relies on scalac's `scala.tools.nsc.interactive.Global.parseAndEnter` showing up as a stack frame. If a + // scalac upgrade renames or inlines it, this never matches and the test passes vacuously (pollCount only + // proves this IGlobal is in use), so re-check the frame name after upgrading Scala. val inParseAndEnter = StackWalker.getInstance.walk { frames => - frames.anyMatch(f => f.getClassName == "scala.tools.nsc.interactive.Global" && f.getMethodName == "parseAndEnter") + frames.anyMatch(f => + f.getClassName == "scala.tools.nsc.interactive.Global" && f.getMethodName == "parseAndEnter" + ) } if (inParseAndEnter) { pollsDuringParseAndEnter.add(Thread.currentThread.getStackTrace.mkString("\n "))