Skip to content

[ML] Linear child ownership and fault-injected lifecycle for Sandbox2 - #3187

Merged
valeriy42 merged 5 commits into
mainfrom
feature/sandbox2-pr-d-lifecycle
Sep 23, 2026
Merged

valeriy42 merged 5 commits into
mainfrom
feature/sandbox2-pr-d-lifecycle

Conversation

@valeriy42

@valeriy42 valeriy42 commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Rebuilds CSandboxedProcessSpawner around an explicit lifecycle state machine (Prepared -> Launched -> IdentityCaptured -> Registered -> Monitoring -> TerminationRequested -> CleanupRequired -> Reaped/Failed), stacked on #3184.

  • Non-throwing kill-and-reap guard, armed immediately after launch and PID capture, holding its own owning reference to the sandbox handle so cleanup is order-independent during unwinding.
  • Four injectable seams (pidfd acquisition, registry allocation, monitor-thread launch, sandbox completion) for deterministic fault injection.
  • Explicit pidfd outcome classification: a kernel lacking pidfd support (ENOSYS) is the only case that falls back to signalling the sandboxee through its owned monitor handle (SIGKILL, identity-safe, no numeric-PID lookup); every other pidfd error is treated as a resource/identity failure and fails registration outright. Numeric kill(pid, ...) does not appear anywhere in this file.
  • A CAS-controlled one-shot latch replacing two-boolean coordination for the timeout-vs-completion race.
  • CSandboxedProcessSpawnerLifecycleTest_Linux.cc: fault-injection coverage for every pidfd class, allocation/resource failures, stale-generation protection, PID-reuse identity binding, the CAS race, descriptor-baseline cleanup, and destructor-latency.

Notes

  • E_Launched/E_CleanupRequired lifecycle states are declared but intentionally left unassigned (no natural single point without broader restructuring) — not silently dropped.
  • The SIGKILL-only assumption for the owned-monitor termination path (no graceful-SIGTERM variant is buildable against the pinned Sandbox2 dependency version) was cross-checked against the vendored monitor source during review.

@valeriy42
valeriy42 added this pull request to stack #3183 September 9, 2026 19:50
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch from 78f29f4 to ddf4aa1 Compare September 9, 2026 20:01
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch 2 times, most recently from bed4aba to fa81668 Compare September 9, 2026 20:18
@valeriy42

Copy link
Copy Markdown
Contributor Author

Devbox verification (Linux x86_64, real Sandbox2 toolchain): `ml_test_sandbox` filtered to `CSandboxedProcessSpawnerLifecycleTest_Linux` — 13/13 test cases passed, 372/372 assertions, exit code 0.

This surfaced and fixed two real defects that macOS-only syntax checks couldn't catch:

  • Two BOOST_TEST_REQUIRE call sites compared non-streamable types (std::future_status, a std::map iterator), which is a hard compile error for that macro (it tries to print both operands on failure). Switched to plain BOOST_REQUIRE.
  • The forkserver warm-up used to stabilize the descriptor-count baseline was wired as a BOOST_GLOBAL_FIXTURE, whose constructor runs before Boost.Test finishes its own framework setup. Forking a real process that early corrupted the framework's internal state (every test case genuinely passed, but the run still reported a spurious "Incorrect setup: no test case executed" with a non-zero exit code). Moved the warm-up to a std::call_once-guarded call from inside the normal per-case fixture instead.

Also ran clang-format via the CI's own check image and fixed several workspace-planning-doc citations that had leaked into comments/commit messages (not resolvable outside this internal effort's own tracking) — both now clean.

Buildkite (macOS/Windows legs, full cross-platform CI) still pending.

@elasticsearchmachine

Copy link
Copy Markdown

Pinging @elastic/ml-core (Team:ML)

@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch from fa81668 to f2e1feb Compare September 18, 2026 07:04
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch from f2e1feb to f69d1e2 Compare September 18, 2026 08:24
@valeriy42
valeriy42 requested a review from edsavage September 18, 2026 11:53
@valeriy42 valeriy42 changed the title [ML] PR D: linear child ownership and fault-injected lifecycle for Sandbox2 [ML] Linear child ownership and fault-injected lifecycle for Sandbox2 Sep 18, 2026
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch 2 times, most recently from 6b18935 to b125e49 Compare September 18, 2026 19:55
@edsavage
edsavage requested a lite review from Copilot September 21, 2026 01:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Unresolved critical lifecycle, cleanup, and sandbox path issues remain.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 4 High severity · 1 Medium severity

Open (5)
What changed in this PR

Rebuilds Sandbox2 child spawning with identity-safe lifecycle tracking, pidfd cleanup, fault-injection seams, and Linux lifecycle tests.

Changes:

  • Adds lifecycle state, ownership, signaling, and cleanup management.
  • Adds race, failure, descriptor, and fault-injection tests.
  • Wires implementation and test payloads into CMake.
File Summary
lib/​sandbox/​unittest/​payloads/​lifecycle_signal_payload.cc Adds the long-lived signal-observing test payload.
lib/​sandbox/​unittest/​CSandboxedProcessSpawnerLifecycleTest_Linux.cc Adds lifecycle and fault-injection coverage. Moderate findings: handle ENOSYS in the positive-path test (2 votes) and add deterministic readiness before signaling (1 vote).
lib/​sandbox/​unittest/​CMakeLists.txt Registers lifecycle tests and payload targets.
lib/​sandbox/​CSandboxedProcessSpawner_Linux.cc Implements spawning, monitoring, signaling, and cleanup. Findings: critical path mapping (1), detach failure handling (3), cleanup on await exceptions (1), and descriptor replacement safety (1); moderate allocation exception handling (1) and stale completion reporting (1).
lib/​sandbox/​CMakeLists.txt Adds the spawner implementation target.
include/​sandbox/​CSandboxedProcessSpawner.h Defines lifecycle states, registry, latch, and injection interfaces.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/sandbox/CSandboxedProcessSpawner_Linux.cc
Comment thread lib/sandbox/CSandboxedProcessSpawner_Linux.cc
Comment thread lib/sandbox/CSandboxedProcessSpawner_Linux.cc
Comment thread lib/sandbox/CSandboxedProcessSpawner_Linux.cc
Comment thread lib/sandbox/unittest/CSandboxedProcessSpawnerLifecycleTest_Linux.cc

@edsavage edsavage left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looking pretty good, I just spotted one thing that I think should be fixed prior to merge

testDestructorDoesNotJoinAndReturnsUnderOneSecond asserts elapsed < 1s via steady_clock. I think this will be subject to spurious failures on a stalled CI box. The property under test here is actually binary: Either a correct destructor returns in microseconds, or a regression that joins/blocks on the monitor (parked in AwaitResult() on a never-exiting child) hangs forever.

We could rework the test as a deadlock watchdog rather than a timing check: run spawner.reset() on its own thread (moving sole ownership of the unique_ptr in to avoid a race), wait_for a largeish timeout (~30s, purely as a deadlock guard rather than a performance bound), and then assert on status being future_status::ready so we assert on completion, not on elapsed time.This matches the watchdog pattern already used in testTerminateChildFallsBackToKillWhenKernelUnsupportsPidfd. If we do go this route we should rename the test to something like testDestructorDoesNotBlockOnLiveChild.

@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch from 92c69e8 to 1a22ab1 Compare September 21, 2026 12:54
@valeriy42

Copy link
Copy Markdown
Contributor Author

Addressed the destructor feedback in 432babb5c: renamed to testDestructorDoesNotBlockOnLiveChild, run spawner.reset() on a worker thread with sole unique_ptr ownership, and assert future_status::ready after a 30s deadlock guard (same watchdog pattern as testTerminateChildFallsBackToKillWhenKernelUnsupportsPidfd) instead of elapsed < 1s.

Copilot lifecycle/cleanup threads are replied inline; IPC argv rewrite and ENOSYS skip left as out-of-scope / intentional positive control.

@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch from 1a22ab1 to e79c6e5 Compare September 21, 2026 13:49
Base automatically changed from feature/sandbox2-pr-c-fs-net-policy to main September 21, 2026 18:29
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch from e79c6e5 to 5215cb3 Compare September 21, 2026 18:30
@edsavage

edsavage commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Addressed the destructor feedback in 432babb5c: renamed to testDestructorDoesNotBlockOnLiveChild, run spawner.reset() on a worker thread with sole unique_ptr ownership, and assert future_status::ready after a 30s deadlock guard (same watchdog pattern as testTerminateChildFallsBackToKillWhenKernelUnsupportsPidfd) instead of elapsed < 1s.

Copilot lifecycle/cleanup threads are replied inline; IPC argv rewrite and ENOSYS skip left as out-of-scope / intentional positive control.

Thanks @valeriy42! I went looking for 432babb5c and I don't think it made it onto the PR branch, the changes aren't in the current head.

However,

  • 432babb5c does exist in the repo (authored 2026-09-21 12:56Z) and it does contain the destructor rework you described: testDestructorDoesNotBlockOnLiveChild with the std::future_status::ready deadlock watchdog, plus the spawner cleanup hardening (pidfd clearing, non-terminating monitor detach, Kill/reap before dropping the sandbox).
  • But the current PR head 5215cb37a still has the pre-review test testDestructorDoesNotJoinAndReturnsUnderOneSecond with BOOST_CHECK(elapsed < std::chrono::seconds(1)), so the watchdog rename/fix isn't present on the branch.
  • Comparing 432babb5c against the branch heads from the 21st (1a22ab149, e79c6e55f, 5215cb37a) all report diverged, i.e. 432babb5c was never an ancestor of feature/sandbox2-pr-d-lifecycle. The branch was force-pushed three times that day (12:54 -> 13:49 -> 18:30 -> 5215cb37a), and the final rebase landed on a history without the review commit, so I suspect it got clobbered somewhere along the way or it ended up on a separate/local branch rather than the PR branch. Could you cherry-pick it onto the current head and push?

@valeriy42

Copy link
Copy Markdown
Contributor Author

Cherry-picked the review fix onto current head 5215cb37a as ff6a40b98 (content from 432babb5c).

  • Destructor test is now testDestructorDoesNotBlockOnLiveChild with the 30s future_status::ready deadlock watchdog (same pattern as testTerminateChildFallsBackToKillWhenKernelUnsupportsPidfd), not elapsed < 1s.
  • Also includes the spawner cleanup hardening from that commit (pidfd clearing after close, non-terminating monitor detach path, Kill/reap before registry erase on monitor await failure).

@edsavage — should be on the PR branch now; thanks for catching the dropped commit after the rebase.

@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch from ff6a40b to aedc32a Compare September 22, 2026 07:43

@edsavage edsavage left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

…ted lifecycle

Replaces numeric-PID process control with an identity-bound spawner for
sandboxed pytorch_inference children, because a sandboxed child's PID can
be reused by an unrelated process while a stale monitor or a delayed
terminate call is still in flight.

CSandboxedProcessSpawner (new) drives every live child through an explicit
state machine (Prepared -> Launched -> IdentityCaptured -> Registered ->
Monitoring -> TerminationRequested -> CleanupRequired -> Reaped/Failed):

- A non-throwing kill-and-reap guard is armed immediately once a sandboxed
  process is launched and its PID captured, before any operation that could
  throw (registry insertion, monitor-thread construction/detach). It holds
  its own owning reference to the sandbox handle so cleanup is safe
  regardless of the destruction order of other locals during unwinding, and
  disarms only after registry insertion and monitor handoff have both
  succeeded.
- Four injectable seams (pidfd acquisition, registry allocation,
  monitor-thread launch, sandbox completion) allow every failure class to
  be exercised deterministically in tests, without waiting on real resource
  exhaustion.
- pidfd-acquisition outcomes are classified explicitly: a kernel that lacks
  pidfd support (ENOSYS) is the only case that falls back to signalling the
  sandboxee through its owned monitor handle (SIGKILL, identity-safe, no
  numeric-PID lookup); every other acquisition failure (ESRCH, EMFILE,
  ENFILE, ...) is treated as a resource/identity error and fails
  registration outright rather than silently choosing a weaker termination
  mechanism. Numeric kill(pid, ...) does not appear anywhere in this file.
- A one-shot CAS latch resolves the race between a timeout and the
  sandbox's own completion signal, replacing independent-boolean
  coordination with a single atomic decision that has exactly one winner.

CSandboxedProcessSpawnerLifecycleTest_Linux.cc adds fault-injection
coverage for every pidfd-acquisition outcome, allocation/resource
failures during registration and monitor launch, protection against a
stale monitor mutating a newer registration for a reused PID, protection
against signalling the wrong process after PID reuse, the CAS race
(deterministic interleavings, no wall-clock polling), descriptor-count
cleanup after every case, and destructor latency with a live child still
running.

Reviewed through several fault-injection and use-after-free fix rounds
this session; not yet compiled or executed against a real Linux/Sandbox2
toolchain.
Wrap the detached monitor thread in try/catch so AwaitResult failures
cannot std::terminate the controller; add a regression test. Rewrite
Task/session comment leakage to present-tense invariants.
Replace the sub-second destructor timing assertion with a deadlock watchdog, clear pidfds after close, make monitor detach failure non-terminating, and Kill/reap before dropping the sandbox on monitor AwaitResult failures.
@valeriy42
valeriy42 force-pushed the feature/sandbox2-pr-d-lifecycle branch from aedc32a to 5674cfe Compare September 23, 2026 06:16
@valeriy42
valeriy42 merged commit 12c3ebf into main Sep 23, 2026
24 checks passed
valeriy42 added a commit that referenced this pull request Sep 24, 2026
## Summary

Stacks on #3187. Adds typed controller-side routing behind two symmetric controller tokens. Landlock fallback when Sandbox2 is unavailable is split to stacked #3215.

- `CCommandProcessor` parses at most one of `--disableSandbox` (operator kill switch, forces legacy) or `--requireSandbox` (operator opt-in, forces Sandbox2 on capable hosts) for the exact configured `pytorch_inference` path. Duplicate occurrences of either token, a mismatched process path, or both tokens present together are all rejected before spawn. The selected token is stripped before the child ever sees it.
- New `CProcessSpawnerRouter` dispatches the decided route to `CSandboxedProcessSpawner` or `CDetachedProcessSpawner` with no automatic fallback: a required Sandbox2 launch that fails returns a failed start response, it never retries through the legacy spawner.
- A `start` command with neither token always takes the legacy route - permanent behaviour for any caller that sends no routing token (support/debug scripts, direct controller invocation, the test harness), not a rollout seam. Elasticsearch is expected to always send exactly one of the two tokens per launch, chosen from its own operator setting's live value.
- Structured once-per-launch observability signal (`sandbox2_launch`: `deployment_id`, `model_id`, `route`, `sandbox2_established`, `mode`, `legacy_reason`, `sandbox2_compiled_in`) so a consumer can distinguish an operator kill switch from the no-token default, and "Sandbox2 supported but no routing token sent" from "built without Sandbox2 support."
- `ML_SANDBOXED` is stripped from every legacy-route child's environment (POSIX and Windows) so an inherited or injected value can never fail-open the mandatory in-process seccomp filter.
- Active Sandbox2 capability probe logged once at controller start (`CSandbox2Diagnostics`) so operators see whether `--requireSandbox` can succeed before a deployment fails closed.
- Staged user-namespace capability probe plus CI wiring: aarch64 runs enforced-mode coverage; x86_64 runs fail-closed-only coverage, since x86_64 Buildkite k8s pods get `EPERM` on `mount("proc", ...)` and there is currently no userns-capable x86_64 CI runner.
- Repaired `test_sandbox2_attack_defense.py`: reached markers, unsandboxed positive controls, per-case cleanup/reap assertions, the real `ml-child-ipc/<child-id>` IPC layout, an assertion that the controller's own `sandbox2_launch` signal reports the expected route before any security-boundary check runs, and explicit `--requireSandbox`/`--disableSandbox` tokens per case instead of a global env-var default.
- Producer-side controller-protocol capability token (`3rd_party/controller-protocol.version`, now version 2) for a future cross-repo compatibility check.
- Build packaging: per-library `install_libs()` guard and unconditional `$ORIGIN` RPATH on bundled ELF libraries so sibling `.so` dependencies resolve when the controller environment is cleared.

## Filesystem-policy fixes found during enforced-mode qualification

End-to-end qualification on the qaf harness with `xpack.ml.trained_models.sandbox_enabled=true` (real `mode:enforced` route, not the legacy fallback) surfaced several launch/filesystem-policy gaps that previously only manifested once a real `pytorch_inference` ran under the enforced sandbox. Fixed here:

- **Per-child IPC directory is created before validation.** The native controller now creates `$TMPDIR/ml-child-ipc/<child-id>` (mode 0700) before `validateChildIpcLaunchSpec()`'s live `realpath()` calls, on both spawn paths. Elasticsearch only ever constructs the IPC path strings; nothing created the directory they name.
- **Per-child IPC root is mounted at the same path inside and outside the sandbox** (no `/run/elastic/ml-ipc` remap), because `pytorch_inference` receives its `--input=`/`--output=`/`--restore=`/`--logPipe=` argv as host paths under that root - a remap left them unresolvable in the sandbox mount namespace.
- **Sandbox2-specific syscall allowlist** ported from the frozen reference (#2873): Sandbox2's namespace/threading setup exercises syscalls the legacy in-process BPF filter never needed, so `legacyBpfAllowedSyscalls()` alone is insufficient.
- **`/proc` is mounted (PID-namespaced) inside the sandbox rootfs.** Sandbox2 mounts a fresh PID-namespaced procfs on the outer root, then `pivot_root`s into the chroot and detaches the old root, so the rootfs had no `/proc`. Without it `readlink(/proc/self/exe)` fails with `ENOENT`, breaking Intel oneMKL's runtime dispatcher (it reads `/proc/self/exe` to self-locate and `dlopen` its CPU-specific `libmkl_*.so.3` kernels) - every enforced `pytorch_inference` aborted with `Intel oneMKL FATAL ERROR: Cannot load <mkl-loader>`. The mount binds the already-namespaced procfs (never the host's): inside the sandbox `/proc` shows only the sandboxee's own PIDs. `/sys` is left unmounted.
- **Regression test:** `ml_sandbox_probe` now checks `/proc/self/exe` is readable inside the real sandbox, asserted by `CPytorchInferenceSandboxPolicyMechanismTest_Linux` - turning the MKL crash into a build-time failure.

Verified: ml-cpp sandbox unit tests 33/33; qaf boot check and `test_scenario_buildly` (8/8) pass under `mode:enforced` with zero MKL crashes and no legacy fallbacks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants