Report EINTR from one waiter per signal - #380
Conversation
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.
e4d1c02 to
3c9c38c
Compare
| * the same lock from also reporting EINTR for it. | ||
| */ | ||
| if (thread_stop_requested() || signal_pending_interruption(NULL)) | ||
| if (thread_stop_requested() || signal_claim_interruption()) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| pthread_mutex_unlock(&sig_lock); | ||
| } | ||
|
|
||
| void signal_defer_restore_blocked(uint64_t saved) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_sigreturnrestores 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 intpending.
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:
- Nest frames as Linux does. This changes the one-frame rule, and has to get the X8 frame-drop marker (
rt_sigreturnoverwrites the restored X8, so a signal taken on ansvcre-enters it as syscall 2 #379), where a ptrace stop is taken, and the altstack check right. - 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 samesaved_blockedpath. 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.
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.
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.
|
Thank @xalestar for contributing! |
kill(2)queues one signal for the whole thread group, and Linuxcomplete_signal()wakes only the thread it picks to run the handler. On elfuse, several threads blocked inread,ppoll,pselect6,epoll_pwaitorflockcould 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 hostpoll()at the same time, so each saw the same bit and reported EINTR, and whichever reachedsignal_deliver()first ran the handler. The others handed their guests an EINTR that no handler explains.nanosleephad 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,pselect6andepoll_pwait, and in the third commitfutex_os_sync_wait,futex_poll_signal_relockandrt_sigtimedwait.The second commit fixes an older defect cubic pointed out in review.
ppoll,pselect6andepoll_pwaitput the caller's mask back before the syscall epilogue delivers, andsignal_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 thesaved_blockedpathrt_sigsuspendalready uses, and the syscall epilogue puts back a saved mask that no handler frame took, asrestore_saved_sigmask()does.epoll_pwaitstill 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 inppollandpselect6, asepoll_pwaitalready did, and has all three make one non-blocking pass when a signal is already pending, so a finitepselect6orepoll_pwaitno longer sits out its whole timeout for it.FUSE stays on
signal_pending_interruption(), which it needs forrestart_out. The comment inio_retry_backoff()said that function filteredSA_RESTART; it never did, and the rewritten comment no longer says so.Reproduction
tests/test-wait-process-signal.cparks four threads in each ofread,ppoll,pselect6,epoll_pwait,flockandFUTEX_WAIT, blocks the signal in the main thread, and sends the group onekill(), 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 onekill()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.cwaits in each ofppoll,pselect6andepoll_pwaitunder 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 forpselect6andepoll_pwait(5005 and 5001 ms spent) and forppollandpselect6(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_RESTARToff:kill()at 4 waitersreadppollepoll_pwaitThe 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_RESTARTis still not honored for a signal-interrupted wait, so the thread that does run the handler still sees EINTR fromread().tests/test-poll.calready documents that.epoll_pwaitonly on its 200 ms kevent slice (about 96 ms on average here), and a finitepselect6orepoll_pwaitonly 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.signal.cdescribes 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 onlytest-shim-futex-toctouwith the -38 fromrt_sigreturnoverwrites the restored X8, so a signal taken on ansvcre-enters it as syscall 2 #379; looped 300 times it fails 19 on 4872bb3 and 9 with this change, so it is not caused heremake check-tsan: exit 0, all 72 tests pass, no ThreadSanitizer reportstest-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 commitsmake check-format,make lint: exit 0make check-ascii: clean across 438 source filesRebased on 4872bb3.