mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
Fixes storytold/craft-launcher#764 in craft-update, and with it storytold/craft-launcher#765 (each link is tied to its own release's tag). Also brings back a Linux self-update rule that the extraction lost.
## Why
craft-update was copied out of ArtCraft Launcher in #311, so its `release.rs`, `version.rs`, `platform.rs`, `distro.rs` and `proxy.rs` are the launcher's files. craft-launcher#764 was fixed only in the launcher's copy. This PR brings the fix here, where every Craft app's self-update runs. Once this PR and #389 merge, the launcher drops its copy (storytold/craft-launcher#930).
## 1. Each asset has exactly one acceptable download link (#764)
**The problem:** an asset's link could point at another release, or climb out of the app's releases with `..` or `%2e%2e`, which github.com decodes before routing.
**The fix:**
- `release::expected_download_url(repo, tag, name)` builds the only link an asset may have: `https://github.com/<repo>/releases/download/<tag>/<name>`. The tag and name may only use ASCII letters, digits and `._+-`. A `+` is written `%2B`, as GitHub writes it.
- Reading the GitHub API, an asset is kept only when its link equals that URL. This also ties each link to its own release.
- `release_from_sums` builds its links with the same function.
- `bind_artifact_version`, which compares the tag and file name inside the link with the release's, reads `%2B` there as `+`. It decodes nothing else.
- An encoded dot (`%2e`) is rejected like an encoded slash (`%2f`, `%5c`), both in links and in file names from `SHA256SUMS.txt`.
- `tag_from_redirect` refuses `.` and `..`.
- `install::check_download_link` checks the asset's link, and the link of the release's `SHA256SUMS.txt`, just before downloading. It runs in `install_at` **and** in `self_update::update`, because a self-update doesn't go through `install_at`.
## 2. Linux self-update: only `/usr/bin/<id>` counts as the package
Before #311, the launcher checked the package databases (dpkg, rpm, pacman) only for `/usr/bin/<id>`. The extraction dropped that check and added a `Standalone` fallback, which broke two cases:
- **A copy in `~/bin`** was treated as the deb/rpm/Arch package whenever that package was also installed. The update installed the package with pkexec, then relaunched the `~/bin` copy, which it never touched.
- **An AUR build in `/usr/bin`**, which the code comment says is pacman's to update, became `Standalone`. The update then tried to overwrite a file pacman owns.
`linux_location` brings the old rule back:
| Where the executable is | What happens |
|---|---|
| `/usr/bin/<id>` | checked against the package databases, as before |
| anywhere else under `/usr`, `/opt`, `/bin`, `/sbin`, `/nix`, `/snap` or `/gnu` (Guix), or in a Homebrew `.linuxbrew` prefix | no self-update: its package manager updates it |
| `/usr/local`, the home folder, anywhere else | updates itself (`Standalone`) |
**Symlinked installs are left alone on purpose.** `current_exe()` follows symlinks, so a package that puts the real file in `/opt` and a symlink in `/usr/bin` lands in the second row and doesn't self-update. That's intended. All 14 Craft apps' packages (`packaging/linux/nfpm.yaml`) put the real file at `/usr/bin/<id>`, so this only happens with someone else's repackaging, such as an AUR `-bin` package, and that copy belongs to its package manager.
## 3. Fixes from reviewing this PR with craft-launcher (3 November)
Rebased onto main (`55332dd`) instead of merging it; #460, #392, #481 and #478 changed the same files. Then two reviews against craft-launcher main, which feeds this crate its releases:
- **A release knows its repository.** `Release::repo` is the repository the release was read from, and `check_download_link` checks links against it (empty means the app's own). The launcher's feed can rename a repository (`Feed::repo_for`); checking against `app.repo` refused the renamed repository's downloads.
- **A `+` matches either way.** `release::same_download_url` reads `%2B` as `+` on both sides. The launcher's own `release_from_sums` and feed write a plain `+`, and so do releases saved by an older version, so only accepting `%2B` refused those self-updates. Nothing else is decoded (`%2b` still fails).
- **The launcher's tag and file-name rule (craft-launcher#1024).** `valid_tag`: up to 64 of `[A-Za-z0-9._+-]`, no leading dot, used for the API's `tag_name`, the redirect and download links. `valid_file_name`: the same characters, no leading or trailing dot, no Windows device name (`CON`, `nul.txt`, `COM1.msi`). The launcher's feed calls `release::valid_tag`, so craft-launcher#930 needs it here.
- **`ask_pages` fetches `SHA256SUMS.txt` from `expected_download_url`**, the link it stores.
- **Guix and Homebrew on Linux** are left to their package manager (table above), like Homebrew and Nix on macOS (#460).
## What changes for apps
- **Loopback mirrors:** a build that downloads from a developer's loopback mirror must turn on `craft-update/local-mirror`, or the new check refuses the mirror's links. The launcher's `local-mirror` feature turns it on (craft-launcher#930). Apps rolling out under #319 without a mirror need no change.
- **Linux:** copies under the package-manager folders above no longer update themselves. That's how the launcher behaved before #311.
- **`+` in a tag:** handled (section 1). No Craft release uses `+` today. GitHub's link form was checked on bell-sw/Liberica's releases: the tag and name keep `+`, the link has `%2B`, and the `releases/latest` redirect has a plain `+`.
## Compared with the launcher
I compared launcher 2d12245 (the snapshot #311 copied) and launcher `main` with this crate.
- **All of the launcher's security fixes from 15–25 October are here:** #747/#864, #865/#748, #867, the proxy part of #868, #689, #815, #821 and the archive limits. The launcher's two commits after the snapshot (#871, #903) change no behaviour here.
- **Two things are here but not yet in the launcher:**
- the minute-long `hdiutil attach` retry (#349): craft-launcher#930 takes it;
- the zip 9 drive-prefix test (#335): it waits for the launcher's zip 9 bump (storytold/craft-launcher#899).
- **Follow-ups that don't belong in this PR** are filed separately (linked below once filed).
## Tests
- **New:** `a_download_link_must_be_its_releases_own`, `a_plus_in_the_tag_is_percent_encoded_in_the_link`, `install_accepts_a_plus_release_up_to_the_download`, `update_accepts_a_plus_release_up_to_the_download`, `checksum_names_with_encoded_separators_make_no_download`, `update_refuses_a_link_that_is_not_its_releases_own`, `only_the_packages_own_path_is_a_package_and_system_folders_are_left_alone`.
- **Extended:** `install_rejects_an_artifact_whose_version_is_not_the_advertised_one` (another repo's link, a foreign checksums link), `drops_downloads_that_point_elsewhere`, `file_names_cannot_escape`, `tag_from_redirect`.
- **Mutation check:** `update_refuses_a_link…` fails when the call in `self_update::update` is removed.
- **Mutation check:** both `…_accepts_a_plus_release_up_to_the_download` tests fail with the bind error when the `%2B` decoding is removed.
- **On Windows, after merging main (31 October):** `cargo test -p craft-update`: 232 passed, 3 ignored, the same without default features. Clippy `-D warnings` is clean with default features and with `egui,local-mirror`, and `cargo fmt --check` is clean.
- **Merged with #389 (before the 31 October merge of main):** 173 passed, 28 without default features, and clippy for wasm32 is clean.
- **Launcher (before the 31 October merge of main):** with this branch merged with #389 (craft-launcher#930 pins that merge), the full ArtCraft Launcher suite passes: 401 tests, plus clippy, fmt and the wasm32 build of its core.
- **Added for review (3ad9074):** `two_links_for_one_asset_keep_only_the_releases_own`, `install_refuses_links_off_the_releases_own_before_downloading` (other hosts, and a `SHA256SUMS.txt` linked elsewhere), `install_refuses_while_the_installed_copy_is_running`, `a_download_that_fails_its_checksum_replaces_nothing`; `a_download_link_must_be_its_releases_own` also refuses other hosts. On Windows: 236 passed, 3 ignored; clippy and fmt clean. The bad-checksum test fails when the server sends the real file.
- **Added in section 3:** `tags_are_plain`, `a_plus_link_matches_either_way_and_nothing_else_does`, `a_release_knows_its_repository`; `file_names_cannot_escape` takes #1024's cases; the install test checks a renamed repository and a plain `+`; the Linux location test adds Guix and Linuxbrew. On Windows after the rebase: 251 passed, 3 ignored; the workspace builds; clippy `-D warnings` is clean for this crate (with `local-mirror`) apart from `translate::button` being unused, which comes from main's #508.
- **Not built yet:** the Linux-only `detect` code, and the Unix branch of the running-app test. CI builds it first, once a maintainer approves the workflow runs.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **Bug Fixes**
* Update downloads are now restricted to assets belonging to the selected release, including checksum files. Unrelated or invalid links are rejected before a download begins.
* Release links with unsafe path components or invalid redirect tags are rejected. Encoded plus signs in release tags and filenames are handled correctly.
* Linux installation detection more accurately distinguishes package-managed installations, other system locations, and standalone installations, improving whether self-updates are available.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
842 lines
33 KiB
Rust
842 lines
33 KiB
Rust
//! Starting programs: the Craft Apps themselves, the file manager, and helper tools.
|
|
|
|
use std::ffi::OsStr;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, ExitStatus, Stdio};
|
|
use std::sync::mpsc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// A `Command` that won't flash a console window on Windows, and clears AppImage-injected
|
|
/// environment variables that corrupt system helper binaries and external applications.
|
|
pub fn command(program: impl AsRef<std::ffi::OsStr>) -> Command {
|
|
#[allow(unused_mut)]
|
|
let mut cmd = Command::new(program);
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
#[cfg(unix)]
|
|
clean_appimage_env(&mut cmd);
|
|
cmd
|
|
}
|
|
|
|
/// Cleans AppImage runtime environment variables so child processes don't load incompatible
|
|
/// libraries from the launcher's bundled AppImage.
|
|
#[cfg(unix)]
|
|
pub fn clean_appimage_env(cmd: &mut Command) {
|
|
if std::env::var_os("APPIMAGE").is_some() {
|
|
for var in [
|
|
"LD_LIBRARY_PATH",
|
|
"LD_PRELOAD",
|
|
"APPDIR",
|
|
"APPIMAGE",
|
|
"ARGV0",
|
|
"OWD",
|
|
"GDK_PIXBUF_MODULE_FILE",
|
|
"GIO_MODULE_DIR",
|
|
"GTK_PATH",
|
|
"PYTHONHOME",
|
|
"QT_PLUGIN_PATH",
|
|
] {
|
|
cmd.env_remove(var);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs a helper and returns its standard output when it succeeds within `timeout`.
|
|
pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option<String> {
|
|
run(program, args, timeout).ok()
|
|
}
|
|
|
|
/// Runs a helper and returns its standard output when it succeeds within `timeout`; otherwise
|
|
/// the reason, in the helper's own words (its standard error) where it gave one.
|
|
pub fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, String> {
|
|
let mut cmd = command(program);
|
|
cmd.args(args);
|
|
run_command(cmd, program, timeout)
|
|
}
|
|
|
|
/// How a helper failed: for a check, whether the helper reached an answer at all.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum HelperFailure {
|
|
/// The helper ran to its end and exited with `code`. `reason` is in its own words where it
|
|
/// gave them.
|
|
Exited { code: i32, reason: String },
|
|
/// The helper gave no answer: it couldn't be started or read, a signal stopped it, or it ran
|
|
/// past its time limit.
|
|
Unfinished(String),
|
|
}
|
|
|
|
impl HelperFailure {
|
|
/// The reason in words, as [`run`] reports it.
|
|
pub fn into_reason(self) -> String {
|
|
match self {
|
|
Self::Exited { reason, .. } | Self::Unfinished(reason) => reason,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// [`run`], for a caller that needs to tell a helper's answer from a helper that gave none.
|
|
pub fn run_detailed(program: &str, args: &[&str], timeout: Duration) -> Result<String, HelperFailure> {
|
|
let mut cmd = command(program);
|
|
cmd.args(args);
|
|
run_command_detailed(cmd, program, timeout)
|
|
}
|
|
|
|
/// Runs a configured helper with bounded output and a timeout.
|
|
pub fn run_command(cmd: Command, program: &str, timeout: Duration) -> Result<String, String> {
|
|
run_command_detailed(cmd, program, timeout).map_err(HelperFailure::into_reason)
|
|
}
|
|
|
|
fn run_command_detailed(mut cmd: Command, program: &str, timeout: Duration) -> Result<String, HelperFailure> {
|
|
let mut child = cmd
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| HelperFailure::Unfinished(format!("couldn't start {program} ({e})")))?;
|
|
// Read both pipes while it runs, so a chatty helper can't block on a full one.
|
|
let readers = read_all(child.stdout.take()).and_then(|stdout| Ok((stdout, read_all(child.stderr.take())?)));
|
|
let (stdout, stderr) = match readers {
|
|
Ok(readers) => readers,
|
|
Err(e) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(HelperFailure::Unfinished(format!("couldn't read the output of {program} ({e})")));
|
|
}
|
|
};
|
|
let timed_out = || HelperFailure::Unfinished(format!("{program} didn't finish within {} seconds", timeout.as_secs()));
|
|
let start = Instant::now();
|
|
let status = loop {
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => break status,
|
|
Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(15)),
|
|
_ => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(timed_out());
|
|
}
|
|
}
|
|
};
|
|
// A descendant may retain a pipe after the direct child exits. Keep reader completion
|
|
// under the same deadline; on timeout the readers detach, as in the child timeout path.
|
|
while !stdout.is_finished() || !stderr.is_finished() {
|
|
if start.elapsed() >= timeout {
|
|
return Err(timed_out());
|
|
}
|
|
std::thread::sleep(Duration::from_millis(15));
|
|
}
|
|
let text = |pipe: std::thread::JoinHandle<Vec<u8>>| String::from_utf8_lossy(&pipe.join().unwrap_or_default()).into_owned();
|
|
let (out, err) = (text(stdout), text(stderr));
|
|
if status.success() {
|
|
return Ok(out);
|
|
}
|
|
let reason = err.trim();
|
|
let reason = if reason.is_empty() { format!("{program} failed ({status})") } else { reason.to_string() };
|
|
Err(match status.code() {
|
|
Some(code) => HelperFailure::Exited { code, reason },
|
|
None => HelperFailure::Unfinished(reason),
|
|
})
|
|
}
|
|
|
|
/// Captures at most 8 MB of a child's pipe and drains the rest on its own thread.
|
|
fn read_all<R: std::io::Read + Send + 'static>(pipe: Option<R>) -> std::io::Result<std::thread::JoinHandle<Vec<u8>>> {
|
|
std::thread::Builder::new().name("helper-output".into()).spawn(move || {
|
|
let mut out = Vec::new();
|
|
if let Some(mut pipe) = pipe {
|
|
let _ = std::io::Read::read_to_end(&mut std::io::Read::take(&mut pipe, 8 << 20), &mut out);
|
|
// Keep reading without retaining more output: closing a full pipe early can make a
|
|
// successful helper fail with a broken pipe (SIGPIPE on Unix).
|
|
let _ = std::io::copy(&mut pipe, &mut std::io::sink());
|
|
}
|
|
out
|
|
})
|
|
}
|
|
|
|
/// Starts `cmd` without waiting for it.
|
|
///
|
|
/// On Unix the process is reaped when it exits: a thread that waits for the process starts
|
|
/// first and starts the process itself. When no thread can be started, the call returns an error
|
|
/// and does not start the process. On Windows the process starts on the calling thread, and no
|
|
/// thread waits for it.
|
|
pub fn spawn_detached(mut cmd: Command) -> Result<(), String> {
|
|
cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
|
if cfg!(windows) {
|
|
return cmd.spawn().map(drop).map_err(|e| format!("couldn't start it ({e})"));
|
|
}
|
|
spawn_detached_with(
|
|
move || cmd.spawn(),
|
|
|wait| {
|
|
std::thread::Builder::new()
|
|
.name("reap-child".into())
|
|
.spawn(move || {
|
|
let _ = wait();
|
|
})
|
|
.map(drop)
|
|
},
|
|
)
|
|
}
|
|
|
|
/// The thread that [`spawn_detached`] starts on Unix: `start_thread` starts a thread that runs
|
|
/// the closure it is given, and that closure calls `spawn` and then waits for the process.
|
|
/// `spawn` is called on that thread only, so when `start_thread` fails nothing is started.
|
|
#[cfg_attr(windows, allow(dead_code))]
|
|
fn spawn_detached_with(
|
|
spawn: impl FnOnce() -> std::io::Result<Child> + Send + 'static,
|
|
start_thread: impl FnOnce(Box<dyn FnOnce() -> Option<ExitStatus> + Send>) -> std::io::Result<()>,
|
|
) -> Result<(), String> {
|
|
let (started, start_result) = mpsc::channel();
|
|
start_thread(Box::new(move || start_and_wait(spawn, &started))).map_err(|e| format!("couldn't start a thread to wait for it ({e})"))?;
|
|
match start_result.recv() {
|
|
Ok(Ok(())) => Ok(()),
|
|
Ok(Err(e)) => Err(format!("couldn't start it ({e})")),
|
|
Err(_) => Err("couldn't start it (its thread ended before starting it)".into()),
|
|
}
|
|
}
|
|
|
|
/// Calls `spawn`, sends whether that worked on `started`, and waits for the process to exit.
|
|
/// Returns its exit status, or `None` when it didn't start or couldn't be waited for.
|
|
#[cfg_attr(windows, allow(dead_code))]
|
|
fn start_and_wait(spawn: impl FnOnce() -> std::io::Result<Child>, started: &mpsc::Sender<std::io::Result<()>>) -> Option<ExitStatus> {
|
|
match spawn() {
|
|
Ok(mut child) => {
|
|
let _ = started.send(Ok(()));
|
|
child.wait().ok()
|
|
}
|
|
Err(e) => {
|
|
let _ = started.send(Err(e));
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Waits up to `timeout` for the process `pid` to exit, and says whether it has. A process that
|
|
/// can't be opened, because it's gone or never was, counts as exited.
|
|
#[cfg(windows)]
|
|
pub fn wait_for_exit(pid: u32, timeout: Duration) -> bool {
|
|
use winsafe::{HPROCESS, co};
|
|
let Ok(process) = HPROCESS::OpenProcess(co::PROCESS::SYNCHRONIZE, false, pid) else { return true };
|
|
// u32::MAX would mean INFINITE.
|
|
let ms = u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX - 1);
|
|
process.WaitForSingleObject(Some(ms)).is_ok_and(|w| w == co::WAIT::OBJECT_0)
|
|
}
|
|
|
|
/// Whether this process is running inside a Flatpak container sandbox.
|
|
pub fn in_sandbox() -> bool {
|
|
std::env::var_os("FLATPAK_ID").is_some() || Path::new("/.flatpak-info").exists()
|
|
}
|
|
|
|
/// A command to run `flatpak`, delegating to the host via `flatpak-spawn --host` if inside a sandbox.
|
|
pub fn flatpak_command() -> Command {
|
|
if in_sandbox() {
|
|
let mut cmd = command("flatpak-spawn");
|
|
cmd.args(["--host", "flatpak"]);
|
|
cmd
|
|
} else {
|
|
command("flatpak")
|
|
}
|
|
}
|
|
|
|
/// [`flatpak_command`] in the C locale, for output this code reads: Flatpak translates its messages.
|
|
pub fn flatpak_command_c() -> Command {
|
|
if in_sandbox() {
|
|
let mut cmd = command("flatpak-spawn");
|
|
cmd.args(["--host", "--env=LC_ALL=C", "flatpak"]);
|
|
cmd
|
|
} else {
|
|
let mut cmd = command("flatpak");
|
|
cmd.env("LC_ALL", "C");
|
|
cmd
|
|
}
|
|
}
|
|
|
|
/// Runs a flatpak command and captures its standard output.
|
|
pub fn capture_flatpak(args: &[&str], timeout: Duration) -> Option<String> {
|
|
if in_sandbox() {
|
|
let mut spawn_args = vec!["--host", "flatpak"];
|
|
spawn_args.extend_from_slice(args);
|
|
capture("flatpak-spawn", &spawn_args, timeout)
|
|
} else {
|
|
capture("flatpak", args, timeout)
|
|
}
|
|
}
|
|
|
|
/// Whether Flatpak is available on the machine (or on the host when sandboxed).
|
|
pub fn is_flatpak_available() -> bool {
|
|
if in_sandbox() {
|
|
capture("flatpak-spawn", &["--host", "flatpak", "--version"], Duration::from_secs(3)).is_some()
|
|
} else {
|
|
which_on_path("flatpak").is_some()
|
|
}
|
|
}
|
|
|
|
fn which_on_path(name: &str) -> Option<std::path::PathBuf> {
|
|
let path = std::env::var_os("PATH")?;
|
|
std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file())
|
|
}
|
|
|
|
/// Starts an app binary from its own folder.
|
|
pub fn launch_binary(exe: &Path, args: &[&str]) -> Result<(), String> {
|
|
let mut cmd = if in_sandbox() {
|
|
let mut c = command("flatpak-spawn");
|
|
c.arg("--host").arg(exe);
|
|
c
|
|
} else {
|
|
command(exe)
|
|
};
|
|
cmd.args(args);
|
|
if let Some(dir) = exe.parent() {
|
|
cmd.current_dir(dir);
|
|
}
|
|
spawn_detached(cmd)
|
|
}
|
|
|
|
/// Opens a file, folder or URL with the system's default handler.
|
|
pub fn open(target: &std::ffi::OsStr) -> Result<(), String> {
|
|
spawn_detached(opener(target)?)
|
|
}
|
|
|
|
/// The system's default opener for `target`: `explorer` on Windows, `open` on macOS, and
|
|
/// `xdg-open` elsewhere (on the host, through `flatpak-spawn --host`, inside the sandbox).
|
|
fn opener(target: &std::ffi::OsStr) -> Result<Command, String> {
|
|
let mut cmd = if cfg!(target_os = "macos") {
|
|
command("open")
|
|
} else if cfg!(windows) {
|
|
command(windows_system_program("explorer.exe")?)
|
|
} else if in_sandbox() {
|
|
let mut c = command("flatpak-spawn");
|
|
c.arg("--host").arg("xdg-open");
|
|
c
|
|
} else {
|
|
command("xdg-open")
|
|
};
|
|
cmd.arg(target);
|
|
Ok(cmd)
|
|
}
|
|
|
|
/// Opens `primary_url` with the system's default handler. If opening fails (for example,
|
|
/// if a custom scheme handler is not found or fails to execute), falls back to `fallback_url`.
|
|
pub fn open_url_with_fallback(primary_url: &str, fallback_url: &str) {
|
|
let primary = primary_url.to_string();
|
|
let fallback = fallback_url.to_string();
|
|
let opener = std::thread::Builder::new().name("open-url".into()).spawn(move || {
|
|
if !open_and_wait_success(&primary, Duration::from_millis(1500)) {
|
|
log::warn!("couldn't open a link; opening its fallback");
|
|
let _ = open(std::ffi::OsStr::new(&fallback));
|
|
}
|
|
});
|
|
if let Err(e) = opener {
|
|
log::warn!("couldn't start a thread to open a link ({e})");
|
|
}
|
|
}
|
|
|
|
/// Starts the default system opener for `target` and returns `true` unless it fails to start
|
|
/// or exits in error within `timeout`. An opener still running after `timeout` counts as
|
|
/// success, and the call waits for it to exit before returning. An opener whose state can't be
|
|
/// read is stopped and waited for, and counts as a failure. [`open_url_with_fallback`] calls this
|
|
/// on its own `open-url` thread.
|
|
///
|
|
/// `explorer` exits with an error status even when it opens the target, so on Windows only a
|
|
/// failure to start counts; the scheme check covers the rest.
|
|
fn open_and_wait_success(target: &str, timeout: Duration) -> bool {
|
|
let mut cmd = match opener(std::ffi::OsStr::new(target)) {
|
|
Ok(cmd) => cmd,
|
|
Err(error) => {
|
|
log::warn!("couldn't resolve the system opener: {error}");
|
|
return false;
|
|
}
|
|
};
|
|
if cfg!(windows) {
|
|
return spawn_detached(cmd).is_ok();
|
|
}
|
|
let mut child = match cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
log::warn!("couldn't start the system opener: {e}");
|
|
return false;
|
|
}
|
|
};
|
|
let start = Instant::now();
|
|
loop {
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => return status.success(),
|
|
Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(25)),
|
|
Ok(None) => {
|
|
let _ = child.wait();
|
|
return true;
|
|
}
|
|
Err(_) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Shows `path` selected in the file manager.
|
|
pub fn reveal(path: &Path) -> Result<(), String> {
|
|
if cfg!(target_os = "macos") {
|
|
let mut cmd = command("open");
|
|
cmd.arg("-R").arg(path);
|
|
spawn_detached(cmd)
|
|
} else if cfg!(windows) {
|
|
reveal_in_explorer(path)
|
|
} else {
|
|
open(path.parent().unwrap_or(path).as_os_str())
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn reveal_in_explorer(path: &Path) -> Result<(), String> {
|
|
use std::os::windows::process::CommandExt;
|
|
let mut cmd = command(windows_system_program("explorer.exe")?);
|
|
// Passed raw: `arg` would wrap all of `/select,...` in quotes when the path has a space
|
|
// (`C:\Program Files\...`), and Explorer then ignores the switch and opens Documents.
|
|
// A quote in the path would end the quoted part early and let the rest reach Explorer as
|
|
// switches; no Windows path has one, so refuse it.
|
|
let arg = explorer_select_arg(path).ok_or_else(|| format!("{} isn't a usable path", path.display()))?;
|
|
cmd.raw_arg(arg);
|
|
spawn_detached(cmd)
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn reveal_in_explorer(_path: &Path) -> Result<(), String> {
|
|
Err("Explorer is only on Windows".into())
|
|
}
|
|
|
|
/// Explorer's `/select,"<path>"` argument as it goes on the command line: only the path is quoted,
|
|
/// and forward slashes become backslashes, which Explorer needs to find the item. `None` for a path
|
|
/// with a `"` in it.
|
|
#[cfg_attr(not(windows), allow(dead_code))]
|
|
fn explorer_select_arg(path: &Path) -> Option<std::ffi::OsString> {
|
|
let s = path.to_str()?;
|
|
if s.contains('"') {
|
|
return None;
|
|
}
|
|
let mut arg = std::ffi::OsString::from("/select,\"");
|
|
arg.push(s.replace('/', "\\"));
|
|
arg.push("\"");
|
|
Some(arg)
|
|
}
|
|
|
|
/// Whether a process started from inside `path` (an exe, an AppImage, or a `.app`) is running.
|
|
pub fn is_running(path: &Path) -> bool {
|
|
running(path).unwrap_or(false)
|
|
}
|
|
|
|
/// Whether a process started from inside `path` (an exe, an AppImage, or a `.app`) is running.
|
|
/// On macOS, `Err` gives the reason the check got no answer. On other systems a check that gets
|
|
/// no answer counts as "not running", since `pgrep` isn't installed on every Linux system.
|
|
pub fn running(path: &Path) -> Result<bool, String> {
|
|
if cfg!(target_os = "macos") {
|
|
let p = path.to_str().ok_or("unusual characters in the app's path")?;
|
|
return pgrep_answer(run_detailed("/usr/bin/pgrep", &["-f", "--", &pgrep_pattern(p)], Duration::from_secs(30)));
|
|
}
|
|
if cfg!(windows) {
|
|
let Some(name) = path.file_name().and_then(OsStr::to_str) else { return Ok(false) };
|
|
let filter = format!("IMAGENAME eq {name}");
|
|
let Ok(tasklist) = windows_system_program(r"System32\tasklist.exe") else { return Ok(false) };
|
|
let mut cmd = command(tasklist);
|
|
cmd.args(["/FI", &filter, "/NH", "/FO", "CSV"]);
|
|
Ok(run_command(cmd, "tasklist", Duration::from_secs(5)).ok().is_some_and(|out| out.to_ascii_lowercase().contains(&name.to_ascii_lowercase())))
|
|
} else {
|
|
let Some(p) = path.to_str() else { return Ok(false) };
|
|
let pattern = pgrep_pattern(p);
|
|
// pgrep exits 0 when something matches; it never matches itself.
|
|
Ok(if in_sandbox() {
|
|
capture("flatpak-spawn", &["--host", "pgrep", "-f", "--", &pattern], Duration::from_secs(5)).is_some()
|
|
} else {
|
|
capture("pgrep", &["-f", "--", &pattern], Duration::from_secs(5)).is_some()
|
|
})
|
|
}
|
|
}
|
|
|
|
/// What a `pgrep` run says: exit 0 is a match and exit 1 is no match. Anything else gave no answer.
|
|
fn pgrep_answer(output: Result<String, HelperFailure>) -> Result<bool, String> {
|
|
match output {
|
|
Ok(_) => Ok(true),
|
|
Err(HelperFailure::Exited { code: 1, .. }) => Ok(false),
|
|
Err(failure) => Err(failure.into_reason()),
|
|
}
|
|
}
|
|
|
|
fn windows_system_program(relative: &str) -> Result<PathBuf, String> {
|
|
let root = std::env::var_os("SystemRoot")
|
|
.filter(|root| !root.is_empty())
|
|
.map(PathBuf::from)
|
|
.ok_or_else(|| "Windows didn't provide its system folder".to_string())?;
|
|
if !root.is_absolute() {
|
|
return Err("Windows provided a non-absolute system folder".into());
|
|
}
|
|
Ok(root.join(relative))
|
|
}
|
|
|
|
/// Quote the path's metacharacters for pgrep's extended regular expression.
|
|
fn pgrep_pattern(path: &str) -> String {
|
|
let mut pattern = String::with_capacity(path.len());
|
|
for c in path.chars() {
|
|
if matches!(c, '.' | '^' | '$' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\') {
|
|
pattern.push('\\');
|
|
}
|
|
pattern.push(c);
|
|
}
|
|
pattern
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[cfg(unix)]
|
|
pub(crate) mod tests {
|
|
use std::io::{BufRead, BufReader};
|
|
use std::process::Child;
|
|
|
|
use super::*;
|
|
|
|
/// A process whose command line is `path`, as `pgrep -f` sees it, until dropped.
|
|
pub(crate) struct RunningProbe(Child);
|
|
|
|
impl RunningProbe {
|
|
pub(crate) fn start(path: &str) -> Self {
|
|
let child = command("/bin/sh")
|
|
.args(["-c", "printf 'ready\\n'; read line", path])
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.unwrap();
|
|
let mut probe = Self(child);
|
|
let mut ready = String::new();
|
|
BufReader::new(probe.0.stdout.take().unwrap()).read_line(&mut ready).unwrap();
|
|
assert_eq!(ready, "ready\n");
|
|
probe
|
|
}
|
|
}
|
|
|
|
impl Drop for RunningProbe {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn running_paths_are_matched_literally() {
|
|
let root = format!("/tmp/artcraft-launcher-process-{}", std::process::id());
|
|
for (actual, requested, expected) in [
|
|
("plain/PhotoCraft.app", "plain/PhotoCraft.app", true),
|
|
("Apps (work)/PhotoCraft.app", "Apps (work)/PhotoCraft.app", true),
|
|
("Apps [work]/PhotoCraft.app", "Apps [work]/PhotoCraft.app", true),
|
|
("other/PhotoCraftXapp", "other/PhotoCraft.app", false),
|
|
("symbols/应用.^$*+?()[]{}|\\.app", "symbols/应用.^$*+?()[]{}|\\.app", true),
|
|
] {
|
|
let actual = format!("{root}/{actual}");
|
|
let requested = format!("{root}/{requested}");
|
|
let _probe = RunningProbe::start(&actual);
|
|
assert_eq!(running(Path::new(&requested)), Ok(expected), "{requested}");
|
|
assert_eq!(is_running(Path::new(&requested)), expected, "{requested}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod run_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn flatpak_output_this_code_reads_stays_in_english() {
|
|
if in_sandbox() {
|
|
return;
|
|
}
|
|
let cmd = flatpak_command_c();
|
|
assert!(cmd.get_envs().any(|(name, value)| name == "LC_ALL" && value == Some(std::ffi::OsStr::new("C"))));
|
|
}
|
|
|
|
/// A shell command line, for the platform's shell.
|
|
fn shell(script: &str) -> (&'static str, [&str; 2]) {
|
|
if cfg!(windows) { ("cmd", ["/C", script]) } else { ("/bin/sh", ["-c", script]) }
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn waiting_for_a_process_ends_when_it_exits() {
|
|
let mut child = Command::new("ping").args(["-n", "3", "127.0.0.1"]).stdout(Stdio::null()).spawn().unwrap();
|
|
assert!(!wait_for_exit(child.id(), Duration::from_millis(200)), "it's still running");
|
|
assert!(wait_for_exit(child.id(), Duration::from_secs(30)), "it has exited");
|
|
child.wait().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_failing_helper_reports_its_own_error() {
|
|
let (program, args) = shell(if cfg!(windows) { "echo no such image 1>&2 & exit /b 3" } else { "echo no such image >&2; exit 3" });
|
|
assert_eq!(run(program, &args, Duration::from_secs(30)), Err("no such image".into()));
|
|
assert_eq!(capture(program, &args, Duration::from_secs(30)), None);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn successful_helpers_can_write_past_the_stdout_capture_limit() {
|
|
let out = run("/bin/sh", &["-c", "head -c 9437184 /dev/zero"], Duration::from_secs(10)).unwrap();
|
|
assert_eq!(out.len(), 8 << 20);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn successful_helpers_can_write_past_the_stderr_capture_limit() {
|
|
let out = run("/bin/sh", &["-c", "head -c 9437184 /dev/zero >&2"], Duration::from_secs(10));
|
|
assert!(out.is_ok(), "the helper should finish without a broken pipe");
|
|
assert!(out.unwrap().is_empty());
|
|
}
|
|
|
|
/// Long enough that no helper in these tests reaches it.
|
|
#[cfg(unix)]
|
|
const NO_LIMIT: Duration = Duration::from_secs(86_400);
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_helper_past_its_time_limit_gives_no_answer() {
|
|
// A time limit of zero expires at the first look, so the test doesn't wait for it.
|
|
let mut cmd = command("/bin/sh");
|
|
cmd.args(["-c", "exec sleep 86400"]);
|
|
let timed_out = run_command_detailed(cmd, "the helper", Duration::ZERO);
|
|
assert_eq!(timed_out, Err(HelperFailure::Unfinished("the helper didn't finish within 0 seconds".into())));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_helper_that_cannot_start_gives_no_answer() {
|
|
let not_found = run_detailed("/nonexistent/helper", &[], NO_LIMIT);
|
|
assert!(matches!(not_found, Err(HelperFailure::Unfinished(_))), "{not_found:?}");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_helper_stopped_by_a_signal_gives_no_answer() {
|
|
let killed = run_detailed("/bin/sh", &["-c", "kill -9 $$"], NO_LIMIT);
|
|
assert!(matches!(killed, Err(HelperFailure::Unfinished(_))), "{killed:?}");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_helper_that_exits_gives_its_code_and_its_reason() {
|
|
let exited = run_detailed("/bin/sh", &["-c", "echo it failed >&2; exit 3"], NO_LIMIT);
|
|
assert_eq!(exited, Err(HelperFailure::Exited { code: 3, reason: "it failed".into() }));
|
|
assert_eq!(run("/bin/sh", &["-c", "echo it failed >&2; exit 3"], NO_LIMIT), Err("it failed".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn pgrep_finding_nothing_is_an_answer() {
|
|
assert_eq!(pgrep_answer(Ok("123\n".into())), Ok(true));
|
|
assert_eq!(pgrep_answer(Err(HelperFailure::Exited { code: 1, reason: "pgrep failed".into() })), Ok(false));
|
|
}
|
|
|
|
#[test]
|
|
fn a_pgrep_that_gives_no_answer_is_not_read_as_not_running() {
|
|
assert_eq!(pgrep_answer(Err(HelperFailure::Unfinished("too slow".into()))), Err("too slow".into()));
|
|
assert_eq!(pgrep_answer(Err(HelperFailure::Exited { code: 2, reason: "bad flag".into() })), Err("bad flag".into()));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_helper_that_keeps_writing_still_times_out() {
|
|
let start = Instant::now();
|
|
let err = run("yes", &[], Duration::from_millis(100)).unwrap_err();
|
|
assert!(err.contains("didn't finish within"), "{err}");
|
|
assert!(start.elapsed() < Duration::from_secs(5));
|
|
}
|
|
|
|
/// The thread that starts a detached process waits for it, so the process is reaped when it
|
|
/// exits: only a wait gives its exit status.
|
|
#[test]
|
|
fn a_detached_process_is_waited_for() {
|
|
let (program, args) = shell("exit 7");
|
|
let mut cmd = command(program);
|
|
cmd.args(args);
|
|
let (status_sent, status) = mpsc::channel();
|
|
let result = spawn_detached_with(
|
|
move || cmd.spawn(),
|
|
move |wait| {
|
|
std::thread::Builder::new()
|
|
.spawn(move || {
|
|
let _ = status_sent.send(wait());
|
|
})
|
|
.map(drop)
|
|
},
|
|
);
|
|
assert_eq!(result, Ok(()));
|
|
assert_eq!(status.recv().ok().flatten().and_then(|status| status.code()), Some(7), "the waiting thread gets the exit status");
|
|
}
|
|
|
|
/// When no thread can be started to wait for a process, the process is not started and the
|
|
/// call says why.
|
|
#[test]
|
|
fn no_process_starts_without_a_thread_to_wait_for_it() {
|
|
let (program, args) = shell("exit 0");
|
|
let mut cmd = command(program);
|
|
cmd.args(args);
|
|
let spawned = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let spawn_called = std::sync::Arc::clone(&spawned);
|
|
let result = spawn_detached_with(
|
|
move || {
|
|
spawn_called.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
cmd.spawn()
|
|
},
|
|
|_wait| Err(std::io::Error::other("no threads")),
|
|
);
|
|
assert_eq!(result, Err("couldn't start a thread to wait for it (no threads)".into()));
|
|
assert!(!spawned.load(std::sync::atomic::Ordering::SeqCst), "the process was started");
|
|
}
|
|
|
|
#[test]
|
|
fn a_detached_program_that_cannot_start_says_so() {
|
|
let result = spawn_detached(command("craft-update-test-no-such-program"));
|
|
assert!(matches!(&result, Err(e) if e.starts_with("couldn't start it (")), "{result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_silent_failure_and_a_success() {
|
|
let (program, args) = shell("exit 4");
|
|
let err = run(program, &args, Duration::from_secs(30)).unwrap_err();
|
|
assert!(err.starts_with(&format!("{program} failed")), "{err}");
|
|
let (program, args) = shell("echo hello");
|
|
assert_eq!(run(program, &args, Duration::from_secs(30)).map(|out| out.trim().to_string()), Ok("hello".into()));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod explorer_tests {
|
|
use super::*;
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn system_helpers_use_absolute_system_paths() {
|
|
let root = PathBuf::from(std::env::var_os("SystemRoot").unwrap());
|
|
assert_eq!(windows_system_program("explorer.exe").unwrap(), root.join("explorer.exe"));
|
|
assert_eq!(windows_system_program(r"System32\tasklist.exe").unwrap(), root.join(r"System32\tasklist.exe"));
|
|
}
|
|
|
|
#[test]
|
|
fn explorer_select_quotes_only_the_path() {
|
|
let arg = explorer_select_arg(Path::new(r"C:\Program Files\PhotoCraft\photocraft.exe"));
|
|
assert_eq!(arg.unwrap(), r#"/select,"C:\Program Files\PhotoCraft\photocraft.exe""#);
|
|
}
|
|
|
|
#[test]
|
|
fn explorer_select_without_spaces() {
|
|
let arg = explorer_select_arg(Path::new(r"C:\Apps\photocraft.exe"));
|
|
assert_eq!(arg.unwrap(), r#"/select,"C:\Apps\photocraft.exe""#);
|
|
assert_eq!(explorer_select_arg(Path::new(r#"C:\Apps" /root,C:\x.exe"#)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn explorer_select_uses_backslashes() {
|
|
let arg = explorer_select_arg(Path::new("C:/Users/A B/AppData/Local/ArtCraft Launcher/apps/photocraft.exe"));
|
|
assert_eq!(arg.unwrap(), r#"/select,"C:\Users\A B\AppData\Local\ArtCraft Launcher\apps\photocraft.exe""#);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod inherited_pipe_tests {
|
|
use super::*;
|
|
use std::io::Write;
|
|
|
|
fn fixture_args(name: &str, marker: &str) -> Vec<String> {
|
|
let (_, module) = module_path!().split_once("::").unwrap();
|
|
// libtest accepts arbitrary skip filters; this one carries our private fixture path.
|
|
vec!["--ignored".into(), "--exact".into(), format!("{module}::{name}"), "--nocapture".into(), "--skip".into(), marker.into()]
|
|
}
|
|
|
|
fn fixture_dir() -> std::path::PathBuf {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
std::path::PathBuf::from(&args[args.iter().position(|arg| arg == "--skip").unwrap() + 1])
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "self-spawned subprocess fixture"]
|
|
fn pipe_holder() {
|
|
let dir = fixture_dir();
|
|
let buffer = [b'x'; 64 << 10];
|
|
// Both streams exceed the capture cap before the direct child exits.
|
|
for _ in 0..144 {
|
|
std::io::stdout().write_all(&buffer).unwrap();
|
|
std::io::stderr().write_all(&buffer).unwrap();
|
|
}
|
|
std::fs::write(dir.join("ready"), []).unwrap();
|
|
// Hold the pipes far longer than any deadline the test tries, so a capture that waited
|
|
// for them would show; the test's guard stops this early.
|
|
let start = Instant::now();
|
|
while !dir.join("stop").exists() && start.elapsed() < HOLD {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
std::fs::write(dir.join("done"), []).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "self-spawned subprocess fixture"]
|
|
fn pipe_parent() {
|
|
let dir = fixture_dir();
|
|
let descendant = command(std::env::current_exe().unwrap()).args(fixture_args("pipe_holder", dir.to_str().unwrap())).spawn().unwrap();
|
|
std::fs::write(dir.join("spawned"), []).unwrap();
|
|
// No deadline of its own: run() kills this process at its deadline if it is too slow.
|
|
let start = Instant::now();
|
|
while !dir.join("ready").exists() && start.elapsed() < HOLD {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
assert!(dir.join("ready").exists(), "descendant did not fill both captured pipes");
|
|
std::fs::write(dir.join("parent-returning"), []).unwrap();
|
|
// Dropping Child leaves the finite fixture running with inherited stdout/stderr.
|
|
drop(descendant);
|
|
}
|
|
|
|
/// How long the descendant holds the pipes when nothing stops it.
|
|
const HOLD: Duration = Duration::from_secs(120);
|
|
|
|
struct StopHolder(tempfile::TempDir);
|
|
|
|
impl Drop for StopHolder {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::write(self.0.path().join("stop"), []);
|
|
if !self.0.path().join("spawned").exists() {
|
|
return;
|
|
}
|
|
let start = Instant::now();
|
|
while !self.0.path().join("done").exists() && start.elapsed() < Duration::from_secs(30) {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn exited_helper_does_not_wait_past_deadline_for_inherited_pipes() {
|
|
let exe = std::env::current_exe().unwrap();
|
|
// The direct child has to start a second test process and fill both pipes before the
|
|
// deadline, which can take seconds on a slow or emulated machine. An attempt where it
|
|
// didn't get that far says nothing about inherited pipes: retry with a longer deadline.
|
|
let mut deadline = Duration::from_secs(1);
|
|
loop {
|
|
let holder = StopHolder(tempfile::tempdir().unwrap());
|
|
let args = fixture_args("pipe_parent", holder.0.path().to_str().unwrap());
|
|
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
|
let start = Instant::now();
|
|
let result = run(exe.to_str().unwrap(), &args, deadline);
|
|
let elapsed = start.elapsed();
|
|
if !holder.0.path().join("parent-returning").exists() && deadline < Duration::from_secs(16) {
|
|
deadline *= 4;
|
|
continue;
|
|
}
|
|
assert!(holder.0.path().join("parent-returning").exists(), "direct child did not exit its fixture within {deadline:?}");
|
|
assert!(
|
|
matches!(&result, Err(error) if error.contains("didn't finish within")),
|
|
"expected a pipe deadline error; helper returned success: {}, elapsed: {elapsed:?}",
|
|
result.is_ok()
|
|
);
|
|
// The descendant would hold the pipes for HOLD, far past this bound.
|
|
assert!(deadline * 2 < HOLD);
|
|
assert!(elapsed < deadline * 2, "capture exceeded its caller deadline {deadline:?}: {elapsed:?}");
|
|
// The guard stops the finite descendant and waits for its completion marker on failures too.
|
|
break;
|
|
}
|
|
}
|
|
}
|