Skip to content

fix: respect register_sys_signals=False (#2956)#3162

Closed
jbbqqf wants to merge 1 commit into
sanic-org:mainfrom
jbbqqf:fix/2956-respect-register-sys-signals
Closed

fix: respect register_sys_signals=False (#2956)#3162
jbbqqf wants to merge 1 commit into
sanic-org:mainfrom
jbbqqf:fix/2956-respect-register-sys-signals

Conversation

@jbbqqf

@jbbqqf jbbqqf commented May 23, 2026

Copy link
Copy Markdown

Summary

Fixes #2956.

Sanic.run(..., register_sys_signals=False) is the documented escape hatch for callers that take over SIGINT/SIGTERM themselves (for instance an orchestrator that wraps Sanic inside a larger process and only wants the orchestrator to react to Ctrl-C). Since 4499d2c (#2811, "Cleaner process management"), _setup_system_signals unconditionally calls signal_func(SIGINT, SIG_IGN) / signal_func(SIGTERM, SIG_IGN) at function entry, before checking the register_sys_signals flag. The flag still suppresses loop.add_signal_handler(...), but the user's pre-existing Python-level handlers are already gone, replaced with SIG_IGN. Net effect: opting out of Sanic's signal handling silently disables the caller's signal handling too.

The fix moves the SIG_IGN reset inside the register_sys_signals branch. The reset is still useful there as a safety step before installing the asyncio handlers (and the existing test test_register_system_signals still passes), but it no longer runs on the opt-out path.

Reproduce BEFORE/AFTER yourself (copy-paste)

# From the repo root (Linux/macOS, on POSIX SIGINT/SIGTERM semantics)
cd /tmp && rm -rf repro-2956 && mkdir repro-2956 && cd repro-2956
python3 -m venv .venv && source .venv/bin/activate
pip install -q "sanic @ git+https://github.com/sanic-org/sanic.git@main"   # BEFORE
# pip install -q "sanic @ git+https://github.com/jbbqqf/sanic.git@fix/2956-respect-register-sys-signals"  # AFTER

cat > repro.py <<'PY'
import asyncio, signal
from sanic.server.runners import _setup_system_signals

calls = []
def my_handler(signum, frame):  # the caller's handler
    calls.append(signum)

signal.signal(signal.SIGINT, my_handler)
signal.signal(signal.SIGTERM, my_handler)

class _StubApp:
    debug = False
    state = type("S", (), {"is_stopping": False, "is_running": False})()
    def stop(self, *a, **kw): pass

loop = asyncio.new_event_loop()
_setup_system_signals(_StubApp(), run_multiple=False,
                     register_sys_signals=False, loop=loop)

print("SIGINT  ->", signal.getsignal(signal.SIGINT))
print("SIGTERM ->", signal.getsignal(signal.SIGTERM))
# Expected BEFORE: Handlers.SIG_IGN (bug — caller's my_handler clobbered)
# Expected AFTER : <function my_handler> (caller's handler preserved)
PY
python repro.py

What I ran locally

$ pytest tests/test_signal_handlers.py -v
...
tests/test_signal_handlers.py::test_register_system_signals PASSED
tests/test_signal_handlers.py::test_no_register_system_signals_fails PASSED
tests/test_signal_handlers.py::test_dont_register_system_signals PASSED
tests/test_signal_handlers.py::test_dont_register_system_signals_preserves_user_handlers PASSED
tests/test_signal_handlers.py::test_windows_workaround PASSED
tests/test_signal_handlers.py::test_signals_with_invalid_invocation PASSED
tests/test_signal_handlers.py::test_signal_server_lifecycle_exception PASSED
======================== 7 passed, 7 warnings in 0.99s ========================

$ pytest tests/test_signal_handlers.py tests/test_signals.py tests/test_app.py tests/worker/ -q
1 failed, 254 passed, 28 warnings in 7.84s
# The single failure is tests/worker/test_socket.py::test_configure_socket
# (AF_UNIX path-too-long in this sandbox) and reproduces identically on
# unmodified main — not caused by this change.

The new test test_dont_register_system_signals_preserves_user_handlers
is the regression. It calls _setup_system_signals(..., register_sys_signals=False)
directly so the assertion is deterministic and doesn't depend on whether
app.run is spun up. Run it against origin/main to see it fail with
SIG_IGN is not <user_handler>; run it on this branch to see it pass.

Edge cases

Case Behavior before Behavior after
register_sys_signals=True (default), no prior user handler Sanic installs asyncio handlers via loop.add_signal_handler; default Python handler reset to SIG_IGN first Unchanged — same path
register_sys_signals=True, user pre-installed a handler User handler replaced with SIG_IGN, then asyncio handler installed Unchanged — same path (Sanic's documented behavior when it owns signals)
register_sys_signals=False, no prior user handler SIGINT/SIGTERM forced to SIG_IGN (silent regression vs. pre-#2811) Default handlers preserved (Python's default_int_handler for SIGINT, default for SIGTERM)
register_sys_signals=False, user pre-installed a handler User handler clobbered → #2956 bug User handler preserved
Windows path (OS_IS_WINDOWS) with register_sys_signals=True Calls ctrlc_workaround_for_windows Unchanged
run_multiple=True workers Still set SANIC_WORKER_PROCESS=true; signals reset only when worker registers signals Same; the env var assignment was moved out of the SIG_IGN block but still runs

PR drafted with assistance from Claude Code (Anthropic). The change was reviewed manually against sanic's source (sanic/server/runners.py at 785d77f, the v25.12 release commit). The reproducer block above is the one I used during development; reviewers can paste it verbatim.

When the caller passes register_sys_signals=False to Sanic.run /
Sanic.serve_single, they are taking over signal handling themselves
(e.g. an orchestrator that wraps Sanic in a wider process). Since
commit 4499d2c ("Cleaner process management", sanic-org#2811) the unconditional
calls to signal_func(SIGINT, SIG_IGN) / signal_func(SIGTERM, SIG_IGN)
at the top of _setup_system_signals overwrite any handler the caller
installed before Sanic started, even on this code path.

Move the SIG_IGN reset inside the register_sys_signals branch so it
only runs when Sanic is actually about to install its own asyncio
signal handlers, restoring the documented behavior of the flag.

Adds a unit-level regression test that fails on main (handlers turn
into SIG_IGN) and passes on this branch (handlers preserved).
@jbbqqf jbbqqf requested a review from a team as a code owner May 23, 2026 10:10
@codecov

codecov Bot commented May 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.707%. Comparing base (785d77f) to head (ccfc59a).

Additional details and impacted files
@@              Coverage Diff              @@
##              main     #3162       +/-   ##
=============================================
- Coverage   87.793%   87.707%   -0.086%     
=============================================
  Files          105       105               
  Lines         8143      8143               
  Branches      1290      1290               
=============================================
- Hits          7149      7142        -7     
- Misses         687       693        +6     
- Partials       307       308        +1     

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

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jbbqqf

jbbqqf commented May 23, 2026

Copy link
Copy Markdown
Author

Quick note on CI: the red checks here are not caused by this PR.

  • Linux / Python 3.* / tox -e security is the only "real" failure — bandit's signature for B105:hardcoded_password_string has grown beyond what bandit.baseline covers, flagging FORWARDED_SECRET: None (sanic/config.py:39) and TLS_CERT_PASSWORD: "" (sanic/config.py:67). Reproduced on stock origin/main (commit 785d77f8, the v25.12 release) with the same bandit version:
    $ bandit --recursive sanic -b ./bandit.baseline
    # Run metrics: Low: 12, Medium: 2, High: 10
    # exit 1 — exactly the same on main as on this branch.
    
    So the baseline file is stale relative to current bandit and needs a separate refresh (likely the same bandit --recursive sanic -b ./bandit.baseline regen described in Create baseline for bandit to remove false positives #3084), not anything in this diff.
  • All the other red checks (py310, py311, py312, py313, py314, -no-ext, lint, type-checking) show ##[error]The operation was canceled. mid-collection — they were cancelled by the workflow's fail-fast strategy after security failed, not by an assertion failure.
  • The checks driven by this PR's actual content pass: Check coverage, codecov/patch, codecov/project, CodeQL, Analyze (python), plus Linux / Python 3.10 / tox -e type-checking and Linux / Python 3.11 / tox -e type-checking (which ran before the security cancel propagated).
  • Locally, pytest tests/test_signal_handlers.py -v and pytest tests/test_signals.py tests/test_app.py tests/worker/ -q are green on this branch (one preexisting test_configure_socket::AF_UNIX sandbox-only failure that also reproduces on origin/main).

Happy to rebase / push a baseline refresh as a follow-up commit on this branch if you'd prefer, but figured separating the two changes keeps this PR reviewable.

@jbbqqf jbbqqf closed this May 24, 2026
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.

Signal handlers for SIGINT and SIGTERM are reset to SIG_IGN even when register_sys_signals is disabled

1 participant