Skip to content

fix(python): keep SIGINT fatal while the core runs - #1165

Merged
thekevinscott merged 3 commits into
mainfrom
claude/1164-sigint-fatal
Sep 20, 2026
Merged

thekevinscott merged 3 commits into
mainfrom
claude/1164-sigint-fatal

Conversation

@thekevinbot

@thekevinbot thekevinbot commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1164

Problem

packages/python/dirsql/cli/main.py installed _absorb_interrupt, an empty SIGINT handler, for the whole run_cli window, on the reasoning that the core "returns promptly for every non-server command". A directory scan does not, so Ctrl-C was swallowed and the process was SIGTERM-only.

Why the TypeScript shape does not port

keep-signals-fatal.ts registers a JS listener that process.exit(130). The Python equivalent -- any Python callable -- cannot run at all while the core is working: run_cli detaches the GIL for its entire duration, so no Python-level handler reaches the eval loop until the scan has already finished.

Measured, with the launcher blocked in an on-file hook (sleep 120):

SIGINT disposition during run_cli query mid-scan dirsql server graceful shutdown
_absorb_interrupt (before) alive after 2x SIGINT; dies only on SIGTERM exit 0
CPython default_int_handler (no launcher install) alive after SIGINT exit 1
signal.SIG_DFL (this PR) dies, exit 130 exit 0 (3/3 runs)

So default_int_handler is not a fix either -- it merely trades a swallowed signal for a swallowed signal plus a broken server exit code. SIG_DFL is the only disposition the kernel can act on without the interpreter, and it is exactly what the standalone binary runs with.

Why dirsql server does not regress

signal-hook (which tokio uses) replaces the disposition when run_server registers its handlers, and does not re-raise SIG_DFL on the way out -- so during the server window the core's own graceful shutdown is the only thing that acts, and its exit code survives. That is the same chain the standalone binary has, and the measurements above confirm it: exit 0, three runs out of three.

The REPL is unaffected: reedline puts the terminal in raw mode, which disables ISIG, so Ctrl-C arrives as a key event and never as a signal. Verified under a pty with a DSR responder -- Ctrl-C returns a fresh prompt and the process stays alive, identically before and after.

Scope: the other two launchers

Both were checked rather than assumed.

  • The standalone Rust binary is unaffected. SIGINT mid-scan exits 130, server exits 0. No handler is needed on the Rust query path. (A caveat for anyone re-running this: a non-interactive shell sets SIGINT to SIG_IGN for & background jobs, which makes the binary look immune. Reset the disposition in the spawner before testing.)
  • The TypeScript launcher has the same bug, and is out of scope here. runCli is a synchronous napi export, so it blocks the Node event loop and keepSignalsFatal's JS listener cannot run either. Probed directly against the built addon with both listeners registered: SIGINT mid-scan is swallowed and the listener never fires. Filed as Ctrl-C does not interrupt a running query in the npm launcher #1166 rather than widening this PR; Node has no SIG_DFL equivalent reachable from JS, so the fix is a different shape.

Changelog / Migrations

  • Changelog fragment added under <root>/<pkg>/changelog.d/ for each changed package (or: skip-changelog trailer on a commit with reason)
  • Migration fragment added under <root>/<pkg>/migrations.d/ (or: not required -- additive/bugfix only)

E2E Verification

  • Ran e2e suites locally for every affected SDK
  • Python SDK e2e: pass
  • TypeScript SDK e2e: N/A (no source change; the napi addon was built only to probe Ctrl-C does not interrupt a running query in the npm launcher #1166)
  • Rust core e2e (if applicable): N/A (no packages/rust change)
  • packages/python/e2e-attestations/claude-1164-sigint-fatal.json receipt written (just e2e-attest-python)
  • packages/ts/e2e-attestations/<branch>.json receipt written if packages/ts changed (just e2e-attest-ts) -- N/A
  • Command(s) run: cd packages/python && uv run python -m pytest tests/e2e/ -x -q, via just e2e-attest-python
  • Result summary: 38 passed in 12.54s, including the new sigint_interrupt_test.py.

By-hand check

Against a real 240,000-file tree under /tmp (not $HOME), no hook, no fixture:

$ dirsql "SELECT path FROM './'" &      # via the launcher
$ kill -INT $!
exit=130, 0 bytes of output

And the server, same launcher, same session: banner printed, kill -INT -> exit=0.

The launcher's colocated test now expects the default disposition, and a
new e2e case blocks the scan in an `on-file` hook and asserts the process
dies of the signal.

skip-changelog: tests only
The launcher's empty handler could never be replaced by a Python callable
that exits, because the core detaches the GIL for the whole of `run_cli`
and no Python handler reaches the eval loop until it returns. SIG_DFL is
the only disposition the kernel acts on unaided, and signal-hook still
overrides it for the `dirsql server` window, so graceful shutdown keeps
its exit code.
@thekevinscott
thekevinscott merged commit 6b0ed67 into main Sep 20, 2026
61 of 64 checks passed
@thekevinscott
thekevinscott deleted the claude/1164-sigint-fatal branch September 20, 2026 13:31
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.

Ctrl-C does not interrupt a running query in the Python launcher

2 participants