From 8f1821eca29eb790cdef22f86836fb2bb1813c4e Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Mon, 17 Aug 2026 18:26:55 +0800 Subject: [PATCH 1/3] Lock the region lookup that clone does resolve_clone_stack_range reads the guest region array to find the mapping a clone's child stack falls in. Any concurrent mmap or munmap rewrites that array while holding mmap_lock, and clone never takes it, so the read races g->regions and g->nregions. ThreadSanitizer reports it as soon as one thread allocates while another clones, which no test did until tests/test-threaded-exec.c gave its workers an mmap loop. Neither caller holds a lock at that point and mmap_lock is order 1, the outermost, so taking it there introduces no inversion. --- src/runtime/forkipc.c | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/runtime/forkipc.c b/src/runtime/forkipc.c index 02b38075..f3e4bb9e 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -604,14 +604,21 @@ static void resolve_clone_stack_range(const guest_t *g, if (sp_off == 0 || sp_off > g->guest_size) return; + /* The region array is mutated under mmap_lock by any concurrent mmap or + * munmap, and clone does not otherwise take it. Reading it unlocked is a + * data race on g->regions and g->nregions, reported by ThreadSanitizer as + * soon as a sibling allocates while another thread clones. Neither caller + * holds a lock here, and mmap_lock is order 1, so taking it is safe. + */ + pthread_mutex_lock(&mmap_lock); const guest_region_t *r = guest_region_find(g, sp_off - 1); - if (!r) - return; - - if (start_out) - *start_out = r->start; - if (end_out) - *end_out = r->end; + if (r) { + if (start_out) + *start_out = r->start; + if (end_out) + *end_out = r->end; + } + pthread_mutex_unlock(&mmap_lock); } /* Forward declaration: worker entry runs after sys_clone_thread */ From f3a76e520fee72d04c76719b324752dc584251ce Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Tue, 18 Aug 2026 14:32:52 +0800 Subject: [PATCH 2/3] Sample the stack of a test about to time out A hang that only reproduces under suite load is otherwise reported as a bare "timeout after Ns" with nothing to diagnose. Both entry points arm a watchdog around every invocation: it samples the live process shortly before timeout(1) kills it, and the caller keeps the output only when the watchdog actually fired. The watchdog polls a sentinel rather than being killed by pid. Lanes that spawn hundreds of short-lived processes can recycle a pid between the watchdog exiting and a kill landing, which would turn this into a random SIGTERM at an unrelated process. The sentinel and the working file are named per arm. Sharing them would force the passing path to block until the watchdog exited, because one still symbolizing would otherwise write into the next test's file, and every passing test would pay the remainder of a collection that is then discarded. With distinct names a straggler can only touch its own, so only the timed-out path waits, and only because the move needs a finished file. The watchdog re-checks its sentinel immediately before writing, and removes its own output when the test it was watching has already been reaped. Its collection ends by appending, which would otherwise recreate a path the caller had just removed and leave it behind whenever no later arm swept it. run_timeout stays out of it: its callers pass their own cap and the coreutils suite expects rc=124 from the guest's own timeout(1), so a 124 there does not mean the harness watchdog fired. --- tests/driver.sh | 15 ++++ tests/lib/hang-sample.sh | 153 +++++++++++++++++++++++++++++++++++++++ tests/lib/test-runner.sh | 78 +++++++++++++------- 3 files changed, 218 insertions(+), 28 deletions(-) create mode 100644 tests/lib/hang-sample.sh diff --git a/tests/driver.sh b/tests/driver.sh index a866530b..cff91dae 100755 --- a/tests/driver.sh +++ b/tests/driver.sh @@ -97,6 +97,12 @@ TEST_LIST="$SCRIPT_DIR/manifest.txt" # shellcheck source=tests/test-config.sh source "$SCRIPT_DIR/test-config.sh" +source "$SCRIPT_DIR/lib/hang-sample.sh" + +# Capture a stack sample from a test about to hit the watchdog. A hang that only +# reproduces under suite load is otherwise reported as a bare "timeout after Ns" +# with nothing to diagnose. Runs detached, reads only, writes under build/. Set +# TEST_SAMPLE_TIMEOUTS=0 to turn it off. case "$ELFUSE" in /*) ;; @@ -368,6 +374,9 @@ for i in "${filtered_idx[@]}"; do # "${array[@]}". Host-limit annotations live in the manifest. Keep this # execution path generic so adding another constrained test does not require # a name-qualified branch here. + hang_sample_arm "$binary" "$TIMEOUT" \ + "$TESTDIR_ABS/test-timeouts/$(basename "$binary")-hang.txt" + if host_nofile=$(elfuse_test_host_nofile "$TEST_LIST" "$name"); then if [ -n "$host_nofile" ]; then if output=$(ulimit -n "$host_nofile" \ @@ -388,6 +397,12 @@ for i in "${filtered_idx[@]}"; do rc=125 fi + if [ "$rc" -eq 124 ]; then + hang_sample_finish 1 + else + hang_sample_finish 0 + fi + if evaluate_result "$rc" "$expected" "$stdout_pat" "$output"; then passed=1 else diff --git a/tests/lib/hang-sample.sh b/tests/lib/hang-sample.sh new file mode 100644 index 00000000..ae225647 --- /dev/null +++ b/tests/lib/hang-sample.sh @@ -0,0 +1,153 @@ +# Stack sample for a test about to hit its watchdog +# +# Copyright 2026 elfuse contributors +# SPDX-License-Identifier: Apache-2.0 + +# shellcheck shell=bash +# A hang that only reproduces under suite load is otherwise reported as a bare +# "timeout after Ns" with nothing to diagnose. Both test entry points +# (tests/driver.sh and tests/lib/test-runner.sh) arm this around every +# invocation: it samples the live process shortly before timeout(1) kills it, +# and the caller keeps the output only when the watchdog actually fired. +# +# Set TEST_SAMPLE_TIMEOUTS=0 to turn it off. +# +# Usage: +# hang_sample_arm +# ... run the command, recording its exit status ... +# hang_sample_finish # 1 keeps the sample, 0 discards + +# Set by hang_sample_arm, read by hang_sample_finish. Empty when disarmed. +hang_sample_out="" +hang_sample_sentinel="" +hang_sample_pid="" +hang_sample_work="" + +# Bumped per arm so the sentinel and the working file are unique to one +# invocation. Sharing them across arms is what forced the passing path to block +# until the watchdog exited: a watchdog still symbolizing would otherwise write +# into the next test's file. With distinct names per arm a straggler can only +# touch its own, so only the timed-out path has any reason to wait. +_hang_sample_seq=0 + +# The watchdog polls a sentinel file rather than being killed by pid: lanes that +# spawn hundreds of short-lived processes (the busybox applets) can recycle a +# pid between the watchdog exiting and a kill landing, which would turn this +# into a random SIGTERM. +_hang_sample_watch() +{ + local binary="$1" cap="$2" out="$3" sentinel="$4" + + # Leave room for the collection plus symbolization, which is the slow part: + # measured around five seconds on a loaded machine, and anything that runs + # past the kill sees a process that is already gone. + local lead=$((cap - 8)) + [ "$lead" -lt 1 ] && lead=1 + + # Quarter-second granularity bounds how long a timed-out test waits for a + # watchdog that has not started collecting yet. + local waited=0 steps=$((lead * 4)) + while [ "$waited" -lt "$steps" ]; do + [ -e "$sentinel" ] || return 0 + sleep 0.25 + waited=$((waited + 1)) + done + [ -e "$sentinel" ] || return 0 + + # Match the process name, not the command line: elfuse renames itself to the + # guest program (src/runtime/proctitle.c), so the guest's basename finds it. + # A -f match would find timeout(1) first, whose argv carries the whole + # command line, and a sample of the watchdog wrapper says nothing. + local pid + pid=$(pgrep -n "$(basename "$binary")" 2> /dev/null) || return 0 + [ -z "$pid" ] && return 0 + + # Per-thread state first: it is instant and always reads, while sample can + # spend seconds symbolizing and has come back with an empty call graph. + # Doing it second measured an empty table, because the process was killed + # while sample was still working. + [ -e "$sentinel" ] || return 0 + { + printf "%s\n" "---- ps -M $pid ----" + ps -M "$pid" 2>&1 + printf "\n" + } > "$out" 2> /dev/null + + sample "$pid" 1 -f "${out}.sample" > /dev/null 2>&1 + + # Re-check before appending. Symbolization can run for seconds, and the test + # may have passed and been reaped in that time; without this the append + # recreates a path the parent just removed and leaves it behind for good if + # no later arm sweeps it. + if [ -e "$sentinel" ]; then + cat "${out}.sample" >> "$out" 2> /dev/null + else + rm -f "$out" + fi + rm -f "${out}.sample" +} + +hang_sample_arm() +{ + local binary="$1" cap="$2" out="$3" + + hang_sample_out="" + hang_sample_sentinel="" + hang_sample_pid="" + hang_sample_work="" + [ "${TEST_SAMPLE_TIMEOUTS:-1}" = 1 ] || return 0 + command -v sample > /dev/null 2>&1 || return 0 + + mkdir -p "$(dirname "$out")" 2> /dev/null || return 0 + + # Sweep whatever earlier arms left behind. A watchdog that was still writing + # when its test passed owns a name no live arm uses, so removing it here is + # safe even if that watchdog has not exited: the write goes to an unlinked + # fd and the directory entry is gone. + rm -f "${out}".part.* "${out}".running.* + + _hang_sample_seq=$((_hang_sample_seq + 1)) + hang_sample_out="$out" + hang_sample_work="${out}.part.$$.${_hang_sample_seq}" + hang_sample_sentinel="${out}.running.$$.${_hang_sample_seq}" + : > "$hang_sample_sentinel" + _hang_sample_watch "$binary" "$cap" "$hang_sample_work" \ + "$hang_sample_sentinel" & + hang_sample_pid=$! +} + +hang_sample_finish() +{ + local timed_out="$1" + + [ -n "$hang_sample_sentinel" ] && rm -f "$hang_sample_sentinel" + if [ -z "$hang_sample_out" ]; then + return 0 + fi + + if [ "$timed_out" = 1 ]; then + + # Only here is the watchdog's output wanted, so only here is it worth + # waiting for: the collection starts before timeout(1) kills the test, + # but symbolization can run for seconds after it, and the move below + # needs a finished file. A test that already burned its whole timeout + # pays this; a passing one must not. + [ -n "$hang_sample_pid" ] && wait "$hang_sample_pid" 2> /dev/null + if [ -s "$hang_sample_work" ]; then + mv "$hang_sample_work" "$hang_sample_out" + printf " sampled hung %s to %s\n" \ + "$(basename "${hang_sample_out%-hang.txt}")" \ + "$hang_sample_out" >&2 + fi + fi + + # The sentinel is already gone, so a watchdog that has not started writing + # returns without touching anything. One that is mid-symbolization keeps a + # name no later arm will use, and the sweep in hang_sample_arm collects it. + rm -f "$hang_sample_work" + + hang_sample_out="" + hang_sample_sentinel="" + hang_sample_pid="" + hang_sample_work="" +} diff --git a/tests/lib/test-runner.sh b/tests/lib/test-runner.sh index 6c21a55d..ad0c32d4 100644 --- a/tests/lib/test-runner.sh +++ b/tests/lib/test-runner.sh @@ -3,21 +3,21 @@ # Copyright 2026 elfuse contributors # Copyright 2025 Moritz Angermann, zw3rk pte. ltd. # SPDX-License-Identifier: Apache-2.0 -# # shellcheck shell=bash # shellcheck disable=SC2034 # shellcheck source=tests/lib/bash-compat.sh . "$(dirname "${BASH_SOURCE[0]}")/bash-compat.sh" +. "$(dirname "${BASH_SOURCE[0]}")/hang-sample.sh" : "${TEST_LABEL_WIDTH:=14}" : "${TEST_TIMEOUT:=10}" # Resolve a working 'timeout' binary. macOS doesn't ship one, so fall back to # GNU coreutils' gtimeout. Wrap as a function so callers keep using the bare -# name 'timeout'. Resolution order: TIMEOUT_BIN env override, 'timeout' on -# PATH, 'gtimeout' on PATH, then Homebrew's stable opt symlinks for ARM and -# Intel macOS (the install prefix differs between the two). +# name 'timeout'. Resolution order: TIMEOUT_BIN env override, 'timeout' on PATH, +# 'gtimeout' on PATH, then Homebrew's stable opt symlinks for ARM and Intel +# macOS (the install prefix differs between the two). if [ -n "${TIMEOUT_BIN:-}" ]; then timeout() { @@ -48,15 +48,15 @@ elif ! command -v timeout > /dev/null 2>&1; then unset _timeout_bin _candidate fi -# epoch_us is provided by bash-compat.sh: it picks the lowest-cost -# microsecond clock the host supports ($EPOCHREALTIME on bash 5.0+, -# 'date +%s %N' on macOS 14+/GNU coreutils, python3, perl, or a -# whole-second fallback). run() uses it to disambiguate the guest -# timeout(1) returning rc=124 from the harness watchdog firing at -# TEST_TIMEOUT; SECONDS resolution would mistake either case at short -# caps. +# epoch_us is provided by bash-compat.sh: it picks the lowest-cost microsecond +# clock the host supports ($EPOCHREALTIME on bash 5.0+, 'date +%s %N' on macOS +# 14+/GNU coreutils, python3, perl, or a whole-second fallback). run() uses it +# to disambiguate the guest timeout(1) returning rc=124 from the harness +# watchdog firing at TEST_TIMEOUT; SECONDS resolution would mistake either case +# at short caps. if [ -t 1 ]; then + # Use ANSI-C quoting so the variables hold real ESC bytes, not the literal # 4-char "\033" sequence. Without this, callers that pass colors as printf # %s arguments (e.g. tests/test-busybox.sh) emit the escape sequence as @@ -103,8 +103,9 @@ test_report() test_excerpt() { local output="$1" - # The closing lines carry the actual assertion failure; the opening line - # is usually just an elfuse WARN banner. + + # The closing lines carry the actual assertion failure; the opening line is + # usually just an elfuse WARN banner. printf "%s\n" "$output" | tail -10 | cut -c -200 | sed 's/^/ /' } @@ -135,21 +136,21 @@ run() return fi - # Wrap every invocation in 'timeout' so a hanging guest tool cannot - # freeze the entire suite. run_pipe and run_timeout already do this; - # the omission here used to let a deadlocked elfuse syscall path - # hang make check forever. + # Wrap every invocation in 'timeout' so a hanging guest tool cannot freeze + # the entire suite. run_pipe and run_timeout already do this; the omission + # here used to let a deadlocked elfuse syscall path hang make check forever. # - # GNU timeout reports rc=124 on its own timeout, but coreutils-suite - # also runs the guest's own timeout(1) with expect_rc=124. Exit code - # alone cannot tell the two apart, so wall-clock elapsed time is - # used as an out-of-band marker: a harness firing means elapsed is - # at or above TEST_TIMEOUT, while the guest case completes well - # under it. epoch_us (from bash-compat.sh) gives microsecond - # resolution; comparing seconds alone via SECONDS could undercount - # by almost a full second and let a real harness timeout slip + # GNU timeout reports rc=124 on its own timeout, but coreutils-suite also + # runs the guest's own timeout(1) with expect_rc=124. Exit code alone cannot + # tell the two apart, so wall-clock elapsed time is used as an out-of-band + # marker: a harness firing means elapsed is at or above TEST_TIMEOUT, while + # the guest case completes well under it. epoch_us (from bash-compat.sh) + # gives microsecond resolution; comparing seconds alone via SECONDS could + # undercount by almost a full second and let a real harness timeout slip # through as a guest-OK at small TEST_TIMEOUT values. local start_us end_us elapsed_us limit_us + hang_sample_arm "$tool" "$TEST_TIMEOUT" \ + "${BUILD_DIR:-build}/test-timeouts/$(basename "$tool")-hang.txt" start_us=$(epoch_us) if output=$(timeout "$TEST_TIMEOUT" ${TEST_RUNNER[@]+"${TEST_RUNNER[@]}"} \ "$(test_tool_path "$tool")" "$@" 2>&1); then @@ -164,6 +165,7 @@ run() if [ "$rc" -eq 124 ] && [ "$elapsed_us" -ge "$limit_us" ]; then harness_timed_out=1 fi + hang_sample_finish "$harness_timed_out" if [ "$harness_timed_out" -eq 1 ]; then test_report fail "$tool" " (timeout after ${TEST_TIMEOUT}s)" @@ -197,15 +199,22 @@ run_check() return fi - # See run() for the timeout-vs-expected ordering rationale. run_check - # has no explicit expect_rc parameter (zero is implied), so any rc=124 - # here is treated as a harness timeout. + # See run() for the timeout-vs-expected ordering rationale. run_check has no + # explicit expect_rc parameter (zero is implied), so any rc=124 here is + # treated as a harness timeout. + hang_sample_arm "$tool" "$TEST_TIMEOUT" \ + "${BUILD_DIR:-build}/test-timeouts/$(basename "$tool")-hang.txt" if output=$(timeout "$TEST_TIMEOUT" ${TEST_RUNNER[@]+"${TEST_RUNNER[@]}"} \ "$(test_tool_path "$tool")" "$@" 2>&1); then rc=0 else rc=$? fi + if [ "$rc" -eq 124 ]; then + hang_sample_finish 1 + else + hang_sample_finish 0 + fi if [ "$rc" -eq 124 ]; then test_report fail "$tool" " (timeout after ${TEST_TIMEOUT}s)" @@ -257,12 +266,19 @@ run_pipe() return fi + hang_sample_arm "$tool" "$TEST_TIMEOUT" \ + "${BUILD_DIR:-build}/test-timeouts/$(basename "$tool")-hang.txt" if output=$(printf '%s' "$input" \ | timeout "$TEST_TIMEOUT" ${TEST_RUNNER[@]+"${TEST_RUNNER[@]}"} "$(test_tool_path "$tool")" "$@" 2>&1); then rc=0 else rc=$? fi + if [ "$rc" -eq 124 ]; then + hang_sample_finish 1 + else + hang_sample_finish 0 + fi if [ "$rc" -ne 0 ]; then test_report fail "$tool" " (exit rc=$rc)" @@ -278,6 +294,12 @@ run_pipe() fi } +# Deliberately does not arm the hang sampler. Its callers pass their own cap and +# an expected rc, and the coreutils suite expects rc=124 from the guest's own +# timeout(1), so a 124 here does not mean the harness watchdog fired. run() +# tells the two apart with elapsed wall time; this wrapper has no such marker, +# and a sampler armed on a timeout the caller intends to hit would collect on +# every pass. run_timeout() { local secs="$1" From 5ca06a819820281f5bb3694e17e67127d1a9e8b7 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Tue, 18 Aug 2026 14:33:12 +0800 Subject: [PATCH 3/3] Destroy the sibling threads on execve Linux de_thread() destroys every sibling before mapping the new image. elfuse has to do the same before guest_reset zeroes the memory those siblings are still running on, or they resume into a zeroed image. thread_exec_de_thread runs in the post-failure region of sys_execve, before the credential commit and the CLOEXEC sweep, so siblings wind down against the old image. A sibling that outlives its bounded join makes it return non-zero, and sys_execve takes its post-PNR fatal exit rather than resetting guest memory under a live thread. The leader is never a de_thread target, because its run loop returning is what destroys the guest. A non-leader execve is handed to it: the requester publishes the syscall arguments and blocks, the leader runs the whole of sys_execve on its own vCPU, and the requester dies as a sibling. The new image therefore always sees gettid() == getpid() and Threads: 1. For that join to terminate, every blocking wait a guest thread can enter has to be reachable by a teardown wake, which is most of this commit: - poll and ppoll carry the wakeup pipe on every wait rather than only an indefinite one, and a finite wait runs to its deadline in slices that re-check the interrupt conditions. - A blocking O_WRONLY FIFO open polls the non-blocking form instead of parking in the host until a reader arrives. - F_SETLKW polls F_SETLK, decoding the guest's struct flock once before the loop so another thread cannot switch which region is locked underneath the wait. - semop polls with IPC_NOWAIT forced onto a copy. When the set does not apply, repeating the walk the kernel makes before it blocks says which operation stopped it, so a set mixing IPC_NOWAIT with blocking operations keeps its per-operation semantics without ever entering a blocking host call. The walk answers SEMOP_BLOCKER_UNKNOWN when it cannot read the values, which a set granting alter but not read permission does, and the caller waits rather than inventing a refusal it cannot back up. - The io and futex retry waits materialize an expired ITIMER_REAL before sleeping, so an alarm that fires during a contended lock is not delayed until the lock is acquired. Only ITIMER_REAL: Linux charges the other two to CPU time, and a thread parked in a host call spends none. An execve teardown and a fork snapshot must not overlap. A sibling parked in the fork barrier owes the forker its quiet until the copy finishes, because unlike Linux, whose fork() takes a copy-on-write snapshot later writes cannot reach, the copy here is not atomic against a running thread. But a barrier that never releases strands that sibling and the join reports it as one that refused to leave. The two are serialized rather than raced. de_thread waits for an open window to close before it publishes the teardown, and thread_quiesce_siblings refuses to arm a new one once it has, with its callers abandoning the operation the quiet was for. Both sides of that handshake run under thread_lock, so the thread that releases the barrier is never one the teardown is waiting on, and the barrier blocks with no timer and no escape branch. The refusals return through the existing cleanup labels: sys_clone has already spawned its child by then, so a bare return would leak its socketpair and leave the child a zombie nothing reaps. One wait is still unreachable and recorded where it lives: the read side of a blocking FIFO open, which macOS gives nothing to poll on in any state. --- Makefile | 5 + src/core/guest.c | 5 +- src/core/launch.c | 82 +++--- src/runtime/forkipc.c | 34 ++- src/runtime/futex.c | 22 +- src/runtime/thread.c | 364 +++++++++++++++++++++++++-- src/runtime/thread.h | 69 ++++- src/syscall/exec.c | 329 +++++++++++++++++++++++- src/syscall/exec.h | 28 ++- src/syscall/fs.c | 166 ++++++++++-- src/syscall/fuse.c | 6 +- src/syscall/inotify.c | 5 +- src/syscall/io.c | 43 +++- src/syscall/io.h | 25 ++ src/syscall/mem.c | 11 +- src/syscall/poll.c | 78 ++++-- src/syscall/proc.c | 55 +++- src/syscall/signal.c | 50 +++- src/syscall/signal.h | 6 + src/syscall/syscall.c | 26 +- src/syscall/sysvipc.c | 141 ++++++++++- src/syscall/time.c | 2 +- tests/manifest.txt | 2 + tests/test-matrix.sh | 46 ++-- tests/test-teardown-live-vcpu-host.c | 13 +- tests/test-threaded-exec.c | 254 +++++++++++++++++++ 26 files changed, 1682 insertions(+), 185 deletions(-) create mode 100644 tests/test-threaded-exec.c diff --git a/Makefile b/Makefile index 3584bb91..45bedb4f 100644 --- a/Makefile +++ b/Makefile @@ -316,6 +316,11 @@ $(BUILD_DIR)/test-thread-churn: tests/test-thread-churn.c | $(BUILD_DIR) @echo " CROSS $< (with -lpthread)" $(Q)$(CROSS_COMPILE)gcc $(CROSS_TEST_CFLAGS) -o $@ $< -lpthread +# test-threaded-exec execs itself repeatedly with live sibling threads. +$(BUILD_DIR)/test-threaded-exec: tests/test-threaded-exec.c | $(BUILD_DIR) + @echo " CROSS $< (with -lpthread)" + $(Q)$(CROSS_COMPILE)gcc $(CROSS_TEST_CFLAGS) -o $@ $< -lpthread + # test-cntvct-thread verifies cloned vCPUs inherit EL0 timer access. $(BUILD_DIR)/test-cntvct-thread: tests/test-cntvct-thread.c | $(BUILD_DIR) @echo " CROSS $< (with -lpthread)" diff --git a/src/core/guest.c b/src/core/guest.c index a2a6cf09..1a3ef353 100644 --- a/src/core/guest.c +++ b/src/core/guest.c @@ -761,10 +761,7 @@ void guest_destroy(guest_t *g) */ if (!proc_exit_group_requested()) proc_request_exit_group(0); - futex_interrupt_request(); - wakeup_pipe_signal(); - thread_interrupt_all(); - thread_wake_exit_waiters(); + thread_wake_all_blocked(); thread_join_workers(); /* Destroy the main vCPU (owned by this thread) before tearing down the VM. diff --git a/src/core/launch.c b/src/core/launch.c index 5fd2b906..bbe068b7 100644 --- a/src/core/launch.c +++ b/src/core/launch.c @@ -1,4 +1,5 @@ -/* elfuse VM launch: bring-up + GDB + run loop + teardown +/* + * elfuse VM launch: bring-up + GDB + run loop + teardown * * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 @@ -8,8 +9,8 @@ * struct, leaving CLI concerns (option parsing, sysroot provisioning, the * shebang loop) in main(). * - * shim_blob.h is included here, not in src/main.c, so the static - * shim_bin / shim_bin_len blob has a single object definition site. + * shim_blob.h is included here, not in src/main.c, so the static shim_bin / + * shim_bin_len blob has a single object definition site. */ #include "launch.h" @@ -59,14 +60,16 @@ int elfuse_launch(const launch_args_t *args) guest_t g; bool guest_initialized = false; guest_bootstrap_t boot; + /* Local copy of the temp flag (ownership contract in launch.h): the - * caller's launch_args_t is const, and the flag must drop once the - * unlink happens. + * caller's launch_args_t is const, and the flag must drop once the unlink + * happens. */ bool elf_host_temp = args->elf_host_temp; - /* The guest-visible entrypoint path is argv[0]; elf_path is the - * resolved host path to that binary. They differ when path - * translation or a FUSE-materialized temp is involved. + + /* The guest-visible entrypoint path is argv[0]; elf_path is the resolved + * host path to that binary. They differ when path translation or a + * FUSE-materialized temp is involved. */ const char *elf_guest_path = (args->guest_argc > 0 && args->guest_argv) ? args->guest_argv[0] @@ -97,9 +100,8 @@ int elfuse_launch(const launch_args_t *args) shim_bin_len, args->verbose, &guest_initialized, &boot) < 0) goto fail; - /* A FUSE-materialized temp has been loaded; drop it once the guest - * has its own mapping, unless Rosetta still needs the reopenable - * host path. + /* A FUSE-materialized temp has been loaded; drop it once the guest has its + * own mapping, unless Rosetta still needs the reopenable host path. */ if (elf_host_temp && !g.is_rosetta) { unlink(args->elf_path); @@ -130,8 +132,8 @@ int elfuse_launch(const launch_args_t *args) proc_set_sysroot_casefold(false); } - /* Placed after the casefold probe so path_translate_at() sees the - * sysroot's real case behavior. + /* Placed after the casefold probe so path_translate_at() sees the sysroot's + * real case behavior. */ if (args->cwd_guest && args->cwd_guest[0] != '\0') { path_translation_t tx; @@ -141,6 +143,7 @@ int elfuse_launch(const launch_args_t *args) args->cwd_guest, strerror(errno)); goto fail; } + /* A shm leaf needs sys_chdir's O_NOFOLLOW fd and virtual-cwd publish; * entering it here would add a holder of the never-follow invariant * dev_shm_resolve_path() enumerates. Refuse instead. @@ -150,6 +153,7 @@ int elfuse_launch(const launch_args_t *args) args->cwd_guest); goto fail; } + /* proc_resolve_sysroot_path() falls back to the host spelling for a * path the sysroot does not hold, which would start the guest in a * same-named host directory outside the tree --workdir named. @@ -161,6 +165,7 @@ int elfuse_launch(const launch_args_t *args) args->cwd_guest); goto fail; } + /* Same carve-out as path_dirent_dir_holds_escapes(): "--sysroot /" * owns every host path, but path_prefix_match on a bare separator * accepts "/" alone. @@ -187,8 +192,8 @@ int elfuse_launch(const launch_args_t *args) 0) goto fail; - /* GDB setup must happen before the first run so entry-stop and - * hardware breakpoints can affect the initial vCPU. + /* GDB setup must happen before the first run so entry-stop and hardware + * breakpoints can affect the initial vCPU. */ if (args->gdb_port > 0) { if (gdb_stub_init(args->gdb_port, &g) < 0) { @@ -207,15 +212,15 @@ int elfuse_launch(const launch_args_t *args) /* Tear down debugger state before joining workers: a worker parked in * gdb_stub_handle_stop() stays active (not deactivated) until this - * broadcasts resume_cond, so joining first would just time out and - * detach it while it is still paused. + * broadcasts resume_cond, so joining first would just time out and detach + * it while it is still paused. */ gdb_stub_shutdown(); /* Join worker vCPU threads before guest_destroy unmaps the guest slab: a * sibling still mid-iteration in its own run loop would fault on freed - * guest memory and crash the host with SIGSEGV, masking the real exit - * code. The join is a no-op once workers have wound down (the common + * guest memory and crash the host with SIGSEGV, masking the real exit code. + * The join is a no-op once workers have wound down (the common * single-threaded case). * * vcpu_run_loop can also return via a bare break (alarm timeout 124, a @@ -228,36 +233,29 @@ int elfuse_launch(const launch_args_t *args) */ if (!proc_exit_group_requested()) proc_request_exit_group(0); - futex_interrupt_request(); - wakeup_pipe_signal(); - thread_interrupt_all(); - /* Workers parked on internal condvars (fork barrier, ptrace stop/wait) - * see neither the pipe nor the vCPU kick; broadcast so they re-check the - * exit-group flag and terminate before the join below gives up on them. - */ - thread_wake_exit_waiters(); + thread_wake_all_blocked(); thread_join_workers(); - /* Diagnostic counter dump runs before guest_destroy so the - * shim_data mapping is still valid. ELFUSE_SHIM_STATS is the gate; - * an unset variable produces no output. + /* Diagnostic counter dump runs before guest_destroy so the shim_data + * mapping is still valid. ELFUSE_SHIM_STATS is the gate; an unset variable + * produces no output. */ if (shim_globals_stats_enabled()) shim_globals_counters_dump(&g); - /* Dump the startup histogram before guest_destroy so any - * cleanup-path syscalls (closing host fds, unmapping the slab) do - * not appear in the captured set. The dump is a no-op when - * ELFUSE_STARTUP_TRACE=syscalls was not requested. + /* Dump the startup histogram before guest_destroy so any cleanup-path + * syscalls (closing host fds, unmapping the slab) do not appear in the + * captured set. The dump is a no-op when ELFUSE_STARTUP_TRACE=syscalls was + * not requested. */ syscall_hist_dump(); /* Give back any pty slaves this process still holds before the guest - * teardown below. The guest's stdio slaves are closed by the kernel, - * not by the guest, so they never pass through the per-fd close hook; a - * master in another process would otherwise wait forever for a hangup - * this exit should have produced. Bring-up failures skip this: the fail - * path is only reachable before the run loop, so no slave exists yet. + * teardown below. The guest's stdio slaves are closed by the kernel, not by + * the guest, so they never pass through the per-fd close hook; a master in + * another process would otherwise wait forever for a hangup this exit + * should have produced. Bring-up failures skip this: the fail path is only + * reachable before the run loop, so no slave exists yet. */ proc_pty_release_process_slaves(); @@ -274,9 +272,9 @@ int elfuse_launch(const launch_args_t *args) return exit_code; fail: - /* Bring-up failed: unwind whatever exists so far, including the temp - * unlink this side owns past the prepare call (contract in launch.h). - * Staged --user credentials are dropped too (proc.h). + /* Bring-up failed: unwind whatever exists so far, including the temp unlink + * this side owns past the prepare call (contract in launch.h). Staged + * --user credentials are dropped too (proc.h). */ proc_clear_initial_ids(); if (guest_initialized) diff --git a/src/runtime/forkipc.c b/src/runtime/forkipc.c index f3e4bb9e..3a0c4e0d 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -1710,6 +1710,13 @@ int64_t sys_clone(hv_vcpu_t vcpu, } int ipc_sock = sock_fds[0]; + mmap_fork_anon_shared_txn_t *anon_shared_txn = NULL; + guest_region_t *regions_snapshot = NULL; + guest_region_t preannounced_snapshot[GUEST_MAX_PREANNOUNCED]; + int snapshot_shm_fd = -1; + bool siblings_quiesced = false; + int64_t fail_rc = -LINUX_ENOMEM; + /* Quiesce sibling vCPUs for snapshot consistency. In multithreaded guests, * sibling vCPUs may be actively mutating guest memory during the fork * snapshot (CoW or legacy IPC copy). Without quiescing them, the child @@ -1717,16 +1724,15 @@ int64_t sys_clone(hv_vcpu_t vcpu, * structures. This matches POSIX fork semantics where only the calling * thread survives. */ - thread_quiesce_siblings(); - - mmap_fork_anon_shared_txn_t *anon_shared_txn = NULL; - guest_region_t *regions_snapshot = NULL; - guest_region_t preannounced_snapshot[GUEST_MAX_PREANNOUNCED]; - - /* APFS clone fd for the CoW snapshot sent to the child. Declared up front - * so early goto fail_snapshot exits do not read an uninitialized local. - */ - int snapshot_shm_fd = -1; + if (!thread_quiesce_siblings()) { + /* An execve is reaping this thread. The snapshot would run without the + * quiet it needs, and the child would outlive a parent that is already + * gone, so refuse the fork instead. + */ + fail_rc = -LINUX_EINTR; + goto fail_snapshot; + } + siblings_quiesced = true; /* Convert MAP_SHARED|MAP_ANONYMOUS regions that have no backing fd into * memfd-backed overlay regions. The conversion seeds a private temp file @@ -1998,7 +2004,8 @@ int64_t sys_clone(hv_vcpu_t vcpu, * backing fds. Keep siblings quiesced until that send completes so a * concurrent munmap/remap cannot close or recycle the captured fd numbers. */ - thread_resume_siblings(); + if (siblings_quiesced) + thread_resume_siblings(); mmap_fork_commit_anon_shared(&anon_shared_txn); close(ipc_sock); @@ -2064,7 +2071,8 @@ int64_t sys_clone(hv_vcpu_t vcpu, "clone: anon-shared rollback partial failure (%d); parent " "may have stale memfd-backed regions", abort_rc); - thread_resume_siblings(); + if (siblings_quiesced) + thread_resume_siblings(); close(ipc_sock); if (vfork_notify_fds[0] >= 0) close(vfork_notify_fds[0]); @@ -2087,7 +2095,7 @@ int64_t sys_clone(hv_vcpu_t vcpu, if (reaped < 0) log_warn("clone: failed to reap fork-child pid=%d: %s", (int) child_host_pid, strerror(errno)); - return -LINUX_ENOMEM; + return fail_rc; } /* clone3: extended clone with clone_args struct. */ diff --git a/src/runtime/futex.c b/src/runtime/futex.c index f258f47a..399301df 100644 --- a/src/runtime/futex.c +++ b/src/runtime/futex.c @@ -585,14 +585,14 @@ static int64_t futex_os_sync_wait(guest_t *g, efault_retries = 0; } - if (proc_exit_group_requested() || futex_interrupt_consume()) + if (thread_stop_requested() || futex_interrupt_consume()) return -LINUX_EINTR; /* Drain any expired guest itimer so its SIGALRM / SIGVTALRM / SIGPROF * queues into sig_state.pending; without this poke, a guest with all * threads parked in futex_wait would never advance the timers. */ - signal_check_timer(); + signal_check_timer_real(); /* Return EINTR only when a real deliverable signal is queued for this * thread. POSIX callers (e.g. glibc sem_wait, foot's render worker) @@ -730,7 +730,7 @@ static int64_t futex_wait(guest_t *g, break; } pthread_cond_timedwait(&waiter.cond, &b->lock, &quantum); - if (proc_exit_group_requested() || futex_interrupt_consume()) { + if (thread_stop_requested() || futex_interrupt_consume()) { ret = -LINUX_EINTR; break; } @@ -747,7 +747,7 @@ static int64_t futex_wait(guest_t *g, * (4). */ pthread_mutex_unlock(&b->lock); - signal_check_timer(); + signal_check_timer_real(); bool sig_ready = signal_pending() != 0; pthread_mutex_lock(&b->lock); @@ -768,7 +768,7 @@ static int64_t futex_wait(guest_t *g, timespec_deadline_in_ms(&poll_ts, 100); pthread_cond_timedwait(&waiter.cond, &b->lock, &poll_ts); - if (proc_exit_group_requested() || futex_interrupt_consume()) { + if (thread_stop_requested() || futex_interrupt_consume()) { ret = -LINUX_EINTR; break; } @@ -782,7 +782,7 @@ static int64_t futex_wait(guest_t *g, * landed in the window. */ pthread_mutex_unlock(&b->lock); - signal_check_timer(); + signal_check_timer_real(); bool sig_ready = signal_pending() != 0; pthread_mutex_lock(&b->lock); @@ -1333,7 +1333,7 @@ static int64_t futex_lock_pi(guest_t *g, uint64_t uaddr, uint64_t timeout_gva) if (!expired) { pthread_cond_timedwait(&waiter.cond, &b->lock, &quantum); if (!__atomic_load_n(&waiter.woken, __ATOMIC_ACQUIRE) && - proc_exit_group_requested()) { + thread_stop_requested()) { /* Mirror the no-timeout exit_group path below. */ bucket_unlink_locked(b, &waiter); pthread_mutex_unlock(&b->lock); @@ -1349,7 +1349,7 @@ static int64_t futex_lock_pi(guest_t *g, uint64_t uaddr, uint64_t timeout_gva) * signal_check_timer/signal_pending touch sig_lock (4). */ pthread_mutex_unlock(&b->lock); - signal_check_timer(); + signal_check_timer_real(); bool sig_ready = signal_pending() != 0; pthread_mutex_lock(&b->lock); @@ -1385,7 +1385,7 @@ static int64_t futex_lock_pi(guest_t *g, uint64_t uaddr, uint64_t timeout_gva) timespec_deadline_in_ms(&poll_ts, 100); pthread_cond_timedwait(&waiter.cond, &b->lock, &poll_ts); - if (proc_exit_group_requested()) { + if (thread_stop_requested()) { /* Dequeue and return */ bucket_unlink_locked(b, &waiter); pthread_mutex_unlock(&b->lock); @@ -1401,7 +1401,7 @@ static int64_t futex_lock_pi(guest_t *g, uint64_t uaddr, uint64_t timeout_gva) * signal_pending touch sig_lock (4). */ pthread_mutex_unlock(&b->lock); - signal_check_timer(); + signal_check_timer_real(); bool sig_ready = signal_pending() != 0; pthread_mutex_lock(&b->lock); @@ -1892,7 +1892,7 @@ int64_t sys_futex_waitv(guest_t *g, if (result_idx >= 0) break; - if (proc_exit_group_requested()) { + if (thread_stop_requested()) { result_idx = -LINUX_EINTR; break; } diff --git a/src/runtime/thread.c b/src/runtime/thread.c index 21f00237..c970a71b 100644 --- a/src/runtime/thread.c +++ b/src/runtime/thread.c @@ -22,9 +22,17 @@ #include "runtime/thread.h" #include "debug/log.h" -#include "core/guest.h" /* guest_t (shim_data_base/ipa_base), BLOCK_2MIB */ -#include "hvutil.h" /* vcpu_get_gpr, vcpu_get_sysreg */ -#include "syscall/proc.h" /* proc_exit_group_requested */ +#include "core/guest.h" /* guest_t (shim_data_base/ipa_base), BLOCK_2MIB */ +#include "hvutil.h" /* vcpu_get_gpr, vcpu_get_sysreg */ +#include "runtime/futex.h" /* futex_interrupt_request */ + +/* Only for the handoff wake below. The hot-path predicate no longer reaches + * into the syscall layer: exec.c publishes it here through + * thread_set_leader_work_pending. + */ +#include "syscall/exec.h" +#include "syscall/proc.h" /* proc_exit_group_requested */ +#include "syscall/wakeup-pipe.h" /* wakeup_pipe_signal */ /* From syscall/signal.h, included here directly to avoid pulling in the full * signal header (macOS defines sa_handler as a macro that conflicts with the @@ -65,8 +73,25 @@ static bool fork_quiesce_active = false; /* True while a fork is in progress */ static int fork_quiesced_count = 0; /* Siblings blocked on barrier */ static int fork_target_count = 0; /* Number of siblings to quiesce */ static pthread_cond_t fork_cond = PTHREAD_COND_INITIALIZER; + +/* Signalled when a deferred stack-unmap transaction clears. Declared here + * rather than beside its users because thread_wake_exit_waiters, earlier in the + * file, has to broadcast it too. + */ +static pthread_cond_t deferred_stack_unmap_cond = PTHREAD_COND_INITIALIZER; static pthread_cond_t fork_all_quiesced_cond = PTHREAD_COND_INITIALIZER; +/* Signalled when a quiesce window closes, so an execve teardown can wait for + * one already open instead of tearing siblings out of it. + */ +static pthread_cond_t fork_window_closed_cond = PTHREAD_COND_INITIALIZER; + +/* Defined with the rest of the execve teardown state further down; declared + * here because thread_quiesce_siblings, above it, refuses to arm while a + * teardown is running. + */ +static _Atomic bool exec_de_thread_active; + /* Iterate every slot. */ #define THREAD_FOR_EACH(t) \ for (thread_entry_t *t = thread_table; t < thread_table + MAX_THREADS; t++) @@ -695,9 +720,7 @@ int thread_signal_deliverable(uint64_t sigbit) /* Fork quiesce. */ -static pthread_cond_t deferred_stack_unmap_cond = PTHREAD_COND_INITIALIZER; - -void thread_quiesce_siblings(void) +bool thread_quiesce_siblings(void) { hv_vcpu_t vcpus[MAX_THREADS]; int count = 0; @@ -705,6 +728,24 @@ void thread_quiesce_siblings(void) pthread_mutex_lock(&thread_lock); + /* Refuse while an execve teardown is reaping. The caller is one of the + * threads being reaped, and a window armed now would park its siblings in a + * barrier that only the caller can release, which it will not do because it + * is about to leave the guest itself. Refusing keeps the teardown and the + * quiesce windows from overlapping at all, which is what lets the barrier + * below block unconditionally: a sibling parked there is never one this + * teardown is waiting for. + * + * Read under thread_lock, and thread_exec_de_thread publishes it under the + * same lock after draining any window already open, so the two orderings + * are the only ones possible: either this arms first and the teardown waits + * for it, or the teardown publishes first and this refuses. + */ + if (atomic_load_explicit(&exec_de_thread_active, memory_order_acquire)) { + pthread_mutex_unlock(&thread_lock); + return false; + } + /* Count every active sibling. Startup siblings may not have published a * vCPU yet, but once they do they check the barrier before guest entry. * fork_counted marks the slots that owe the barrier a response, so a @@ -722,7 +763,7 @@ void thread_quiesce_siblings(void) if (targets == 0) { pthread_mutex_unlock(&thread_lock); - return; + return true; /* Nothing to quiet, so the window is trivially held */ } /* Arm the barrier */ @@ -758,6 +799,8 @@ void thread_quiesce_siblings(void) } } pthread_mutex_unlock(&thread_lock); + + return true; } void thread_resume_siblings(void) @@ -774,6 +817,11 @@ void thread_resume_siblings(void) THREAD_FOR_EACH (t) t->fork_counted = false; pthread_cond_broadcast(&fork_cond); + + /* An execve teardown parked in thread_exec_de_thread is waiting for exactly + * this. + */ + pthread_cond_broadcast(&fork_window_closed_cond); pthread_mutex_unlock(&thread_lock); } @@ -798,29 +846,74 @@ int thread_fork_barrier_check(void) pthread_cond_signal(&fork_all_quiesced_cond); } - /* Block until fork is complete. Bail out on exit_group: the resume - * broadcast comes from the forking thread, whose progress the teardown path - * does not control, so waiting for it would leave this park outside the - * bounded-wake guarantee. thread_wake_exit_waiters broadcasts fork_cond - * after the flag is set; the caller's run loop re-checks - * proc_exit_group_requested and exits. + /* Block until the window closes. An execve teardown deliberately does not + * break this wait: the whole point of the barrier is that no other thread + * mutates guest memory while the forker copies it, and a sibling released + * here would run its exit path, which writes clear_child_tid, walks the + * robust list and unmaps its stack. The child would receive a torn image. + * + * Unlike Linux, where fork() takes a copy-on-write snapshot that later + * sibling writes cannot reach, the copy here is not atomic against a + * running thread, so the quiet is load-bearing rather than advisory. + * + * Nothing deadlocks behind that, because a teardown and a window never + * overlap. thread_exec_de_thread drains a window already open before it + * publishes the teardown, and thread_quiesce_siblings refuses to arm a new + * one once it has. Both sides of that handshake run under thread_lock, so + * the thread that releases this barrier is never one the teardown is + * waiting on. exit_group is still honored: that is process death, and no + * snapshot outlives it. */ - while (fork_quiesce_active && !proc_exit_group_requested()) - pthread_cond_wait(&fork_cond, &thread_lock); + while (fork_quiesce_active && !proc_exit_group_requested()) { + if (!thread_exec_stop_requested()) { + pthread_cond_wait(&fork_cond, &thread_lock); + continue; + } + + struct timespec deadline; + timespec_deadline_in_ms(&deadline, 200); + if (pthread_cond_timedwait(&fork_cond, &thread_lock, &deadline) == + ETIMEDOUT) + break; + } pthread_mutex_unlock(&thread_lock); return 1; } +void thread_wake_all_blocked(void) +{ + /* hv_vcpus_exit only reaches threads inside hv_vcpu_run, so the futex + * interrupt covers futex waiters, the wakeup pipe covers poll/epoll/read + * parks, and thread_wake_exit_waiters covers the internal condvars (fork + * barrier, ptrace stop/wait). Every teardown caller needs all four; what + * differs between them is only why they are tearing down. + */ + futex_interrupt_request(); + wakeup_pipe_signal(); + thread_interrupt_all(); + thread_wake_exit_waiters(); + exec_handoff_wake_waiters(); +} + void thread_wake_exit_waiters(void) { pthread_mutex_lock(&thread_lock); - /* Fork barrier: siblings parked in thread_fork_barrier_check. Their wait - * loop re-checks proc_exit_group_requested on wake. + /* Fork barrier: siblings parked in thread_fork_barrier_check. Only + * exit_group releases them, since an execve teardown waits for the window + * to close rather than breaking it; the broadcast is what makes them + * re-check on process death. */ pthread_cond_broadcast(&fork_cond); + /* Deferred stack-unmap transactions: a thread waiting for another one's + * transaction to clear parks on this condvar, which nothing else wakes. + * Without the broadcast it sleeps through an execve teardown and is then + * counted as a thread that would not leave. + */ + pthread_cond_broadcast(&deferred_stack_unmap_cond); + /* Ptrace parks: tracers blocked in thread_ptrace_wait (ptrace_cond) and * tracees blocked in thread_ptrace_stop (resume_cond). Scan every slot with * live condvars, not just active ones: a tracer may still be parked on a @@ -838,6 +931,223 @@ void thread_wake_exit_waiters(void) pthread_mutex_unlock(&thread_lock); } +/* execve de_thread. */ + +/* Set while an execve is tearing its siblings down. Linux destroys every + * sibling in de_thread() before mapping the new image; elfuse has to do the + * same before guest_reset zeroes the memory those siblings are still running + * on. + * + * A flag rather than a survivor pointer because sys_execve hands a non-leader + * caller to the leader before the point of no return, so the thread that runs + * the teardown is always slot 0 and "am I the survivor" is "am I the leader". + * + * Two flags rather than one because the leader has to leave its blocking wait + * for the handoff too, and that is the opposite of being torn down. Both are + * read by every blocking wait, so they are plain atomics rather than + * thread_lock state. _Atomic is spelled as a qualifier, never as _Atomic(T): + * frama-c-stubs defines the keyword away so the analyzer can parse this file, + * and the specifier form leaves a stray parenthesized type behind. + */ +static _Atomic bool exec_leader_work_pending; + +void thread_set_leader_work_pending(bool pending) +{ + atomic_store_explicit(&exec_leader_work_pending, pending, + memory_order_release); +} + +bool thread_leader_work_pending(void) +{ + return atomic_load_explicit(&exec_leader_work_pending, + memory_order_acquire); +} + +int thread_exec_stop_requested(void) +{ + /* The flag first, so the common case (no execve in flight) never reaches + * current_thread: on Darwin every _Thread_local read goes through the TLV + * descriptor thunk, which is an indirect call, not a register read. + */ + if (!atomic_load_explicit(&exec_de_thread_active, memory_order_acquire)) + return 0; + + /* No table entry (the preemption thread, the GDB stub, the rosettad + * bridge): runs no guest code and is nobody's sibling. The leader is the + * thread running the teardown. And a CLONE_VM child is a distinct task with + * its own tgid, which Linux execve leaves alone: reaping it would publish a + * bogus exit(0) to the parent's wait4 and let its exit path request a + * process-wide exit_group in the middle of the exec. + */ + return current_thread && !thread_current_is_leader() && + !current_thread->is_vm_clone; +} + +int thread_stop_requested(void) +{ + if (thread_exec_stop_requested() || proc_exit_group_requested()) + return 1; + + /* A non-leader execve is handed to the leader, which can only pick it up + * from its run loop. Break it out of whatever it is parked in so the + * handoff does not wait on an unrelated blocking syscall. Every other + * thread ignores this: the requester is blocked in the handoff itself, and + * a third thread has no part in it. + */ + return thread_leader_work_pending() && thread_current_is_leader(); +} + +/* Whether an execve teardown will reap this slot: not the caller, not the main + * thread's (slot 0 is never torn down; it owns process teardown), and not a + * CLONE_VM child (a separate task that execve leaves alone). Counting every + * active thread instead would wait, and then report, on threads that were never + * going to leave. Caller must hold thread_lock. + */ +static bool thread_is_joinable_sibling(const thread_entry_t *t) +{ + return t != current_thread && t != &thread_table[0] && !t->is_vm_clone; +} + +static int thread_count_joinable_siblings(void) +{ + int n = 0; + + pthread_mutex_lock(&thread_lock); + THREAD_FOR_EACH_ACTIVE (t) { + if (thread_is_joinable_sibling(t)) + n++; + } + pthread_mutex_unlock(&thread_lock); + + return n; +} + +bool thread_current_is_leader(void) +{ + return current_thread == &thread_table[0]; +} + +int thread_exec_de_thread(void) +{ + if (!current_thread || thread_count_joinable_siblings() == 0) + return 0; + + /* Let an open quiesce window close before reaping anything. + * + * The two cannot overlap. A sibling parked in the fork barrier owes the + * forker its quiet until the snapshot is done, so the teardown must not + * pull it out; but if the teardown were already running, the forker would + * be a thread being reaped, and the barrier would never be released. Doing + * the wait here, before the teardown is published, keeps the forker outside + * it: it finishes and calls thread_resume_siblings, which is what wakes + * this. + * + * Bounded, because the alternative to giving up is a teardown that cannot + * finish. The cap covers a fork snapshot with margin. Past it the window is + * treated as stuck and the teardown proceeds, which is the pre-existing + * race rather than a new one. + */ + pthread_mutex_lock(&thread_lock); + if (fork_quiesce_active) { + struct timespec deadline; + timespec_deadline_in_ms(&deadline, 1000); + while (fork_quiesce_active) { + if (pthread_cond_timedwait(&fork_window_closed_cond, &thread_lock, + &deadline) == ETIMEDOUT) { + log_warn("execve: fork snapshot still open, reaping anyway"); + break; + } + } + } + atomic_store_explicit(&exec_de_thread_active, true, memory_order_release); + pthread_mutex_unlock(&thread_lock); + + /* Siblings leave the guest through their normal exit path (robust list, + * CLEARTID, own-vCPU destroy), which still runs against the pre-reset image + * because this returns only once they are gone. futex_interrupt stays set; + * execve clears it after guest_reset. + */ + thread_wake_all_blocked(); + + /* wakeup_pipe_drain takes every queued byte, so a sibling that reaches + * poll() after another one drained sits out its own 200 ms recheck no + * matter how many bytes the wake wrote. Re-poke while they wind down, which + * costs a threaded execve milliseconds instead of a poll quantum per parked + * sibling (measured: 38 s to 2.5 s over 200 threaded execs). The join below + * still bounds the wait if a sibling never arrives. + */ + for (int i = 0; i < 200 && thread_count_joinable_siblings() > 0; i++) { + /* The whole wake set, not just the pipe: a sibling in a tight compute + * loop is reachable only by hv_vcpus_exit, and one kick that lands + * between two hv_vcpu_run calls is lost. Re-issuing costs nothing here + * and removes the dependence on a single kick landing. + */ + thread_wake_all_blocked(); + usleep(1000); + } + + /* Name who is still here before the bounded join runs, so a teardown that + * ends fatally says which thread held it up rather than only how many did. + */ + if (thread_count_joinable_siblings() > 0) { + pthread_mutex_lock(&thread_lock); + THREAD_FOR_EACH_ACTIVE (t) { + if (thread_is_joinable_sibling(t)) + log_warn("execve: tid=%lld has not left the guest yet", + (long long) t->guest_tid); + } + pthread_mutex_unlock(&thread_lock); + } + + thread_join_workers(); + + /* thread_join_workers is bounded, so a sibling parked in a host call that + * never re-checked can outlive the cap and be detached. One such call is + * still reachable from guest code: the read side of a blocking FIFO open, + * which macOS gives nothing to poll on (see open_nonblocking_writer in + * syscall/fs.c). semop, fcntl F_SETLKW and flock used to belong on this + * list and no longer do; each polls a non-blocking form now. + * + * The caller must not reset guest memory under a straggler: it still holds + * that thread's registers and would resume into the zeroed image. Report + * the count and let sys_execve apply its post-PNR policy. + */ + int left = thread_count_joinable_siblings(); + + /* Cleared last. A straggler that wakes after this reads false and resumes, + * which is only safe because the caller treats a non-zero return as fatal. + */ + atomic_store_explicit(&exec_de_thread_active, false, memory_order_release); + + return left; +} + +void thread_reset_for_exec(void) +{ + if (!current_thread) + return; + + /* Linux begin_new_exec() drops both: the robust list and clear_child_tid + * are addresses in the image that just went away. Carrying them across the + * reset makes this thread's eventual exit walk a list, and write a zero + * word plus a futex wake, at whatever the new image happens to have put + * there. Nothing is marked FUTEX_OWNER_DIED on the way out because no + * thread survives that could observe it. + */ + current_thread->robust_list_head = 0; + current_thread->clear_child_tid = 0; + + /* rseq goes the same way, as rseq_execve does. Left registered, the address + * belongs to the old image while the new one cannot register its own: + * sc_rseq answers EBUSY for a thread that already has one, and the + * preemption and signal paths would abort against the stale critical + * section. + */ + current_thread->rseq_gva = 0; + current_thread->rseq_len = 0; + current_thread->rseq_signature = 0; +} + /* Ptrace helpers. */ pthread_mutex_t *thread_get_lock(void) @@ -873,6 +1183,16 @@ int thread_collect_and_defer_stack_ranges( if (rs >= re || re <= start || rs >= end) continue; if (t->deferred_stack_unmap_busy > 0) { + /* Give up rather than wait when this thread is leaving the guest. + * The caller reports the failure to its guest, which never reads it + * because the run loop winds the thread down first, and an execve + * teardown that waited here instead would count this thread as one + * that refused to leave. + */ + if (thread_stop_requested()) { + pthread_mutex_unlock(&thread_lock); + return -1; + } pthread_cond_wait(&deferred_stack_unmap_cond, &thread_lock); goto retry; } @@ -1163,9 +1483,9 @@ int thread_ptrace_stop(thread_entry_t *t, int sig) * tracer signals resume_cond, and a tracer that exits (or calls exit_group * itself) will never CONT this stop. thread_wake_exit_waiters broadcasts * resume_cond; returning 0 sends the caller back to its run loop, which - * re-checks proc_exit_group_requested. + * re-checks thread_stop_requested. */ - while (t->ptrace_stopped && !proc_exit_group_requested()) + while (t->ptrace_stopped && !thread_stop_requested()) pthread_cond_wait(&t->resume_cond, &thread_lock); /* Apply register changes if tracer wrote via SETREGSET */ @@ -1220,13 +1540,13 @@ int64_t thread_ptrace_wait(int64_t tracer_tid, pthread_mutex_lock(&thread_lock); for (;;) { - /* exit_group teardown: the stop/exit notifications that would signal - * ptrace_cond stop arriving once workers are being torn down. + /* exit_group or execve teardown: the stop/exit notifications that would + * signal ptrace_cond stop arriving once workers are being torn down. * * Return 0 ("no matching children") so the caller falls through and its * blocking paths re-check proc_exit_group_requested. */ - if (proc_exit_group_requested()) { + if (thread_stop_requested()) { pthread_mutex_unlock(&thread_lock); return 0; } diff --git a/src/runtime/thread.h b/src/runtime/thread.h index 0c7e1314..72982afe 100644 --- a/src/runtime/thread.h +++ b/src/runtime/thread.h @@ -339,6 +339,16 @@ bool thread_destroy_all_vcpus(hv_vcpu_t main_vcpu, */ void thread_interrupt_all(void); +/* Wake every thread parked anywhere it cannot see a teardown flag: futex + * waiters, poll/epoll/read parks on the wakeup pipe, vCPUs inside hv_vcpu_run, + * and the internal condvars. The four wakes are always needed together, so + * exit_group teardown (guest_destroy, main's run-loop exit) and execve + * de_thread share this rather than each keeping its own list. Callers that mean + * process teardown must set the exit-group flag first, per + * thread_wake_exit_waiters below. + */ +void thread_wake_all_blocked(void); + /* Wake workers parked on internal condvars (fork barrier, ptrace stop/wait) so * exit_group teardown reaches them within a bounded time. hv_vcpus_exit only * interrupts threads inside hv_vcpu_run, and the wakeup pipe / futex interrupt @@ -362,9 +372,14 @@ int thread_signal_deliverable(uint64_t sigbit); /* Quiesce all sibling vCPUs for fork snapshot consistency. Calls hv_vcpus_exit * on all active threads except the caller, then waits until they are all - * blocked on the fork barrier. Caller must NOT hold thread_lock. + * blocked on the fork barrier. Caller must NOT hold thread_lock. Hold every + * sibling outside guest code until thread_resume_siblings. + * Returns false without arming, and without any need to resume, when an execve + * teardown is reaping: the caller is one of the threads being reaped, and the + * barrier it would arm is one nobody would release. Callers must abandon the + * operation the quiet was for. */ -void thread_quiesce_siblings(void); +bool thread_quiesce_siblings(void); /* Resume sibling vCPUs after fork snapshot is complete. Clears the quiesce flag * and broadcasts the fork condvar. @@ -386,6 +401,56 @@ int thread_fork_barrier_check(void); */ void thread_fork_release_counted_locked(thread_entry_t *t); +/* execve de_thread helpers. */ + +/* True when an execve on another thread is tearing this one down. Callers that + * already tested proc_exit_group_requested use this; everything else wants + * thread_stop_requested below. + */ +int thread_exec_stop_requested(void); + +/* The leader has an execve handed to it and must leave whatever it is parked in + * to run it. Published by the exec layer, read by thread_stop_requested, so the + * hot-path predicate stays inside runtime/. + */ +void thread_set_leader_work_pending(bool pending); +bool thread_leader_work_pending(void); + +/* True when the calling thread must leave guest execution: a process-wide + * exit_group was requested, or an execve is tearing this thread down. Every + * blocking wait in a guest syscall re-checks this and returns EINTR so the run + * loop can wind the thread down; a thread that only polled the exit-group flag + * would keep the exec'ing thread parked in thread_exec_de_thread until the join + * cap expired. + */ +int thread_stop_requested(void); + +/* True when the caller is the thread group leader (the main host thread, the + * one whose run loop returning tears the process down). de_thread cannot + * destroy the leader, so an execve from any other thread is handed to it and + * runs on its vCPU; see exec_handoff_to_leader. + */ +bool thread_current_is_leader(void); + +/* Drop the exec'ing thread's robust list and clear_child_tid, as Linux + * begin_new_exec() does: both name addresses in the image that just went away. + * Call from sys_execve after guest_reset. + */ +void thread_reset_for_exec(void); + +/* Destroy every sibling guest thread on behalf of an execve, Linux de_thread(). + * Call from the exec'ing thread at the point of no return, BEFORE guest_reset: + * siblings wind down against the old image, so their CLEARTID and robust-list + * writes still land on the memory their guest expects. + * + * Returns the number of siblings still live, which is 0 unless one outlived the + * bounded join. A non-zero return means guest memory MUST NOT be reset: that + * thread still holds registers into the old image and would resume into the + * zeroed one. The caller is past the point of no return, so its only safe + * response is a diagnosed fatal exit. + */ +int thread_exec_de_thread(void); + /* Ptrace helpers. */ /* Tracee: snapshot vCPU regs, enter ptrace-stop, block until resumed. diff --git a/src/syscall/exec.c b/src/syscall/exec.c index 9156463f..4f92dc3c 100644 --- a/src/syscall/exec.c +++ b/src/syscall/exec.c @@ -11,6 +11,8 @@ */ #include +#include +#include #include #include #include @@ -33,10 +35,12 @@ #include "runtime/forkipc.h" #include "runtime/futex.h" +#include "runtime/thread.h" #include "syscall/linux-wire.h" #include "syscall/chown-overlay.h" #include "syscall/exec.h" +#include "syscall/wakeup-pipe.h" /* wakeup_pipe_signal */ #include "syscall/fuse.h" #include "syscall/internal.h" #include "syscall/path.h" @@ -542,13 +546,13 @@ static void exec_close_cloexec_fds(void) * * Each batch rescans from slot 0 rather than carrying a cursor * across the unlocked window, and the loop ends on the first pass - * that finds nothing. That is deliberate: the window drops - * fd_lock, so a cursor that only moves forward would never - * re-examine a slot it had already passed. Every pass marks what - * it takes closed, so the candidate set shrinks and the loop - * terminates. The repeated scanning is bounded by the table size - * times the batch count and only happens once malloc has already - * failed, which is a trade this path can afford. + * that finds nothing. That is deliberate: the window drops fd_lock, + * so a cursor that only moves forward would never re-examine a slot + * it had already passed. Every pass marks what it takes closed, so + * the candidate set shrinks and the loop terminates. The repeated + * scanning is bounded by the table size times the batch count and + * only happens once malloc has already failed, which is a trade + * this path can afford. */ struct cloexec_entry batch[32]; for (;;) { @@ -694,6 +698,244 @@ static int64_t exec_preload_interp(const guest_t *g, return 0; } + +/* execve handoff to the thread group leader. + * + * One slot, because two threads racing to replace the same image have no + * meaningful joint outcome: the second waits for the first, and if the first + * succeeded the second never wakes as a running thread (de_thread reaps it). + * Linux serializes the same window on cred_guard_mutex. EMPTY -> PUBLISHED -> + * TAKEN -> (EMPTY on success, DONE on failure). A successful exec never reaches + * DONE: the requester is reaped by de_thread and has no one to report to. + */ +typedef enum { + HANDOFF_EMPTY = 0, + HANDOFF_PUBLISHED, + HANDOFF_TAKEN, + HANDOFF_DONE, +} exec_handoff_state_t; + +static struct { + exec_handoff_state_t state; + int64_t result; + uint64_t path_gva, argv_gva, envp_gva; + + /* Copied, not borrowed: on success the requester is reaped by de_thread and + * its pthread stack is freed while the leader is still inside sys_execve. + */ + char host_path[LINUX_PATH_MAX]; /* empty when the caller passed none */ + uint64_t blocked_mask; /* requester's signal mask, adopted by the leader */ +} exec_handoff; + +static pthread_mutex_t exec_handoff_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t exec_handoff_cond = PTHREAD_COND_INITIALIZER; + +/* The state is the truth; the flag thread_stop_requested polls is its lock-free + * mirror, since that predicate runs in blocking waits where taking this mutex + * would be wrong. Move them together so they cannot drift. Caller holds the + * lock. + */ +static void exec_handoff_set_state(exec_handoff_state_t st) +{ + exec_handoff.state = st; + thread_set_leader_work_pending(st == HANDOFF_PUBLISHED); +} + +void exec_handoff_wake_waiters(void) +{ + pthread_mutex_lock(&exec_handoff_lock); + pthread_cond_broadcast(&exec_handoff_cond); + pthread_mutex_unlock(&exec_handoff_lock); +} + +/* Block until the slot reaches want. Returns false when thread_stop_requested + * broke the wait instead, which for the requester means the exec it asked for + * already succeeded and de_thread is reaping it. Caller holds the lock; the + * bounded quantum is a safety net under the wake in thread_wake_all_blocked. + */ +static bool exec_handoff_wait_for(exec_handoff_state_t want) +{ + while (exec_handoff.state != want && !thread_stop_requested()) { + struct timespec ts; + timespec_deadline_in_ms(&ts, 100); + pthread_cond_timedwait(&exec_handoff_cond, &exec_handoff_lock, &ts); + + /* Re-poke while the request is still unclaimed. The leader is woken + * once at publish, and a kick that lands between two hv_vcpu_run calls + * is lost; without this the requester waits forever and the leader + * runs on none the wiser. The wake set broadcasts this same condvar, + * so the lock has to come off around it. + */ + if (exec_handoff.state == HANDOFF_PUBLISHED) { + pthread_mutex_unlock(&exec_handoff_lock); + thread_wake_all_blocked(); + pthread_mutex_lock(&exec_handoff_lock); + } + } + return exec_handoff.state == want; +} + +/* Requester side: publish the request, wake the leader, and block until it + * reports back or this thread is torn down. + * + * Returns the errno the guest should see, or SYSCALL_EXEC_HAPPENED if the exec + * succeeded (in which case this thread is already being reaped and the value + * only has to keep the dispatcher from writing X0). + */ +static int64_t exec_handoff_to_leader(uint64_t path_gva, + uint64_t argv_gva, + uint64_t envp_gva, + const char *host_path) +{ + pthread_mutex_lock(&exec_handoff_lock); + + /* Wait for the slot. A concurrent handoff either fails (freeing the slot) + * or succeeds, in which case thread_stop_requested breaks this wait. + */ + if (!exec_handoff_wait_for(HANDOFF_EMPTY)) { + pthread_mutex_unlock(&exec_handoff_lock); + return -LINUX_EINTR; + } + + exec_handoff_set_state(HANDOFF_PUBLISHED); + exec_handoff.path_gva = path_gva; + exec_handoff.argv_gva = argv_gva; + exec_handoff.envp_gva = envp_gva; + /* An empty string is the "no host_path" marker, so the flag the buffer + * would otherwise need stays derivable from the buffer itself. + */ + exec_handoff.host_path[0] = '\0'; + if (host_path && str_copy_trunc(exec_handoff.host_path, host_path, + sizeof(exec_handoff.host_path)) >= + sizeof(exec_handoff.host_path)) { + exec_handoff_set_state(HANDOFF_EMPTY); + pthread_cond_broadcast(&exec_handoff_cond); + pthread_mutex_unlock(&exec_handoff_lock); + return -LINUX_ENAMETOOLONG; + } + exec_handoff.blocked_mask = current_thread ? current_thread->blocked : 0; + pthread_mutex_unlock(&exec_handoff_lock); + + /* Release mmap_lock, which this thread's sc_execve wrapper holds, for the + * whole wait: the leader needs it to run the exec, and the teardown that + * reaps this thread needs it free. Re-taken before returning so the + * wrapper's unlock stays balanced, and by then the leader has released it + * around its own teardown. + */ + pthread_mutex_unlock(&mmap_lock); + + /* The leader may be parked in a blocking syscall. thread_stop_requested is + * true for it while a request is pending, so its wait returns EINTR and its + * run loop reaches the service point. + */ + thread_wake_all_blocked(); + + pthread_mutex_lock(&exec_handoff_lock); + bool reported = exec_handoff_wait_for(HANDOFF_DONE); + + int64_t result; + if (!reported) { + /* Torn down: the exec succeeded and de_thread is reaping this thread. + * Leave the slot to the leader, which clears it. + */ + result = -LINUX_EINTR; + } else { + result = exec_handoff.result; + exec_handoff_set_state(HANDOFF_EMPTY); + pthread_cond_broadcast(&exec_handoff_cond); + } + pthread_mutex_unlock(&exec_handoff_lock); + + pthread_mutex_lock(&mmap_lock); + return result; +} + + +/* Drop any handoff state on behalf of the image being replaced. A requester + * blocked in the slot is reaped by de_thread and never returns to read it, so + * the new image must not inherit an occupied slot. + */ +static void exec_handoff_reset(void) +{ + pthread_mutex_lock(&exec_handoff_lock); + exec_handoff_set_state(HANDOFF_EMPTY); + pthread_cond_broadcast(&exec_handoff_cond); + pthread_mutex_unlock(&exec_handoff_lock); +} + +int64_t exec_run_handoff(hv_vcpu_t vcpu, guest_t *g, bool verbose) +{ + uint64_t path_gva, argv_gva, envp_gva; + char host_path_buf[LINUX_PATH_MAX]; + const char *host_path = NULL; + uint64_t saved_mask = 0; + + pthread_mutex_lock(&exec_handoff_lock); + if (exec_handoff.state != HANDOFF_PUBLISHED) { + pthread_mutex_unlock(&exec_handoff_lock); + return 0; + } + exec_handoff_set_state(HANDOFF_TAKEN); + path_gva = exec_handoff.path_gva; + argv_gva = exec_handoff.argv_gva; + envp_gva = exec_handoff.envp_gva; + if (exec_handoff.host_path[0]) { + str_copy_trunc(host_path_buf, exec_handoff.host_path, + sizeof(host_path_buf)); + host_path = host_path_buf; + } + + /* Moving out of PUBLISHED also clears the pending mirror, which is what + * stops thread_stop_requested from returning EINTR to the very thread now + * servicing the request: sys_execve can itself block on a FUSE-backed + * binary. + */ + + /* Linux keeps the exec'ing thread's signal mask across execve, and that + * thread is the one the new image inherits from. The leader runs the + * syscall in its place, so it adopts the mask too. + */ + uint64_t adopt_mask = exec_handoff.blocked_mask; + pthread_mutex_unlock(&exec_handoff_lock); + + /* Adopt the requester's mask, which is what the new image inherits on + * Linux. Through the signal module rather than by storing to the field: + * every other writer holds sig_lock (order 4), and + * thread_signal_deliverable reads it lock-free against them. + */ + saved_mask = signal_save_blocked(); + signal_set_blocked(adopt_mask); + + /* sys_execve is written to run with mmap_lock held, which on the direct + * path its sc_execve wrapper takes. This path comes from the run loop, so + * take it here instead. + */ + pthread_mutex_lock(&mmap_lock); + int64_t rc = + sys_execve(vcpu, g, path_gva, argv_gva, envp_gva, verbose, host_path); + pthread_mutex_unlock(&mmap_lock); + + pthread_mutex_lock(&exec_handoff_lock); + if (rc == SYSCALL_EXEC_HAPPENED) { + /* The requester is being reaped by de_thread and will never read this. + * Free the slot so the new image can execve again. + */ + exec_handoff_set_state(HANDOFF_EMPTY); + } else { + /* The exec failed before its point of no return, so this thread goes + * back to being itself: the requester's mask belonged to the image that + * never got loaded. + */ + signal_restore_blocked(saved_mask); + exec_handoff.result = rc; + exec_handoff_set_state(HANDOFF_DONE); + } + pthread_cond_broadcast(&exec_handoff_cond); + pthread_mutex_unlock(&exec_handoff_lock); + + return rc == SYSCALL_EXEC_HAPPENED ? SYSCALL_EXEC_HAPPENED : 0; +} + /* NOLINTNEXTLINE(readability-function-size) */ int64_t sys_execve(hv_vcpu_t vcpu, guest_t *g, @@ -703,6 +945,25 @@ int64_t sys_execve(hv_vcpu_t vcpu, bool verbose, const char *host_path) { + /* Not the leader: hand the whole syscall to it. See exec_handoff_to_leader + * for why the leader has to be the one that survives. + */ + if (!thread_current_is_leader()) + return exec_handoff_to_leader(path_gva, argv_gva, envp_gva, host_path); + + /* Linux gives the exec'ing task a new mm and leaves a CLONE_VM child on the + * old one. elfuse has a single guest slab, so guest_reset would zero the + * memory that child is executing. Refuse while one is live, for the same + * reason and with the same recoverable errno as above. + */ + if (thread_count_active_vm_clones() > 0) { + log_error( + "execve with %d live CLONE_VM child(ren) is not supported; " + "they share the guest memory guest_reset would zero", + thread_count_active_vm_clones()); + return -LINUX_ENOSYS; + } + /* Copy guest execve inputs before any state-reset point of no return. A * provided host_path (from execveat resolution) is used directly for the * exec open, but the guest-visible identity in path must carry the guest @@ -1131,6 +1392,52 @@ int64_t sys_execve(hv_vcpu_t vcpu, /* Past pre-PNR validation. Fall through to point of no return. The fail * label below handles all pre-PNR error paths. */ + + /* Linux de_thread(): the siblings die before the new image exists, and + * before commit_creds, so ordering it first here matches begin_new_exec and + * keeps the credential commit below from reaching a thread that Linux would + * already have destroyed. It also has to precede the CLOEXEC sweep and + * guest_reset: a sibling parked in read() on a fd about to close, or still + * executing the old image's code, winds down against the memory and fd + * table its guest still expects. + */ + /* Both callers hold mmap_lock (order 1) across the whole syscall, and the + * teardown must not run under it: a sibling blocked in + * pthread_mutex_lock(&mmap_lock) inside sc_brk, sc_mmap, sc_munmap, + * sc_mprotect, or its own deferred stack unmap is reachable by none of the + * teardown wakes, so it can never reach a stop check and the join below + * would always time out. Measured before this release: four siblings + * looping on mmap/munmap took the fatal path every time. + * + * Dropping it here is safe because nothing in the teardown touches guest + * memory or the region table, and re-acquiring cannot contend: by the time + * it returns 0 no other guest thread is left to hold it. + */ + pthread_mutex_unlock(&mmap_lock); + int survivors = thread_exec_de_thread(); + pthread_mutex_lock(&mmap_lock); + + /* The refusal above is a snapshot: a sibling could have created a CLONE_VM + * child in the window between it and here. de_thread neither reaps nor + * waits for one (Linux leaves it on the old mm), so count it now, when + * every thread that could have created one is gone. guest_reset would + * otherwise zero the shim data holding its EL1 stack and unmap host memory + * under a live foreign vCPU. + */ + survivors += thread_count_active_vm_clones(); + if (survivors > 0) { + /* A sibling outlived the bounded join, so it still holds registers into + * the image guest_reset is about to zero. Past the point of no return + * the only safe answer is the same diagnosed exit the other post-reset + * failures take. + */ + log_fatal( + "execve failed after point of no return: " + "%d guest thread(s) survived de_thread", + survivors); + exit(128); + } + /* Commit credentials right before the Point of No Return. Saved UID/GID are * refreshed from the final effective IDs. * @@ -1142,11 +1449,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, * the gate afterwards, so this elevates the whole process tree from here * on, not just the image being loaded. * - * elfuse never tears sibling guest threads down at exec, so a sibling that - * outlives this call keeps the credentials committed here for the rest of - * the process lifetime -- a multithreaded guest that execs the marked - * binary from one thread hands root to every thread that was already - * running. The gate is published after the IDs because no permission check + * The gate is published after the IDs because no permission check * grants on the gate alone: proc-identity.c pairs it with "emu_euid == 0 || * fakeroot", and sys_getgroups and capget require both. Publishing it last * can therefore only narrow the window, never open one. @@ -1223,6 +1526,8 @@ int64_t sys_execve(hv_vcpu_t vcpu, */ proc_clear_exit_group(); futex_interrupt_clear(); + thread_reset_for_exec(); + exec_handoff_reset(); /* POSIX exec signal semantics: Handlers set to SIG_DFL (except SIG_IGN * stays SIG_IGN), pending signals preserved, and signal mask preserved. diff --git a/src/syscall/exec.h b/src/syscall/exec.h index ebb17811..01188f71 100644 --- a/src/syscall/exec.h +++ b/src/syscall/exec.h @@ -19,8 +19,34 @@ /* Execute a new binary, replacing current process image. Reads path, argv[], * envp[] from guest memory, reloads ELF, resets state. * Returns SYSCALL_EXEC_HAPPENED on success (caller skips X0 write), or negative - * Linux errno on failure. + * Linux errno on failure. execve handoff to the thread group leader. + * + * Linux de_thread() destroys the leader and gives its tid to the exec'ing + * thread. elfuse cannot: the leader is the main host thread, and its run loop + * returning is what tears the process down. So a non-leader execve is handed to + * the leader instead, which runs the whole syscall on its own vCPU. The result + * is what Linux produces, the new image runs single-threaded with gettid() == + * getpid(), and the requester dies as a sibling in de_thread. + * + * Whether a request is waiting is published through + * thread_set_leader_work_pending, so both readers (the leader's run loop and + * thread_stop_requested) ask the thread table rather than the exec layer. + */ + +/* Wake anything parked in the handoff, so a requester whose exec already + * succeeded notices that de_thread is reaping it instead of sitting out its + * safety-net quantum. Part of thread_wake_all_blocked's wake set. */ +void exec_handoff_wake_waiters(void); + +/* Leader side: run a pending handoff to completion on this vCPU. + * + * Returns SYSCALL_EXEC_HAPPENED when the new image is installed (the caller + * resumes the vCPU on the rebuilt registers), or 0 when the exec failed before + * its point of no return and the requester was given the errno. + */ +int64_t exec_run_handoff(hv_vcpu_t vcpu, guest_t *g, bool verbose); + int64_t sys_execve(hv_vcpu_t vcpu, guest_t *g, uint64_t path_gva, diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 0c6fdad5..f7303a3c 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -46,6 +46,7 @@ _Static_assert(NAME_MAX == DIRENT64_NAME_MAX, #include "syscall/fuse.h" #include "syscall/fs.h" #include "syscall/internal.h" +#include "syscall/io.h" /* io_retry_backoff */ #include "syscall/net.h" /* absock_unregister_fd */ #include "syscall/path.h" #include "syscall/poll.h" /* epoll_dup_fd */ @@ -512,6 +513,77 @@ static int64_t reject_unsupported_fuse_path_op(const path_translation_t *tx) /* open/close. */ + +/* openat, without parking the vCPU thread in a host call no teardown wake + * reaches. A write-only open of a FIFO with no reader blocks until one arrives; + * O_NONBLOCK reports that state as ENXIO instead, which is unambiguous (no + * other file type produces it here), so poll for the reader and restore the + * blocking flag once the open succeeds. The guest-visible result is the same. + * + * The read-only side keeps the blocking open, which is a known teardown hazard + * rather than an oversight: a thread parked in it is reachable by no teardown + * wake, so an execve de_thread running concurrently counts it as a thread that + * would not leave and takes its fatal path. It cannot be emulated the same way + * as the write side: an O_RDONLY | O_NONBLOCK open of a FIFO succeeds + * immediately whether or not a writer exists, and macOS poll() reports revents + * == 0 on the read end in every state (measured), so there is nothing to wait + * on that would reproduce "return once a writer arrives". Lifting it needs the + * open to run on a thread that owns no vCPU. + */ +static int open_nonblocking_writer(int dirfd, + const char *path, + int flags, + mode_t mode) +{ + bool guest_wants_nonblock = (flags & O_NONBLOCK) != 0; + bool may_block_for_reader = + !guest_wants_nonblock && (flags & O_ACCMODE) == O_WRONLY; + + if (!may_block_for_reader) { + return (dirfd == AT_FDCWD) ? open(path, flags, mode) + : openat(dirfd, path, flags, mode); + } + + unsigned backoff = 0; + for (;;) { + int fd = (dirfd == AT_FDCWD) + ? open(path, flags | O_NONBLOCK, mode) + : openat(dirfd, path, flags | O_NONBLOCK, mode); + if (fd >= 0) { + /* Restore the blocking mode the guest asked for. Nothing observed + * the O_NONBLOCK window: no guest-visible I/O has run on this fd. + */ + if (fd_update_status_flag(fd, O_NONBLOCK, false) < 0) { + /* The guest asked for a blocking fd; handing it a non-blocking + * one would surface as spurious EAGAIN later. + */ + close_keep_errno(fd); + return -1; + } + return fd; + } + if (errno != ENXIO) + return -1; + + /* ENXIO also means "special file, no device configured", which is + * permanent: retrying it would spin forever. Only a FIFO can become + * openable later, when a reader arrives. + */ + struct stat st; + int strc = (dirfd == AT_FDCWD) ? stat(path, &st) + : fstatat(dirfd, path, &st, 0); + if (strc != 0 || !S_ISFIFO(st.st_mode)) { + errno = ENXIO; + return -1; + } + + if (io_retry_backoff(&backoff) < 0) { + errno = EINTR; + return -1; + } + } +} + int64_t sys_openat_path(guest_t *g, int dirfd, const char *pathp, @@ -529,7 +601,8 @@ int64_t sys_openat_path(guest_t *g, int flags = translate_open_flags(linux_flags); if (!tx.fuse_path && tx.proc_resolved == 0 && dirfd == LINUX_AT_FDCWD && pathp[0] != '/' && !proc_get_sysroot()) { - int host_fd = openat(AT_FDCWD, tx.host_path, flags, mode); + int host_fd = + open_nonblocking_writer(AT_FDCWD, tx.host_path, flags, mode); if (host_fd < 0) return linux_errno(); @@ -596,7 +669,8 @@ int64_t sys_openat_path(guest_t *g, } if (dirfd == LINUX_AT_FDCWD) { - int host_fd = open(tx.host_path, flags, mode); + int host_fd = + open_nonblocking_writer(AT_FDCWD, tx.host_path, flags, mode); if (host_fd < 0) return linux_errno(); @@ -618,7 +692,8 @@ int64_t sys_openat_path(guest_t *g, if (host_dirfd_ref_open(dirfd, &dir_ref) < 0) return -LINUX_EBADF; - int host_fd = openat(dir_ref.fd, tx.host_path, flags, mode); + int host_fd = + open_nonblocking_writer(dir_ref.fd, tx.host_path, flags, mode); host_fd_ref_close(&dir_ref); if (host_fd < 0) return linux_errno(); @@ -1115,12 +1190,17 @@ int64_t sys_dup3(int oldfd, int newfd, int linux_flags) * Use guest_read/guest_write (not guest_ptr) to safely handle structs that span * 2MiB page table block boundaries. */ -static int64_t fcntl_flock_op(guest_t *g, - host_fd_ref_t *host_ref, - uint64_t arg, - int mac_cmd, - bool is_getlk, - bool is_ofd) +/* Read the guest's struct flock once and translate it to the host's. + * + * Split out so a waiting command can decode before it starts polling: rereading + * guest memory on every retry lets another thread change the request underneath + * the wait, which would silently switch which region is being locked, turn a + * lock into an unlock, or fail with EFAULT after the mapping went away. + */ +static int64_t fcntl_flock_decode(guest_t *g, + uint64_t arg, + bool is_ofd, + struct flock *out) { uint8_t lflock[32]; /* Linux struct flock is 32 bytes on aarch64 */ if (guest_read_small(g, arg, lflock, sizeof(lflock)) < 0) @@ -1165,13 +1245,27 @@ static int64_t fcntl_flock_op(guest_t *g, return -LINUX_EINVAL; } - struct flock mac_fl = { + *out = (struct flock) { .l_start = l_start, .l_len = l_len, .l_pid = 0, .l_type = mac_type, .l_whence = l_whence, /* SEEK_SET=0, SEEK_CUR=1, SEEK_END=2 same */ }; + return 0; +} + +static int64_t fcntl_flock_op(guest_t *g, + host_fd_ref_t *host_ref, + uint64_t arg, + int mac_cmd, + bool is_getlk, + bool is_ofd) +{ + struct flock mac_fl; + int64_t decoded = fcntl_flock_decode(g, arg, is_ofd, &mac_fl); + if (decoded < 0) + return decoded; if (fcntl(host_ref->fd, mac_cmd, &mac_fl) < 0) return linux_errno(); @@ -1214,6 +1308,11 @@ static int64_t fcntl_flock_op(guest_t *g, int64_t gpid = proc_host_to_guest_pid((pid_t) mac_fl.l_pid); rp = (gpid > 0) ? (int32_t) gpid : (int32_t) mac_fl.l_pid; } + + /* The decode reads into its own buffer, so the GETLK answer needs one of + * its own to pack into. + */ + uint8_t lflock[32]; memset(lflock, 0, sizeof(lflock)); memcpy(lflock + 0, &rt, 2); memcpy(lflock + 2, &rw, 2); @@ -1225,6 +1324,45 @@ static int64_t fcntl_flock_op(guest_t *g, return 0; } +/* F_SETLKW / F_OFD_SETLKW, without parking the vCPU thread in a host call that + * no teardown wake reaches. Polls the non-waiting command instead: macOS + * reports a conflicting lock as EAGAIN, and POSIX allows EACCES for the same + * condition, so both mean "retry". One thing polling cannot recover: F_SETLK + * does no deadlock detection, so a guest that deadlocks on POSIX record locks + * waits here instead of one participant getting EDEADLK. waiting is false for + * the GETLK and SETLK commands, which never block and pass straight through. + */ +static int64_t fcntl_flock_wait(guest_t *g, + host_fd_ref_t *host_ref, + uint64_t arg, + int mac_cmd, + bool is_getlk, + bool is_ofd, + bool waiting) +{ + if (!waiting) + return fcntl_flock_op(g, host_ref, arg, mac_cmd, is_getlk, is_ofd); + + struct flock mac_fl; + int64_t decoded = fcntl_flock_decode(g, arg, is_ofd, &mac_fl); + if (decoded < 0) + return decoded; + + int poll_cmd = is_ofd ? F_OFD_SETLK : F_SETLK; + unsigned backoff = 0; + for (;;) { + if (fcntl(host_ref->fd, poll_cmd, &mac_fl) == 0) + return 0; + int64_t rc = linux_errno(); + if (rc != -LINUX_EAGAIN && rc != -LINUX_EACCES) + return rc; + + int64_t wait_rc = io_retry_backoff(&backoff); + if (wait_rc < 0) + return wait_rc; + } +} + int64_t sys_fcntl(guest_t *g, int fd, int cmd, uint64_t arg) { if (!RANGE_CHECK(fd, 0, FD_TABLE_SIZE)) @@ -1409,8 +1547,8 @@ int64_t sys_fcntl(guest_t *g, int fd, int cmd, uint64_t arg) if (host_fd_ref_open(fd, &host_ref) < 0) return -LINUX_EBADF; int mac_cmd = (cmd == 5) ? F_GETLK : (cmd == 6) ? F_SETLK : F_SETLKW; - int64_t rc = - fcntl_flock_op(g, &host_ref, arg, mac_cmd, cmd == 5, false); + int64_t rc = fcntl_flock_wait(g, &host_ref, arg, mac_cmd, cmd == 5, + false, cmd == 7); host_fd_ref_close(&host_ref); return rc; } @@ -1424,8 +1562,8 @@ int64_t sys_fcntl(guest_t *g, int fd, int cmd, uint64_t arg) int mac_cmd = (cmd == 36) ? F_OFD_GETLK : (cmd == 37) ? F_OFD_SETLK : F_OFD_SETLKW; - int64_t rc = - fcntl_flock_op(g, &host_ref, arg, mac_cmd, cmd == 36, true); + int64_t rc = fcntl_flock_wait(g, &host_ref, arg, mac_cmd, cmd == 36, + true, cmd == 38); host_fd_ref_close(&host_ref); return rc; } diff --git a/src/syscall/fuse.c b/src/syscall/fuse.c index dbc5645e..f374379a 100644 --- a/src/syscall/fuse.c +++ b/src/syscall/fuse.c @@ -29,6 +29,7 @@ #include "syscall/fuse.h" #include "syscall/internal.h" #include "syscall/path.h" +#include "runtime/thread.h" /* thread_stop_requested */ #include "syscall/proc.h" #include "syscall/signal.h" @@ -2425,9 +2426,10 @@ int64_t fuse_dev_read(int guest_fd, * thread_join_workers' poll cap and touches guest memory (the reply * frame write below) on an eventual delayed wake, well after * guest_destroy may have unmapped it. Poll in bounded quanta and bail - * out once exit_group is requested. + * out once the thread is told to leave the guest, which is exit_group + * or an execve tearing this thread down. */ - if (proc_exit_group_requested()) { + if (thread_stop_requested()) { pthread_mutex_unlock(&session->lock); pthread_mutex_lock(&fuse_lock); fuse_session_put_locked(session); diff --git a/src/syscall/inotify.c b/src/syscall/inotify.c index 5c4b02b5..b7e753f2 100644 --- a/src/syscall/inotify.c +++ b/src/syscall/inotify.c @@ -41,7 +41,8 @@ #include "syscall/inotify.h" #include "syscall/internal.h" #include "syscall/path.h" -#include "syscall/proc.h" /* proc_exit_group_requested */ +#include "runtime/thread.h" /* thread_stop_requested */ +#include "syscall/proc.h" /* proc_exit_group_requested */ static void inotify_close(int guest_fd); @@ -912,7 +913,7 @@ int64_t inotify_read(int guest_fd, guest_t *g, uint64_t buf_gva, uint64_t count) break; if (nev < 0 && errno != EINTR) return linux_errno(); - if (proc_exit_group_requested()) + if (thread_stop_requested()) return -LINUX_EINTR; } if (nev <= 0) diff --git a/src/syscall/io.c b/src/syscall/io.c index 23e9c418..cf7187c0 100644 --- a/src/syscall/io.c +++ b/src/syscall/io.c @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -191,6 +192,44 @@ static int64_t linux_siocgifhwaddr(guest_t *g, uint64_t arg) return 0; } +int64_t io_retry_backoff(unsigned *backoff_us) +{ + /* Materialize an expired guest interval timer first. ITIMER_REAL is virtual + * and only becomes a pending SIGALRM when this runs, and the syscall + * epilogue that would otherwise do it cannot run while the caller is + * looping here. Without this a guest whose alarm fires during a contended + * lock waits for the lock rather than for the signal. The interruptible fd + * wait below does the same thing for the same reason. + */ + signal_check_timer_real(); + + /* Teardown, or a signal Linux would have delivered: semop, flock and + * F_SETLKW are all interruptible, and the blocking host calls these replace + * were reachable by neither. signal_pending_interruption already filters + * SIG_IGN, default-ignore, and SA_RESTART, so it cannot manufacture an + * EINTR the guest would not have seen. + */ + if (thread_stop_requested() || signal_pending_interruption(NULL)) + return -LINUX_EINTR; + + /* First miss: yield rather than sleep. A lock or semaphore released inside + * the current scheduling quantum is the common case, and a sleep would turn + * it into a timer round trip that macOS rounds up well past the request. + */ + if (*backoff_us == 0) { + sched_yield(); + *backoff_us = IO_RETRY_BACKOFF_START_US; + return 0; + } + + unsigned us = *backoff_us; + usleep(us); + + us *= 2; + *backoff_us = us > IO_RETRY_BACKOFF_MAX_US ? IO_RETRY_BACKOFF_MAX_US : us; + return 0; +} + int64_t io_wait_fd_or_interrupted(int host_fd, short events) { int wake_fd = wakeup_pipe_read_fd(); @@ -209,12 +248,12 @@ int64_t io_wait_fd_or_interrupted(int host_fd, short events) * epilogue, which cannot run while this thread is parked here. The * futex wait loops do the same. */ - signal_check_timer(); + signal_check_timer_real(); /* Ignored/default-ignore signals do not interrupt; restartable handlers * still need to run promptly through the syscall epilogue. */ - if (proc_exit_group_requested() || futex_interrupt_consume() || + if (thread_stop_requested() || futex_interrupt_consume() || signal_pending_interruption(NULL)) return -LINUX_EINTR; diff --git a/src/syscall/io.h b/src/syscall/io.h index 12a279da..db0e0e8a 100644 --- a/src/syscall/io.h +++ b/src/syscall/io.h @@ -25,6 +25,7 @@ int64_t sys_write(guest_t *g, int fd, uint64_t buf_gva, uint64_t count); int64_t sys_read(guest_t *g, int fd, uint64_t buf_gva, uint64_t count); void urandom_fd_cleanup(int guest_fd); void urandom_fd_reset_cache(int guest_fd); + /* Initialize the per-fd urandom cache locks. Must run before any guest thread * enters sys_read or sys_readv on /dev/urandom. Called from syscall_init * alongside the other subsystem init hooks. @@ -41,6 +42,30 @@ void io_init(void); */ int64_t io_wait_fd_or_interrupted(int host_fd, short events); +/* Backoff bounds for io_retry_backoff. These replace a blocking host call that + * returned the instant the resource freed, so the ceiling is the added latency + * a guest pays after the holder releases: 2 ms costs at most 500 wakeups/s on a + * thread that is otherwise asleep, and keeps the worst case an order of + * magnitude below what the execve teardown budget (200 ms of pokes plus a + * 500 ms join) would tolerate. The floor is short because the common case is a + * lock held for microseconds; io_retry_backoff yields once before sleeping at + * all, which catches a holder that releases inside the same quantum. + */ +#define IO_RETRY_BACKOFF_START_US 50 +#define IO_RETRY_BACKOFF_MAX_US 2000 + +/* One backoff step for a host call that has no interruptible form: semop, + * flock, fcntl F_SETLKW, and a blocking FIFO open. None of them participates in + * the wakeup pipe, and hv_vcpus_exit does not reach a thread outside + * hv_vcpu_run, so a thread parked inside one is invisible to every teardown + * wake. Callers loop over the non-blocking form of the operation and call this + * between attempts instead. + * + * Returns 0 when the caller should retry, or -LINUX_EINTR when teardown needs + * this thread out of the guest. *backoff_us must start at 0. + */ +int64_t io_retry_backoff(unsigned *backoff_us); + int64_t sys_pread64(guest_t *g, int fd, uint64_t buf_gva, diff --git a/src/syscall/mem.c b/src/syscall/mem.c index e7cbc936..b79b28c4 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -1193,7 +1193,10 @@ static int64_t sys_mmap_high_va(guest_t *g, * to concurrent readers until the region tables commit (or the fail * path restores the bytes). */ - thread_quiesce_siblings(); + if (!thread_quiesce_siblings()) { + ret = -LINUX_EINTR; /* Being reaped; abandon the overlay */ + goto fail; + } siblings_quiesced = true; memcpy(replaced_bytes_snap, map_host, length); } @@ -2341,7 +2344,8 @@ static int hvf_apply_file_overlay(guest_t *g, { if (!overlay_fd_writable(fd)) return -LINUX_EACCES; - thread_quiesce_siblings(); + if (!thread_quiesce_siblings()) + return -LINUX_EINTR; /* Being reaped; abandon the overlay */ int err = hvf_apply_file_overlay_quiesced(g, ipa, len, fd, file_off); thread_resume_siblings(); return err; @@ -2399,7 +2403,8 @@ static int hvf_remove_file_overlay_quiesced(guest_t *g, */ static int hvf_remove_file_overlay(guest_t *g, uint64_t ipa, uint64_t len) { - thread_quiesce_siblings(); + if (!thread_quiesce_siblings()) + return -LINUX_EINTR; /* Being reaped; abandon the overlay */ int err = hvf_remove_file_overlay_quiesced(g, ipa, len); thread_resume_siblings(); return err; diff --git a/src/syscall/poll.c b/src/syscall/poll.c index 031f6e7e..cab9c47c 100644 --- a/src/syscall/poll.c +++ b/src/syscall/poll.c @@ -28,6 +28,7 @@ #include "debug/log.h" #include "runtime/futex.h" +#include "runtime/thread.h" /* thread_stop_requested */ #include "syscall/linux-wire.h" #include "syscall/internal.h" @@ -59,6 +60,36 @@ typedef struct { host_fd_ref_t ref; } pselect_req_t; +/* Longest a host wait may run before its caller re-checks the interrupt + * conditions. A wait armed with the guest's whole timeout does not watch the + * wakeup pipe and cannot be woken by thread_wake_all_blocked, so a teardown or + * an execve handoff would wait out the guest's timeout instead of the slice. + */ +#define POLL_WAKE_SLICE_MS 200 + +static int64_t poll_now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t) ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +/* Milliseconds left of a finite wait, never negative. deadline_ms is -1 for a + * wait with no deadline, which reports the full slice every time. + */ +static int poll_slice_ms(int64_t deadline_ms) +{ + if (deadline_ms < 0) + return POLL_WAKE_SLICE_MS; + + int64_t remaining = deadline_ms - poll_now_ms(); + if (remaining <= 0) + return 0; + return remaining < POLL_WAKE_SLICE_MS ? (int) remaining + : POLL_WAKE_SLICE_MS; +} + + static inline void host_fd_refs_close(host_fd_ref_t *refs, uint32_t n) { for (uint32_t i = 0; i < n; i++) @@ -181,14 +212,18 @@ int64_t sys_ppoll(guest_t *g, mask_installed = true; } - /* For indefinite polls, add the wakeup pipe so exit_group/futex/signal - * requests can interrupt threads blocked in host poll(). Without this, - * host-blocked threads cannot be interrupted by hv_vcpus_exit() because - * they're not in hv_vcpu_run(). + /* Add the wakeup pipe so exit_group/futex/signal requests can interrupt a + * thread blocked in host poll(). Without this, host-blocked threads cannot + * be interrupted by hv_vcpus_exit() because they're not in hv_vcpu_run(). + * + * Every wait gets it, not just an indefinite one: a finite wait that only + * watched the guest's own fds would sit out its whole timeout before + * noticing a teardown, and an execve de_thread waiting on that thread + * counts it as one that would not leave. */ bool added_wakeup = false; int wake_fd = wakeup_pipe_read_fd(); - if (timeout_ms < 0 && wake_fd >= 0 && nfds < 256) { + if (wake_fd >= 0 && nfds < 256) { host_fds[nfds].fd = wake_fd; host_fds[nfds].events = POLLIN; host_fds[nfds].revents = 0; @@ -204,25 +239,31 @@ int64_t sys_ppoll(guest_t *g, if (invalid_count > 0) poll_timeout_ms = 0; + /* A finite wait runs to this deadline in slices; an unbounded one has none + * and re-arms forever. A zero timeout is a poll, not a wait, and keeps its + * single non-blocking call. + */ + int64_t deadline_ms = + poll_timeout_ms > 0 ? poll_now_ms() + poll_timeout_ms : -1; + int ret; ppoll_retry: do { - ret = poll(host_fds, nfds + added_wakeup, - poll_timeout_ms < 0 ? 200 : poll_timeout_ms); + int slice = poll_timeout_ms == 0 ? 0 : poll_slice_ms(deadline_ms); + ret = poll(host_fds, nfds + added_wakeup, slice); /* Check for process/thread interrupts after waking. */ - if (proc_exit_group_requested() || futex_interrupt_consume() || + if (thread_stop_requested() || futex_interrupt_consume() || signal_pending_interruption(NULL)) { ret = -1; errno = EINTR; break; } - /* If poll emulation used a short timeout (200ms) on an infinite poll - * and nothing happened, loop back. If the caller had a real timeout, - * poll emulation only called poll once with that timeout, so break. An - * infinite poll re-arms on a 200ms slice; break out when a master has - * hung up, since the host will never make that fd ready. + /* Nothing happened within the slice, so re-arm: an indefinite wait + * forever, a finite one until its deadline. Only a zero timeout, which + * is a poll rather than a wait, gets a single call. Break out when a + * master has hung up, since the host will never make that fd ready. */ if (ret == 0) { bool hup_pending = false; @@ -233,7 +274,8 @@ int64_t sys_ppoll(guest_t *g, if (hup_pending) break; } - } while (ret == 0 && poll_timeout_ms < 0); + } while (ret == 0 && poll_timeout_ms != 0 && + (deadline_ms < 0 || poll_slice_ms(deadline_ms) > 0)); /* POSIX poll() ignores entries with fd < 0 and resets revents to 0, so * re-stamp POLLNVAL on the invalid slots and credit them to the return @@ -272,7 +314,8 @@ int64_t sys_ppoll(guest_t *g, wakeup_pipe_drain(); if (ret > 0) ret--; - if (ret == 0 && poll_timeout_ms < 0) + if (ret == 0 && poll_timeout_ms != 0 && + (deadline_ms < 0 || poll_slice_ms(deadline_ms) > 0)) goto ppoll_retry; } @@ -497,6 +540,7 @@ int64_t sys_pselect6(guest_t *g, * requests can interrupt. */ bool added_wakeup = false; + /* One read of the pipe fd for the whole call: the FD_SET here and the * FD_ISSET/FD_CLR after the wait must name the same descriptor. */ @@ -595,7 +639,7 @@ int64_t sys_pselect6(guest_t *g, has_timeout ? &ts : &poll_ts, NULL); } - if (proc_exit_group_requested() || futex_interrupt_consume() || + if (thread_stop_requested() || futex_interrupt_consume() || signal_pending_interruption(NULL)) { ret = -1; errno = EINTR; @@ -1432,7 +1476,7 @@ int64_t sys_epoll_pwait(guest_t *g, * exit_group still wins outright: the process is going away and there * is nothing to deliver events to. */ - bool interrupted = proc_exit_group_requested(); + bool interrupted = thread_stop_requested(); if (!interrupted && nready <= 0) interrupted = futex_interrupt_consume() || signal_pending_interruption(NULL); diff --git a/src/syscall/proc.c b/src/syscall/proc.c index 48ef92e6..281c0227 100644 --- a/src/syscall/proc.c +++ b/src/syscall/proc.c @@ -30,7 +30,6 @@ #include #include #include /* struct rusage, for wait4 rusage population */ -#include #include #include "debug/log.h" @@ -41,8 +40,11 @@ #include "core/vdso.h" #include "runtime/futex.h" +#include "runtime/thread.h" #include "syscall/abi.h" +#include "syscall/exec.h" +#include "syscall/io.h" /* io_retry_backoff */ #include "syscall/linux-wire.h" #include "syscall/internal.h" #include "syscall/net.h" @@ -2161,6 +2163,7 @@ static bool proc_wait_selector_matches(const proc_entry_t *entry, static int64_t proc_wait_autoreap_children(int pid, int options) { int64_t caller_pgid = proc_get_pgid(); + unsigned backoff = 0; for (;;) { bool found = false; bool still_active = false; @@ -2235,9 +2238,10 @@ static int64_t proc_wait_autoreap_children(int pid, int options) return -LINUX_ECHILD; if (options & 1) /* WNOHANG */ return 0; - if (proc_exit_group_requested()) - return -LINUX_EINTR; - usleep(1000); + + int64_t wait_rc = io_retry_backoff(&backoff); + if (wait_rc < 0) + return wait_rc; } } @@ -2442,7 +2446,7 @@ int64_t sys_wait4(guest_t *g, * wait quanta. The errno is never guest-visible: the run loop * breaks on the exit-group flag before returning to the guest. */ - if (proc_exit_group_requested()) { + if (thread_stop_requested()) { pthread_mutex_unlock(&pid_lock); return -LINUX_EINTR; } @@ -2491,7 +2495,7 @@ int64_t sys_wait4(guest_t *g, return sys_wait4(g, pid, status_gva, options, rusage_gva); if (mac_options & WNOHANG) return 0; - if (proc_exit_group_requested()) + if (thread_stop_requested()) return -LINUX_EINTR; usleep(1000); return sys_wait4(g, pid, status_gva, options, rusage_gva); @@ -2520,7 +2524,7 @@ int64_t sys_wait4(guest_t *g, ret = wait4(host_pid, &status, mac_options | WNOHANG, &ru); if (ret != 0) break; - if (proc_exit_group_requested()) + if (thread_stop_requested()) return -LINUX_EINTR; struct timespec ts; timespec_deadline_in_ms(&ts, 100); @@ -3612,6 +3616,32 @@ int vcpu_run_loop_with_hooks(hv_vcpu_t vcpu, exit_code = proc_exit_group_code(); break; } + + /* An execve on a sibling thread is tearing this one down. Leaving the + * loop takes it through the normal worker exit path (robust list, + * CLEARTID, own-vCPU destroy) against the still-intact old image; the + * exec'ing thread waits for that before guest_reset. The main thread is + * exempt because it owns process teardown: returning from its run loop + * destroys the guest, which is exactly what the exec'ing thread still + * needs. This gates re-entry into hv_vcpu_run, so one check per + * iteration is enough; every path back here is non-blocking for a + * stopping worker, whose waits now return EINTR. + */ + if (!is_main && thread_exec_stop_requested()) { + exit_code = 0; + break; + } + + /* A non-leader execve is handed here: the leader owns process teardown, + * so it is the only thread that can survive one. Running it at the top + * of the loop puts the rebuilt EL0 state in place before the vCPU is + * resumed, whether this thread was preempted in guest code or is + * returning from its own syscall (sys_execve sets the X8=2 frame-drop + * marker either way). + */ + if (thread_current_is_leader() && thread_leader_work_pending()) + exec_run_handoff(vcpu, g, verbose); + if (hooks && hooks->tick) { int tick_ret = hooks->tick(g, hooks->opaque); if (tick_ret != 0) { @@ -4214,6 +4244,17 @@ int vcpu_run_loop_with_hooks(hv_vcpu_t vcpu, break; } + /* An execve tearing this thread down wins over everything below: + * the GDB stop parks on an unbounded condvar with no teardown + * predicate, and the ptrace stop and signal delivery both touch + * guest memory the exec is about to reset. Reaching any of them + * here would outlive the join cap in thread_exec_de_thread. + */ + if (!is_main && thread_exec_stop_requested()) { + exit_code = 0; + break; + } + /* GDB stub: if GDB requested a stop (Ctrl+C or another thread hit a * breakpoint), enter GDB stop state. */ diff --git a/src/syscall/signal.c b/src/syscall/signal.c index 3714b50e..5a21aa8d 100644 --- a/src/syscall/signal.c +++ b/src/syscall/signal.c @@ -1191,11 +1191,28 @@ static int check_one_timer(guest_itimer_t *timer, const struct timeval *now) return 1; /* expired */ } -void signal_check_timer(void) +/* cpu_timers selects whether ITIMER_VIRTUAL and ITIMER_PROF are advanced along + * with ITIMER_REAL. All three are measured against the monotonic clock here, + * which is an approximation for the two that Linux charges to CPU time, and it + * only holds where the guest was actually running. A caller parked in a host + * wait burns no guest CPU, so advancing them there would expire a virtual timer + * out of wall clock the guest never spent. + * + * This narrows that error, it does not remove it. The expiry stays an absolute + * monotonic instant, so a wait that spans it still leaves it expired, and the + * next full check delivers SIGVTALRM or SIGPROF for time the guest did not + * execute. Skipping the check inside the wait only stops the signal landing + * while the thread is parked. Removing the error needs guest CPU-time + * accounting, so that a wait can hold both timers rather than merely decline to + * read them, and elfuse tracks no such clock today. ITIMER_REAL is exact either + * way, because wall clock is what it counts. + */ +static void signal_check_timers(bool cpu_timers) { if (!__atomic_load_n(&guest_itimer.active, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&guest_itimer_virt.active, __ATOMIC_ACQUIRE) && - !__atomic_load_n(&guest_itimer_prof.active, __ATOMIC_ACQUIRE)) + (!cpu_timers || + (!__atomic_load_n(&guest_itimer_virt.active, __ATOMIC_ACQUIRE) && + !__atomic_load_n(&guest_itimer_prof.active, __ATOMIC_ACQUIRE)))) return; struct timeval now = monotonic_now(); @@ -1204,10 +1221,12 @@ void signal_check_timer(void) pthread_mutex_lock(&sig_lock); if (check_one_timer(&guest_itimer, &now)) sig_real = LINUX_SIGALRM; - if (check_one_timer(&guest_itimer_virt, &now)) - sig_virt = 26; /* SIGVTALRM */ - if (check_one_timer(&guest_itimer_prof, &now)) - sig_prof = 27; /* SIGPROF */ + if (cpu_timers) { + if (check_one_timer(&guest_itimer_virt, &now)) + sig_virt = 26; /* SIGVTALRM */ + if (check_one_timer(&guest_itimer_prof, &now)) + sig_prof = 27; /* SIGPROF */ + } pthread_mutex_unlock(&sig_lock); if (sig_real) @@ -1218,6 +1237,17 @@ void signal_check_timer(void) signal_queue(sig_prof); } +void signal_check_timer(void) +{ + signal_check_timers(true); +} + +/* For a caller about to block, or already looping in a retry wait. */ +void signal_check_timer_real(void) +{ + signal_check_timers(false); +} + /* Set/get ITIMER_VIRTUAL (which=1) or ITIMER_PROF (which=2) */ void signal_set_itimer_virt(int which, const struct timeval *value, @@ -1530,7 +1560,7 @@ int64_t signal_rt_sigsuspend(guest_t *g, uint64_t mask_gva, uint64_t sigsetsize) * runs on the way back out. */ bool woke = false; - while (!proc_exit_group_requested()) { + while (!thread_stop_requested()) { /* Drain any expired guest itimer so its SIGALRM / SIGVTALRM / * SIGPROF queues into the pending set. Nothing else advances the * timers while this thread is parked here, and sigsuspend() waiting @@ -1738,8 +1768,8 @@ int64_t signal_rt_sigtimedwait(guest_t *g, if (has_timeout && remaining_ns <= 0) return -LINUX_EAGAIN; - /* Exit if the process is tearing down. */ - if (proc_exit_group_requested()) + /* Exit if the process, or just this thread, is tearing down. */ + if (thread_stop_requested()) return -LINUX_EINTR; /* If a non-waited, guest-visible signal is pending, return -EINTR. diff --git a/src/syscall/signal.h b/src/syscall/signal.h index cbd1f064..5520256f 100644 --- a/src/syscall/signal.h +++ b/src/syscall/signal.h @@ -546,3 +546,9 @@ void signal_get_itimer_virt(int which, * the vCPU loop after each syscall. */ void signal_check_timer(void); + +/* ITIMER_REAL only. A blocking wait must not advance ITIMER_VIRTUAL or + * ITIMER_PROF: those are charged to guest CPU time, and a thread parked in a + * host call spends none. + */ +void signal_check_timer_real(void); diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index c8f027fe..a0e86d7e 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -1596,7 +1596,31 @@ static int64_t sc_flock(guest_t *g, int64_t err = host_fd_ref_open_io((int) x0, &host_ref); if (err < 0) return err; - int64_t ret = flock(host_ref.fd, (int) x1) < 0 ? linux_errno() : 0; + + /* A blocking flock parks the vCPU thread where no teardown wake reaches it, + * so poll LOCK_NB instead. An explicit LOCK_NB, and LOCK_UN which never + * blocks, go straight through. + */ + int op = (int) x1; + int64_t ret; + if ((op & LOCK_NB) || (op & ~LOCK_NB) == LOCK_UN) { + ret = flock(host_ref.fd, op) < 0 ? linux_errno() : 0; + } else { + unsigned backoff = 0; + for (;;) { + if (flock(host_ref.fd, op | LOCK_NB) == 0) { + ret = 0; + break; + } + if (errno != EWOULDBLOCK) { + ret = linux_errno(); + break; + } + ret = io_retry_backoff(&backoff); + if (ret < 0) + break; + } + } host_fd_ref_close(&host_ref); return ret; } diff --git a/src/syscall/sysvipc.c b/src/syscall/sysvipc.c index a9702f79..503b2510 100644 --- a/src/syscall/sysvipc.c +++ b/src/syscall/sysvipc.c @@ -26,6 +26,7 @@ #include "syscall/sysvipc.h" #include "syscall/linux-wire.h" #include "syscall/internal.h" +#include "syscall/io.h" /* io_retry_backoff */ #include "syscall/mem.h" /* Linux SysV IPC constants. */ @@ -405,6 +406,101 @@ int64_t sys_semget(guest_t *g, int32_t key, int nsems, int semflg) return id; } +/* Largest set this can walk; semctl GETALL fills a caller array. */ +#define SEMOP_MAX_SEMS 1024 + +/* Outcomes of the walk below that are not an operation index. */ +#define SEMOP_BLOCKER_NONE (-1) /* Every operation could proceed */ +#define SEMOP_BLOCKER_UNKNOWN (-2) /* The values could not be read */ + +/* Semaphores in the set, or SEMOP_BLOCKER_UNKNOWN. + * + * Split from the walk because a set cannot be resized, so this is read once per + * semop rather than once per retry: the walk runs again on every pass of the + * polling loop, and re-asking the kernel for a constant on each one is a + * syscall spent to learn nothing. + */ +static int semop_read_nsems(int semid) +{ + struct semid_ds info; + if (semctl(semid, 0, IPC_STAT, &info) < 0) + return SEMOP_BLOCKER_UNKNOWN; + + int nsems = (int) info.sem_nsems; + if (nsems <= 0 || nsems > SEMOP_MAX_SEMS) + return SEMOP_BLOCKER_UNKNOWN; + return nsems; +} + +/* Index of the operation that cannot proceed against the current values. + * + * This repeats the walk the kernel makes before it decides to block: a positive + * operation always proceeds, a zero operation needs the value already at zero, + * and a negative one needs enough units to take. Linux answers EAGAIN when the + * operation it stopped on carries IPC_NOWAIT, and blocks otherwise, so which + * one stopped it is the whole question. + * + * Returns SEMOP_BLOCKER_NONE when nothing blocked, and SEMOP_BLOCKER_UNKNOWN + * when the values could not be read, which a set granting alter but not read + * permission does while its operations still apply. + * + * The caller keeps waiting on either, rather than answering EAGAIN from a + * result it cannot back up. That trade has a cost worth naming: a mixed set on + * a set it may alter but not read never gets the refusal Linux would have given + * it, and waits instead. Answering EAGAIN there would be inventing a refusal in + * every other case the probe cannot read, which is the more common one. + * + * nsems is resolved once per semop by the caller, since a set cannot be + * resized. The values are re-read on every pass, because those are what move. + * + * They can move between this read and the failed semop it explains, so the + * operation named here can differ from the one the host actually stopped on. + * Usually that costs a retry: applying the set is still one atomic host call, + * and the next pass re-reads. The case it gets wrong is a set whose true + * blocker carries IPC_NOWAIT while a concurrent change makes a blocking + * operation look like the blocker, where the caller waits and Linux would have + * answered EAGAIN. It resolves as soon as one pass reads a consistent set. + */ +static int semop_first_blocker(int semid, + const struct sembuf *sops, + unsigned nsops, + int nsems) +{ + unsigned short vals[SEMOP_MAX_SEMS]; + if (nsems <= 0 || semctl(semid, 0, GETALL, vals) < 0) + return SEMOP_BLOCKER_UNKNOWN; + + /* Widened because a positive operation may carry the running value past + * what a semaphore can hold, and the walk only needs the comparisons to + * stay honest until it reaches the operation that stops it. + */ + int cur[SEMOP_MAX_SEMS]; + for (int i = 0; i < nsems; i++) + cur[i] = (int) vals[i]; + + for (unsigned i = 0; i < nsops; i++) { + int num = sops[i].sem_num; + if (num < 0 || num >= nsems) { + /* Out of range: the host semop owns that error */ + return SEMOP_BLOCKER_UNKNOWN; + } + + int op = sops[i].sem_op; + if (op > 0) { + cur[num] += op; + } else if (op == 0) { + if (cur[num] != 0) + return (int) i; + } else { + if (cur[num] < -op) + return (int) i; + cur[num] += op; + } + } + + return SEMOP_BLOCKER_NONE; +} + int64_t sys_semop(guest_t *g, int semid, uint64_t sops_gva, unsigned nsops) { if (nsops == 0 || nsops > 256) @@ -417,10 +513,49 @@ int64_t sys_semop(guest_t *g, int semid, uint64_t sops_gva, unsigned nsops) if (guest_read(g, sops_gva, sops, len) < 0) return -LINUX_EFAULT; - if (semop(semid, sops, nsops) < 0) - return linux_errno(); + /* Every set polls, mixed or not. A blocking semop parks the vCPU thread in + * a host call no teardown wake reaches, so nothing here may enter one, and + * a set that mixes IPC_NOWAIT with blocking operations would otherwise have + * to. The copy carries the flag on every operation so the host still + * applies the set atomically or not at all; sops keeps the guest's own + * flags, which is what decides whether a refusal is owed to the caller now. + */ + struct sembuf poll_sops[256]; + bool any_nowait = false; + for (unsigned i = 0; i < nsops; i++) { + poll_sops[i] = sops[i]; + poll_sops[i].sem_flg |= IPC_NOWAIT; + if (sops[i].sem_flg & IPC_NOWAIT) + any_nowait = true; + } - return 0; + /* Resolved on the first probe, which only a set carrying IPC_NOWAIT ever + * makes, so an all-blocking set pays nothing for it. + */ + int nsems = 0; + + unsigned backoff = 0; + for (;;) { + if (semop(semid, poll_sops, nsops) == 0) + return 0; + if (errno != EAGAIN) + return linux_errno(); + + /* The set did not apply. Linux owes EAGAIN only when the operation that + * stopped it is itself IPC_NOWAIT; when a blocking one stopped it the + * caller waits, whatever flags the rest of the set carries. + */ + if (any_nowait) { + if (nsems == 0) + nsems = semop_read_nsems(semid); + int blocker = semop_first_blocker(semid, sops, nsops, nsems); + if (blocker >= 0 && (sops[blocker].sem_flg & IPC_NOWAIT)) + return -LINUX_EAGAIN; + } + int64_t wait_rc = io_retry_backoff(&backoff); + if (wait_rc < 0) + return wait_rc; + } } int64_t sys_semctl(guest_t *g, int semid, int semnum, int cmd, uint64_t arg) diff --git a/src/syscall/time.c b/src/syscall/time.c index ffc471fe..51fe1201 100644 --- a/src/syscall/time.c +++ b/src/syscall/time.c @@ -116,7 +116,7 @@ static int64_t interruptible_sleep_ns(guest_t *g, bool write_rem) { while (remaining_ns > 0) { - if (proc_exit_group_requested() || signal_pending()) { + if (thread_stop_requested() || signal_pending()) { if (write_rem && write_remaining_sleep(g, rem_gva, remaining_ns) < 0) return -LINUX_EFAULT; diff --git a/tests/manifest.txt b/tests/manifest.txt index 68bfd2f3..a75089fb 100644 --- a/tests/manifest.txt +++ b/tests/manifest.txt @@ -84,6 +84,8 @@ test-dev-shm-paths test-thread # diff=skip test-pthread test-thread-churn +test-threaded-exec +test-threaded-exec worker test-simd-clone # diff=skip [section] Stress tests diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index f635b013..c1b5576f 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash + # Run aarch64 test suites under both elfuse and self-contained QEMU. # # Copyright 2026 elfuse contributors @@ -23,6 +24,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" FIXTURES="${REPO_ROOT}/externals/test-fixtures" + # Allow tests to point the translator probe at a missing path to exercise the # non-Rosetta-host skip path without uninstalling the translator. : "${MATRIX_ROSETTA_TRANSLATOR:=/Library/Apple/usr/libexec/oah/RosettaLinux/rosetta}" @@ -240,6 +242,7 @@ stage_sysroot_fixtures() local guest_rel="/tmp/matrix-coreutils.$$" local host_dir="${sysroot}${guest_rel}" mkdir -p "$host_dir" + # Copy the fixtures setup_fixtures already authored rather than re-creating # their content here, so the two stay in sync from one definition. cp "$TEST_TMPDIR/hello.txt" "$TEST_TMPDIR/unsorted.txt" \ @@ -261,23 +264,23 @@ unstage_sysroot_fixtures() # Generic test helpers. -# The qemu reference lane runs the portable matrix tests against the real -# Alpine linux-virt kernel. Add a test's name here only if it asserts -# elfuse-specific behavior a real kernel does not honor; it still runs in -# elfuse-aarch64 mode and in 'make check'. +# The qemu reference lane runs the portable matrix tests against the real Alpine +# linux-virt kernel. Add a test's name here only if it asserts elfuse-specific +# behavior a real kernel does not honor; it still runs in elfuse-aarch64 mode +# and in 'make check'. # # The two oom_adj/oom_score_adj sendfile-and-copy_file_range-interception # subtests that used to make test-io-opt diverge here were split out into -# tests/test-oom-proc.c (make check only, not part of this matrix at all) -- -# see that file's header comment. test-io-opt itself is now pure portable -# sendfile/fsync/fallocate/copy_file_range coverage and runs against qemu -# like any other test. +# tests/test-oom-proc.c (make check only, not part of this matrix at all) -- see +# that file's header comment. test-io-opt itself is now pure portable +# sendfile/fsync/fallocate/copy_file_range coverage and runs against qemu like +# any other test. # # The entries below were added when run_unit_tests grew to cover the rest of # tests/manifest.txt ("make check"). Each was verified against a live # qemu-aarch64 boot before being listed here -- see the per-entry comment for -# the observed divergence. Do not add a test here just because it *might* -# behave differently; confirm it first the same way. +# the observed divergence. Do not add a test here just because it *might* behave +# differently; confirm it first the same way. QEMU_SKIP=" test-session test-pidfd @@ -307,6 +310,7 @@ QEMU_SKIP=" test-proc-fidelity test-proc-smap " + # test-session: getpgid/getsid/setsid assume the test is its own session and # process-group leader, true when elfuse launches it directly but not when # sshd execs it non-interactively -- a launcher artifact, not an elfuse @@ -493,6 +497,7 @@ run_summary_suite() if [ -n "$fields" ]; then local suite_pass=0 suite_fail=0 suite_skip=0 suite_total=0 read -r suite_pass suite_fail suite_skip suite_total <<< "$fields" + # Force decimal: a sub-suite that ever emits a zero-padded count ('08', # '09') would otherwise trip bash's "invalid octal" error inside # $((...)) and abort the matrix under 'set -e'. @@ -542,6 +547,7 @@ test_check() report_timeout "$label" return fi + # Require a clean exit before trusting the regex. A crashing tool can still # emit the expected substring on stdout before dying, and the earlier "regex # match alone passes" behavior would have reported that as OK -- the same @@ -611,6 +617,7 @@ test_pipe() report_timeout "$label" return fi + # See test_check for the rc=0 precondition rationale: a non-zero exit must # surface as FAIL even when the regex matches, otherwise a crashing pipeline # that happens to print the expected substring would be reported OK. @@ -638,10 +645,10 @@ test_pipe() # elfuse's guest-IPA infra reserve, and test-oom-proc, documented in its own # header). test-mremap-tail-emfile is listed here as an elfuse-lane regression # and marked QEMU_SKIP because its host-reserve assertion has no Linux analogue. -# There is no "core" vs "extended" split here; everything below runs -# in both elfuse-aarch64 and qemu-aarch64 modes, and genuine, understood -# divergences from the qemu reference kernel are called out via QEMU_SKIP with -# a comment rather than silently dropped from this list. +# There is no "core" vs "extended" split here; everything below runs in both +# elfuse-aarch64 and qemu-aarch64 modes, and genuine, understood divergences +# from the qemu reference kernel are called out via QEMU_SKIP with a comment +# rather than silently dropped from this list. run_unit_tests() { local runner="$1" bindir="$2" @@ -751,6 +758,9 @@ run_unit_tests() test_check "$runner" "test-simd-clone" "0 failed" "$bindir/test-simd-clone" test_check "$runner" "test-stress" "0 failed" "$bindir/test-stress" test_rc "$runner" "test-thread-churn" 0 "$bindir/test-thread-churn" + test_rc "$runner" "test-threaded-exec" 0 "$bindir/test-threaded-exec" + test_rc "$runner" "test-threaded-exec-worker" 0 \ + "$bindir/test-threaded-exec" worker test_rc "$runner" "test-mprotect-mt" 0 "$bindir/test-mprotect-mt" printf "\nNegative tests\n" @@ -837,6 +847,7 @@ run_unit_tests() printf "\nCredential/identity emulation\n" test_rc "$runner" "test-credentials" 0 "$bindir/test-credentials" test_rc "$runner" "test-credentials-fakeroot" 0 --fakeroot "$bindir/test-credentials" + # Arm the opt-in transition on the test binary itself: it re-execs its own # path to cross into fakeroot, and a copy of itself to prove the negative. # The assignment prefix scopes the variable to this one call, so an @@ -926,6 +937,7 @@ run_unit_tests() run_coreutils_tests() { local runner="$1" bindir="$2" + # Exec-child targets (env/nice/... run "/true") must resolve inside the # guest. Native/static runs address them by the same host path they launch # from; a --sysroot run overrides this with the binary's guest path via @@ -975,6 +987,7 @@ run_coreutils_tests() test_rc "$runner" "timeout" 0 "$bindir/timeout" 5 "$guest_bindir/true" printf "\nCoreutils encoding%s\n" "$_COREUTILS_SUFFIX" + # The if/then form contains require_binary's exit status so missing binaries # do not propagate as a function-exit-1 under 'set -e'. The earlier '&& # test_check' chain failed the matrix script outright whenever the LAST @@ -1109,6 +1122,7 @@ run_static_tests() test_check "$runner" "bash echo" "hello" "$bindir/bash" -c "echo hello" test_pipe "$runner" "bash subshell" "sub=25" "" "$bindir/bash" -c 'echo "sub=$(echo $((5*5)))"' fi + # lua has two acceptable names; prefer 5.4, then fall back to plain lua, and # skip with accounting if neither is present. if [ -e "$bindir/lua5.4" ]; then @@ -1214,6 +1228,7 @@ run_suite() run_busybox_tests "$runner" "$GUEST_BUSYBOX" if [ -d "$GUEST_STATIC_BINS" ]; then + # run_elfuse auto-adds --sysroot for binaries under GUEST_SYSROOT or the # dyn-bin dir (see its case), so tree/find/diff here run chrooted and # must read the fixtures from inside the sysroot, same as the dynamic @@ -1249,6 +1264,7 @@ run_suite() _COREUTILS_SUFFIX=" (musl dyn)" _SYSROOT="$GUEST_SYSROOT" if [ "$mode" = "elfuse-aarch64" ]; then + # dyn-bin binaries symlink to the rootfs /bin multiplexer, so # their guest path under --sysroot is /bin. _COREUTILS_GUEST_BINDIR="/bin" @@ -1433,6 +1449,7 @@ detect_x86_64_host_class() # now passes there (observed on the self-hosted runner and in local captures); # the qemu row in EXPECTED_BASELINES therefore pins exactly zero failures. KNOWN_FAILURES_QEMU_AARCH64="" + # elfuse-x86_64: rosetta limitations documented in the upstream hyper-linux # audit. test-signal-thread fails because rosetta shadows signal state # internally (SA_RESETHAND not reset); test-thread / test-stress hang on @@ -1451,6 +1468,7 @@ verify_expected_counts() local exp_min="" exp_fail="" if ! expected_baseline_get "$key" exp_min exp_fail; then + # No recorded baseline for this key (experimental local mode, or an # x86_64 host class the detector did not classify). Stay silent so the # matrix runner remains usable as a smoke probe. diff --git a/tests/test-teardown-live-vcpu-host.c b/tests/test-teardown-live-vcpu-host.c index 27d93e2b..ce74fd5b 100644 --- a/tests/test-teardown-live-vcpu-host.c +++ b/tests/test-teardown-live-vcpu-host.c @@ -31,8 +31,11 @@ #include "runtime/thread.h" -#include "debug/log.h" /* log_impl prototype */ -#include "syscall/proc.h" /* proc_exit_group_requested prototype */ +#include "debug/log.h" /* log_impl prototype */ +#include "runtime/futex.h" /* futex_interrupt_request prototype */ +#include "syscall/exec.h" /* exec_handoff_wake_waiters prototype */ +#include "syscall/proc.h" /* proc_exit_group_requested prototype */ +#include "syscall/wakeup-pipe.h" /* wakeup_pipe_signal prototype */ /* thread.c externs not exercised by the code paths under test. */ int proc_exit_group_requested(void) @@ -40,6 +43,12 @@ int proc_exit_group_requested(void) return 0; } +void futex_interrupt_request(void) {} + +void wakeup_pipe_signal(void) {} + +void exec_handoff_wake_waiters(void) {} + void signal_refresh_pending_hint(void) {} void log_impl(int level, const char *file, int line, const char *fmt, ...) diff --git a/tests/test-threaded-exec.c b/tests/test-threaded-exec.c new file mode 100644 index 00000000..39ee8aa2 --- /dev/null +++ b/tests/test-threaded-exec.c @@ -0,0 +1,254 @@ +/* + * Threaded execve stress: exec while sibling threads are still live + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Linux de_thread()s on execve: every sibling thread is destroyed and the + * calling thread takes over the group leader's tid, so the new image always + * starts single-threaded with gettid() == getpid(). elfuse does the same in + * thread_exec_de_thread(), and this is what holds it to that: without the + * teardown, guest_reset zeroes memory that sibling vCPU threads are still + * parked in host syscalls on, or still executing. + * + * The process execs itself TOTAL_EXECS times, spawning WORKERS siblings before + * each exec (half parked in a blocking read(), half in a compute loop), and + * checks the three facts Linux guarantees in each new image, failing fast if + * any is wrong. Chained exec rather than fork-per-iteration: it keeps one guest + * for the whole run, so a sibling teardown bug has nowhere to hide behind a + * fresh VM. + * + * Two modes, both in make check. "main" (the default) execs from the main + * thread. "worker" execs from a sibling, which elfuse cannot satisfy directly + * (it cannot destroy the main host thread, whose run loop returning is what + * tears the process down) so it hands the syscall to the leader, which runs it + * on its own vCPU. Both modes assert the same three facts, which is the point: + * the guest cannot tell which thread called execve. + * + * Syscalls exercised: execve(221), clone(220), read(63), pipe2(59), + * gettid(178), getpid(172) + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +#define WORKERS 8 +#define TOTAL_EXECS 200 + +static atomic_int ready; +static atomic_int exec_now; +static int pipe_rd = -1; +static const char *self_path; +static const char *g_mode = "main"; +static int g_iter; + +static void exec_next(void) +{ + /* execve replaces the image, stdio buffer included, and the harness + * captures stdout through a pipe, so it is block-buffered. Anything this + * image printed is discarded unless it is flushed first. + */ + fflush(stdout); + fflush(stderr); + + char iterbuf[16], pidbuf[16]; + snprintf(iterbuf, sizeof(iterbuf), "%d", g_iter + 1); + snprintf(pidbuf, sizeof(pidbuf), "%d", (int) getpid()); + + extern char **environ; + char *argv[] = {(char *) self_path, (char *) g_mode, iterbuf, pidbuf, NULL}; + execve(self_path, argv, environ); + + fprintf(stderr, "\ntest-threaded-exec: execve(%s) failed at iter %d (%s)\n", + self_path, g_iter, strerror(errno)); + _exit(1); +} + +/* Nothing is ever written to the pipe, so this parks in a host syscall that + * hv_vcpus_exit cannot interrupt, the case an exec-time teardown has to reach + * through the wakeup pipe instead. EINTR is a retry: only the read end closing + * (at exec) ends the loop. + */ +static void park_on_pipe(void) +{ + char c; + for (;;) { + ssize_t n = read(pipe_rd, &c, 1); + if (n == 0 || (n < 0 && errno != EINTR)) + return; + } +} + +static void *blocking_worker(void *arg) +{ + (void) arg; + atomic_fetch_add(&ready, 1); + park_on_pipe(); + return NULL; +} + +/* The other half stay in guest code. One of them is the designated exec'ing + * thread in worker mode. + */ +static void *compute_worker(void *arg) +{ + long exec_here = (long) arg; + volatile unsigned long acc = 0; + + atomic_fetch_add(&ready, 1); + for (;;) { + for (int i = 0; i < 20000; i++) + acc += (unsigned long) i; + + /* Touch the memory syscalls too. A sibling parked on the host mutex + * that serializes them is reachable by none of the teardown wakes, so a + * teardown that runs while holding it can never finish. Workers that + * only compute and read cannot show that. + */ + void *p = mmap(NULL, 64 * 1024, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (p != MAP_FAILED) + munmap(p, 64 * 1024); + + if (exec_here && atomic_load(&exec_now)) + exec_next(); + } +} + +static int read_thread_count(void) +{ + FILE *f = fopen("/proc/self/status", "r"); + if (!f) + return -1; + + char line[256]; + int n = -1; + while (fgets(line, sizeof(line), f)) { + if (sscanf(line, "Threads: %d", &n) == 1) + break; + } + fclose(f); + return n; +} + +/* The three facts Linux guarantees in the post-execve image. Fail fast: a + * per-iteration PASS line would print 200 times. + */ +static void check_post_exec(int iter, int want_pid) +{ + int nthreads = read_thread_count(); + if (nthreads != 1) { + printf("\niter %d: /proc/self/status Threads: %d, want 1\n", iter, + nthreads); + fails++; + } + + int pid = (int) getpid(); + if (pid != want_pid) { + printf("\niter %d: pid %d after exec, want %d\n", iter, pid, want_pid); + fails++; + } + + int tid = (int) syscall(SYS_gettid); + if (tid != pid) { + printf("\niter %d: gettid %d != getpid %d after exec\n", iter, tid, + pid); + fails++; + } +} + +static void spawn_workers(void) +{ + int fds[2]; + if (pipe2(fds, O_CLOEXEC) != 0) { + FAIL("pipe2 failed"); + exit(1); + } + + /* fds[1] is deliberately left open and never written: the readers block + * rather than seeing EOF. + */ + pipe_rd = fds[0]; + + for (int i = 0; i < WORKERS; i++) { + pthread_t t; + + /* Worker 1 is a compute thread and is the one that execs on odd + * iterations. + */ + int rc = (i % 2 == 0) ? pthread_create(&t, NULL, blocking_worker, NULL) + : pthread_create(&t, NULL, compute_worker, + (void *) (long) (i == 1)); + if (rc != 0) { + FAIL("pthread_create failed"); + exit(1); + } + pthread_detach(t); + } + + /* Yield rather than spin: the workers being waited on are competing for the + * same cores, WORKERS fresh vCPUs at a time. + */ + while (atomic_load(&ready) < WORKERS) + sched_yield(); +} + +int main(int argc, char **argv) +{ + self_path = argv[0]; + if (argc > 1) + g_mode = argv[1]; + int iter = argc > 2 ? atoi(argv[2]) : 0; + bool from_worker = strcmp(g_mode, "worker") == 0; + + if (iter == 0) { + printf( + "test-threaded-exec: %d execs from the %s thread, %d live " + "siblings each\n", + TOTAL_EXECS, from_worker ? "worker" : "main", WORKERS); + TEST("threaded execve chain"); + } else { + check_post_exec(iter, argc > 3 ? atoi(argv[3]) : 0); + if (fails > 0) + return 1; + } + + if (iter >= TOTAL_EXECS) { + PASS(); + SUMMARY("test-threaded-exec"); + return fails > 0 ? 1 : 0; + } + + g_iter = iter; + spawn_workers(); + + if (!from_worker) + exec_next(); + + /* Worker mode: park the main thread in a host syscall while the exec runs + * under it. + */ + atomic_store(&exec_now, 1); + park_on_pipe(); + + /* Only reachable if the designated worker never replaced this image: a + * successful exec never returns here, and the pipe's write end is held open + * so the park ends at EOF alone. Reporting success would hide exactly the + * failure this mode exists to catch. + */ + return 1; +}