Files
tauri-plugins-workspace/plugins/single-instance/tests/dbus_errors.rs
T
Vincent KocandLucas Nogueira f83c5d3b0f fix(single-instance): propagate D-Bus setup and handoff errors (#3584)
* fix(single-instance): propagate D-Bus setup and handoff errors

* chore: satisfy release metadata and test header checks

* fix(single-instance): launch normally without a D-Bus session bus

Log a warning instead of failing setup when the session bus is unreachable, and add context to handoff errors.

* ci: run the single-instance D-Bus test under dbus-run-session

Run it in test-rust on Linux and keep it out of the updater integration job, which has no session bus.

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
2026-10-11 06:18:21 -03:00

193 lines
6.4 KiB
Rust

// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
#![cfg(all(
any(target_os = "linux", target_os = "freebsd"),
not(feature = "semver")
))]
use std::{
env, fs,
path::PathBuf,
process::{Child, Command, Output, Stdio},
sync::mpsc,
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tauri::test::{mock_builder, mock_context, noop_assets};
const TEST_NAME: &str = "dbus_setup_errors";
const ID_ENV: &str = "TAURI_DBUS_ERROR_ID";
const SETUP_ERROR: &str = "SETUP_ERROR:";
const STARTED: &str = "STARTED";
struct RejectCallback(mpsc::Sender<()>);
#[zbus::interface(name = "org.SingleInstance.DBus")]
impl RejectCallback {
fn execute_callback(&self, _argv: Vec<String>, _cwd: String) -> zbus::fdo::Result<()> {
self.0.send(()).unwrap();
Err(zbus::fdo::Error::Failed(
"test primary rejected delivery".into(),
))
}
}
struct Sandbox(PathBuf);
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
struct TestChild(Option<Child>);
impl TestChild {
fn spawn(id: &str, address: &str) -> Self {
Self(Some(
Command::new(env::current_exe().unwrap())
.args(["--ignored", "--exact", TEST_NAME, "--nocapture"])
.env(ID_ENV, id)
.env("DBUS_SESSION_BUS_ADDRESS", address)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap(),
))
}
fn wait(mut self) -> Output {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if self.0.as_mut().unwrap().try_wait().unwrap().is_some() {
return self.0.take().unwrap().wait_with_output().unwrap();
}
assert!(Instant::now() < deadline, "D-Bus test child timed out");
thread::sleep(Duration::from_millis(10));
}
}
}
impl Drop for TestChild {
fn drop(&mut self) {
if let Some(child) = self.0.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn assert_setup_error(output: Output) -> String {
assert_eq!(output.status.code(), Some(101));
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains(SETUP_ERROR), "{stdout}");
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("PluginInitialization(\"single-instance\""),
"{stderr}"
);
stdout
}
fn assert_started(output: Output) {
assert!(output.status.success(), "{output:?}");
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains(STARTED), "{stdout}");
}
#[test]
#[ignore = "requires an explicit dbus-run-session invocation"]
fn dbus_setup_errors() -> Result<(), tauri::Error> {
if let Ok(id) = env::var(ID_ENV) {
let result = mock_builder()
.plugin(
tauri_plugin_single_instance::Builder::new()
.dbus_id(id)
.build(),
)
.build(mock_context(noop_assets()));
return match result {
// Without a usable session bus the app must still launch.
Ok(app) => {
println!("{STARTED}");
tauri_plugin_single_instance::destroy(&app);
Ok(())
}
Err(tauri::Error::PluginInitialization(plugin, message)) => {
assert_eq!(plugin, "single-instance");
println!("{SETUP_ERROR} {message}");
Err(tauri::Error::PluginInitialization(plugin, message))
}
Err(error) => panic!("unexpected error: {error}"),
};
}
let address =
env::var("DBUS_SESSION_BUS_ADDRESS").expect("run this test inside dbus-run-session");
let id = format!("org.tauri.DBusErrorTest.p{}", std::process::id());
let root = env::temp_dir().join(format!(
"tauri-dbus-error-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir(&root).unwrap();
let sandbox = Sandbox(root);
let missing_address = format!("unix:path={}", sandbox.0.join("missing.sock").display());
assert_started(TestChild::spawn(&id, &missing_address).wait());
assert_started(TestChild::spawn(&id, "invalid-address").wait());
assert_setup_error(TestChild::spawn("invalid dbus id", &address).wait());
// A real endpoint rejects delivery immediately, without bus timeouts or startup races.
let name = format!("{id}.SingleInstance");
let path = format!("/{}", name.replace('.', "/"));
let (tx, rx) = mpsc::channel();
let owner = zbus::blocking::connection::Builder::session()
.unwrap()
.name(name.as_str())
.unwrap()
.serve_at(path.as_str(), RejectCallback(tx))
.unwrap()
.build()
.unwrap();
let failure = assert_setup_error(TestChild::spawn(&id, &address).wait());
assert!(failure.contains("test primary rejected delivery"));
rx.recv_timeout(Duration::from_secs(1)).unwrap();
assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
drop(owner);
// The successful primary and secondary paths still use the real plugin and bus.
let (tx, rx) = mpsc::channel();
let app = mock_builder()
.plugin(
tauri_plugin_single_instance::Builder::new()
.dbus_id(format!("{id}.Success"))
.callback(move |_, args, cwd| tx.send((args, cwd)).unwrap())
.build(),
)
.build(mock_context(noop_assets()))?;
let secondary = TestChild::spawn(&format!("{id}.Success"), &address).wait();
assert!(secondary.status.success());
assert!(
!String::from_utf8(secondary.stdout)
.unwrap()
.contains(STARTED)
);
let (args, cwd) = rx.recv_timeout(Duration::from_secs(1)).unwrap();
assert_eq!(
args,
[
env::current_exe().unwrap().to_str().unwrap(),
"--ignored",
"--exact",
TEST_NAME,
"--nocapture"
]
);
assert_eq!(cwd, env::current_dir().unwrap().to_str().unwrap());
assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
tauri_plugin_single_instance::destroy(&app);
Ok(())
}