git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commit06343dfaed5a0821efcd1eb9e0e9907bdbe1d77f
parentf59e278cbc
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 17:25
fix: launch apps in session-bound transient scopes

Apps launched from the menu were children in cce-cloud.service's cgroup,
where KillMode=process -- there so a daemon restart would not kill them --
also let them outlive logout. A Proton launch queued behind a
still-running game then started in the next session before Xwayland was
up, ran with no display, and held every later launch behind itself.

spawn_detached now starts each launch through `systemd-run --user
--scope` as app-cce\x2dcloud-<name>-<n>.scope in app.slice, with
PartOf=cce-session.target: outside the daemon's cgroup, so a restart
still cannot reach it, and stopped with the session. Without systemd-run
on PATH the app is started directly, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md         | 10 +++++-
 cce-cloud.service |  7 ++--
 src/main.rs       | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++----
 3 files changed, 104 insertions(+), 11 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 8abdebb..1b2a253 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -92,7 +92,15 @@ needs-stdin decision, in `run_client()`).
   frecency persisted in `~/.cache/cce-cloud-apps.json`; selecting spawns the app's
   `Exec` (spawn output logged to `$XDG_RUNTIME_DIR/cce/spawn.log`, via
   `cce_ui::config::cce_runtime_dir()` — it was `/tmp/cce-spawn.log` before
-  2026-08-22). Each entry's `Icon=` is
+  2026-08-22). Every launch (`spawn_detached`) runs in **its own transient
+  scope**, `app-cce\x2dcloud-<name>-<n>.scope` in app.slice, `PartOf=`
+  `cce-session.target` (`scope_argv`, through `systemd-run --scope`): a daemon
+  restart never reaches it, and the session's end stops it. Until 2026-09-26
+  launches stayed in cce-cloud.service's own cgroup, where `KillMode=process`
+  (there so a restart would not kill them) also let them outlive logout — a
+  queued Proton launch then started in the next session before Xwayland
+  existed and ran invisible. Without `systemd-run` on PATH the app is started
+  directly, as before. Each entry's `Icon=` is
   resolved through `cce_ui::icon` and drawn in a gutter left of the label — the
   gutter is applied to every row, so one unresolvable icon doesn't rag the text
   edge. Apps and the Super-Tab switcher are the only lists with icons: other
diff --git a/cce-cloud.service b/cce-cloud.service
index abc8c39..e4e2bca 100644
--- a/cce-cloud.service
+++ b/cce-cloud.service
@@ -16,9 +16,10 @@ Type=simple
 ExecStart=%h/.local/bin/cce-cloud --daemon
 Restart=on-failure
 RestartSec=3
-# The daemon spawns the user's app-menu launches as children; with the
-# default control-group kill mode a service restart silently kills every
-# app ever launched from the menu. Only signal the daemon itself.
+# Launches run in their own transient scopes (PartOf=cce-session.target, see
+# spawn_detached), so they are outside this cgroup and a restart cannot reach
+# them. KillMode=process stays for anything still here from before that --
+# with the default control-group mode a restart would kill those.
 KillMode=process
 Environment=RUST_LOG=info
 # App launches inherit this PATH; without %h/.local/bin, desktop entries
diff --git a/src/main.rs b/src/main.rs
index 2202e67..d84cd06 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -636,15 +636,66 @@ fn command_in_path(cmd: &str) -> bool {
     })
 }
 
+/// The session target a launched app's scope is `PartOf`: startcce starts it
+/// once the compositor is up and stops it when the compositor exits (see
+/// cce-cloud.service).
+const SESSION_TARGET: &str = "cce-session.target";
+
+/// A systemd unit-name fragment for `program`: its file name, reduced to the
+/// characters a unit name may hold.
+fn scope_name_part(program: &str) -> String {
+    let base = program.rsplit('/').next().unwrap_or(program);
+    let part: String = base
+        .chars()
+        .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
+        .take(40)
+        .collect();
+    if part.is_empty() { "app".to_string() } else { part }
+}
+
+/// `systemd-run` arguments that start `program args` in its own transient
+/// scope, `app-cce\x2dcloud-<name>-<n>.scope` in app.slice, `PartOf` the
+/// session target. `n` only has to make the name unique.
+fn scope_argv(program: &str, args: &[&str], n: u128) -> Vec<String> {
+    let mut argv = vec![
+        "--user".to_string(),
+        "--scope".to_string(),
+        "--collect".to_string(),
+        "--slice=app.slice".to_string(),
+        format!("--unit=app-cce\\x2dcloud-{}-{n}", scope_name_part(program)),
+        format!("--property=PartOf={SESSION_TARGET}"),
+        "--".to_string(),
+        program.to_string(),
+    ];
+    argv.extend(args.iter().map(|a| a.to_string()));
+    argv
+}
+
 fn spawn_detached(program: &str, args: &[&str]) {
-    // Launched apps must outlive this daemon: process_group(0) moves them out
-    // of our process group so a terminal ^C (manual daemon run) doesn't kill
-    // them, and cce-cloud.service sets KillMode=process so a service restart
-    // doesn't cgroup-kill them either (systemd kills by cgroup, which no
-    // amount of setsid/double-fork escapes).
+    // Launched apps must outlive this daemon but not the session. Each gets
+    // its own transient scope (`scope_argv`): a service restart signals only
+    // the daemon (cce-cloud.service's KillMode=process) and never reaches
+    // another unit's cgroup, while the scope's PartOf= stops the app with
+    // the session. Until 2026-09-26 apps stayed in this service's cgroup,
+    // where KillMode=process left them running after logout: a Proton
+    // launch queued behind a still-running game started in the NEXT
+    // session before its Xwayland existed, ran with no display, and held
+    // every later launch of it behind itself. process_group(0) still keeps
+    // a terminal ^C (manual daemon run) away from them.
     use std::os::unix::process::CommandExt;
-    let mut cmd = std::process::Command::new(program);
-    cmd.args(args).process_group(0);
+    let mut cmd = if command_in_path("systemd-run") {
+        let n = std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .map_or(0, |d| d.as_nanos());
+        let mut c = std::process::Command::new("systemd-run");
+        c.args(scope_argv(program, args, n));
+        c
+    } else {
+        let mut c = std::process::Command::new(program);
+        c.args(args);
+        c
+    };
+    cmd.process_group(0);
     // Per-user runtime dir, not /tmp: this records every app the launcher
     // starts and captures their stdout/stderr, so a fixed /tmp path is both a
     // collision between users and a readable trace of one user's activity.
@@ -4925,3 +4976,36 @@ impl FuzzelWidget {
         labels
     }
 }
+
+#[cfg(test)]
+mod scope_tests {
+    use super::*;
+
+    #[test]
+    fn launches_run_in_a_session_bound_scope() {
+        let argv = scope_argv("/usr/bin/sh", &["-c", "exec cce-files"], 7);
+        assert_eq!(
+            argv,
+            [
+                "--user",
+                "--scope",
+                "--collect",
+                "--slice=app.slice",
+                "--unit=app-cce\\x2dcloud-sh-7",
+                "--property=PartOf=cce-session.target",
+                "--",
+                "/usr/bin/sh",
+                "-c",
+                "exec cce-files",
+            ]
+        );
+    }
+
+    #[test]
+    fn unit_names_keep_only_legal_characters() {
+        assert_eq!(scope_name_part("google-chrome-stable"), "google-chrome-stable");
+        assert_eq!(scope_name_part("/opt/1Password/1password"), "1password");
+        assert_eq!(scope_name_part("my app.bin"), "my_app_bin");
+        assert_eq!(scope_name_part(""), "app");
+    }
+}