Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion desktop/src-tauri/src/claim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,12 @@ pub async fn run(app: &AppHandle, argv: Vec<String>, deadline: Instant) -> Claim
return ClaimResult::Failed(format!("the bundled CLI could not be started ({error})"))
}
};
match timeout_at(deadline, command.output()).await {
match timeout_at(
deadline,
crate::process_spawn::with_spawn_async(command.output()),
)
.await
{
Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr),
Ok(Err(error)) => {
ClaimResult::Failed(format!("the bundled CLI could not be run ({error})"))
Expand Down
48 changes: 26 additions & 22 deletions desktop/src-tauri/src/cli_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,18 +454,20 @@ mod tests {
}
fn selected_observation_case(case: &str) {
let t = Temp::new();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"cli_command::tests::selected_observation_child",
"--nocapture",
])
.env("OCX_CLI_OBSERVATION_TEST", case)
.env("HOME", &t.0)
.env("ZDOTDIR", &t.0)
.env("XDG_CONFIG_HOME", t.0.join(".config"))
.output()
.unwrap();
let result = crate::process_spawn::with_spawn(|| {
std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"cli_command::tests::selected_observation_child",
"--nocapture",
])
.env("OCX_CLI_OBSERVATION_TEST", case)
.env("HOME", &t.0)
.env("ZDOTDIR", &t.0)
.env("XDG_CONFIG_HOME", t.0.join(".config"))
.output()
})
.unwrap();
assert!(result.status.success(), "selected-observation-failed");
assert!(
String::from_utf8_lossy(&result.stdout).contains("1 passed"),
Expand Down Expand Up @@ -578,16 +580,18 @@ mod tests {
}
}
fn disabled_selector_case(action: &str) {
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"cli_command::tests::disabled_selector_child",
"--nocapture",
])
.env("OCX_CLI_SELECTOR_TEST", action)
.env("ZDOTDIR", "/invalid:zdotdir")
.output()
.unwrap();
let result = crate::process_spawn::with_spawn(|| {
std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"cli_command::tests::disabled_selector_child",
"--nocapture",
])
.env("OCX_CLI_SELECTOR_TEST", action)
.env("ZDOTDIR", "/invalid:zdotdir")
.output()
})
.unwrap();
assert!(result.status.success(), "disabled-selector-failed");
assert!(
String::from_utf8_lossy(&result.stdout).contains("1 passed"),
Expand Down
70 changes: 39 additions & 31 deletions desktop/src-tauri/src/cli_command_posix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ pub fn remove_plan(store: &Store, current: &Record) -> Result<(Record, Vec<Chang
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::process_spawn::with_spawn;
use record::tests::{bundle, Temp};
#[cfg(target_os = "macos")]
fn stable_bundle_fixture() -> Temp {
Expand Down Expand Up @@ -701,13 +702,13 @@ mod tests {
command.arg("-N");
}
assert!(
command
with_spawn(|| command
.arg(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap()
.success(),
.status())
.unwrap()
.success(),
"acl-command-failed"
);
}
Expand Down Expand Up @@ -969,7 +970,7 @@ mod tests {
if let Some(path) = path {
command.env("PATH", path);
}
let output = command.output().unwrap();
let output = with_spawn(|| command.output()).unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8(output.stdout).unwrap(), expected);
}
Expand All @@ -988,14 +989,16 @@ mod tests {
0o700,
)
.unwrap();
let out = std::process::Command::new(&shim)
.env_clear()
.env("ANTHROPIC_AUTH_TOKEN", "dummy")
.env("ANTHROPIC_API_KEY", "")
.env("OCX_NODE_LAUNCH_CONTEXT", "untrusted")
.arg("status")
.output()
.unwrap();
let out = with_spawn(|| {
std::process::Command::new(&shim)
.env_clear()
.env("ANTHROPIC_AUTH_TOKEN", "dummy")
.env("ANTHROPIC_API_KEY", "")
.env("OCX_NODE_LAUNCH_CONTEXT", "untrusted")
.arg("status")
.output()
})
.unwrap();
assert!(out.status.success());
let text = String::from_utf8(out.stdout).unwrap();
let lines: Vec<_> = text.lines().collect();
Expand All @@ -1011,9 +1014,7 @@ mod tests {
assert!(!text.contains("dummy"));
fs::remove_file(cli).unwrap();
assert_eq!(
std::process::Command::new(&shim)
.env_clear()
.output()
with_spawn(|| std::process::Command::new(&shim).env_clear().output())
.unwrap()
.status
.code(),
Expand All @@ -1035,13 +1036,18 @@ mod tests {
.replace("-N32", "-N1"),
] {
record::atomic(&t.0.join("cli"), None, inspect, 0o700).unwrap();
fs::write(&shim, rendered).unwrap();
let out = std::process::Command::new(&shim)
.env_clear()
.env("OCX_NODE_LAUNCH_CONTEXT", "inherited")
.arg("status")
.output()
.unwrap();
{
let _writer = crate::process_spawn::executable_write();
fs::write(&shim, rendered).unwrap();
}
let out = with_spawn(|| {
std::process::Command::new(&shim)
.env_clear()
.env("OCX_NODE_LAUNCH_CONTEXT", "inherited")
.arg("status")
.output()
})
.unwrap();
assert!(out.status.success());
assert_eq!(out.stdout, b"\nstatus\n");
fs::remove_file(t.0.join("cli")).unwrap();
Expand Down Expand Up @@ -1197,15 +1203,17 @@ mod tests {
// System rc files may print to stdout (Ubuntu's /etc/bash.bashrc sudo hint), so only
// the marked result lines count; a failed lookup or CLI run still fails the shell, and
// the end marker makes any extra CLI output line fail the comparison.
let output = command
.args([
"-i",
"-c",
"p=$(command -v ocx) || exit 11; o=$(ocx hello) || exit 12; \
let output = with_spawn(|| {
command
.args([
"-i",
"-c",
"p=$(command -v ocx) || exit 11; o=$(ocx hello) || exit 12; \
printf 'OCX-PATH:%s\\nOCX-OUT:%s:OCX-END\\n' \"$p\" \"$o\"",
])
.output()
.expect("cannot start real shell");
])
.output()
})
.expect("cannot start real shell");
assert!(output.status.success(), "{shell}: command failed");
let stdout = String::from_utf8(output.stdout).expect("shell stdout is not UTF-8");
let marked = |tag: &str| {
Expand Down
64 changes: 53 additions & 11 deletions desktop/src-tauri/src/cli_command_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,8 @@ fn replace(from: &Path, to: &Path) -> Result<()> {
io(fs::rename(from, to))
}
pub fn atomic(path: &Path, before: Option<&[u8]>, after: &[u8], mode: u32) -> Result<()> {
#[cfg(unix)]
let _executable_write = (mode & 0o111 != 0).then(crate::process_spawn::executable_write);
let parent = path.parent().ok_or("unsafe-file")?;
check(parent, true)?;
rc_acl_check(path)?;
Expand Down Expand Up @@ -593,6 +595,8 @@ pub fn atomic(path: &Path, before: Option<&[u8]>, after: &[u8], mode: u32) -> Re
if read_bytes(path)?.as_deref() != before {
return Err("concurrent-edit".into());
}
// Publish only after releasing our writer; a terminal may execute the new shim immediately.
drop(f);
replace(&tmp, path)?;
#[cfg(unix)]
io(fs::File::open(parent).and_then(|f| f.sync_all()))?;
Expand Down Expand Up @@ -1145,6 +1149,40 @@ impl Store {
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[cfg(unix)]
use crate::process_spawn::with_spawn;
#[cfg(unix)]
#[test]
fn executable_writes_wait_for_process_spawns_but_record_writes_do_not() {
use std::{sync::mpsc, thread, time::Duration};
let t = Temp::new();
let path = t.0.join("shim");
let (started_tx, started_rx) = mpsc::channel();
let (done_tx, done_rx) = mpsc::channel();
let (writer, early_completion) = with_spawn(|| {
// Non-executable record writes must still overlap the process-spawn stress test.
atomic(&t.0.join("record"), None, b"record", 0o600).unwrap();
let path = path.clone();
let writer = thread::spawn(move || {
started_tx.send(()).unwrap();
let result = atomic(&path, None, b"#!/bin/sh\nexit 0\n", 0o700);
done_tx.send(()).unwrap();
result
});
started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
let early_completion = done_rx.recv_timeout(Duration::from_millis(100));
(writer, early_completion)
});
if early_completion.is_err() {
done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
}
writer.join().unwrap().unwrap();
assert!(matches!(
early_completion,
Err(mpsc::RecvTimeoutError::Timeout)
));
assert_eq!(fs::read(path).unwrap(), b"#!/bin/sh\nexit 0\n");
}
pub(crate) struct Temp(pub PathBuf);
impl Temp {
pub(crate) fn new() -> Self {
Expand Down Expand Up @@ -1554,13 +1592,13 @@ pub(crate) mod tests {
command.arg("-N");
}
assert!(
command
with_spawn(|| command
.arg(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap()
.success(),
.status())
.unwrap()
.success(),
"acl-command-failed"
);
}
Expand Down Expand Up @@ -1631,12 +1669,14 @@ pub(crate) mod tests {
fn linux_acl_inheritance_clear_and_existing_child_refusal() {
let t = Temp::new();
let set = |path: &Path, spec: &str| {
std::process::Command::new("setfacl")
.args(["-m", spec])
.arg(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
with_spawn(|| {
std::process::Command::new("setfacl")
.args(["-m", spec])
.arg(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
})
};
let result = set(&t.0, "d:u:65534:rwx");
if result
Expand Down Expand Up @@ -1906,7 +1946,9 @@ pub(crate) mod tests {
ready.wait();
let mut spawns = 0;
while !stop.load(Ordering::Relaxed) {
assert!(Command::new(TRUE).status().unwrap().success());
assert!(with_spawn(|| Command::new(TRUE).status())
.unwrap()
.success());
spawns += 1;
}
spawns
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/exit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ async fn finish_and_exit_after(app: &AppHandle, reason: ExitReason) {
match (reason, verdict) {
(ExitReason::UserQuit, _) => app.exit(0),
(ExitReason::CoordinatedRestart, DrainVerdict::Drained) => {
app.restart();
crate::process_spawn::with_spawn(|| app.restart());
}
(ExitReason::CoordinatedRestart, _) => {
crate::logging::log_once("the update restart was refused", verdict.describe());
Expand Down Expand Up @@ -574,7 +574,7 @@ pub async fn prepare_restart(app: &AppHandle) -> RestartReadiness {

/// Come back, once the installer has finished and returned.
pub fn complete_restart(app: &AppHandle) -> ! {
app.restart()
crate::process_spawn::with_spawn(|| app.restart())
}

/// Stop the runtime this app owns and confirm it is gone.
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ mod tray_login;
#[allow(dead_code)]
#[path = "popup.rs"]
mod popup_portable_test;
mod process_spawn;
mod proxy;
mod resolve;
mod runtime_stop;
Expand Down
40 changes: 33 additions & 7 deletions desktop/src-tauri/src/login_registration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,13 +527,16 @@ mod command {
pub(super) fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, RunError> {
let slot = Slot::take().ok_or(RunError::Failed)?;
let deadline = Instant::now() + timeout;
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|_| RunError::Failed)?;
let spawn = || {
Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
};
let child = crate::process_spawn::with_spawn(spawn);
let mut child = child.map_err(|_| RunError::Failed)?;
let readers = [drain(child.stdout.take()), drain(child.stderr.take())];
let status = loop {
match child.try_wait() {
Expand Down Expand Up @@ -579,6 +582,29 @@ mod command {

#[test]
fn a_command_past_its_deadline_holds_the_only_slot_until_it_ends() {
// Measure command deadlines in a fresh process: the parent's executable-fixture
// writers may hold the test spawn lock longer than this test's timing budget.
const CHILD: &str = "OCX_LOGIN_COMMAND_DEADLINE_CHILD";
if std::env::var_os(CHILD).is_none() {
let output = crate::process_spawn::with_spawn(|| {
Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"login_registration::command::tests::a_command_past_its_deadline_holds_the_only_slot_until_it_ends",
"--nocapture",
])
.env(CHILD, "1")
.output()
})
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
return;
}
let started = Instant::now();
assert_eq!(
run("/bin/sleep", &["1"], Duration::from_millis(50)),
Expand Down
Loading
Loading