Keep sandboxed background jobs alive after their spawning worker exits

Bubblewrap parent-death signals follow the creating thread. The exact
31535e34bf Linux RC killed both pipe and PTY background jobs when Tokio
retired that blocking thread, even though Engine stayed alive.

Spawn Linux bubblewrap background children on one process-lifetime thread.
Both process launch sites and both PTY consumers use the shared ExecEnv
path. Preserve --die-with-parent, namespace/mount confinement, filtered
environments, controlling-terminal setup, and existing stop/cleanup.

Verification: 6 existing focused macOS tests passed; formatting and diff
checks passed. Linux ARM64 diagnostic build passed with the existing
keyring/vendored feature (the initial default macOS cross-build lacked a
D-Bus sysroot). That binary passed 35 immediate terminal/confinement/Stop
observations and 11 reconnect observations after 53.7 seconds idle. Both
pipe and PTY jobs survived the identical 28-second failure control. Killing
Engine removed all four tracked sandbox processes and systemd recovered.
Source stayed frozen across builds and observations. Final native RC and
release qualification remain separate; no new tests or comments added.

Refs CW-6606, CW-7462.
This commit is contained in:
CodeWhale Bot committed 2026-10-24 04:03:41 -07:00
1 parent a838498509
commit ff466b2ed0
4 files changed
+58 -20

No files matched your search

+30
View File
@@ -69,6 +69,36 @@ pub(crate) fn existing_directory_shim(path: &Path) -> Option<PathBuf> {
#[cfg(target_os = "linux")]
pub const BWRAP_PATH: &str = "/usr/bin/bwrap";
pub(super) fn spawn<T: Send + 'static>(
launch: impl FnOnce() -> T + Send + 'static,
) -> std::io::Result<T> {
use std::sync::{LazyLock, mpsc};
type Launch = Box<dyn FnOnce() + Send>;
static SPAWNER: LazyLock<std::io::Result<mpsc::Sender<Launch>>> = LazyLock::new(|| {
let (sender, receiver) = mpsc::channel::<Launch>();
std::thread::Builder::new()
.name("codewhale-sandbox-spawner".into())
.spawn(move || {
for launch in receiver {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(launch));
}
})?;
Ok(sender)
});
let spawner = SPAWNER
.as_ref()
.map_err(|error| std::io::Error::new(error.kind(), error.to_string()))?;
let (sender, receiver) = mpsc::sync_channel(1);
spawner
.send(Box::new(move || {
let _ = sender.send(launch());
}))
.map_err(|_| std::io::Error::other("sandbox spawner stopped"))?;
receiver
.recv()
.map_err(|_| std::io::Error::other("sandbox spawn did not return"))
}
/// How long the availability probe may take. A working bwrap runs a wrapped
/// `/bin/true` in well under a second, even on a loaded host.
#[cfg(target_os = "linux")]
+21 -4
View File
@@ -337,11 +337,23 @@ impl ExecEnv {
}
}
#[cfg(not(target_env = "ohos"))]
pub fn pty_command(
pub fn spawn_background(
&self,
mut command: std::process::Command,
) -> std::io::Result<std::process::Child> {
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
if self.sandbox_type == SandboxType::LinuxBubblewrap {
return bwrap::spawn(move || command.spawn())?;
}
command.spawn()
}
#[cfg(not(target_env = "ohos"))]
pub fn spawn_pty(
&self,
slave: Box<dyn portable_pty::SlavePty + Send>,
cwd: &std::path::Path,
) -> anyhow::Result<portable_pty::CommandBuilder> {
) -> anyhow::Result<Box<dyn portable_pty::Child + Send + Sync>> {
let mut command = portable_pty::CommandBuilder::new(self.program());
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
if self.sandbox_type == SandboxType::LinuxBubblewrap {
@@ -375,7 +387,12 @@ impl ExecEnv {
&mut command,
crate::child_env::string_map_env(&self.env),
);
Ok(command)
let launch = move || slave.spawn_command(command);
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
if self.sandbox_type == SandboxType::LinuxBubblewrap {
return bwrap::spawn(launch)?;
}
launch()
}
/// Check if this execution is sandboxed.
+5 -9
View File
@@ -2752,13 +2752,9 @@ impl ShellManager {
})
.context("Failed to open PTY")?;
let cmd = exec_env.pty_command(working_dir)?;
let mut child = pair
.slave
.spawn_command(cmd)
let mut child = exec_env
.spawn_pty(pair.slave, working_dir)
.with_context(|| format!("Failed to spawn PTY command: {original_command}"))?;
drop(pair.slave);
let reader = match pair.master.try_clone_reader() {
Ok(reader) => reader,
@@ -2805,7 +2801,7 @@ impl ShellManager {
child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
remove_readonly_redirect_env(&mut cmd, &exec_env.env);
let child = cmd.spawn().with_context(|| {
let child = exec_env.spawn_background(cmd).with_context(|| {
format!("Failed to spawn persistent service: {original_command}")
})?;
(ShellChild::Process(child), None, None, None)
@@ -2831,8 +2827,8 @@ impl ShellManager {
child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
remove_readonly_redirect_env(&mut cmd, &exec_env.env);
let mut child = cmd
.spawn()
let mut child = exec_env
.spawn_background(cmd)
.with_context(|| format!("Failed to spawn background: {original_command}"))?;
// Managed children die with the TUI (#6654); unlike the
// persistent branch above, nobody can take ownership of them.
+2 -7
View File
@@ -355,14 +355,9 @@ fn create_session(
let (prepared, sandbox_confined) =
prepare_pty_shell(&shell, &workspace, policy.clone(), shell_manager)?;
let command = prepared
.pty_command(&prepared.cwd)
.map_err(|e| format!("failed to prepare PTY shell: {e}"))?;
let child = pair
.slave
.spawn_command(command)
let child = prepared
.spawn_pty(pair.slave, &prepared.cwd)
.map_err(|e| format!("failed to start shell {shell}: {e}"))?;
drop(pair.slave);
let reader = pair
.master