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 spinningThree 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.init — but 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 movesunlocked → locked_onceand never tocontended— an unlock with noIotherefore 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.