fix: time out a stalled portal and sample motion across segments
Hyprland's portal sometimes creates the session and never answers source selection, before any picker appears. Recording now fails if the portal has not reached its picker within 10 s; time in the picker is not limited. The motion check uses the segment with the most motion, since capture can start late.
This commit is contained in:
@@ -176,6 +176,14 @@ fn pipewire_state(message: &str) -> Option<PipeWireState> {
|
||||
PipeWireState::Stopped
|
||||
});
|
||||
}
|
||||
// Requested from the portal, then the picker is shown (or a restore
|
||||
// token is used): only the first step can stall without the user.
|
||||
if message == "PipeWire initialized" {
|
||||
return Some(PipeWireState::Requesting);
|
||||
}
|
||||
if message.starts_with("[pipewire] Asking for ") {
|
||||
return Some(PipeWireState::Selecting);
|
||||
}
|
||||
// The user denied or cancelled the portal request, or it failed.
|
||||
let refused = message.starts_with("[pipewire] Error ")
|
||||
|| (message.starts_with("[pipewire] Failed to ")
|
||||
@@ -185,6 +193,8 @@ fn pipewire_state(message: &str) -> Option<PipeWireState> {
|
||||
|
||||
fn load_pipewire_state() -> Option<PipeWireState> {
|
||||
match PIPEWIRE_STATE.load(Ordering::Relaxed) {
|
||||
state if state == PipeWireState::Requesting as u8 => Some(PipeWireState::Requesting),
|
||||
state if state == PipeWireState::Selecting as u8 => Some(PipeWireState::Selecting),
|
||||
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),
|
||||
@@ -877,6 +887,14 @@ mod tests {
|
||||
state("[pipewire] Error selecting screencast source: Timeout"),
|
||||
Some(PipeWireState::Refused)
|
||||
);
|
||||
assert_eq!(
|
||||
state("PipeWire initialized"),
|
||||
Some(PipeWireState::Requesting)
|
||||
);
|
||||
assert_eq!(
|
||||
state("[pipewire] Asking for monitor and window"),
|
||||
Some(PipeWireState::Selecting)
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -286,11 +286,15 @@ pub struct Health {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PipeWireState {
|
||||
Streaming = 1,
|
||||
/// A screen-share session was requested from the portal.
|
||||
Requesting = 1,
|
||||
/// The portal is showing its picker or restoring an earlier selection.
|
||||
Selecting = 2,
|
||||
Streaming = 3,
|
||||
/// The stream exists but is not delivering frames.
|
||||
Stopped = 2,
|
||||
Stopped = 4,
|
||||
/// The portal request was denied, cancelled by the user, or failed.
|
||||
Refused = 3,
|
||||
Refused = 5,
|
||||
}
|
||||
|
||||
/// Derives the status shown to the user from health samples and output events.
|
||||
@@ -305,6 +309,7 @@ pub struct StatusTracker {
|
||||
stall_limit: Duration,
|
||||
last_written: u64,
|
||||
last_progress: Instant,
|
||||
requesting_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl StatusTracker {
|
||||
@@ -312,6 +317,9 @@ impl StatusTracker {
|
||||
/// be finalised rather than failing mid-write.
|
||||
pub const DEFAULT_MIN_FREE_BYTES: u64 = 1 << 30;
|
||||
|
||||
/// How long the portal may take to reach its picker or restore a selection.
|
||||
pub const PORTAL_RESPONSE_LIMIT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Matroska output can stay buffered until its segment closes, so footage
|
||||
/// may appear on disk only once per segment. A stall is declared after two
|
||||
/// segment lengths without growth.
|
||||
@@ -322,6 +330,7 @@ impl StatusTracker {
|
||||
stall_limit: (segment_length * 2 + Duration::from_secs(1)).max(Duration::from_secs(3)),
|
||||
last_written: 0,
|
||||
last_progress: now,
|
||||
requesting_since: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +352,21 @@ impl StatusTracker {
|
||||
self.last_written = health.written_bytes;
|
||||
self.last_progress = now;
|
||||
}
|
||||
// Before the picker appears the portal needs no user input, so a
|
||||
// request stuck there has stalled (seen with Hyprland's portal).
|
||||
if health.pipewire == Some(PipeWireState::Requesting) {
|
||||
let since = *self.requesting_since.get_or_insert(now);
|
||||
if now.duration_since(since) >= Self::PORTAL_RESPONSE_LIMIT {
|
||||
self.status = RecordingStatus::Failed {
|
||||
reason:
|
||||
"the screen-share portal did not respond; select a capture source again"
|
||||
.into(),
|
||||
};
|
||||
return &self.status;
|
||||
}
|
||||
} else {
|
||||
self.requesting_since = None;
|
||||
}
|
||||
if health.pipewire == Some(PipeWireState::Refused) {
|
||||
self.status = RecordingStatus::Failed {
|
||||
reason: "screen capture was not allowed: the source selection was denied, cancelled or failed"
|
||||
@@ -618,6 +642,38 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_portal_that_never_reaches_its_picker_fails() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
let requesting = Health {
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
pipewire: Some(PipeWireState::Requesting),
|
||||
..healthy(5000)
|
||||
};
|
||||
assert_eq!(
|
||||
tracker.sample(&requesting, start),
|
||||
&RecordingStatus::WaitingForCapture
|
||||
);
|
||||
// The user may take as long as they like in the picker.
|
||||
let selecting = Health {
|
||||
pipewire: Some(PipeWireState::Selecting),
|
||||
..requesting.clone()
|
||||
};
|
||||
let much_later = start + StatusTracker::PORTAL_RESPONSE_LIMIT * 3;
|
||||
assert_eq!(
|
||||
tracker.sample(&selecting, much_later),
|
||||
&RecordingStatus::WaitingForCapture
|
||||
);
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
tracker.sample(&requesting, start);
|
||||
assert!(matches!(
|
||||
tracker.sample(&requesting, start + StatusTracker::PORTAL_RESPONSE_LIMIT),
|
||||
RecordingStatus::Failed { reason } if reason.contains("did not respond")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_errors_fail_and_clean_stops_go_idle() {
|
||||
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
|
||||
|
||||
@@ -470,24 +470,24 @@ fn check(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
// A black or frozen capture still produces valid segments. When the
|
||||
// captured content was moving, the middle segment must show it.
|
||||
// captured content was moving, some segment must show it (capture may
|
||||
// start late, after the user answers the picker).
|
||||
if options.expect_motion {
|
||||
let middle = &segments[segments.len() / 2];
|
||||
let distinct = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-i"])
|
||||
.arg(middle)
|
||||
.args(["-map", "0:v:0", "-vf", "mpdecimate=max=0", "-f", "null", "-", "-stats_period", "100"])
|
||||
.args(["-progress", "pipe:1"])
|
||||
.output()?;
|
||||
let frames = String::from_utf8_lossy(&distinct.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("frame="))
|
||||
.filter_map(|frames| frames.parse::<u64>().ok())
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
let moving = frames > 1;
|
||||
let mut most = (0, None);
|
||||
for segment in &segments {
|
||||
let frames = distinct_frames(segment)?;
|
||||
if frames > most.0 {
|
||||
most = (frames, Some(segment));
|
||||
}
|
||||
}
|
||||
let moving = most.0 > 1;
|
||||
ok &= moving;
|
||||
println!("motion ok={moving} distinct_frames={frames} segment={}", middle.display());
|
||||
println!(
|
||||
"motion ok={moving} distinct_frames={} segment={}",
|
||||
most.0,
|
||||
most.1
|
||||
.map_or(String::new(), |segment| segment.display().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// A clip spanning the boundaries between the first three segments.
|
||||
@@ -540,6 +540,30 @@ fn check(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
/// Frames in a segment's video that differ from the previous one.
|
||||
fn distinct_frames(segment: &Path) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-i"])
|
||||
.arg(segment)
|
||||
.args([
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-vf",
|
||||
"mpdecimate=max=0",
|
||||
"-f",
|
||||
"null",
|
||||
"-",
|
||||
])
|
||||
.args(["-progress", "pipe:1"])
|
||||
.output()?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("frame="))
|
||||
.filter_map(|frames| frames.parse().ok())
|
||||
.next_back()
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
fn ffprobe(path: &Path, query: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("ffprobe")
|
||||
.args(["-v", "error"])
|
||||
|
||||
+18
-1
@@ -9,7 +9,7 @@ pass/fail results.
|
||||
| Where | Script | Status |
|
||||
| --- | --- | --- |
|
||||
| Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 9 scenarios pass on devbox (Intel UHD 770, VA-API) |
|
||||
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | First run 2026-10-08: H.264, HEVC and AV1 all usable; restore token still to confirm |
|
||||
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | 2026-10-08: H.264, HEVC and AV1 usable; restore token works; portal stall fix awaiting re-run |
|
||||
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
|
||||
|
||||
## Probe commands
|
||||
@@ -77,6 +77,20 @@ CachyOS 7.2.9, Hyprland, RX 9070 XT, 1440p60, OBS 32.0.4:
|
||||
the PipeWire stream `streaming` and OBS repeats the window's last frame.
|
||||
See findings.
|
||||
|
||||
### Second hardware run (2026-10-08)
|
||||
|
||||
- The restore token works: the restored run started recording in 2 s without
|
||||
showing the picker.
|
||||
- The portal stalled silently on one run again: it created the session but
|
||||
never answered `SelectSources`, before any picker. Recording now fails if
|
||||
the portal has not reached its picker 10 s after the request
|
||||
(`StatusTracker::PORTAL_RESPONSE_LIMIT`); time spent in the picker itself
|
||||
is not limited.
|
||||
- The motion check sampled a segment from before capture started (the user
|
||||
took 16 s in the picker); it now uses the segment with the most motion.
|
||||
- A portrait monitor (Hyprland transform 3) is captured as 1440x2560 and
|
||||
pillarboxed in the 2560x1440 canvas.
|
||||
|
||||
## Windows
|
||||
|
||||
Run `scripts/validate/windows.ps1 -GameWindow "<title>:<class>:<exe>"` with a
|
||||
@@ -133,6 +147,9 @@ Windows status:
|
||||
has a picture, so a source that never arrives produces black segments. The
|
||||
session timeline should show that time as missing footage, or the output
|
||||
should start only once capture is usable.
|
||||
- **Canvas orientation.** The output canvas is fixed by the configured
|
||||
resolution, so portrait or other-aspect sources are letterboxed. Decide
|
||||
whether the canvas should follow the captured source.
|
||||
- **Audio capture failures are not detected yet.** When the PulseAudio server
|
||||
was unreachable, recording continued with a silent track.
|
||||
- **Linux runtime dependencies.** The Linux OBS build links the system's
|
||||
|
||||
Reference in New Issue
Block a user