[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)
This commit is contained in:
BrianHotopp 2026-08-03 16:28:11 -04:00 committed by GitHub
parent 5d7dbdb427
commit 25445191d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 17 additions and 2 deletions

View File

@ -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

View File

@ -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)

View File

@ -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).

View File

@ -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}")
},
)