From c1eccde86e6b137eb079b0b8eaebbc5bc5313316 Mon Sep 17 00:00:00 2001 From: fbnlrz <137117973+fbnlrz@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:52:14 +0000 Subject: [PATCH 1/2] chore: begin 1.0.11 development --- Cargo.lock | 2 +- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 355cc82..570e54f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1937,7 +1937,7 @@ dependencies = [ [[package]] name = "inari" -version = "1.0.10" +version = "1.0.11" dependencies = [ "axum", "dirs", diff --git a/package.json b/package.json index 825b90c..c8360eb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "inari", "private": true, - "version": "1.0.10", + "version": "1.0.11", "type": "module", "license": "GPL-3.0-only", "scripts": { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1141c8c..af4a976 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "inari" -version = "1.0.10" +version = "1.0.11" description = "Linux-native audio routing and mixing for PipeWire, with SteelSeries device control" edition = "2021" rust-version = "1.77" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 71835e2..c246c21 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Inari", - "version": "1.0.10", + "version": "1.0.11", "identifier": "com.fbnlrz.inari", "build": { "beforeDevCommand": "npm run dev", From 09769669692159dae7ff5f00bd2b2630baec1993 Mon Sep 17 00:00:00 2001 From: fbnlrz <137117973+fbnlrz@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:09:38 +0200 Subject: [PATCH 2/2] fix(update): let systemd restart us, instead of a helper it will kill After an in-app update Inari quit and stayed down whenever it was running from the autostart unit. Two things had to go wrong together: - restart_app spawns "setsid sh -c 'sleep 2; exec /usr/bin/inari'" and exits. setsid gets a new session but stays in the service's cgroup, and KillMode defaults to control-group, so systemd sweeps the helper up the moment the main process exits - during its sleep, before it ever reaches the exec. - The unit has Restart=on-failure, and quitting in order to restart is a clean exit, so systemd did not step in either. Restart=always would be wrong: it would make quitting from the tray impossible. Reproduced both: a service running the same setsid pattern leaves no trace of its helper, and the journal for the 1.0.9 -> 1.0.10 upgrade shows the unit exiting with status=0 one second after dpkg and never coming back. When we are the unit, ask systemd to restart it and let the job outlive us. --no-block matters: the first half of that job is stopping this very process, so a blocking call would wait on our own death. Detection deliberately does not use INVOCATION_ID. systemd sets it for every unit, and KDE wraps a manual launch in a transient app-@.service - restarting inari.service from inside one of those would start a unit the user never asked for and orphan the launched instance. The cgroup path names the unit we are actually in. Verified against both real cgroups from this machine. Off systemd - AppImage, a direct launch - the existing relauncher is still the right answer and is untouched. --- src-tauri/src/commands/update.rs | 15 +++++ src-tauri/src/persistence/autostart.rs | 79 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 804e34e..7d052de 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -324,8 +324,23 @@ fn relaunch_script(exe: &str) -> String { /// and the app would just quit. Instead: resolve the real path, hand a detached /// helper the job of relaunching it after we've fully exited (so our virtual /// sinks are torn down before the fresh instance recreates them), then exit. +/// +/// Under the autostart unit that helper does not survive us either: +/// `setsid` gets a new session but stays in the service's cgroup, and systemd's +/// default `KillMode=control-group` sweeps it up the moment the main process +/// exits - mid-sleep, before it ever reaches the `exec`. `Restart=on-failure` +/// does not cover for that either, because quitting to restart is a clean exit. +/// +/// So when we *are* that unit, hand the job to systemd instead of trying to +/// outlive ourselves. #[tauri::command] pub fn restart_app(app: tauri::AppHandle) { + if crate::persistence::autostart::running_as_unit() + && crate::persistence::autostart::request_restart() + { + app.exit(0); + return; + } if let Ok(exe) = std::env::current_exe() { let script = relaunch_script(&exe.to_string_lossy()); // setsid detaches the relauncher into its own session so it survives our diff --git a/src-tauri/src/persistence/autostart.rs b/src-tauri/src/persistence/autostart.rs index 5a1d6d2..531e750 100644 --- a/src-tauri/src/persistence/autostart.rs +++ b/src-tauri/src/persistence/autostart.rs @@ -68,6 +68,47 @@ pub fn is_enabled() -> bool { .unwrap_or(false) } +/// Does this cgroup path put us inside `unit`? +/// +/// Split out from [`running_as_unit`] so the interesting part - telling our own +/// unit apart from the transient `app-*.service` a desktop launcher wraps +/// manual starts in - is testable without a systemd session. +fn cgroup_names_unit(cgroup: &str, unit: &str) -> bool { + cgroup.lines().any(|line| { + // Format is `hierarchy:controllers:path`; only the path matters, and on + // cgroup v2 the first two fields are empty. + line.rsplit(':') + .next() + .map(|path| path.trim_end_matches('/').ends_with(&format!("/{unit}"))) + .unwrap_or(false) + }) +} + +/// Are we running *as* the autostart unit? +/// +/// `INVOCATION_ID` is the obvious test and the wrong one: systemd sets it for +/// every unit, and KDE wraps a manual launch in a transient +/// `app-@.service` of its own. Restarting `inari.service` from +/// inside one of those would start a unit the user never asked to run - and +/// leave the launched instance behind. The cgroup path names the unit we are +/// actually in, so ask that instead. +pub fn running_as_unit() -> bool { + fs::read_to_string("/proc/self/cgroup") + .map(|c| cgroup_names_unit(&c, UNIT_NAME)) + .unwrap_or(false) +} + +/// Ask systemd to restart our own unit; `true` if the job was queued. +/// +/// `--no-block` is not optional here. The restart job's first half is stopping +/// this very process, so waiting for it to finish would wait on our own death. +/// Queue it and let systemd - which outlives us by definition - carry it out. +pub fn request_restart() -> bool { + systemctl(&["--no-block", "restart", UNIT_NAME]) + .map(|out| out.status.success()) + .unwrap_or(false) +} + pub fn enable() -> Result<(), SinkError> { let path = unit_path()?; if let Some(parent) = path.parent() { @@ -121,4 +162,42 @@ mod tests { assert!(unit.contains("WantedBy=graphical-session.target")); assert!(unit.contains("After=graphical-session.target pipewire.service")); } + + /// The cgroup of an instance actually started by the autostart unit. + const UNDER_UNIT: &str = + "0::/user.slice/user-1000.slice/user@1000.service/app.slice/inari.service"; + + /// What KDE's launcher produces for a manual start - captured from a real + /// session. It is a systemd unit too, which is exactly why `INVOCATION_ID` + /// cannot be used to tell the two apart. + const UNDER_KDE_LAUNCHER: &str = "0::/user.slice/user-1000.slice/user@1000.service/\ + app.slice/app-net.local.smart\\x2dlauncher@7fd00cd58eaa49578ad6a3cc871e6d24.service"; + + #[test] + fn cgroup_detection_finds_our_own_unit() { + assert!(cgroup_names_unit(UNDER_UNIT, UNIT_NAME)); + assert!(cgroup_names_unit(&format!("{UNDER_UNIT}/"), UNIT_NAME)); + } + + #[test] + fn cgroup_detection_rejects_a_launcher_scope() { + assert!(!cgroup_names_unit(UNDER_KDE_LAUNCHER, UNIT_NAME)); + } + + #[test] + fn cgroup_detection_rejects_a_merely_similar_name() { + // Suffix matching without the separator would accept these. + assert!(!cgroup_names_unit("0::/app.slice/not-inari.service", UNIT_NAME)); + assert!(!cgroup_names_unit("0::/app.slice/inari.service.d", UNIT_NAME)); + assert!(!cgroup_names_unit("", UNIT_NAME)); + } + + #[test] + fn cgroup_detection_reads_v1_style_lines() { + // A hybrid hierarchy lists one line per controller; the unit is named + // in the path field, not the first. + let v1 = "12:pids:/user.slice/user-1000.slice/user@1000.service/inari.service\n\ + 0::/user.slice/user-1000.slice/user@1000.service/inari.service"; + assert!(cgroup_names_unit(v1, UNIT_NAME)); + } }