Skip to content

fix(sync): stop craft's locks busy-waiting - #80

Merged
glennmichael123 merged 1 commit into
mainfrom
fix/blocking-primitives
Aug 27, 2026
Merged

fix(sync): stop craft's locks busy-waiting#80
glennmichael123 merged 1 commit into
mainfrom
fix/blocking-primitives

Conversation

@glennmichael123

Copy link
Copy Markdown
Contributor

The deferred item from #78. compat_mutex is what every lock in craft is built on — 10 files, 88 lock sites — and it spins.

What it costs

std.Thread.Mutex and std.Thread.Condition were removed; the Io rework moved blocking primitives to std.Io.Mutex / std.Io.Condition, which take an Io because that is what knows how to wait. This file papered over that by spinning: Mutex.lock looped on tryLock with spinLoopHint, Condition.wait spun on a u32 flag, and neither ever yielded.

Measured, not asserted:

3 waiters, 300ms wall: 896 ms of CPU burned spinning

Three cores held against whatever they are waiting to finish. And #78 made this hotter, by routing ~200 std.log sites through a module that takes one of these locks across a write.

The fix

Both real primitives, with the Io handed to the module once from io_context.init rather than threaded through 88 call sites — that would be a signature change across ten files for something that is a property of the process, not of any call.

Installed before global_state, deliberately. global_state locks one of these mutexes to answer io_context.get(), so having the lock ask for the Io itself would recurse into the lock being taken.

The spin path stays for anything running before startup finishes — tests, --eval, anything locking before io_context.initbut it yields now, so a waiter cannot hold a core against the holder.

Mixing the two paths is safe in the only direction that occurs. install happens once and is never undone, so:

  • before it, the only way to acquire is tryLock, which moves unlocked → locked_once and never to contended — an unlock with no Io therefore has no waiter to wake, and none can exist
  • after it, every acquire and release has an Io

A lock taken before and released after is fine: the state is locked_once either way, and unlock wakes nobody.

What I could not demonstrate, and am not claiming

The flag-based Condition also had a lost-wakeup race — broadcast set one flag and the first waiter to observe it cleared it. I wrote a test to catch that and it passed against the old code, because spinning waiters poll often enough that they all see the flag before any clears it. It is a race, not a certainty.

So it is recorded in the file as a reason, not as a fixed bug, and the test is kept for the property it asserts with a comment saying explicitly that it is not a regression test. The CPU measurement is the part that reproduces.

Tests

These primitives had none — which is how they stayed spin-only across the Zig version change that made them so. Five now: mutual exclusion, the pre-Io path, the before/after mixing case, a 4-thread × 2000-iteration counter that would not add up under a lost update, and broadcast reaching every waiter.

Also verified the binary still runs correctly under the new locks — --log-file output is byte-identical to before the change.

Verified

zig build · zig build test · zig build test-js · x86_64-windows cross-build · zig fmt --check · 511 bun tests · pickier 38 warnings, unchanged from main.

@glennmichael123

Copy link
Copy Markdown
Contributor Author

Reopening to trigger CI — no run was created on push.

`compat_mutex` exists because `std.Thread.Mutex` and `std.Thread.Condition`
were removed: the Io rework moved blocking primitives to `std.Io.Mutex` and
`std.Io.Condition`, which take an `Io` because that is what knows how to wait.
This file papered over that by spinning. `Mutex.lock` looped on `tryLock` with
`spinLoopHint`, `Condition.wait` spun on a `u32` flag, and neither ever
yielded.

Measured: three threads waiting 300ms on a spun flag burn 896ms of CPU. Three
cores held against whatever they are waiting to finish. Ten files and 88 lock
sites are built on this, and #78 added ~200 more by routing every std.log call
through a module that takes one of these locks across a write.

Both real primitives are used now. The `Io` they need is handed to the module
once from `io_context.init` rather than threaded through all 88 call sites,
which would have been a signature change across ten files for something that
is a property of the process rather than of any call. It is installed before
`global_state` deliberately: `global_state` locks one of these mutexes to
answer `io_context.get()`, so having the lock ask for the `Io` would recurse
into the lock being taken.

The spin path stays for everything that runs before startup finishes — tests,
`--eval`, anything locking before `io_context.init` — but it yields now, so a
waiter cannot hold a core against the holder. Mixing the two is safe in the
only direction that occurs: `install` happens once and is never undone, so
before it the only way to acquire is `tryLock`, which never reaches the
`contended` state that unlock needs an `Io` to wake.

One thing I could not demonstrate and am not claiming: the flag-based
`Condition` also had a lost-wakeup race, where `broadcast` set one flag and the
first waiter to observe it cleared it. A test written to catch that passed
against the old code — spinning waiters poll often enough that they all see the
flag first — so it is recorded as a reason and not as a fixed bug. The test is
kept for the property, labelled as not being a regression test.

These primitives had no tests, which is how they stayed spin-only through the
Zig version change that made them so.
@glennmichael123
glennmichael123 force-pushed the fix/blocking-primitives branch from 3300ac4 to 9d7b1fe Compare August 27, 2026 14:47
@github-actions

Copy link
Copy Markdown

✅ Binary load time

rounds:    25 interleaved
base:      p50 15.8ms   p95 17.2ms   (15.3–18.8ms)
head:      p50 15.8ms   p95 17.5ms   (15.5–18.1ms)
delta:     -0.3%  (fails above +20.0%)

No binary load time regression.
What this measures

craft --help: process spawn, dynamic linking and argument parsing.
It never opens a window, so it cannot see a change in window or
webview startup — real startup is benchmarks/startup.bench.ts, which
needs a display.

Both binaries are measured interleaved on this runner and compared by
p50, rather than against a number recorded on another machine. On
byte-identical binaries that method reads within ~3.5%; the old one
swung 45%.

@github-actions

Copy link
Copy Markdown

✅ Binary Size Report

Metric Value
Current Size 14252KB (13.91MB)
Change 0KB (0%) increased
Size limits
  • Warning: 14.50MB
  • Maximum: 16.00MB

@glennmichael123
glennmichael123 merged commit e5cb271 into main Aug 27, 2026
10 checks passed
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