Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion java_codebase_rag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import argparse
import asyncio
import json
import os
import pprint
import shutil
import sys
Expand Down Expand Up @@ -930,5 +931,21 @@ def main(argv: list[str] | None = None) -> int:
return 2


def _console_script_main() -> None:
"""Real CLI entry: terminate without interpreter finalization.

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). Flushing + ``os._exit`` skips that
racy teardown — the command has already done its work and emitted its result.
``main()`` stays return-based so in-process test callers (``cli.main(...)``)
keep working.
"""
rc = main()
sys.stdout.flush()
sys.stderr.flush()
os._exit(rc)


if __name__ == "__main__":
raise SystemExit(main())
_console_script_main()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Repository = "https://github.com/HumanBean17/java-codebase-rag"
Issues = "https://github.com/HumanBean17/java-codebase-rag/issues"

[project.scripts]
java-codebase-rag = "java_codebase_rag.cli:main"
java-codebase-rag = "java_codebase_rag.cli:_console_script_main"
java-codebase-rag-mcp = "server:main"

[tool.setuptools]
Expand Down
56 changes: 56 additions & 0 deletions tests/test_java_codebase_rag_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1174,3 +1174,59 @@ def fake_asyncio_run(awaitable, *, debug=None):

server_mod.resolve_operator_config.assert_called_once()
assert server_mod.resolve_operator_config.call_args.kwargs["source_root"] == server_mod._project_root()


def test_console_script_main_propagates_rc_via_os_exit_after_flush(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The installed CLI entry must flush streams and os._exit(rc) rather than
return into normal interpreter finalization.

A pyarrow/lance worker thread can outlive CPython finalization in a one-shot
CLI subprocess and trip ``PyGILState_Release`` (SIGABRT, exit -6). Routing the
real entry through ``_console_script_main`` skips that racy teardown; ``main()``
itself stays return-based so in-process test callers keep working.
"""
import os as _os

from java_codebase_rag import cli as cli

class _StubStream:
def __init__(self) -> None:
self.flushed = False

def flush(self) -> None:
self.flushed = True

for fake_rc in (0, 2):
out = _StubStream()
err = _StubStream()
snapshot: dict[str, object] = {}

monkeypatch.setattr(cli, "main", lambda rc=fake_rc: rc)
monkeypatch.setattr(sys, "stdout", out)
monkeypatch.setattr(sys, "stderr", err)

def fake_exit(code: int) -> None:
snapshot["exit_code"] = code
snapshot["out_flushed_before_exit"] = out.flushed
snapshot["err_flushed_before_exit"] = err.flushed

monkeypatch.setattr(_os, "_exit", fake_exit)

result = cli._console_script_main()

assert snapshot["exit_code"] == fake_rc, fake_rc
assert snapshot["out_flushed_before_exit"] is True, fake_rc
assert snapshot["err_flushed_before_exit"] is True, fake_rc
assert result is None, fake_rc


def test_console_script_entry_point_routes_through_wrapper() -> None:
"""``[project.scripts]`` must point ``java-codebase-rag`` at
``_console_script_main`` (not ``main``) so the deterministic-exit path is the
one the installed CLI actually uses."""
pyproject = (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text(encoding="utf-8")
assert 'java-codebase-rag = "java_codebase_rag.cli:_console_script_main"' in pyproject
assert 'java-codebase-rag = "java-codebase-rag:main"' not in pyproject
assert 'java-codebase-rag = "java_codebase_rag.cli:main"' not in pyproject
Loading