Skip to content

signal: implement signalfd(2)#1439

Open
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/signalfd
Open

signal: implement signalfd(2)#1439
gburd wants to merge 1 commit into
cloudius-systems:masterfrom
gburd:pr/signalfd

Conversation

@gburd

@gburd gburd commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

What

signalfd(2) was stubbed to return ENOSYS, so event loops that consume
signals through a file descriptor (a common pattern with epoll) could not run.

How

Implemented as a pollable special_file (modeled on eventfd) backed by a
global registry of live signalfd objects. OSv is a single process, so a
signalfd watches a set of signals for the whole image. kill() now checks the
registry first: if any signalfd is watching the raised signal, the signal is
queued to those fds (as a signalfd_siginfo) and their pollers are woken, and
the signal is consumed -- the default action and any handler are skipped. This
matches Linux, where a signalfd-consumed signal must be blocked and is dequeued
through the fd.

  • signalfd(-1, mask, flags) creates a new fd; signalfd(fd, mask, flags)
    updates an existing one's mask. SFD_CLOEXEC and SFD_NONBLOCK are honored.
  • read() returns as many queued signalfd_siginfo structures as fit in the
    buffer, blocking (or EAGAIN when non-blocking) when the queue is empty, and
    rejecting a buffer smaller than one siginfo with EINVAL.
  • poll() reports POLLIN when signals are queued.

The delivery hook (osv_signalfd_deliver) is declared in <osv/signal.hh> and
called from kill() in libc/signal.cc; the stub there is removed.

Testing

tests/tst-signalfd.cc covers delivery of a watched signal to the fd (instead
of the default poweroff action), the EAGAIN/poll/queue-order paths, and the
EINVAL cases. Passes on OSv under KVM (1 and 2 vCPUs). tst-sigwait and
tst-sigaction continue to pass, so normal signal delivery is unaffected for
signals no signalfd is watching.

Note

OSv's signal model is thread-directed and minimal; this integrates at the
process-wide kill() delivery point, which is the right granularity for a
single-address-space unikernel. Signals raised synchronously in-thread (e.g.
faults) are a separate path and not routed to signalfd here.

(Recreated from #1421, which GitHub auto-closed when its branch was rebased onto current master. Same change, rebased and verified on master 3aba46c.)

@nyh

nyh commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Please squash such PRs, which only have a single commit plus a few small correction patches, into one commit. It makes it easier to review, easier to read "git log" later, and we don't get incorrect statements in commit messages that are corrected in the next patch.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Implements signalfd(2) support in OSv so signal-driven event loops can consume signals via a pollable file descriptor (similar to Linux usage with epoll/poll), and wires delivery into kill().

Changes:

  • Add libc/signalfd.cc implementing a pollable special_file-backed signalfd with a global registry and queued signalfd_siginfo delivery.
  • Route kill() through the signalfd registry first and remove the prior signalfd() ENOSYS stub.
  • Add tst-signalfd and integrate it into the test module/build.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/tst-signalfd.cc Adds coverage for signalfd delivery/read/poll semantics and error cases
modules/tests/Makefile Adds tst-signalfd.so to the test suite build
Makefile Builds and hides signalfd.o in libc
libc/signalfd.cc Implements signalfd fd object, queueing, polling, and delivery hook
libc/signal.cc Hooks kill() to deliver to signalfd and removes the old stub
include/osv/signal.hh Declares the osv_signalfd_deliver() hook used by kill()

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread libc/signalfd.cc
Comment on lines +27 to +28
#include <signal.h>
#include <errno.h>
Comment thread libc/signalfd.cc
Comment on lines +96 to +105
// Copy out as many queued siginfos as fit in the caller's buffer.
while (!_queue.empty() &&
uio->uio_resid >= (ssize_t)sizeof(signalfd_siginfo)) {
signalfd_siginfo si = _queue.front();
_queue.pop_front();
int err = uiomove(&si, sizeof(si), uio);
if (err) {
return err;
}
}
Comment thread tests/tst-signalfd.cc
Comment on lines +26 to +32
// Create a signalfd watching SIGUSR1 and SIGUSR2.
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGUSR1);
sigaddset(&mask, SIGUSR2);
int sfd = signalfd(-1, &mask, SFD_NONBLOCK);
assert(sfd >= 0);
Comment thread libc/signalfd.cc Outdated
Comment on lines +10 to +14
// OSv is a single process, so a signalfd watches a set of signals for the whole
// image. When kill() raises a signal that some signalfd is watching, the
// signal is queued to those fds (and their pollers woken) instead of running
// the default action or a handler -- matching Linux, where a signalfd-consumed
// signal must be blocked and is dequeued through the fd.
@gburd

gburd commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review and rebuilt the branch cleanly on current master:

  • Missing includes: added <unistd.h> and <string.h> (getpid/memset).
  • read() dropping a signal on copy-out failure: now peeks, uiomoves, and only pop_front()s on success, so a failed copy cannot lose a queued siginfo. (OSv's uiomove is memcpy-based and doesn't currently fail, but the ordering is now correct regardless.)
  • Over-claimed Linux semantics: the header comment claimed a signalfd-consumed signal 'must be blocked'. Corrected it to state OSv's actual behavior honestly - OSv's signal model is a simplification and doesn't track a precise per-thread blocked mask here, so a watched signal is consumed unconditionally. Documented as such.
  • Test: now sigprocmask(SIG_BLOCK, ...) before signalfd(), matching how a real signalfd user works and keeping the test meaningful on Linux.
  • Removed the pre-existing WARN_STUBBED signalfd() stub in libc/signal.cc that the real implementation replaces (this was a genuine link-time collision that surfaced when I rebuilt on current master - now a clean link).

Squashed into one coherent commit. Rebuilt: tst-signalfd passes, and I confirmed the neighbouring tst-signal-fills (tgkill/rt_sigtimedwait, merged separately) still passes - this branch touches only signalfd, not linux.cc.

The change was independently reviewed for the read() reordering correctness and, importantly, for lock ordering: osv_signalfd_deliver takes registry_lock then _mutex (via deliver); no path takes _mutex then registry_lock, so there's no deadlock.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment thread libc/signalfd.cc
Comment on lines +165 to +167
if (!mask || (flags & ~(SFD_CLOEXEC | SFD_NONBLOCK))) {
return libc_error(EINVAL);
}
Comment thread libc/signalfd.cc
Comment on lines +194 to +198
if (!sfd) {
return libc_error(EINVAL);
}
sfd->set_mask(*mask);
return fd;
Comment thread libc/signalfd.cc
Comment on lines +151 to +158
WITH_LOCK(registry_lock) {
for (auto* sfd : registry) {
if (sfd->watches(signo)) {
sfd->deliver(si);
delivered = true;
}
}
}

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.

Interesting. I was also wonder about that while reading this - whether you need to "return true" after the first match instead of "delivered = true". I don't know. Please investigate what is the correct behavior on Linux.

@nyh nyh left a comment

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.

Looks good. Copilot found a couple of mistakes (I think copilot is smarter than I am ;-)).

Comment thread include/osv/signal.hh Outdated
// signalfd delivery hook (libc/signalfd.cc). Called by kill() when a signal is
// raised: returns true if a signalfd consumed the signal, in which case the
// caller must not run the default action or a handler.
extern "C" bool osv_signalfd_deliver(int signo);

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.

Why did this function need to be extern "C"? Do you plan to use it from C code?

Comment thread libc/signalfd.cc
Comment on lines +151 to +158
WITH_LOCK(registry_lock) {
for (auto* sfd : registry) {
if (sfd->watches(signo)) {
sfd->deliver(si);
delivered = true;
}
}
}

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.

Interesting. I was also wonder about that while reading this - whether you need to "return true" after the first match instead of "delivered = true". I don't know. Please investigate what is the correct behavior on Linux.

Replace the ENOSYS signalfd() stub with a real implementation: create (or
update) a file descriptor that a program reads to receive signals as
signalfd_siginfo records instead of via a handler.

- signal_fd is a special_file holding the watched sigset and a queue of
  siginfos; read() copies out as many queued records as fit (blocking or
  EAGAIN under SFD_NONBLOCK), poll() reports POLLIN when the queue is
  non-empty.  read() peeks-copies-then-dequeues so a failed copy-out cannot
  drop a queued signal.
- kill()/the signal-raise path (libc/signal.cc) calls osv_signalfd_deliver():
  if a signalfd watches the signal it is queued to a single (arbitrary)
  watching fd and consumed (no default action / handler), approximating Linux
  where a blocked signal is consumed by one reader rather than broadcast.  The
  old WARN_STUBBED signalfd() in libc/signal.cc is removed.
- SIGKILL and SIGSTOP are filtered from the watched mask (sigdelset) so they
  keep their uncatchable default behavior, as Linux silently does.
- signalfd() updating an existing fd's mask takes registry_lock, the same lock
  the dispatch path holds while reading the mask, so a mask update cannot race
  a delivery.  The flags argument is only applied on creation (per the man
  page).

Note on Linux fidelity: OSv's signal model is a simplification and does not
track a precise per-thread blocked mask here, so a watched signal is consumed
unconditionally, and with two signalfds watching the same signal the choice of
fd is arbitrary.  Sufficient for the common use (block the signals, read them
through one fd); documented in the source.

Covered by tst-signalfd (blocks the signals first, as a real signalfd user
does, so it is meaningful on Linux too).
@gburd

gburd commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the round-2 comments:

  • Single-consumer delivery (you + Copilot): osv_signalfd_deliver() now delivers to a single (arbitrary) watching fd and returns, instead of broadcasting to every signalfd. This approximates Linux, where a blocked signal is consumed by one reader. I investigated the exact Linux semantics: a signalfd is a readable view of the pending blocked-signal set, and with two signalfds watching the same signal the per-thread pending accounting decides who dequeues; OSv's single-process model can't reproduce that exactly, so I documented it honestly as an approximation (the chosen fd is arbitrary - registry iteration order).
  • SIGKILL/SIGSTOP (Copilot): filtered out of the watched mask with sigdelset on both the create and update paths, so they retain their uncatchable default behavior. (Note: Linux silently ignores them in the mask rather than returning EINVAL, which is what this does.)
  • extern "C" on osv_signalfd_deliver (your question): dropped it - there's no C caller, it's only called from libc/signal.cc which is C++. Decl and def are now both plain C++.
  • flags on fd != -1 update (Copilot): per the signalfd(2) man page the flags argument is only meaningful when creating a new fd, so it is intentionally not re-applied on update; documented that.
  • Earlier round (includes, peek-copy-pop, honest blocked-mask comment, test blocks signals) remains addressed.

Squashed into one commit. Rebuilt, tst-signalfd passes. Independently reviewed for the single-consumer semantics and the extern"C"/linkage change.

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.

3 participants