From a1e44fd5fb0ad1768820e8d46fc62195045adc14 Mon Sep 17 00:00:00 2001 From: Kyzer <36684609+kyzer1023@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:56:39 +0800 Subject: [PATCH 1/8] perf(windows-amd): MF texture encode path for AMD AMF Stop forcing AMD GPUs onto the FFmpeg h264_amf readback fallback for screen and camera MP4 muxers. AMD now uses the native Media Foundation D3D11 texture path with BT.709 limited-range signalling and a bounded AMD-only drain on stop so tail frames are not discarded. FFmpeg remains the fallback when MF init fails or software is preferred. Bench (Ryzen 7 8745HS / Radeon 780M, 5 measured trials + warmup, 300 frames): - 3840x2160: median wall 1.62x faster, mean CPU 7.57x (-86.8%) - 1920x1080: median wall 0.82x (slower), mean CPU 2.73x (-63.4%) --- crates/enc-mediafoundation/src/video/h264.rs | 99 ++++- .../src/video/video_processor.rs | 29 +- .../examples/mf-texture-benchmark.rs | 343 ++++++++++++++++++ crates/recording/src/output_pipeline/win.rs | 83 ++++- 4 files changed, 527 insertions(+), 27 deletions(-) create mode 100644 crates/recording/examples/mf-texture-benchmark.rs diff --git a/crates/enc-mediafoundation/src/video/h264.rs b/crates/enc-mediafoundation/src/video/h264.rs index 2e496e47f64..312007cfc44 100644 --- a/crates/enc-mediafoundation/src/video/h264.rs +++ b/crates/enc-mediafoundation/src/video/h264.rs @@ -17,22 +17,26 @@ use windows::{ Foundation::E_NOTIMPL, Graphics::{ Direct3D11::{ID3D11Device, ID3D11Texture2D}, - Dxgi::Common::{DXGI_FORMAT, DXGI_FORMAT_NV12}, + Dxgi::{ + Common::{DXGI_FORMAT, DXGI_FORMAT_NV12}, + IDXGIDevice, + }, }, Media::MediaFoundation::{ self, IMFAttributes, IMFDXGIDeviceManager, IMFMediaEventGenerator, IMFMediaType, - IMFSample, IMFTransform, MF_E_INVALIDMEDIATYPE, MF_E_NO_MORE_TYPES, - MF_E_TRANSFORM_TYPE_NOT_SET, MF_EVENT_FLAG_NONE, MF_EVENT_TYPE, - MF_MT_ALL_SAMPLES_INDEPENDENT, MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, - MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, - MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, MF_TRANSFORM_ASYNC_UNLOCK, - MFCreateDXGIDeviceManager, MFCreateDXGISurfaceBuffer, MFCreateMediaType, - MFCreateSample, MFMediaType_Video, MFT_ENUM_FLAG, MFT_ENUM_FLAG_HARDWARE, - MFT_ENUM_FLAG_TRANSCODE_ONLY, MFT_MESSAGE_COMMAND_FLUSH, - MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, MFT_MESSAGE_NOTIFY_END_OF_STREAM, - MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, - MFT_MESSAGE_SET_D3D_MANAGER, MFT_OUTPUT_DATA_BUFFER, MFT_SET_TYPE_TEST_ONLY, - MFVideoFormat_H264, MFVideoFormat_NV12, MFVideoInterlace_Progressive, + IMFSample, IMFTransform, MF_E_INVALIDMEDIATYPE, MF_E_NO_EVENTS_AVAILABLE, + MF_E_NO_MORE_TYPES, MF_E_TRANSFORM_TYPE_NOT_SET, MF_EVENT_FLAG_NO_WAIT, + MF_EVENT_FLAG_NONE, MF_EVENT_TYPE, MF_MT_ALL_SAMPLES_INDEPENDENT, MF_MT_AVG_BITRATE, + MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, + MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, + MF_TRANSFORM_ASYNC_UNLOCK, MFCreateDXGIDeviceManager, MFCreateDXGISurfaceBuffer, + MFCreateMediaType, MFCreateSample, MFMediaType_Video, MFT_ENUM_FLAG, + MFT_ENUM_FLAG_HARDWARE, MFT_ENUM_FLAG_TRANSCODE_ONLY, MFT_MESSAGE_COMMAND_DRAIN, + MFT_MESSAGE_COMMAND_FLUSH, MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, + MFT_MESSAGE_NOTIFY_END_OF_STREAM, MFT_MESSAGE_NOTIFY_END_STREAMING, + MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_MESSAGE_SET_D3D_MANAGER, + MFT_OUTPUT_DATA_BUFFER, MFT_SET_TYPE_TEST_ONLY, MFVideoFormat_H264, MFVideoFormat_NV12, + MFVideoInterlace_Progressive, }, }, core::{Error, Interface}, @@ -163,6 +167,7 @@ pub struct H264Encoder { output_stream_id: u32, output_type: IMFMediaType, bitrate: u32, + drain_on_stop: bool, } #[derive(Clone, Debug, thiserror::Error)] @@ -251,6 +256,11 @@ impl H264Encoder { frame_rate, bitrate_multipler, ); + let adapter = d3d_device + .cast::() + .and_then(|device| unsafe { device.GetAdapter()?.GetDesc() }) + .map_err(NewVideoEncoderError::DeviceManager)?; + let is_amd = adapter.VendorId == 0x1002; let transform = EncoderDevice::enumerate_with_flags(MFMediaType_Video, MFVideoFormat_H264, flags) @@ -270,6 +280,9 @@ impl H264Encoder { frame_rate, ) .map_err(NewVideoEncoderError::VideoProcessor)?; + if is_amd { + video_processor.set_bt709_color_space(format); + } let mut device_manager_reset_token: u32 = 0; let media_device_manager = { @@ -367,6 +380,9 @@ impl H264Encoder { MFSetAttributeRatio(&attributes, &MF_MT_PIXEL_ASPECT_RATIO, 1, 1)?; output_type.SetUINT32(&MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive.0 as u32)?; output_type.SetUINT32(&MF_MT_ALL_SAMPLES_INDEPENDENT, 1)?; + if is_amd { + set_bt709_attributes(&output_type)?; + } transform.SetOutputType(output_stream_id, &output_type, 0)?; Ok(output_type) })() @@ -386,6 +402,9 @@ impl H264Encoder { let attributes: IMFAttributes = input_type.cast()?; input_type.SetGUID(&MF_MT_MAJOR_TYPE, &MFMediaType_Video)?; input_type.SetGUID(&MF_MT_SUBTYPE, &MFVideoFormat_NV12)?; + if is_amd { + set_bt709_attributes(&input_type)?; + } MFSetAttributeSize( &attributes, &MF_MT_FRAME_SIZE, @@ -431,6 +450,7 @@ impl H264Encoder { input_stream_id, output_stream_id, bitrate, + drain_on_stop: is_amd, output_type, }) @@ -557,7 +577,19 @@ impl H264Encoder { let mut consecutive_empty_samples: u8 = 0; let mut should_exit = false; + let mut drain_started: Option = None; while !should_exit { + if drain_started + .is_some_and(|started| started.elapsed() > ENCODER_OPERATION_TIMEOUT) + { + let _ = self.cleanup_encoder(); + return Err(EncoderRuntimeError::EncoderUnhealthy { + reason: EncoderFailureReason::Timeout, + inputs_without_output: health_monitor.inputs_without_output, + process_failures: health_monitor.consecutive_process_failures, + frames_encoded: health_monitor.total_frames_encoded, + }); + } let health_status = health_monitor.check_health(); if !health_status.is_healthy && let Some(reason) = health_status.failure_reason @@ -571,11 +603,25 @@ impl H264Encoder { }); } - let event = self.event_generator.GetEvent(MF_EVENT_FLAG_NONE)?; + let event = if drain_started.is_some() { + match self.event_generator.GetEvent(MF_EVENT_FLAG_NO_WAIT) { + Ok(event) => event, + Err(error) if error.code() == MF_E_NO_EVENTS_AVAILABLE => { + std::thread::sleep(Duration::from_millis(1)); + continue; + } + Err(error) => return Err(error.into()), + } + } else { + self.event_generator.GetEvent(MF_EVENT_FLAG_NONE)? + }; let event_type = MF_EVENT_TYPE(event.GetType()? as i32); match event_type { MediaFoundation::METransformNeedInput => { + if drain_started.is_some() { + continue; + } health_monitor.record_input(); should_exit = true; let get_frame_started = Instant::now(); @@ -631,6 +677,14 @@ impl H264Encoder { should_exit = false; } } + } else if self.drain_on_stop { + // AMD retains input at stop; flushing here discards the final frames. + self.transform + .ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0)?; + self.transform + .ProcessMessage(MFT_MESSAGE_COMMAND_DRAIN, 0)?; + drain_started = Some(Instant::now()); + should_exit = false; } } MediaFoundation::METransformHaveOutput => { @@ -661,6 +715,9 @@ impl H264Encoder { } } } + MediaFoundation::METransformDrainComplete if drain_started.is_some() => { + should_exit = true; + } _ => { panic!("Unknown media event type: {}", event_type.0); } @@ -695,3 +752,17 @@ fn calculate_bitrate(width: u32, height: u32, fps: u32, multiplier: f32) -> u32 let frame_rate_factor = (fps as f32 - 30.0).max(0.0) / 2.0 + 30.0; (width as f32 * height as f32 * frame_rate_factor * multiplier) as u32 } + +fn set_bt709_attributes(media_type: &IMFMediaType) -> windows::core::Result<()> { + use MediaFoundation::{ + MF_MT_TRANSFER_FUNCTION, MF_MT_VIDEO_NOMINAL_RANGE, MF_MT_VIDEO_PRIMARIES, + MF_MT_YUV_MATRIX, MFNominalRange_16_235, MFVideoPrimaries_BT709, MFVideoTransFunc_709, + MFVideoTransferMatrix_BT709, + }; + unsafe { + media_type.SetUINT32(&MF_MT_YUV_MATRIX, MFVideoTransferMatrix_BT709.0 as u32)?; + media_type.SetUINT32(&MF_MT_VIDEO_PRIMARIES, MFVideoPrimaries_BT709.0 as u32)?; + media_type.SetUINT32(&MF_MT_TRANSFER_FUNCTION, MFVideoTransFunc_709.0 as u32)?; + media_type.SetUINT32(&MF_MT_VIDEO_NOMINAL_RANGE, MFNominalRange_16_235.0 as u32) + } +} diff --git a/crates/enc-mediafoundation/src/video/video_processor.rs b/crates/enc-mediafoundation/src/video/video_processor.rs index fe26d73f337..547f45dc01a 100644 --- a/crates/enc-mediafoundation/src/video/video_processor.rs +++ b/crates/enc-mediafoundation/src/video/video_processor.rs @@ -18,7 +18,10 @@ use windows::{ ID3D11VideoContext, ID3D11VideoDevice, ID3D11VideoProcessor, ID3D11VideoProcessorInputView, ID3D11VideoProcessorOutputView, }, - Dxgi::Common::{DXGI_FORMAT, DXGI_RATIONAL, DXGI_SAMPLE_DESC}, + Dxgi::Common::{ + DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_RATIONAL, + DXGI_SAMPLE_DESC, + }, }, }, core::Interface, @@ -218,6 +221,30 @@ impl VideoProcessor { &self.video_output_texture } + pub(super) fn set_bt709_color_space(&self, input_format: DXGI_FORMAT) { + let input_range = if matches!( + input_format, + DXGI_FORMAT_B8G8R8A8_UNORM | DXGI_FORMAT_R8G8B8A8_UNORM + ) { + D3D11_VIDEO_PROCESSOR_NOMINAL_RANGE_0_255 + } else { + D3D11_VIDEO_PROCESSOR_NOMINAL_RANGE_16_235 + }; + // Nominal_Range occupies bits 4-5; unshifted values change Usage/RGB_Range. + let output = D3D11_VIDEO_PROCESSOR_COLOR_SPACE { + _bitfield: 1 | (1 << 2) | ((D3D11_VIDEO_PROCESSOR_NOMINAL_RANGE_16_235.0 as u32) << 4), + }; + let input = D3D11_VIDEO_PROCESSOR_COLOR_SPACE { + _bitfield: 1 | (1 << 2) | ((input_range.0 as u32) << 4), + }; + unsafe { + self.video_context + .VideoProcessorSetOutputColorSpace(&self.video_processor, &output); + self.video_context + .VideoProcessorSetStreamColorSpace(&self.video_processor, 0, &input); + } + } + pub fn process_texture( &mut self, input_texture: &ID3D11Texture2D, diff --git a/crates/recording/examples/mf-texture-benchmark.rs b/crates/recording/examples/mf-texture-benchmark.rs new file mode 100644 index 00000000000..d64525c275e --- /dev/null +++ b/crates/recording/examples/mf-texture-benchmark.rs @@ -0,0 +1,343 @@ +#[cfg(not(windows))] +fn main() { + eprintln!("Windows only"); +} + +#[cfg(windows)] +fn main() -> anyhow::Result<()> { + windows_bench::run() +} + +#[cfg(windows)] +mod windows_bench { + use anyhow::{Context, anyhow}; + use cap_enc_ffmpeg::h264::H264Encoder; + use cap_media_info::VideoInfo; + use cap_mediafoundation_ffmpeg::{H264StreamMuxer, MuxerConfig}; + use std::{ + path::Path, + sync::{Arc, atomic::AtomicBool}, + time::{Duration, Instant}, + }; + use windows::{ + Foundation::TimeSpan, + Graphics::SizeInt32, + Win32::{ + Foundation::FILETIME, + Graphics::{Direct3D11::*, Dxgi::Common::*}, + System::Threading::{GetCurrentProcess, GetProcessTimes}, + }, + }; + + fn cpu_seconds() -> anyhow::Result { + let mut creation = FILETIME::default(); + let mut exit = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + unsafe { + GetProcessTimes( + GetCurrentProcess(), + &mut creation, + &mut exit, + &mut kernel, + &mut user, + )?; + } + let ticks = + |time: FILETIME| (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime); + Ok((ticks(kernel) + ticks(user)) as f64 / 10_000_000.0) + } + + fn textures( + device: &ID3D11Device, + width: u32, + height: u32, + ) -> anyhow::Result> { + let desc = D3D11_TEXTURE2D_DESC { + Width: width, + Height: height, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_R8G8B8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32, + ..Default::default() + }; + (0..8) + .map(|index| { + let mut pixels = vec![0u8; width as usize * height as usize * 4]; + for (offset, pixel) in pixels.chunks_exact_mut(4).enumerate() { + let x = offset % width as usize; + let y = offset / width as usize; + pixel.copy_from_slice(&[ + ((x / 8 + index * 13) % 256) as u8, + ((y / 8 + index * 7) % 256) as u8, + ((x / 16 + y / 16 + index * 5) % 256) as u8, + 255, + ]); + } + let initial = D3D11_SUBRESOURCE_DATA { + pSysMem: pixels.as_ptr().cast(), + SysMemPitch: width * 4, + ..Default::default() + }; + let mut texture = None; + unsafe { + device.CreateTexture2D(&desc, Some(&initial), Some(&mut texture))?; + } + texture.context("texture missing") + }) + .collect() + } + + fn check_pixels(frame: &ffmpeg::frame::Video, index: usize) -> anyhow::Result<()> { + let index = index % 8; + let red = ((1000 / 8 + index * 13) % 256) as f64; + let green = ((500 / 8 + index * 7) % 256) as f64; + let blue = ((1000 / 16 + 500 / 16 + index * 5) % 256) as f64; + let expected = 16.0 + (0.2126 * red + 0.7152 * green + 0.0722 * blue) * 219.0 / 255.0; + let actual = frame.data(0)[500 * frame.stride(0) + 1000] as f64; + if (expected - actual).abs() > 4.0 { + return Err(anyhow!( + "frame {index}: expected BT.709 luma {expected:.2}, got {actual}" + )); + } + Ok(()) + } + + fn decode(path: &Path) -> anyhow::Result { + let mut input = ffmpeg::format::input(path)?; + let stream = input + .streams() + .best(ffmpeg::media::Type::Video) + .context("video stream")?; + let index = stream.index(); + let mut decoder = ffmpeg::codec::context::Context::from_parameters(stream.parameters())? + .decoder() + .video()?; + let mut decoded = ffmpeg::frame::Video::empty(); + let mut count = 0; + for (stream, packet) in input.packets() { + if stream.index() != index { + continue; + } + decoder.send_packet(&packet)?; + while decoder.receive_frame(&mut decoded).is_ok() { + check_pixels(&decoded, count)?; + count += 1; + } + } + decoder.send_eof()?; + while decoder.receive_frame(&mut decoded).is_ok() { + check_pixels(&decoded, count)?; + count += 1; + } + Ok(count) + } + + fn trial( + device: &ID3D11Device, + textures: &[ID3D11Texture2D], + size: SizeInt32, + mode: &str, + frames: usize, + path: &Path, + ) -> anyhow::Result<()> { + let width = size.Width as u32; + let height = size.Height as u32; + let mut output = ffmpeg::format::output(path)?; + let mut latencies = Vec::with_capacity(frames); + let cpu_start; + let started; + let elapsed; + let cpu; + let outputs; + if mode == "texture" { + let mut encoder = cap_enc_mediafoundation::H264Encoder::new( + device, + DXGI_FORMAT_R8G8B8A8_UNORM, + size, + 30, + 0.15, + )?; + encoder.validate()?; + let mut muxer = H264StreamMuxer::new( + &mut output, + MuxerConfig { + width, + height, + fps: 30, + bitrate: encoder.bitrate(), + ..Default::default() + }, + )?; + output.write_header()?; + let mut inputs = 0; + let mut previous = Instant::now(); + cpu_start = cpu_seconds()?; + started = Instant::now(); + let health = encoder.run( + Arc::new(AtomicBool::new(false)), + || { + if inputs > 0 { + latencies.push(previous.elapsed().as_secs_f64() * 1000.0); + } + if inputs == frames { + return Ok(None); + } + previous = Instant::now(); + let texture = textures[inputs % textures.len()].clone(); + let timestamp = TimeSpan { + Duration: inputs as i64 * 10_000_000 / 30, + }; + inputs += 1; + Ok(Some((texture, timestamp))) + }, + |sample| { + muxer.write_sample(&sample, &mut output).map_err(|error| { + windows::core::Error::new( + windows::core::HRESULT(0x80004005u32 as i32), + error.to_string(), + ) + }) + }, + )?; + elapsed = started.elapsed(); + cpu = cpu_seconds()? - cpu_start; + outputs = health.total_frames_encoded as usize; + println!( + "MF inputs={inputs} samples={outputs} healthy={}", + health.is_healthy + ); + } else { + let info = VideoInfo::from_raw_ffmpeg(ffmpeg::format::Pixel::RGBA, width, height, 30); + let mut encoder = H264Encoder::builder(info).build(&mut output)?; + let conversion = encoder.conversion_requirements(); + println!( + "FFmpeg hardware={} conversion={:?}->{:?}", + encoder.is_hardware(), + conversion.input_format, + conversion.output_format + ); + output.write_header()?; + let context = unsafe { device.GetImmediateContext()? }; + let mut desc = D3D11_TEXTURE2D_DESC::default(); + unsafe { + textures[0].GetDesc(&mut desc); + } + desc.Usage = D3D11_USAGE_STAGING; + desc.BindFlags = 0; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ.0 as u32; + let mut staging = None; + unsafe { + device.CreateTexture2D(&desc, None, Some(&mut staging))?; + } + let staging = staging.context("staging texture")?; + let mut frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height); + cpu_start = cpu_seconds()?; + started = Instant::now(); + for index in 0..frames { + let frame_started = Instant::now(); + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + unsafe { + context.CopyResource(&staging, &textures[index % textures.len()]); + context.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?; + let stride = frame.stride(0); + for (row, destination) in frame + .data_mut(0) + .chunks_mut(stride) + .take(height as usize) + .enumerate() + { + std::ptr::copy_nonoverlapping( + mapped + .pData + .cast::() + .add(row * mapped.RowPitch as usize), + destination.as_mut_ptr(), + width as usize * 4, + ); + } + context.Unmap(&staging, 0); + } + let mut reference = ffmpeg::frame::Video::empty(); + let status = + unsafe { ffmpeg::ffi::av_frame_ref(reference.as_mut_ptr(), frame.as_ptr()) }; + if status < 0 { + return Err(anyhow!("av_frame_ref: {status}")); + } + encoder.queue_frame( + reference, + Duration::from_micros(index as u64 * 1_000_000 / 30), + &mut output, + )?; + latencies.push(frame_started.elapsed().as_secs_f64() * 1000.0); + } + encoder.flush(&mut output)?; + elapsed = started.elapsed(); + cpu = cpu_seconds()? - cpu_start; + outputs = frames; + } + output.write_trailer()?; + drop(output); + let decoded = decode(path)?; + latencies.sort_by(f64::total_cmp); + let mean = latencies.iter().sum::() / latencies.len() as f64; + println!( + "RESULT mode={mode} size={width}x{height} inputs={frames} outputs={outputs} decoded={decoded} wall_ms={:.3} ms_per_input={:.4} mean_interval_ms={mean:.4} median_interval_ms={:.4} cpu_ms_per_input={:.4}", + elapsed.as_secs_f64() * 1000.0, + elapsed.as_secs_f64() * 1000.0 / frames as f64, + latencies[latencies.len() / 2], + cpu * 1000.0 / frames as f64 + ); + if decoded != outputs || decoded != frames || decoded == 0 { + return Err(anyhow!("decoded count mismatch")); + } + Ok(()) + } + + pub fn run() -> anyhow::Result<()> { + cap_mediafoundation_utils::thread_init(); + ffmpeg::init()?; + tracing_subscriber::fmt() + .with_env_filter("info") + .with_ansi(false) + .init(); + let args: Vec = std::env::args().collect(); + let width: i32 = args.get(1).context("width")?.parse()?; + let height: i32 = args.get(2).context("height")?.parse()?; + let frames: usize = args.get(3).context("frames")?.parse()?; + let repeats: usize = args.get(4).context("repeats")?.parse()?; + let directory = Path::new(args.get(5).context("output directory")?); + std::fs::create_dir_all(directory)?; + let device = cap_enc_mediafoundation::d3d::create_d3d_device()?; + let textures = textures(&device, width as u32, height as u32)?; + let size = SizeInt32 { + Width: width, + Height: height, + }; + for repeat in 0..repeats { + let modes = if repeat % 2 == 0 { + ["fallback", "texture"] + } else { + ["texture", "fallback"] + }; + for mode in modes { + println!("TRIAL repeat={repeat} mode={mode}"); + trial( + &device, + &textures, + size, + mode, + frames, + &directory.join(format!("{width}x{height}-{repeat}-{mode}.mp4")), + )?; + } + } + Ok(()) + } +} diff --git a/crates/recording/src/output_pipeline/win.rs b/crates/recording/src/output_pipeline/win.rs index faab33d6e76..36433a177d4 100644 --- a/crates/recording/src/output_pipeline/win.rs +++ b/crates/recording/src/output_pipeline/win.rs @@ -237,12 +237,7 @@ impl Muxer for WindowsMuxer { .map_err(|e| anyhow!("ScreenSoftwareEncoder/{e}")) }; - if encoder_preferences.should_force_software() - || matches!( - cap_frame_converter::detect_primary_gpu().map(|gpu| gpu.vendor), - Some(cap_frame_converter::GpuVendor::Amd) - ) - { + if encoder_preferences.should_force_software() { return fallback(None); } @@ -508,6 +503,75 @@ impl Muxer for WindowsMuxer { mod tests { use super::reference_video_frame; + #[tokio::test] + #[ignore = "Requires AMD D3D11/MF hardware"] + async fn amd_muxer_tries_textures_and_preserves_ffmpeg_fallback() -> anyhow::Result<()> { + use super::{Muxer, WindowsMuxer, WindowsMuxerConfig}; + use crate::{ + TaskPool, capture_pipeline::EncoderPreferences, + output_pipeline::core::PipelineBuildScope, + }; + use std::sync::{Arc, atomic::AtomicBool}; + use std::time::Duration; + use windows::Win32::Graphics::Dxgi::Common::{ + DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_UNKNOWN, + }; + + assert_eq!( + cap_frame_converter::detect_primary_gpu().map(|gpu| gpu.vendor), + Some(cap_frame_converter::GpuVendor::Amd) + ); + cap_mediafoundation_utils::thread_init(); + ffmpeg::init()?; + let directory = tempfile::tempdir()?; + let device = cap_enc_mediafoundation::d3d::create_d3d_device()?; + for (index, (pixel_format, force_software, expect_fallback)) in [ + (DXGI_FORMAT_R8G8B8A8_UNORM, false, false), + (DXGI_FORMAT_UNKNOWN, false, true), + (DXGI_FORMAT_R8G8B8A8_UNORM, true, true), + ] + .into_iter() + .enumerate() + { + let preferences = EncoderPreferences::new(); + if force_software { + preferences.force_software_only(); + } + let scope = PipelineBuildScope::new(); + let mut tasks = TaskPool::default(); + let mut muxer = scope + .run(WindowsMuxer::setup( + WindowsMuxerConfig { + pixel_format, + d3d_device: device.clone(), + frame_rate: 30, + bitrate_multiplier: 0.15, + output_size: None, + encoder_preferences: preferences.clone(), + fragmented: false, + frag_duration_us: 2_000_000, + }, + directory.path().join(format!("selection-{index}.mp4")), + Some(cap_media_info::VideoInfo::from_raw_ffmpeg( + ffmpeg::format::Pixel::RGBA, + 1920, + 1080, + 30, + )), + None, + Arc::new(AtomicBool::new(false)), + &mut tasks, + )) + .await?; + let used_fallback = preferences.should_force_software(); + muxer.stop(); + scope.cancel_and_join().await.map_err(anyhow::Error::msg)?; + muxer.finish(Duration::ZERO)??; + assert_eq!(used_fallback, expect_fallback, "case {index}"); + } + Ok(()) + } + #[test] fn retained_software_frame_shares_pixels_and_survives_original() { let mut original = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::BGRA, 16, 12); @@ -781,12 +845,7 @@ impl Muxer for WindowsCameraMuxer { .map_err(|e| anyhow!("CameraSoftwareEncoder/{e}")) }; - if encoder_preferences.should_force_software() - || matches!( - cap_frame_converter::detect_primary_gpu().map(|gpu| gpu.vendor), - Some(cap_frame_converter::GpuVendor::Amd) - ) - { + if encoder_preferences.should_force_software() { return fallback(None); } From 1b22812b14dc11fb38e342339ed8840129c9fba5 Mon Sep 17 00:00:00 2001 From: Kyzer <36684609+kyzer1023@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:56:10 +0800 Subject: [PATCH 2/8] fix(windows-amd): drain health + AMD vendor + bench pixels - Rebase the encoder health timeout clock when entering the AMD drain and skip check_health while draining, so a >5s pause followed by stop no longer trips a false Timeout. - Drain deadline now logs a warning and falls through to the normal END_OF_STREAM / END_STREAMING / FLUSH shutdown instead of failing. - Treat both 0x1002 and 0x1022 as AMD, matching cap_frame_converter. - mf-texture-benchmark check_pixels samples coordinates inside the decoded frame and uses the same x/y for expected and actual luma. --- crates/enc-mediafoundation/src/video/h264.rs | 33 ++++++++++++------- .../examples/mf-texture-benchmark.rs | 15 ++++++--- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/crates/enc-mediafoundation/src/video/h264.rs b/crates/enc-mediafoundation/src/video/h264.rs index 312007cfc44..5cffb651cc6 100644 --- a/crates/enc-mediafoundation/src/video/h264.rs +++ b/crates/enc-mediafoundation/src/video/h264.rs @@ -260,7 +260,8 @@ impl H264Encoder { .cast::() .and_then(|device| unsafe { device.GetAdapter()?.GetDesc() }) .map_err(NewVideoEncoderError::DeviceManager)?; - let is_amd = adapter.VendorId == 0x1002; + // Match `cap_frame_converter::GpuVendor::from_id`: AMD reports either PCI ID. + let is_amd = matches!(adapter.VendorId, 0x1002 | 0x1022); let transform = EncoderDevice::enumerate_with_flags(MFMediaType_Video, MFVideoFormat_H264, flags) @@ -579,19 +580,23 @@ impl H264Encoder { let mut should_exit = false; let mut drain_started: Option = None; while !should_exit { - if drain_started - .is_some_and(|started| started.elapsed() > ENCODER_OPERATION_TIMEOUT) - { - let _ = self.cleanup_encoder(); - return Err(EncoderRuntimeError::EncoderUnhealthy { - reason: EncoderFailureReason::Timeout, - inputs_without_output: health_monitor.inputs_without_output, - process_failures: health_monitor.consecutive_process_failures, - frames_encoded: health_monitor.total_frames_encoded, - }); + if let Some(started) = drain_started { + // A slow drain only costs the tail frames; never fail the + // recording over it. Fall through to the normal shutdown. + if started.elapsed() > ENCODER_OPERATION_TIMEOUT { + tracing::warn!( + frames_encoded = health_monitor.total_frames_encoded, + "MediaFoundation H264 drain did not complete within {:?}; finishing stop without remaining output", + ENCODER_OPERATION_TIMEOUT + ); + break; + } } + // While draining, the drain deadline above is the only timeout; + // the health check would otherwise count pre-stop idle time. let health_status = health_monitor.check_health(); - if !health_status.is_healthy + if drain_started.is_none() + && !health_status.is_healthy && let Some(reason) = health_status.failure_reason { let _ = self.cleanup_encoder(); @@ -683,6 +688,10 @@ impl H264Encoder { .ProcessMessage(MFT_MESSAGE_NOTIFY_END_OF_STREAM, 0)?; self.transform .ProcessMessage(MFT_MESSAGE_COMMAND_DRAIN, 0)?; + // `get_frame` may have parked through a long pause + // before reporting stop; that idle time is not an + // encoder stall, so rebase the timeout clock. + health_monitor.notify_input_stall_recovered(); drain_started = Some(Instant::now()); should_exit = false; } diff --git a/crates/recording/examples/mf-texture-benchmark.rs b/crates/recording/examples/mf-texture-benchmark.rs index d64525c275e..767127d59ee 100644 --- a/crates/recording/examples/mf-texture-benchmark.rs +++ b/crates/recording/examples/mf-texture-benchmark.rs @@ -96,14 +96,19 @@ mod windows_bench { fn check_pixels(frame: &ffmpeg::frame::Video, index: usize) -> anyhow::Result<()> { let index = index % 8; - let red = ((1000 / 8 + index * 13) % 256) as f64; - let green = ((500 / 8 + index * 7) % 256) as f64; - let blue = ((1000 / 16 + 500 / 16 + index * 5) % 256) as f64; + // Sample inside the decoded frame, centred in a 16px pattern cell so + // block edges and chroma subsampling don't blur the reading. + let sample = |limit: usize, preferred: usize| (preferred.min(limit / 2) / 16) * 16 + 4; + let x = sample(frame.width() as usize, 1000); + let y = sample(frame.height() as usize, 500); + let red = ((x / 8 + index * 13) % 256) as f64; + let green = ((y / 8 + index * 7) % 256) as f64; + let blue = ((x / 16 + y / 16 + index * 5) % 256) as f64; let expected = 16.0 + (0.2126 * red + 0.7152 * green + 0.0722 * blue) * 219.0 / 255.0; - let actual = frame.data(0)[500 * frame.stride(0) + 1000] as f64; + let actual = frame.data(0)[y * frame.stride(0) + x] as f64; if (expected - actual).abs() > 4.0 { return Err(anyhow!( - "frame {index}: expected BT.709 luma {expected:.2}, got {actual}" + "frame {index}: expected BT.709 luma {expected:.2} at ({x}, {y}), got {actual}" )); } Ok(()) From c54cae573cd5ff3135cd4fb3fc3f20e3fd801ac2 Mon Sep 17 00:00:00 2001 From: Kyzer <36684609+kyzer1023@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:15:06 +0800 Subject: [PATCH 3/8] fix(windows-amd): collapse drain timeout if for clippy Combine nested if let / elapsed check so Windows Clippy collapsible_if (deny in AGENTS.md) passes. --- crates/enc-mediafoundation/src/video/h264.rs | 46 ++++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/enc-mediafoundation/src/video/h264.rs b/crates/enc-mediafoundation/src/video/h264.rs index 5cffb651cc6..a3fe0de3de8 100644 --- a/crates/enc-mediafoundation/src/video/h264.rs +++ b/crates/enc-mediafoundation/src/video/h264.rs @@ -5,12 +5,13 @@ use crate::{ }; use std::{ sync::{ - Arc, atomic::{AtomicBool, Ordering}, + Arc, }, time::{Duration, Instant}, }; use windows::{ + core::{Error, Interface}, Foundation::TimeSpan, Graphics::SizeInt32, Win32::{ @@ -24,22 +25,21 @@ use windows::{ }, Media::MediaFoundation::{ self, IMFAttributes, IMFDXGIDeviceManager, IMFMediaEventGenerator, IMFMediaType, - IMFSample, IMFTransform, MF_E_INVALIDMEDIATYPE, MF_E_NO_EVENTS_AVAILABLE, - MF_E_NO_MORE_TYPES, MF_E_TRANSFORM_TYPE_NOT_SET, MF_EVENT_FLAG_NO_WAIT, - MF_EVENT_FLAG_NONE, MF_EVENT_TYPE, MF_MT_ALL_SAMPLES_INDEPENDENT, MF_MT_AVG_BITRATE, - MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, - MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, - MF_TRANSFORM_ASYNC_UNLOCK, MFCreateDXGIDeviceManager, MFCreateDXGISurfaceBuffer, - MFCreateMediaType, MFCreateSample, MFMediaType_Video, MFT_ENUM_FLAG, + IMFSample, IMFTransform, MFCreateDXGIDeviceManager, MFCreateDXGISurfaceBuffer, + MFCreateMediaType, MFCreateSample, MFMediaType_Video, MFVideoFormat_H264, + MFVideoFormat_NV12, MFVideoInterlace_Progressive, MFT_ENUM_FLAG, MFT_ENUM_FLAG_HARDWARE, MFT_ENUM_FLAG_TRANSCODE_ONLY, MFT_MESSAGE_COMMAND_DRAIN, MFT_MESSAGE_COMMAND_FLUSH, MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, MFT_MESSAGE_NOTIFY_END_OF_STREAM, MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_MESSAGE_SET_D3D_MANAGER, - MFT_OUTPUT_DATA_BUFFER, MFT_SET_TYPE_TEST_ONLY, MFVideoFormat_H264, MFVideoFormat_NV12, - MFVideoInterlace_Progressive, + MFT_OUTPUT_DATA_BUFFER, MFT_SET_TYPE_TEST_ONLY, MF_EVENT_FLAG_NONE, + MF_EVENT_FLAG_NO_WAIT, MF_EVENT_TYPE, MF_E_INVALIDMEDIATYPE, MF_E_NO_EVENTS_AVAILABLE, + MF_E_NO_MORE_TYPES, MF_E_TRANSFORM_TYPE_NOT_SET, MF_MT_ALL_SAMPLES_INDEPENDENT, + MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, + MF_MT_MAJOR_TYPE, MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, + MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, MF_TRANSFORM_ASYNC_UNLOCK, }, }, - core::{Error, Interface}, }; const MAX_CONSECUTIVE_EMPTY_SAMPLES: u8 = 20; @@ -580,17 +580,17 @@ impl H264Encoder { let mut should_exit = false; let mut drain_started: Option = None; while !should_exit { - if let Some(started) = drain_started { + if let Some(started) = drain_started + && started.elapsed() > ENCODER_OPERATION_TIMEOUT + { // A slow drain only costs the tail frames; never fail the // recording over it. Fall through to the normal shutdown. - if started.elapsed() > ENCODER_OPERATION_TIMEOUT { - tracing::warn!( - frames_encoded = health_monitor.total_frames_encoded, - "MediaFoundation H264 drain did not complete within {:?}; finishing stop without remaining output", - ENCODER_OPERATION_TIMEOUT - ); - break; - } + tracing::warn!( + frames_encoded = health_monitor.total_frames_encoded, + "MediaFoundation H264 drain did not complete within {:?}; finishing stop without remaining output", + ENCODER_OPERATION_TIMEOUT + ); + break; } // While draining, the drain deadline above is the only timeout; // the health check would otherwise count pre-stop idle time. @@ -764,9 +764,9 @@ fn calculate_bitrate(width: u32, height: u32, fps: u32, multiplier: f32) -> u32 fn set_bt709_attributes(media_type: &IMFMediaType) -> windows::core::Result<()> { use MediaFoundation::{ - MF_MT_TRANSFER_FUNCTION, MF_MT_VIDEO_NOMINAL_RANGE, MF_MT_VIDEO_PRIMARIES, - MF_MT_YUV_MATRIX, MFNominalRange_16_235, MFVideoPrimaries_BT709, MFVideoTransFunc_709, - MFVideoTransferMatrix_BT709, + MFNominalRange_16_235, MFVideoPrimaries_BT709, MFVideoTransFunc_709, + MFVideoTransferMatrix_BT709, MF_MT_TRANSFER_FUNCTION, MF_MT_VIDEO_NOMINAL_RANGE, + MF_MT_VIDEO_PRIMARIES, MF_MT_YUV_MATRIX, }; unsafe { media_type.SetUINT32(&MF_MT_YUV_MATRIX, MFVideoTransferMatrix_BT709.0 as u32)?; From 6a1c312e5033051db4d971cf712ecc22e0d777d8 Mon Sep 17 00:00:00 2001 From: Kyzer <36684609+kyzer1023@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:57:51 +0800 Subject: [PATCH 4/8] style(windows-amd): rustfmt h264 imports --- crates/enc-mediafoundation/src/video/h264.rs | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/enc-mediafoundation/src/video/h264.rs b/crates/enc-mediafoundation/src/video/h264.rs index a3fe0de3de8..bc1748d8c08 100644 --- a/crates/enc-mediafoundation/src/video/h264.rs +++ b/crates/enc-mediafoundation/src/video/h264.rs @@ -5,13 +5,12 @@ use crate::{ }; use std::{ sync::{ - atomic::{AtomicBool, Ordering}, Arc, + atomic::{AtomicBool, Ordering}, }, time::{Duration, Instant}, }; use windows::{ - core::{Error, Interface}, Foundation::TimeSpan, Graphics::SizeInt32, Win32::{ @@ -25,21 +24,22 @@ use windows::{ }, Media::MediaFoundation::{ self, IMFAttributes, IMFDXGIDeviceManager, IMFMediaEventGenerator, IMFMediaType, - IMFSample, IMFTransform, MFCreateDXGIDeviceManager, MFCreateDXGISurfaceBuffer, - MFCreateMediaType, MFCreateSample, MFMediaType_Video, MFVideoFormat_H264, - MFVideoFormat_NV12, MFVideoInterlace_Progressive, MFT_ENUM_FLAG, + IMFSample, IMFTransform, MF_E_INVALIDMEDIATYPE, MF_E_NO_EVENTS_AVAILABLE, + MF_E_NO_MORE_TYPES, MF_E_TRANSFORM_TYPE_NOT_SET, MF_EVENT_FLAG_NO_WAIT, + MF_EVENT_FLAG_NONE, MF_EVENT_TYPE, MF_MT_ALL_SAMPLES_INDEPENDENT, MF_MT_AVG_BITRATE, + MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, MF_MT_MAJOR_TYPE, + MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, + MF_TRANSFORM_ASYNC_UNLOCK, MFCreateDXGIDeviceManager, MFCreateDXGISurfaceBuffer, + MFCreateMediaType, MFCreateSample, MFMediaType_Video, MFT_ENUM_FLAG, MFT_ENUM_FLAG_HARDWARE, MFT_ENUM_FLAG_TRANSCODE_ONLY, MFT_MESSAGE_COMMAND_DRAIN, MFT_MESSAGE_COMMAND_FLUSH, MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, MFT_MESSAGE_NOTIFY_END_OF_STREAM, MFT_MESSAGE_NOTIFY_END_STREAMING, MFT_MESSAGE_NOTIFY_START_OF_STREAM, MFT_MESSAGE_SET_D3D_MANAGER, - MFT_OUTPUT_DATA_BUFFER, MFT_SET_TYPE_TEST_ONLY, MF_EVENT_FLAG_NONE, - MF_EVENT_FLAG_NO_WAIT, MF_EVENT_TYPE, MF_E_INVALIDMEDIATYPE, MF_E_NO_EVENTS_AVAILABLE, - MF_E_NO_MORE_TYPES, MF_E_TRANSFORM_TYPE_NOT_SET, MF_MT_ALL_SAMPLES_INDEPENDENT, - MF_MT_AVG_BITRATE, MF_MT_FRAME_RATE, MF_MT_FRAME_SIZE, MF_MT_INTERLACE_MODE, - MF_MT_MAJOR_TYPE, MF_MT_PIXEL_ASPECT_RATIO, MF_MT_SUBTYPE, - MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, MF_TRANSFORM_ASYNC_UNLOCK, + MFT_OUTPUT_DATA_BUFFER, MFT_SET_TYPE_TEST_ONLY, MFVideoFormat_H264, MFVideoFormat_NV12, + MFVideoInterlace_Progressive, }, }, + core::{Error, Interface}, }; const MAX_CONSECUTIVE_EMPTY_SAMPLES: u8 = 20; @@ -764,9 +764,9 @@ fn calculate_bitrate(width: u32, height: u32, fps: u32, multiplier: f32) -> u32 fn set_bt709_attributes(media_type: &IMFMediaType) -> windows::core::Result<()> { use MediaFoundation::{ - MFNominalRange_16_235, MFVideoPrimaries_BT709, MFVideoTransFunc_709, - MFVideoTransferMatrix_BT709, MF_MT_TRANSFER_FUNCTION, MF_MT_VIDEO_NOMINAL_RANGE, - MF_MT_VIDEO_PRIMARIES, MF_MT_YUV_MATRIX, + MF_MT_TRANSFER_FUNCTION, MF_MT_VIDEO_NOMINAL_RANGE, MF_MT_VIDEO_PRIMARIES, + MF_MT_YUV_MATRIX, MFNominalRange_16_235, MFVideoPrimaries_BT709, MFVideoTransFunc_709, + MFVideoTransferMatrix_BT709, }; unsafe { media_type.SetUINT32(&MF_MT_YUV_MATRIX, MFVideoTransferMatrix_BT709.0 as u32)?; From d6134d28575d801fc8d3fc6b5151e9cfb9f347da Mon Sep 17 00:00:00 2001 From: Kyzer <36684609+kyzer1023@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:58:38 +0800 Subject: [PATCH 5/8] fix: preserve AMD camera fallback for compressed frames --- crates/recording/src/output_pipeline/win.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/recording/src/output_pipeline/win.rs b/crates/recording/src/output_pipeline/win.rs index 36433a177d4..47a917b2f54 100644 --- a/crates/recording/src/output_pipeline/win.rs +++ b/crates/recording/src/output_pipeline/win.rs @@ -845,7 +845,12 @@ impl Muxer for WindowsCameraMuxer { .map_err(|e| anyhow!("CameraSoftwareEncoder/{e}")) }; - if encoder_preferences.should_force_software() { + if encoder_preferences.should_force_software() + || matches!( + cap_frame_converter::detect_primary_gpu().map(|gpu| gpu.vendor), + Some(cap_frame_converter::GpuVendor::Amd) + ) + { return fallback(None); } From f7d0f22821ae059f267b3aa5f043669b560c9fc2 Mon Sep 17 00:00:00 2001 From: Kyzer <36684609+kyzer1023@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:11:16 +0800 Subject: [PATCH 6/8] fix: configure current Hyprland in the Linux harness --- scripts/linux-desktop-harness/arch-up.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/linux-desktop-harness/arch-up.sh b/scripts/linux-desktop-harness/arch-up.sh index 6a1a8028862..043d83d2e23 100755 --- a/scripts/linux-desktop-harness/arch-up.sh +++ b/scripts/linux-desktop-harness/arch-up.sh @@ -30,6 +30,17 @@ debug { disable_logs = false } exec-once = sh -c '$export_session' exec-once = waybar CONF + cat >"$HOME/.config/hypr/hyprland.lua" <"$HOME/.config/hypr/xdph.conf" < Date: Sun, 1 Nov 2026 02:40:44 +0800 Subject: [PATCH 7/8] fix: allow busy Wayland compositors to enumerate displays --- .../src/platform/linux/wayland.rs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/scap-targets/src/platform/linux/wayland.rs b/crates/scap-targets/src/platform/linux/wayland.rs index c4b8bcdbf6a..8c0118f92bb 100644 --- a/crates/scap-targets/src/platform/linux/wayland.rs +++ b/crates/scap-targets/src/platform/linux/wayland.rs @@ -15,6 +15,8 @@ use wayland_protocols::xdg::xdg_output::zv1::client::{zxdg_output_manager_v1, zx use super::{DisplayImpl, LogicalBounds, LogicalPosition, LogicalSize}; +const QUERY_TIMEOUT: Duration = Duration::from_secs(1); + #[derive(Clone, Copy, Default)] struct OutputGeometry { position: (i32, i32), @@ -108,7 +110,7 @@ pub(super) fn displays() -> Option> { runtime.join(display) }; let connection = Connection::from_socket(UnixStream::connect(socket).ok()?).ok()?; - query(&connection, Duration::from_millis(250)) + query(&connection, QUERY_TIMEOUT) } fn output_uuid(name: &str) -> Option { @@ -453,6 +455,45 @@ mod tests { assert!(started.elapsed() < Duration::from_secs(1)); } + #[test] + fn busy_compositor_can_finish_display_enumeration_after_a_quarter_second() { + use std::io::{Read, Write}; + + let (client, mut server) = UnixStream::pair().unwrap(); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let responder = std::thread::spawn(move || -> std::io::Result<()> { + for reply in 0..2 { + loop { + let mut header = [0u8; 8]; + server.read_exact(&mut header)?; + let object = u32::from_ne_bytes(header[..4].try_into().unwrap()); + let message = u32::from_ne_bytes(header[4..].try_into().unwrap()); + let mut payload = vec![0; (message >> 16) as usize - header.len()]; + server.read_exact(&mut payload)?; + if object != 1 || message & 0xffff != 0 { + continue; + } + if reply == 0 { + std::thread::sleep(Duration::from_millis(350)); + } + let callback = u32::from_ne_bytes(payload[..4].try_into().unwrap()); + server.write_all(&callback.to_ne_bytes())?; + server.write_all(&(12u32 << 16).to_ne_bytes())?; + server.write_all(&0u32.to_ne_bytes())?; + break; + } + } + let _ = finished_rx.recv(); + Ok(()) + }); + let connection = Connection::from_socket(client).unwrap(); + let result = query(&connection, QUERY_TIMEOUT); + drop(connection); + let _ = finished_tx.send(()); + responder.join().unwrap().unwrap(); + assert!(result.is_some()); + } + #[test] fn wayland_session_does_not_use_xwayland_dimensions() { assert!(super::super::prefers_wayland_environment( From b1189818e73c822e526057f850adbe51594037a4 Mon Sep 17 00:00:00 2001 From: Kyzer <36684609+kyzer1023@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:29:29 +0800 Subject: [PATCH 8/8] chore: retry CI after hosted runner disconnect