Skip to content

Report EINTR from one waiter per signal - #380

Merged
jserv merged 5 commits into
sysprog21:mainfrom
xalestar:wait-claim-process-signal
Sep 14, 2026
Merged

jserv merged 5 commits into
sysprog21:mainfrom
xalestar:wait-claim-process-signal

Conversation

@xalestar

@xalestar xalestar commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

kill(2) queues one signal for the whole thread group, and Linux complete_signal() wakes only the thread it picks to run the handler. On elfuse, several threads blocked in read, ppoll, pselect6, epoll_pwait or flock could each return EINTR for that one signal, while only one of them ran the handler.

These waits tested the shared pending set with signal_pending_interruption() and never took the signal. One byte on the wakeup pipe returns every thread parked in host poll() at the same time, so each saw the same bit and reported EINTR, and whichever reached signal_deliver() first ran the handler. The others handed their guests an EINTR that no handler explains. nanosleep had the same defect and claims the signal since #377; this moves the other waits onto that claim: io_wait_fd_timed_or_interrupted, tty_drain_interruptible, io_retry_backoff, ppoll, pselect6 and epoll_pwait, and in the third commit futex_os_sync_wait, futex_poll_signal_relock and rt_sigtimedwait.

The second commit fixes an older defect cubic pointed out in review. ppoll, pselect6 and epoll_pwait put the caller's mask back before the syscall epilogue delivers, and signal_deliver() tests that restored mask, so a signal that only the wait's sigmask unblocks ended the wait with EINTR and was never delivered: a guest that blocks the signal everywhere else and unblocks it only inside the wait got EINTR on every retry and no handler. Linux keeps the temporary mask until delivery and restores the original at sigreturn. These waits now do the same when they return EINTR for a signal they claimed, through the saved_blocked path rt_sigsuspend already uses, and the syscall epilogue puts back a saved mask that no handler frame took, as restore_saved_sigmask() does. epoll_pwait still claims only when kevent produced nothing, so ready events keep outranking the signal.

The third commit changes what a claim is, after review. It used to move the signal into the claimer's private set, which made it thread-directed for good: a mask restored over it, or the claimer exiting, stranded it where no other thread could take it. A claim now leaves the signal in the shared set and records the thread, and the other threads leave it alone only while that thread can still deliver it; any dequeue, thread_deactivate(), exec and a fork snapshot release it. With a wait's saved mask pending, delivery takes the claimed signal first, so the frame carrying that mask is its own. The fourth commit lets ready descriptors outrank a pending signal in ppoll and pselect6, as epoll_pwait already did, and has all three make one non-blocking pass when a signal is already pending, so a finite pselect6 or epoll_pwait no longer sits out its whole timeout for it.

FUSE stays on signal_pending_interruption(), which it needs for restart_out. The comment in io_retry_backoff() said that function filtered SA_RESTART; it never did, and the rewritten comment no longer says so.

Reproduction

tests/test-wait-process-signal.c parks four threads in each of read, ppoll, pselect6, epoll_pwait, flock and FUTEX_WAIT, blocks the signal in the main thread, and sends the group one kill(), up to four rounds per wait. It fails if more than one waiter returns EINTR, or if no round caught a waiter parked. The futex kind returned 3 EINTRs for one kill() on the second commit and passes with the third.

On 4872bb3 it failed 5 of 5 runs, with every wait failing at least once. With this change it passes 10 of 10, and 3 of 3 under the QEMU reference lane.

tests/test-wait-sigmask-signal.c waits in each of ppoll, pselect6 and epoll_pwait under a sigmask that is the only one unblocking SIGUSR1, and checks that the handler runs, that it runs under the wait's mask, and that the original mask is back after both an EINTR and a ready return. Without the second commit 8 of those 12 checks fail. Two more checks come with the fourth commit: a signal already pending at entry ends a 5 s wait at once, and a ready descriptor beats a pending signal, which stays pending. On the second commit they fail for pselect6 and epoll_pwait (5005 and 5001 ms spent) and for ppoll and pselect6 (EINTR instead of the ready count). With all four commits the 18 checks pass, as they do under QEMU.

Measurements

A separate program with the same shape, 30 rounds each, SA_RESTART off:

EINTR per kill() at 4 waiters before after Linux 6.12
read 1 to 3 (42 over 30) 1 (30) 1 (30)
ppoll 1 to 4 (80 over 30) 1 (30) 1 (30)
epoll_pwait 1 to 3 (48 over 30) 1 (30) 1 (30)

The handler ran exactly once per round in every row.

The temporary-mask case: every thread blocks SIGUSR1, and the main thread loops for 1 s on each wait with an empty sigmask after one kill(). On 4872bb3 and on the first commit the handler never runs and the waits return EINTR 4 (pselect6), 7 (ppoll) and 4 (epoll_pwait) times. With the second commit, as under Linux 6.12, the handler runs once on the waiting thread and each wait returns EINTR once.

Not in this change

  • SA_RESTART is still not honored for a signal-interrupted wait, so the thread that does run the handler still sees EINTR from read(). tests/test-poll.c already documents that.
  • A signal that arrives during the wait still reaches an indefinite epoll_pwait only on its 200 ms kevent slice (about 96 ms on average here), and a finite pselect6 or epoll_pwait only at its deadline, since none of them joins the wakeup. That is A guest signal does not interrupt futex, epoll_wait or a finite select #378, which will add the wake on top of this claim.
  • With two signals pending that only a wait's mask unblocks, elfuse builds one frame per epilogue, so unless the first handler makes a syscall the second stays pending under the restored mask instead of nesting under the first as on Linux. The reply on the review thread at signal.c describes the two ways to close it; it is left for a follow-up.

Commands

  • make check: exit 0, all 99 unit tests pass. An earlier run failed only test-shim-futex-toctou with the -38 from rt_sigreturn overwrites the restored X8, so a signal taken on an svc re-enters it as syscall 2 #379; looped 300 times it fails 19 on 4872bb3 and 9 with this change, so it is not caused here
  • make check-tsan: exit 0, all 72 tests pass, no ThreadSanitizer reports
  • test-nanosleep-process-signal, test-sigsuspend, test-sigtimedwait, test-signalfd, test-poll, test-epoll, test-epoll-mt, test-epoll-edge, test-signal, test-signal-thread, test-tgkill-directed: pass with all four commits
  • Each commit builds on its own
  • make check-format, make lint: exit 0
  • make check-ascii: clean across 438 source files

Rebased on 4872bb3.

@jserv
jserv requested a review from Max042004 September 13, 2026 16:48
cubic-dev-ai[bot]

This comment was marked as resolved.

kill(2) queues one instance for the thread group, and Linux
complete_signal() wakes only the thread it picks to run the handler.
The fd wait behind read, write and connect, the tty drain, ppoll,
pselect6, epoll_pwait and the semop/flock/F_SETLKW retry all tested
the shared pending set without taking the signal. One wakeup byte
returns every thread parked in host poll() at once, so each saw the
same bit and reported EINTR, while only the first to reach
signal_deliver() ran the handler.

With four threads blocked and one kill(), read returned 1 to 3 EINTRs
per round and ppoll 1 to 4, where Linux 6.12 returns exactly 1. These
waits now claim the signal under sig_lock, as nanosleep does, and
exactly one reports EINTR.

ppoll, pselect6 and epoll_pwait put the caller's mask back before the
syscall epilogue delivers, and signal_deliver() tests that mask. A
signal only the temporary mask unblocks is left shared: binding it to
a thread that cannot deliver it would take it from a sibling that can.
signal_claim_interruption_masked() claims only what the restored mask
leaves deliverable and reports the rest as before.

FUSE stays on signal_pending_interruption() for its restart_out. The
io_retry_backoff() comment said that function filtered SA_RESTART,
which it never did; the claim replaces it there.
ppoll, pselect6 and epoll_pwait put the caller's mask back before the
syscall epilogue delivers, and signal_deliver() tests that mask. A
signal only the wait's sigmask unblocks therefore ended the wait with
EINTR and stayed pending: with every other thread blocking it, the
handler never ran and each retry returned EINTR again. Linux keeps
the temporary mask for ERESTARTNOHAND, delivers under it, and puts
the original back at sigreturn.

When one of these waits returns EINTR for a signal it claimed, it now
leaves the temporary mask installed and records the original in
saved_blocked, which rt_sigsuspend already uses and signal_deliver()
already stamps into uc_sigmask. Any other return restores the mask at
once. The syscall epilogue, and the HVC sysprog21#13 stop that delivers in its
place, restore a saved mask no frame took, as restore_saved_sigmask()
does.

With the mask still in force at delivery, the plain claim is the
right test again, so signal_claim_interruption_masked() goes away.
@xalestar
xalestar force-pushed the wait-claim-process-signal branch from e4d1c02 to 3c9c38c Compare September 14, 2026 05:01
cubic-dev-ai[bot]

This comment was marked as resolved.

Comment thread src/syscall/io.c
* the same lock from also reporting EINTR for it.
*/
if (thread_stop_requested() || signal_pending_interruption(NULL))
if (thread_stop_requested() || signal_claim_interruption())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claim makes the converted waits agree with each other, but the waits that were not converted still test the shared set non-destructively, so one kill(2) can still hand out two EINTRs. futex_os_sync_wait returns -LINUX_EINTR on a bare signal_pending(), futex_poll_signal_relock wakes a bucket waiter the same way, and rt_sigtimedwait decides on signal_set_would_wake_locked(). A guest with one thread in futex_wait and one in ppoll still sees exactly the defect this fixes. Either move those onto the claim as a follow-up, or say in the commit that the invariant holds only among the waits listed here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved them onto the claim in 8dee3fb. futex_os_sync_wait, futex_poll_signal_relock and the rt_sigtimedwait interrupt test, which claims outside its own wait set, now take the signal the way the other waits do; each site tests teardown before it claims. tests/test-wait-process-signal.c gains a futex kind: four threads in FUTEX_WAIT returned 3 EINTRs for one kill() on 3c9c38c, and exactly one now, as under QEMU.

Comment thread src/syscall/poll.c Outdated
Comment thread src/syscall/signal.c
pthread_mutex_unlock(&sig_lock);
}

void signal_defer_restore_blocked(uint64_t saved)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deliver_signal_locked hands saved to whichever signal it delivers first, not to the one the wait claimed, and a claimed process-directed signal is now indistinguishable from a thread-directed one in tpending. Thread A blocks SIGUSR2 outside the wait, unblocks it in the wait's mask, and claims a process-directed SIGUSR2; a SIGUSR1 aimed at A before the epilogue is lower-numbered, so signal_deliver_one picks it, stamps uc_sigmask with saved, and rt_sigreturn restores the mask that blocks SIGUSR2. That SIGUSR2 is left in A's private set where no other thread can take it, and it dies with A. A reservation on the shared set, released on mask restore or teardown, would keep process-directed signals out of tpending.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done as you suggest, in 8dee3fb. A claim no longer moves the signal into tpending: it stays in the shared set with the claiming thread recorded, and every other thread's view of the shared set (delivery, rt_sigtimedwait, signalfd, the claim itself) leaves it out for as long as the claimer does not block it. Once a mask is restored over it, or the claimer exits (thread_deactivate releases its claims and raises attention), it is an ordinary shared signal again. Any dequeue from the shared set releases the claim, and exec and a fork snapshot start with none. When a wait has left its saved mask, delivery takes the signal this thread claimed before a thread-directed one, so the frame that carries saved belongs to the claimed signal.

I could not make the interleaving you describe, a SIGUSR1 landing between the claim and the epilogue, deterministic from a guest, so no test pins that order; the wait, signal, sigtimedwait and signalfd tests pass with the change, and make check-tsan is clean.

One difference from Linux is left for a follow-up.

The case: two signals are pending that only the wait's mask unblocks.

  • Linux delivers both before returning to user space, nesting a frame for each.
  • elfuse builds one frame per return. The first handler's frame carries the saved mask, so its rt_sigreturn restores the original mask over the second signal. Unless that handler makes a syscall, the second stays pending: a process-directed one in the shared set, where another thread can still take it, a thread-directed one in tpending.

The one-frame limit is syscall_return_epilogue's. Its comment calls signal_deliver a once-per-epilogue call by contract, since a second call "would stack a frame on the handler the first one just installed". That rule came in with the ptrace-stop work in 348e0e5, and its message gives no correctness reason against nesting.

Two ways to close it:

  1. Nest frames as Linux does. This changes the one-frame rule, and has to get the X8 frame-drop marker (rt_sigreturn overwrites the restored X8, so a signal taken on an svc re-enters it as syscall 2 #379), where a ptrace stop is taken, and the altstack check right.
  2. Chain at rt_sigreturn: when the frame being restored carries a wait's saved mask, keep the handler's mask for one more delivery and pass the saved mask to that frame through the same saved_blocked path. Both handlers run, but in the opposite order from Linux.

I'd rather do 2 as a separate PR after this one than grow this one further.

Comment thread tests/test-wait-process-signal.c Outdated
A claim moved a process-directed signal into the claimer's private
set, which made it thread-directed for good. When a wait had left its
saved mask, signal_deliver() could spend that mask on a lower-numbered
thread-directed signal instead, and the rt_sigreturn restoring it
blocked the claimed signal where no other thread could take it; a
claimer that exited took the signal with it.

A claim now leaves the signal in the shared set and records the
thread. Every other thread's view of the shared set -- delivery,
rt_sigtimedwait, signalfd and the claim itself -- leaves the signal
out while the claimer does not block it, so a mask restored over it
hands it back. Any dequeue from the shared set releases the claim,
thread_deactivate() releases the exiting thread's, and exec and a
fork snapshot start with none. With a saved mask pending, delivery
takes this thread's claimed signal first, so the frame carrying the
saved mask belongs to it.

futex_os_sync_wait(), futex_poll_signal_relock() and the
rt_sigtimedwait interrupt test only looked at the shared set, so one
kill() still gave several EINTRs across futex waiters; they claim
now, after their teardown checks. Four threads in FUTEX_WAIT returned
3 EINTRs for one kill() before this, and one after.

The fan-out test failed a kind only on more than one EINTR, so a run
in which no round caught a waiter parked passed without testing
anything. It now fails a kind with no round that saw one handler run
and one EINTR.
ppoll and pselect6 claimed a signal whatever the host wait returned,
so a pass that found ready descriptors reported EINTR, dropped them,
and spent the process's one signal on a call that did not report it.
Linux do_poll() looks at signal_pending() only for a pass that found
nothing, and sys_epoll_pwait already gates its check that way. Both
now consume the futex interrupt and claim only when no guest-visible
event came back; the wakeup descriptor does not count, and POLLNVAL
entries and the entries select() evaluates do.

A signal already pending when a finite pselect6 or epoll_pwait
started was noticed only once the whole timeout had run, since those
waits carry no wakeup descriptor and nothing arrives to end them. All
three waits now make one non-blocking pass when a signal is pending,
so ready descriptors still win and the signal ends the wait at once.
pselect6 now restores its fd_sets before every pass, because a finite
wait can take a second one when a sibling claimed the signal first.
A signal that arrives partway through a finite wait is still seen at
the deadline.

The sigmask test's first wait has no timeout, so a signal that never
reached the waiter hung the lane; the sending thread now makes the
pipe ready after 5 s. Its setup failure branches close what they
opened.
cubic-dev-ai[bot]

This comment was marked as resolved.

When the first wait in check_kind() never sees its signal, the
sending thread makes the pipe ready after 5 s so the check fails
instead of hanging. That byte stayed in the pipe, and every later
check in the same kind returned ready instead of exercising its own
case, burying the first failure under ones it caused. Drain it after
joining the sending thread.
@jserv
jserv merged commit e03b72e into sysprog21:main Sep 14, 2026
16 checks passed
@jserv

jserv commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Thank @xalestar for contributing!

@xalestar
xalestar deleted the wait-claim-process-signal branch September 14, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants