[ML] Linear child ownership and fault-injected lifecycle for Sandbox2 - #3187
Conversation
78f29f4 to
ddf4aa1
Compare
bed4aba to
fa81668
Compare
|
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:
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. |
|
Pinging @elastic/ml-core (Team:ML) |
fa81668 to
f2e1feb
Compare
f2e1feb to
f69d1e2
Compare
6b18935 to
b125e49
Compare
There was a problem hiding this comment.
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
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.
edsavage
left a comment
There was a problem hiding this comment.
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.
92c69e8 to
1a22ab1
Compare
|
Addressed the destructor feedback in Copilot lifecycle/cleanup threads are replied inline; IPC argv rewrite and ENOSYS skip left as out-of-scope / intentional positive control. |
1a22ab1 to
e79c6e5
Compare
e79c6e5 to
5215cb3
Compare
Thanks @valeriy42! I went looking for However,
|
|
Cherry-picked the review fix onto current head
@edsavage — should be on the PR branch now; thanks for catching the dropped commit after the rebase. |
ff6a40b to
aedc32a
Compare
…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.
aedc32a to
5674cfe
Compare
## 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.


Summary
Rebuilds
CSandboxedProcessSpawneraround an explicit lifecycle state machine (Prepared -> Launched -> IdentityCaptured -> Registered -> Monitoring -> TerminationRequested -> CleanupRequired -> Reaped/Failed), stacked on #3184.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. Numerickill(pid, ...)does not appear anywhere in this file.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_CleanupRequiredlifecycle states are declared but intentionally left unassigned (no natural single point without broader restructuring) — not silently dropped.