Skip to content

Commit 91afacb

Browse files
committed
Bugfix: bound read_accent_color_linux probes so D-Bus can't hang startup
`read_accent_color_portal()` called `zbus::blocking::Connection::session()` with no timeout. On a half-configured Linux environment (orphan session-bus socket, no daemon serving — observed on GitHub Actions Ubuntu runner), the connect call blocked forever. The Tauri `setup` chain called this on every cold start, so app launch could wedge for arbitrary time before the brand-gold fallback ever kicked in. - `read_accent_color_portal` and `read_accent_color_gsettings` now run their probes inside `tokio::time::timeout(PROBE_TIMEOUT=500ms)`. Worst case end-to-end: ~1 s (both tiers + fallback) before settling on the Cmdr brand hex. - `read_accent_color` is now async; `get_accent_color` (Tauri command) and `observe_accent_color_changes` (called from `lib.rs::setup`) updated accordingly. The observe path now awaits the initial probe inside the spawned task instead of synchronously on the setup thread. - Test `read_accent_color_returns_valid_hex` was the symptom of this bug (timed out at nextest's 120 s cap in CI for 4 days before it was noticed). Replaced with `read_accent_color_is_bounded_and_returns_valid_hex`, which asserts `elapsed < 2s` alongside the shape checks — so a regression of the timeout discipline fails fast and locally next time.
1 parent c374dd3 commit 91afacb

1 file changed

Lines changed: 70 additions & 24 deletions

File tree

apps/desktop/src-tauri/src/accent_color_linux.rs

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
//! Also observes the portal's `SettingChanged` signal for live accent color
88
//! updates, matching the macOS `NSSystemColorsDidChangeNotification` behavior.
99
10+
use std::time::Duration;
11+
1012
use log::{debug, info, warn};
1113
use tauri::{AppHandle, Emitter, Runtime};
1214
use zbus::zvariant::{OwnedValue, Value};
@@ -20,6 +22,13 @@ const PORTAL_IFACE: &str = "org.freedesktop.portal.Settings";
2022
const APPEARANCE_NS: &str = "org.freedesktop.appearance";
2123
const ACCENT_KEY: &str = "accent-color";
2224

25+
/// Hard cap on each probe. A healthy local session-bus / gsettings responds in milliseconds;
26+
/// anything slower than this means a misconfigured environment (orphan socket with no daemon,
27+
/// stalled subprocess) and we should fall through to the next tier instead of blocking app
28+
/// startup. Without this cap, `zbus::Connection::session()` can hang indefinitely on a
29+
/// half-configured D-Bus (observed at 120 s+ on the GitHub Actions Ubuntu runner).
30+
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
31+
2332
/// Converts sRGB floats in [0, 1] to a `#rrggbb` hex string.
2433
fn rgb_floats_to_hex(r: f64, g: f64, b: f64) -> String {
2534
let r8 = (r.clamp(0.0, 1.0) * 255.0).round() as u8;
@@ -55,13 +64,18 @@ fn extract_rgb(value: &Value<'_>) -> Option<(f64, f64, f64)> {
5564
}
5665

5766
/// Reads accent color via XDG Desktop Portal D-Bus (GNOME 47+, KDE Plasma 5.23+).
58-
fn read_accent_color_portal() -> Option<String> {
59-
let conn = zbus::blocking::Connection::session().ok()?;
60-
let proxy = zbus::blocking::Proxy::new(&conn, PORTAL_DEST, PORTAL_PATH, PORTAL_IFACE).ok()?;
61-
62-
let reply: OwnedValue = proxy.call("ReadOne", &(APPEARANCE_NS, ACCENT_KEY)).ok()?;
63-
64-
let (r, g, b) = extract_rgb(&reply)?;
67+
/// Bounded by `PROBE_TIMEOUT` so a stalled session bus can't hang the caller.
68+
async fn read_accent_color_portal() -> Option<String> {
69+
let probe = async {
70+
let conn = zbus::Connection::session().await.ok()?;
71+
let proxy = zbus::Proxy::new(&conn, PORTAL_DEST, PORTAL_PATH, PORTAL_IFACE)
72+
.await
73+
.ok()?;
74+
let reply: OwnedValue = proxy.call("ReadOne", &(APPEARANCE_NS, ACCENT_KEY)).await.ok()?;
75+
let (r, g, b) = extract_rgb(&reply)?;
76+
Some((r, g, b))
77+
};
78+
let (r, g, b) = tokio::time::timeout(PROBE_TIMEOUT, probe).await.ok().flatten()?;
6579
let hex = rgb_floats_to_hex(r, g, b);
6680
debug!("XDG Portal accent color: ({r:.3}, {g:.3}, {b:.3}) -> {hex}");
6781
Some(hex)
@@ -84,11 +98,12 @@ fn gnome_accent_name_to_hex(name: &str) -> Option<&'static str> {
8498
}
8599

86100
/// Reads accent color via `gsettings` (older GNOME without portal support).
87-
fn read_accent_color_gsettings() -> Option<String> {
88-
let output = std::process::Command::new("gsettings")
101+
/// Bounded by `PROBE_TIMEOUT` so a hung gsettings subprocess can't block startup.
102+
async fn read_accent_color_gsettings() -> Option<String> {
103+
let probe = tokio::process::Command::new("gsettings")
89104
.args(["get", "org.gnome.desktop.interface", "accent-color"])
90-
.output()
91-
.ok()?;
105+
.output();
106+
let output = tokio::time::timeout(PROBE_TIMEOUT, probe).await.ok()?.ok()?;
92107

93108
if !output.status.success() {
94109
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -107,13 +122,16 @@ fn read_accent_color_gsettings() -> Option<String> {
107122
}
108123

109124
/// Reads accent color with fallback chain: XDG Portal → gsettings → brand gold.
110-
fn read_accent_color() -> String {
111-
if let Some(hex) = read_accent_color_portal() {
125+
/// Each tier is wrapped in `PROBE_TIMEOUT` (500 ms) so a half-configured session
126+
/// bus or a stalled `gsettings` subprocess can't wedge the caller — at worst we
127+
/// pay ~1 s total in the pathological case before settling on the brand fallback.
128+
async fn read_accent_color() -> String {
129+
if let Some(hex) = read_accent_color_portal().await {
112130
return hex;
113131
}
114132
debug!("XDG Portal accent color not available, trying gsettings");
115133

116-
if let Some(hex) = read_accent_color_gsettings() {
134+
if let Some(hex) = read_accent_color_gsettings().await {
117135
return hex;
118136
}
119137
debug!("gsettings accent color not available, using Cmdr brand gold");
@@ -124,17 +142,16 @@ fn read_accent_color() -> String {
124142
/// Tauri command: returns the current Linux accent color as a hex string.
125143
#[tauri::command]
126144
#[specta::specta]
127-
pub fn get_accent_color() -> String {
128-
read_accent_color()
145+
pub async fn get_accent_color() -> String {
146+
read_accent_color().await
129147
}
130148

131149
/// Starts observing XDG Portal `SettingChanged` signal for live accent color updates.
132150
/// Emits `accent-color-changed` events to the frontend, matching macOS behavior.
133151
pub fn observe_accent_color_changes<R: Runtime>(app_handle: AppHandle<R>) {
134-
let initial = read_accent_color();
135-
debug!("Linux accent color: {initial}");
136-
137152
tauri::async_runtime::spawn(async move {
153+
let initial = read_accent_color().await;
154+
debug!("Linux accent color: {initial}");
138155
if let Err(e) = watch_portal_signal(app_handle).await {
139156
debug!("Portal accent color watcher not available: {e}");
140157
}
@@ -201,11 +218,40 @@ mod tests {
201218
assert_eq!(gnome_accent_name_to_hex(""), None);
202219
}
203220

204-
#[test]
205-
fn read_accent_color_returns_valid_hex() {
206-
let color = read_accent_color();
207-
assert!(color.starts_with('#'));
208-
assert_eq!(color.len(), 7);
221+
/// Verifies the two contracts that matter:
222+
/// 1. `read_accent_color` always returns within the combined `PROBE_TIMEOUT`
223+
/// budget (`portal` + `gsettings` ≤ 2 × 500 ms = 1 s), so a flaky
224+
/// session-bus or hung subprocess can never block app startup.
225+
/// 2. The result is a valid `#rrggbb` hex string, regardless of which
226+
/// tier produced it (portal / gsettings / brand fallback).
227+
///
228+
/// Replaces the older `read_accent_color_returns_valid_hex`, which had the
229+
/// same shape assertions but **no timeout assertion** and called the
230+
/// unbounded blocking zbus connect. That hung CI for 120 s when the
231+
/// GitHub Actions Ubuntu runner shipped an orphan session-bus socket
232+
/// (path set, no daemon serving). The new timeout in production code makes
233+
/// the function honest, and this test pins it down so the regression can't
234+
/// come back.
235+
///
236+
/// Asserting a `<2 s` wall-clock with a `500 ms` budget gives plenty of
237+
/// slack for slow CI runners / heavy parallelism while still catching the
238+
/// "blocking forever" regression — anything close to 2 s means a probe
239+
/// stopped honoring its timeout.
240+
#[tokio::test]
241+
async fn read_accent_color_is_bounded_and_returns_valid_hex() {
242+
let start = std::time::Instant::now();
243+
let color = read_accent_color().await;
244+
let elapsed = start.elapsed();
245+
246+
assert!(
247+
elapsed < std::time::Duration::from_secs(2),
248+
"read_accent_color took {elapsed:?}, expected < 2 s (PROBE_TIMEOUT={PROBE_TIMEOUT:?})",
249+
);
250+
assert!(color.starts_with('#'), "expected #rrggbb, got {color}");
251+
assert_eq!(color.len(), 7, "expected #rrggbb (7 chars), got {color}");
252+
for c in color.chars().skip(1) {
253+
assert!(c.is_ascii_hexdigit(), "invalid hex digit in {color}");
254+
}
209255
}
210256

211257
#[test]

0 commit comments

Comments
 (0)