Stop faking SIGCHLD on a CLONE_THREAD exit - #361
Merged
Conversation
jserv
requested changes
Sep 4, 2026
jserv
left a comment
Contributor
There was a problem hiding this comment.
Refine tests/test-futex-no-phantom-eintr.c to be more consistent with existing naming schemes.
alanhc
force-pushed
the
futex-phantom-eintr-v2
branch
from
September 5, 2026 03:55
0fb5df7 to
f707291
Compare
Collaborator
Author
|
Naming: renamed cubic's findings:
Rebuilt, ran on elfuse and cross-checked on the qemu reference kernel (Linux 6.18.44) — both pass 7/7 unchanged. |
Contributor
|
Rename |
When the last CLONE_THREAD worker exited, forkipc.c raised the futex interrupt, which makes the next blocking call in the surviving thread return EINTR. The comment gave the reason: "In real Linux, child exit delivers SIGCHLD which interrupts futex_wait with -EINTR." Linux does not. clone(2) is explicit that a thread created with CLONE_THREAD sends no signal to its parent when it terminates; the thread group's exit signal goes to the process's parent, not to a sibling. Nothing is delivered, so a sibling's wait is not interrupted and its timeout is what ends it. Measured on Linux 6.18.44 through the qemu lane, a worker exits and the main thread then parks for 300 ms with nothing to wake it: call Linux elfuse before futex(FUTEX_WAIT) ETIMEDOUT 303ms EINTR 101ms ppoll 0 303ms EINTR 0ms epoll_pwait 0 311ms EINTR 302ms nanosleep 0 309ms 0 328ms No signal is pending on either side. nanosleep agrees because it does not read the flag, which is what identifies the flag as the mechanism rather than anything process-wide. Removing the request drops the fabricated errno and nothing else. The futex waiter is not woken by the interrupt: it wakes on its own 100 ms quantum and re-checks thread_stop_requested, its woken flag, expired itimers and queued signals, of which futex_interrupt_consume is one reason among several. wakeup_pipe_signal and thread_interrupt_all stay, so threads parked on the shared pipe and inside hv_vcpu_run still get the nudge this site exists to send. Teardown keeps its interrupt. All four callers that mean it go through thread_wake_all_blocked, where teardown state marks every thread as leaving but the atomic interrupt itself is consumed by only one waiter. This site was the only one that raised it while the process carried on running. The one-shot consume in 520568c is untouched. That fixed the flag staying set, which is why foot spun on EINTR forever; it did not ask whether the flag should have been set at all. tests/test-futex-ops.c parks in each of the three calls above after a worker exit and fails on an early return. It fails three of seven before this change, passes after, and passes unchanged on the reference kernel. It lives in test-matrix.sh's run_unit_tests and not in tests/manifest.txt, per that file's scope note: every assertion is a timeout the guest can observe, so it is cross-checkable. The worker's own reap loop polls the CLEARTID store with nanosleep rather than a futex wait: a futex wait on any address, including that one, is a chance to consume the one-shot interrupt before the calls under test run, which would pass the test even with the bug back. Reconfirmed against the reintroduced bug: the reap loop's own wait no longer hides it, and the same three cases fail again. Naming follows test-thread.c and test-exec-handoff.c: child_tid, child_stack_buf, spawn_and_reap_worker. Filed as test-futex-no-phantom-eintr.c, which named the regression; renamed to test-futex-ops.c to match the file-naming scheme, where ops names the futex operations under test (test-file-ops.c is the existing precedent).
alanhc
force-pushed
the
futex-phantom-eintr-v2
branch
from
September 5, 2026 04:10
f707291 to
e7dc395
Compare
Collaborator
Author
|
Renamed to |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When the last
CLONE_THREADworker exits,forkipc.craises the futexinterrupt, which makes the next blocking call in the surviving thread return
EINTR. The comment gave the reason: "In real Linux, child exit deliversSIGCHLD which interrupts futex_wait with -EINTR."
Linux does not.
clone(2)is explicit that a thread created withCLONE_THREADsends no signal to its parent when it terminates; the threadgroup's exit signal goes to the process's parent, not to a sibling. Nothing
is delivered, so the sibling's wait runs to its timeout.
Reproduction
Clone a
CLONE_THREADworker that exits at once, wait for theCLONE_CHILD_CLEARTIDstore, then park for 300 ms with nothing to wake thecaller.
tests/test-futex-no-phantom-eintr.cis that program; the threerows below are what it observes.
futex(FUTEX_WAIT)ppollepoll_pwaitnanosleepNo signal is pending on either side.
nanosleepagrees because it does notread the flag, which identifies the flag as the mechanism rather than
anything process-wide.
Why the request can go
The interrupt is not what wakes anybody. The futex waiter wakes on its own
100 ms quantum and re-checks
thread_stop_requested, itswokenflag,expired itimers and queued signals;
futex_interrupt_consumeis one reasonamong several, not the wake.
wakeup_pipe_signalandthread_interrupt_allstay, so threads parked on the shared pipe and inside
hv_vcpu_runstillget the nudge this site sends. Teardown keeps its interrupt: the four
callers that mean it go through
thread_wake_all_blocked, where everythread really is leaving. This site was the only one raising it while the
process carried on running.
The one-shot consume from 520568c is untouched. That fixed the flag staying
set, which is why foot spun on
EINTRforever, and left open whether theflag should have been set at all. The premise dates to the initial import.
Two clean matrix runs bound the liveness risk without closing it. A lost
wake that the interrupt had been masking would surface as a hang rather
than a failure, and the argument above is what rules that out: every
re-check the waiter performs survives the change.
Test
tests/test-futex-no-phantom-eintr.cfails 3 of 7 before the change, passesafter, and passes unchanged on the reference kernel. It is registered in
test-matrix.shunderrun_unit_testsand not intests/manifest.txt, perthat file's scope note, since every assertion is a timeout the guest can
observe.
Its
CLONE_CHILD_CLEARTIDwait is spelled out rather than reusingraw_futex_wait, which carriesFUTEX_PRIVATE_FLAG. The exit-time wake isa plain futex wake, so a private wait never matches its key; the test hung
on the reference kernel until that was a plain wait with a timeout.
Environment and status
MacBookPro18,3, Apple M1 Pro, macOS 26.5.2 (25F84), SDK 26.5, Apple clang
21.0.0. Reference kernel is Linux 6.18.44 through the
qemu-aarch64lane,QEMU 11.1.1. Rebased on
mainat 4388e7c.make checkstops atbuild/test-hellowithaarch64-none-elf-as: No such file or directory; the bare-metal toolchain needs a sudo installer thismachine cannot run, so
test-helloand the fulltest-matrixlanes areunbuilt rather than passing. What did run:
tests/driver.shpasses 98 of98, and
tests/test-matrix.sh elfuse-aarch64passes 280 with 0 failed and10 skipped, twice. That lane is the real-application check, since it runs
busybox, coreutils, dash, lua and jq under the patched binary rather than
unit tests alone. The new test passes 5 of 5 under elfuse and 3 of 3 on the
reference kernel under
QEMU_ACCEL=tcg, where timing is slowest.check-format,check-ascii,check-eintr-contract,check-lock-order,check-atomics,check-skill-refs,check-syscall-coverage,check-svc-tailsand.ci/check-matrix-lists.share clean.