From 25445191d75064c53470e919f254ce1e325986d5 Mon Sep 17 00:00:00 2001 From: BrianHotopp Date: Mon, 3 Aug 2026 16:28:11 -0400 Subject: [PATCH] [2.x] fix: Complete earlyOutputPing on cache-hit and failed compiles (#9542) With usePipelining enabled, earlyOutputPing was completed only as a side effect of zinc reporting progress mid-compile (CompileProgress.afterEarlyOutput via writeEarlyOut / notifyNoEarlyOut in zinc-core). On an action-cache hit zinc never runs, and on a failed compile it never reports, so the promise stayed unfulfilled and every task waiting on it - compileEarly and makePickleProducts across downstream pipelined projects - parked forever. A warm compile;compile on a multi-module build hung indefinitely (#9486). The compile task now completes the ping on every resolution path. Zinc's own completion still wins when zinc ran (tryComplete is atomic, so it can never pre-empt it); a cache hit completes it from the pickle jar's presence on disk (absent jar means downstream falls back to a full compile, which is the correct degradation); a failed compile completes it false so waiters take the full compile path and propagate the failure instead of hanging. Generated-by: kimi-code/k3 (Oh My Pi) --- main-settings/src/main/scala/sbt/PromiseWrap.scala | 5 +++++ main/src/main/scala/sbt/Defaults.scala | 6 ++++++ notes/2.0.0/pipelining-cache-hit-hang.md | 3 +++ .../src/sbt-test/source-dependencies/pipelining/build.sbt | 5 +++-- .../source-dependencies/pipelining/{disabled => test} | 0 5 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 notes/2.0.0/pipelining-cache-hit-hang.md rename sbt-app/src/sbt-test/source-dependencies/pipelining/{disabled => test} (100%) diff --git a/main-settings/src/main/scala/sbt/PromiseWrap.scala b/main-settings/src/main/scala/sbt/PromiseWrap.scala index 8fa3d6aa3..ba485cfc7 100644 --- a/main-settings/src/main/scala/sbt/PromiseWrap.scala +++ b/main-settings/src/main/scala/sbt/PromiseWrap.scala @@ -17,6 +17,11 @@ final class PromiseWrap[A]: case Result.Inc(cause) => underlying.failure(cause) case Result.Value(value) => underlying.success(value) } + def tryComplete(result: Result[A]): Boolean = + result match { + case Result.Inc(cause) => underlying.tryFailure(cause) + case Result.Value(value) => underlying.trySuccess(value) + } def success(value: A): Unit = underlying.success(value) def failure(cause: Throwable): Unit = underlying.failure(cause) def isCompleted: Boolean = underlying.isCompleted diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index f5a080bb8..b3bed8b3f 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -2259,8 +2259,13 @@ object Defaults extends BuildCommon with DefExtra { val ci = (compile / compileInputs).value val c = fileConverter.value val dir = c.toPath(backendOutput.value).toFile + val ping = (TaskZero / earlyOutputPing).value result match case Result.Value(res) => + // Zinc completes the ping during a real compile; on an action-cache hit + // zinc never runs, so complete it from the pickle jar. tryComplete is + // atomic: zinc's own completion always wins when it ran. + ping.tryComplete(Result.Value(c.toPath(earlyOutput.value).toFile.exists)) val af = compileAnalysisFile.value val store = analysisStore(af.toPath(), c) if !af.exists then sys.error(s"${af} is missing") @@ -2269,6 +2274,7 @@ object Defaults extends BuildCommon with DefExtra { bspTask.notifySuccess(analysis) res case Result.Inc(cause) => + ping.tryComplete(Result.Value(false)) val compileFailed = cause.directCause.collect { case c: CompileFailed => c } reporter.sendFailureReport(ci.options.sources, compileFailed) bspTask.notifyFailure(compileFailed) diff --git a/notes/2.0.0/pipelining-cache-hit-hang.md b/notes/2.0.0/pipelining-cache-hit-hang.md new file mode 100644 index 000000000..b4d9f6438 --- /dev/null +++ b/notes/2.0.0/pipelining-cache-hit-hang.md @@ -0,0 +1,3 @@ +### Fixes + +- Don't hang on `earlyOutputPing` when pipelining is enabled and a compile resolves from the action cache or fails. The ping was completed only as a side effect of zinc running, so a cache-hit or failed compile left downstream pipelined compiles waiting on it forever (`compile;compile` on a warm build hung indefinitely). The compile task now completes the ping on every resolution path: zinc's own completion still wins when zinc runs, a cache hit completes it from the pickle jar's presence, and a failure completes it false so waiters fall back to a full compile. Fixes [#9486](https://github.com/sbt/sbt/issues/9486). diff --git a/sbt-app/src/sbt-test/source-dependencies/pipelining/build.sbt b/sbt-app/src/sbt-test/source-dependencies/pipelining/build.sbt index 25dfae396..b37cf7cc0 100644 --- a/sbt-app/src/sbt-test/source-dependencies/pipelining/build.sbt +++ b/sbt-app/src/sbt-test/source-dependencies/pipelining/build.sbt @@ -12,11 +12,12 @@ lazy val dep = project lazy val use = project .dependsOn(dep) .settings( - TaskKey[Unit]("checkPickle") := { + TaskKey[Unit]("checkPickle") := Def.uncached { val s = streams.value val x = (dep / Compile / compile).value val picklePath = (Compile / internalDependencyPicklePath).value assert(picklePath.size == 1 && - picklePath.head.data.name == "early.jar", s"picklePath = ${picklePath}") + picklePath.head.data.name.endsWith(".jar") && + picklePath.head.data.toString.contains("early"), s"picklePath = ${picklePath}") }, ) diff --git a/sbt-app/src/sbt-test/source-dependencies/pipelining/disabled b/sbt-app/src/sbt-test/source-dependencies/pipelining/test similarity index 100% rename from sbt-app/src/sbt-test/source-dependencies/pipelining/disabled rename to sbt-app/src/sbt-test/source-dependencies/pipelining/test