Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions src/runtime/forkipc.c
Original file line number Diff line number Diff line change
Expand Up @@ -1142,15 +1142,12 @@ static void *thread_create_and_run(void *arg)
thread_deactivate(t);

/* When all CLONE_THREAD workers have exited and only the main thread
* remains, interrupt its futex_wait. In real Linux, child exit delivers
* SIGCHLD which interrupts futex_wait with -EINTR. elfuse simulates this
* through the futex interrupt API.
* remains, nudge it so anything parked on host state re-checks. No EINTR
* goes with it: clone(2) sends no signal for a CLONE_THREAD exit, so a
* sibling's wait is not interrupted and its timeout is what ends it.
*/
if (thread_active_count() == 1) {
log_debug(
"last worker exited, interrupting "
"main thread futex_wait/poll");
futex_interrupt_request();
log_debug("last worker exited, waking main thread");
wakeup_pipe_signal();
thread_interrupt_all();
}
Expand Down
18 changes: 11 additions & 7 deletions src/runtime/futex.c
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@
#define ELFUSE_HAVE_OS_SYNC_WAIT_ON_ADDRESS 0
#endif

/* Interrupt flag: when set, futex_wait returns -EINTR. Used to simulate SIGCHLD
* delivery when all CLONE_THREAD workers exit: wakes the main thread from
* blocking futex_wait without triggering a full exit_group.
/* Interrupt flag: when set, futex_wait returns -EINTR. Raised only by teardown
* through thread_wake_all_blocked, so every blocked wait can observe that the
* process is tearing down without a full exit_group.
*/
static _Atomic int futex_interrupt_requested = 0;

Expand Down Expand Up @@ -583,13 +583,17 @@ void futex_interrupt_clear(void)
}

/* Test-and-clear: returns 1 if the interrupt request was pending and atomically
* clears it, 0 otherwise. The interrupt is a one-shot edge: forkipc.c sets it
* when the last clone-thread exits so the main thread observes EINTR in its
* next blocking wait, mirroring how real Linux delivers SIGCHLD. Without the
* clear, the flag stays set and every subsequent epoll_pwait, ppoll, futex
* clears it, 0 otherwise. The interrupt is a one-shot edge, set by the teardown
* paths through thread_wake_all_blocked. Teardown state marks every thread as
* leaving; the atomic interrupt itself is consumed by only one waiter. Without
* the clear, the flag stays set and every subsequent epoll_pwait, ppoll, futex
* wait, etc. spins on EINTR until execve clears it -- in foot's case it never
* does, and the spinning main thread eventually faults in a code path the guest
* never expects to reach.
*
* forkipc.c set it too, on the last clone-thread exit, for a SIGCHLD that
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
* clone(2) does not send. That is gone; only a process actually tearing down
* fabricates an EINTR now.
*/
int futex_interrupt_consume(void)
{
Expand Down
191 changes: 191 additions & 0 deletions tests/test-futex-ops.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/*
* A CLONE_THREAD worker's exit must not interrupt anybody
*
* Copyright 2026 elfuse contributors
* SPDX-License-Identifier: Apache-2.0
*
* clone(2): a thread created with CLONE_THREAD sends no signal to its parent
* when it terminates. Nothing is delivered, so a sibling parked in a blocking
* call stays parked and its timeout is what ends the wait.
*
* Each case parks for PARK_MS with nothing to wake it. A timeout answer means
* the exit went unnoticed, which is what Linux does; EINTR means the wait was
* cut short by something the guest was never sent.
*
* Syscalls exercised: futex(98), ppoll(73), epoll_pwait(22), clone(220),
* exit(93), clock_gettime(113)
*/

#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <linux/futex.h>

#include "test-harness.h"
#include "raw-syscall.h"
#include "test-util.h"

int passes = 0, fails = 0;

/* Long enough that an immediate return is unambiguous, short enough that four
* of them do not slow the lane.
*/
#define PARK_MS 300
#define PARK_NS (PARK_MS * 1000L * 1000L)

/* A wait cut short by the exit returns well inside the park; the futex path
* polls on a 100 ms quantum, so anything under half the park is early.
*/
#define EARLY_MS (PARK_MS / 2)

static int word;
static volatile int child_tid;
static char child_stack_buf[16384] __attribute__((aligned(16)));

struct k_timespec {
int64_t tv_sec;
int64_t tv_nsec;
};

static long now_ms(void)
{
struct k_timespec ts;
raw_syscall2(113, 1 /* CLOCK_MONOTONIC */, (long) &ts);
return (long) (ts.tv_sec * 1000 + ts.tv_nsec / 1000000);
}

static void msleep(long ms)
{
struct k_timespec ts = {0, ms * 1000L * 1000L};
raw_syscall4(101 /* nanosleep */, (long) &ts, 0, 0, 0);
}

/* Spawn a CLONE_THREAD worker that exits at once, and wait until it is gone.
* CLONE_PARENT_SETTID seeds child_tid so the CLEARTID store is an observable
* edge rather than a word that was already zero.
*/
static int spawn_and_reap_worker(void)
{
unsigned long flags = 0x00010000 /* CLONE_THREAD */
| 0x00000100 /* CLONE_VM */
| 0x00000200 /* CLONE_FS */
| 0x00000800 /* CLONE_SIGHAND */
| 0x00100000 /* CLONE_PARENT_SETTID */
| 0x00200000; /* CLONE_CHILD_CLEARTID */

long r = raw_syscall5(220, (long) flags,
(long) (child_stack_buf + sizeof(child_stack_buf)),
(long) &child_tid, 0, (long) &child_tid);
if (r == 0) {
raw_exit(0);
test_unreachable();
}
if (r < 0)
return -1;

/* Poll the CLEARTID store with msleep rather than a futex wait: any futex
* wait, including one on this address, is a chance to consume the one-shot
* phantom EINTR this test exists to catch, before the calls under test run.
* Bounded, so a teardown that never publishes is a reported failure rather
* than a driver timeout.
*/
long deadline = now_ms() + 5000;
while (__atomic_load_n((volatile int *) &child_tid, __ATOMIC_ACQUIRE) !=
0) {
msleep(100);
if (now_ms() > deadline)
return -1;
}
return 0;
}

/* Runs one park and reports whether it lasted. rc/err are the raw answer. */
static void expect_uninterrupted(const char *name, long rc, long elapsed)
{
TEST(name);
if (rc == -EINTR) {
FAIL("a CLONE_THREAD exit interrupted a wait Linux leaves parked");
return;
}
if (elapsed < EARLY_MS) {
FAIL("the wait ended early without reporting an interruption");
return;
}
PASS();
}

int main(void)
{
printf("=== phantom EINTR on CLONE_THREAD exit ===\n\n");

TEST("baseline park with no worker");
{
struct k_timespec ts = {0, PARK_NS};
long t0 = now_ms();
long rc =
raw_syscall6(__NR_futex, (long) &word,
FUTEX_WAIT | FUTEX_PRIVATE_FLAG, 0, (long) &ts, 0, 0);
long elapsed = now_ms() - t0;
if (rc != -ETIMEDOUT)
FAIL("an unwoken park must report ETIMEDOUT");
else if (elapsed < EARLY_MS)
FAIL("the park did not last");
else
PASS();
}

/* One worker per case: the interrupt this test exists to catch is a
* one-shot edge, so a second case would find it already consumed.
*/
TEST("clone and reap a worker");
if (spawn_and_reap_worker() < 0) {
FAIL("worker never exited");
goto done;
}
PASS();

{
struct k_timespec ts = {0, PARK_NS};
long t0 = now_ms();
long rc =
raw_syscall6(__NR_futex, (long) &word,
FUTEX_WAIT | FUTEX_PRIVATE_FLAG, 0, (long) &ts, 0, 0);
expect_uninterrupted("futex wait survives the exit", rc, now_ms() - t0);
}

TEST("clone and reap a worker for ppoll");
if (spawn_and_reap_worker() < 0) {
FAIL("worker never exited");
goto done;
}
PASS();

{
/* The raw ppoll writes the remaining time back, so it gets its own. */
struct k_timespec ts = {0, PARK_NS};
long t0 = now_ms();
long rc = raw_syscall5(73, 0, 0, (long) &ts, 0, 8);
expect_uninterrupted("ppoll survives the exit", rc, now_ms() - t0);
}

TEST("clone and reap a worker for epoll_pwait");
long ep = raw_syscall1(20 /* epoll_create1 */, 0);
if (ep < 0 || spawn_and_reap_worker() < 0) {
FAIL("worker never exited");
goto done;
}
PASS();

{
char evs[16];
long t0 = now_ms();
long rc = raw_syscall6(22 /* epoll_pwait */, ep, (long) evs, 1, PARK_MS,
0, 8);
expect_uninterrupted("epoll_pwait survives the exit", rc,
now_ms() - t0);
}

done:
SUMMARY("test-futex-ops");
return fails > 0 ? 1 : 0;
}
2 changes: 2 additions & 0 deletions tests/test-matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,8 @@ run_unit_tests()
test_rc "$runner" "test-futex-requeue-account" 0 \
"$bindir/test-futex-requeue-account"
test_rc "$runner" "test-robust-futex" 0 "$bindir/test-robust-futex"
test_rc "$runner" "test-futex-ops" 0 \
"$bindir/test-futex-ops"
test_check "$runner" "test-shim-futex-fast" "OK" \
"$bindir/test-shim-futex-fast"

Expand Down
Loading