browser: a fork brings spinning threads to a safepoint, and says when it can't

A thread running guest code that makes no call stopped for a fork's copy
only when its safepoint countdown ran out on its own. gex_fork_hold now runs
each other thread's counter out, as a signal for it does, so its next
back-edge stops it. A thread that still isn't out of guest code after two
seconds (lost in a host call) is said in one line: that child's memory may
be torn.

tests/fork-snapshot runs three cases: one writer that never makes a
syscall; four such writers; and four writers, all but one making calls,
beside a thread that creates and joins threads all along, whose children
each start a thread of their own. Each case prints its mean ms per fork.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_c8c04c00da9a4525ab21b0b9f146c598
This commit is contained in:
velzieandClaude Opus 5.5 committed 2026-10-25 14:37:35 -04:00
1 parent d10c694f1e
commit 7e8e3a202e
3 files changed
+107 -21

No files matched your search

+28 -7
View File
@@ -9992,26 +9992,47 @@ thread_local! {
/// from different moments: libc's lists of threads and stacks as no thread
/// ever left them, which the child walks after fork. Each other thread is
/// told to hold and is waited for until it is out of guest code (it parks
/// at its return to it) or parked at the hold; one that never gets there is
/// given up on after `EXEC_WAIT_MAX_NS`. [`gex_fork_taken`] lets them go.
/// at its return to it) or parked at the hold. One running guest code is
/// brought to its next back-edge's safepoint, as a signal for it is: its
/// safepoint counter is run out. One that never gets there (lost in a host
/// call) is given up on after `EXEC_WAIT_MAX_NS`, and said so: that child's
/// memory may be torn. [`gex_fork_taken`] lets them go.
#[no_mangle]
pub extern "C" fn gex_fork_hold() {
use std::sync::atomic::Ordering::SeqCst;
let me = cur();
let cells: Vec<Arc<ThreadCell>> =
with_exec_lock(|h| h.threads.iter().filter(|(i, _)| *i != me).map(|(_, t)| t.state_arc()).collect());
for c in &cells {
let cells: Vec<(Arc<ThreadCell>, u64)> = with_exec_lock(|h| {
h.threads.iter().filter(|(i, _)| *i != me).map(|(_, t)| (t.state_arc(), t.ctx_addr())).collect()
});
let mem = guest_bytes();
for (c, at) in &cells {
c.hold.store(1, SeqCst);
// Run the counter out. A store of the thread's own countdown racing
// this one puts it back at most `SAFEPOINT_INTERVAL` back-edges away.
let at = (*at as usize).wrapping_add(ctx::SAFEPOINT_COUNTER);
if let Some(w) = mem.get_mut(at..at + 4) {
// SAFETY: an aligned word of the guest's context, in linear memory.
unsafe { (*(w.as_mut_ptr() as *const std::sync::atomic::AtomicI32)).store(0, SeqCst) };
}
}
let stopped = |c: &ThreadCell| c.out.load(SeqCst) || c.held.load(SeqCst) || c.halted.load(SeqCst);
let until = now_ns() + EXEC_WAIT_MAX_NS;
let nap = std::sync::atomic::AtomicI32::new(0);
while cells.iter().any(|c| !c.out.load(SeqCst) && !c.held.load(SeqCst) && !c.halted.load(SeqCst)) && now_ns() < until {
while cells.iter().any(|(c, _)| !stopped(c)) && now_ns() < until {
#[cfg(feature = "shared-memory")]
unsafe {
warch::memory_atomic_wait32(nap.as_ptr(), 0, 50_000)
};
}
HOLDING.with(|h| h.borrow_mut().extend(cells));
let running = cells.iter().filter(|(c, _)| !stopped(c)).count();
if running != 0 {
warn_line(&format!(
"[gex] pid {}: fork copied memory with {running} thread(s) still running guest code after {} ms; the child's memory may be torn",
MY_PID.load(std::sync::atomic::Ordering::Relaxed),
EXEC_WAIT_MAX_NS / 1_000_000
));
}
HOLDING.with(|h| h.borrow_mut().extend(cells.into_iter().map(|(c, _)| c)));
}
/// The memory for the fork is copied: the threads [`gex_fork_hold`] held go
+56 -2
View File
@@ -5,36 +5,76 @@
* (A's page early, B's later) gives the child an A well behind its B. At any
* one moment A is B or B - 1, and the child says which it saw.
*
* main FORKS THREADS: prints "forks N torn T" and exits 0 when T is 0. */
* Writer 0 never makes a syscall: only its back-edges' safepoints can stop
* it. With CALLS, the others make one every 64 writes. With CHURN, one more
* thread creates and joins threads all along, so libc's list of threads
* changes under the fork, and each child starts and joins one of its own.
*
* main FORKS THREADS [CALLS [CHURN]]: prints "forks N torn T", then
* "fork ms M" (the mean from fork to the child's reaping), and exits 0
* when T is 0. */
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#define MAXT 8
static volatile uint64_t A[MAXT];
static volatile uint64_t *B;
static volatile int stop;
static int calls;
static void *writer(void *arg)
{
int t = (int)(intptr_t)arg;
int call = calls && t != 0;
for (uint64_t k = 1; !stop; k++) {
B[t * 512] = k;
__atomic_thread_fence(__ATOMIC_RELEASE);
A[t] = k;
__atomic_thread_fence(__ATOMIC_RELEASE);
if (call && (k & 63) == 0) syscall(SYS_sched_yield);
}
return NULL;
}
static void *nothing(void *arg)
{
return arg;
}
static volatile uint64_t churned;
static void *churn(void *arg)
{
(void)arg;
while (!stop) {
pthread_t t;
if (pthread_create(&t, NULL, nothing, NULL) == 0) pthread_join(t, NULL);
churned++;
}
return NULL;
}
static double now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1e3 + ts.tv_nsec / 1e6;
}
int main(int argc, char **argv)
{
int forks = argc > 1 ? atoi(argv[1]) : 10;
int threads = argc > 2 ? atoi(argv[2]) : 2;
calls = argc > 3 && atoi(argv[3]);
int churning = argc > 4 && atoi(argv[4]);
if (threads < 1) threads = 1;
if (threads > MAXT) threads = MAXT;
/* Far above .bss: 64 MiB of mapping, B at its end. */
size_t len = 64u << 20;
@@ -42,13 +82,24 @@ int main(int argc, char **argv)
if (m == MAP_FAILED) return 2;
B = (volatile uint64_t *)(m + len - MAXT * 512 * 8);
pthread_t th[MAXT];
pthread_t ch;
for (int t = 0; t < threads; t++) pthread_create(&th[t], NULL, writer, (void *)(intptr_t)t);
while (A[threads - 1] < 1000) usleep(1000);
if (churning) pthread_create(&ch, NULL, churn, NULL);
while (A[threads - 1] < 1000 || (churning && churned < 10)) usleep(1000);
int torn = 0;
double ms = 0;
for (int i = 0; i < forks; i++) {
double t0 = now_ms();
pid_t p = fork();
if (p == 0) {
int bad = 0;
if (churning) {
pthread_t t;
if (pthread_create(&t, NULL, nothing, NULL) != 0 || pthread_join(t, NULL) != 0) {
printf("child %d: no thread\n", i);
bad = 1;
}
}
for (int t = 0; t < threads; t++) {
uint64_t a = A[t], b = B[t * 512];
if (b - a > 1) {
@@ -61,10 +112,13 @@ int main(int argc, char **argv)
}
int st = 0;
if (p < 0 || waitpid(p, &st, 0) != p || !WIFEXITED(st)) return 3;
ms += now_ms() - t0;
torn += WEXITSTATUS(st) != 0;
}
stop = 1;
for (int t = 0; t < threads; t++) pthread_join(th[t], NULL);
if (churning) pthread_join(ch, NULL);
printf("forks %d torn %d\n", forks, torn);
printf("fork ms %.1f\n", ms / forks);
return torn ? 1 : 0;
}
+23 -12
View File
@@ -6,7 +6,7 @@
# memory had A from one moment and B from a later one, every fork; glibc's
# child walked its stack lists as no thread ever left them and crashed.
#
# tests/fork-snapshot/run.sh
# tests/fork-snapshot/run.sh (FORKS, GEX_WEB_DIR: another build of the shim and seed)
#
# Needs ~/toolchains/aarch64-linux-musl-cross (GEX_TOOLCHAIN) and the shared
# browser build in web/public/gex (shims/browser/build.sh).
@@ -15,18 +15,29 @@ here=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$here/../.." && pwd)
tc=${GEX_TOOLCHAIN:-$HOME/toolchains/aarch64-linux-musl-cross}
cc=$tc/bin/aarch64-linux-musl-gcc
seed=$root/web/public/gex/seed.shared.wasm
seed=${GEX_WEB_DIR:-$root/web/public/gex}/seed.shared.wasm
for t in "$cc" "$seed"; do [ -e "$t" ] || { echo "skip: $t is not there"; exit 0; }; done
w=${WORK:-$root/target/fork-snapshot-test}
rm -rf "$w"; mkdir -p "$w"
"$cc" -O1 -o "$w/main" "$here/main.c" -lpthread || exit 1
forks=${FORKS:-20} threads=${THREADS:-4}
(cd "$w" && GEX_SHARED=1 timeout "${TMO:-300}" node "$root/shims/browser/js/run-node.mjs" \
--file "/lib/ld-musl-aarch64.so.1=$tc/aarch64-linux-musl/lib/libc.so" --file "$w/main=$w/main" \
"$seed" "$w/main" "$forks" "$threads" > "$w/run.out" 2> "$w/run.err" < /dev/null); rc=$?
if [ $rc = 0 ] && grep -q "^forks $forks torn 0$" "$w/run.out"; then
echo "ok fork-snapshot ($forks forks beside $threads writing threads, each child one moment's memory)"
else
echo "FAIL fork-snapshot (rc=$rc): $(tail -1 "$w/run.out")"; grep -a '^child' "$w/run.out" | head -3
exit 1
fi
forks=${FORKS:-20}
pub=${GEX_WEB_DIR:-$root/web/public/gex}
# THREADS writers, CALLS (the writers but the first make syscalls), CHURN
# (a thread making and joining threads); the first case's one writer never
# makes a syscall.
fail=0
for c in "1 0 0" "4 0 0" "4 1 1"; do
set -- $c
(cd "$w" && GEX_SHARED=1 GEX_WEB_DIR=$pub timeout "${TMO:-300}" node "$root/shims/browser/js/run-node.mjs" \
--file "/lib/ld-musl-aarch64.so.1=$tc/aarch64-linux-musl/lib/libc.so" --file "$w/main=$w/main" \
"$pub/seed.shared.wasm" "$w/main" "$forks" "$@" > "$w/run.out" 2> "$w/run.err" < /dev/null); rc=$?
what="$forks forks beside $1 writing thread(s)$([ "$2" = 1 ] && echo ", all but one making calls")$([ "$3" = 1 ] && echo ", threads made and joined")"
if [ $rc = 0 ] && grep -q "^forks $forks torn 0$" "$w/run.out"; then
echo "ok fork-snapshot ($what; $(grep '^fork ms' "$w/run.out"))"
else
echo "FAIL fork-snapshot ($what, rc=$rc): $(grep -a '^forks' "$w/run.out")"; grep -a '^child' "$w/run.out" | head -3
grep -a 'torn\|panic' "$w/run.err" | head -3
fail=1
fi
done
exit $fail