From e9ccba1f1642ad42477463b02ff67086b6f21f8b Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Mon, 22 Jun 2026 08:58:50 -0400 Subject: [PATCH 1/3] handler: add optional working_dir to override game process cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launcher-shim games run the real binary from a subdir but need cwd at a higher level — e.g. native Frozenbyte titles: the game binary is in _enchanted_edition_/bin/ while its data root (and goldberg's GseAppPath) is _enchanted_edition_, and the game's own config-GUI launcher can't be driven by gamepad-only together seats. Add an optional `working_dir` handler field (relative to the mounted game root) that overrides the cwd derivation in build_cmds. Unset = today's behavior (the exec's parent dir). Generic — replaces per-game wrapper scripts for the whole launcher-shim class: exec: _enchanted_edition_/bin/trine1_linux_32bit working_dir: _enchanted_edition_ env: "LD_LIBRARY_PATH=lib/lib32" --- src/handler.rs | 10 ++++++++++ src/launch/pipelines/build_cmds.rs | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/handler.rs b/src/handler.rs index 8486519..1aa16e1 100755 --- a/src/handler.rs +++ b/src/handler.rs @@ -113,6 +113,15 @@ pub struct Handler { pub args: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub env: String, + /// Working directory for the game process (and goldberg's GseAppPath), + /// relative to the mounted game root. Optional — when empty, cwd defaults to + /// the exec's parent dir (today's behavior). Set only for launcher-shim + /// games where the real binary lives in a subdir but must run with cwd at a + /// higher level, e.g. native Frozenbyte titles: + /// exec: _enchanted_edition_/bin/trine1_linux_32bit + /// working_dir: _enchanted_edition_ + #[serde(default, skip_serializing_if = "String::is_empty")] + pub working_dir: String, #[serde(default, skip_serializing_if = "is_default_sdl2")] pub sdl2_override: SDL2Override, /// Path to Proton installation. If set, uses direct Proton instead of umu-run. @@ -269,6 +278,7 @@ impl Default for Handler { exec: String::new(), args: String::new(), env: String::new(), + working_dir: String::new(), sdl2_override: SDL2Override::No, proton_path: String::new(), diff --git a/src/launch/pipelines/build_cmds.rs b/src/launch/pipelines/build_cmds.rs index e063f5f..33462fe 100644 --- a/src/launch/pipelines/build_cmds.rs +++ b/src/launch/pipelines/build_cmds.rs @@ -79,12 +79,24 @@ pub fn launch_cmds( } let path_exec = gamedir.join(exec); - let cwd = path_exec.parent().ok_or_else(|| "couldn't get parent")?; + // Working dir for the game process (and goldberg's GseAppPath). Optional + // handler `working_dir` (relative to game root) overrides the default for + // launcher-shim games whose real binary lives in a subdir but must run + // with cwd at a higher level (e.g. native Frozenbyte titles). Default = + // the exec's parent dir. + let cwd: PathBuf = if !h.working_dir.is_empty() { + gamedir.join(&h.working_dir) + } else { + path_exec + .parent() + .ok_or_else(|| "couldn't get parent")? + .to_path_buf() + }; let path_prof = PATH_PARTY.join("profiles").join(&instance.profname); // 1. Create gamescope command let mut cmd = gamescope::create_command(cfg); - cmd.current_dir(cwd); + cmd.current_dir(&cwd); // 2. Set up gamescope environment gamescope::setup_env(&mut cmd); From 8aa66e8b96fd9b89e77ac7e5d626558d0d94f71d Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Tue, 23 Jun 2026 07:16:16 -0400 Subject: [PATCH 2/3] =?UTF-8?q?launch:=20per-process=20concurrency=20?= =?UTF-8?q?=E2=80=94=20namespaced=20scratch=20+=20per-launch=20window=20ow?= =?UTF-8?q?nership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit splitux could only run ONE session per host: every launch keyed its scratch by instance index (tmp/game-0, work-0, -overlay-0), so a 2nd concurrent launch collided on the fuse-overlayfs mounts ("mount failed"); teardown unmounted ALL tmp/game-* globally; the startup unit-sweep stopped other live sessions; and the niri WM grabbed EVERY gamescope window (stacking all sessions into one column + cross-repositioning that crashed games). Each `splitux launch` is its own process with one active launch, so namespace all per-launch state by a per-process launch id: - paths: set_launch_ns() / launch_tmp_dir() = tmp/; scratch (game-N, work-N, -overlay-N, game-patches) now lives under it. overlays.rs, build_cmds.rs and backend overlay all route through it (mount + cwd agree). - teardown: fuse_overlayfs_unmount_gamedirs() is launch-scoped (only this launch's tmp/, then removes it) — concurrent sessions untouched. session.rs end-of-launch cleanup uses it instead of the blanket clear_tmp(). - startup: sweep_orphan_units() skips units whose owner pid is still alive (other live sessions); reap_orphan_launch_scratch() removes only DEAD-pid tmp/. - WM: niri get_gamescope_windows() filters to windows whose pid cgroup carries THIS launch's namespace (splitux-), so each session manages only its own gamescope window. Falls back to all when unscoped. Validated: 3 native couch games launched concurrently — distinct namespaces, 6 seats, each WM "Found 1 window" + mapped, no mount collision. --- src/backend/operations/overlay.rs | 10 ++-- src/launch/operations/overlays.rs | 2 +- src/launch/operations/scope.rs | 21 +++++++ src/launch/pipelines/build_cmds.rs | 2 +- src/launch/pipelines/execute.rs | 8 +++ src/launch/pipelines/session.rs | 10 ++-- src/paths.rs | 33 ++++++++++- src/util.rs | 91 +++++++++++++++++++++++------- src/wm/niri.rs | 26 ++++++++- 9 files changed, 166 insertions(+), 37 deletions(-) diff --git a/src/backend/operations/overlay.rs b/src/backend/operations/overlay.rs index 021c7b8..c341e66 100644 --- a/src/backend/operations/overlay.rs +++ b/src/backend/operations/overlay.rs @@ -3,18 +3,16 @@ use std::fs; use std::path::PathBuf; -use crate::paths::PATH_PARTY; - /// Prepare a clean overlay directory for a backend instance. /// -/// Creates `{PATH_PARTY}/tmp/{backend_name}-overlay-{instance_idx}`, -/// removing any previous overlay at the same path. +/// Creates `{PATH_PARTY}/tmp/{launch_ns}/{backend_name}-overlay-{instance_idx}` +/// (per-launch namespaced so concurrent sessions don't collide), removing any +/// previous overlay at the same path. pub fn prepare_overlay_dir( backend_name: &str, instance_idx: usize, ) -> Result> { - let overlay_dir = PATH_PARTY - .join("tmp") + let overlay_dir = crate::paths::launch_tmp_dir() .join(format!("{}-overlay-{}", backend_name, instance_idx)); // Clean previous overlay diff --git a/src/launch/operations/overlays.rs b/src/launch/operations/overlays.rs index 14304f7..2589e42 100644 --- a/src/launch/operations/overlays.rs +++ b/src/launch/operations/overlays.rs @@ -21,7 +21,7 @@ pub fn fuse_overlayfs_mount_gamedirs( instances: &Vec, backend_overlays: &[Vec], ) -> Result<(), Box> { - let tmp_dir = PATH_PARTY.join("tmp"); + let tmp_dir = crate::paths::launch_tmp_dir(); let game_root = h.get_game_rootpath()?; let game_root_path = Path::new(&game_root); let gamename = h.handler_dir_name().to_string(); diff --git a/src/launch/operations/scope.rs b/src/launch/operations/scope.rs index 97e3a2b..b508f26 100644 --- a/src/launch/operations/scope.rs +++ b/src/launch/operations/scope.rs @@ -341,6 +341,23 @@ pub fn sweep_orphan_units() { ]; let is_mine = |unit: &str| mine.iter().any(|p| unit.starts_with(p.as_str())); + // CONCURRENCY: a unit may belong to ANOTHER live splitux process running a + // concurrent session — those must NOT be swept. Every splitux unit embeds the + // owning pid (splitux-main-, splitux-restore-, splitux-_...); + // extract it and skip the unit if that pid is still alive. Only DEAD-pid units + // (crashed/killed runs) are genuine orphans. + let owner_pid = |unit: &str| -> Option { + let rest = unit.strip_prefix("splitux-")?; + let pid_part = rest + .strip_prefix("main-") + .or_else(|| rest.strip_prefix("restore-")) + .map(|s| s.split('.').next().unwrap_or(s)) + .unwrap_or_else(|| rest.split('_').next().unwrap_or(rest)); + pid_part.parse::().ok() + }; + let belongs_to_live_session = + |unit: &str| owner_pid(unit).map(crate::util::pid_alive).unwrap_or(false); + let Ok(output) = Command::new("systemctl") .args([ "--user", @@ -377,6 +394,10 @@ pub fn sweep_orphan_units() { if is_mine(unit) { continue; } + // ...nor a CONCURRENT live session from another splitux process. + if belongs_to_live_session(unit) { + continue; + } to_stop.push(unit.to_string()); } diff --git a/src/launch/pipelines/build_cmds.rs b/src/launch/pipelines/build_cmds.rs index 33462fe..c0cca69 100644 --- a/src/launch/pipelines/build_cmds.rs +++ b/src/launch/pipelines/build_cmds.rs @@ -69,7 +69,7 @@ pub fn launch_cmds( for (i, instance) in instances.iter().enumerate() { let gamedir = if h.is_saved_handler() && !cfg.disable_mount_gamedirs { - PATH_PARTY.join("tmp").join(format!("game-{}", i)) + crate::paths::launch_tmp_dir().join(format!("game-{}", i)) } else { PathBuf::from(h.get_game_rootpath()?) }; diff --git a/src/launch/pipelines/execute.rs b/src/launch/pipelines/execute.rs index 310ff5b..d90a66f 100644 --- a/src/launch/pipelines/execute.rs +++ b/src/launch/pipelines/execute.rs @@ -70,6 +70,14 @@ pub fn launch_game( // cgroup AND seat-streamers. let scoping = scope::enabled(); let launch_id = scope::new_launch_id(); + // Namespace ALL per-launch scratch (overlay mounts, goldberg overlays, work + // dirs) under tmp/ so concurrent splitux processes don't collide + // on tmp/game-0 etc. Must be set before any scratch dir is created below. + crate::paths::set_launch_ns(&launch_id); + // Reap scratch (tmp/) left by DEAD splitux processes — skips LIVE + // concurrent sessions (their pid is still alive), so this is safe to run + // while other splitux processes have games up. + crate::util::reap_orphan_launch_scratch(); let main_scope = scope::current_main_scope(); if scoping { // Reap leftover units from any previous crashed/killed run first. diff --git a/src/launch/pipelines/session.rs b/src/launch/pipelines/session.rs index a3543e2..4bf3c8f 100644 --- a/src/launch/pipelines/session.rs +++ b/src/launch/pipelines/session.rs @@ -18,7 +18,7 @@ use crate::handler::Handler; use crate::monitor::Monitor; use crate::profiles::remove_guest_profiles; use crate::save_sync; -use crate::util::{cleanup_orphaned_processes, clear_tmp}; +use crate::util::{cleanup_orphaned_processes, fuse_overlayfs_unmount_gamedirs}; use super::launch_game; use super::super::setup_profiles; @@ -77,9 +77,11 @@ pub fn run_session( println!("[splitux] Error removing guest profiles: {}", err); notify("Failed removing guest profiles", &format!("{err}")); } - if let Err(err) = clear_tmp() { - println!("[splitux] Error removing tmp directory: {}", err); - notify("Failed removing tmp directory", &format!("{err}")); + // Launch-scoped: drop only THIS launch's scratch (tmp/), never the whole + // tmp/ — a concurrent session from another splitux process keeps its own. + if let Err(err) = fuse_overlayfs_unmount_gamedirs() { + println!("[splitux] Error removing launch scratch: {}", err); + notify("Failed removing launch scratch", &format!("{err}")); } // Clean teardown reached (incl. any save sync-back above): drop the runtime diff --git a/src/paths.rs b/src/paths.rs index 7777cea..efa4633 100755 --- a/src/paths.rs +++ b/src/paths.rs @@ -1,6 +1,6 @@ use std::env; use std::path::PathBuf; -use std::sync::LazyLock; +use std::sync::{LazyLock, Mutex}; pub static PATH_ASSETS: LazyLock = LazyLock::new(|| { // Check system-wide install @@ -31,6 +31,37 @@ pub static PATH_PARTY: LazyLock = LazyLock::new(|| { PATH_LOCAL_SHARE.join("splitux") }); +/// Per-process active-launch namespace. Each `splitux launch` runs as its own +/// process with ONE active launch, so all per-launch scratch (overlay mounts, +/// goldberg overlays, work dirs) lives under `tmp/` keyed by this — letting +/// several splitux processes run CONCURRENT sessions on one host without +/// colliding on `tmp/game-0` etc. Set once at launch start (launch::execute). +static LAUNCH_NS: Mutex> = Mutex::new(None); + +/// Set the active launch namespace for this process (the launch_id). +pub fn set_launch_ns(ns: &str) { + if let Ok(mut g) = LAUNCH_NS.lock() { + *g = Some(ns.to_string()); + } +} + +/// The active launch namespace, or a pid fallback if unset (defensive — the +/// launch flow always sets it before any scratch is created). +pub fn launch_ns() -> String { + LAUNCH_NS + .lock() + .ok() + .and_then(|g| g.clone()) + .unwrap_or_else(|| std::process::id().to_string()) +} + +/// Per-launch scratch root: `PATH_PARTY/tmp/`. Holds every per-instance +/// launch scratch dir (`game-{i}`, `work-{i}`, `-overlay-{i}`, +/// `game-patches`) so concurrent launches never share a path. +pub fn launch_tmp_dir() -> PathBuf { + PATH_PARTY.join("tmp").join(launch_ns()) +} + pub static PATH_STEAM: LazyLock = LazyLock::new(|| { // Check for native Steam installation first if PATH_LOCAL_SHARE.join("Steam").exists() { diff --git a/src/util.rs b/src/util.rs index 774a7a4..fcae072 100644 --- a/src/util.rs +++ b/src/util.rs @@ -238,43 +238,92 @@ pub fn is_mount_point(dir: &PathBuf) -> Result> } pub fn fuse_overlayfs_unmount_gamedirs() -> Result<(), Box> { - let tmp = PATH_PARTY.join("tmp"); - - let Ok(entries) = std::fs::read_dir(&tmp) else { - return Err("Failed to read directory".into()); - }; + // LAUNCH-SCOPED: only this launch's own tmp// subtree, so tearing one + // session down never unmounts a CONCURRENT session's game dirs (each splitux + // process has its own launch namespace — see paths::launch_tmp_dir). + let dir = crate::paths::launch_tmp_dir(); // Best-effort: a single stuck/raced unmount must NOT abort the rest of the // teardown (that left other game dirs mounted and surfaced a scary "fuse // error" on kill). Unmount every game-N dir we can, collect genuine failures, // and only report those. let mut failures: Vec = Vec::new(); - for entry_result in entries { - let Ok(entry) = entry_result else { continue }; - let path = entry.path(); - if !(path.is_dir() && entry.file_name().to_string_lossy().starts_with("game-")) { - continue; - } - if !is_mount_point(&path).unwrap_or(false) { - continue; - } - let status = Command::new("umount").arg("-l").arg("-v").arg(&path).status(); - match status { - Ok(s) if s.success() => {} - // Raced: it got unmounted between the check and now — fine. - _ if !is_mount_point(&path).unwrap_or(false) => {} - Ok(_) => failures.push(path.to_string_lossy().into_owned()), - Err(e) => failures.push(format!("{} ({e})", path.to_string_lossy())), + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry_result in entries { + let Ok(entry) = entry_result else { continue }; + let path = entry.path(); + if !(path.is_dir() && entry.file_name().to_string_lossy().starts_with("game-")) { + continue; + } + if !is_mount_point(&path).unwrap_or(false) { + continue; + } + let status = Command::new("umount").arg("-l").arg("-v").arg(&path).status(); + match status { + Ok(s) if s.success() => {} + // Raced: it got unmounted between the check and now — fine. + _ if !is_mount_point(&path).unwrap_or(false) => {} + Ok(_) => failures.push(path.to_string_lossy().into_owned()), + Err(e) => failures.push(format!("{} ({e})", path.to_string_lossy())), + } } } if failures.is_empty() { + // All unmounted — remove this launch's whole scratch subtree (game-N, + // work-N, -overlay-N, game-patches). No-op if already gone. + let _ = std::fs::remove_dir_all(&dir); Ok(()) } else { Err(format!("failed to unmount: {}", failures.join(", ")).into()) } } +/// Reap per-launch scratch (`tmp//`) left behind by DEAD splitux processes, +/// without touching LIVE concurrent sessions. The namespace is `_`, +/// so a dir whose pid is no longer running is a crashed-launch orphan — unmount +/// its game-N mounts and remove it. Live pids (other concurrent sessions) and the +/// non-namespaced shared tmp entries are left alone. Safe to call at startup. +pub fn reap_orphan_launch_scratch() { + let tmp = PATH_PARTY.join("tmp"); + let Ok(entries) = std::fs::read_dir(&tmp) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + // Only namespaced launch dirs: "_". Skip shared scratch + // (game-patches, photon-shared, -overlay-N from old layouts). + let Some(pid_str) = name.split('_').next() else { continue }; + let Ok(pid) = pid_str.parse::() else { continue }; + if name == pid_str { + continue; // no "_counter" suffix — not a launch namespace + } + if pid_alive(pid) { + continue; // a LIVE concurrent session — leave it alone + } + // Dead-pid orphan: unmount any game-N mounts, then remove the subtree. + if let Ok(sub) = std::fs::read_dir(&path) { + for e in sub.flatten() { + let p = e.path(); + if p.is_dir() + && e.file_name().to_string_lossy().starts_with("game-") + && is_mount_point(&p).unwrap_or(false) + { + let _ = Command::new("umount").arg("-l").arg(&p).status(); + } + } + } + let _ = std::fs::remove_dir_all(&path); + } +} + +/// True if a process with this pid is currently alive (checked via `/proc`). +pub fn pid_alive(pid: u32) -> bool { + std::path::Path::new(&format!("/proc/{pid}")).exists() +} + pub fn clear_tmp() -> Result<(), Box> { let tmp = PATH_PARTY.join("tmp"); diff --git a/src/wm/niri.rs b/src/wm/niri.rs index 8fb191d..1379054 100644 --- a/src/wm/niri.rs +++ b/src/wm/niri.rs @@ -148,12 +148,30 @@ impl NiriManager { Err(format!("Monitor '{}' not found after {} retries", connector_name, max_retries).into()) } - /// Get list of gamescope windows + /// Get list of gamescope windows belonging to THIS launch. fn get_gamescope_windows(&self) -> WmResult> { let response = self.niri_msg(&["windows"])?; let windows: serde_json::Value = serde_json::from_str(&response) .map_err(|e| format!("Failed to parse windows: {}", e))?; + // CONCURRENCY: only windows owned by THIS launch's systemd scope, so two + // splitux sessions never grab each other's gamescope windows (which + // stacked all of them into one column + fought over the output). Every + // process in a launch (gamescope → bwrap → game) runs inside + // `splitux--iN.scope`, so the window's pid cgroup carries our + // launch namespace. When this launch isn't scoped there's no concurrency + // to disambiguate, so fall back to matching all gamescope windows. + let scoped = crate::launch::scope::enabled(); + let ns = crate::paths::launch_ns(); + let is_mine = |pid: u64| -> bool { + if !scoped { + return true; + } + std::fs::read_to_string(format!("/proc/{pid}/cgroup")) + .map(|c| c.contains(&format!("splitux-{ns}"))) + .unwrap_or(false) + }; + let mut result = Vec::new(); if let Some(arr) = windows.as_array() { for win in arr { @@ -165,8 +183,10 @@ impl NiriManager { // matching silently finds 0 windows and the launch times out. // Fall back to the window's PID: niri exposes it, and a window // backed by a gamescope binary is ours regardless of app_id. - let pid_is_gs = win["pid"].as_u64().is_some_and(pid_is_gamescope); - if app_id.to_lowercase().contains("gamescope") || pid_is_gs { + let pid = win["pid"].as_u64(); + let pid_is_gs = pid.is_some_and(pid_is_gamescope); + let is_gamescope = app_id.to_lowercase().contains("gamescope") || pid_is_gs; + if is_gamescope && pid.is_some_and(is_mine) { if let Some(id) = win["id"].as_u64() { result.push(NiriWindow { id, From de0c0789768aeb74bfbc6c62d7769cad38e6d9eb Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Wed, 24 Jun 2026 06:28:52 -0400 Subject: [PATCH 3/3] audio + concurrency: per-instance virtual sinks, crash-safe orphan cleanup Per-instance capture/mute virtual sinks for both PulseAudio and PipeWire, with orphan-sink reaping that recovers from dead launches without disturbing live concurrent sessions. Threads per-instance audio and namespaced scratch through launch (scope/build_cmds/execute), bwrap base, and together so multiple sessions run isolated side by side. --- src/audio.rs | 2 +- src/audio/operations.rs | 38 ++++++-- src/audio/operations/pipewire.rs | 106 +++++++++++++++------ src/audio/operations/pulseaudio.rs | 118 ++++++++++++++++++------ src/audio/pipelines/setup_session.rs | 63 ++++++------- src/audio/pure.rs | 2 +- src/audio/pure/sink_name.rs | 47 +++++++++- src/audio/types.rs | 13 +++ src/bwrap/operations/base.rs | 16 +++- src/launch/operations/scope.rs | 133 ++++++++++++++++++++------- src/launch/pipelines/build_cmds.rs | 18 +++- src/launch/pipelines/execute.rs | 54 ++++++++--- src/together.rs | 27 +++++- 13 files changed, 474 insertions(+), 163 deletions(-) diff --git a/src/audio.rs b/src/audio.rs index a407546..67ce99e 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -23,7 +23,7 @@ use std::process::Command; // Re-export types pub use types::{ AudioContext, AudioDeviceType, AudioSink, AudioSystem, AudioSystemPreference, - VirtualSink, AUDIO_MUTED_SENTINEL, + VirtualSink, AUDIO_CAPTURE_SENTINEL, AUDIO_MUTED_SENTINEL, }; // Re-export operations diff --git a/src/audio/operations.rs b/src/audio/operations.rs index 94a8ecf..6ca630d 100644 --- a/src/audio/operations.rs +++ b/src/audio/operations.rs @@ -20,12 +20,13 @@ pub fn scan_sinks(system: AudioSystem) -> AudioResult> { /// Create a virtual sink for an instance, routed to the target physical sink pub fn create_virtual_sink( system: AudioSystem, + ns: &str, instance_idx: usize, target_sink: &str, ) -> AudioResult { match system { - AudioSystem::PulseAudio => pulseaudio::create_virtual_sink(instance_idx, target_sink), - AudioSystem::PipeWireNative => pipewire::create_virtual_sink(instance_idx, target_sink), + AudioSystem::PulseAudio => pulseaudio::create_virtual_sink(ns, instance_idx, target_sink), + AudioSystem::PipeWireNative => pipewire::create_virtual_sink(ns, instance_idx, target_sink), AudioSystem::None => Err("No audio system available".into()), } } @@ -33,10 +34,28 @@ pub fn create_virtual_sink( /// Create a mute sink for an instance (null sink with no output) /// /// Audio sent to this sink goes nowhere - used for explicit muting -pub fn create_mute_sink(system: AudioSystem, instance_idx: usize) -> AudioResult { +pub fn create_mute_sink( + system: AudioSystem, + ns: &str, + instance_idx: usize, +) -> AudioResult { + match system { + AudioSystem::PulseAudio => pulseaudio::create_mute_sink(ns, instance_idx), + AudioSystem::PipeWireNative => pipewire::create_mute_sink(ns, instance_idx), + AudioSystem::None => Err("No audio system available".into()), + } +} + +/// Create a capture sink for an instance (null sink whose monitor a +/// splitux-together seat-streamer captures, isolating that instance's audio). +pub fn create_capture_sink( + system: AudioSystem, + ns: &str, + instance_idx: usize, +) -> AudioResult { match system { - AudioSystem::PulseAudio => pulseaudio::create_mute_sink(instance_idx), - AudioSystem::PipeWireNative => pipewire::create_mute_sink(instance_idx), + AudioSystem::PulseAudio => pulseaudio::create_capture_sink(ns, instance_idx), + AudioSystem::PipeWireNative => pipewire::create_capture_sink(ns, instance_idx), AudioSystem::None => Err("No audio system available".into()), } } @@ -50,11 +69,12 @@ pub fn cleanup_sinks(system: AudioSystem, sinks: &[VirtualSink]) -> AudioResult< } } -/// Emergency cleanup: remove all splitux-related audio modules/nodes -pub fn cleanup_all_splitux_sinks(system: AudioSystem) -> AudioResult<()> { +/// Reap splitux sinks left behind by DEAD launches (crash recovery), without +/// touching live concurrent sessions' sinks. +pub fn cleanup_orphan_sinks(system: AudioSystem) -> AudioResult<()> { match system { - AudioSystem::PulseAudio => pulseaudio::cleanup_all_splitux_sinks(), - AudioSystem::PipeWireNative => pipewire::cleanup_all_splitux_sinks(), + AudioSystem::PulseAudio => pulseaudio::cleanup_orphan_sinks(), + AudioSystem::PipeWireNative => pipewire::cleanup_orphan_sinks(), AudioSystem::None => Ok(()), } } diff --git a/src/audio/operations/pipewire.rs b/src/audio/operations/pipewire.rs index 847526d..0cde414 100644 --- a/src/audio/operations/pipewire.rs +++ b/src/audio/operations/pipewire.rs @@ -6,7 +6,7 @@ use std::process::Command; use crate::audio::pure::{ classify_device, generate_virtual_sink_description, generate_virtual_sink_name, - is_splitux_sink, + is_splitux_sink, parse_sink_owner_pid, }; use crate::audio::types::{AudioResult, AudioSink, VirtualSink}; @@ -114,17 +114,50 @@ fn parse_wpctl_sink_line(line: &str) -> Option { /// Create a mute sink for an instance (null sink with no output) /// /// Audio sent to this sink goes nowhere - used for explicit muting -pub fn create_mute_sink(instance_idx: usize) -> AudioResult { - let sink_name = generate_virtual_sink_name(instance_idx); - let description = format!("Splitux Instance {} (Muted)", instance_idx); +pub fn create_mute_sink(ns: &str, instance_idx: usize) -> AudioResult { + create_null_sink( + ns, + instance_idx, + &format!("Splitux Instance {} (Muted)", instance_idx), + "mute sink", + ) +} + +/// Create a capture sink for an instance (null sink with no output). +/// +/// Structurally a mute sink, but its monitor is captured by a splitux-together +/// seat-streamer so the instance's audio reaches only that seat. See the +/// PulseAudio variant for the full rationale. +pub fn create_capture_sink(ns: &str, instance_idx: usize) -> AudioResult { + create_null_sink( + ns, + instance_idx, + &format!("Splitux Instance {} (Remote Capture)", instance_idx + 1), + "capture sink", + ) +} + +/// Create a bare null sink (no output) via pactl's compat layer. Shared by the +/// mute and capture paths, which differ only in description/logging. +fn create_null_sink( + ns: &str, + instance_idx: usize, + description: &str, + kind: &str, +) -> AudioResult { + let sink_name = generate_virtual_sink_name(ns, instance_idx); println!( - "[splitux] audio - Creating PipeWire mute sink '{}' (no output)", - sink_name + "[splitux] audio - Creating PipeWire {} '{}' (no output)", + kind, sink_name ); // Use pactl for compatibility - // Specify rate/channels to match other sinks + // Specify rate/channels to match other sinks. + // node.latency pins a small FIXED quantum so the monitor delivers steady + // fine-grained audio the seat-streamer's pulsesrc can keep up with; without it + // the free-running null sink starves the capture and the stream cuts. See the + // PulseAudio variant for the full rationale. let null_sink_output = Command::new("pactl") .args([ "load-module", @@ -133,7 +166,7 @@ pub fn create_mute_sink(instance_idx: usize) -> AudioResult { "rate=48000", "channels=2", &format!( - "sink_properties=device.description=\"{}\"", + "sink_properties=device.description=\"{}\" node.latency=512/48000", description.replace(' ', "\\ ") ), ]) @@ -141,7 +174,8 @@ pub fn create_mute_sink(instance_idx: usize) -> AudioResult { if !null_sink_output.status.success() { return Err(format!( - "Failed to create mute sink: {}", + "Failed to create {}: {}", + kind, String::from_utf8_lossy(&null_sink_output.stderr) ) .into()); @@ -150,11 +184,11 @@ pub fn create_mute_sink(instance_idx: usize) -> AudioResult { let module_id = crate::audio::pure::parse_module_id(&String::from_utf8_lossy( &null_sink_output.stdout, )) - .ok_or("Failed to parse mute sink module ID")?; + .ok_or("Failed to parse null-sink module ID")?; println!( - "[splitux] audio - Created PipeWire mute sink {} (module {})", - sink_name, module_id + "[splitux] audio - Created PipeWire {} {} (module {})", + kind, sink_name, module_id ); Ok(VirtualSink { @@ -167,8 +201,12 @@ pub fn create_mute_sink(instance_idx: usize) -> AudioResult { /// /// Note: PipeWire virtual sink creation is more complex than PulseAudio. /// This uses the pipewire-pulse compat layer's module-null-sink internally. -pub fn create_virtual_sink(instance_idx: usize, target_sink: &str) -> AudioResult { - let sink_name = generate_virtual_sink_name(instance_idx); +pub fn create_virtual_sink( + ns: &str, + instance_idx: usize, + target_sink: &str, +) -> AudioResult { + let sink_name = generate_virtual_sink_name(ns, instance_idx); let description = generate_virtual_sink_description(instance_idx); println!( @@ -340,9 +378,11 @@ pub fn cleanup_sinks(sinks: &[VirtualSink]) -> AudioResult<()> { } } -/// Emergency cleanup: destroy all splitux-related nodes -pub fn cleanup_all_splitux_sinks() -> AudioResult<()> { - // Use pactl for compatibility +/// Reap splitux sink modules left behind by DEAD launches (crash recovery), +/// without touching LIVE concurrent sessions' sinks. Mirrors the PulseAudio +/// variant — sinks/loopbacks are created via pactl's compat layer either way, so +/// we reap through pactl and skip any sink whose owning launch pid is still alive. +pub fn cleanup_orphan_sinks() -> AudioResult<()> { let output = Command::new("pactl") .args(["list", "modules", "short"]) .output()?; @@ -350,17 +390,27 @@ pub fn cleanup_all_splitux_sinks() -> AudioResult<()> { let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { - if line.contains("splitux_instance_") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if let Some(module_id) = parts.first() { - println!( - "[splitux] audio - Emergency cleanup: unloading module {}", - module_id - ); - let _ = Command::new("pactl") - .args(["unload-module", module_id]) - .output(); - } + let Some(name) = line.split_whitespace().find_map(|tok| { + let val = tok.split_once('=')?.1; + let val = val.strip_suffix(".monitor").unwrap_or(val); + is_splitux_sink(val).then_some(val) + }) else { + continue; + }; + let Some(pid) = parse_sink_owner_pid(name) else { + continue; + }; + if crate::util::pid_alive(pid) { + continue; + } + if let Some(module_id) = line.split_whitespace().next() { + println!( + "[splitux] audio - Reaping orphan sink module {} ({}, dead pid {})", + module_id, name, pid + ); + let _ = Command::new("pactl") + .args(["unload-module", module_id]) + .output(); } } diff --git a/src/audio/operations/pulseaudio.rs b/src/audio/operations/pulseaudio.rs index 9d1c3fb..3c10c0d 100644 --- a/src/audio/operations/pulseaudio.rs +++ b/src/audio/operations/pulseaudio.rs @@ -6,7 +6,7 @@ use std::process::Command; use crate::audio::pure::{ classify_device, generate_virtual_sink_description, generate_virtual_sink_name, - is_splitux_sink, parse_module_id, + is_splitux_sink, parse_module_id, parse_sink_owner_pid, }; use crate::audio::types::{AudioResult, AudioSink, VirtualSink}; @@ -75,23 +75,61 @@ fn parse_pactl_sinks(output: &str, default_sink: &str) -> Vec { /// Create a mute sink for an instance (null sink with no loopback) /// /// Audio sent to this sink goes nowhere - used for explicit muting -pub fn create_mute_sink(instance_idx: usize) -> AudioResult { - let sink_name = generate_virtual_sink_name(instance_idx); - let description = format!("Splitux Instance {} (Muted)", instance_idx); +pub fn create_mute_sink(ns: &str, instance_idx: usize) -> AudioResult { + create_null_sink( + ns, + instance_idx, + &format!("Splitux Instance {} (Muted)", instance_idx), + "mute sink", + ) +} - println!( - "[splitux] audio - Creating mute sink '{}' (no output)", - sink_name - ); +/// Create a capture sink for an instance (null sink with no loopback). +/// +/// Structurally identical to a mute sink, but its monitor is meant to be +/// captured by a splitux-together seat-streamer (`.monitor`) so the +/// instance's audio reaches ONLY that seat — giving each together instance its +/// own audio stream instead of every seat sharing the default sink's monitor. +pub fn create_capture_sink(ns: &str, instance_idx: usize) -> AudioResult { + create_null_sink( + ns, + instance_idx, + &format!("Splitux Instance {} (Remote Capture)", instance_idx + 1), + "capture sink", + ) +} - // Create null sink only (no loopback = audio goes nowhere) +/// Create a bare null sink (no loopback) for an instance. Shared by the mute and +/// capture paths, which differ only in description/logging. +fn create_null_sink( + ns: &str, + instance_idx: usize, + description: &str, + kind: &str, +) -> AudioResult { + let sink_name = generate_virtual_sink_name(ns, instance_idx); + + println!("[splitux] audio - Creating {} '{}' (no output)", kind, sink_name); + + // Create null sink only (no loopback = audio goes nowhere on the host). + // rate/channels pinned so a downstream monitor capture sees a stable format. + // + // node.latency pins a small FIXED quantum on the PipeWire node. Without it the + // null sink free-runs on PipeWire's floating quantum and its monitor delivers + // audio in coarse, irregular bursts; the seat-streamer's pulsesrc (25ms + // latency-time) then can't read fast enough (acap ~30 vs the 50 frames/s the + // Opus encoder wants) → the stream stutters/cuts. A ~10ms quantum (512/48000) + // gives the monitor a steady, fine-grained real-time cadence the capture keeps + // up with. Harmless on plain PulseAudio (unknown sink_properties are ignored). let null_sink_output = Command::new("pactl") .args([ "load-module", "module-null-sink", &format!("sink_name={}", sink_name), + "rate=48000", + "channels=2", &format!( - "sink_properties=device.description=\"{}\"", + "sink_properties=device.description=\"{}\" node.latency=512/48000", description.replace(' ', "\\ ") ), ]) @@ -99,18 +137,19 @@ pub fn create_mute_sink(instance_idx: usize) -> AudioResult { if !null_sink_output.status.success() { return Err(format!( - "Failed to create mute sink: {}", + "Failed to create {}: {}", + kind, String::from_utf8_lossy(&null_sink_output.stderr) ) .into()); } let module_id = parse_module_id(&String::from_utf8_lossy(&null_sink_output.stdout)) - .ok_or("Failed to parse mute sink module ID")?; + .ok_or("Failed to parse null-sink module ID")?; println!( - "[splitux] audio - Created mute sink {} (module {})", - sink_name, module_id + "[splitux] audio - Created {} {} (module {})", + kind, sink_name, module_id ); Ok(VirtualSink { @@ -120,8 +159,12 @@ pub fn create_mute_sink(instance_idx: usize) -> AudioResult { } /// Create a virtual sink for an instance, routed to the target physical sink -pub fn create_virtual_sink(instance_idx: usize, target_sink: &str) -> AudioResult { - let sink_name = generate_virtual_sink_name(instance_idx); +pub fn create_virtual_sink( + ns: &str, + instance_idx: usize, + target_sink: &str, +) -> AudioResult { + let sink_name = generate_virtual_sink_name(ns, instance_idx); let description = generate_virtual_sink_description(instance_idx); println!( @@ -248,10 +291,14 @@ fn unload_module(module_id: &str) -> AudioResult<()> { Ok(()) } -/// Emergency cleanup: unload all splitux-related modules +/// Reap splitux sink modules left behind by DEAD launches (crash recovery), +/// without touching LIVE concurrent sessions' sinks. /// -/// Used when we don't have the module IDs (e.g., crash recovery) -pub fn cleanup_all_splitux_sinks() -> AudioResult<()> { +/// Each sink name carries its owning launch's pid (`splitux_instance__…`), +/// so a module whose pid is no longer running is a crashed-launch orphan and is +/// unloaded; sinks owned by a live pid (another concurrent splitux process) are +/// left alone. Safe to call at session start even while other sessions run. +pub fn cleanup_orphan_sinks() -> AudioResult<()> { let output = Command::new("pactl") .args(["list", "modules", "short"]) .output()?; @@ -259,15 +306,30 @@ pub fn cleanup_all_splitux_sinks() -> AudioResult<()> { let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { - if line.contains("splitux_instance_") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if let Some(module_id) = parts.first() { - println!( - "[splitux] audio - Emergency cleanup: unloading module {}", - module_id - ); - let _ = unload_module(module_id); - } + // A splitux sink module references its sink by name in its arguments: + // null-sink: `sink_name=splitux_instance___` + // loopback: `source=splitux_instance___.monitor` + // Find whichever `key=value` token names a splitux sink (strip a trailing + // `.monitor`), then recover the owning launch pid from it. + let Some(name) = line.split_whitespace().find_map(|tok| { + let val = tok.split_once('=')?.1; + let val = val.strip_suffix(".monitor").unwrap_or(val); + is_splitux_sink(val).then_some(val) + }) else { + continue; + }; + let Some(pid) = parse_sink_owner_pid(name) else { + continue; + }; + if crate::util::pid_alive(pid) { + continue; // a LIVE concurrent session — leave its sink alone + } + if let Some(module_id) = line.split_whitespace().next() { + println!( + "[splitux] audio - Reaping orphan sink module {} ({}, dead pid {})", + module_id, name, pid + ); + let _ = unload_module(module_id); } } diff --git a/src/audio/pipelines/setup_session.rs b/src/audio/pipelines/setup_session.rs index c40af53..1ff0d1a 100644 --- a/src/audio/pipelines/setup_session.rs +++ b/src/audio/pipelines/setup_session.rs @@ -1,7 +1,11 @@ //! Audio session setup pipeline -use crate::audio::operations::{cleanup_all_splitux_sinks, create_mute_sink, create_virtual_sink}; -use crate::audio::types::{AudioContext, AudioResult, VirtualSink, AUDIO_MUTED_SENTINEL}; +use crate::audio::operations::{ + cleanup_orphan_sinks, create_capture_sink, create_mute_sink, create_virtual_sink, +}; +use crate::audio::types::{ + AudioContext, AudioResult, VirtualSink, AUDIO_CAPTURE_SENTINEL, AUDIO_MUTED_SENTINEL, +}; /// Set up audio routing for a game session /// @@ -9,45 +13,36 @@ use crate::audio::types::{AudioContext, AudioResult, VirtualSink, AUDIO_MUTED_SE /// Returns the created virtual sinks (for cleanup) and the sink names to use /// for PULSE_SINK environment variable per instance. pub fn setup_audio_session(ctx: &AudioContext) -> AudioResult<(Vec, Vec)> { - // First, clean up any leftover sinks from previous sessions - let _ = cleanup_all_splitux_sinks(ctx.system); + // Reap sinks left by DEAD launches (crash recovery). Liveness-filtered by the + // owning pid embedded in each sink name, so concurrent sessions are untouched. + let _ = cleanup_orphan_sinks(ctx.system); let mut virtual_sinks = Vec::new(); let mut sink_env_vars = Vec::new(); for (instance_idx, maybe_target) in ctx.assignments.iter().enumerate() { if let Some(target_sink) = maybe_target { - // Check if this is an explicit mute request - if target_sink == AUDIO_MUTED_SENTINEL { - // Create mute sink (null sink with no loopback - audio goes nowhere) - match create_mute_sink(ctx.system, instance_idx) { - Ok(virtual_sink) => { - sink_env_vars.push(virtual_sink.sink_name.clone()); - virtual_sinks.push(virtual_sink); - } - Err(e) => { - println!( - "[splitux] audio - Warning: Failed to create mute sink for instance {}: {}", - instance_idx, e - ); - sink_env_vars.push(String::new()); - } - } + // Pick the sink flavor by sentinel: explicit mute, per-instance capture + // (together audio isolation), or a virtual sink routed to a device. + let result = if target_sink == AUDIO_MUTED_SENTINEL { + create_mute_sink(ctx.system, &ctx.ns, instance_idx) + } else if target_sink == AUDIO_CAPTURE_SENTINEL { + create_capture_sink(ctx.system, &ctx.ns, instance_idx) } else { - // Create virtual sink routed to the target physical device - match create_virtual_sink(ctx.system, instance_idx, target_sink) { - Ok(virtual_sink) => { - sink_env_vars.push(virtual_sink.sink_name.clone()); - virtual_sinks.push(virtual_sink); - } - Err(e) => { - // Log error but continue - audio failure shouldn't block game launch - println!( - "[splitux] audio - Warning: Failed to create virtual sink for instance {}: {}", - instance_idx, e - ); - sink_env_vars.push(String::new()); - } + create_virtual_sink(ctx.system, &ctx.ns, instance_idx, target_sink) + }; + match result { + Ok(virtual_sink) => { + sink_env_vars.push(virtual_sink.sink_name.clone()); + virtual_sinks.push(virtual_sink); + } + Err(e) => { + // Log error but continue - audio failure shouldn't block game launch + println!( + "[splitux] audio - Warning: Failed to create sink for instance {}: {}", + instance_idx, e + ); + sink_env_vars.push(String::new()); } } } else { diff --git a/src/audio/pure.rs b/src/audio/pure.rs index a52d966..1312cd1 100644 --- a/src/audio/pure.rs +++ b/src/audio/pure.rs @@ -8,5 +8,5 @@ mod sink_name; pub use device_classification::classify_device; pub use sink_name::{ generate_virtual_sink_description, generate_virtual_sink_name, is_splitux_sink, - parse_module_id, + parse_module_id, parse_sink_owner_pid, }; diff --git a/src/audio/pure/sink_name.rs b/src/audio/pure/sink_name.rs index 2336b16..256903c 100644 --- a/src/audio/pure/sink_name.rs +++ b/src/audio/pure/sink_name.rs @@ -2,9 +2,26 @@ //! //! Pure functions for working with audio sink names. -/// Generate a virtual sink name for an instance -pub fn generate_virtual_sink_name(instance_idx: usize) -> String { - format!("splitux_instance_{}", instance_idx) +/// Generate a virtual sink name for an instance within a launch namespace. +/// +/// The `ns` (the launch_id, `_`) is embedded so CONCURRENT splitux +/// processes never collide on `splitux_instance_0`, and so [`parse_sink_owner_pid`] +/// can recover the owning launch's pid to reap a crashed session's leftovers +/// without touching a live concurrent session's sinks. +pub fn generate_virtual_sink_name(ns: &str, instance_idx: usize) -> String { + format!("splitux_instance_{}_{}", ns, instance_idx) +} + +/// Recover the owning launch's pid from a splitux sink name. The name carries +/// `splitux_instance___` (ns = `_`), so the pid +/// is the first segment after the prefix. None if `name` isn't a splitux sink or +/// has no numeric leading segment. +pub fn parse_sink_owner_pid(name: &str) -> Option { + name.strip_prefix("splitux_instance_")? + .split('_') + .next()? + .parse() + .ok() } /// Generate a human-readable description for a virtual sink @@ -35,8 +52,28 @@ mod tests { #[test] fn test_generate_virtual_sink_name() { - assert_eq!(generate_virtual_sink_name(0), "splitux_instance_0"); - assert_eq!(generate_virtual_sink_name(3), "splitux_instance_3"); + assert_eq!( + generate_virtual_sink_name("12345_0", 0), + "splitux_instance_12345_0_0" + ); + assert_eq!( + generate_virtual_sink_name("12345_0", 3), + "splitux_instance_12345_0_3" + ); + } + + #[test] + fn test_parse_sink_owner_pid() { + // ns = "_" → name carries the pid as the first segment. + assert_eq!( + parse_sink_owner_pid("splitux_instance_12345_0_3"), + Some(12345) + ); + // ns fallback is a bare pid (no counter). + assert_eq!(parse_sink_owner_pid("splitux_instance_999_2"), Some(999)); + // Non-splitux sinks and malformed names yield None. + assert_eq!(parse_sink_owner_pid("alsa_output.pci-0000"), None); + assert_eq!(parse_sink_owner_pid("splitux_instance_abc_0"), None); } #[test] diff --git a/src/audio/types.rs b/src/audio/types.rs index 7fb6da3..1fe31ae 100644 --- a/src/audio/types.rs +++ b/src/audio/types.rs @@ -7,6 +7,14 @@ use serde::{Deserialize, Serialize}; /// so audio goes nowhere instead of to the default output pub const AUDIO_MUTED_SENTINEL: &str = "__muted__"; +/// Sentinel value used in audio assignments to request a per-instance CAPTURE +/// sink. Like the mute sink it's a bare null sink (no loopback to hardware), but +/// its purpose is the opposite: a splitux-together seat-streamer captures this +/// sink's `.monitor` so the instance's audio reaches ONLY that seat's browser. +/// This is what gives each together instance its own audio stream instead of +/// every seat capturing the one shared default-sink monitor (audio bleed). +pub const AUDIO_CAPTURE_SENTINEL: &str = "__capture__"; + /// Which audio system to use for virtual sink management #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AudioSystem { @@ -80,6 +88,11 @@ pub struct AudioContext { pub system: AudioSystem, /// Target sink for each instance (None = use default) pub assignments: Vec>, + /// Per-launch namespace (the launch_id, `_`). Embedded in every + /// virtual sink name so CONCURRENT splitux processes never collide on + /// `splitux_instance_0` and the orphan reaper can tell a crashed launch's + /// leftover sinks (dead pid) from a live concurrent session's. + pub ns: String, } /// Result type for audio operations diff --git a/src/bwrap/operations/base.rs b/src/bwrap/operations/base.rs index b0520d0..5aec863 100644 --- a/src/bwrap/operations/base.rs +++ b/src/bwrap/operations/base.rs @@ -39,13 +39,27 @@ pub fn setup_sdl_env(cmd: &mut Command, gamepad_paths: &[String]) { /// Set up audio routing environment variables inside the bwrap container /// /// Sets PULSE_SINK to route audio to a specific sink (works for both -/// PulseAudio and PipeWire via pipewire-pulse compatibility layer) +/// PulseAudio and PipeWire via pipewire-pulse compatibility layer). +/// +/// PULSE_SINK alone is NOT enough: `module-stream-restore` remembers per-stream +/// device AND volume keyed by the stream's identity (media.role, else app-name), +/// and a matching saved entry OVERRIDES PULSE_SINK. On a Sunshine host the saved +/// "role:game → sink-sunshine-stereo @ 0%" rule hijacks any game that declares +/// `media.role=game` (e.g. Enter the Gungeon): it lands on the wrong sink AND is +/// muted to 0%, so its together stream is silent. We sidestep stream-restore by +/// forcing a per-instance-unique `media.role` (PULSE_PROP_OVERRIDE wins even over +/// the app's own proplist): a fresh role has no saved entry, so PULSE_SINK and +/// the default full volume both apply. The role is unique per launch (the sink +/// name carries `_`), so no stale entry can ever match it either. pub fn setup_audio_env(cmd: &mut Command, sink_name: &str) { if sink_name.is_empty() { return; } // PULSE_SINK works for both PulseAudio and PipeWire (via pipewire-pulse) cmd.args(["--setenv", "PULSE_SINK", sink_name]); + // Force a unique stream-restore identity so no pre-existing device/volume + // rule can override the routing above. `media.=` proplist syntax. + cmd.args(["--setenv", "PULSE_PROP_OVERRIDE", &format!("media.role={sink_name}")]); } /// Set up BepInEx environment variables for Linux native games diff --git a/src/launch/operations/scope.rs b/src/launch/operations/scope.rs index b508f26..7d2e031 100644 --- a/src/launch/operations/scope.rs +++ b/src/launch/operations/scope.rs @@ -21,6 +21,29 @@ //! //! Everything degrades gracefully: if `systemd-run --user` is unavailable we //! skip scoping and fall back to direct spawning + a best-effort sweep. +//! +//! # Unit hierarchy (the naming contract) +//! +//! Every unit splitux creates lives under one stable root slice, and every +//! per-process/per-launch leaf encodes its OWNER pid in its name. This is what +//! makes concurrent splitux processes safe to tell apart and reap independently: +//! +//! ```text +//! splitux.slice ROOT — shared, stable, never stopped by us +//! ├─ splitux-main-.scope a splitux process's own scope (owner: ) +//! ├─ splitux-restore-.service that process's death watcher (owner: ) +//! ├─ splitux-bar-*.scope relaunched host status bars (not a leftover) +//! └─ splitux-_.slice one launch by process (owner: ) +//! ├─ splitux-_-i.scope instance k's gamescope+game +//! └─ splitux-_-seat.scope seat k's seat-streamer +//! ``` +//! +//! The launch id is `_` (underscore, NOT hyphen — see [`new_launch_id`]), +//! so a launch slice nests DIRECTLY under [`ROOT_SLICE`] without systemd +//! auto-materializing surprise intermediate parents. Consequences the sweep MUST +//! respect ([`sweep_orphan_units`]): the root is shared infrastructure (stopping +//! it cascade-kills every concurrent launch), and a leaf is a reap-able orphan +//! only if [`owner_pid`] is dead. use std::process::Command; use std::sync::atomic::{AtomicU64, Ordering}; @@ -30,6 +53,13 @@ use std::sync::{Mutex, OnceLock}; /// infinite re-exec loop. const SCOPED_ENV: &str = "SPLITUX_SCOPED"; +/// The single stable parent slice every splitux unit nests under. Shared by ALL +/// concurrent splitux processes and auto-created by systemd from the child +/// names; it carries no owner pid because it belongs to no one launch. It is +/// infrastructure — the sweep must never stop it (doing so cascades teardown to +/// every concurrent launch's child slice). +pub const ROOT_SLICE: &str = "splitux.slice"; + /// The currently-active launch slice, so any teardown path (normal exit, the /// eframe `on_exit` hook, the startup sweep) can stop it. static ACTIVE_SLICE: Mutex> = Mutex::new(None); @@ -190,6 +220,21 @@ pub fn slice_name(launch_id: &str) -> String { format!("splitux-{launch_id}.slice") } +/// The splitux process that owns a unit, recovered from its name — the inverse of +/// the constructors above (`splitux-main-`, `splitux-restore-`, and the +/// `splitux-_[...]` launch family). `None` for a unit that belongs to no +/// single process — notably [`ROOT_SLICE`], the shared parent. Keep this in +/// lockstep with the naming contract documented at the top of the module. +pub fn owner_pid(unit: &str) -> Option { + let rest = unit.strip_prefix("splitux-")?; + let pid_part = rest + .strip_prefix("main-") + .or_else(|| rest.strip_prefix("restore-")) + .map(|s| s.split('.').next().unwrap_or(s)) + .unwrap_or_else(|| rest.split('_').next().unwrap_or(rest)); + pid_part.parse::().ok() +} + /// Record the active launch slice (so teardown / on_exit can stop it). pub fn set_active_slice(launch_id: &str) { if let Ok(mut g) = ACTIVE_SLICE.lock() { @@ -324,40 +369,19 @@ pub fn stop_active_slice() { /// Sweep leftover splitux launch units from previous (crashed/killed) runs. /// /// Deterministic, unlike marker/ppid guessing: we enumerate splitux's own -/// transient units and stop any that are NOT our current main scope. Called at -/// startup and before each launch. +/// transient units and stop only the ones we can attribute to a DEAD splitux +/// process. The naming contract (see the module header) makes this unambiguous — +/// every reap-able leaf carries its owner pid; the shared [`ROOT_SLICE`] does +/// not. Called at startup and before each launch, INCLUDING while other splitux +/// processes are running concurrent sessions, so it must never touch: +/// * [`ROOT_SLICE`] — the shared parent; stopping it cascade-kills every launch. +/// * `splitux-bar-*` — relaunched host status bars, not launch leftovers. +/// * any unit whose [`owner_pid`] is still alive — a live concurrent session. pub fn sweep_orphan_units() { if !systemd_user_available() { return; } - // Units belonging to THIS instance (must never be swept): our main scope, - // our restore-on-death watcher, and our launch slice(s). All carry our pid. - let pid = std::process::id(); - let mine: [String; 3] = [ - format!("splitux-main-{pid}."), - format!("splitux-restore-{pid}."), - format!("splitux-{pid}_"), - ]; - let is_mine = |unit: &str| mine.iter().any(|p| unit.starts_with(p.as_str())); - - // CONCURRENCY: a unit may belong to ANOTHER live splitux process running a - // concurrent session — those must NOT be swept. Every splitux unit embeds the - // owning pid (splitux-main-, splitux-restore-, splitux-_...); - // extract it and skip the unit if that pid is still alive. Only DEAD-pid units - // (crashed/killed runs) are genuine orphans. - let owner_pid = |unit: &str| -> Option { - let rest = unit.strip_prefix("splitux-")?; - let pid_part = rest - .strip_prefix("main-") - .or_else(|| rest.strip_prefix("restore-")) - .map(|s| s.split('.').next().unwrap_or(s)) - .unwrap_or_else(|| rest.split('_').next().unwrap_or(rest)); - pid_part.parse::().ok() - }; - let belongs_to_live_session = - |unit: &str| owner_pid(unit).map(crate::util::pid_alive).unwrap_or(false); - let Ok(output) = Command::new("systemctl") .args([ "--user", @@ -385,17 +409,23 @@ pub fn sweep_orphan_units() { if !unit.starts_with("splitux") { continue; } + // The shared parent slice is infrastructure, not a leftover — stopping it + // would cascade teardown to every concurrent launch's child slice. + if unit == ROOT_SLICE { + continue; + } // Restored status bars run in their own splitux-bar-*.scope units — they // are legitimately-running user apps we relaunched, NOT launch leftovers. if unit.starts_with("splitux-bar-") { continue; } - // Never tear down units belonging to this running instance. - if is_mine(unit) { + // Reap a leaf only when its owning splitux process is DEAD. A live pid is + // either THIS process or a concurrent session — both must be left alone. + // (No parseable pid means it isn't an attributable leftover; skip it.) + let Some(pid) = owner_pid(unit) else { continue; - } - // ...nor a CONCURRENT live session from another splitux process. - if belongs_to_live_session(unit) { + }; + if crate::util::pid_alive(pid) { continue; } to_stop.push(unit.to_string()); @@ -415,3 +445,38 @@ pub fn sweep_orphan_units() { cmd.args(&to_stop); let _ = cmd.status(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_id_uses_underscore_so_it_nests_under_one_root() { + // `_` keeps the launch slice a DIRECT child of ROOT_SLICE; a hyphen + // would make systemd auto-create an intermediate parent per pid. + let slice = slice_name("12345_0"); + assert_eq!(slice, "splitux-12345_0.slice"); + // The hierarchy separator '-' must only appear in the fixed prefix, not + // between pid and counter. + assert!(!"12345_0".contains('-')); + } + + #[test] + fn owner_pid_recovers_owner_from_every_leaf_unit() { + assert_eq!(owner_pid("splitux-main-12345.scope"), Some(12345)); + assert_eq!(owner_pid("splitux-12345_0.slice"), Some(12345)); + assert_eq!(owner_pid("splitux-12345_0-i0.scope"), Some(12345)); + assert_eq!(owner_pid("splitux-12345_3-seat2.scope"), Some(12345)); + // restore watcher is a .service (not swept) but still attributable + assert_eq!(owner_pid("splitux-restore-12345.service"), Some(12345)); + } + + #[test] + fn root_slice_has_no_owner_so_it_is_never_reaped() { + // The crux of the concurrency bug: the shared parent must NOT parse to a + // pid, so the sweep can never mistake it for a dead-pid leftover. + assert_eq!(owner_pid(ROOT_SLICE), None); + // A non-splitux unit is not ours at all. + assert_eq!(owner_pid("user.slice"), None); + } +} diff --git a/src/launch/pipelines/build_cmds.rs b/src/launch/pipelines/build_cmds.rs index c0cca69..9f2b5bf 100644 --- a/src/launch/pipelines/build_cmds.rs +++ b/src/launch/pipelines/build_cmds.rs @@ -455,11 +455,19 @@ pub fn launch_cmds( println!("[splitux] Instance {}: SDL_JOYSTICK_DEVICE={}", i, gamepad_paths.join(",")); } - // Set up SDL environment inside container. Keep it for both isolation - // modes: it forces SDL onto evdev (HIDAPI off) and pins the device, - // which keeps SDL games well-behaved alongside the evdev allowlist. - // Skip only when isolation is fully off. - if h.effective_input_isolation() != crate::handler::InputIsolation::None { + // Set up SDL environment inside container. This must run whenever this + // instance has pads to wire — INDEPENDENT of device isolation. gamescope + // exports SDL_JOYSTICK_DEVICE=/dev/null (so its own SDL ignores host + // pads) and the game inherits it; udev joystick enumeration doesn't work + // inside bwrap, so SDL falls back to that hint and sees NO controller + // unless we override SDL_JOYSTICK_DEVICE with the real pad. Gating this + // on isolation != None meant isolation:none handlers (Overcooked, Trine) + // inherited /dev/null → the pad never appeared and "Press A" did nothing, + // even though the seat's virtual pad was wired in. So: set it up for any + // isolated handler AND for any handler that has gamepads to expose. + if h.effective_input_isolation() != crate::handler::InputIsolation::None + || !gamepad_paths.is_empty() + { bwrap::setup_sdl_env(&mut cmd, &gamepad_paths); } diff --git a/src/launch/pipelines/execute.rs b/src/launch/pipelines/execute.rs index d90a66f..547a9a1 100644 --- a/src/launch/pipelines/execute.rs +++ b/src/launch/pipelines/execute.rs @@ -55,7 +55,18 @@ pub fn launch_game( cfg: &SplituxConfig, ready: &std::sync::atomic::AtomicBool, ) -> Result<(), Box> { - // Set up audio routing if enabled + // Establish the per-launch namespace FIRST. Everything per-launch keys off it + // — scratch dirs (tmp/), scope units, AND the audio capture sink names — + // so concurrent splitux processes never collide. Must precede audio routing, + // which embeds this ns in each sink name. + let scoping = scope::enabled(); + let launch_id = scope::new_launch_id(); + // Namespace ALL per-launch scratch (overlay mounts, goldberg overlays, work + // dirs) under tmp/ so concurrent splitux processes don't collide + // on tmp/game-0 etc. Must be set before any scratch dir is created below. + crate::paths::set_launch_ns(&launch_id); + + // Set up audio routing (per-session capture sinks for together instances). let (audio_system, virtual_sinks, audio_sink_envs) = setup_audio_routing(instances, cfg); // Set up gptokeyb daemons if enabled (spawns before command building so we can pass virtual device paths) @@ -68,12 +79,6 @@ pub fn launch_game( // child on a hard kill and keeping its virtual input devices alive). Killing // splitux by any signal cascades teardown to the whole launch slice — game // cgroup AND seat-streamers. - let scoping = scope::enabled(); - let launch_id = scope::new_launch_id(); - // Namespace ALL per-launch scratch (overlay mounts, goldberg overlays, work - // dirs) under tmp/ so concurrent splitux processes don't collide - // on tmp/game-0 etc. Must be set before any scratch dir is created below. - crate::paths::set_launch_ns(&launch_id); // Reap scratch (tmp/) left by DEAD splitux processes — skips LIVE // concurrent sessions (their pid is still alive), so this is safe to run // while other splitux processes have games up. @@ -119,6 +124,7 @@ pub fn launch_game( &launch_id, main_scope.as_deref(), scoping, + &audio_sink_envs, ); let new_cmds = launch_cmds( @@ -421,8 +427,14 @@ fn setup_audio_routing( instances: &[Instance], cfg: &SplituxConfig, ) -> (AudioSystem, Vec, Vec) { - if !cfg.audio.enabled { - // Audio routing disabled, return empty vectors + // Per-session audio isolation: every `together` instance needs its OWN sink so + // its seat-streamer can capture only that instance's audio. Without this the + // streamers all tap the shared default-sink monitor and every game's sound + // bleeds into every stream. So we run audio setup whenever EITHER explicit + // routing is enabled OR any remote seat is present, even if cfg.audio.enabled + // is off (that flag governs only the user-facing per-device routing feature). + let any_together = instances.iter().any(|inst| inst.together); + if !cfg.audio.enabled && !any_together { return (AudioSystem::None, vec![], vec![String::new(); instances.len()]); } @@ -432,14 +444,32 @@ fn setup_audio_routing( return (AudioSystem::None, vec![], vec![String::new(); instances.len()]); } - // Build assignments from config - let assignments: Vec> = (0..instances.len()) - .map(|i| cfg.audio.default_assignments.get(&i).cloned()) + // Build per-instance sink assignments. An explicit config assignment (only + // honored when the routing feature is enabled) wins; otherwise a together + // instance gets a dedicated CAPTURE sink (its monitor is fed to that seat's + // stream); local instances keep the default sink (None). + let assignments: Vec> = instances + .iter() + .enumerate() + .map(|(i, inst)| { + if cfg.audio.enabled { + if let Some(target) = cfg.audio.default_assignments.get(&i) { + return Some(target.clone()); + } + } + if inst.together { + return Some(crate::audio::AUDIO_CAPTURE_SENTINEL.to_string()); + } + None + }) .collect(); let ctx = AudioContext { system: audio_system, assignments, + // Tie sink names to this launch's namespace (already set in launch_game + // before this runs) so concurrent splitux processes never collide. + ns: crate::paths::launch_ns(), }; match setup_audio_session(&ctx) { diff --git a/src/together.rs b/src/together.rs index a3cbb1e..458ca77 100644 --- a/src/together.rs +++ b/src/together.rs @@ -169,6 +169,7 @@ fn spawn_seat_streamer( seat_idx: usize, main_scope: Option<&str>, scoping: bool, + audio_monitor: Option<&str>, ) -> std::io::Result { let log_path = format!("/tmp/splitux-together-{seat}.log"); let log = std::fs::File::create(&log_path)?; @@ -189,11 +190,16 @@ fn spawn_seat_streamer( .args(["--bitrate", &cfg.together.bitrate.to_string()]) .args(["--stun", &cfg.together.stun]); cmd.args(["--fps", &cfg.together.resolved_fps().to_string()]); - // Audio passthrough: stream the game's sound as an Opus track. First cut taps - // the default sink's monitor (captures game audio for the single-seat case; - // multi-seat per-instance isolation via dedicated null sinks is a follow-up). - // Resolve the default sink name so we can hand pulsesrc ".monitor". - if let Some(mon) = default_sink_monitor() { + // Audio passthrough: stream the game's sound as an Opus track. Each together + // instance has its OWN capture sink (a per-launch null sink the game is routed + // to via PULSE_SINK), so we capture THAT sink's monitor — isolating this + // instance's audio to this seat's stream instead of every seat tapping the one + // shared default-sink monitor (which mixed all games into all streams). Falls + // back to the default sink's monitor if no per-instance sink was created. + let audio_device = audio_monitor + .map(str::to_string) + .or_else(default_sink_monitor); + if let Some(mon) = audio_device { cmd.args(["--audio-device", &mon]); } if instance.width > 0 && instance.height > 0 { @@ -324,6 +330,7 @@ pub fn setup_together_seats( launch_id: &str, main_scope: Option<&str>, scoping: bool, + audio_sink_envs: &[String], ) -> (Vec, Vec>, Vec) { let n = instances.len(); // Total remote seats across all instances. Normally one per `together` @@ -369,6 +376,15 @@ pub fn setup_together_seats( continue; // local player — untouched } let node = node_name_for_instance(i); + // This instance's audio capture source. setup_audio_routing gives every + // together instance its own null sink (PULSE_SINK = that sink), so capture + // ".monitor"; all of this instance's seats share it (one game = one + // audio stream). Empty env → no per-instance sink, so spawn_seat_streamer + // falls back to the default-sink monitor. + let audio_monitor = audio_sink_envs + .get(i) + .filter(|s| !s.is_empty()) + .map(|s| format!("{s}.monitor")); for _ in 0..instance.together_seats { let seat = seat_id(seat_index); let name = if remote_count == 1 { @@ -381,6 +397,7 @@ pub fn setup_together_seats( match spawn_seat_streamer( cfg, &seat, &name, &token, instance, &node, launch_id, seat_index, main_scope, scoping, + audio_monitor.as_deref(), ) { Ok(child) => handles.push(child), Err(e) => {