Skip to content

Commit c2d4d2a

Browse files
HumanBean17claude
andcommitted
fix: raise RLIMIT_NOFILE and use real cocoindex inflight env var
The #293 fix (#300) set COCOINDEX_SOURCE_MAX_INFLIGHT_ROWS — an env var CocoIndex never reads. The real semaphore var is COCOINDEX_MAX_INFLIGHT_COMPONENTS (default 1024, see cocoindex/_internal/app.py), so the throttle was a no-op and the EMFILE "Too many open files (os error 24)" recurred (#306). Layer A (correctness): centralize the throttle in cocoindex_subprocess_env_defaults() using the real env var; both cocoindex subprocess sites (pipeline.run_cocoindex_update + server._cocoindex_subprocess_env) apply it via setdefault so an operator override still wins. Layer B (deterministic): raise_fd_limit() raises the process soft RLIMIT_NOFILE toward its hard limit (capped 65536, never infinity) at cli.main / server.main startup. rlimits are inherited across fork+exec, so cocoindex children get headroom regardless of launch context — macOS GUI/launchd/IDE-launched processes inherit a 256 FD ceiling, not the shell's raised limit, which is why the error recurred even on hosts whose terminal shows a high ulimit. No ontology/schema/re-index impact. Fixes #306. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 33da915 commit c2d4d2a

8 files changed

Lines changed: 194 additions & 6 deletions

File tree

java_codebase_rag/_fdlimit.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Raise the process soft file-descriptor limit to avoid LanceDB EMFILE.
2+
3+
LanceDB's merge-insert path opens many file handles concurrently; under the
4+
default OS soft ``RLIMIT_NOFILE`` (256 on macOS processes launched by GUI /
5+
launchd / IDE hosts, *not* the shell's raised limit) this exhausts file
6+
descriptors and surfaces as::
7+
8+
RuntimeError: lance error: LanceError(IO): ... Too many open files (os error 24)
9+
lance-io-4.0.0/src/local.rs:133:24
10+
11+
``raise_fd_limit`` raises the process's *own* soft limit toward its hard limit.
12+
``RLIMIT_NOFILE`` is inherited across ``fork``+``exec``, so every CocoIndex /
13+
``cocoindex-code`` child spawned afterwards inherits the headroom. This fixes the
14+
failure regardless of launch context (shell vs IDE vs MCP host) and regardless of
15+
Lance's internal IO concurrency.
16+
17+
Never raise to ``RLIM_INFINITY`` — that breaks ``select()``/kqueue and Python
18+
selectors on macOS; ``cap`` bounds the target to a safe value.
19+
20+
See https://github.com/HumanBean17/java-codebase-rag/issues/306
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import resource
26+
27+
# Safe ceiling well above LanceDB's appetite, comfortably below macOS libc
28+
# quirks. The hard limit caps it further if lower (locked-down servers).
29+
_DEFAULT_CAP = 65536
30+
31+
32+
def raise_fd_limit(cap: int = _DEFAULT_CAP) -> None:
33+
"""Raise this process's soft ``RLIMIT_NOFILE`` toward its hard limit.
34+
35+
Best-effort and silent: never raises. No-op where ``RLIMIT_NOFILE`` is
36+
unsupported (Windows) or where the soft limit already meets ``min(hard, cap)``.
37+
"""
38+
if not hasattr(resource, "RLIMIT_NOFILE"):
39+
return
40+
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
41+
target = min(hard, cap)
42+
if soft >= target:
43+
return
44+
try:
45+
resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard))
46+
except (ValueError, OSError):
47+
# Best-effort: a locked-down environment shouldn't fail the run.
48+
pass

java_codebase_rag/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
index_dir_has_existing_artifacts,
2222
resolve_operator_config,
2323
)
24+
from java_codebase_rag._fdlimit import raise_fd_limit
2425
from java_codebase_rag.pipeline import clip, run_build_ast_graph, run_cocoindex_drop, run_cocoindex_update, run_incremental_graph
2526
from java_ontology import VALID_UNRESOLVED_CALL_REASONS
2627

@@ -902,6 +903,7 @@ def build_parser() -> argparse.ArgumentParser:
902903

903904

904905
def main(argv: list[str] | None = None) -> int:
906+
raise_fd_limit()
905907
raw = list(argv if argv is not None else sys.argv[1:])
906908
if raw and raw[0] == "refresh":
907909
print(_REFRESH_DEPRECATION, file=sys.stderr)

java_codebase_rag/config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@
2525
ENV_DEBUG_CONTEXT = "JAVA_CODEBASE_RAG_DEBUG_CONTEXT"
2626
ENV_RUN_HEAVY = "JAVA_CODEBASE_RAG_RUN_HEAVY"
2727

28+
# CocoIndex inflight-component throttle. CocoIndex's default is 1024 inflight
29+
# components (cocoindex/_internal/app.py: ``_ENV_MAX_INFLIGHT_COMPONENTS``),
30+
# which spawns enough concurrent LanceDB merge-inserts to exhaust OS file
31+
# descriptors under default ulimits -> "Too many open files (os error 24)".
32+
# NOTE: this is the REAL env var. An earlier fix (#293) set the non-existent
33+
# ``COCOINDEX_SOURCE_MAX_INFLIGHT_ROWS`` — CocoIndex never reads it, so it was a
34+
# no-op and the EMFILE error recurred (#306).
35+
COCOINDEX_MAX_INFLIGHT_COMPONENTS_ENV = "COCOINDEX_MAX_INFLIGHT_COMPONENTS"
36+
COCOINDEX_DEFAULT_MAX_INFLIGHT_COMPONENTS = "256"
37+
38+
39+
def cocoindex_subprocess_env_defaults() -> dict[str, str]:
40+
"""Env defaults applied to every CocoIndex subprocess to bound concurrency.
41+
42+
Apply with ``env.setdefault(...)`` so a caller-provided (operator) value
43+
always wins. See :issue:`306`.
44+
"""
45+
return {
46+
COCOINDEX_MAX_INFLIGHT_COMPONENTS_ENV: COCOINDEX_DEFAULT_MAX_INFLIGHT_COMPONENTS
47+
}
48+
2849
_DEFAULT_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
2950

3051
# Matches either $VAR or ${VAR} (POSIX shell variable syntax).

java_codebase_rag/pipeline.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from java_codebase_rag.cli_format import Spinner, is_noise_line, stderr_is_tty
1313
from java_codebase_rag.cli_progress import emit_vectors_finish, emit_vectors_start
14+
from java_codebase_rag.config import cocoindex_subprocess_env_defaults
1415

1516
COCOINDEX_TARGET = "java_index_flow_lancedb.py:JavaCodeIndexLance"
1617

@@ -128,10 +129,11 @@ def run_cocoindex_update(
128129
stdout="",
129130
stderr=f"java_index_flow_lancedb.py not found under {bd}",
130131
)
131-
# Set CocoIndex concurrency limits to prevent "too many open files" error
132-
# See: https://github.com/HumanBean17/java-codebase-rag/issues/293
132+
# Cap CocoIndex concurrency to avoid EMFILE ("too many open files") under
133+
# default OS fd limits. See: https://github.com/HumanBean17/java-codebase-rag/issues/306
133134
env = env.copy()
134-
env.setdefault("COCOINDEX_SOURCE_MAX_INFLIGHT_ROWS", "256")
135+
for _k, _v in cocoindex_subprocess_env_defaults().items():
136+
env.setdefault(_k, _v)
135137
cmd: list[str] = [str(exe), "update", COCOINDEX_TARGET]
136138
if full_reprocess:
137139
cmd.extend(["--full-reprocess", "-f"])

server.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
emit_vectors_finish,
1717
emit_vectors_start,
1818
)
19+
from java_codebase_rag._fdlimit import raise_fd_limit
1920
from java_codebase_rag.config import (
21+
cocoindex_subprocess_env_defaults,
2022
discover_project_root,
2123
emit_legacy_env_hints_if_present,
2224
resolved_sbert_model_for_process_env,
@@ -162,9 +164,10 @@ def _cocoindex_subprocess_env(project_root: Path) -> dict[str, str]:
162164
idx = os.environ.get("JAVA_CODEBASE_RAG_INDEX_DIR", "").strip()
163165
if idx:
164166
sub_env["JAVA_CODEBASE_RAG_INDEX_DIR"] = str(Path(idx).expanduser().resolve())
165-
# Set CocoIndex concurrency limits to prevent "too many open files" error
166-
# See: https://github.com/HumanBean17/java-codebase-rag/issues/293
167-
sub_env.setdefault("COCOINDEX_SOURCE_MAX_INFLIGHT_ROWS", "256")
167+
# Cap CocoIndex concurrency to avoid EMFILE ("too many open files") under
168+
# default OS fd limits. See: https://github.com/HumanBean17/java-codebase-rag/issues/306
169+
for _k, _v in cocoindex_subprocess_env_defaults().items():
170+
sub_env.setdefault(_k, _v)
168171
return sub_env
169172

170173

@@ -622,6 +625,7 @@ async def resolve(
622625

623626

624627
def main() -> None:
628+
raise_fd_limit()
625629
emit_legacy_env_hints_if_present()
626630

627631
# Load YAML config and apply embedding settings to environment

tests/test_config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,19 @@ def test_existing_behavior_unchanged(self, tmp_path, monkeypatch):
239239

240240
# Also test that index_dir derives from source_root
241241
assert result.index_dir == tmp_path / ".java-codebase-rag"
242+
243+
244+
def test_cocoindex_subprocess_env_defaults_uses_real_inflight_env_var() -> None:
245+
"""The throttle must use CocoIndex's REAL env var name.
246+
247+
The earlier #293 "fix" set ``COCOINDEX_SOURCE_MAX_INFLIGHT_ROWS``, an env
248+
var CocoIndex never reads (it reads ``COCOINDEX_MAX_INFLIGHT_COMPONENTS``,
249+
default 1024), so it was a no-op and the EMFILE error recurred (#306).
250+
"""
251+
from java_codebase_rag.config import cocoindex_subprocess_env_defaults
252+
253+
defaults = cocoindex_subprocess_env_defaults()
254+
255+
assert defaults["COCOINDEX_MAX_INFLIGHT_COMPONENTS"] == "256"
256+
# The bogus name from the broken #293 fix must NOT leak back in.
257+
assert "COCOINDEX_SOURCE_MAX_INFLIGHT_ROWS" not in defaults

tests/test_fd_limit.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Tests for ``raise_fd_limit`` — EMFILE / "too many open files" mitigation.
2+
3+
LanceDB's merge-insert path opens many file handles concurrently; under the
4+
default OS soft ``RLIMIT_NOFILE`` (256 on macOS GUI/launchd-launched processes)
5+
this exhausts file descriptors -> ``Too many open files (os error 24)`` in
6+
``lance-io/local.rs``. ``raise_fd_limit`` raises the process's own soft limit
7+
toward its hard limit so cocoindex children (which inherit rlimits) get headroom.
8+
9+
See https://github.com/HumanBean17/java-codebase-rag/issues/306
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from java_codebase_rag import _fdlimit
15+
16+
17+
def test_raises_soft_limit_up_to_cap(monkeypatch):
18+
"""When soft < min(hard, cap), raise soft to the target and keep hard."""
19+
monkeypatch.setattr(_fdlimit.resource, "getrlimit", lambda _rlim: (256, 65536))
20+
calls: list[tuple] = []
21+
monkeypatch.setattr(
22+
_fdlimit.resource, "setrlimit", lambda rlim, limits: calls.append((rlim, limits))
23+
)
24+
25+
_fdlimit.raise_fd_limit(cap=4096)
26+
27+
assert calls == [(_fdlimit.resource.RLIMIT_NOFILE, (4096, 65536))]
28+
29+
30+
def test_caps_target_at_hard_limit(monkeypatch):
31+
"""Never exceed the hard limit even when cap > hard."""
32+
monkeypatch.setattr(_fdlimit.resource, "getrlimit", lambda _rlim: (256, 1024))
33+
calls: list[tuple] = []
34+
monkeypatch.setattr(
35+
_fdlimit.resource, "setrlimit", lambda rlim, limits: calls.append((rlim, limits))
36+
)
37+
38+
_fdlimit.raise_fd_limit(cap=65536) # target = min(1024, 65536) = 1024
39+
40+
assert calls == [(_fdlimit.resource.RLIMIT_NOFILE, (1024, 1024))]
41+
42+
43+
def test_noop_when_soft_already_at_or_above_target(monkeypatch):
44+
"""No setrlimit call when the soft limit is already high enough."""
45+
monkeypatch.setattr(_fdlimit.resource, "getrlimit", lambda _rlim: (1048576, 1048576))
46+
calls: list[tuple] = []
47+
monkeypatch.setattr(
48+
_fdlimit.resource, "setrlimit", lambda rlim, limits: calls.append((rlim, limits))
49+
)
50+
51+
_fdlimit.raise_fd_limit(cap=65536)
52+
53+
assert calls == []
54+
55+
56+
def test_noop_when_rlimit_nofile_unsupported(monkeypatch):
57+
"""Windows-like host with no RLIMIT_NOFILE: no error, no setrlimit."""
58+
monkeypatch.delattr(_fdlimit.resource, "RLIMIT_NOFILE")
59+
calls: list[tuple] = []
60+
monkeypatch.setattr(
61+
_fdlimit.resource, "setrlimit", lambda *a, **k: calls.append((a, k))
62+
)
63+
64+
_fdlimit.raise_fd_limit() # must not raise
65+
66+
assert calls == []
67+
68+
69+
def test_swallows_setrlimit_errors(monkeypatch):
70+
"""Best-effort: a failing setrlimit must never propagate."""
71+
monkeypatch.setattr(_fdlimit.resource, "getrlimit", lambda _rlim: (256, 65536))
72+
73+
def boom(rlim, limits):
74+
raise OSError("permission denied")
75+
76+
monkeypatch.setattr(_fdlimit.resource, "setrlimit", boom)
77+
78+
_fdlimit.raise_fd_limit(cap=4096) # must not raise

tests/test_mcp_tools.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,20 @@ def test_cocoindex_subprocess_env_sets_project_root(monkeypatch, tmp_path) -> No
9494
env = server._cocoindex_subprocess_env(resolved)
9595
assert env["JAVA_CODEBASE_RAG_SOURCE_ROOT"] == str(resolved)
9696
assert env["PRESERVE_ME_FOR_SUBPROCESS"] == "ok"
97+
98+
99+
def test_cocoindex_subprocess_env_applies_inflight_default(monkeypatch, tmp_path) -> None:
100+
"""The MCP-triggered cocoindex subprocess must carry the real inflight throttle.
101+
102+
Regression guard for #306: the throttle env var must be CocoIndex's real
103+
``COCOINDEX_MAX_INFLIGHT_COMPONENTS`` (default 1024 -> capped to 256), not the
104+
non-existent ``COCOINDEX_SOURCE_MAX_INFLIGHT_ROWS`` from the broken #293 fix.
105+
"""
106+
import server
107+
108+
proj = tmp_path / "external-java-repo"
109+
proj.mkdir()
110+
env = server._cocoindex_subprocess_env(proj.resolve())
111+
112+
assert env["COCOINDEX_MAX_INFLIGHT_COMPONENTS"] == "256"
113+
assert "COCOINDEX_SOURCE_MAX_INFLIGHT_ROWS" not in env

0 commit comments

Comments
 (0)