From 2a66a1a909706368b51f1ad9297d3b9330c6cd8b Mon Sep 17 00:00:00 2001 From: Keith Mattix II Date: Fri, 16 Oct 2026 11:04:40 -0500 Subject: [PATCH 1/2] [Bubblewrap] Fork bwrap from a process-lifetime thread `bwrap --die-with-parent` arms PR_SET_PDEATHSIG in the launcher, and Linux delivers it when the *thread* that forked bwrap exits, not the process. `v1::spawn` forked bwrap on the caller's thread, so a sandbox started from a pooled or short-lived thread (Tokio `spawn_blocking`, a .NET thread-pool thread, a scoped worker) and waited on elsewhere was SIGKILLed when that thread was retired, surfacing as `Exited(-1)` with truncated output. The proxy (`runtimeConfig.networkProxy`) path hit it deterministically because spawn blocks until bwrap has armed the signal. Route the bwrap spawn through a dedicated thread that lives for the process lifetime, so the sandbox still dies with the embedding process but no longer with an arbitrary caller thread. Adds a regression test that spawns on a thread which exits while the workload is still running. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/backends/bwrap/bubblewrap-backend.md | 6 + .../bubblewrap/common/bwrap_runner.rs | 6 +- .../bubblewrap/common/lifetime_spawner.rs | 109 ++++++++++++++++++ .../src/backends/bubblewrap/common/mod.rs | 3 + src/mxc-sdk/tests/streaming_bubblewrap.rs | 39 +++++++ 5 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 src/mxc-sdk/src/backends/bubblewrap/common/lifetime_spawner.rs diff --git a/docs/backends/bwrap/bubblewrap-backend.md b/docs/backends/bwrap/bubblewrap-backend.md index 8debd76e9..f1e409496 100644 --- a/docs/backends/bwrap/bubblewrap-backend.md +++ b/docs/backends/bwrap/bubblewrap-backend.md @@ -147,6 +147,12 @@ Bubblewrap creates a namespace-isolated process by: The sandboxed process runs as a child of `bwrap` and dies automatically when execution completes — no container lifecycle management required. +`bwrap` runs with `--die-with-parent`, whose parent-death signal Linux delivers +when the *thread* that forked `bwrap` exits. MXC therefore forks `bwrap` from +a dedicated thread that lives as long as the process, so a sandbox started from +a pooled or short-lived thread (for example a Tokio `spawn_blocking` worker) +survives that thread and dies only with the embedding process. + ### Deny-by-default filesystem The baseline mirrors the macOS Seatbelt backend's `(deny default)` posture: diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs index 2e5df33d0..11be79a7f 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_runner.rs @@ -38,7 +38,7 @@ use crate::mxc_pty::{LivePty, PtySize as UnixPtySize}; use crate::bwrap_common::{ bwrap_command::{self, ResolvedNetworkMode}, - bwrap_version, network_rules, + bwrap_version, lifetime_spawner, network_rules, provider_monitor::ProviderMonitor, proxy_network, }; @@ -596,7 +596,9 @@ impl BubblewrapScriptRunner { startup.prepare_command(&mut command); } - let mut child = match command.spawn() { + // `--die-with-parent` binds bwrap to the forking *thread*, so fork from + // one that outlives every sandbox rather than from the caller's. + let mut child = match lifetime_spawner::spawn(command) { Ok(process) => process, Err(error) => { stop_proxy_network(&mut proxy_network, logger); diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/lifetime_spawner.rs b/src/mxc-sdk/src/backends/bubblewrap/common/lifetime_spawner.rs new file mode 100644 index 000000000..4cb02854e --- /dev/null +++ b/src/mxc-sdk/src/backends/bubblewrap/common/lifetime_spawner.rs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Spawns `bwrap` from a thread that lives as long as the process. +//! +//! `bwrap --die-with-parent` arms `PR_SET_PDEATHSIG` in the launcher, and Linux +//! delivers that signal when the *thread* that forked the launcher exits, not +//! when its process does. Spawning on the caller's thread therefore ties the +//! sandbox to that thread: an SDK caller that spawns from a pooled or +//! short-lived thread (a Tokio `spawn_blocking` worker, a .NET thread-pool +//! thread, a scoped worker) and waits elsewhere has a still-running sandbox +//! SIGKILLed when the pool retires the spawning thread. +//! +//! Forking from one dedicated thread that never exits keeps the documented +//! contract -- the sandbox dies with the process -- for every caller. + +use std::io; +use std::process::{Child, Command}; +use std::sync::mpsc::{self, Sender}; +use std::sync::{Mutex, PoisonError}; +use std::thread; + +type SpawnRequest = (Command, Sender>); + +static SPAWNER: Mutex>> = Mutex::new(None); + +/// Spawn `command` from the process-lifetime spawner thread. +/// +/// Blocks until the spawn completes and returns its result unchanged. Any +/// `pre_exec` hooks run in the forked child exactly as with +/// [`Command::spawn`]; descriptors they reference must stay open until this +/// returns, which holds because the caller blocks here. +pub(crate) fn spawn(command: Command) -> io::Result { + let sender = spawner()?; + let (reply, response) = mpsc::channel(); + if sender.send((command, reply)).is_err() { + forget_spawner(); + return Err(io::Error::other( + "Bubblewrap: the spawner thread has exited", + )); + } + response.recv().unwrap_or_else(|_| { + forget_spawner(); + Err(io::Error::other( + "Bubblewrap: the spawner thread exited before reporting the spawn result", + )) + }) +} + +fn spawner() -> io::Result> { + let mut slot = SPAWNER.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(sender) = slot.as_ref() { + return Ok(sender.clone()); + } + let (sender, requests) = mpsc::channel::(); + thread::Builder::new() + .name("mxc-bwrap-spawner".to_string()) + .spawn(move || { + // The static keeps a sender alive, so this loop runs until the + // process exits. + for (mut command, reply) in requests { + let result = command.spawn(); + // Release the parent's copies of the child's stdio before the + // caller resumes, as an inline spawn's scope would. + drop(command); + let _ = reply.send(result); + } + }) + .map_err(|error| { + io::Error::other(format!( + "Bubblewrap: failed to start the spawner thread: {error}" + )) + })?; + *slot = Some(sender.clone()); + Ok(sender) +} + +/// Drop a dead spawner so the next spawn starts a fresh thread. +fn forget_spawner() { + SPAWNER + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Stdio; + + #[test] + fn spawn_runs_the_command_and_returns_its_child() { + let mut command = Command::new("sh"); + command + .args(["-c", "exit 7"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = spawn(command).expect("spawn"); + assert_eq!(child.wait().expect("wait").code(), Some(7)); + } + + #[test] + fn spawn_reports_launch_errors() { + let error = spawn(Command::new("mxc-lifetime-spawner-absent-binary")) + .expect_err("missing binary must fail"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } +} diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/mod.rs b/src/mxc-sdk/src/backends/bubblewrap/common/mod.rs index 2d768a94e..0ec945e80 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/mod.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/mod.rs @@ -20,6 +20,9 @@ pub mod bwrap_command; #[cfg(target_os = "linux")] pub mod bwrap_runner; pub mod bwrap_version; +/// Forks `bwrap` from a process-lifetime thread (see the module docs). +#[cfg(target_os = "linux")] +pub(crate) mod lifetime_spawner; /// Consumed only by the Linux-gated runner and proxy modules. #[cfg(target_os = "linux")] pub(crate) mod network_rules; diff --git a/src/mxc-sdk/tests/streaming_bubblewrap.rs b/src/mxc-sdk/tests/streaming_bubblewrap.rs index b7e6d4398..be2bf6637 100644 --- a/src/mxc-sdk/tests/streaming_bubblewrap.rs +++ b/src/mxc-sdk/tests/streaming_bubblewrap.rs @@ -282,3 +282,42 @@ fn streaming_bubblewrap_reports_no_output_metadata() { "Bubblewrap should report no structured output metadata" ); } + +#[test] +fn streaming_bubblewrap_outlives_the_thread_that_spawned_it() { + // `bwrap --die-with-parent` arms PR_SET_PDEATHSIG, which Linux fires when + // the *thread* that forked bwrap exits. Callers that spawn on a pooled or + // short-lived thread and wait elsewhere must not have the sandbox killed + // when that thread is retired. + if !bwrap_available() { + return; + } + use std::io::{BufRead, BufReader, Read}; + + let (mut proc, mut stdout) = std::thread::spawn(|| { + let mut proc = spawn( + bwrap_request("echo ready; sleep 1; echo survived", 30_000), + Default::default(), + ) + .expect("spawn"); + let mut stdout = BufReader::new(proc.take_stdout().expect("stdout available")); + // The workload is running, so bwrap has armed its parent-death signal. + let mut line = String::new(); + stdout.read_line(&mut line).expect("read first line"); + assert_eq!(line.trim(), "ready"); + (proc, stdout) + }) + .join() + .expect("spawning thread"); + + let mut rest = String::new(); + stdout + .read_to_string(&mut rest) + .expect("read remaining stdout"); + assert_eq!( + rest.trim(), + "survived", + "sandbox died with its spawning thread" + ); + assert_eq!(proc.wait().expect("wait"), WaitResult::Exited(0)); +} From 491f8701b843d7b9c652d275cb33e08e4a2351b8 Mon Sep 17 00:00:00 2001 From: Keith Mattix Date: Mon, 19 Oct 2026 12:14:56 -0500 Subject: [PATCH 2/2] Test that .NET and Node sandboxes outlive the spawning thread bwrap --die-with-parent ties a sandbox to the thread that forked bwrap, which the Rust SDK fixed by spawning from a process-lifetime thread. Pin the same behavior in the other SDKs: - .NET: MxcContainer.Spawn on a thread that exits while the workload runs. It fails against the native library without the fix. - Node: the public spawn from a worker_threads Worker that is terminated while the workload runs. Node's spawn runs on the libuv pool, so this guards against moving it onto the calling thread. Both gate on a usable Bubblewrap; MXC_BWRAP_TESTS_REQUIRE_EXECUTION turns the skip into a failure, as for the Rust suites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Mxc.Sdk.Tests/BubblewrapHost.cs | 38 ++++++++++++ .../V1/MxcContainerBubblewrapE2ETests.cs | 57 ++++++++++++++++++ .../integration/linux-bubblewrap.test.ts | 59 +++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/BubblewrapHost.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerBubblewrapE2ETests.cs diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/BubblewrapHost.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/BubblewrapHost.cs new file mode 100644 index 000000000..dd1ac4dcf --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/BubblewrapHost.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mxc.Sdk; +using Microsoft.Mxc.Sdk.V1; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +/// +/// The live-host gate for Bubblewrap tests: Linux with a usable `bwrap` that +/// reports. +/// +internal static class BubblewrapHost +{ + // Evaluated once: this answer decides failure versus skip, so it has to be + // the same for every test that consults it. + private static readonly Lazy Available = new(() => + OperatingSystem.IsLinux() + && MxcPlatform.GetAvailableBackends() + .Any(b => b.Backend == ContainmentBackend.Bubblewrap)); + + // The same variable the Rust Bubblewrap suites honour. + private static bool SkipsAreFailures => + Environment.GetEnvironmentVariable("MXC_BWRAP_TESTS_REQUIRE_EXECUTION") is "1" or "true"; + + /// Skips the calling test when Bubblewrap is unavailable, or fails + /// it when skips have been declared failures. + internal static void Require() + { + Assert.False( + SkipsAreFailures && !Available.Value, + "MXC_BWRAP_TESTS_REQUIRE_EXECUTION is set, but this host cannot run " + + "Bubblewrap. That needs Linux and a usable bwrap that " + + "GetAvailableBackends() reports."); + Assert.SkipUnless(Available.Value, "this host has no Bubblewrap backend"); + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerBubblewrapE2ETests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerBubblewrapE2ETests.cs new file mode 100644 index 000000000..54f820703 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/V1/MxcContainerBubblewrapE2ETests.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.ExceptionServices; +using System.Text; +using Microsoft.Mxc.Sdk.V1; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests.V1; + +public class MxcContainerBubblewrapE2ETests +{ + [Fact] + public void Spawn_OutlivesTheThreadThatSpawnedIt() + { + // `bwrap --die-with-parent` arms PR_SET_PDEATHSIG, which Linux fires + // when the *thread* that forked bwrap exits. A caller that spawns on a + // thread-pool or short-lived thread and waits elsewhere must not have + // the sandbox killed when that thread exits. + BubblewrapHost.Require(); + + MxcProcess? proc = null; + StreamReader? stdout = null; + ExceptionDispatchInfo? failure = null; + var spawner = new Thread(() => + { + try + { + proc = MxcContainer.Spawn(new ContainerRequest("echo ready; sleep 1; echo survived") + { + Containment = new Containment.Bubblewrap(), + Filesystem = new FilesystemPolicy { ReadwritePaths = ["/tmp"] }, + TimeoutMs = 30_000, + }); + stdout = new StreamReader(proc.StandardOutput!, Encoding.UTF8); + // The workload is running, so bwrap has armed its parent-death signal. + Assert.Equal("ready", stdout.ReadLine()); + } + catch (Exception ex) + { + failure = ExceptionDispatchInfo.Capture(ex); + } + }); + spawner.Start(); + spawner.Join(); + failure?.Throw(); + + using (proc) + using (stdout) + { + Assert.Equal("survived", stdout!.ReadToEnd().Trim()); + var result = proc!.Wait(); + Assert.False(result.TimedOut); + Assert.Equal(0, result.ExitCode); + } + } +} diff --git a/sdk/node/tests/integration/linux-bubblewrap.test.ts b/sdk/node/tests/integration/linux-bubblewrap.test.ts index 993fca47a..996fccf85 100644 --- a/sdk/node/tests/integration/linux-bubblewrap.test.ts +++ b/sdk/node/tests/integration/linux-bubblewrap.test.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { execFileSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; +import { Worker } from 'node:worker_threads'; import { sdk, supportedVersions, @@ -318,3 +319,61 @@ describe('lxc-exec --available-backends contract', { } }); }); + +// `bwrap --die-with-parent` arms PR_SET_PDEATHSIG, which Linux fires when the +// *thread* that forked bwrap exits. The SDK must not tie a sandbox to the +// JavaScript thread that requested it: a Worker that spawns a sandbox and then +// exits must leave the workload running. +describe('Linux Bubblewrap spawn thread lifetime', { + skip: !isLinuxBubblewrap ? 'requires Linux with bwrap installed' : undefined, +}, () => { + it('keeps the sandbox running after the spawning Worker exits', { timeout: 30000 }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mxc-sdk-bwrap-thread-')); + const marker = path.join(dir, 'marker'); + try { + // The workload reports through a file: the Worker's end of the stdout + // pipe closes when the Worker exits. + const worker = new Worker(` + const { parentPort, workerData } = require('node:worker_threads'); + (async () => { + const { spawn } = await import(workerData.sdkV1); + const proc = await spawn({ + command: 'echo ready; sleep 1; echo survived > ' + workerData.marker, + containment: { type: 'bubblewrap' }, + filesystem: { readwritePaths: [workerData.dir] }, + timeoutMs: 30000, + }); + // Keep reading until the Worker is terminated: leaving the loop + // destroys the stream, and a stream error ends the run. + let announced = false; + for await (const chunk of proc.standardOutput) { + if (!announced && String(chunk).includes('ready')) { + // The workload is running, so bwrap has armed its parent-death signal. + announced = true; + parentPort.postMessage('ready'); + } + } + })().catch((error) => parentPort.postMessage({ error: String(error?.stack ?? error) })); + `, { + eval: true, + workerData: { + sdkV1: pathToFileURL(path.join(getSdkPackageRoot(), 'dist', 'v1', 'index.js')).href, + marker, + dir, + }, + }); + const [message] = await new Promise((resolve) => worker.once('message', (m) => resolve([m]))); + assert.strictEqual(message, 'ready', `the Worker did not see the workload start: ${JSON.stringify(message)}`); + await worker.terminate(); + + const deadline = Date.now() + 15000; + while (!fs.existsSync(marker) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.ok(fs.existsSync(marker), 'the sandbox died with the Worker that spawned it'); + assert.strictEqual(fs.readFileSync(marker, 'utf8').trim(), 'survived'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +});