Files
pixelpass/src/host/wayland.rs
T
molluskandClaude Opus 4.8 cfc480044f fix: three robustness bugs outside the friends list
Found in a wider bug audit of the streaming/process-management code.

- Viewer ctrl-c/SIGINT was ignored mid-stream: viewer::run raced the
  cancel token only against listener.accept(), not the bridge itself, so
  once the local player connected nothing checked it. CLI needed a second
  ctrl-c to quit and a GUI "Disconnect" only took effect via the child's 2s
  SIGKILL backstop (and the host saw the viewer ~2s longer). Now races the
  bridge against cancel, mirroring the host's handle_peer. (viewer/mod.rs)

- Wayland portal pipewire fd leaked on a capture-setup error: wayland::start
  into_raw_fd'd the fd and relied on pipeline::spawn's after_spawn hook to
  close it, but setup_audio/gst-spawn can ?-return before the hook runs,
  leaking the fd per failed attempt. Now the OwnedFd is moved into the hook,
  so it's closed whether the hook runs or (on early error) the unused closure
  is dropped. (host/wayland.rs)

- Detached players (mpv/vlc) zombied under the long-lived GUI: spawn_detached
  dropped the std Child, which has no orphan reaping, so each closed player
  left a <defunct> entry until the GUI exited. Now a detached thread wait()s
  it; the setsid'd player still survives a parent exit (init reaps it then).
  A double-fork was avoided deliberately — fork(2) + non-trivial work in this
  multithreaded process is unsound. (common/process.rs)

47 gui / 8 headless tests pass, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:27:07 -04:00

99 lines
3.3 KiB
Rust

//! Wayland capture: ashpd ScreenCast portal → PipeWire fd → `pipewiresrc`.
//! This module owns only the portal handshake and the source-element args;
//! the shared encode/mux tail, gst spawn, and serving live in
//! [`super::pipeline`].
use anyhow::{Context, Result};
use ashpd::{
WindowIdentifier,
desktop::{
PersistMode,
screencast::{CursorMode, Screencast, SourceType},
},
};
use nix::fcntl::{FcntlArg, FdFlag, fcntl};
use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
use super::pipeline::{self, CaptureHandle};
use super::quality::EffectiveQuality;
use crate::cli::HostOpts;
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
// 1. Negotiate the screencast session with the portal.
let proxy = Screencast::new()
.await
.context("could not reach the xdg-desktop-portal ScreenCast interface")?;
let session = proxy.create_session().await?;
let source = if opts.window {
SourceType::Window
} else {
SourceType::Monitor
};
proxy
.select_sources(
&session,
CursorMode::Embedded,
source.into(),
false,
None,
PersistMode::DoNot,
)
.await
.context("select_sources failed")?;
let response = proxy
.start(&session, &WindowIdentifier::default())
.await
.context("portal Start failed (did the user cancel the picker?)")?
.response()?;
let stream = response
.streams()
.first()
.context("portal returned no screencast streams")?;
let node_id = stream.pipe_wire_node_id();
let (w, h) = stream
.size()
.context("portal returned a stream with no size — pipewiresrc can't infer dimensions")?;
let pw_fd: OwnedFd = proxy.open_pipe_wire_remote(&session).await?;
tracing::info!(node_id, width = w, height = h, "portal handshake complete");
// The fd is CLOEXEC by default; the gst child needs to inherit it across
// exec, so clear CLOEXEC. We keep the OwnedFd alive across the spawn (gst
// inherits its own copy at exec) by moving it into the after_spawn hook,
// which drops — and so closes — the parent's copy once gst is running. If
// pipeline::spawn errors *before* calling the hook (e.g. audio setup or the
// gst spawn fails), the unused closure is dropped, dropping the fd just the
// same — so the portal fd never leaks on the error path.
clear_cloexec(&pw_fd)?;
let raw_fd: RawFd = pw_fd.as_raw_fd();
let source_args = vec![
"pipewiresrc".to_string(),
format!("fd={raw_fd}"),
format!("path={node_id}"),
"do-timestamp=true".to_string(),
];
pipeline::spawn(
opts,
quality,
Some((w as u32, h as u32)),
source_args,
move || {
// Parent no longer needs the pipewire fd — gst inherited its own copy.
drop(pw_fd);
},
)
.await
}
fn clear_cloexec(fd: &impl AsFd) -> Result<()> {
let flags_int = fcntl(fd.as_fd(), FcntlArg::F_GETFD).context("F_GETFD on pipewire fd")?;
let mut flags = FdFlag::from_bits_truncate(flags_int);
flags.remove(FdFlag::FD_CLOEXEC);
fcntl(fd.as_fd(), FcntlArg::F_SETFD(flags)).context("F_SETFD on pipewire fd")?;
Ok(())
}