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/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 });
+ }
+ });
+});
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