Skip to content

feat(guest): raise spawned-process RLIMIT_NOFILE with a sane default and env override#826

Open
stubbi wants to merge 3 commits into
mainfrom
feat/guest-rlimits-786
Open

feat(guest): raise spawned-process RLIMIT_NOFILE with a sane default and env override#826
stubbi wants to merge 3 commits into
mainfrom
feat/guest-rlimits-786

Conversation

@stubbi

@stubbi stubbi commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

  • Mitos boots Firecracker microVMs and forks them via copy-on-write snapshots, exposed through CRDs; the guest agent is PID 1 inside each VM and spawns every user process.
  • This touches the guest agent (guest/agent-rs): the exec, PTY, run_code Python kernel, and serving-workload spawn paths.
  • A bare Linux init inherits a low soft RLIMIT_NOFILE (commonly 1024), and the agent passed that limit to every child. Data libraries that open many files at import time (pandas, openpyxl, scikit) then die with EMFILE, so a plain import pandas could fail inside a sandbox even though the host-side caps (husk pod cgroup, per-sandbox stream caps) were nowhere near saturated. Those host caps do not govern in-guest per-process rlimits; only setrlimit inside the guest does.
  • It needs addressing now because run_code and interactive exec are core sandbox surfaces (Experience is DNA): a first import pandas dying with a cryptic EMFILE is a dead end for the data-science path.
  • This pull request adds sys/rlimit.rs, which raises the soft RLIMIT_NOFILE of every spawned process to a documented default (65536), clamped to the inherited hard limit and never lowered below the current soft, via an async-signal-safe pre_exec hook wired into all four guest spawn paths. An operator override is read from the MITOS_RLIMIT_NOFILE environment variable.
  • The benefit is that data-science and file-heavy workloads run inside a sandbox without hitting the 1024-descriptor wall, while the host-side isolation and resource envelope are unchanged.

Linked Issues or Issue Description

What Changed

  • New guest/agent-rs/src/sys/rlimit.rs: pure, unit-tested parse_nofile_override and resolve_nofile_soft plus an async-signal-safe pre_exec hook (apply_nofile_limit) that raises the child's soft RLIMIT_NOFILE. Linux-only syscall body; a no-op on the darwin developer build, matching sys/pty.rs.
  • Default soft limit of 65536, clamped to the inherited hard limit (so setrlimit cannot fail on the value) and never lowered below the current soft. The hard limit is never raised (that needs CAP_SYS_RESOURCE, out of scope).
  • Operator override via the MITOS_RLIMIT_NOFILE environment variable, read once per spawn in the parent so the post-fork closure allocates nothing; an unparseable value is ignored with a warning and the default applies.
  • Wired the hook into every guest spawn path: service/exec.rs (exec, exec-stream, PTY), kernel/driver.rs (the run_code Python kernel), and service/workload.rs (the serving-workload supervisor). The exec and driver tokio builders were refactored to build a std::process::Command, register the hook, then convert with tokio::process::Command::from, keeping all unsafe inside sys/ (the same pattern the PTY path already uses).
  • docs/templates.md: a new "Guest process resource limits" section documenting the default, the env seam, and a follow-up bullet to plumb a first-class template.spec field (for example resources.rlimits.nofile) through CreateTemplate and Fork into the guest boot env.

Verification

  • cargo test in guest/agent-rs: the 7 new sys::rlimit unit tests pass on darwin (parse accept/reject, default raise/clamp/no-lower, override honor/clamp/lower). Pre-existing darwin-only failures in sys::signal (needs Linux /proc SigCgt) and kernel::driver runcode (needs a Python ipykernel) are unrelated to this change and are in files this PR does not modify.
  • cargo build in guest/agent-rs: clean (only pre-existing dead_code warnings in fork/network.rs and sys/netlink.rs).
  • cargo clippy --all-targets: zero warnings attributable to the changed files; all reported warnings are pre-existing dead_code / unused-import findings elsewhere.
  • cargo fmt --check: the new sys/rlimit.rs is fmt-clean; the repo-wide diff is a toolchain-version skew (local rustfmt 1.8.0 reformats every checked-in file, including untouched ones), not introduced here.
  • CI (firecracker-test job, Linux runner): this PR adds a cargo test --lib sys::rlimit step to the existing guest-agent job in kvm-test.yaml, so the 7 unit tests AND the Linux-gated integration test spawned_child_sees_raised_soft_nofile (spawns /bin/sh -c 'ulimit -Sn' and asserts the child's soft limit is actually raised) now run on a real Linux host in required CI. That integration test is compiled out on the darwin developer host, which is why it could not run in my local cargo test.
  • Not asserted by an automated check: the raised limit surviving a real Firecracker snapshot/restore fork. The firecracker-test job boots a real VM and execs over vsock through the changed spawn paths (so it proves those paths still work on KVM), but it does not assert the specific NOFILE value inside a forked sandbox. Deferred to the follow-up alongside the first-class template field.

Risks

  • Low risk. The limit is only ever raised toward a value clamped to the inherited hard limit, so setrlimit cannot fail on the value and no spawn path can regress into a spawn failure. The change is confined to the guest agent and does not move the host or forkd escape surface: an in-guest process opening more descriptors consumes only its own VM's kernel memory, bounded by the VM's fixed memory budget, and cannot affect other tenants (separate VMs) or the host. No threat-model delta is required for that reason. No fork-correctness hazard: rlimits are per-process and set deterministically at spawn, touching no RNG, clock, or secret inheritance.

Model Used

  • Claude Opus 4.8 (1M context), extended thinking.

Checklist

  • PR title is a conventional commit (feat)
  • Thinking Path traces from project context to this change
  • Model Used is filled in (with version and capability details)
  • Tests added for behavior changes, in the same commit (TDD)
  • Docs updated in the same PR
  • Threat-model delta (docs/threat-model.md) included if the security surface moved (not required here; rationale in Risks)
  • Benchmark run (bench/) included if the hot path was touched (not a measured hot path; no public number claimed)
  • No em or en dashes introduced anywhere
  • Secret values never logged, in errors, in condition messages, or on host paths
  • No internal/instance-local references (only public #NNN / mitos-run/mitos URLs)
  • Every commit carries a Signed-off-by trailer (git commit -s)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Increased the default soft file-descriptor limit for processes started by the guest agent (to 65536), improving reliability for workloads needing many open files.
    • Added an environment-based override to customize the soft limit for each spawn.
    • Expanded documentation with an end-to-end, KVM-gated proof of the raised-limit behavior and noted a follow-up toward declarative configuration.
  • Bug Fixes
    • Applied the raised limit consistently across shell, PTY, detached, and driver-started processes.
  • Tests
    • Added a CI step to run the guest-agent RLIMIT_NOFILE test suite.

…and env override

A bare Linux init inherits a low soft RLIMIT_NOFILE (commonly 1024), and the
guest agent (PID 1) passed that limit to every exec, PTY, run_code kernel, and
serving-workload child. Data libraries that open many files at import time
(pandas, openpyxl, scikit) then die with EMFILE, the exact microsandbox failure
(issue #786). Host-side caps (husk pod cgroup, per-sandbox stream caps) do not
govern in-guest per-process rlimits; only setrlimit inside the guest does.

New sys/rlimit.rs registers an async-signal-safe pre_exec hook that raises the
child's soft RLIMIT_NOFILE to a documented default of 65536, clamped to the
inherited hard limit and never lowered below the current soft. An operator
override is read from the MITOS_RLIMIT_NOFILE environment variable (a decimal
file count), read once in the parent so the post-fork closure only performs
getrlimit, pure integer arithmetic, and setrlimit. The limit computation and
override parsing are pure and unit-tested on any host; the end-to-end proof
(a spawned guest process observing the raised limit) is KVM gated.

The hook is wired into all four guest spawn paths: exec, exec-stream, PTY, the
run_code Python kernel driver, and the serving-workload supervisor. Docs cover
the default, the env seam, and the scoped follow-up to plumb a first-class
template.spec field into that boot env.

Refs #786

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@stubbi, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9275342e-aa7c-4a9e-b7b7-a57137bc8bce

📥 Commits

Reviewing files that changed from the base of the PR and between 9969726 and 64cab93.

📒 Files selected for processing (2)
  • docs/templates.md
  • guest/agent-rs/src/sys/rlimit.rs
📝 Walkthrough

Walkthrough

This PR adds a Rust module that raises the guest agent’s soft RLIMIT_NOFILE for spawned processes, with a default value, an environment-variable override, wiring into spawn paths, and updated docs and CI test coverage.

Changes

Guest RLIMIT_NOFILE raising

Layer / File(s) Summary
Rlimit module: constants, parsing, resolution, and pre_exec hook
guest/agent-rs/src/sys/rlimit.rs, guest/agent-rs/src/sys/mod.rs
Adds the nofile limit constants, override parsing, soft-limit resolution, environment lookup, Linux pre_exec installation, non-Linux no-op behavior, unit tests, and registers the new rlimit submodule.
Wiring apply_nofile_limit into process spawn points
guest/agent-rs/src/kernel/driver.rs, guest/agent-rs/src/service/exec.rs, guest/agent-rs/src/service/workload.rs
Changes driver startup, shell exec, PTY exec, and detached workload spawning to use std::process::Command, apply the nofile limit hook, then spawn through Tokio.
Documentation and CI coverage
docs/templates.md, .github/workflows/kvm-test.yaml
Documents the guest process resource limits behavior and follow-up item, and adds a workflow step to run the sys::rlimit tests.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise, conventional, and accurately describes the main change.
Description check ✅ Passed The PR description follows the template and includes the required sections with substantive content.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/guest-rlimits-786

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@guest/agent-rs/src/sys/rlimit.rs`:
- Around line 209-254: The test spawned_child_sees_raised_soft_nofile mutates
the process-wide RLIMIT_NOFILE and can race with other parallel tests; update
this test to run in isolation. Use an isolation mechanism such as #[serial] on
spawned_child_sees_raised_soft_nofile or otherwise ensure the rlimit.rs test
suite runs single-threaded, while keeping the existing apply_nofile_limit and
resolve_nofile_soft behavior intact.
- Around line 38-51: The NOFILE override path currently accepts 0 and other tiny
values, which can be applied directly by apply_nofile_limit_inner and disable
file descriptors for spawned children. Update parse_nofile_override and/or
resolve_nofile_soft to enforce a documented minimum floor before returning a
value, and keep invalid/too-small overrides falling back to DEFAULT_NOFILE_SOFT.
Add a unit test around parse_nofile_override or resolve_nofile_soft to cover the
boundary case for zero and other sub-minimum inputs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 95d5df30-c762-49ae-97f2-ba5de64de920

📥 Commits

Reviewing files that changed from the base of the PR and between f74f4f0 and 43568b9.

📒 Files selected for processing (6)
  • docs/templates.md
  • guest/agent-rs/src/kernel/driver.rs
  • guest/agent-rs/src/service/exec.rs
  • guest/agent-rs/src/service/workload.rs
  • guest/agent-rs/src/sys/mod.rs
  • guest/agent-rs/src/sys/rlimit.rs

Comment thread guest/agent-rs/src/sys/rlimit.rs
Comment thread guest/agent-rs/src/sys/rlimit.rs
Jannes Stubbemann added 2 commits July 7, 2026 13:32
…nner

The guest-agent CI job built the Rust agent but never ran cargo test, so the
new RLIMIT_NOFILE unit tests and the Linux-gated integration test that asserts a
spawned child's soft limit is actually raised had no automated coverage. Add a
scoped `cargo test --lib sys::rlimit` step to the existing firecracker-test job
so both run on a real Linux host in a required check. Scoped to the module so it
stays deterministic and needs neither the booted-VM nor Python-kernel fixtures
the other guest tests require.

Refs #786

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
…ion test

Address two review findings on the rlimit module:

- A degenerate override (MITOS_RLIMIT_NOFILE=0 or a tiny typo) was applied
  verbatim, which would set every spawned child's soft limit that low and
  recreate the EMFILE-on-everything failure this module exists to prevent. Add a
  MIN_NOFILE_SOFT floor of 64: values below it (or empty or unparseable) are
  ignored with a warning and the sane default applies. New unit tests cover the
  zero and sub-floor boundary.
- The integration test lowered and restored the test binary's own process-wide
  RLIMIT_NOFILE, which could race with other parallel tests. Rewrite it to read
  the limits read-only and assert the spawned child's soft limit equals
  resolve_nofile_soft of this process's actual limits, touching no shared state.

Refs #786

Signed-off-by: Jannes Stubbemann <jannes@openclaw.rocks>
@stubbi

stubbi commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed in full. The code is correct and well-tested: the pre_exec closure keeps the async-signal-safety contract (env read hoisted to the parent, pure integer clamp math post-fork), RLIM_INFINITY and the never-lower-soft arm are handled, hook ordering with the setsid hook is fine, and the new CI step closes a real gap (the guest-agent job previously built but never tested).

Three optional nits, none blocking: (1) an invalid MITOS_RLIMIT_NOFILE warns on every spawn; a once-flag would silence the repeat spam on the hot exec path. (2) the Linux integration test applies the env-honoring path but asserts against resolve_nofile_soft(..., None), so a stray MITOS_RLIMIT_NOFILE in the CI environment would fail it spuriously. (3) docs say to set the variable "in the guest agent's environment" but nothing plumbs it there short of hand-editing kernel boot args; the template.spec follow-up is named, so just soften that sentence.

This touches guest/agent-rs, a named-human-reviewer path per CLAUDE.md, so it is staged rather than merged: green CI plus this review plus your ack and it goes in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant