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>
This commit is contained in:
Vincent KocandLucas Nogueira authored and GitHub committed 2026-10-11 06:18:21 -03:00
1 parent 6a263bec1c
commit f83c5d3b0f
6 files changed
+237 -13

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"single-instance": patch
---
On Linux, a secondary instance that cannot deliver its arguments to the primary instance now returns a plugin initialization error instead of exiting successfully, and an invalid `dbus_id` returns an error instead of panicking. When the session bus is unavailable the app keeps launching normally, without single-instance support, and logs a warning.
+3 -1
View File
@@ -137,4 +137,6 @@ jobs:
# about a minute linking each of those builds. None of it matters to the updater.
CARGO_PROFILE_RELEASE_LTO: 'false'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
run: cargo test --test '*' -- --ignored
# The single-instance D-Bus test is also ignored by default, but needs a session bus and
# runs in test-rust.yml instead.
run: cargo test --workspace --exclude tauri-plugin-single-instance --test '*' -- --ignored
+6 -1
View File
@@ -111,7 +111,7 @@ jobs:
if: contains(matrix.platform.target, 'unknown-linux')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev
sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev dbus
- uses: dtolnay/rust-toolchain@1.90
with:
@@ -174,6 +174,11 @@ jobs:
# Only on the platforms we actually test on, since cargo skips doc tests when cross compiling.
elif [ "$COMMAND" = test ] && ! $RUNNER test --doc --package "$package" --target "$TARGET" "${features[@]}"; then
failed+=("$package (doc tests)")
# The D-Bus test is ignored by default because it needs its own session bus, and it
# is compiled out with `--all-features` (the `semver` feature changes the bus name).
elif [ "$package" = tauri-plugin-single-instance ] && [ "$COMMAND" = test ] && [[ "$TARGET" == *-linux-* ]] \
&& ! dbus-run-session -- $RUNNER test --package "$package" --target "$TARGET" --test dbus_errors -- --ignored; then
failed+=("$package (D-Bus tests)")
fi
echo "::endgroup::"
done
+3
View File
@@ -26,6 +26,9 @@ tauri-plugin-deep-link = { path = "../deep-link", version = "2.6.1", optional =
semver = { version = "1", optional = true }
tokio = { version = "1", features = ["net"] }
[dev-dependencies]
tauri = { workspace = true, features = ["test"] }
[target."cfg(target_os = \"windows\")".dependencies.windows-sys]
version = "0.61"
features = [
@@ -53,22 +53,30 @@ pub fn init<R: Runtime>(
app_handle: app.clone(),
};
match zbus::blocking::connection::Builder::session()
.unwrap()
.name(dbus_name.as_str())
.unwrap()
let builder = match zbus::blocking::connection::Builder::session() {
Ok(builder) => builder,
Err(error) => {
tracing::warn!(
"single-instance: invalid D-Bus session address, launching normally: {error}"
);
return Ok(());
}
};
match builder
.name(dbus_name.as_str())?
.replace_existing_names(false)
.allow_name_replacements(false)
.serve_at(dbus_path.as_str(), single_instance_dbus)
.unwrap()
.serve_at(dbus_path.as_str(), single_instance_dbus)?
.build()
{
Ok(connection) => {
app.manage(ConnectionHandle(connection));
}
Err(zbus::Error::NameTaken) => {
if let Ok(connection) = Connection::session() {
let _ = connection.call_method(
let connection = Connection::session()?;
connection
.call_method(
Some(dbus_name.as_str()),
dbus_path.as_str(),
Some("org.SingleInstance.DBus"),
@@ -82,12 +90,21 @@ pub fn init<R: Runtime>(
.to_string_lossy()
.as_ref(),
),
);
}
)
.map_err(|error| {
format!("failed to forward arguments to the running instance: {error}")
})?;
// Exit successfully only after the primary accepted the arguments.
app.cleanup_before_exit();
std::process::exit(0);
}
_ => {}
Err(error) => {
// Without a session bus there is no way to find other instances.
tracing::warn!(
"single-instance: D-Bus session bus unavailable, launching normally: {error}"
);
return Ok(());
}
}
app.manage(DBusName(dbus_name));
@@ -0,0 +1,192 @@
// 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(())
}