fix: fail on refused portal requests and catch black recordings
Rust checks / windows-cross-check (push) Failing after 40s
Rust checks / linux (push) Failing after 5m38s

The portal reports refusals only in OBS's log; a denied, cancelled or failed
source selection now fails with an actionable reason instead of waiting
forever. check --expect-motion rejects black or frozen footage, and the
hardware script checks only runs that recorded, tests restore tokens in their
own step, and treats closing a window as informational: on Hyprland the
stream keeps its last frame, so sessions must end on game exit.
This commit is contained in:
2026-10-08 15:01:25 +02:00
parent 91f119e6b6
commit 06377de707
7 changed files with 208 additions and 83 deletions
+53 -32
View File
@@ -19,7 +19,7 @@ use libobs as sys;
use crate::{
capture::{SettingValue, SourceSpec},
recorder::{Health, RecorderEvent},
recorder::{Health, PipeWireState, RecorderEvent},
};
fn cstring(value: &str) -> CString {
@@ -149,13 +149,8 @@ unsafe extern "C" fn log_to_stderr(
_ => "debug",
};
if let Some(text) = owned(message.array) {
if let Some(state) = pipewire_stream_state(&text) {
let code = if state == "streaming" {
STREAMING
} else {
NOT_STREAMING
};
PIPEWIRE_STREAM.store(code, Ordering::Relaxed);
if let Some(state) = pipewire_state(&text) {
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
}
eprintln!("obs {label}: {text}");
}
@@ -163,22 +158,38 @@ unsafe extern "C" fn log_to_stderr(
unsafe { sys::bfree(message.array.cast()) };
}
/// The state of the PipeWire capture stream, as last logged by OBS.
/// The PipeWire capture state, as last logged by OBS (0 = nothing logged).
///
/// OBS 32.0.4's PipeWire capture keeps reporting its last size and rendering
/// its last frame after the stream stops, and exposes the stream state only
/// through this log line. Clipbin records one capture source at a time.
static PIPEWIRE_STREAM: AtomicU8 = AtomicU8::new(NO_PIPEWIRE_STREAM);
const NO_PIPEWIRE_STREAM: u8 = 0;
const STREAMING: u8 = 1;
const NOT_STREAMING: u8 = 2;
/// its last frame after its stream stops, and reports portal refusals and
/// stream states only in its log (linux-pipewire's screencast-portal.c and
/// pipewire.c). Clipbin records one capture source at a time.
static PIPEWIRE_STATE: AtomicU8 = AtomicU8::new(0);
/// Parses `[pipewire] Stream 0x... state: "streaming" (error: none)` from
/// linux-pipewire's on_state_changed_cb.
fn pipewire_stream_state(message: &str) -> Option<&str> {
let rest = message.strip_prefix("[pipewire] Stream ")?;
let state = rest.split_once(" state: \"")?.1;
state.split_once('"').map(|(state, _)| state)
fn pipewire_state(message: &str) -> Option<PipeWireState> {
if let Some(rest) = message.strip_prefix("[pipewire] Stream ") {
// `[pipewire] Stream 0x... state: "streaming" (error: none)`
let state = rest.split_once(" state: \"")?.1.split_once('"')?.0;
return Some(if state == "streaming" {
PipeWireState::Streaming
} else {
PipeWireState::Stopped
});
}
// The user denied or cancelled the portal request, or it failed.
let refused = message.starts_with("[pipewire] Error ")
|| (message.starts_with("[pipewire] Failed to ")
&& message.ends_with("denied or cancelled by user"));
refused.then_some(PipeWireState::Refused)
}
fn load_pipewire_state() -> Option<PipeWireState> {
match PIPEWIRE_STATE.load(Ordering::Relaxed) {
state if state == PipeWireState::Streaming as u8 => Some(PipeWireState::Streaming),
state if state == PipeWireState::Stopped as u8 => Some(PipeWireState::Stopped),
state if state == PipeWireState::Refused as u8 => Some(PipeWireState::Refused),
_ => None,
}
}
pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
@@ -568,7 +579,7 @@ impl Pipeline {
events: Sender<RecorderEvent>,
current_segment: Arc<Mutex<Option<String>>>,
) -> Result<Self, String> {
PIPEWIRE_STREAM.store(NO_PIPEWIRE_STREAM, Ordering::Relaxed);
PIPEWIRE_STATE.store(0, Ordering::Relaxed);
// OBS releases objects asynchronously, so names must not be reused.
static RECORDINGS: AtomicU64 = AtomicU64::new(0);
let id = RECORDINGS.fetch_add(1, Ordering::Relaxed);
@@ -745,14 +756,10 @@ impl Pipeline {
sys::obs_source_get_height(self.video_source),
)
};
let source_streaming = match PIPEWIRE_STREAM.load(Ordering::Relaxed) {
NO_PIPEWIRE_STREAM => None,
state => Some(state == STREAMING),
};
Health {
source_width: width,
source_height: height,
source_streaming,
pipewire: load_pipewire_state(),
output_active: sys::obs_output_active(self.output),
total_frames: sys::obs_output_get_total_frames(self.output).max(0) as u64,
dropped_frames: sys::obs_output_get_frames_dropped(self.output).max(0) as u64,
@@ -852,13 +859,27 @@ mod tests {
use super::*;
#[test]
fn pipewire_stream_state_is_read_from_the_plugin_log() {
let line = r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#;
assert_eq!(pipewire_stream_state(line), Some("paused"));
fn pipewire_state_is_read_from_the_plugin_log() {
let state = |line| pipewire_state(line);
assert_eq!(
pipewire_stream_state("[pipewire] Screencast session created"),
None
state(r#"[pipewire] Stream 0x7fe128037440 state: "streaming" (error: none)"#),
Some(PipeWireState::Streaming)
);
assert_eq!(
state(r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#),
Some(PipeWireState::Stopped)
);
assert_eq!(
state("[pipewire] Failed to select source, denied or cancelled by user"),
Some(PipeWireState::Refused)
);
assert_eq!(
state("[pipewire] Error selecting screencast source: Timeout"),
Some(PipeWireState::Refused)
);
assert_eq!(state("[pipewire] Screencast session created"), None);
// OBS cancelling its own pending request at teardown is not a refusal.
assert_eq!(state("[portals] Request cancelled"), None);
}
#[test]