Skip to content

Tests and Folder lock: the CI flakes since #487 are races with the code's own background work, fixed at the cause - #494

Merged
immakermatty merged 5 commits into
mainfrom
claude/ci-test-isolation
Oct 18, 2026
Merged

immakermatty merged 5 commits into
mainfrom
claude/ci-test-isolation

Conversation

@immakermatty

@immakermatty immakermatty commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Why

main CI failed on most pushes since 96045c0 (#487), each time on a different test that the push did not touch. #487 itself is not the cause: it only adds a pure test file and a timings entry. The failures are a handful of races between tests and work the code under test does on its own, and they have been there for days; they only clustered on 2026-10-18.

Evidence: I collected every failed check.yml job since 2026-10-12 (284 runs, 33 of them re-run) and grouped the failing tests. The same races account for almost all of the flakes, on pull requests as well as on main:

Failing test (count since 10-12) Cause
launchpad-voice "saves an active voice …" (12), launchpad "profile API …" (6), "shows the immutable handover …" (4), "tools API …" (1), tools-team-github "a gh login … stops" (5), folder-lock "a mutation … waits for its own short read" (7) and "the wait for an own read is bounded" (3), launchpad-ensure "ensure timing …" (4, folder-lock-unavailable) A. Folder lock: own read counted too late
launchpad-prompts "hosted personal: new-personal-module …" (15, #425/#490), content-routes "root access (root decision 0201 point 8) …" (6) B. The start's background GitHub read lands in the test's measurement (#413)
launchpad-organization-settings (2), environment-browser-view-identity (1): Failed to start server. Is port <n> in use? C. Probe-and-release ports
qualify-launchpad-once "a started Launchpad …" (2): exit 143 D. Fake installs its TERM trap after the start line
launchpad-vault-page "looks at the audience after its start" (4): 1 removal journaled, 2 expected E. Test waits for the vault, not for the journal

I reproduced it on Linux in a CI-like container (bun 1.4.2, 4 CPUs, ext4, the exact shard command): on main, shard 1 failed both times with the race A and B tests (the wait for an own read is bounded: "Received: 1" ms instead of >= 140, i.e. the mutation refused at once).

What changes

A. src/folder/lock.ts (product fix). #272 (#317) lets a mutation of this process wait for the process's own short read. The read was counted only when acquireFolderOperationLock returned, but the kernel grants the flock earlier and the read then still verifies and fsyncs the lock directory. A mutation refused in that window saw no own read and failed as busy: /api/update answered 400 operation-failed right after a change whose shell-setup re-read had started; a Launchpad start found the lock unavailable. The read now counts in the same synchronous step as the kernel's grant and stops counting when its descriptor closes; a mutation refused while an own read ended on its way retries too. Another process's lock is still refused at once. New test: a mutation started at every moment of a read's acquisition and release; it fails on the old code (deterministically, locally) and passes now. This also helps people: a quick second change in the Launchpad no longer fails with "recovery may be required".

B. startLaunchpad(...).settled(). Resolves when the reads the Launchpad began on its own in the background have ended (whose Personalspace this is, decided at the start; the shell setup re-read). No answer waits for it. The prompts and content-routes tests wait for it before they count what a request asks GitHub; the work-Environment case now also checks deterministically that the start itself asked nothing. The holder's own settled() (src/content/holder.ts) also waits for a peek that is still reading the checkout's layout: the start's void peek() registers its decision only after that read, so settled() used to resolve before the decision had begun (found in the Linux container after the first version of this PR; new deterministic test, fails on the old code).

C. tests/fixtures/ports.ts. 22 test files picked a port for a listener the Launchpad binds itself by binding port 0 and releasing it: an ephemeral port the kernel may give to any other socket before the Launchpad binds it (four workers open connections all the time). One fixture replaces the copies: ports 20000-31999, below the Linux and macOS ephemeral ranges, 1000 per --parallel worker (BUN_TEST_WORKER_ID), never twice in a file, bound ports skipped.

D./E. The qualify fake installs its trap first; the vault audience test waits until both removals are journaled.

What does not change

No retries, no skipped tests, no longer timeouts, the workflow is unchanged. close() of the Launchpad is unchanged (it does not wait for GitHub). Servers that bind port 0 themselves are unchanged.

Not covered

  • folder-vault-line "with a vault in the handover …" failed once on macOS (setup exit 1 instead of 2, 37 ms). It did not reproduce in 12 local runs and I found no race in the code; left as is.
  • Rare one-offs seen once each (module-lifecycle app-request exit 2, systemd-user-integration launch-failed, shell-artifact contract.js build hang on 10-13/10-15) are different causes and not addressed here.

Verification

  • Linux container (bun 1.4.2, --cpus=4, ext4 loop mount as TMPDIR, bun test --shard=N/2 --parallel=4 --timings=tests/timings.json --timeout=20000), one container at a time:
    • main 442e83f, shard 1, 5 runs: 3 failed on race A/B tests (the wait for an own read is bounded twice, Launchpad saves an active voice …, root access (root decision 0201 point 8) …).
    • this PR 96f5b1b, shard 1, 3 runs: no race failure (one run hit the 15 s timeout of frozen-install-process "runs real Bun", which installs over the network from the container); shard 2, 3 runs: one run hit the 3 s bound of tools-team-github "reads of the Folder's preset beside each other …" (24 serialized lock acquisitions with two fsyncs each on the container's loop device; never failed on CI). Both are time bounds under the container's slower I/O, not races, and are unchanged here.
  • The two new regression tests (folder-lock "a mutation started at any moment …", personalspace-holder "settled waits …") fail on the code before this PR and pass with it.
  • CI on this PR: 8c6fe7e green (run 39128463852); 96f5b1b green three times in a row as whole-workflow runs (run 39128671202, attempts 1, 2 and 3).
  • bun test (the macOS job's command, without --parallel) locally on macOS at 96f5b1b: 3949 pass, 1 fail. The failure, recover "the supervised unit through systemctl --user, and its health socket" (5 s default timeout), fails the same way on main on this Mac when run alone (2 of 2), so it is local to this machine and not touched here (it takes about 1.7 s on the macOS runner).

Refs #413, #425 and its duplicate #490 (they can be closed once this is merged and main stays green).

🤖 Generated with Claude Code

Note

Fix CI test races by tracking folder own-reads and adding settled()

  • Fixes the CI flakes from rc.6 readiness for #476: a refused tile Open shows its reason, install help states the data mount, the candidate smoke runs again #487 by fixing races between tests and the code's own background work, rather than working around them in the tests.
  • lock.ts: a process's short read is now counted from kernel lock acquisition until the descriptor closes. withFolderOperationLock waits on such reads instead of failing busy; busy locks from other processes still fail immediately.
  • holder.ts: settled() waits for in-progress peek layout reads and their holder decisions before resolving. The Launchpad object exposes settled() for the same purpose, covering startup Personalspace and shell setup reads.
  • Adds a shared freePort test fixture in ports.ts that hands out worker-specific low-numbered ports, replacing per-file ephemeral port-0 probes. Many tests now wait for startup settling before recording GitHub calls.
  • Risk: withFolderOperationLock may now retry busy acquisitions within its deadline when an own read is held or newly ended, instead of failing fast — check the retry logic in lock.ts if lock-deadline behavior matters.

Macroscope summarized 96f5b1b.

immakermatty and others added 5 commits October 18, 2026 14:39
…grants it

#272 (#317) let a mutation of this process wait for the process's own
short read instead of failing as busy. The read was counted only once
acquireFolderOperationLock returned, after the kernel had already granted
the flock and the read went on verifying and fsyncing the lock directory.
A mutation refused in that window saw no own read and failed at once:
`/api/update`, `/api/tools/update`, `/api/preview` answered 400
operation-failed right after a change whose shell setup re-read had just
started, and a Launchpad start found the Folder lock unavailable. Under a
loaded CI runner (four workers on four vCPUs, ext4 fsync) the window is
long enough to be hit regularly; it was the most frequent flake of the
suite (issue #413).

The read is now counted in the same synchronous step as the kernel's grant
and uncounted when its descriptor is closed; a mutation refused while an
own read ended on the way is retried too. Another process's lock is still
refused at once. The new test starts a mutation at every moment of a read's
acquisition and release; it failed on the old code and passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ephemeral range

22 test files picked a listener's port (a handover's listenPort, which the
Launchpad binds itself) by binding port 0 and releasing it. That is an
ephemeral port: until the Launchpad binds it, the kernel may hand the same
port to any other socket, and with four workers per runner opening
connections and port-0 listeners all the time it sometimes did, failing the
start with "Failed to start server. Is port <n> in use?"
(launchpad-organization-settings, environment-browser-view-identity on
#487's runs).

One fixture, tests/fixtures/ports.ts, replaces the copies: ports 20000-31999,
below the Linux (32768-60999) and macOS (49152-65535) ephemeral ranges so
the kernel never hands them out, a range of 1000 per `bun test --parallel`
worker (BUN_TEST_WORKER_ID), never the same port twice in one file, and a
port something already listens on is skipped. Servers that bind port 0
themselves are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the request

The Launchpad decides whose Personalspace this is in the background at its
start (a GitHub viewer and repository read) and re-reads the shell setup
after a change. Tests that assert "this request asks GitHub nothing" took
their snapshot of the stub's calls while that start work could still land,
so under load the start's own `viewer` call showed up as the request's
(launchpad-prompts "hosted personal: new-personal-module …", content-routes
"root access (root decision 0201 point 8) …"; issues #413, #425, #490).

The started Launchpad now has settled(): it resolves when those background
reads have ended. No answer waits for it. The tests wait for it before they
count what a request asks, and the work-Environment case also checks that
the start itself asked nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
qualify-launchpad-once: the fake Launchpad printed its start line before it
installed its TERM trap. launchpadOnce stops it as soon as the line and the
page arrive, so on a loaded runner the TERM came first and the shell ended
with 143 instead of 0. The trap is now installed first.

launchpad-vault-page "the installed Launchpad looks at the audience after
its start": the test waited until the fake vault listed no Správce, then
counted the journal's removals. The look removes a member and journals it
after the vault answers, so the second removal could still be unjournaled.
It now waits until both removals are journaled and the vault lists none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eek is about to start

The Launchpad's start peeks whose Personalspace this is without waiting.
peek() reads the checkout's layout first and only then starts the decision,
so settled() called meanwhile found nothing being decided and resolved at
once; the start's GitHub viewer call then still landed in the prompts
test's measurement (reproduced in a Linux container with the previous
commit). settled() now first waits for the peeks still reading the layout.
The new test failed on the old code and passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty Reviewed exact head 96f5b1b. The short read is counted synchronously after flock grants it and uncounted only after descriptor close; a mutation retries on a live or just-ended own read, with a bounded wait. An unrelated holder is not counted and remains a busy refusal. I found no deadlock or unbounded wait. The new settled() is called only by observers/tests, not in any HTTP request path; the holder waits for the in-flight peek to register its background decision. Port fixture and fake synchronization remain test-scoped. Local focused tests: 43/43 and 26/26; static checks and diff check pass. Workflow run 39128671202 attempts 1–3 succeeded on this SHA. No unresolved review threads. APPROVED; no merge performed.

@immakermatty
immakermatty merged commit 4680c44 into main Oct 18, 2026
19 checks passed
@immakermatty
immakermatty deleted the claude/ci-test-isolation branch October 18, 2026 13:45
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.

2 participants