Skip to content

Commit faeeb38

Browse files
HumanBean17claude
andauthored
fix(cli): exit via os._exit to dodge native SIGABRT at shutdown (#321)
A pyarrow/lance worker thread (loaded via lancedb in lifecycle commands) can outlive CPython finalization in a one-shot CLI subprocess and trip PyGILState_Release (SIGABRT, exit -6). It's a thread-timing race — flaky — and it intermittently red-blocked unrelated PRs: it killed the erase step of test_cli_lifecycle_round_trip_init_increment_meta_erase on PR #320 (which touches only installer.py), while the same test passed on green master #319. Route the installed `java-codebase-rag` entry through _console_script_main, which flushes stdout/stderr and os._exit(rc) instead of returning into the racy teardown. main() stays return-based so in-process test callers keep working. Co-authored-by: Claude <noreply@anthropic.com>
1 parent e866304 commit faeeb38

3 files changed

Lines changed: 75 additions & 2 deletions

File tree

java_codebase_rag/cli.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import argparse
77
import asyncio
88
import json
9+
import os
910
import pprint
1011
import shutil
1112
import sys
@@ -930,5 +931,21 @@ def main(argv: list[str] | None = None) -> int:
930931
return 2
931932

932933

934+
def _console_script_main() -> None:
935+
"""Real CLI entry: terminate without interpreter finalization.
936+
937+
A pyarrow/lance worker thread (loaded via lancedb in lifecycle commands) can
938+
outlive CPython finalization in a one-shot CLI subprocess and trip
939+
``PyGILState_Release`` (SIGABRT, exit -6). Flushing + ``os._exit`` skips that
940+
racy teardown — the command has already done its work and emitted its result.
941+
``main()`` stays return-based so in-process test callers (``cli.main(...)``)
942+
keep working.
943+
"""
944+
rc = main()
945+
sys.stdout.flush()
946+
sys.stderr.flush()
947+
os._exit(rc)
948+
949+
933950
if __name__ == "__main__":
934-
raise SystemExit(main())
951+
_console_script_main()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Repository = "https://github.com/HumanBean17/java-codebase-rag"
5353
Issues = "https://github.com/HumanBean17/java-codebase-rag/issues"
5454

5555
[project.scripts]
56-
java-codebase-rag = "java_codebase_rag.cli:main"
56+
java-codebase-rag = "java_codebase_rag.cli:_console_script_main"
5757
java-codebase-rag-mcp = "server:main"
5858

5959
[tool.setuptools]

tests/test_java_codebase_rag_cli.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,3 +1174,59 @@ def fake_asyncio_run(awaitable, *, debug=None):
11741174

11751175
server_mod.resolve_operator_config.assert_called_once()
11761176
assert server_mod.resolve_operator_config.call_args.kwargs["source_root"] == server_mod._project_root()
1177+
1178+
1179+
def test_console_script_main_propagates_rc_via_os_exit_after_flush(
1180+
monkeypatch: pytest.MonkeyPatch,
1181+
) -> None:
1182+
"""The installed CLI entry must flush streams and os._exit(rc) rather than
1183+
return into normal interpreter finalization.
1184+
1185+
A pyarrow/lance worker thread can outlive CPython finalization in a one-shot
1186+
CLI subprocess and trip ``PyGILState_Release`` (SIGABRT, exit -6). Routing the
1187+
real entry through ``_console_script_main`` skips that racy teardown; ``main()``
1188+
itself stays return-based so in-process test callers keep working.
1189+
"""
1190+
import os as _os
1191+
1192+
from java_codebase_rag import cli as cli
1193+
1194+
class _StubStream:
1195+
def __init__(self) -> None:
1196+
self.flushed = False
1197+
1198+
def flush(self) -> None:
1199+
self.flushed = True
1200+
1201+
for fake_rc in (0, 2):
1202+
out = _StubStream()
1203+
err = _StubStream()
1204+
snapshot: dict[str, object] = {}
1205+
1206+
monkeypatch.setattr(cli, "main", lambda rc=fake_rc: rc)
1207+
monkeypatch.setattr(sys, "stdout", out)
1208+
monkeypatch.setattr(sys, "stderr", err)
1209+
1210+
def fake_exit(code: int) -> None:
1211+
snapshot["exit_code"] = code
1212+
snapshot["out_flushed_before_exit"] = out.flushed
1213+
snapshot["err_flushed_before_exit"] = err.flushed
1214+
1215+
monkeypatch.setattr(_os, "_exit", fake_exit)
1216+
1217+
result = cli._console_script_main()
1218+
1219+
assert snapshot["exit_code"] == fake_rc, fake_rc
1220+
assert snapshot["out_flushed_before_exit"] is True, fake_rc
1221+
assert snapshot["err_flushed_before_exit"] is True, fake_rc
1222+
assert result is None, fake_rc
1223+
1224+
1225+
def test_console_script_entry_point_routes_through_wrapper() -> None:
1226+
"""``[project.scripts]`` must point ``java-codebase-rag`` at
1227+
``_console_script_main`` (not ``main``) so the deterministic-exit path is the
1228+
one the installed CLI actually uses."""
1229+
pyproject = (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text(encoding="utf-8")
1230+
assert 'java-codebase-rag = "java_codebase_rag.cli:_console_script_main"' in pyproject
1231+
assert 'java-codebase-rag = "java-codebase-rag:main"' not in pyproject
1232+
assert 'java-codebase-rag = "java_codebase_rag.cli:main"' not in pyproject

0 commit comments

Comments
 (0)