diff --git a/README.md b/README.md index 7a071e7..1beb949 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,36 @@ Run `voisu doctor` if the Trigger Key does not respond — it reports a portal without a usable GlobalShortcuts interface, and adds `--verbose` for the full reasoning behind each check. +### Ubuntu GNOME troubleshooting + +- **Voisu is not running after login.** The daemon waits up to 20 seconds for + GNOME to export `WAYLAND_DISPLAY`/`DISPLAY`. If `voisu service status` still + shows inactive or failed, run `voisu service restart` once the desktop is up, + and check `journalctl --user -u voisu.service`. +- **`Paste action` and `Paste backend` show SKIP.** Those checks are + Hyprland-only; SKIP is expected on GNOME. +- **`Focus guard none` shows WARN.** Expected on GNOME, where Voisu cannot + verify which window has focus. In `type` mode (`voisu delivery type`) Delivery + pastes into whichever window has focus. In `guarded` mode + (`voisu delivery guarded`) it cannot verify focus, so it leaves the Transcript + on the clipboard instead of pasting. +- **The Overlay is the GNOME Shell extension `overlay@voisu.app`.** + `voisu-overlay.service` (the GTK observer) being inactive is expected there. +- **Direct Delivery puts the Transcript on the clipboard and asks GNOME to + paste it.** It goes through the RemoteDesktop portal and libei. Unless the + compositor's input seat offers text input, Voisu submits a Ctrl+V keystroke, so + a window that pastes with a different shortcut (a terminal using + Ctrl+Shift+V) receives nothing; paste manually, the Transcript is on the + clipboard. +- **Reset Voisu's RemoteDesktop grant.** This clears only the grant Voisu saved + itself. GNOME may ask again when the daemon restarts or at the next Delivery, + and a permission GNOME remembers on its own is not cleared by this. + + ```sh + rm ~/.local/state/voisu/remote-desktop.restore-token + systemctl --user restart voisu.service + ``` + ## Command reference `voisu` controls the daemon (`voisu-daemon`). All history and diagnostics stay diff --git a/crates/voisu-app/src/bin/voisu-daemon.rs b/crates/voisu-app/src/bin/voisu-daemon.rs index 856b4e4..57e2365 100644 --- a/crates/voisu-app/src/bin/voisu-daemon.rs +++ b/crates/voisu-app/src/bin/voisu-daemon.rs @@ -102,6 +102,17 @@ fn main() { } _ => {} } + // Under systemd the unit carries no display start condition (a skip is never + // retried), so wait for the session display here, before any + // display-dependent setup: exit 75 to be respawned with the manager's + // environment once it arrives, or 78 (not restarted) if it never does. + let systemd_owned = matches!( + std::env::args().skip(1).collect::>().as_slice(), + [argument] if argument == "--systemd" + ); + if let Some(code) = voisu_app::system::startup_display_gate(systemd_owned) { + std::process::exit(code); + } voisu_app::system::install_crypto_provider(); let mut builder = tokio::runtime::Builder::new_multi_thread(); builder.enable_all(); diff --git a/crates/voisu-app/src/service.rs b/crates/voisu-app/src/service.rs index 36bd971..7f87470 100644 --- a/crates/voisu-app/src/service.rs +++ b/crates/voisu-app/src/service.rs @@ -1145,17 +1145,21 @@ fn service_unit(executable: &Path) -> Result { concat!( "[Unit]\n", "Description=Voisu dictation daemon\n", - "After=wayland-session-waitenv.service dbus.socket pipewire.service\n", + // Ordered after the target because it helps, but GNOME imports the + // display variables on its own schedule, and a ConditionEnvironment + // skip is never retried. The daemon waits for the display itself + // under --systemd: exit 75 (arrived late) is respawned by + // Restart=on-failure, exit 78 (never arrived) is not restarted. + "After=graphical-session.target wayland-session-waitenv.service dbus.socket pipewire.service\n", "Wants=dbus.socket pipewire.service\n", "PartOf=graphical-session.target\n", - "ConditionEnvironment=|WAYLAND_DISPLAY\n", - "ConditionEnvironment=|DISPLAY\n", "StartLimitIntervalSec=30s\n", "StartLimitBurst=3\n\n", "[Service]\n", "Type=simple\n", "ExecStart={} --systemd\n", "Restart=on-failure\n", + "RestartPreventExitStatus=78\n", "RestartSec=2s\n", // Graceful shutdown stops an active Recording, processes it to // completion, joins the actor, and drains retained provider cleanup; diff --git a/crates/voisu-app/src/system/display_wait.rs b/crates/voisu-app/src/system/display_wait.rs new file mode 100644 index 0000000..bac33c4 --- /dev/null +++ b/crates/voisu-app/src/system/display_wait.rs @@ -0,0 +1,354 @@ +// Startup wait for the session display environment under `voisu-daemon --systemd`. +// +// The packaged unit has no ConditionEnvironment: systemd evaluates conditions +// once and never retries a skip, and GNOME imports WAYLAND_DISPLAY/DISPLAY into +// the user manager on its own schedule, so ordering after +// graphical-session.target cannot prove the variables are there. The daemon +// waits for them itself instead. + +use std::time::Duration; + +use super::readiness::manager_env_has; +use super::run_restricted_stdout; + +/// How long a systemd-launched daemon waits for a display variable to appear in +/// the user manager environment. +pub const DISPLAY_WAIT_TIMEOUT: Duration = Duration::from_secs(20); +/// Interval between user manager environment polls. +pub const DISPLAY_WAIT_POLL: Duration = Duration::from_millis(250); +/// EX_TEMPFAIL: the display environment arrived after start. `Restart=on-failure` +/// respawns the daemon, which then inherits the manager's current environment. +pub const DISPLAY_ARRIVED_EXIT: i32 = 75; +/// EX_CONFIG: the display environment never arrived. The unit's +/// `RestartPreventExitStatus=78` keeps this from becoming a restart loop. +pub const DISPLAY_TIMEOUT_EXIT: i32 = 78; + +const DISPLAY_VARIABLES: [&str; 2] = ["WAYLAND_DISPLAY", "DISPLAY"]; + +#[derive(Debug, PartialEq, Eq)] +pub enum DisplayWaitOutcome { + /// The daemon's own environment already has a display variable. + Present, + /// The user manager gained a display variable while waiting. + Arrived, + TimedOut, +} + +/// Poll `manager_environment` (the `systemctl --user show-environment` text, or +/// `None` when it cannot be read) until a display variable appears or `timeout` +/// elapses. `now` is the time elapsed since the wait began, so a slow query +/// counts against the budget like a sleep does. The last sleep is capped to the +/// time remaining and no query starts after the deadline, so an unresponsive +/// user manager costs at most one query beyond it. +pub fn wait_for_display_environment( + process_has_display: bool, + timeout: Duration, + poll: Duration, + mut manager_environment: impl FnMut() -> Option, + mut sleep: impl FnMut(Duration), + mut now: impl FnMut() -> Duration, +) -> DisplayWaitOutcome { + if process_has_display { + return DisplayWaitOutcome::Present; + } + loop { + if manager_environment().is_some_and(|environment| { + DISPLAY_VARIABLES + .iter() + .any(|key| manager_env_has(&environment, key)) + }) { + return DisplayWaitOutcome::Arrived; + } + let Some(remaining) = timeout.checked_sub(now()).filter(|left| !left.is_zero()) else { + return DisplayWaitOutcome::TimedOut; + }; + sleep(poll.min(remaining)); + if now() >= timeout { + return DisplayWaitOutcome::TimedOut; + } + } +} + +fn process_has_display() -> bool { + DISPLAY_VARIABLES + .iter() + .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty())) +} + +/// Wait bounds; the `VOISU_TEST_*` overrides (never set by the packaged unit) +/// let tests shrink the 20 s production wait to milliseconds. +fn display_wait_bounds() -> (Duration, Duration) { + let millis = |name: &str| { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + }; + ( + millis("VOISU_TEST_DISPLAY_WAIT_MS").unwrap_or(DISPLAY_WAIT_TIMEOUT), + millis("VOISU_TEST_DISPLAY_POLL_MS") + .filter(|poll| !poll.is_zero()) + .unwrap_or(DISPLAY_WAIT_POLL), + ) +} + +fn live_manager_environment() -> Option { + run_restricted_stdout("systemctl", &["--user", "show-environment"]) + .map(|stdout| String::from_utf8_lossy(&stdout).into_owned()) +} + +/// The startup gate for `voisu-daemon`. Returns the exit code the daemon must +/// terminate with, or `None` to continue starting. Only a systemd-owned start +/// waits; a manual daemon starts as before. Logs go to stderr (the journal). +pub fn startup_display_gate(systemd_owned: bool) -> Option { + let (timeout, poll) = display_wait_bounds(); + let started = std::time::Instant::now(); + display_gate( + systemd_owned, + process_has_display(), + (timeout, poll), + live_manager_environment, + std::thread::sleep, + || started.elapsed(), + |line| eprintln!("{line}"), + ) +} + +fn display_gate( + systemd_owned: bool, + process_has_display: bool, + (timeout, poll): (Duration, Duration), + manager_environment: impl FnMut() -> Option, + sleep: impl FnMut(Duration), + now: impl FnMut() -> Duration, + mut log: impl FnMut(&str), +) -> Option { + if !systemd_owned { + return None; + } + match wait_for_display_environment( + process_has_display, + timeout, + poll, + manager_environment, + sleep, + now, + ) { + DisplayWaitOutcome::Present => None, + DisplayWaitOutcome::Arrived => { + log( + "session display environment arrived after start; restarting to pick up WAYLAND_DISPLAY/DISPLAY from the user manager", + ); + Some(DISPLAY_ARRIVED_EXIT) + } + DisplayWaitOutcome::TimedOut => { + log(&format!( + "no display environment after {}s: WAYLAND_DISPLAY and DISPLAY are both missing from the systemd user manager; start a graphical session, then run: voisu service restart", + timeout.as_secs() + )); + Some(DISPLAY_TIMEOUT_EXIT) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + + const NO_DISPLAY: &str = "LANG=C\nDISPLAY=\n"; + const WITH_DISPLAY: &str = "LANG=C\nWAYLAND_DISPLAY=wayland-0\nDISPLAY=:0\n"; + + /// A fake clock shared by the injected `sleep`, `now` and manager query, so + /// time spent in a query is visible to the deadline like a real one. + #[derive(Default)] + struct Clock { + elapsed: Cell, + sleeps: RefCell>, + } + + impl Clock { + fn advance(&self, by: Duration) { + self.elapsed.set(self.elapsed.get() + by); + } + } + + #[test] + fn a_display_already_in_the_process_environment_never_waits() { + let outcome = wait_for_display_environment( + true, + Duration::from_secs(20), + Duration::from_millis(250), + || panic!("the manager must not be queried"), + |_| panic!("the daemon must not sleep"), + || panic!("the clock must not be read"), + ); + assert_eq!(outcome, DisplayWaitOutcome::Present); + } + + #[test] + fn a_display_arriving_in_the_manager_ends_the_wait() { + let clock = Clock::default(); + let polls = Cell::new(0); + let outcome = wait_for_display_environment( + false, + Duration::from_secs(20), + Duration::from_millis(250), + || { + polls.set(polls.get() + 1); + Some( + if polls.get() < 3 { + NO_DISPLAY + } else { + WITH_DISPLAY + } + .to_owned(), + ) + }, + |interval| clock.advance(interval), + || clock.elapsed.get(), + ); + assert_eq!(outcome, DisplayWaitOutcome::Arrived); + assert_eq!(polls.get(), 3); + assert_eq!(clock.elapsed.get(), Duration::from_millis(500)); + } + + #[test] + fn an_unreadable_manager_keeps_waiting_until_the_timeout() { + let clock = Clock::default(); + let polls = Cell::new(0); + let outcome = wait_for_display_environment( + false, + Duration::from_secs(1), + Duration::from_millis(250), + || { + polls.set(polls.get() + 1); + None + }, + |interval| clock.advance(interval), + || clock.elapsed.get(), + ); + assert_eq!(outcome, DisplayWaitOutcome::TimedOut); + // One poll at t=0 plus one after each of the first three intervals; the + // wait ends at the deadline without a further query. + assert_eq!(polls.get(), 4); + assert_eq!(clock.elapsed.get(), Duration::from_secs(1)); + } + + #[test] + fn slow_manager_queries_count_against_the_deadline() { + // Every query "takes" 2 s (the production PROCESS_DEADLINE order of + // magnitude). The wait must end at the 20 s budget, not after 20 s of + // sleeps plus the time spent in queries. + let clock = Clock::default(); + let polls = Cell::new(0); + let outcome = wait_for_display_environment( + false, + Duration::from_secs(20), + Duration::from_millis(250), + || { + polls.set(polls.get() + 1); + clock.advance(Duration::from_secs(2)); + None + }, + |interval| clock.advance(interval), + || clock.elapsed.get(), + ); + assert_eq!(outcome, DisplayWaitOutcome::TimedOut); + assert_eq!(clock.elapsed.get(), Duration::from_secs(20)); + // Each cycle is a 2 s query plus a 250 ms sleep; 8 full cycles reach 18 s + // and the ninth query lands exactly on the budget. + assert_eq!(polls.get(), 9); + } + + #[test] + fn the_final_sleep_is_capped_to_the_time_remaining() { + let clock = Clock::default(); + let outcome = wait_for_display_environment( + false, + Duration::from_secs(1), + Duration::from_millis(400), + || None, + |interval| { + clock.sleeps.borrow_mut().push(interval); + clock.advance(interval); + }, + || clock.elapsed.get(), + ); + assert_eq!(outcome, DisplayWaitOutcome::TimedOut); + assert_eq!( + *clock.sleeps.borrow(), + [ + Duration::from_millis(400), + Duration::from_millis(400), + Duration::from_millis(200) + ] + ); + assert_eq!(clock.elapsed.get(), Duration::from_secs(1)); + } + + fn gate( + systemd_owned: bool, + process_has_display: bool, + environment: Option<&'static str>, + log: &RefCell>, + ) -> Option { + let clock = Clock::default(); + display_gate( + systemd_owned, + process_has_display, + (Duration::from_secs(1), Duration::from_millis(250)), + || environment.map(str::to_owned), + |interval| clock.advance(interval), + || clock.elapsed.get(), + |line| log.borrow_mut().push(line.to_owned()), + ) + } + + #[test] + fn a_manual_daemon_starts_without_consulting_the_manager() { + let log = RefCell::new(Vec::new()); + let exit = display_gate( + false, + false, + (Duration::from_secs(1), Duration::from_millis(250)), + || panic!("a manual daemon must not query the manager"), + |_| panic!("a manual daemon must not sleep"), + || panic!("a manual daemon must not read the clock"), + |line| log.borrow_mut().push(line.to_owned()), + ); + assert_eq!(exit, None); + assert!(log.borrow().is_empty()); + } + + #[test] + fn a_systemd_daemon_with_a_display_starts_normally() { + let log = RefCell::new(Vec::new()); + assert_eq!(gate(true, true, None, &log), None); + assert!(log.borrow().is_empty()); + } + + #[test] + fn late_arrival_exits_75_and_says_it_is_restarting() { + let log = RefCell::new(Vec::new()); + assert_eq!( + gate(true, false, Some(WITH_DISPLAY), &log), + Some(DISPLAY_ARRIVED_EXIT) + ); + assert_eq!(DISPLAY_ARRIVED_EXIT, 75); + assert!(log.borrow()[0].contains("arrived after start; restarting")); + } + + #[test] + fn timeout_exits_78_naming_the_variables_and_the_recovery() { + let log = RefCell::new(Vec::new()); + assert_eq!( + gate(true, false, Some(NO_DISPLAY), &log), + Some(DISPLAY_TIMEOUT_EXIT) + ); + assert_eq!(DISPLAY_TIMEOUT_EXIT, 78); + let line = log.borrow()[0].clone(); + assert!(line.contains("WAYLAND_DISPLAY") && line.contains("DISPLAY")); + assert!(line.contains("voisu service restart")); + } +} diff --git a/crates/voisu-app/src/system/mod.rs b/crates/voisu-app/src/system/mod.rs index 121d408..485e679 100644 --- a/crates/voisu-app/src/system/mod.rs +++ b/crates/voisu-app/src/system/mod.rs @@ -181,6 +181,7 @@ mod capture; mod credential; mod deepgram; mod delivery; +mod display_wait; mod grammar; mod groq; mod libei_delivery; @@ -194,6 +195,7 @@ pub use capture::*; pub use credential::*; pub use deepgram::*; pub use delivery::*; +pub use display_wait::*; pub use grammar::*; pub use groq::*; pub use libei_delivery::*; diff --git a/crates/voisu-app/tests/daemon_display_wait.rs b/crates/voisu-app/tests/daemon_display_wait.rs new file mode 100644 index 0000000..1e98a45 --- /dev/null +++ b/crates/voisu-app/tests/daemon_display_wait.rs @@ -0,0 +1,93 @@ +//! `voisu-daemon --systemd` waits for the session display environment itself +//! instead of relying on a unit start condition (a condition skip is never +//! retried). Late arrival exits 75 so `Restart=on-failure` respawns it with the +//! manager's environment; no arrival exits 78 so it is not restart-looped. +//! +//! The manager environment comes from a fake `systemctl` on PATH and the wait +//! is shrunk through the VOISU_TEST_* seams, so no test waits in real time. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::{Command, Output}; + +use tempfile::TempDir; + +/// A fake `systemctl --user show-environment`. It counts calls; the display +/// variables appear from the `arrive_after`-th call on (0 = never). +fn fake_systemctl(dir: &Path, arrive_after: u32) { + fs::write(dir.join("arrive_after"), arrive_after.to_string()).unwrap(); + let script = dir.join("systemctl"); + fs::write( + &script, + r#"#!/bin/sh +dir=$(dirname "$0") +printf '%s\n' "$*" >> "$dir/systemctl.log" +if [ "$1" != "--user" ] || [ "$2" != "show-environment" ]; then exit 0; fi +n=$(cat "$dir/count" 2>/dev/null || echo 0) +n=$((n + 1)) +printf '%s' "$n" > "$dir/count" +printf 'LANG=C\n' +arrive=$(cat "$dir/arrive_after") +if [ "$arrive" -gt 0 ] && [ "$n" -ge "$arrive" ]; then + printf 'WAYLAND_DISPLAY=wayland-0\nDISPLAY=:0\n' +fi +"#, + ) + .unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o700)).unwrap(); +} + +fn run_systemd_daemon(bin: &Path, wait_ms: &str, poll_ms: &str) -> Output { + let runtime = TempDir::new().unwrap(); + fs::set_permissions(runtime.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + Command::new(env!("CARGO_BIN_EXE_voisu-daemon")) + .arg("--systemd") + .env("PATH", path) + .env("XDG_RUNTIME_DIR", runtime.path()) + .env("XDG_STATE_HOME", runtime.path().join("state")) + .env_remove("WAYLAND_DISPLAY") + .env_remove("DISPLAY") + .env("VOISU_TEST_DISPLAY_WAIT_MS", wait_ms) + .env("VOISU_TEST_DISPLAY_POLL_MS", poll_ms) + .output() + .expect("daemon should run") +} + +#[test] +fn display_arriving_late_exits_75_so_systemd_respawns_the_daemon() { + let bin = TempDir::new().unwrap(); + fake_systemctl(bin.path(), 3); + + // A generous wait proves arrival, not the timeout, ends the run; the exit + // is driven by the third `show-environment` call, not by elapsed time. + let output = run_systemd_daemon(bin.path(), "60000", "10"); + + assert_eq!(output.status.code(), Some(75), "{output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("display environment arrived after start; restarting"), + "{stderr}" + ); +} + +#[test] +fn display_never_arriving_exits_78_naming_the_missing_variables() { + let bin = TempDir::new().unwrap(); + fake_systemctl(bin.path(), 0); + + let output = run_systemd_daemon(bin.path(), "100", "10"); + + assert_eq!(output.status.code(), Some(78), "{output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("WAYLAND_DISPLAY") && stderr.contains("DISPLAY"), + "{stderr}" + ); + assert!(stderr.contains("voisu service restart"), "{stderr}"); +} diff --git a/crates/voisu-app/tests/service_cli.rs b/crates/voisu-app/tests/service_cli.rs index c792f0b..8b194ef 100644 --- a/crates/voisu-app/tests/service_cli.rs +++ b/crates/voisu-app/tests/service_cli.rs @@ -447,6 +447,9 @@ fn assert_graphical_session_unit_shape( fn assert_packaged_daemon_runtime_contract(unit: &str) { for (assignment, expected) in [ ("Restart=", "on-failure"), + // Exit 78 = the daemon gave up waiting for the session display; it must + // land in `failed` instead of being respawned in a loop. + ("RestartPreventExitStatus=", "78"), ("RestartSec=", "2s"), ("TimeoutStopSec=", "60s"), ("NoNewPrivileges=", "yes"), @@ -513,14 +516,14 @@ fn packaged_units_have_graphical_session_readiness_without_portal_ownership() { assert_graphical_session_unit_shape( "voisu.service", PACKAGED_DAEMON_UNIT, - "wayland-session-waitenv.service dbus.socket pipewire.service", + "graphical-session.target wayland-session-waitenv.service dbus.socket pipewire.service", Some("dbus.socket pipewire.service"), - &["|WAYLAND_DISPLAY", "|DISPLAY"], + &[], ); assert_graphical_session_unit_shape( "voisu-overlay.service", PACKAGED_OVERLAY_UNIT, - "wayland-session-waitenv.service voisu.service", + "graphical-session.target wayland-session-waitenv.service voisu.service", None, &["WAYLAND_DISPLAY"], ); @@ -630,9 +633,9 @@ fn install_is_idempotent_atomic_and_free_of_stale_session_or_checkout_values() { assert_graphical_session_unit_shape( "generated voisu.service", &unit, - "wayland-session-waitenv.service dbus.socket pipewire.service", + "graphical-session.target wayland-session-waitenv.service dbus.socket pipewire.service", Some("dbus.socket pipewire.service"), - &["|WAYLAND_DISPLAY", "|DISPLAY"], + &[], ); assert!(unit.contains(&format!( "ExecStart=\"{}\" --systemd", @@ -1127,6 +1130,7 @@ fn installed_service_bounds_repeated_startup_failures() { let unit = fs::read_to_string(fixture.unit_path()).unwrap(); assert!(unit.contains("Restart=on-failure\n"), "{unit}"); + assert!(unit.contains("RestartPreventExitStatus=78\n"), "{unit}"); assert!(unit.contains("StartLimitIntervalSec=30s\n"), "{unit}"); assert!(unit.contains("StartLimitBurst=3\n"), "{unit}"); // Graceful shutdown's internal budget (stop, process, join, drain) peaks @@ -1421,6 +1425,10 @@ fn a_systemd_launched_duplicate_exits_cleanly_while_the_manual_daemon_remains_re .arg("--systemd") .env("XDG_RUNTIME_DIR", &fixture.runtime) .env("XDG_STATE_HOME", fixture.runtime.join("state")) + // A --systemd daemon waits for a session display before anything else; + // supply one so this test exercises duplicate handling on hosts (CI) + // with no graphical session. + .env("WAYLAND_DISPLAY", "wayland-voisu-test") .output() .unwrap(); assert!(duplicate.status.success(), "{}", stderr(&duplicate)); diff --git a/docs/hyprland_problems.md b/docs/hyprland_problems.md index 621b32a..71d2b65 100644 --- a/docs/hyprland_problems.md +++ b/docs/hyprland_problems.md @@ -35,7 +35,7 @@ Systemd breaks the cycle by deleting a start job. On the observed cold login, it #### Intended fix - The desktop session owns the portal. Voisu must not add `After=` or `Wants=` dependencies on `xdg-desktop-portal.service`. -- On Omarchy/UWSM, start Voisu after `wayland-session-waitenv.service` and use `ConditionEnvironment=WAYLAND_DISPLAY`. The service remains enabled by `graphical-session.target` but must not also order itself after that target, which would create a cycle. +- Order Voisu `After=graphical-session.target` and, on Omarchy/UWSM, `wayland-session-waitenv.service`. `WantedBy=graphical-session.target` with that `After=` does not cycle; the cycle recorded above came from `After=xdg-desktop-portal.service`, since the portal itself starts after that target. Ordering alone does not prove the session display variables are already in the user manager (GNOME imports them on its own schedule), and a `ConditionEnvironment=` skip is never retried, so the daemon unit has no start condition. Under `--systemd` the daemon itself waits up to 20 s for `WAYLAND_DISPLAY` or `DISPLAY` to appear in the user manager: if they arrive late it exits 75 and `Restart=on-failure` respawns it with the manager's environment; if they never arrive it exits 78, which `RestartPreventExitStatus=78` leaves as a failed unit. - Keep `PartOf=graphical-session.target` so Voisu stops with the compositor. - Keep login enablement through `WantedBy=graphical-session.target`. - Replace the complete packaged dependency set. Systemd cannot remove `After=` or `Wants=` dependencies from a drop-in by assigning an empty value. @@ -45,10 +45,13 @@ The target shape is: ```ini [Unit] -After=wayland-session-waitenv.service dbus.socket pipewire.service +After=graphical-session.target wayland-session-waitenv.service dbus.socket pipewire.service Wants=dbus.socket pipewire.service PartOf=graphical-session.target -ConditionEnvironment=WAYLAND_DISPLAY + +[Service] +Restart=on-failure +RestartPreventExitStatus=78 [Install] WantedBy=graphical-session.target @@ -69,7 +72,7 @@ It could capture audio and produce a Transcript, but clipboard Delivery failed w #### Intended fix - Order startup after the session environment readiness boundary. Omarchy/UWSM provides `wayland-session-waitenv.service`, which imports Wayland variables before `graphical-session.target` completes. -- Require `WAYLAND_DISPLAY` for graphical startup rather than starting a permanently degraded daemon. +- Require a display (`WAYLAND_DISPLAY` or `DISPLAY`) for graphical startup rather than starting a permanently degraded daemon; the daemon waits for it under `--systemd`. - Make the daemon rediscover the active Wayland socket and session metadata after compositor changes. It must not trust its initial process environment forever. - Recover after a compositor or portal restart without requiring `voisu service restart`. @@ -132,7 +135,7 @@ The Overlay previously checked for a display once, selected journal-only feedbac #### Intended fix -- Apply the same graphical-session ordering and `ConditionEnvironment=WAYLAND_DISPLAY` used by the daemon. +- Apply the same `After=graphical-session.target` ordering as the daemon and keep `ConditionEnvironment=WAYLAND_DISPLAY` on the Overlay unit (it only observes, so a skip is acceptable; the daemon unit has no condition). - Retry display discovery after compositor changes instead of degrading permanently after one check. - Keep supervision so a child crash does not affect Recording or Transcript Delivery. - Investigate the GTK crash only if it repeats with a comparable or symbolized stack. diff --git a/packaging/ci/smoke-fedora.sh b/packaging/ci/smoke-fedora.sh index a3d003b..598a27e 100755 --- a/packaging/ci/smoke-fedora.sh +++ b/packaging/ci/smoke-fedora.sh @@ -77,17 +77,21 @@ if grep -Eq '^ReadWritePaths=.*%h' "$unit"; then echo "FAIL: voisu.service ReadWritePaths still references a %h home path"; exit 1 fi grep -qx 'Environment=VOISU_ENABLE_DPR=1' "$unit" -grep -qx 'After=wayland-session-waitenv.service dbus.socket pipewire.service' "$unit" +grep -qx 'After=graphical-session.target wayland-session-waitenv.service dbus.socket pipewire.service' "$unit" grep -qx 'Wants=dbus.socket pipewire.service' "$unit" grep -qx 'PartOf=graphical-session.target' "$unit" -grep -qx 'ConditionEnvironment=|WAYLAND_DISPLAY' "$unit" -grep -qx 'ConditionEnvironment=|DISPLAY' "$unit" +# No start condition on the daemon unit: a condition skip is never retried. The +# daemon waits for the display environment itself; exit 78 means it gave up. +if grep -q '^ConditionEnvironment=' "$unit"; then + echo "FAIL: packaged voisu.service must not gate startup on ConditionEnvironment"; exit 1 +fi +grep -qx 'RestartPreventExitStatus=78' "$unit" grep -qx 'WantedBy=graphical-session.target' "$unit" if grep -Eq '^(After|Wants)=.*xdg-desktop-portal\.service' "$unit"; then echo "FAIL: packaged voisu.service must not own xdg-desktop-portal.service"; exit 1 fi overlay_unit=/usr/lib/systemd/user/voisu-overlay.service -grep -qx 'After=wayland-session-waitenv.service voisu.service' "$overlay_unit" +grep -qx 'After=graphical-session.target wayland-session-waitenv.service voisu.service' "$overlay_unit" grep -qx 'PartOf=graphical-session.target' "$overlay_unit" grep -qx 'ConditionEnvironment=WAYLAND_DISPLAY' "$overlay_unit" grep -qx 'WantedBy=graphical-session.target' "$overlay_unit" diff --git a/packaging/ci/smoke-ubuntu.sh b/packaging/ci/smoke-ubuntu.sh index ad6720a..288c95e 100755 --- a/packaging/ci/smoke-ubuntu.sh +++ b/packaging/ci/smoke-ubuntu.sh @@ -100,6 +100,16 @@ echo "== systemd-analyze verify (both user units) ==" systemd-analyze verify /usr/lib/systemd/user/voisu.service systemd-analyze verify /usr/lib/systemd/user/voisu-overlay.service +# Both units order after graphical-session.target. The daemon unit has no +# ConditionEnvironment (a skip is never retried; the daemon waits for the display +# itself and exits 78, which RestartPreventExitStatus keeps from looping). +grep -qx 'After=graphical-session.target wayland-session-waitenv.service dbus.socket pipewire.service' /usr/lib/systemd/user/voisu.service +grep -qx 'After=graphical-session.target wayland-session-waitenv.service voisu.service' /usr/lib/systemd/user/voisu-overlay.service +grep -qx 'RestartPreventExitStatus=78' /usr/lib/systemd/user/voisu.service +if grep -q '^ConditionEnvironment=' /usr/lib/systemd/user/voisu.service; then + echo "FAIL: packaged voisu.service must not gate startup on ConditionEnvironment"; exit 1 +fi + # These directives can require capability or namespace setup unavailable to a # per-user manager when Ubuntu restricts unprivileged user namespaces. for unit in voisu.service voisu-overlay.service; do diff --git a/packaging/fedora-smoke.sh b/packaging/fedora-smoke.sh index 1a4290c..98e6350 100755 --- a/packaging/fedora-smoke.sh +++ b/packaging/fedora-smoke.sh @@ -297,17 +297,19 @@ test -x /usr/bin/voisu-daemon test -r /usr/lib/systemd/user/voisu.service grep -qx 'ExecStart=/usr/bin/voisu-daemon --systemd' /usr/lib/systemd/user/voisu.service grep -qx 'Environment=VOISU_ENABLE_DPR=1' /usr/lib/systemd/user/voisu.service -grep -qx 'After=wayland-session-waitenv.service dbus.socket pipewire.service' /usr/lib/systemd/user/voisu.service +grep -qx 'After=graphical-session.target wayland-session-waitenv.service dbus.socket pipewire.service' /usr/lib/systemd/user/voisu.service grep -qx 'Wants=dbus.socket pipewire.service' /usr/lib/systemd/user/voisu.service grep -qx 'PartOf=graphical-session.target' /usr/lib/systemd/user/voisu.service -grep -qx 'ConditionEnvironment=|WAYLAND_DISPLAY' /usr/lib/systemd/user/voisu.service -grep -qx 'ConditionEnvironment=|DISPLAY' /usr/lib/systemd/user/voisu.service +if grep -q '^ConditionEnvironment=' /usr/lib/systemd/user/voisu.service; then + printf 'FAIL: packaged voisu.service must not gate startup on ConditionEnvironment\n' >&2; exit 1 +fi +grep -qx 'RestartPreventExitStatus=78' /usr/lib/systemd/user/voisu.service grep -qx 'WantedBy=graphical-session.target' /usr/lib/systemd/user/voisu.service if grep -Eq '^(After|Wants)=.*xdg-desktop-portal\.service' /usr/lib/systemd/user/voisu.service; then printf 'FAIL: packaged voisu.service must not own xdg-desktop-portal.service\n' >&2; exit 1 fi if test "$overlay_installed_before" -eq 1; then - grep -qx 'After=wayland-session-waitenv.service voisu.service' /usr/lib/systemd/user/voisu-overlay.service + grep -qx 'After=graphical-session.target wayland-session-waitenv.service voisu.service' /usr/lib/systemd/user/voisu-overlay.service grep -qx 'PartOf=graphical-session.target' /usr/lib/systemd/user/voisu-overlay.service grep -qx 'ConditionEnvironment=WAYLAND_DISPLAY' /usr/lib/systemd/user/voisu-overlay.service grep -qx 'WantedBy=graphical-session.target' /usr/lib/systemd/user/voisu-overlay.service diff --git a/packaging/voisu-overlay.service b/packaging/voisu-overlay.service index b417fcb..e9b8f0c 100644 --- a/packaging/voisu-overlay.service +++ b/packaging/voisu-overlay.service @@ -1,7 +1,9 @@ [Unit] Description=Voisu overlay observer PartOf=graphical-session.target -After=wayland-session-waitenv.service voisu.service +# See voisu.service: GNOME exports the display environment while +# graphical-session.target is being reached, and a condition skip is not retried. +After=graphical-session.target wayland-session-waitenv.service voisu.service ConditionEnvironment=WAYLAND_DISPLAY [Service] diff --git a/packaging/voisu.service b/packaging/voisu.service index b1de064..310ef4a 100644 --- a/packaging/voisu.service +++ b/packaging/voisu.service @@ -1,10 +1,16 @@ [Unit] Description=Voisu dictation daemon -After=wayland-session-waitenv.service dbus.socket pipewire.service +# Ordered after graphical-session.target because it helps and is harmless, but +# it does not guarantee the session display variables are in the user manager +# yet (GNOME imports them on its own schedule). There is deliberately no +# ConditionEnvironment: a condition skip is evaluated once and never retried. +# Instead the daemon waits for WAYLAND_DISPLAY/DISPLAY itself under --systemd: +# if they arrive late it exits 75 so Restart=on-failure respawns it with the +# manager's environment, and if they never arrive it exits 78, which +# RestartPreventExitStatus turns into a plain failed unit rather than a loop. +After=graphical-session.target wayland-session-waitenv.service dbus.socket pipewire.service Wants=dbus.socket pipewire.service PartOf=graphical-session.target -ConditionEnvironment=|WAYLAND_DISPLAY -ConditionEnvironment=|DISPLAY StartLimitIntervalSec=30s StartLimitBurst=3 @@ -14,6 +20,7 @@ ExecStart=/usr/bin/voisu-daemon --systemd # Local spoken-mark / list organize. Cloud extra formatting stays off. Environment=VOISU_ENABLE_DPR=1 Restart=on-failure +RestartPreventExitStatus=78 RestartSec=2s TimeoutStopSec=60s