From 32bd2a21814a6e9bfc27eae386b63e1a532e4757 Mon Sep 17 00:00:00 2001 From: oyasumi Date: Sun, 9 Aug 2026 00:53:44 +0000 Subject: [PATCH] fix(core): raise the open-file limit so many-agent scans don't exhaust fds A scan runs dozens of agents concurrently, each holding a sandbox session, a browser session, a model client, and a SQLite handle. At the common 1024 soft file-descriptor limit that budget is exhausted around ~32 agents, after which SQLite can no longer open agents.db and agents fail en masse with "unable to open database file" (an fd-exhaustion symptom, not DB corruption). Measured directly: fd usage scales ~linearly with concurrent agents and hits 1024 at 32 agents; fds are reclaimed as agents finish, so this is a ceiling problem, not a leak. Strix never set its own limit, inheriting whatever the launching shell had (often 1024). Raise RLIMIT_NOFILE toward the hard cap at scan start (best-effort, idempotent, POSIX-guarded; warns when the hard cap is itself too low to lift without a privileged operator). Runs no longer depend on the operator setting ulimit. Co-Authored-By: Claude Opus 5 (1M context) --- strix/core/runner.py | 36 +++++++++++++++++++++ tests/test_open_file_limit.py | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/test_open_file_limit.py diff --git a/strix/core/runner.py b/strix/core/runner.py index efcf1be3..8ac98b75 100644 --- a/strix/core/runner.py +++ b/strix/core/runner.py @@ -62,6 +62,41 @@ logger = logging.getLogger(__name__) StreamEventSink = Callable[[str, Any], None] +# A scan runs many agents at once, each holding a sandbox session, a browser +# session, a model client, and a SQLite handle. At the common 1024 soft limit +# that closes the file-descriptor budget at a few dozen agents, surfacing as +# "unable to open database file" once SQLite can no longer open agents.db. +_MIN_OPEN_FILE_SOFT_LIMIT = 65536 + + +def raise_open_file_limit(minimum: int = _MIN_OPEN_FILE_SOFT_LIMIT) -> None: + """Raise the process open-file soft limit toward its hard cap. + + Idempotent and best-effort: does nothing on non-POSIX platforms, when the + soft limit already suffices, or when the hard cap forbids the raise (which + needs a privileged operator to lift). Never fails a scan. + """ + try: + import resource + except ImportError: + return # non-POSIX (e.g. Windows) has no RLIMIT_NOFILE + try: + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + target = minimum if hard == resource.RLIM_INFINITY else min(minimum, hard) + if soft >= target: + return + resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard)) + logger.info("raised open-file soft limit %d -> %d (hard=%s)", soft, target, hard) + if hard != resource.RLIM_INFINITY and hard < minimum: + logger.warning( + "open-file hard limit is %d, below the %d a large scan may need; " + "raise it (ulimit -Hn) to avoid file-descriptor exhaustion", + hard, + minimum, + ) + except (ValueError, OSError): + logger.debug("could not raise open-file limit", exc_info=True) + def _safety_mode(scan_config: dict[str, Any]) -> SafetyMode: raw = str(scan_config.get("safety_mode") or "off") @@ -158,6 +193,7 @@ async def run_strix_scan( state_dir.mkdir(parents=True, exist_ok=True) teardown_logging = setup_scan_logging(run_dir) set_scan_id(scan_id) + raise_open_file_limit() agents_path = state_dir / "agents.json" agents_db = state_dir / "agents.db" diff --git a/tests/test_open_file_limit.py b/tests/test_open_file_limit.py new file mode 100644 index 00000000..06da516b --- /dev/null +++ b/tests/test_open_file_limit.py @@ -0,0 +1,59 @@ +"""The scan runner raises the open-file soft limit so many-agent scans don't +exhaust file descriptors (surfacing as SQLite 'unable to open database file').""" + +from __future__ import annotations + +import pytest + +from strix.core.runner import raise_open_file_limit + + +resource = pytest.importorskip("resource") + + +@pytest.fixture +def _restore_nofile() -> None: + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + try: + yield + finally: + resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard)) + + +@pytest.mark.usefixtures("_restore_nofile") +def test_raises_soft_limit_toward_hard() -> None: + _, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + if hard != resource.RLIM_INFINITY and hard <= 1024: + pytest.skip("hard limit too low to raise in this environment") + resource.setrlimit(resource.RLIMIT_NOFILE, (1024, hard)) + + raise_open_file_limit(4096) + + soft, _ = resource.getrlimit(resource.RLIMIT_NOFILE) + assert soft >= min(4096, hard) + + +@pytest.mark.usefixtures("_restore_nofile") +def test_never_lowers_an_already_high_limit() -> None: + _, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + if hard == resource.RLIM_INFINITY or hard < 8192: + pytest.skip("need headroom above the requested minimum") + resource.setrlimit(resource.RLIMIT_NOFILE, (8192, hard)) + + raise_open_file_limit(4096) + + soft, _ = resource.getrlimit(resource.RLIMIT_NOFILE) + assert soft == 8192 + + +@pytest.mark.usefixtures("_restore_nofile") +def test_does_not_exceed_the_hard_cap() -> None: + _, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + if hard == resource.RLIM_INFINITY: + pytest.skip("no finite hard cap to test against") + resource.setrlimit(resource.RLIMIT_NOFILE, (min(1024, hard), hard)) + + raise_open_file_limit(hard + 1_000_000) # ask for more than allowed + + soft, _ = resource.getrlimit(resource.RLIMIT_NOFILE) + assert soft <= hard