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) <noreply@anthropic.com>
This commit is contained in:
oyasumi 2026-08-09 00:53:44 +00:00
parent 806a2f81ea
commit 32bd2a2181
2 changed files with 95 additions and 0 deletions

View file

@ -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"

View file

@ -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