Files
werkator/docs/prs/2026-09-09-PR#46-flaky-cancel-test.md
397d590702 test: stop reading the current-builds list in the same breath as the status (#46)
The cancel test failed now and then because it asserted an ordering the
executor deliberately does not provide. A build reaches its terminal status and
leaves currentBuilds() at two different moments, and between them lie the
status event, the commit status posted to the forge and the artifact copy. The
test waited for the first and asserted the second in the next line, which held
on an idle machine and not under load — reproduced here in one of three
consecutive runs.

The assertion moves into eventually, like the other waits in this test. Both
places now say why the order is what it is: a build gone from currentBuilds()
while its stored result still says RUNNING would read as stuck forever, and the
watcher's worktree pruning borrows that same list to know which worktrees are
still in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #46
2026-09-10 06:56:34 +02:00

3.3 KiB

WARNING: This document describes only the change applied in this PR. It may already be outdated once the next PR is merged. Historic PR-documentation is not maintained along with new PRs — treat it as a snapshot, not as current documentation.

The Problem

BuildExecutorTest's "cancel only affects the addressed build, other branches keep running" fails now and then, always the same way: after cancelling build A, currentBuilds() still contains it beside build B.

It is not a race in the executor. The test asserts an ordering the executor deliberately does not provide.

A build reaches its terminal status and leaves currentBuilds() at two different moments, and there is real work between them:

  1. transition() stores the terminal status — from here the test's awaitStatus returns,
  2. the same call publishes BuildStatusChangedEvent synchronously to every listener,
  3. and then posts the commit status to the forge, an HTTP request,
  4. artifactStore.persist() copies the build's artifacts,
  5. only now builds.remove() takes it out of currentBuilds().

The test waited for step 1 and asserted step 5 in the next line. On an idle machine that usually works; under load — a full ./gradlew build, or a busy workstation — it does not. Reproduced here in one of three consecutive runs while other work was running.

Non-Goals

  • Not changing the order. It is the benign one, see below.
  • Not making currentBuilds() exclude builds that already reached a terminal status. That would give the test its expectation and would also stop the Current view from briefly listing a finished build — but the watcher's worktree pruning relies on exactly this list to know which worktrees are in use, and a build in step 4 is still reading its worktree. Excluding it would let a branch that vanished from origin have its worktree deleted underneath a running artifact copy.

The Scenarios

Feature: cancelling one build of several

Scenario#46.01: The list of current builds catches up with the status

  • Given two builds running, one of them cancelled
  • Then its result reaches CANCELLED first
    • and it leaves currentBuilds() once its artifacts are persisted, a moment later
    • and the other build is untouched throughout
Verified by

The Solution

The assertion moves inside eventually, like every other assertion in this test that waits for the executor.

Both places say why. The test says the list trails the status by design and must not be read in the same breath; BuildExecutor says why the removal is last — a reader that saw a build gone from currentBuilds() while its stored result still said RUNNING would see a build stuck forever, whereas one that is terminal and still listed simply reads as finishing, and the worktree pruning depends on the same order.

Open Questions

  • The Current view can briefly show a build that has already finished, for as long as its artifacts take to persist. It is honest — the build is still doing something — but it is not what "current" suggests. Fixing it properly means giving the worktree pruning its own notion of "still using a worktree" rather than borrowing currentBuilds().

Additional Changes

  • No version bump: nothing in the product changes, so there is nothing to deploy.