Repository navigation
Tests and Folder lock: the CI flakes since #487 are races with the code's own background work, fixed at the cause - #494
Conversation
…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
left a comment
There was a problem hiding this comment.
@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.
Why
mainCI 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.ymljob 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 onmain:folder-lock-unavailable)Failed to start server. Is port <n> in use?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 whenacquireFolderOperationLockreturned, 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/updateanswered 400operation-failedright 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 ownsettled()(src/content/holder.ts) also waits for a peek that is still reading the checkout's layout: the start'svoid peek()registers its decision only after that read, sosettled()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--parallelworker (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
setupexit 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.app-requestexit 2, systemd-user-integrationlaunch-failed, shell-artifactcontract.jsbuild hang on 10-13/10-15) are different causes and not addressed here.Verification
--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:main442e83f, shard 1, 5 runs: 3 failed on race A/B tests (the wait for an own read is boundedtwice,Launchpad saves an active voice …,root access (root decision 0201 point 8) …).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 onmainon 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()withFolderOperationLockwaits on such reads instead of failing busy; busy locks from other processes still fail immediately.settled()waits for in-progresspeeklayout reads and their holder decisions before resolving. The Launchpad object exposessettled()for the same purpose, covering startup Personalspace and shell setup reads.freePorttest 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.withFolderOperationLockmay 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.