Skip to content

fix(install): make Ctrl+C abort indexing promptly#414

Merged
HumanBean17 merged 2 commits into
masterfrom
fix/ctrl-c-install-abort
Jul 10, 2026
Merged

fix(install): make Ctrl+C abort indexing promptly#414
HumanBean17 merged 2 commits into
masterfrom
fix/ctrl-c-install-abort

Conversation

@HumanBean17

Copy link
Copy Markdown
Owner

Problem

Ctrl+C during install (and reprocess/init) did not exit — the parent hung until the child closed its pipes, often requiring SIGQUIT/kill.

Root cause

_popen_capturing_stderr (the helper that drains cocoindex/graph subprocess output) did:

t_out.join()   # ← blocks here, uninterruptible
t_err.join()
code = proc.wait()

Thread.join() with no timeout calls lock.acquire(block=True, timeout=-1), and CPython's acquire_timed does a single blocking lock with no signal-polling loop for infinite timeout. So while the drain threads were blocked on proc.stdout/stderr.read() — which stays true as long as the child holds the pipes open — SIGINT could not interrupt the join. cocoindex's teardown (and its flow-server grandchild) can hold those pipes open for a long time, so the install couldn't be aborted.

Confirmed by reading CPython's _wait_for_tstate_lock and by a regression test that fails on the old order and passes on the fix.

Fix

Wait on the child firstPopen.wait() is interruptible (PEP 475: os.waitpid returns EINTR, CPython raises KeyboardInterrupt). On abort, terminate the child non-blocking and re-raise without joining the drain threads (a join would re-introduce the hang; daemon threads vanish at exit). The daemon drain threads keep the child's pipes empty while we wait, so it never blocks on a full pipe. On the normal path, joins are safe because the child is gone → pipes EOF → threads finish promptly.

Also catch KeyboardInterrupt in cli._console_script_main and jrag._console_script_main → flush + os._exit(130):

  • clean exit (no traceback)
  • routes through os._exit to skip the interpreter-finalization SIGABRT documented in that same function for commands that loaded lancedb (e.g. Ctrl+C during the optimize phase)

Tests (tests/package/test_pipeline.py)

  • test_popen_captures_normal_child_output — regression guard that the reorder didn't break normal stdout/stderr/code capture.
  • test_popen_abort_terminates_child_without_joining — when proc.wait() raises while drain threads block forever, the helper must terminate the child and re-raise without joining. Runs the SUT in a daemon thread with a deadline so a regression fails the assertion instead of hanging the session. Verified to fail (assert not True) on the old join-first order and pass on the fix.

Verification

  • Targeted: tests/package/test_pipeline.py (6) + installer/cli/graph-only subset (123) pass.
  • Full suite: 1302 passed, 14 skipped, 0 failures.
  • Both entrypoints route Ctrl+C to a clean os._exit(130) (verified by simulating KeyboardInterrupt from main).

🤖 Generated with Claude Code

HumanBean17 and others added 2 commits July 10, 2026 23:49
_popen_capturing_stderr joined the drain threads (Thread.join, no timeout)
BEFORE proc.wait(). Thread.join blocks on a lock whose infinite-timeout
acquire CPython never polls for signals, so while the child held the pipes
open during teardown the join could not be interrupted — install/reprocess
hung on Ctrl+C until the child closed its pipes (cocoindex teardown + its
flow-server grandchild can hold them a long time).

Wait on the child first (Popen.wait is interruptible via PEP 475:
os.waitpid returns EINTR, CPython raises KeyboardInterrupt). On abort,
terminate the child non-blocking and re-raise WITHOUT joining the drain
threads (a join re-introduces the hang; daemon threads vanish at exit).

Also catch KeyboardInterrupt in cli/jrag _console_script_main for a clean
os._exit(130): no traceback, and routes through os._exit to skip the
interpreter-finalization SIGABRT documented there for lancedb-loaded
commands (e.g. Ctrl+C during the optimize phase).

Tests: normal-capture regression guard + abort-without-joining (verified
to fail on the old join-first order, pass on the fix).

Co-Authored-By: Claude <noreply@anthropic.com>
Address code-review findings (all non-blocking; reviewers verdict: ready
to merge). No Critical/Important code defects — these close test/coverage
gaps and improve accuracy:

- test: cli _console_script_main KeyboardInterrupt -> os._exit(130), notice
  on stderr (not stdout), no interpreter finalization. Previously only rc
  in {0,2} was tested at this layer.
- test: _abort_child must swallow an already-dead child (ProcessLookupError)
  so teardown never replaces the abort cause on the exception path.
- pipeline: collapse redundant (OSError, ProcessLookupError) -> OSError
  (the latter is a subclass); document that the post-wait joins rely on the
  pipe-inheriting grandchild closing its write ends (on Ctrl+C via the
  shared-process-group SIGINT), so a future reader doesn't assume both
  phases of the wait are equally interruptible.

Co-Authored-By: Claude <noreply@anthropic.com>
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