Skip to content

kotlin: cancelling a compile now returns at once, instead of waiting for kotlinc - #642

Open
oyvindberg wants to merge 2 commits into
masterfrom
kotlin-cancellation
Open

kotlin: cancelling a compile now returns at once, instead of waiting for kotlinc#642
oyvindberg wants to merge 2 commits into
masterfrom
kotlin-cancellation

Conversation

@oyvindberg

Copy link
Copy Markdown
Owner

A regression that shipped in M11, found because the flaky-test fix in #641 was wrong.

That PR bumped these tests to a 120s timeout. Papering over — cancelling a compile should cost about nothing, and a bound tolerating two minutes asserts the opposite.

Setting the bound to what it should be isolated it immediately

Each test's own sleep plus a 200ms cancellation budget:

compiler time to cancel
Scala (fiber) 162ms
Scala (mid-compile, ProgressCallback) 4ms
Java fast
Kotlin as long as the entire compile

Root cause

kotlinc observes cancellation only when it calls back into our message collector or polls the Services status. Between those points it is unreachable — Thread.interrupt sets a flag nothing checks.

compileIncremental then invokes the compiler on the calling thread, so there was nothing to interrupt into: IO.interruptible cancellation could not return until the compile finished by itself. compileWithReflection accidentally did better, because it parks the caller in an interruptible join.

That also explains the timing. Before #625 the incremental runner never resolved, so every Kotlin compile took the reflection path. Making incremental compilation actually engage moved these compiles onto the path with no way out. So this is a real regression in M11 that surfaced only as a flaky test — a user pressing Ctrl-C on a Kotlin build waits for the whole compile.

Fix

runCancellably gives the compile its own thread and has the caller wait on a latch either side can trip — cancellation.onCancel for one, completion for the other. A cancelled compile abandons that thread rather than joining it, the same trade Outcome.runInFreshThread documents for native compilers that ignore interrupts: the work is wasted either way, and the alternative is making the user wait for output nobody wants. Daemon thread, so an abandoned one cannot hold the JVM open. Both paths go through it, since the fallback deserves the same guarantee.

Test bounds

Per-site budgets rather than one flat number: the tests do not all wait the same time before cancelling — one deliberately sleeps 500ms to get well into a compile — and a flat total would assert something weaker for it than for the others. (I got this wrong first time round and it produced a spurious failure, which is what surfaced the distinction.)

This cannot reproduce on a fast machine: the compile finishes inside any generous bound, which is exactly how a 30s timeout passed for so long while being wrong. The tight bound is what makes the behaviour observable, so it is the point of the change rather than incidental.

Verification

  • CancellationTest 7/7, three consecutive runs
  • the suite got faster (2314ms → 1578ms) because the abandoned compile no longer runs to completion
  • full bleep-bsp-tests minus linker suites: 618 passed, 0 failed

🤖 Generated with Claude Code

…for kotlinc

The 120s I put on these tests in the previous commit was wrong, and hid a real
bug. Cancelling a compile should cost about nothing; a bound that tolerates two
minutes asserts the opposite.

Setting the bound to what it should be — the test's own sleep plus a 200ms
cancellation budget — isolated it immediately. Scala returns in 162ms and its
mid-compile ProgressCallback variant in 4ms; Java likewise. Only Kotlin failed,
and it failed by taking as long as the whole compile.

The cause: kotlinc observes cancellation only when it calls back into our
message collector or polls the `Services` status, and between those points it is
unreachable — `Thread.interrupt` sets a flag nothing checks. `compileIncremental`
then invokes the compiler ON THE CALLING THREAD, so there was nothing to
interrupt into: `IO.interruptible` cancellation could not return until the
compile had finished by itself. `compileWithReflection` accidentally did better,
because it parks the caller in an interruptible `join`.

That also explains why this surfaced now rather than earlier. Before #625 the
incremental runner never resolved, so every Kotlin compile took the reflection
path. Making incremental compilation actually engage moved these compiles onto
the path with no way out.

`runCancellably` gives the compile its own thread and has the caller wait on a
latch either side can trip — `cancellation.onCancel` for one side, completion
for the other. A cancelled compile ABANDONS that thread rather than joining it,
the same trade `Outcome.runInFreshThread` documents for native compilers that
ignore interrupts: the work is wasted either way, and the alternative is making
the user wait for output nobody wants. Daemon thread, so an abandoned one cannot
hold the JVM open. Both paths go through it, since the fallback deserves the
same guarantee.

Note it cannot reproduce on a fast machine: the compile finishes inside any
generous bound, which is exactly how a 30s timeout passed for so long while
being wrong. The tight bound is what makes the behaviour observable at all, so
it is the point of the change rather than incidental to it.

Per-site budgets rather than one flat number, because the tests do not all wait
the same time before cancelling — one deliberately sleeps 500ms to get well into
a compile, and a flat total would assert something weaker for it than for the
others.

Verified: CancellationTest 7/7, three consecutive runs, and the suite got FASTER
(2314ms -> 1578ms) because the abandoned compile no longer runs to completion.
Full bleep-bsp-tests minus the linker suites: 618 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Abandoning a thread is not the same as it stopping, and the previous commit did
not say which happens.

It is cooperative: kotlinc calls `checkCanceled` at phase boundaries and our
`CompilationCanceledStatus` proxy throws there, so most abandoned compiles stop
shortly after the cancel. But that is cooperation, not a guarantee — one inside
a stretch that polls neither the status nor the message collector runs to the
end, still writing class files into an output directory after we have reported
CompilationCancelled.

That hazard is real and this repo has already been bitten by it: #626 fixed a
teardown that raced "a kotlinc still emitting into it", which is the same thing
one level up.

So the threads are tracked, exactly as ZincBridge.abandonedEcjThreads tracks the
ECJ equivalent — identity set, self-removing on completion, snapshot for
diagnostics. A non-empty snapshot during a build means something is writing into
a directory nobody is waiting for, which is worth being able to see rather than
inferring from corrupted output.

Not solved here, and worth stating: nothing stops a NEW compile of the same
project from starting while an abandoned one still writes. ProjectLock serializes
compiles, but the abandoned thread does not hold it. The alternatives are to keep
holding the lock until the runaway finishes (correct, but reintroduces the wait
for the next compile rather than for the cancel) or to compile into a scratch
directory and publish atomically. Both are larger than this fix, and the existing
ECJ and native-compiler paths make the same trade today.

CancellationTest 7/7.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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