Skip to content

Implement signal.set_wakeup_fd and run pending signal handlers after select/poll - #7

Merged
Sam Gammon (sgammon) merged 1 commit into
elide-25.5.xfrom
fix/signal-set-wakeup-fd
Oct 15, 2026
Merged

Sam Gammon (sgammon) merged 1 commit into
elide-25.5.xfrom
fix/signal-set-wakeup-fd

Conversation

@sgammon

Copy link
Copy Markdown
Member

Problem

signal.set_wakeup_fd was a stub returning -1 (upstream too). asyncio relies on it: a subprocess transport without pipes is only completed from the SIGCHLD handler installed with loop.add_signal_handler, and the loop's poll() is only woken by the byte CPython's C signal handler writes to the wakeup fd. In GraalPy the Python handler is queued as an async action that runs when the main thread next reaches a safepoint, which never happens while it is blocked in a native poll() with no timeout, unless the kernel happened to deliver the signal to that very thread (EINTR).

Result: test_asyncio.test_events.PollEventLoopTests.test_subprocess_exitcode, test_subprocess_close_after_finish and test_subprocess_wait_no_same_group hang until the 600s timeout in the tagged unittest gates on linux-amd64 and linux-aarch64 (they are untagged for the *-github tag sets in #6 for now). Locally (JVM standalone, 6 parallel loops) 8 of 48 runs hung; a hung process has a zombie sh, the main thread in NativePosixSupport.poll and no other Python thread.

Change

  • set_wakeup_fd stores the fd (validated like CPython: main thread, valid fd, non-blocking) and returns the previous one. The Java-level signal handler writes the signal number to it before queueing the Python handler, as CPython's trip_signal() does.
  • PosixSupport.writeSignalWakeupByte() does that write without creating Python exceptions or TruffleStrings from native memory, because the JVM's signal dispatcher thread has not entered the context; the native backend returns errno directly. A failed write is reported from the main thread through sys.unraisablehook ("Exception ignored when trying to write to the signal wakeup fd"), honouring warn_on_full_buffer.
  • raise_signal waits (bounded) for the handler of a signal raised in this process to run before returning, since the JVM dispatches signals on another thread; CPython runs it synchronously.
  • select.select and poll.poll run a pending signal handler before returning, so a handler's exception interrupts the call as in CPython (the wakeup byte otherwise makes the call return normally first).

Verification

  • New tests/test_signal.py::WakeupFdTests (block in a native poll() with no timeout and rely on the wakeup byte): pass.
  • CPython test_signal.WakeupFDTests + WakeupSignalTests: all pass (3 consecutive runs; test_pending skipped, needs pthread_sigmask); now tagged on linux.
  • The three asyncio tests above: 0 of 48 runs hang with this change (8 of 48 before).
  • mx checkstyle clean.

Not for the upstream-sync PR (#6); separate runtime change.

@sgammon
Sam Gammon (sgammon) force-pushed the fix/signal-set-wakeup-fd branch 2 times, most recently from 9bcb06b to 07726a7 Compare October 14, 2026 22:37
…select/poll

signal.set_wakeup_fd was a stub returning -1. asyncio relies on it: a
subprocess transport without pipes is only completed from the SIGCHLD
handler that loop.add_signal_handler installs, and the loop's poll() is
only woken by the byte CPython's C signal handler writes to the wakeup fd.
In GraalPy the Python handler is queued as an async action that runs when
the main thread next reaches a safepoint, which never happens while it is
blocked in a native poll() with no timeout, unless the kernel happened to
deliver the signal to that very thread (EINTR). So
test_asyncio.test_events.PollEventLoopTests.test_subprocess_exitcode,
test_subprocess_close_after_finish and test_subprocess_wait_no_same_group
hung in 8 of 48 local runs under load, and regularly in the tagged unittest
gates on both linux-amd64 and linux-aarch64.

- set_wakeup_fd stores the fd (validated like CPython: must be called from
  the main thread, must be a valid fd in non-blocking mode) and returns the
  previous one. The Java-level signal handler writes the signal number to
  it before queueing the Python handler, as CPython's trip_signal() does.
- PosixSupport.writeSignalWakeupByte() does that write without creating
  Python exceptions or TruffleStrings from native memory, because the JVM's
  signal dispatcher thread has not entered the context; the native backend
  returns errno directly. A failed write is reported from the main thread
  through sys.unraisablehook ("Exception ignored when trying to write to
  the signal wakeup fd"), honouring warn_on_full_buffer.
- raise_signal waits (bounded) for the handler of a signal raised in this
  process to run before returning, since the JVM dispatches signals on
  another thread; CPython runs it synchronously.
- select.select and poll.poll run a pending signal handler before
  returning, so a handler's exception interrupts the call as in CPython
  (the wakeup byte otherwise makes the call return normally first).

Adds tests/test_signal.py::WakeupFdTests, which block in a native poll()
with no timeout and rely on the wakeup byte, and tags the now passing
CPython WakeupFDTests/WakeupSignalTests on linux. With this change the
three asyncio tests above no longer hang (0 of 48 runs).
@sgammon

Copy link
Copy Markdown
Member Author

Rebased onto elide-25.5.x after the upstream sync (#6). The branch is now a single commit, 0f47bec904.

Conflicts. Upstream's POSIX refactor in #6 (c84c43dbd8 and the "Convert PosixSupportLibrary.…" series) deleted PosixSupportLibrary. Its messages are now plain @TruffleBoundary methods on PosixSupport, and the exception and buffer types are nested there. I kept upstream's new code and ported this PR onto it:

  • PosixSupport.java: kept upstream's imports. The PosixSupportLibrary.{Buffer,PosixException,PosixErrnoException} imports are gone because the types are now PosixSupport.*. The default writeSignalWakeupByte now calls write(fd, Buffer.wrap(...)) directly instead of going through PosixSupportLibrary.getUncached().write(this, ...).
  • LoggingPosixSupport.java / PreInitPosixSupport.java: upstream turned write from an @ExportMessage with a @CachedLibrary delegate into a plain @Override public final long write(...). I kept that, and added the writeSignalWakeupByte overrides next to it, unchanged: the delegate, and checkNotInPreInitialization() for pre-init.
  • @TruffleBoundary added to the writeSignalWakeupByte declaration and all three overrides (Logging, PreInit, Native), following the new convention in PosixSupport that POSIX operations have the boundary on both declaration and implementation.
  • SignalModuleBuiltins.java merged textually but still called the removed library. set_wakeup_fd's fstat/getBlocking checks and WakeupWriteErrorAction's strerror now use context.getPosixSupport().fstat(fd) / .getBlocking(fd) / .strerror(errno). raise_signal merged cleanly onto upstream's context.getPosixSupport().raise(signum).
  • select.select / poll.poll: no conflict. runPendingSignalHandlers still runs right after the GIL is reacquired following the native call, inside upstream's loops.

The behavior is the same as before. The signal dispatcher thread writes the wakeup byte, then queues the Python handler, and pending handlers run when select/poll returns. This PR doesn't touch PosixModuleBuiltins.postInitialize / populateEnviron(), so the fork's pre-init split is unaffected.

The second commit ("Fix pre-existing style gate failures on elide-25.5.x") was dropped. elide-25.5.x already has the same PosixModuleBuiltins formatting and copyright-year fixes (0f7cb5ac90 and #6).

Local testing (sibling graal = elide-25.5.x ac2994317e9, labsjdk-ce-latest-jvmci-25.4-b23):

  • mx build --build-logs=silent: OK. mx checkstyle --primary: clean.
  • mx graalpytest test_signal.py: 9 run, all pass, including the 3 WakeupFdTests.
  • mx graalpytest --tagged test_signal.py test_select.py test_poll.py test_selectors.py: 85 run, all pass, including the 10 newly tagged WakeupFDTests / WakeupSignalTests.
  • PollEventLoopTests.test_subprocess_exitcode, test_subprocess_close_after_finish and test_subprocess_wait_no_same_group (JVM standalone, -m unittest): 3 parallel loops × 4 runs, 12 of 12 pass in 3–5 s each, no hangs.

Follow-up, not in this PR: #6 untagged these three asyncio tests for the GitHub tag sets until this PR landed. Once it merges, they can be re-tagged.

@sgammon

Copy link
Copy Markdown
Member Author

CI note: the first attempt of run 38793369213 had one failure, python-unittest-standalone-gate-batch1-windows-amd64. It was infrastructure, not this PR: on runner sandbox-r1c, both attempts by mx sforceimports to clone oracle/graal failed ("Recv failure: Connection was reset", then "Failed to connect to github.com:443"), so mx stopped with ..\graal\vm does not contain an mx suite before any test ran. I re-ran only the failed job, and it passed. All 65 checks are now green.

@sgammon
Sam Gammon (sgammon) merged commit 8da5221 into elide-25.5.x Oct 15, 2026
129 of 130 checks passed
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