Skip to content

fix(signal): install signal handlers before returning in thread-context builds - #6407

Open
binaryfire wants to merge 2 commits into
swoole:masterfrom
binaryfire:fix/thread-context-signal-registration
Open

binaryfire wants to merge 2 commits into
swoole:masterfrom
binaryfire:fix/thread-context-signal-registration

Conversation

@binaryfire

Copy link
Copy Markdown
Member

In builds with --enable-thread-context, Process::signal() returns and System::waitSignal() yields before their signal handlers are installed. A SIGUSR1, SIGTERM or SIGINT received in that gap kills the process instead of reaching the registered handler:

Co\run(function () {
    Swoole\Process::signal(SIGUSR1, function ($signo) {
        echo "got $signo\n";
        Swoole\Process::signal(SIGUSR1, null);
    });
    posix_kill(getmypid(), SIGUSR1);
    Co::sleep(0.1);
});
echo "done\n";

A standard build prints got 10 and done. A thread-context build is killed by SIGUSR1. The existing signal_twice.phpt fails the same way.

Cause

Each coroutine runs on its own thread, and those threads block all signals (#5081). signalfd needs the signal blocked on the thread running the event loop, and a thread can only change its own signal mask. So these calls defer swoole_signal_set() to the event loop with swoole_event_defer(), and the signal's previous disposition stays in effect until the loop runs the task.

Cancelling System::waitSignal() before that task runs is also broken: the task still registers the signal, and it writes to the finished coroutine's stack.

Fix

Thread-context builds no longer use signalfd. sigaction applies to the whole process, so a registration from a coroutine thread is complete when the call returns, as on macOS or with enable_signalfd => false. The deferred registrations are removed. That includes System::waitPid(), which deferred SIGCHLD the same way and used a catch-up from #6337 to reap children that exited first. php --ri swoole no longer lists signalfd in these builds, and enable_signalfd has no effect there.

With sigaction, the handler only sets a flag, and the event loop calls the PHP handler after it wakes. A signal caught while PHP code runs before the loop waits stays pending until the next event or timer. Standard builds already show this on macOS and with enable_signalfd => false, and thread-context builds would show it whenever a signal arrives while a coroutine runs. Reactor::get_timeout_msec() now returns 0 while a caught signal is waiting, only on the reactor that dispatches signals. kqueue now uses it too, since it handles SIGCHLD with sigaction. This fixes signals already pending when the timeout is checked; the existing race between that check and the wait call remains.

Why not keep signalfd in these builds

  • Yield until the event loop registers the signal: fixes only Process::signal(), and lets other coroutines run inside the call.
  • Block all signals on the main thread while a coroutine runs: Ctrl+C and SIGTERM wait until the coroutine yields, and every switch costs two more syscalls.
  • Install with sigaction first, then move to signalfd: two backends per signal, plus replaying signals caught in between.

Tests

  • signal_dispatch_before_wait.phpt: a Process::signal() handler and a System::waitPid() whose signal is caught before the loop waits run straight away. It runs with enable_signalfd => false on Linux, and with poll and kqueue on macOS.
  • waitSignal_immediate.phpt: a signal sent as soon as waitSignal() yields is delivered, and a cancelled wait leaves no registration behind.
  • signal_restore_action.phpt and waitSignal_ignored.phpt no longer wait for the deferred registration.

On master, the new tests and signal_twice.phpt fail in a thread-context build, and signal_dispatch_before_wait.phpt also fails in a standard build. All pass with this change. The kqueue path is only covered by the macOS CI job.

This overlaps #6230 in the same signal code. The two merge cleanly, and I'll rebase whichever lands second if needed.

With the sigaction backend, the signal handler only sets a flag, and the
reactor's signal end callback calls the PHP handler. A signal caught
while PHP code runs before the loop waits stayed pending until the next
event or timer. A Process::signal() handler could run a full timer
interval late, and System::waitPid() could wait until its timeout.

get_timeout_msec() now returns 0 while a caught signal is waiting, but
only on the reactor that owns the signal end callback, so other reactors
keep blocking normally. kqueue now gets its timeout from
get_timeout_msec() too. It handles SIGCHLD with sigaction, and it also
ignored pending deferred tasks whenever a timer was set.

This covers signals already pending when the timeout is checked. A
signal caught between that check and the wait call is still delayed, as
before.

This affects builds without signalfd, such as macOS and builds with
enable_signalfd disabled, and SIGCHLD with kqueue.
In thread-context builds each coroutine runs on its own thread, and
those threads block all signals. signalfd needs the signal blocked on the
reactor thread, and a thread can only change its own signal mask, so
Process::signal(), System::waitSignal() and System::waitPid() deferred
swoole_signal_set() to the reactor thread.

Until the deferred task ran, the signal kept its default action. A
signal sent right after Process::signal() or System::waitSignal()
returned could kill the process. A waitSignal() cancelled before the
deferred task ran left the signal registered, and the task wrote to the
finished coroutine's stack.

Thread-context builds no longer use signalfd. The sigaction backend is
process-wide, so registration is complete when the call returns. The
deferred registrations, the waitpid catch-up and the tests' waits for
deferred registration are removed.
@codecov

codecov Bot commented Nov 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.09%. Comparing base (bcbde20) to head (6885a9d).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6407      +/-   ##
==========================================
- Coverage   84.10%   84.09%   -0.02%     
==========================================
  Files         115      115              
  Lines       20237    20239       +2     
  Branches     3638     3639       +1     
==========================================
- Hits        17021    17020       -1     
- Misses       3216     3219       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

1 participant