waydriver_compositor_mutter/lib.rs
1//! Mutter implementation of [`waydriver::CompositorRuntime`].
2//!
3//! Owns the private-bus `dbus-daemon`, the `pipewire` + `wireplumber` pair,
4//! and a headless `mutter --wayland` instance. After [`MutterCompositor::start`]
5//! returns, [`MutterCompositor::state`] exposes an `Arc<MutterState>` that
6//! sibling backends (`waydriver-input-mutter`, `waydriver-capture-mutter`) use
7//! to talk to the same mutter D-Bus session.
8//!
9//! ## Shared-state invariant
10//!
11//! While any `Arc<MutterState>` exists, the mutter child processes and the
12//! private D-Bus connection MUST remain alive. [`waydriver::Session::kill`]
13//! enforces this by dropping input and capture trait objects before calling
14//! `compositor.stop().await`.
15
16mod error;
17
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::process::Stdio;
21use std::sync::{Arc, LazyLock, Mutex};
22
23use async_trait::async_trait;
24use tokio::process::{Child, Command};
25use zbus::zvariant::OwnedValue;
26
27use waydriver::gsettings::{self, GSettingEntry, GSettingsConfig};
28use waydriver::{CompositorRuntime, Result};
29
30use crate::error::MutterError;
31
32/// Default virtual-monitor geometry passed to mutter when the caller doesn't
33/// override it. Matches mutter's own implicit default.
34const DEFAULT_RESOLUTION: &str = "1024x768";
35
36/// Default logical-monitor scale: 1:1, i.e. `resolution` pixels are also the
37/// logical (application) size. Any other value drives the HiDPI path in
38/// [`apply_scale`].
39const DEFAULT_SCALE: f64 = 1.0;
40
41/// GVariant-text value seeded into `org.gnome.mutter experimental-features`
42/// when GSettings isolation is on. `scale-monitor-framebuffer` switches the
43/// native headless backend to logical layout mode, which is what makes
44/// fractional scales (1.5, 1.75, …) appear in a mode's `supported-scales` and
45/// be accepted by `ApplyMonitorsConfig`. Harmless at integer/1.0 scales.
46const MUTTER_FRACTIONAL_SCALING: &str = "['scale-monitor-framebuffer']";
47
48/// Accepted scale range. Below 0.5 the UI is unusably small; above 4.0 mutter
49/// won't offer the scale for any virtual-monitor mode we'd create. Validated
50/// up-front so a typo fails before we spawn any subprocess.
51const MIN_SCALE: f64 = 0.5;
52const MAX_SCALE: f64 = 4.0;
53
54/// How far a requested scale may sit from the nearest mutter-supported scale
55/// before we log a warning about snapping to it. Mutter only accepts scales it
56/// lists in a mode's `supported-scales`, so an exact arbitrary value (e.g.
57/// 1.66) may be nudged to the closest legal step.
58const SCALE_SNAP_TOLERANCE: f64 = 0.01;
59
60// ── DisplayConfig D-Bus shapes ───────────────────────────────────────────────
61//
62// Type aliases mirroring the `org.gnome.Mutter.DisplayConfig` wire types so
63// `body().deserialize::<CurrentState>()` validates the reply against the exact
64// signature mutter sends. The `a{sv}` property dicts are kept as
65// `HashMap<String, OwnedValue>` (signature `a{sv}`) and ignored — we only need
66// the connector, mode id, and supported-scales list.
67
68/// `a{sv}` — a D-Bus property dict.
69type DbusProps = HashMap<String, OwnedValue>;
70/// `(siiddada{sv})` — one monitor mode: id, width, height, refresh, preferred
71/// scale, supported scales, properties.
72type MonitorMode = (String, i32, i32, f64, f64, Vec<f64>, DbusProps);
73/// `(ssss)` — connector, vendor, product, serial.
74type MonitorSpec = (String, String, String, String);
75/// `((ssss)a(siiddada{sv})a{sv})` — one physical monitor: spec, modes, props.
76type PhysicalMonitor = (MonitorSpec, Vec<MonitorMode>, DbusProps);
77/// `(iiduba(ssss)a{sv})` — one logical monitor in the current layout.
78type LogicalMonitor = (i32, i32, f64, u32, bool, Vec<MonitorSpec>, DbusProps);
79/// Return tuple of `GetCurrentState`: `(serial, monitors, logical, props)`.
80type CurrentState = (u32, Vec<PhysicalMonitor>, Vec<LogicalMonitor>, DbusProps);
81
82/// `(ssa{sv})` — one monitor assignment in an `ApplyMonitorsConfig` request:
83/// connector, mode id, properties.
84type MonitorAssignment = (String, String, DbusProps);
85/// `(iiduba(ssa{sv}))` — one logical monitor to apply: x, y, scale, transform,
86/// primary, assigned monitors.
87type LogicalMonitorConfig = (i32, i32, f64, u32, bool, Vec<MonitorAssignment>);
88
89/// Shared mutter-backend state consumed by `waydriver-input-mutter` and
90/// `waydriver-capture-mutter`.
91///
92/// **Invariant:** while any `Arc<MutterState>` exists, the underlying D-Bus
93/// connection and the mutter child process must remain alive. See the
94/// module docs for details.
95///
96/// Fields are private — all access goes through the accessor methods
97/// below. Sibling crates (`waydriver-input-mutter`,
98/// `waydriver-capture-mutter`) that previously read fields directly
99/// now call `state.conn()`, `state.rd_session_path()`, etc. The
100/// shape of the underlying storage (e.g. how `active_stream_path` is
101/// guarded) is therefore an implementation detail that can change
102/// without breaking those callers — the contract lives entirely in
103/// the method signatures.
104pub struct MutterState {
105 conn: zbus::Connection,
106 rd_session_path: String,
107 rd_session_id: String,
108 rd_started: Arc<Mutex<bool>>,
109 runtime_dir: PathBuf,
110 active_stream_path: Arc<Mutex<Option<String>>>,
111}
112
113impl MutterState {
114 /// Persistent connection to mutter's private D-Bus.
115 ///
116 /// Both sibling backends (`waydriver-input-mutter`,
117 /// `waydriver-capture-mutter`) issue all their RemoteDesktop and
118 /// ScreenCast method calls through this connection.
119 pub fn conn(&self) -> &zbus::Connection {
120 &self.conn
121 }
122
123 /// RemoteDesktop session object path. Used by
124 /// `waydriver-input-mutter` as the `path` argument on every
125 /// pointer / keyboard `Notify*` D-Bus call.
126 pub fn rd_session_path(&self) -> &str {
127 &self.rd_session_path
128 }
129
130 /// RemoteDesktop session id, read from the `SessionId` property on
131 /// the RD session. `waydriver-capture-mutter` passes this as the
132 /// `remote-desktop-session-id` option to
133 /// `ScreenCast.CreateSession` so mutter links the two; the link is
134 /// required for `NotifyPointerMotionAbsolute` to be accepted.
135 pub fn rd_session_id(&self) -> &str {
136 &self.rd_session_id
137 }
138
139 /// Per-session `XDG_RUNTIME_DIR`. `waydriver-capture-mutter` joins
140 /// this with `pipewire-0` to locate the PipeWire socket.
141 pub fn runtime_dir(&self) -> &Path {
142 &self.runtime_dir
143 }
144
145 /// Lock the "RD-started" flag.
146 ///
147 /// Acquires the underlying mutex and returns the guard so the
148 /// caller can perform a check-and-set under one critical section
149 /// (the capture backend defers `RD.Session.Start` until the first
150 /// linked `ScreenCast.CreateSession` succeeds — that's a load,
151 /// some D-Bus work, and a store; splitting the read and write
152 /// would race). `Error::Process` if the mutex is poisoned.
153 pub fn rd_started_lock(&self) -> Result<std::sync::MutexGuard<'_, bool>> {
154 self.rd_started
155 .lock()
156 .map_err(|_| waydriver::Error::process("rd_started mutex poisoned"))
157 }
158
159 /// Lock the active ScreenCast Stream object path.
160 ///
161 /// Set by `waydriver-capture-mutter` in `start_stream`, cleared in
162 /// `stop_stream`. `waydriver-input-mutter` reads it to route
163 /// `NotifyPointerMotionAbsolute` at the correct monitor. `None`
164 /// inside the guard means no stream is open — absolute pointer
165 /// motion will error.
166 pub fn active_stream_path_lock(&self) -> Result<std::sync::MutexGuard<'_, Option<String>>> {
167 self.active_stream_path
168 .lock()
169 .map_err(|_| waydriver::Error::process("active_stream_path mutex poisoned"))
170 }
171}
172
173/// Headless mutter instance.
174pub struct MutterCompositor {
175 id: String,
176 wayland_display: String,
177 runtime_dir: PathBuf,
178 mutter_dbus_address: String,
179 /// The private session bus, run as a *managed* `dbus-daemon` child (rather
180 /// than `dbus-launch`, which daemonizes it out of our process tree). Kept
181 /// alive for the compositor's lifetime; killed on `stop()`/`Drop` and —
182 /// via [`set_pdeathsig`] — reaped by the kernel if the controlling process
183 /// is hard-killed, taking the D-Bus-activated `at-spi-bus-launcher` down
184 /// with it instead of orphaning a stale a11y bus.
185 dbus_daemon: Option<Child>,
186 mutter: Option<Child>,
187 pipewire: Option<Child>,
188 wireplumber: Option<Child>,
189 state: Option<Arc<MutterState>>,
190 gsettings: GSettingsConfig,
191}
192
193/// The host runtime root under which every session's `wd-session-<id>`
194/// directory is created. Snapshotted once, lazily, on the first
195/// `MutterCompositor::new()` call.
196///
197/// This is deliberately read **once** and cached, rather than re-read from
198/// `XDG_RUNTIME_DIR` per session. `waydriver`'s screenshot and video pipelines
199/// (`waydriver::capture`) mutate the parent process's `XDG_RUNTIME_DIR` to
200/// point `pipewiresrc` at the *live* session's pipewire socket, and never
201/// restore it. If `new()` re-read the live env each time, session N+1's
202/// runtime dir would be created **inside** session N's dir
203/// (`…/wd-session-A/wd-session-B/…`), nesting one level deeper per session.
204/// After ~4 levels the `<dir>/pipewire-0` path exceeds the ~107-byte AF_UNIX
205/// `sun_path` limit, pipewire can no longer bind its socket, and every
206/// subsequent `start_session` fails with a "timeout: pipewire socket" error
207/// until the server is restarted (which resets the process env). Snapshotting
208/// the root keeps each session dir a flat sibling under the original
209/// `XDG_RUNTIME_DIR`, independent of how many sessions preceded it.
210///
211/// The first `new()` runs before any session exists, so the env is still the
212/// pristine value set by the launcher (e.g. the Docker entrypoint) — capturing
213/// it then is safe.
214static HOST_RUNTIME_ROOT: LazyLock<PathBuf> = LazyLock::new(|| {
215 let root = std::env::var("XDG_RUNTIME_DIR")
216 .unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
217 PathBuf::from(root)
218});
219
220/// Eagerly capture [`HOST_RUNTIME_ROOT`] from the current `XDG_RUNTIME_DIR`
221/// and return it.
222///
223/// The snapshot is otherwise taken lazily on the first [`MutterCompositor::new`].
224/// Call this once at process startup — before any session is created and
225/// before anything can mutate `XDG_RUNTIME_DIR` — to pin the root to the
226/// pristine launcher value deterministically, rather than relying on `new()`
227/// happening first. Idempotent: subsequent calls (and `new()`) return the same
228/// captured value.
229pub fn establish_runtime_root() -> &'static std::path::Path {
230 HOST_RUNTIME_ROOT.as_path()
231}
232
233impl MutterCompositor {
234 /// Construct but do not start. Generates the session id and computes
235 /// where the Wayland socket and runtime dir will live. No I/O.
236 pub fn new() -> Self {
237 let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
238 let wayland_display = format!("wayland-wd-{}", id);
239
240 let runtime_dir = HOST_RUNTIME_ROOT.join(format!("wd-session-{}", id));
241
242 Self {
243 id,
244 wayland_display,
245 runtime_dir,
246 mutter_dbus_address: String::new(),
247 dbus_daemon: None,
248 mutter: None,
249 pipewire: None,
250 wireplumber: None,
251 state: None,
252 gsettings: GSettingsConfig::default(),
253 }
254 }
255
256 /// Set the per-session GSettings isolation config (see
257 /// [`waydriver::gsettings`]). Defaults to isolated with no seeded entries.
258 /// When isolated, [`start`](Self::start) writes a private keyfile (seeded
259 /// with `org.gnome.mutter experimental-features` plus `config.initial`)
260 /// and points mutter at it, so fractional scales work and the host's dconf
261 /// is neither read nor written. Pass `isolated: false` to run mutter
262 /// against the host's GSettings instead.
263 pub fn with_gsettings(mut self, config: GSettingsConfig) -> Self {
264 self.gsettings = config;
265 self
266 }
267
268 /// Returns the shared `Arc<MutterState>` for passing to sibling
269 /// backends, or `None` when called outside the started window.
270 ///
271 /// `None` is returned when:
272 /// - `start()` has not yet completed (or returned an error), or
273 /// - `stop()` has been called and dropped the state.
274 ///
275 /// Callers that have just awaited `start()?` know the state is
276 /// present — `expect()` or `?`-with-typed-error is appropriate
277 /// there. Returning `Option` instead of panicking keeps the API
278 /// honest about the lifecycle and lets callers detect "stopped"
279 /// without first matching on a panic.
280 pub fn state(&self) -> Option<Arc<MutterState>> {
281 self.state.clone()
282 }
283}
284
285impl Default for MutterCompositor {
286 fn default() -> Self {
287 Self::new()
288 }
289}
290
291impl MutterCompositor {
292 /// Typed-error implementation of `start`. The trait method calls
293 /// this and converts the result via `From<MutterError>`.
294 ///
295 /// Steps (each fails with a specific `MutterError` variant):
296 /// 1. validate resolution + scale,
297 /// 2. ensure the session runtime dir exists,
298 /// 3. spawn a private `dbus-daemon` and parse its address + PID,
299 /// 4. spawn `pipewire` + `wireplumber` on that bus,
300 /// 5. spawn headless `mutter --wayland`,
301 /// 6. wait for the Wayland socket,
302 /// 7. open a zbus connection, retry-create the RemoteDesktop session,
303 /// 8. read its `SessionId` property,
304 /// 9. apply a non-default logical-monitor scale via DisplayConfig,
305 /// 10. publish the `Arc<MutterState>` for sibling backends.
306 async fn start_inner(
307 &mut self,
308 resolution: Option<&str>,
309 scale: Option<f64>,
310 ) -> std::result::Result<(), MutterError> {
311 let resolution = resolution.unwrap_or(DEFAULT_RESOLUTION);
312 // Validate before we start spawning subprocesses — mutter silently
313 // ignores bad --virtual-monitor values and falls back to its own
314 // default, which would surprise the caller.
315 parse_resolution(resolution)?;
316 let scale = scale.unwrap_or(DEFAULT_SCALE);
317 // Fail fast on a nonsense scale too, for the same reason — the
318 // DisplayConfig apply that consumes it doesn't run until mutter is up.
319 validate_scale(scale)?;
320
321 tracing::info!(
322 id = self.id,
323 resolution,
324 scale,
325 isolated = self.gsettings.isolated,
326 "starting mutter compositor"
327 );
328
329 tokio::fs::create_dir_all(&self.runtime_dir).await?;
330 // `runtime_dir` is built in `new()` from a UTF-8 String
331 // (XDG_RUNTIME_DIR or `/run/user/<uid>`) joined with a UTF-8
332 // ASCII session id, so the path is guaranteed valid UTF-8.
333 // `expect` documents that invariant rather than re-deriving
334 // it via the `to_str()` `Option`.
335 let runtime_str = self
336 .runtime_dir
337 .to_str()
338 .expect("invariant: runtime_dir built from UTF-8 inputs in new()")
339 .to_string();
340
341 // GSettings isolation: when on, write the session's private keyfile
342 // (read by both mutter and the app — see `waydriver::gsettings`) and
343 // compute the env that points mutter at it. The keyfile is seeded with
344 // the fractional-scaling experimental feature so a non-integer `scale`
345 // is actually advertised by mutter, then any caller-supplied entries
346 // are appended (last-wins, so callers can override). When off, mutter
347 // reads the host's GSettings and `config_env` stays empty.
348 let config_env: Vec<(&str, String)> = if self.gsettings.isolated {
349 let mut entries = vec![GSettingEntry::new(
350 "org.gnome.mutter",
351 "experimental-features",
352 MUTTER_FRACTIONAL_SCALING,
353 )];
354 entries.extend(self.gsettings.initial.iter().cloned());
355 gsettings::write_keyfile(&self.runtime_dir, &entries)?;
356 let config_dir = gsettings::config_dir(&self.runtime_dir)
357 .to_str()
358 .expect("invariant: config_dir is runtime_dir (UTF-8) + ASCII suffix")
359 .to_string();
360 vec![
361 ("XDG_CONFIG_HOME", config_dir),
362 ("GSETTINGS_BACKEND", gsettings::KEYFILE_BACKEND.to_string()),
363 ]
364 } else {
365 Vec::new()
366 };
367
368 // Step 1: Private D-Bus for mutter (so its ScreenCast API doesn't
369 // conflict with host). Run `dbus-daemon` directly as a managed,
370 // PDEATHSIG-protected child instead of `dbus-launch`: dbus-launch
371 // daemonizes the bus out of our process tree, so a hard-killed
372 // controlling process would orphan it — and the `at-spi-bus-launcher`
373 // it D-Bus-activates, whose stale socket then GUID-mismatches every
374 // later run. As our own child, the kernel reaps it on hard-kill.
375 // Pick the bus socket ourselves (under the per-session runtime dir,
376 // alongside the wayland/pipewire sockets) and pass it via `--address`,
377 // rather than parsing the daemon's stdout for `--print-address`: reading
378 // stdout is fragile across distros/containers (an early stderr-only
379 // failure reads back as an empty address), and a chosen path lets us use
380 // the same socket-appears readiness poll as wayland/pipewire.
381 let bus_socket = self.runtime_dir.join("bus");
382 let address = format!("unix:path={}", bus_socket.display());
383 let mut dbus_cmd = Command::new("dbus-daemon");
384 dbus_cmd
385 .args(["--session", "--nofork", "--nopidfile"])
386 .arg(format!("--address={address}"))
387 .stdout(Stdio::null())
388 .stderr(Stdio::null())
389 .kill_on_drop(true);
390 set_pdeathsig(&mut dbus_cmd);
391 let dbus_daemon = dbus_cmd.spawn().map_err(|source| MutterError::Spawn {
392 process: "dbus-daemon",
393 source,
394 })?;
395 self.dbus_daemon = Some(dbus_daemon);
396 wait_for_dbus_socket(&bus_socket).await?;
397 self.mutter_dbus_address = address;
398 tracing::debug!(id = self.id, mutter_dbus_address = %self.mutter_dbus_address, "private D-Bus for mutter");
399
400 // Step 2: PipeWire + WirePlumber (for screenshots via ScreenCast).
401 //
402 // `env_remove("PIPEWIRE_REMOTE")` is load-bearing: `waydriver`'s
403 // `grab_png_sync` mutates the parent's process env to point
404 // `pipewiresrc` at the live session's pipewire socket. After a
405 // session stops, that socket is gone but the env var lingers in
406 // the parent. Without scrubbing it here, a freshly spawned
407 // `pipewire`/`wireplumber`/`mutter` for the next session would
408 // inherit the stale value and try to connect to the previous
409 // session's dead socket — wireplumber/mutter prefer
410 // `PIPEWIRE_REMOTE` over `XDG_RUNTIME_DIR/pipewire-0`, so the
411 // explicit `XDG_RUNTIME_DIR` override below isn't enough.
412 // Symptom: `ScreenCast.Start` fails with "Couldn't connect
413 // pipewire context" on every session after the first.
414 let mut pipewire_cmd = Command::new("pipewire");
415 pipewire_cmd
416 .env_remove("PIPEWIRE_REMOTE")
417 .env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
418 .env("XDG_RUNTIME_DIR", &runtime_str)
419 .stdout(Stdio::null())
420 .stderr(Stdio::null());
421 set_pdeathsig(&mut pipewire_cmd);
422 let pipewire = pipewire_cmd.spawn().map_err(|source| MutterError::Spawn {
423 process: "pipewire",
424 source,
425 })?;
426 self.pipewire = Some(pipewire);
427
428 // Wait for pipewire's socket to appear before launching
429 // wireplumber. Polling for the socket file is the same
430 // readiness signal `wait_for_wayland_socket` uses for
431 // mutter: it's the actual handshake clients use, so any
432 // earlier signal would either be racier (process spawn) or
433 // just as expensive to probe.
434 wait_for_pipewire_socket(&runtime_str).await?;
435
436 let mut wireplumber_cmd = Command::new("wireplumber");
437 wireplumber_cmd
438 .env_remove("PIPEWIRE_REMOTE")
439 .env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
440 .env("XDG_RUNTIME_DIR", &runtime_str)
441 .stdout(Stdio::null())
442 .stderr(Stdio::null());
443 set_pdeathsig(&mut wireplumber_cmd);
444 let wireplumber = wireplumber_cmd
445 .spawn()
446 .map_err(|source| MutterError::Spawn {
447 process: "wireplumber",
448 source,
449 })?;
450 self.wireplumber = Some(wireplumber);
451
452 // No bus-readiness signal poll for wireplumber: it's a
453 // session-policy daemon that doesn't register a stable D-Bus
454 // name we can probe, and its initialisation runs in parallel
455 // with mutter's own startup. The downstream
456 // `ScreenCast.CreateSession` retry loop in
457 // `waydriver-capture-mutter::start_stream` is what actually
458 // gates on wireplumber having joined the graph — putting a
459 // pessimistic sleep here as well would add startup latency
460 // without changing correctness.
461 tracing::debug!(id = self.id, "PipeWire + WirePlumber started");
462
463 // Step 3: mutter in headless Wayland mode (on its private D-Bus).
464 let mut mutter_cmd = Command::new("mutter");
465 mutter_cmd
466 .args([
467 "--headless",
468 "--wayland",
469 "--no-x11",
470 "--wayland-display",
471 &self.wayland_display,
472 "--virtual-monitor",
473 resolution,
474 ])
475 .env_remove("PIPEWIRE_REMOTE")
476 .env("DBUS_SESSION_BUS_ADDRESS", &self.mutter_dbus_address)
477 .env("XDG_RUNTIME_DIR", &runtime_str)
478 // Empty when isolation is off; otherwise points mutter at the
479 // per-session keyfile GSettings store written above.
480 .envs(config_env.iter().map(|(k, v)| (*k, v.as_str())))
481 .stdout(Stdio::null())
482 .stderr(Stdio::inherit());
483 set_pdeathsig(&mut mutter_cmd);
484 let mutter = mutter_cmd.spawn().map_err(|source| MutterError::Spawn {
485 process: "mutter",
486 source,
487 })?;
488 self.mutter = Some(mutter);
489 tracing::debug!(id = self.id, wayland_display = %self.wayland_display, "mutter spawned");
490
491 // Step 4: Wait for the Wayland socket.
492 wait_for_wayland_socket(&runtime_str, &self.wayland_display).await?;
493 tracing::debug!(id = self.id, "wayland socket ready");
494
495 // Step 5: Connect to mutter's private D-Bus and start RemoteDesktop session.
496 let mutter_addr: zbus::address::Address = self
497 .mutter_dbus_address
498 .as_str()
499 .try_into()
500 .map_err(|source: zbus::Error| MutterError::DbusAddressInvalid {
501 addr: self.mutter_dbus_address.clone(),
502 source,
503 })?;
504 let mutter_conn = zbus::connection::Builder::address(mutter_addr)
505 .map_err(|source| MutterError::DbusConnect {
506 stage: "build connection builder",
507 source,
508 })?
509 .build()
510 .await
511 .map_err(|source| MutterError::DbusConnect {
512 stage: "connect",
513 source,
514 })?;
515
516 // Wait for mutter to register its D-Bus services (may take a moment after socket appears)
517 let mut rd_reply = None;
518 for i in 0..50 {
519 match mutter_conn
520 .call_method(
521 Some("org.gnome.Mutter.RemoteDesktop"),
522 "/org/gnome/Mutter/RemoteDesktop",
523 Some("org.gnome.Mutter.RemoteDesktop"),
524 "CreateSession",
525 &(),
526 )
527 .await
528 {
529 Ok(reply) => {
530 rd_reply = Some(reply);
531 break;
532 }
533 Err(e) if i < 49 => {
534 tracing::debug!(
535 id = self.id,
536 attempt = i,
537 "waiting for RemoteDesktop service: {e}"
538 );
539 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
540 }
541 Err(e) => {
542 return Err(MutterError::RemoteDesktopCreate(e));
543 }
544 }
545 }
546 // The retry loop above either `break`s with `rd_reply = Some(_)`
547 // or returns `Err(...)` from the final attempt — `unwrap` here
548 // is unreachable by construction.
549 let rd_reply = rd_reply.expect("retry loop sets Some on break or returns Err");
550 let rd_session_path: zbus::zvariant::OwnedObjectPath = rd_reply
551 .body()
552 .deserialize()
553 .map_err(MutterError::RdSessionPathParse)?;
554 // Intentionally do NOT call `RemoteDesktop.Session.Start` here.
555 // Mutter only accepts `remote-desktop-session-id` on
556 // `ScreenCast.CreateSession` when the RD session is not yet
557 // started, so `waydriver-capture-mutter::start_stream` defers
558 // the Start call until after it has created the linked
559 // ScreenCast session.
560 // Read the RD session's `SessionId` property — it's the token
561 // ScreenCast.CreateSession needs in `remote-desktop-session-id`
562 // to link the two sessions. Without that link, mutter rejects
563 // NotifyPointerMotionAbsolute with "No screen cast active".
564 let rd_session_id_reply = mutter_conn
565 .call_method(
566 Some("org.gnome.Mutter.RemoteDesktop"),
567 rd_session_path.as_str(),
568 Some("org.freedesktop.DBus.Properties"),
569 "Get",
570 &("org.gnome.Mutter.RemoteDesktop.Session", "SessionId"),
571 )
572 .await
573 .map_err(MutterError::SessionIdGet)?;
574 // `Get` returns a variant; deserialize as `OwnedValue` to detach
575 // the string from the reply's body before the reply is dropped.
576 let rd_session_id_body = rd_session_id_reply.body();
577 let rd_session_id_variant: zbus::zvariant::OwnedValue = rd_session_id_body
578 .deserialize()
579 .map_err(MutterError::SessionIdVariantParse)?;
580 let rd_session_id: String = rd_session_id_variant
581 .try_into()
582 .map_err(MutterError::SessionIdNotString)?;
583
584 let rd_session_path = rd_session_path.to_string();
585 tracing::debug!(
586 id = self.id,
587 rd_session_path = %rd_session_path,
588 rd_session_id = %rd_session_id,
589 "RemoteDesktop session started"
590 );
591
592 // Step: apply a non-default logical-monitor scale. `--virtual-monitor`
593 // has no scale component, so HiDPI is configured here over mutter's
594 // private bus once DisplayConfig is up. Skipped at 1.0 to leave the
595 // default 1:1 path completely untouched.
596 if (scale - DEFAULT_SCALE).abs() > f64::EPSILON {
597 let applied = apply_scale(&mutter_conn, scale, &self.id).await?;
598 tracing::info!(
599 id = self.id,
600 requested = scale,
601 applied,
602 "applied logical-monitor scale"
603 );
604 }
605
606 self.state = Some(Arc::new(MutterState {
607 conn: mutter_conn,
608 rd_session_path,
609 rd_session_id,
610 rd_started: Arc::new(Mutex::new(false)),
611 runtime_dir: self.runtime_dir.clone(),
612 active_stream_path: Arc::new(Mutex::new(None)),
613 }));
614
615 Ok(())
616 }
617}
618
619#[async_trait]
620impl CompositorRuntime for MutterCompositor {
621 async fn start(&mut self, resolution: Option<&str>, scale: Option<f64>) -> Result<()> {
622 // Body uses the crate-local typed `MutterError`. The `?` at the
623 // end of `self.start_inner(...).await?` runs the
624 // `From<MutterError> for waydriver::Error` impl in `error.rs`,
625 // which is the single boundary at which the typed enum becomes
626 // the workspace's shared `waydriver::Error`.
627 Ok(self.start_inner(resolution, scale).await?)
628 }
629
630 async fn stop(&mut self) -> Result<()> {
631 tracing::info!(id = self.id, "stopping mutter compositor");
632
633 // Stop RemoteDesktop session if still reachable. We could
634 // touch the private fields directly here (same crate), but
635 // routing through the public accessors keeps the contract
636 // visible and means a future change to the field layout
637 // doesn't need to update this site.
638 if let Some(state) = &self.state {
639 let _ = state
640 .conn()
641 .call_method(
642 Some("org.gnome.Mutter.RemoteDesktop"),
643 state.rd_session_path(),
644 Some("org.gnome.Mutter.RemoteDesktop.Session"),
645 "Stop",
646 &(),
647 )
648 .await;
649 }
650
651 // Drop our strong ref to the shared state. If callers haven't dropped
652 // theirs (the input/capture trait objects), their Arc still points at
653 // the D-Bus connection we're about to tear down below — any method
654 // call on them after this will fail with "connection closed".
655 self.state = None;
656
657 if let Some(mut mutter) = self.mutter.take() {
658 let _ = mutter.kill().await;
659 let _ = mutter.wait().await;
660 }
661 if let Some(mut wireplumber) = self.wireplumber.take() {
662 let _ = wireplumber.kill().await;
663 let _ = wireplumber.wait().await;
664 }
665 if let Some(mut pipewire) = self.pipewire.take() {
666 let _ = pipewire.kill().await;
667 let _ = pipewire.wait().await;
668 }
669
670 // Kill the private bus last: its death drops the a11y bus connection,
671 // so the D-Bus-activated `at-spi-bus-launcher` exits with it.
672 if let Some(mut dbus) = self.dbus_daemon.take() {
673 let _ = dbus.kill().await;
674 let _ = dbus.wait().await;
675 }
676
677 let _ = tokio::fs::remove_dir_all(&self.runtime_dir).await;
678
679 tracing::debug!(id = self.id, "mutter compositor stopped");
680 Ok(())
681 }
682
683 fn id(&self) -> &str {
684 &self.id
685 }
686
687 fn wayland_display(&self) -> &str {
688 &self.wayland_display
689 }
690
691 fn runtime_dir(&self) -> &Path {
692 &self.runtime_dir
693 }
694}
695
696impl Drop for MutterCompositor {
697 fn drop(&mut self) {
698 // Best-effort cleanup when dropped without calling stop().
699 // Can't use async here, so send SIGKILL synchronously.
700 self.state = None;
701
702 if let Some(ref mut child) = self.mutter {
703 let _ = child.start_kill();
704 }
705 if let Some(ref mut child) = self.wireplumber {
706 let _ = child.start_kill();
707 }
708 if let Some(ref mut child) = self.pipewire {
709 let _ = child.start_kill();
710 }
711 if let Some(ref mut child) = self.dbus_daemon {
712 let _ = child.start_kill();
713 }
714 let _ = std::fs::remove_dir_all(&self.runtime_dir);
715 }
716}
717
718// ── Helpers ─────────────────────────────────────────────────────────────────
719
720/// Linux parent-death protection for a spawned session daemon: ask the kernel
721/// to `SIGKILL` the child the instant the spawning thread dies.
722///
723/// Drop/`stop()`-based teardown can't run when the *controlling* process is
724/// itself hard-killed (`SIGKILL`, `panic = "abort"`, OOM, a CI/test-runner
725/// timeout). Without this, such a death orphans the whole session quartet —
726/// `dbus-daemon`, `pipewire`, `wireplumber`, `mutter` — and, worst of all, the
727/// D-Bus-activated `at-spi-bus-launcher`, whose stale a11y-bus socket then
728/// GUID-mismatches every later run. `PR_SET_PDEATHSIG` closes that hole at the
729/// kernel level. The `getppid` check covers the race where the parent dies
730/// between fork and exec (the child would otherwise miss the death signal).
731fn set_pdeathsig(cmd: &mut Command) {
732 // Capture our PID now (in the parent). The child compares it against its
733 // own parent after fork: a mismatch means we already died and it was
734 // reparented (to init, or a subreaper), so it should bail. We must NOT
735 // hard-code "getppid() == 1 → orphaned": in a container the controlling
736 // process often *is* PID 1, so every legitimately-parented child sees
737 // getppid() == 1 and would be killed at exec.
738 let parent = std::process::id();
739 // SAFETY: the closure runs in the forked child before exec and calls only
740 // async-signal-safe libc functions (prctl, getppid, _exit).
741 unsafe {
742 cmd.pre_exec(move || {
743 if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
744 return Err(std::io::Error::last_os_error());
745 }
746 if libc::getppid() != parent as i32 {
747 libc::_exit(0);
748 }
749 Ok(())
750 });
751 }
752}
753
754fn parse_resolution(s: &str) -> std::result::Result<(u32, u32), MutterError> {
755 let invalid = || MutterError::ResolutionInvalid {
756 value: s.to_string(),
757 };
758 let (w, h) = s.split_once('x').ok_or_else(invalid)?;
759 let parse = |part: &str| -> std::result::Result<u32, MutterError> {
760 part.parse::<u32>()
761 .ok()
762 .filter(|n| *n > 0)
763 .ok_or_else(invalid)
764 };
765 Ok((parse(w)?, parse(h)?))
766}
767
768/// Reject a scale that isn't a finite, positive factor inside
769/// [`MIN_SCALE`]..=[`MAX_SCALE`]. Run before any subprocess spawns so a bad
770/// value fails fast.
771fn validate_scale(scale: f64) -> std::result::Result<(), MutterError> {
772 if scale.is_finite() && (MIN_SCALE..=MAX_SCALE).contains(&scale) {
773 Ok(())
774 } else {
775 Err(MutterError::ScaleInvalid {
776 value: scale,
777 min: MIN_SCALE,
778 max: MAX_SCALE,
779 })
780 }
781}
782
783/// Pick the entry of `supported` closest to `requested`. Mutter only accepts
784/// scales it advertises for a mode, so an arbitrary request (e.g. 1.66) is
785/// snapped to the nearest legal step. Falls back to `requested` when the list
786/// is empty (mutter then validates — and likely rejects — it).
787fn nearest_supported_scale(requested: f64, supported: &[f64]) -> f64 {
788 supported
789 .iter()
790 .copied()
791 .min_by(|a, b| {
792 let da = (a - requested).abs();
793 let db = (b - requested).abs();
794 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
795 })
796 .unwrap_or(requested)
797}
798
799/// Apply `requested` as the logical-monitor scale of the (single) virtual
800/// monitor via `org.gnome.Mutter.DisplayConfig`, returning the scale mutter
801/// actually accepted (snapped to a supported step).
802///
803/// Reads `GetCurrentState` fresh each call so the `serial` is current —
804/// mutter rejects `ApplyMonitorsConfig` on a stale serial, and the serial
805/// bumps when the virtual monitor first appears. Fractional scales (1.5,
806/// 1.75, …) require the `scale-monitor-framebuffer` experimental feature; the
807/// container entrypoint enables it. Without it only integer scales are
808/// advertised, so [`nearest_supported_scale`] would snap a fractional request
809/// to 1.0 or 2.0.
810async fn apply_scale(
811 conn: &zbus::Connection,
812 requested: f64,
813 id: &str,
814) -> std::result::Result<f64, MutterError> {
815 let state_reply = conn
816 .call_method(
817 Some("org.gnome.Mutter.DisplayConfig"),
818 "/org/gnome/Mutter/DisplayConfig",
819 Some("org.gnome.Mutter.DisplayConfig"),
820 "GetCurrentState",
821 &(),
822 )
823 .await
824 .map_err(|source| MutterError::DisplayConfigState {
825 stage: "call",
826 source,
827 })?;
828 let state_body = state_reply.body();
829 let (serial, monitors, _logical, _props): CurrentState =
830 state_body
831 .deserialize()
832 .map_err(|source| MutterError::DisplayConfigState {
833 stage: "deserialize",
834 source,
835 })?;
836
837 // Headless mutter started with a single `--virtual-monitor` exposes
838 // exactly one monitor advertising exactly the mode we asked for, so the
839 // first monitor / first mode is the one to scale.
840 let (spec, modes, _mprops) = monitors
841 .into_iter()
842 .next()
843 .ok_or(MutterError::DisplayConfigNoMonitor)?;
844 let connector = spec.0;
845 let (mode_id, _w, _h, _refresh, _preferred, supported, _modeprops) =
846 modes
847 .into_iter()
848 .next()
849 .ok_or(MutterError::DisplayConfigNoMonitor)?;
850
851 let applied = nearest_supported_scale(requested, &supported);
852 if (applied - requested).abs() > SCALE_SNAP_TOLERANCE {
853 tracing::warn!(
854 id,
855 requested,
856 applied,
857 supported = ?supported,
858 "requested scale not advertised by mutter; snapped to nearest supported"
859 );
860 }
861
862 // (x, y, scale, transform, primary, [(connector, mode_id, {})]).
863 let logical: LogicalMonitorConfig = (
864 0,
865 0,
866 applied,
867 0,
868 true,
869 vec![(connector, mode_id, DbusProps::new())],
870 );
871 // method 1 = temporary: applies for this session without writing
872 // ~/.config/monitors.xml, which is all a throwaway headless run needs.
873 conn.call_method(
874 Some("org.gnome.Mutter.DisplayConfig"),
875 "/org/gnome/Mutter/DisplayConfig",
876 Some("org.gnome.Mutter.DisplayConfig"),
877 "ApplyMonitorsConfig",
878 &(serial, 1u32, vec![logical], DbusProps::new()),
879 )
880 .await
881 .map_err(|source| MutterError::DisplayConfigApply {
882 scale: applied,
883 source,
884 })?;
885
886 Ok(applied)
887}
888
889async fn wait_for_wayland_socket(
890 runtime_dir: &str,
891 display: &str,
892) -> std::result::Result<(), MutterError> {
893 let socket_path = PathBuf::from(runtime_dir).join(display);
894 for _ in 0..50 {
895 if socket_path.exists() {
896 return Ok(());
897 }
898 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
899 }
900 Err(MutterError::WaylandSocketTimeout {
901 socket: socket_path.display().to_string(),
902 })
903}
904
905/// PipeWire creates `<runtime_dir>/pipewire-0` as soon as it's ready
906/// to accept client connections. Polling for that file replaces the
907/// previous unconditional `sleep(1s)` after spawning the pipewire
908/// process — same readiness model as
909/// [`wait_for_wayland_socket`].
910async fn wait_for_pipewire_socket(runtime_dir: &str) -> std::result::Result<(), MutterError> {
911 let socket_path = PathBuf::from(runtime_dir).join("pipewire-0");
912 for _ in 0..50 {
913 if socket_path.exists() {
914 return Ok(());
915 }
916 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
917 }
918 Err(MutterError::PipewireSocketTimeout {
919 socket: socket_path.display().to_string(),
920 })
921}
922
923/// Poll for the managed `dbus-daemon`'s listen socket to appear before any
924/// client connects to it — the same readiness signal used for the wayland and
925/// pipewire sockets. A timeout means the daemon failed to bind (bad config,
926/// path too long for `AF_UNIX`, missing binary).
927async fn wait_for_dbus_socket(socket: &Path) -> std::result::Result<(), MutterError> {
928 for _ in 0..50 {
929 if socket.exists() {
930 return Ok(());
931 }
932 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
933 }
934 Err(MutterError::DbusLaunchFailed(format!(
935 "dbus-daemon socket never appeared at {}",
936 socket.display()
937 )))
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943
944 /// Proves [`set_pdeathsig`]'s mechanism: a child spawned with
945 /// `PR_SET_PDEATHSIG = SIGKILL` is reaped by the kernel when its parent is
946 /// hard-killed (bypassing all Drop/teardown) — the exact protection that
947 /// stops a SIGKILL'd run from orphaning the session daemons + the
948 /// `at-spi-bus-launcher`.
949 ///
950 /// Re-execs the test binary as a *helper*: the helper spawns `sleep 300`
951 /// with the same `pre_exec` as `set_pdeathsig`, writes its PID to a shared
952 /// pidfile, then blocks. The supervisor reads the PID, `SIGKILL`s the helper
953 /// (so no Drop runs), and asserts the kernel reaps the `sleep`.
954 #[test]
955 #[ignore = "spawns and SIGKILLs a subprocess; run manually with --ignored"]
956 fn pdeathsig_reaps_orphaned_child() {
957 use std::os::unix::process::CommandExt;
958 use std::process::Command as StdCommand;
959
960 // The supervisor passes the pidfile path; its presence selects the role.
961 if let Ok(pidfile) = std::env::var("WD_PDEATHSIG_PIDFILE") {
962 // Helper role: spawn `sleep` protected by PR_SET_PDEATHSIG.
963 let mut sleep = StdCommand::new("sleep");
964 sleep.arg("300");
965 let parent = std::process::id();
966 // SAFETY: only async-signal-safe libc calls before exec.
967 unsafe {
968 sleep.pre_exec(move || {
969 if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
970 return Err(std::io::Error::last_os_error());
971 }
972 if libc::getppid() != parent as i32 {
973 libc::_exit(0);
974 }
975 Ok(())
976 });
977 }
978 let mut child = sleep.spawn().expect("spawn sleep");
979 // Write+rename so the supervisor never reads a half-written pid.
980 let tmp = format!("{pidfile}.tmp");
981 std::fs::write(&tmp, child.id().to_string()).unwrap();
982 std::fs::rename(&tmp, &pidfile).unwrap();
983 // Block on the sleep until the supervisor SIGKILLs us (also
984 // satisfies clippy::zombie_processes — the child is waited on).
985 let _ = child.wait();
986 return;
987 }
988
989 // Supervisor role: re-exec self as the helper.
990 let dir = tempfile::tempdir().unwrap();
991 let pidfile = dir.path().join("sleep.pid");
992 let exe = std::env::current_exe().unwrap();
993 let mut helper = StdCommand::new(exe)
994 .args([
995 "--exact",
996 "tests::pdeathsig_reaps_orphaned_child",
997 "--ignored",
998 ])
999 .env("WD_PDEATHSIG_PIDFILE", &pidfile)
1000 .spawn()
1001 .expect("spawn helper");
1002
1003 // Wait for the helper to publish the sleep PID.
1004 let mut sleep_pid = None;
1005 for _ in 0..100 {
1006 if let Ok(s) = std::fs::read_to_string(&pidfile) {
1007 if let Ok(pid) = s.trim().parse::<i32>() {
1008 sleep_pid = Some(pid);
1009 break;
1010 }
1011 }
1012 std::thread::sleep(std::time::Duration::from_millis(100));
1013 }
1014 let sleep_pid = sleep_pid.expect("helper never published the sleep PID");
1015
1016 // The sleep is alive while the helper lives.
1017 assert_eq!(
1018 unsafe { libc::kill(sleep_pid, 0) },
1019 0,
1020 "sleep ({sleep_pid}) should be running before the helper is killed"
1021 );
1022
1023 // Hard-kill the helper — no Drop, no teardown.
1024 unsafe {
1025 libc::kill(helper.id() as i32, libc::SIGKILL);
1026 }
1027 let _ = helper.wait();
1028
1029 // The kernel must SIGKILL the orphaned sleep; init reaps the zombie.
1030 let mut reaped = false;
1031 for _ in 0..50 {
1032 if unsafe { libc::kill(sleep_pid, 0) } != 0 {
1033 reaped = true;
1034 break;
1035 }
1036 std::thread::sleep(std::time::Duration::from_millis(100));
1037 }
1038 if !reaped {
1039 unsafe { libc::kill(sleep_pid, libc::SIGKILL) };
1040 }
1041 assert!(
1042 reaped,
1043 "PR_SET_PDEATHSIG did not reap the orphaned child after its parent was SIGKILLed"
1044 );
1045 }
1046
1047 /// Live end-to-end check that a fractional scale is actually applied by a
1048 /// real mutter. Requires the runtime stack (mutter, pipewire, wireplumber,
1049 /// dbus-launch) on `PATH` plus the GSettings schemas in `XDG_DATA_DIRS`, so
1050 /// it's `#[ignore]`d by default; run with the dev shell's env via
1051 /// `cargo test -p waydriver-compositor-mutter -- --ignored`.
1052 ///
1053 /// This exercises the whole chain at once: the per-session keyfile must
1054 /// enable `scale-monitor-framebuffer` (otherwise 1.5 is not advertised and
1055 /// would snap to 1.0/2.0), and `apply_scale` must drive DisplayConfig
1056 /// correctly. We read the scale straight back from `GetCurrentState`.
1057 #[tokio::test]
1058 #[ignore = "requires a live mutter/pipewire/dbus runtime stack"]
1059 async fn applies_fractional_scale_against_real_mutter() {
1060 let mut compositor = MutterCompositor::new();
1061 compositor
1062 .start(Some("1920x1080"), Some(1.5))
1063 .await
1064 .expect("compositor should start");
1065 let state = compositor.state().expect("state present after start");
1066
1067 let reply = state
1068 .conn()
1069 .call_method(
1070 Some("org.gnome.Mutter.DisplayConfig"),
1071 "/org/gnome/Mutter/DisplayConfig",
1072 Some("org.gnome.Mutter.DisplayConfig"),
1073 "GetCurrentState",
1074 &(),
1075 )
1076 .await
1077 .expect("GetCurrentState should succeed");
1078 let body = reply.body();
1079 let (_serial, _monitors, logical, _props): CurrentState = body
1080 .deserialize()
1081 .expect("GetCurrentState body should deserialize");
1082 let applied = logical.first().expect("at least one logical monitor").2;
1083
1084 compositor.stop().await.expect("compositor should stop");
1085
1086 assert!(
1087 (applied - 1.5).abs() < 0.01,
1088 "expected logical scale ~1.5 (fractional scaling enabled via keyfile), got {applied}"
1089 );
1090 }
1091
1092 #[tokio::test]
1093 async fn test_wait_for_socket_found() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let runtime_dir = dir.path().to_str().unwrap().to_string();
1096 let display = "wayland-test-99";
1097 std::fs::File::create(dir.path().join(display)).unwrap();
1098 wait_for_wayland_socket(&runtime_dir, display)
1099 .await
1100 .unwrap();
1101 }
1102
1103 #[tokio::test]
1104 async fn test_wait_for_pipewire_socket_found() {
1105 let dir = tempfile::tempdir().unwrap();
1106 let runtime_dir = dir.path().to_str().unwrap().to_string();
1107 std::fs::File::create(dir.path().join("pipewire-0")).unwrap();
1108 wait_for_pipewire_socket(&runtime_dir).await.unwrap();
1109 }
1110
1111 #[tokio::test]
1112 async fn test_wait_for_pipewire_socket_timeout() {
1113 let dir = tempfile::tempdir().unwrap();
1114 let runtime_dir = dir.path().to_str().unwrap().to_string();
1115 let err = wait_for_pipewire_socket(&runtime_dir).await.unwrap_err();
1116 assert!(
1117 matches!(err, MutterError::PipewireSocketTimeout { .. }),
1118 "expected PipewireSocketTimeout, got: {err}"
1119 );
1120 // Public mapping: same Timeout bucket as the wayland one,
1121 // so workspace callers matching `Error::Timeout(_)` (e.g.
1122 // the e2e tests) keep working.
1123 let public: waydriver::Error = err.into();
1124 assert!(
1125 matches!(public, waydriver::Error::Timeout(_)),
1126 "expected waydriver::Error::Timeout, got: {public}"
1127 );
1128 }
1129
1130 #[tokio::test]
1131 async fn test_wait_for_socket_timeout() {
1132 let dir = tempfile::tempdir().unwrap();
1133 let runtime_dir = dir.path().to_str().unwrap().to_string();
1134 let display = "wayland-nonexistent-0";
1135 let err = wait_for_wayland_socket(&runtime_dir, display)
1136 .await
1137 .unwrap_err();
1138 assert!(
1139 matches!(err, MutterError::WaylandSocketTimeout { .. }),
1140 "expected WaylandSocketTimeout, got: {err}"
1141 );
1142 // And confirm the From<MutterError> -> waydriver::Error mapping
1143 // still produces the public Timeout variant — workspace callers
1144 // (notably the e2e tests) match on it.
1145 let public: waydriver::Error = err.into();
1146 assert!(
1147 matches!(public, waydriver::Error::Timeout(_)),
1148 "expected waydriver::Error::Timeout, got: {public}"
1149 );
1150 }
1151
1152 #[test]
1153 fn test_new_generates_unique_ids() {
1154 let a = MutterCompositor::new();
1155 let b = MutterCompositor::new();
1156 assert_ne!(a.id(), b.id());
1157 }
1158
1159 #[test]
1160 fn test_new_wayland_display_contains_id() {
1161 let c = MutterCompositor::new();
1162 assert!(
1163 c.wayland_display().contains(c.id()),
1164 "display '{}' should contain id '{}'",
1165 c.wayland_display(),
1166 c.id()
1167 );
1168 }
1169
1170 #[test]
1171 fn test_new_runtime_dir_contains_id() {
1172 let c = MutterCompositor::new();
1173 let dir_str = c.runtime_dir().to_str().unwrap();
1174 assert!(
1175 dir_str.contains(c.id()),
1176 "runtime_dir '{}' should contain id '{}'",
1177 dir_str,
1178 c.id()
1179 );
1180 }
1181
1182 /// Regression: session runtime dirs must be flat siblings under one root,
1183 /// never nested inside each other. `waydriver::capture` repoints the
1184 /// process-wide `XDG_RUNTIME_DIR` at the live session's dir after a
1185 /// screenshot/recording; if `new()` re-read that mutated value, each
1186 /// session would nest one level deeper and eventually overflow the
1187 /// AF_UNIX `sun_path` limit, wedging pipewire socket creation. See
1188 /// `HOST_RUNTIME_ROOT`.
1189 #[test]
1190 fn test_session_runtime_dirs_are_siblings_not_nested() {
1191 let a = MutterCompositor::new();
1192 let dir_a = a.runtime_dir().to_path_buf();
1193
1194 // Simulate what a screenshot/recording does: point XDG_RUNTIME_DIR at
1195 // the live session's runtime dir and leave it there.
1196 unsafe {
1197 std::env::set_var("XDG_RUNTIME_DIR", &dir_a);
1198 }
1199
1200 let b = MutterCompositor::new();
1201 let dir_b = b.runtime_dir().to_path_buf();
1202
1203 assert_eq!(
1204 dir_a.parent(),
1205 dir_b.parent(),
1206 "session dirs must share a parent (siblings), got a={dir_a:?} b={dir_b:?}"
1207 );
1208 assert!(
1209 !dir_b.starts_with(&dir_a),
1210 "session B nested inside session A: {dir_b:?}"
1211 );
1212 }
1213
1214 #[test]
1215 fn test_new_wayland_display_prefix() {
1216 let c = MutterCompositor::new();
1217 assert!(c.wayland_display().starts_with("wayland-wd-"));
1218 }
1219
1220 #[test]
1221 fn test_new_runtime_dir_contains_session_prefix() {
1222 let c = MutterCompositor::new();
1223 let dir_str = c.runtime_dir().to_str().unwrap();
1224 assert!(dir_str.contains("wd-session-"));
1225 }
1226
1227 #[test]
1228 fn test_state_returns_none_before_start() {
1229 // `state()` previously panicked when called outside the started
1230 // window. The current contract returns `None` so callers can
1231 // detect the lifecycle without trapping a panic.
1232 let c = MutterCompositor::new();
1233 assert!(c.state().is_none());
1234 }
1235
1236 #[test]
1237 fn test_parse_resolution_accepts_hd() {
1238 assert_eq!(parse_resolution("1920x1080").unwrap(), (1920, 1080));
1239 assert_eq!(parse_resolution("1024x768").unwrap(), (1024, 768));
1240 }
1241
1242 #[test]
1243 fn test_parse_resolution_rejects_garbage() {
1244 for bad in [
1245 "",
1246 "1920",
1247 "1920x",
1248 "x1080",
1249 "0x0",
1250 "1920x0",
1251 "0x1080",
1252 "1920x1080x1",
1253 "abcxdef",
1254 "-1x1080",
1255 "1920 x 1080",
1256 ] {
1257 assert!(parse_resolution(bad).is_err(), "expected error for {bad:?}");
1258 }
1259 }
1260
1261 #[test]
1262 fn test_validate_scale_accepts_common_factors() {
1263 for ok in [0.5, 1.0, 1.25, 1.5, 1.6666, 1.75, 2.0, 3.0, 4.0] {
1264 assert!(validate_scale(ok).is_ok(), "expected {ok} to validate");
1265 }
1266 }
1267
1268 #[test]
1269 fn test_validate_scale_rejects_out_of_range_and_nonfinite() {
1270 for bad in [0.0, 0.49, 4.01, -1.0, f64::NAN, f64::INFINITY] {
1271 assert!(
1272 validate_scale(bad).is_err(),
1273 "expected {bad} to be rejected"
1274 );
1275 }
1276 }
1277
1278 #[test]
1279 fn test_nearest_supported_scale_snaps_to_closest() {
1280 let supported = [1.0, 1.25, 1.5, 1.75, 2.0];
1281 // Exact hits pass straight through.
1282 assert_eq!(nearest_supported_scale(1.5, &supported), 1.5);
1283 assert_eq!(nearest_supported_scale(2.0, &supported), 2.0);
1284 // 1.66 (166%) isn't advertised → nearest is 1.75.
1285 assert_eq!(nearest_supported_scale(1.66, &supported), 1.75);
1286 // 1.6 is closer to 1.5.
1287 assert_eq!(nearest_supported_scale(1.6, &supported), 1.5);
1288 }
1289
1290 #[test]
1291 fn test_nearest_supported_scale_empty_list_returns_request() {
1292 assert_eq!(nearest_supported_scale(1.5, &[]), 1.5);
1293 }
1294
1295 #[test]
1296 fn test_default_same_structure_as_new() {
1297 let c = MutterCompositor::default();
1298 assert!(c.wayland_display().starts_with("wayland-wd-"));
1299 assert!(c.runtime_dir().to_str().unwrap().contains("wd-session-"));
1300 }
1301}