fix(install): make Ctrl+C abort indexing promptly#414
Merged
Conversation
_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>
This was referenced Jul 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Ctrl+C during
install(andreprocess/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:Thread.join()with no timeout callslock.acquire(block=True, timeout=-1), and CPython'sacquire_timeddoes a single blocking lock with no signal-polling loop for infinite timeout. So while the drain threads were blocked onproc.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_lockand by a regression test that fails on the old order and passes on the fix.Fix
Wait on the child first —
Popen.wait()is interruptible (PEP 475:os.waitpidreturnsEINTR, CPython raisesKeyboardInterrupt). 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
KeyboardInterruptincli._console_script_mainandjrag._console_script_main→ flush +os._exit(130):os._exitto 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— whenproc.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
tests/package/test_pipeline.py(6) + installer/cli/graph-only subset (123) pass.os._exit(130)(verified by simulatingKeyboardInterruptfrommain).🤖 Generated with Claude Code