mirror of
https://github.com/usestrix/strix.git
synced 2026-08-28 05:25:00 +00:00
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:
parent
806a2f81ea
commit
32bd2a2181
2 changed files with 95 additions and 0 deletions
|
|
@ -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"
|
||||
|
|
|
|||
59
tests/test_open_file_limit.py
Normal file
59
tests/test_open_file_limit.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue