Files
werkator/docs/prs/2026-09-13-PR#60-flaky-maxconcurrent-slot-race.md
fcd11d960e
werkator build succeeded after 01:39
test(build): the maxConcurrent-1 test no longer bets on which thread wins (#60)
"with maxConcurrent 1 a second branch stays PENDING until the first
finished" failed on CI on a branch that does not touch BuildExecutor, and
the message named the cause: expected:<PENDING> but was:<SUCCESS>. Not
merely started -- already finished.

The test started two builds of two branches back to back and assumed the
first one started would get the single slot. Nothing provides that. Each
branch has a worker thread of its own, both call slot.acquire() inside
execute, and RUNNING is recorded only afterwards; which thread arrives
first is the scheduler's business. Semaphore fairness orders threads
already waiting, not two threads racing to arrive. So branch-b took the
slot, ran `echo ok` to the end, released it, branch-a went RUNNING -- the
state the test was waiting for -- and the next line found branch-b
finished. Starting the two in the other order reproduces it every time.

The gate file from 9221550 could never cover this: it holds branch-a only
once branch-a already has the slot.

branch-b is now enqueued after branch-a is observed RUNNING, which is
proof the slot is taken, and the gate keeps it taken. No window is left,
and nothing is assumed about scheduling. It is also the case the test is
named after and the one that happens in production: a push arriving while
another branch builds. The transitions assertion at the end, which is the
real requirement, is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #60
2026-09-13 11:54:26 +02:00

4.6 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 "with maxConcurrent 1 a second branch stays PENDING until the first finished" fails now and then.

It failed on 2026-09-13 on the CI instance, on the branch of PR#59, which does not touch BuildExecutor at all. The same machine built the commit before it and the commit after it green, and the test class passes five forced runs in a row locally.

The recorded failure names the cause exactly:

org.opentest4j.AssertionFailedError: expected:<PENDING> but was:<SUCCESS>
    at BuildExecutorTest.kt:571

SUCCESS, not RUNNING: the second build had not merely started, it had already finished.

The test starts two builds of two different branches back to back and then waits for the first to be RUNNING before asserting that the second is still PENDING. That assumes the build started first gets the single executor slot, and nothing provides that:

  • startBuild submits each build to branchWorkers.computeIfAbsent(repo to branch), a single-thread worker per branch, so the two builds run on two independent threads.
  • Each thread calls slotsFor(...).acquire() inside execute, and only then records RUNNING.
  • Which of the two threads reaches acquire() first is up to the scheduler. Semaphore(maxConcurrent, true) is fair, but fairness orders threads that are already waiting; it does not order two threads racing to arrive.

So on the CI machine branch-b acquired the slot first, ran echo ok to completion and released it; branch-a then acquired it and went RUNNING, which is what the test was waiting for; and the assertion in the next line read branch-b as SUCCESS.

The gate file that commit 9221550 introduced ("test: gate the maxConcurrent-1 test instead of racing a sleep") cannot prevent this. It holds branch-a only once branch-a already has the slot, and the failure happens before that.

The race was reproduced deterministically here by starting the two builds in the other order, which fails with the very same message.

Non-Goals

  • No change to the executor. Two builds of different branches competing for the last free slot have no promised order, and none is needed: a CI does not owe callers first-come-first-served across branches, and the per-branch worker already keeps one branch's builds in order.
  • No fix by waiting longer or by asserting repeatedly. The old assertion was not too early, it was asserting something the executor never guaranteed.
  • No change to the sibling tests. "with maxConcurrent 2 two branches build at the same time" has a slot for each build, and "a second build of the same branch waits even when a slot is free" enqueues both builds on the same per-branch worker, which is FIFO by construction.

The Scenarios

Feature: one build slot, a second branch waiting

Scenario#60.01: a build enqueued while another one runs stays PENDING

So that the promise of executor.maxConcurrent is tested, not the thread scheduler.

  • Given a build of branch-a that is RUNNING, holding the only slot
  • When a build of branch-b is enqueued
  • Then it is recorded PENDING
  • And when branch-a finishes
  • Then branch-b starts, and its RUNNING transition is recorded after branch-a's SUCCESS
Verified by

The Solution

The second build is enqueued only once the first is observed RUNNING.

RUNNING is recorded after slot.acquire() returns, so observing it is proof that the slot is taken, and the gate file keeps it taken until the test says otherwise. There is no longer a window in which branch-b could acquire anything, and no assumption about which thread wins a race.

That is also the case the test is named after, and the one that happens in production: a push arrives while a build of another branch is running. The final assertion is unchanged and remains the actual requirement — the recorded transitions must show branch-b starting after branch-a finished.

The comment above the test now says which race the gate does and does not cover, so the next reader does not have to rediscover it from a CI log.

Open Questions

  • A test-only change still takes a version and a release-notes entry, per the rule introduced with v1.8.0. The comparable earlier fix, PR#46, predates that rule and deliberately took neither.