Skip to content

Two host tests that reddened under concurrency read their own state right - #1186

Merged
Japabu merged 2 commits into
mainfrom
flaky-host
Nov 5, 2026
Merged

Japabu merged 2 commits into
mainfrom
flaky-host

Conversation

@Japabu

@Japabu Japabu commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Two host tests went red intermittently, each for a defect in the test:

  • buildlock::tests::a_narrow_guest_does_not_overtake_a_wide_one_waiting (issues/a-narrow-guest-test-reads-any-queue-holder-as-the-wide-waiter.md). The test treats the wide guest as queued once a probe finds the queue lock held. A flock belongs to the open file description, and a fork made by a sibling test shares every description this process has open until the child execs. If a sibling forks while the first guest holds the queue, the lock stays held after that guest drops its handle. The probe then treats the wide guest as queued before it has asked, and the narrow guest takes the free CPU. The failed test's guest threads also outlive it and recreate lock files in its removed scratch directory, so the process's last TempDir drop panics Directory not empty. That panic lands on whichever test drops last: sysroot's the_primary_s_fork_checkout_is_moved_to_its_pin in a whole --ci host run here, and a_clean_cannot_land_inside_a_build in a loop.
  • toyos SDK log::proof::a_lane_gives_its_reader_every_record_in_order_or_counts_it_refused (issues/a-lane-reader-counted-one-refusal-fewer-than-its-writer-made.md, 49999 counted against 50000). The read loop stopped on JoinHandle::is_finished, which is a Relaxed Arc::strong_count load and orders nothing. join documents that the thread's completion synchronizes with its return. On a weak-ordered host the last read could therefore miss the writer's last refusal.

Change

  • The queue scenario becomes an #[ignore]d test that the real test runs alone through rerun, the pattern this module already uses for process-level scenarios. That process has no sibling forks, so a failed probe means the wide guest holds the queue. The scenario's threads also end with its process.
  • The lane proof reads while the writer runs, then joins it, then reads once more.
  • Both records are closed.

Evidence

Dev host (aarch64 macOS, 14 cores), the build-system lib test binary filtered to buildlock::, the base and fixed binaries interleaved in one session, 150 runs each: base 9 red, this change 0. The one leftover scratch root inspected held only this test's locks/guest-cpus/cpu-*. Earlier loops of the base reproduced it 3 of 15 and 2 of 60.

The lane race was not reproduced: the unchanged test ran green 2400 times (twelve parallel loops of 200 on the same host). That fix rests on join's documented ordering against is_finished's implementation.

Host checks: cargo run -- --ci host at 205e7f8 (the head 7d79229 has the same tree, its message reworded), exit 0, 108 steps green. The same check on #1187's tree reddened only on this buildlock test and its Directory not empty follow-on. Logs and the A/B: https://github.com/ToyOSOrg/evidence/tree/ab37950cf5b2603b81464a27a05eeed52b5480af

No guest behaviour changes, so no guest rows ran. sdk/toyos/src changes in test code only, but that is a token change, so the sysroot identity moves.

🤖 Generated with Claude Code

…is_finished

a_lane_gives_its_reader_every_record_in_order_or_counts_it_refused ended
its read loop on JoinHandle::is_finished and compared the refusals it had
read with the writer's own count. JoinHandle::join documents that the
thread's completion synchronizes with its return; is_finished documents no
ordering, and is `Arc::strong_count(&self.packet) == 1`, a Relaxed load
(std's thread/lifecycle.rs, alloc's sync.rs). So seeing the writer finished
orders none of its pushes before the reader's next read, and on a
weak-ordered host the last read can miss the writer's last refusal: the one
red issues/a-lane-reader-counted-one-refusal-fewer-than-its-writer-made.md
recorded, 49999 read against 50000 refused at a load average of 44, was
one short at the end.

The reader now reads while the writer runs, joins it, and reads once more:
the join orders every push before that read. The ring test beside it already
waited on a Release/Acquire flag stored after its joins.

Not reproduced: the unchanged test ran green 2400 times, twelve loops of
200 at once on an aarch64 macOS host. The fix rests on join's documented
ordering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildlock's a_narrow_guest_does_not_overtake_a_wide_one_waiting reads the
wide guest as queued once a probe finds the queue lock held, and only then
starts the narrow one
(issues/a-narrow-guest-test-reads-any-queue-holder-as-the-wide-waiter.md). A flock belongs to the open file description, and a
sibling test's fork shares every description this process has open until the
child execs: if one forks while the first guest holds the queue, the queue
stays held after that guest's handle is dropped, the probe reads the wide
guest as queued before it has asked, and the narrow guest that follows takes
the free CPU first. Its guest threads then outlive the failed test, write lock
files into its removed scratch, and the process's last TempDir finds its root
not empty: the second red that run reported against whichever test dropped
last (sysroot's the_primary_s_fork_checkout_is_moved_to_its_pin, buildlock's
a_clean_cannot_land_inside_a_build).

Measured on the dev host (aarch64 macOS), the lib test binary filtered to
`buildlock::`: in a whole `cargo run -- --ci host`, the two reds above; in
loops of the filtered binary, 3 of 15 and 2 of 60 red; the one red read
whole was this test, its leftover root holding only this test's
`locks/guest-cpus/cpu-*`. A/B in
one session, the two binaries interleaved 150 runs each: the base 9 red, this
change 0.

The scenario is now an #[ignore]d test the real one runs alone through
`rerun`, as the module's other process-level scenarios are: no sibling forks
in that process, and its threads end with it. There the queue's only holders
are the guests and the probe, and the first guest's handle is closed before
it reports, so a failed probe is the wide guest holding the queue, which is
what the record's exit asked for, and the record goes.

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

Japabu commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1186 at 7d79229

Architecture. Both changes are confined to tests and fix defects in the tests themselves. The product code in build/buildlock.rs and sdk/toyos/src/log/ring.rs is unchanged, and I found no product defect that the changes hide. Net change: +19/−56, nearly all of the deletion being the two closed records.

buildlock: the cause is real, and isolating the test does not hide a fairness defect.

  • guest_cpus drops queue before it returns (buildlock.rs:224), and the first guest reports only after that. In the old test, a failed probe could therefore come only from the wide guest or from another copy of the first guest's description. A sibling test's spawn creates such a copy, which stays open until the child execs.
  • The red log (host-ci-e1f7a40fb-buildlock-red.log:687–692) shows the wide guest waiting for the queue and the narrow guest then acquiring it. So the probe saw a holder that was not the wide guest, which is exactly this mechanism.
  • In production, a copy of a description held this way only delays the queue or a CPU until the child execs. Waiters still never take the CPU of a queued wide guest. Only the test's probe read the copy as "the wide guest is queued". Fairness among waiters on queue is not FIFO by design, and this change leaves that untouched.
  • The process run through rerun has no sibling tests, so a failed probe now means the wide guest holds the queue. That meets the record's exit. The orphaned-threads explanation for the Directory not empty follow-on also holds: the sysroot test's first panic at sysroot.rs:649 is that test's own expected refusal, and its real failure is the panic in the TempDir root removal.

Lane proof: the cause is real. In the fork, is_finished is Arc::strong_count(..) == 1, and strong_count is a Relaxed load (rust/library/std/src/thread/lifecycle.rs:212, alloc/src/sync.rs:2031). The lane's refused word is written only by its one writer and read Relaxed (ring.rs:132–133, 224). Reader::refused accumulates monotonically, so the lane cannot lose a count. The only way to read 49999 is a stale read that no happens-before edge prevents, and the join supplies that edge. Closing the record without a reproduction is justified by inspection, and the commit says so honestly.

Records: both deletions are accurate, and nothing outside issues/ refers to either record.

Required

None.

Suggestions

  • build/buildlock.rs:224 (and Guard's drop, :96): call unlock() explicitly before closing. flock's unlock applies to the description, so a concurrent spawn's inherited copy would no longer hold the queue or the CPUs. In production the build runs guests on threads and spawns QEMU from threads, so a released lock stays held until that child's exec, which costs latency only. This one-line product change would also remove the reason for the rerun. Keeping the isolation is acceptable.
  • Evidence r1/buildlock-ab.txt is a single summary line. Add the loop command and the two binaries' revisions so that the 9/150 versus 0/150 result can be reproduced.

LAND

@Japabu
Japabu marked this pull request as ready for review November 5, 2026 11:38
@Japabu
Japabu added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 556b68d Nov 5, 2026
10 checks passed
@Japabu
Japabu deleted the flaky-host branch November 5, 2026 12:07
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