Repository navigation
[Bubblewrap] Fork bwrap from a process-lifetime thread #1526
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /// <summary> | ||
| /// The live-host gate for Bubblewrap tests: Linux with a usable `bwrap` that | ||
| /// <see cref="MxcPlatform.GetAvailableBackends"/> reports. | ||
| /// </summary> | ||
| 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<bool> 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"; | ||
|
|
||
| /// <summary>Skips the calling test when Bubblewrap is unavailable, or fails | ||
| /// it when skips have been declared failures.</summary> | ||
| 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"); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<io::Result<Child>>); | ||
|
|
||
| static SPAWNER: Mutex<Option<Sender<SpawnRequest>>> = 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<Child> { | ||
| 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<Sender<SpawnRequest>> { | ||
| 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::<SpawnRequest>(); | ||
| 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")) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A nonexistent bare executable name does not guarantee Use an absolute nonexistent path inside a test-owned temporary directory so the test checks error propagation rather than the hostβs PATH configuration. |
||
| .expect_err("missing binary must fail"); | ||
| assert_eq!(error.kind(), io::ErrorKind::NotFound); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The requested 30-second timeout does not bound this read: Bubblewrap enforces its deadline through |
||
| .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)); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default .NET workflow discovers this test but neither prepares Bubblewrap nor sets
MXC_BWRAP_TESTS_REQUIRE_EXECUTION, so a runner without Bubblewrap silently skips it. Add a Bubblewrap-prepared .NET invocation with strict execution and a test-specific execution check, so unrelated passing tests cannot leave this regression unexercised.