Skip to content

fix three runtime races the green default exposed - #625

Merged
kacy merged 4 commits into
mainfrom
runtime-concurrency-audit
Jul 30, 2026
Merged

fix three runtime races the green default exposed#625
kacy merged 4 commits into
mainfrom
runtime-concurrency-audit

Conversation

@kacy

@kacy kacy commented Jul 30, 2026

Copy link
Copy Markdown
Owner

summary

a concurrency audit of cranelift/runtime/src/ after green became the linux
default. before the flip most runtime state saw one thread, or one thread per
task; now a fixed worker pool runs many tasks, three blocking pools exist, and
a reactor wakes tasks across threads. three genuine defects came out of it.

the struct weak-reference dead flag was a plain u32, written by the last
strong release on one thread and read by pith_struct_weak_load on another —
a data race, and the reader could also miss the destructor's writes. it is an
atomic now, with a release/acquire pair giving the reader an ordering edge to
the destructor.

process.output and the exec family held the calling green worker for the
child's entire lifetime, which contradicted the module's own documentation.
measured on a two-core box: two concurrent process.output calls starved a
cpu-bound task for 2012 ms at one worker and 1007 ms at two — one child's
lifetime per worker. they run on a small process pool now, the same shape dns
and file i/o use, and only plain owned data crosses to a pool thread so the
handle rule still holds. output_shell was already fine and is untouched.

the rng stepped its seed with a relaxed load/store pair, so two tasks could
step from the same seed and return identical "random" values. it is a
compare-exchange now.

what was tested

cargo test --release -p pith-runtime 134 pass, five new — including a
threaded test that pins the weak-reference ordering. make test on both
backends, both corpora at 285/285, make memcheck, make leak-check, and the
grpc examples. the starvation fix was verified by measurement on both sides:
2012 ms to 8 ms at one worker, with the children still completing correctly.

notes

three findings are documented in docs/limitations.md rather than fixed,
because each is a design decision rather than a repair: os.set_env racing
libc's getenv from pool threads, the raw-fd recycle window between a
registry lookup and its syscall, and sleep holding a green worker — which
also affects select's probe loop, so two idle selects can occupy both
workers. that last one wants a pure-timeout wait in the reactor, which is a
scheduling change worth deciding deliberately.

the audit's cleared list, recorded so it need not be re-derived: blocking.rs
queue, slot and counters; netpoll.rs registration and on_close coverage
across every close path including the pidfd; SendCoroutine's pinning
invariant against all three new wakers; the handle-construction rule on pool
threads; the refcount paths; and the perf and stats counters, which are all
atomics already.

kacy added 4 commits July 30, 2026 13:21
the last strong release wrote the dead flag with a plain u32 store and
pith_struct_weak_load read it back with a plain load. structs cross task
boundaries, and under the green backend tasks run concurrently on a fixed
worker pool, so the writer and the reader can genuinely be on different
threads: a plain read/write pair on shared memory is a data race, and the
load had no ordering edge to the destructor's writes either.

the flag is now an AtomicU32 like its strong/weak neighbors. the release
store in pith_struct_release pairs with the acquire load in
pith_struct_weak_load, so a reader that observes the target dead also
observes everything its destructor did. a reader that still sees it alive
gets the borrowed pointer exactly as before — the weak-read semantics are
unchanged, only the flag's cross-thread visibility is now defined.

adds a test that releases the last strong owner on another thread and
checks the weak load here sees the death (the join provides the
happens-after edge the assertion relies on).
process.rs opens with the claim that no green worker is parked on a child
anywhere in the runtime, and for spawn/read/write/wait that is true — the
pipes and the pidfd all go through the epoll reactor. the run-to-completion
calls broke it: pith_process_output_argv, pith_exec_output, and pith_exec
all drove Command::output()/status() inline, holding the calling worker for
the child's whole lifetime. the default worker pool is two threads, so two
concurrent process.output calls froze every other task in the program until
the children chose to exit.

these now go through a small dedicated pool, the same shape dns (#598) and
file i/o (#601) use: the Command crosses to the pool thread as plain owned
data, the Output/ExitStatus comes back the same way, the task parks in the
meantime, and every pith-visible value is still built on the calling task's
thread. off a green task blocking::run takes its inline arm, so the
os-thread backend and main-thread callers are byte-for-byte unaffected. the
pool is separate from file/dns so an unbounded child cannot queue in front
of a write or a dial.

tested: new unit tests drive command_output/command_status through the
inline arm (exit codes, both pipes, a missing program); the pool arm is the
same closure the existing blocking-pool tests already cover.
pith_random_float advanced the shared seed with a relaxed load followed by
a relaxed store. that pair is not one step: two tasks drawing at once on
different workers could both read the same seed, both compute the same
successor, and both return the identical draw — a lost update the atomics
made race-free in the rust sense but still wrong as a sequence.

fetch_update makes each draw claim a distinct step of the LCG chain.
relaxed ordering is all it needs — the returned value is the only payload,
nothing else is published through the seed, and pith_random_seed remains a
plain store since seeding is a deliberate overwrite of the sequence.

tested: a reproducibility test pins the seeded single-thread sequence, and
a four-thread draw asserts no two concurrent draws return the same value
(the lost-update shape produced duplicates by construction; the interleaving
is timing-dependent on a two-core host, so this is a regression gate rather
than a reliable reproduction).
a pass over the runtime's shared state now that green is the linux default
turned up three things worth fixing (previous commits) and three things
whose fix is a semantics decision, recorded here instead:

- os.set_env after tasks exist races glibc's own getenv on the pool
  threads (getaddrinfo reads the environment per lookup); both candidate
  fixes change observable behavior.
- an fd number recycled between a handle lookup and the syscall can land
  the call on the wrong fd — the raw-fd hazard, on both backends.
- sleep holds a green worker, and so do the loops built on it: select's
  probe wait, the timer workers, a context's deadline watcher. the fix is
  a pure-timeout wait in the reactor, which already keeps the deadline
  heap it would need.

the green sections of both docs also catch up with the previous commit:
process.output and friends no longer hold a worker, they run on a process
pool while the caller parks.
@kacy
kacy merged commit dd1a75e into main Jul 30, 2026
2 checks passed
@kacy
kacy deleted the runtime-concurrency-audit branch July 30, 2026 15:30
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