A dependency cycle reports a clean error at --workers 1 and hangs forever at --workers 16.
$ cat cyc.nix
let
work = n: builtins.foldl' (a: b: a + b) 0 (builtins.genList (i: i) n);
xs = builtins.genList (i:
if i == 17 then (work 20000) + (builtins.elemAt xs 33)
else if i == 33 then (work 20000) + (builtins.elemAt xs 17)
else work 30000) 64;
in builtins.deepSeq xs 1
Eight runs each, 15s timeout (124 is the timeout, 1 is the correct error: RecursiveThunk):
workers=1 : 1 1 1 1 1 1 1 1
workers=16 : 124 1 124 124 1 124 124 1
workers=16 --no-fanout : 1 1 1 1 1 1 1 1
The hang is a park, not a spin:
$ /usr/bin/time -l timeout 15 fix eval --workers 16 cyc.nix
15.01 real 0.11 user 0.07 sys
0.18s of CPU over 15s of wall clock, across 16 workers. Every worker is asleep.
Future.tryClaim (src/runtime/future.zig:210) detects recursion only when the same fiber re-enters its own claim:
.evaluating => {
const c = self.claimer.load(.acquire);
if (c == claimer) return .blackhole;
return .busy;
},
.busy means enroll on the waiter list and yield. That is correct for genuine contention, but when a cycle is split across two fibers — which is exactly what the urgent list fan-out does here, since elements 17 and 33 land in different 16-item batches — fiber A claims 17 and parks on 33, fiber B claims 33 and parks on 17. There is no wait-for graph, no timeout and no claim stealing, so neither ever wakes.
--no-fanout restores the correct error, which is consistent with fan-out being what splits the cycle across fibers.
Two notes on why this survived the suite:
test-lang runs --workers 1 for all 471 cases, and bench_check defaults to 1, so nothing differential ever runs multi-worker.
model/FutureWait.tla models each Future in isolation. A wait cycle spanning two futures is outside the state space, so the TLA+ model cannot reach this state by construction.
fix 0.3.0 at 1f1a99cf, built -Doptimize=ReleaseSafe with Zig 0.16.0, darwin/aarch64. Reference implementation is Nix 2.34.8.
A dependency cycle reports a clean error at
--workers 1and hangs forever at--workers 16.Eight runs each, 15s timeout (
124is the timeout,1is the correcterror: RecursiveThunk):The hang is a park, not a spin:
0.18s of CPU over 15s of wall clock, across 16 workers. Every worker is asleep.
Future.tryClaim(src/runtime/future.zig:210) detects recursion only when the same fiber re-enters its own claim:.busymeans enroll on the waiter list and yield. That is correct for genuine contention, but when a cycle is split across two fibers — which is exactly what the urgent list fan-out does here, since elements 17 and 33 land in different 16-item batches — fiber A claims 17 and parks on 33, fiber B claims 33 and parks on 17. There is no wait-for graph, no timeout and no claim stealing, so neither ever wakes.--no-fanoutrestores the correct error, which is consistent with fan-out being what splits the cycle across fibers.Two notes on why this survived the suite:
test-langruns--workers 1for all 471 cases, andbench_checkdefaults to 1, so nothing differential ever runs multi-worker.model/FutureWait.tlamodels each Future in isolation. A wait cycle spanning two futures is outside the state space, so the TLA+ model cannot reach this state by construction.fix 0.3.0 at
1f1a99cf, built-Doptimize=ReleaseSafewith Zig 0.16.0, darwin/aarch64. Reference implementation is Nix 2.34.8.