From f2717216caad1414ec3faced38a0e5df5d4a4a59 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 8 Nov 2026 14:51:46 -0500 Subject: [PATCH 1/3] Wake a laptop's discrete card when an iPad looks for PCs A laptop powers its discrete card off entirely (D3cold) about 20 s after its last use: on an AMD hybrid laptop the card and every PCIe port above it, up to the root port, went to D3. The next D3D11 device or AMF open on it then took about 800 ms instead of 160, and an AMF session's first picture after 40 s idle took about 870 ms instead of 130. An iPad looks for PCs over mDNS on its connect screen, seconds before it sends HELLO. The host now reads mDNS queries alongside its daemon, and a PTR or ANY question for the service from another device (not the PC's own daemon, which probes at each registration and re-advertisement) creates a D3D11 device on the encoder's adapter on a thread of its own and drops it. Nothing is kept open, and nothing wakes the card on a timer: a wake comes at most every 15 s, never while a viewer is connected, and each one no session followed doubles the wait for the next, up to 10 min. --- host/src/discovery.rs | 197 +++++++++++++++++++++++++++++++++- host/src/gpu_wake.rs | 184 +++++++++++++++++++++++++++++++ host/src/lib.rs | 1 + host/src/main.rs | 20 +++- host/src/transport/session.rs | 5 + 5 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 host/src/gpu_wake.rs diff --git a/host/src/discovery.rs b/host/src/discovery.rs index f9dcac4c..8b3cb9b6 100644 --- a/host/src/discovery.rs +++ b/host/src/discovery.rs @@ -46,8 +46,15 @@ impl ReAdvertLog { /// documented upsert in mdns-sd, so the refresh never takes the service off /// the air (the old unregister-first cycle opened a ~1s discovery hole). /// +/// `on_browse` runs each time another device on the network looks for PCs +/// running EternalMonitor, as an iPad does on its connect screen (see +/// [`watch_browsers`] and [`crate::gpu_wake`]). +/// /// Returns the `ServiceDaemon` handle — call [`say_goodbye`] on it at exit. -pub fn advertise_service(port: u16) -> Option { +pub fn advertise_service( + port: u16, + on_browse: impl FnMut() + Send + 'static, +) -> Option { let mdns = match ServiceDaemon::new() { Ok(d) => d, Err(e) => { @@ -78,6 +85,7 @@ pub fn advertise_service(port: u16) -> Option { } info!("mDNS service registered — iPad can now discover this host"); PIPELINE_STATS.lock().mdns_active = true; + watch_browsers(on_browse); let mdns_clone = mdns.clone(); let host_fqdn_for_thread = host_fqdn.clone(); @@ -107,6 +115,131 @@ pub fn advertise_service(port: u16) -> Option { Some(mdns) } +/// Calls `on_browse` on a thread of its own each time a device that isn't +/// this PC asks the network for the service: a PTR or ANY question for +/// [`SERVICE_TYPE`]. The daemon's own events can't tell: it answers its own +/// probes too, at each registration and re-advertisement. Reads alongside +/// the daemon on the shared mDNS port and sends nothing. +#[cfg(windows)] +fn watch_browsers(mut on_browse: impl FnMut() + Send + 'static) { + use socket2::{Domain, Protocol, Socket, Type}; + let group = std::net::Ipv4Addr::new(224, 0, 0, 251); + let socket = (|| -> std::io::Result { + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_reuse_address(true)?; + socket.bind(&std::net::SocketAddr::from(([0, 0, 0, 0], 5353)).into())?; + let mut joined = 0; + for address in own_ipv4() { + joined += usize::from(socket.join_multicast_v4(&group, &address).is_ok()); + } + if joined == 0 { + socket.join_multicast_v4(&group, &std::net::Ipv4Addr::UNSPECIFIED)?; + } + Ok(socket.into()) + })(); + let socket = match socket { + Ok(socket) => socket, + Err(error) => { + info!(%error, "Not watching for iPads looking for this PC"); + return; + } + }; + let _ = thread::Builder::new() + .name("mdns-browsers".into()) + .spawn(move || { + let mut packet = [0u8; 1500]; + while let Ok((len, from)) = socket.recv_from(&mut packet) { + if asks_for_service(&packet[..len]) && !is_own(from.ip()) { + on_browse(); + } + } + }); +} + +#[cfg(not(windows))] +fn watch_browsers(_on_browse: impl FnMut() + Send + 'static) {} + +/// This PC's IPv4 addresses. +#[cfg(windows)] +fn own_ipv4() -> Vec { + crate::network::adapters() + .unwrap_or_default() + .into_iter() + .filter(|adapter| adapter.up) + .flat_map(|adapter| adapter.ipv4) + .collect() +} + +/// Whether a query came from this PC: its own daemon, or another program on it. +#[cfg(windows)] +fn is_own(address: IpAddr) -> bool { + address.is_loopback() + || match address { + IpAddr::V4(address) => own_ipv4().contains(&address), + IpAddr::V6(_) => false, + } +} + +/// Whether `packet` is a DNS query (not a response) with a PTR or ANY +/// question for [`SERVICE_TYPE`], as a device browsing for EternalMonitor +/// PCs sends. +#[cfg(any(windows, test))] +pub(crate) fn asks_for_service(packet: &[u8]) -> bool { + const PTR: u16 = 12; + const ANY: u16 = 255; + let u16_at = |at: usize| -> Option { + Some(u16::from_be_bytes([*packet.get(at)?, *packet.get(at + 1)?])) + }; + let (Some(flags), Some(questions)) = (u16_at(2), u16_at(4)) else { + return false; + }; + if flags & 0x8000 != 0 { + return false; + } + let wanted = SERVICE_TYPE.trim_end_matches('.'); + let mut at = 12; + for _ in 0..questions { + let Some((name, next)) = dns_name(packet, at) else { + return false; + }; + let Some(kind) = u16_at(next) else { + return false; + }; + if (kind == PTR || kind == ANY) && name.eq_ignore_ascii_case(wanted) { + return true; + } + at = next + 4; + } + false +} + +/// The dotted name at `at`, following compression pointers, and where the +/// record goes on after it. None if it is malformed or loops. +#[cfg(any(windows, test))] +fn dns_name(packet: &[u8], mut at: usize) -> Option<(String, usize)> { + let mut name = String::new(); + let mut after = None; + for _ in 0..32 { + let len = *packet.get(at)? as usize; + if len == 0 { + return Some((name, after.unwrap_or(at + 1))); + } + if len & 0xC0 == 0xC0 { + let target = ((len & 0x3F) << 8) | *packet.get(at + 1)? as usize; + after.get_or_insert(at + 2); + at = target; + continue; + } + let label = packet.get(at + 1..at + 1 + len)?; + if !name.is_empty() { + name.push('.'); + } + name.push_str(std::str::from_utf8(label).ok()?); + at += 1 + len; + } + None +} + /// Send mDNS goodbye packets and stop the daemon. Called on the way out so /// iPads drop the host from their scan list promptly instead of waiting for /// the TTL to lapse. The short grace period lets the goodbyes hit the wire — @@ -153,6 +286,68 @@ fn hostname() -> Option { mod tests { use super::*; + /// A DNS packet with one question per `(name, type)`. + fn query(flags: u16, questions: &[(&str, u16)]) -> Vec { + let mut packet = vec![0, 0]; + packet.extend_from_slice(&flags.to_be_bytes()); + packet.extend_from_slice(&(questions.len() as u16).to_be_bytes()); + packet.extend_from_slice(&[0; 6]); + for (name, kind) in questions { + for label in name.split('.') { + packet.push(label.len() as u8); + packet.extend_from_slice(label.as_bytes()); + } + packet.push(0); + packet.extend_from_slice(&kind.to_be_bytes()); + packet.extend_from_slice(&[0x80, 1]); + } + packet + } + + #[test] + fn a_browse_for_the_service_is_told_from_other_mdns_traffic() { + let ours = "_eternaldisplay._udp.local"; + assert!(asks_for_service(&query(0, &[(ours, 12)]))); + assert!(asks_for_service(&query( + 0, + &[("_airplay._tcp.local", 12), (ours, 12)] + ))); + assert!(asks_for_service(&query( + 0, + &[("_EternalDisplay._UDP.local", 255)] + ))); + assert!(!asks_for_service(&query(0, &[("_airplay._tcp.local", 12)]))); + assert!(!asks_for_service(&query(0, &[(ours, 1)])), "an A question"); + assert!( + !asks_for_service(&query(0x8400, &[(ours, 12)])), + "a response" + ); + // A second question that points back into the first name. + let mut packet = query(0, &[("_airplay._tcp.local", 12)]); + packet[5] = 2; + packet.extend_from_slice(&[15]); + packet.extend_from_slice(b"_eternaldisplay"); + packet.extend_from_slice(&[4]); + packet.extend_from_slice(b"_udp"); + // "local" is at offset 12 + 1 + 8 + 1 + 4 = 26. + packet.extend_from_slice(&[0xC0, 26, 0, 12, 0, 1]); + assert!(asks_for_service(&packet)); + } + + #[test] + fn a_malformed_or_looping_packet_is_not_a_browse() { + assert!(!asks_for_service(&[])); + assert!(!asks_for_service(&[0; 11])); + let full = query(0, &[("_eternaldisplay._udp.local", 12)]); + for cut in 12..full.len() - 4 { + assert!(!asks_for_service(&full[..cut]), "cut at {cut}"); + } + // A pointer to itself. + let mut looping = vec![0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]; + looping.extend_from_slice(&[0xC0, 12, 0, 12, 0, 1]); + assert!(!asks_for_service(&looping)); + } + /// The service pins no address: mdns-sd advertises every interface's, /// Wi-Fi and Tailscale alike. The Address row's ranking never reaches it. #[test] diff --git a/host/src/gpu_wake.rs b/host/src/gpu_wake.rs new file mode 100644 index 00000000..8cd1bf1e --- /dev/null +++ b/host/src/gpu_wake.rs @@ -0,0 +1,184 @@ +//! Wakes the encoder's graphics card when an iPad is probably about to +//! connect. A laptop's discrete card powers off entirely (D3cold) about 20 s +//! after its last use: its PCIe link and the ports above it go down too. On +//! an AMD hybrid laptop the next D3D11 device or AMF open on it then took +//! about 800 ms instead of 160, all of it inside the first session's first +//! picture. An iPad looks for PCs over mDNS on its connect screen, seconds +//! before it sends HELLO, so its query (see `discovery::watch_browsers`) +//! starts the card waking: one tiny D3D11 device on it, dropped at once. +//! Nothing is kept open, and nothing wakes it on a timer. The first AMF open +//! then took about 280 ms instead of 830; holding the device for 10 s +//! changed nothing measurable, so it isn't held. + +use std::time::{Duration, Instant}; + +/// The shortest time between two wakes. A card woken this recently is still +/// in D3hot at worst, which wakes in about 160 ms. +pub const WAKE_EVERY: Duration = Duration::from_secs(15); +/// The longest time between two wakes after many wasted ones. +const WAKE_EVERY_AT_MOST: Duration = Duration::from_secs(600); + +/// When a browse may wake the card. Devices browse without connecting too +/// (an iPad left on its connect screen, another app's scan), so each wake no +/// session followed doubles the time to the next, up to +/// [`WAKE_EVERY_AT_MOST`]: a few wakes an hour at most, never a keep-alive. +/// A session resets it. +#[derive(Debug)] +pub struct WakeGate { + last: Option, + every: Duration, + /// `Session::sessions_started` at the last wake. + sessions_at_wake: Option, +} + +impl Default for WakeGate { + fn default() -> Self { + Self { + last: None, + every: WAKE_EVERY, + sessions_at_wake: None, + } + } +} + +impl WakeGate { + /// A device browsed for the service at `now`. Whether to wake the card: + /// never while a viewer is connected (`in_session`, the card is in use). + /// `sessions` is `Session::sessions_started`: a session since the last + /// wake means the wake paid off. + pub fn browse(&mut self, now: Instant, in_session: bool, sessions: u32) -> bool { + if in_session { + return false; + } + if let Some(last) = self.last { + if now.saturating_duration_since(last) < self.every { + return false; + } + } + let paid = self.sessions_at_wake.is_none_or(|at| at != sessions); + self.every = if paid { + WAKE_EVERY + } else { + (self.every * 2).min(WAKE_EVERY_AT_MOST) + }; + self.sessions_at_wake = Some(sessions); + self.last = Some(now); + true + } +} + +/// Starts waking the graphics card at DXGI adapter `adapter_index`, on a +/// thread of its own, unless a wake is still running. +#[cfg(windows)] +pub fn wake(adapter_index: u32) { + use std::sync::atomic::{AtomicBool, Ordering}; + static RUNNING: AtomicBool = AtomicBool::new(false); + if RUNNING.swap(true, Ordering::SeqCst) { + return; + } + let spawned = std::thread::Builder::new() + .name("gpu-wake".into()) + .spawn(move || { + let started = Instant::now(); + match touch(adapter_index) { + Ok(()) => tracing::debug!( + adapter_index, + took_ms = started.elapsed().as_millis() as u64, + "Woke the encoder's graphics card for a viewer that may connect" + ), + Err(error) => { + tracing::debug!(adapter_index, %error, "Could not wake the graphics card") + } + } + RUNNING.store(false, Ordering::SeqCst); + }); + if spawned.is_err() { + RUNNING.store(false, Ordering::SeqCst); + } +} + +#[cfg(not(windows))] +pub fn wake(_adapter_index: u32) {} + +/// A D3D11 device on the adapter, dropped at once: enough to power it up. +#[cfg(windows)] +fn touch(adapter_index: u32) -> windows::core::Result<()> { + use windows::core::Interface; + use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN; + use windows::Win32::Graphics::Direct3D11::{D3D11CreateDevice, D3D11_SDK_VERSION}; + use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1}; + let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }?; + let adapter: IDXGIAdapter = unsafe { factory.EnumAdapters1(adapter_index) }?.cast()?; + let mut device = None; + unsafe { + D3D11CreateDevice( + &adapter, + D3D_DRIVER_TYPE_UNKNOWN, + None, + Default::default(), + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + None, + ) + }?; + drop(device); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(start: Instant, s: u64) -> Instant { + start + Duration::from_secs(s) + } + + #[test] + fn a_browse_wakes_the_card_at_most_once_per_interval_and_never_in_a_session() { + let start = Instant::now(); + let mut gate = WakeGate::default(); + assert!(gate.browse(at(start, 0), false, 7)); + assert!( + !gate.browse(at(start, 5), false, 7), + "a scan's burst of queries" + ); + assert!(!gate.browse(at(start, 14), false, 7)); + assert!(!gate.browse(at(start, 40), true, 8), "the card is in use"); + // The iPad connected and left: the wake paid off. + assert!(gate.browse(at(start, 60), false, 8)); + assert!(gate.browse(at(start, 75), false, 9)); + } + + #[test] + fn wakes_no_session_followed_come_ever_less_often() { + let start = Instant::now(); + let mut gate = WakeGate::default(); + // A device that browses every 20 s and never connects. + let mut wakes = Vec::new(); + for s in (0..3600).step_by(20) { + if gate.browse(at(start, s), false, 7) { + wakes.push(s); + } + } + let gaps: Vec = wakes.windows(2).map(|pair| pair[1] - pair[0]).collect(); + assert!(gaps.windows(2).all(|pair| pair[0] <= pair[1]), "{gaps:?}"); + // Then one every ten minutes: six an hour. + let settled = &gaps[gaps.len() - 3..]; + assert!( + settled + .iter() + .all(|&gap| gap >= WAKE_EVERY_AT_MOST.as_secs()), + "{gaps:?}" + ); + assert!( + wakes.len() <= 12, + "{} wakes in the first hour: {wakes:?}", + wakes.len() + ); + // An iPad connects: back to the short interval. + assert!(gate.browse(at(start, 4000), false, 8)); + assert!(gate.browse(at(start, 4015), false, 9)); + } +} diff --git a/host/src/lib.rs b/host/src/lib.rs index 4b7be678..f57c0ed6 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -14,6 +14,7 @@ pub mod encoder; pub mod feedback; pub mod firewall; pub mod gpu_preference; +pub mod gpu_wake; pub mod gui; pub mod handoff; pub mod i18n; diff --git a/host/src/main.rs b/host/src/main.rs index 8415cda5..d095a2c8 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -260,11 +260,29 @@ fn main() -> Result<(), Box> { } } + // An iPad looking for PCs starts waking the encoder's graphics card, which + // a laptop powers off when idle (see `gpu_wake`). + let wake_card = (gpu_info.encoder_name != "libx264").then_some(gpu_info.adapter_index); + let wake_shared = shared.clone(); + let mut wake_gate = eternal_host::gpu_wake::WakeGate::default(); + let on_browse = move || { + if let Some(adapter_index) = wake_card { + let sessions = wake_shared.session.lock().sessions_started(); + if wake_gate.browse( + std::time::Instant::now(), + wake_shared.client_connected(), + sessions, + ) { + eternal_host::gpu_wake::wake(adapter_index); + } + } + }; + let supervisor_thread = std::thread::spawn(move || { eternal_host::supervisor::run(listen_port, shared, gpu_info, supervisor_tx, supervisor_rx); }); - let mdns = discovery::advertise_service(listen_port); + let mdns = discovery::advertise_service(listen_port, on_browse); // The quit path, once: after the window closes (or the headless loop // ends), or from the first-paint watcher when the user quits while the diff --git a/host/src/transport/session.rs b/host/src/transport/session.rs index be2f8475..a2288121 100644 --- a/host/src/transport/session.rs +++ b/host/src/transport/session.rs @@ -865,6 +865,11 @@ impl Session { self.active.as_ref().map(|s| s.session_id) } + /// Changes each time a session starts: a count, from a random start. + pub fn sessions_started(&self) -> u32 { + self.next_session_id + } + pub fn link_lost(&mut self, link: LinkId) -> Actions { let on_link = self .active From 2ea4a2c270c96cbae64a48437c1498552c7100a3 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 8 Nov 2026 15:09:11 -0500 Subject: [PATCH 2/3] Keep the browse watch running, and wake only when the card will be used - The browse watch read into a 1500-byte buffer and stopped at its first receive error: one oversized mDNS datagram (WSAEMSGSIZE) ended warming until the app restarted. It now reads up to mDNS's 9000 bytes, goes on at once after errors that pass, and waits longer after repeated or other errors, up to a minute, without stopping. - A HELLO could start a session between the browse's check and the wake's device. The wake thread now checks again, just before it creates the device, that no session has started since (the session count), and drops the wake if one has. - After a long backoff a session didn't restore the short wait until the old ten-minute one had passed: the gate checked the wait before noticing the session. It now resets the wait first, keeping the 15 s minimum. - Whether to wake now follows the encoder that will stream: the override in Settings, and no wake after a fallback to the CPU, nor for an encoder on another card. --- host/src/discovery.rs | 74 +++++++++++++++++++++++++-- host/src/gpu_wake.rs | 115 ++++++++++++++++++++++++++++++++++++++---- host/src/main.rs | 33 ++++++++---- 3 files changed, 198 insertions(+), 24 deletions(-) diff --git a/host/src/discovery.rs b/host/src/discovery.rs index 8b3cb9b6..e33eeb5d 100644 --- a/host/src/discovery.rs +++ b/host/src/discovery.rs @@ -147,15 +147,57 @@ fn watch_browsers(mut on_browse: impl FnMut() + Send + 'static) { let _ = thread::Builder::new() .name("mdns-browsers".into()) .spawn(move || { - let mut packet = [0u8; 1500]; - while let Ok((len, from)) = socket.recv_from(&mut packet) { - if asks_for_service(&packet[..len]) && !is_own(from.ip()) { - on_browse(); + let mut packet = vec![0u8; MDNS_PACKET_MAX]; + let mut errors = 0u32; + loop { + match socket.recv_from(&mut packet) { + Ok((len, from)) => { + errors = 0; + if asks_for_service(&packet[..len]) && !is_own(from.ip()) { + on_browse(); + } + } + Err(error) => { + let pause = after_receive_error(&error, errors); + errors = errors.saturating_add(1); + if errors.is_power_of_two() { + tracing::debug!(%error, errors, "mDNS browse watch: receive failed"); + } + thread::sleep(pause); + } } } }); } +/// The largest mDNS message (RFC 6762 section 17). A longer datagram fails +/// the receive with WSAEMSGSIZE, and is dropped. +#[cfg(windows)] +const MDNS_PACKET_MAX: usize = 9000; + +/// How long the browse watch pauses after a receive error, the `in_a_row`th +/// since the last packet. It never stops: one oversized or reset datagram +/// had ended it until the app restarted. Errors that pass (an oversized +/// datagram, a reset from an ICMP reply, an interruption) go on at once, a +/// few in a row; anything else, or more, waits longer each time, up to a +/// minute. +#[cfg(any(windows, test))] +fn after_receive_error(error: &std::io::Error, in_a_row: u32) -> Duration { + use std::io::ErrorKind; + const WSAEMSGSIZE: i32 = 10040; + let passing = matches!( + error.kind(), + ErrorKind::Interrupted + | ErrorKind::WouldBlock + | ErrorKind::TimedOut + | ErrorKind::ConnectionReset + ) || error.raw_os_error() == Some(WSAEMSGSIZE); + if passing && in_a_row < 8 { + return Duration::ZERO; + } + (Duration::from_millis(100) * 2u32.pow(in_a_row.min(10))).min(Duration::from_secs(60)) +} + #[cfg(not(windows))] fn watch_browsers(_on_browse: impl FnMut() + Send + 'static) {} @@ -334,6 +376,30 @@ mod tests { assert!(asks_for_service(&packet)); } + #[test] + fn a_receive_error_never_ends_the_browse_watch() { + use std::io::{Error, ErrorKind}; + let oversized = Error::from_raw_os_error(10040); + let reset = Error::from(ErrorKind::ConnectionReset); + let interrupted = Error::from(ErrorKind::Interrupted); + for error in [&oversized, &reset, &interrupted] { + assert_eq!(after_receive_error(error, 0), Duration::ZERO, "{error}"); + assert_eq!(after_receive_error(error, 7), Duration::ZERO, "{error}"); + assert!( + after_receive_error(error, 8) > Duration::ZERO, + "{error} keeps coming" + ); + } + let other = Error::from(ErrorKind::PermissionDenied); + let pauses: Vec = (0..20).map(|n| after_receive_error(&other, n)).collect(); + assert!(pauses[0] > Duration::ZERO); + assert!( + pauses.windows(2).all(|pair| pair[0] <= pair[1]), + "{pauses:?}" + ); + assert_eq!(*pauses.last().unwrap(), Duration::from_secs(60)); + } + #[test] fn a_malformed_or_looping_packet_is_not_a_browse() { assert!(!asks_for_service(&[])); diff --git a/host/src/gpu_wake.rs b/host/src/gpu_wake.rs index 8cd1bf1e..3718178d 100644 --- a/host/src/gpu_wake.rs +++ b/host/src/gpu_wake.rs @@ -50,27 +50,58 @@ impl WakeGate { if in_session { return false; } + // A session since the last wake restores the short wait first: after + // a long backoff, the next viewer's browse mustn't wait it out. + let paid = self.sessions_at_wake.is_none_or(|at| at != sessions); + if paid { + self.every = WAKE_EVERY; + } if let Some(last) = self.last { if now.saturating_duration_since(last) < self.every { return false; } } - let paid = self.sessions_at_wake.is_none_or(|at| at != sessions); - self.every = if paid { - WAKE_EVERY - } else { - (self.every * 2).min(WAKE_EVERY_AT_MOST) - }; + if !paid { + self.every = (self.every * 2).min(WAKE_EVERY_AT_MOST); + } self.sessions_at_wake = Some(sessions); self.last = Some(now); true } } +/// Whether the encoder that will stream runs on the card detection chose +/// (`detected`): the override in Settings (`encoder_override`) if any, and +/// not a software fallback. Only that card is woken; a CPU encoder, or one +/// on another card, needs none. +pub fn wakes_card(encoder_override: Option<&str>, detected: &str, software_fallback: bool) -> bool { + let family = |name: &str| { + name.strip_prefix("h264_") + .or_else(|| name.strip_prefix("hevc_")) + .map(str::to_owned) + }; + let effective = encoder_override.unwrap_or(detected); + !software_fallback && family(effective).is_some() && family(effective) == family(detected) +} + +/// Runs `touch` unless `still_wanted`, checked just before, says a session +/// has started since the wake was decided: the card is then the session's, +/// and a device of the wake's during encoder or capture startup would only +/// compete with it. Returns whether it ran. +#[cfg(any(windows, test))] +fn run_wake(still_wanted: impl FnOnce() -> bool, touch: impl FnOnce()) -> bool { + if !still_wanted() { + return false; + } + touch(); + true +} + /// Starts waking the graphics card at DXGI adapter `adapter_index`, on a -/// thread of its own, unless a wake is still running. +/// thread of its own, unless a wake is still running. The thread drops it +/// if `still_wanted` returns false when it starts (see [`run_wake`]). #[cfg(windows)] -pub fn wake(adapter_index: u32) { +pub fn wake(adapter_index: u32, still_wanted: impl FnOnce() -> bool + Send + 'static) { use std::sync::atomic::{AtomicBool, Ordering}; static RUNNING: AtomicBool = AtomicBool::new(false); if RUNNING.swap(true, Ordering::SeqCst) { @@ -80,7 +111,7 @@ pub fn wake(adapter_index: u32) { .name("gpu-wake".into()) .spawn(move || { let started = Instant::now(); - match touch(adapter_index) { + let ran = run_wake(still_wanted, || match touch(adapter_index) { Ok(()) => tracing::debug!( adapter_index, took_ms = started.elapsed().as_millis() as u64, @@ -89,6 +120,12 @@ pub fn wake(adapter_index: u32) { Err(error) => { tracing::debug!(adapter_index, %error, "Could not wake the graphics card") } + }); + if !ran { + tracing::debug!( + adapter_index, + "A session started first: not waking the graphics card" + ); } RUNNING.store(false, Ordering::SeqCst); }); @@ -98,7 +135,7 @@ pub fn wake(adapter_index: u32) { } #[cfg(not(windows))] -pub fn wake(_adapter_index: u32) {} +pub fn wake(_adapter_index: u32, _still_wanted: impl FnOnce() -> bool + Send + 'static) {} /// A D3D11 device on the adapter, dropped at once: enough to power it up. #[cfg(windows)] @@ -151,6 +188,64 @@ mod tests { assert!(gate.browse(at(start, 75), false, 9)); } + #[test] + fn only_the_encoder_that_will_stream_on_the_detected_card_wakes_it() { + assert!(wakes_card(None, "h264_amf", false)); + assert!( + wakes_card(Some("hevc_amf"), "h264_amf", false), + "its HEVC sibling" + ); + assert!( + !wakes_card(Some("libx264"), "h264_amf", false), + "a CPU override" + ); + assert!(!wakes_card(None, "h264_amf", true), "a software fallback"); + assert!(!wakes_card(None, "libx264", false)); + assert!( + !wakes_card(Some("h264_qsv"), "h264_amf", false), + "another card" + ); + assert!( + !wakes_card(Some("h264_nvenc"), "h264_amf", false), + "another card" + ); + } + + #[test] + fn a_wake_is_dropped_when_a_session_started_first() { + let touched = std::cell::Cell::new(false); + assert!(!run_wake(|| false, || touched.set(true))); + assert!( + !touched.get(), + "the card was touched during the session's startup" + ); + assert!(run_wake(|| true, || touched.set(true))); + assert!(touched.get()); + } + + #[test] + fn a_session_after_a_long_backoff_restores_the_short_interval_at_once() { + let start = Instant::now(); + let mut gate = WakeGate::default(); + // Browses with nobody connecting, until the wait is at its longest. + let mut last = 0; + for s in (0..7200).step_by(20) { + if gate.browse(at(start, s), false, 7) { + last = s; + } + } + assert_eq!(gate.every, WAKE_EVERY_AT_MOST); + // An iPad connects, leaves, and browses again 20 s after that wake. + assert!( + gate.browse(at(start, last + 20), false, 8), + "the session's browse waited out the old ten-minute wait" + ); + assert!( + !gate.browse(at(start, last + 30), false, 8), + "the 15 s minimum" + ); + } + #[test] fn wakes_no_session_followed_come_ever_less_often() { let start = Instant::now(); diff --git a/host/src/main.rs b/host/src/main.rs index d095a2c8..e25333f0 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -262,19 +262,32 @@ fn main() -> Result<(), Box> { // An iPad looking for PCs starts waking the encoder's graphics card, which // a laptop powers off when idle (see `gpu_wake`). - let wake_card = (gpu_info.encoder_name != "libx264").then_some(gpu_info.adapter_index); + let (wake_adapter, detected_encoder) = (gpu_info.adapter_index, gpu_info.encoder_name.clone()); let wake_shared = shared.clone(); let mut wake_gate = eternal_host::gpu_wake::WakeGate::default(); let on_browse = move || { - if let Some(adapter_index) = wake_card { - let sessions = wake_shared.session.lock().sessions_started(); - if wake_gate.browse( - std::time::Instant::now(), - wake_shared.client_connected(), - sessions, - ) { - eternal_host::gpu_wake::wake(adapter_index); - } + // The encoder that will stream: the override, unless the pipeline + // fell back to the CPU. + let encoder_override = wake_shared.encoder_override.lock().clone(); + let software_fallback = stats::PIPELINE_STATS.lock().using_software_fallback; + if !eternal_host::gpu_wake::wakes_card( + encoder_override.as_deref(), + &detected_encoder, + software_fallback, + ) { + return; + } + let sessions = wake_shared.session.lock().sessions_started(); + if wake_gate.browse( + std::time::Instant::now(), + wake_shared.client_connected(), + sessions, + ) { + // A HELLO between here and the wake's device takes priority. + let shared = wake_shared.clone(); + eternal_host::gpu_wake::wake(wake_adapter, move || { + !shared.client_connected() && shared.session.lock().sessions_started() == sessions + }); } }; From 25b5fbc2528b3c6284a3a7a7792eb94ea767ae15 Mon Sep 17 00:00:00 2001 From: Ali Younes Date: Sun, 8 Nov 2026 15:29:15 -0500 Subject: [PATCH 3/3] Wake the card of the encoder that streams, as the pipeline last opened it The browse callback judged a snapshot of startup: the detected encoder, the Settings override and the CPU-fallback flag. A hardware fallback (NVENC or AMF failing over to Quick Sync, which clears that flag) still woke the card no one used, and a late GPU detection the supervisor adopted never became wakeable: startup's libx264 stayed in the callback. SharedControl::wake_adapter now holds the card to wake. Each encoder open, fallbacks included, sets it from the encoder that opened and the pipeline's GPU: that GPU's adapter for a hardware encoder of its family, None for the CPU or another card's encoder. Adopting a late detection sets it too, until the new pipeline's encoder opens. The callback reads only that. --- host/src/control.rs | 6 +++++ host/src/encoder/mod.rs | 31 +++++++++++++++++++---- host/src/gpu_wake.rs | 54 +++++++++++++++++++++++------------------ host/src/main.rs | 16 +++--------- host/src/supervisor.rs | 38 ++++++++++++++++++++++++++++- 5 files changed, 103 insertions(+), 42 deletions(-) diff --git a/host/src/control.rs b/host/src/control.rs index 55cda40d..52acfc58 100644 --- a/host/src/control.rs +++ b/host/src/control.rs @@ -30,6 +30,11 @@ pub struct SharedControl { /// Encoder name override from the Settings tab, applied on next pipeline restart. /// `None` means use auto-detected encoder from GpuInfo. pub encoder_override: Arc>>, + /// The DXGI adapter of the encoder that streams, for waking it when an + /// iPad looks for PCs (see `gpu_wake`): set at each encoder open and + /// when a late detection is adopted, None for a CPU encoder or one on + /// a card detection didn't choose. + pub wake_adapter: Arc>>, /// Which display output the capture loop should duplicate, applied on next pipeline /// restart. `PrimaryAuto` mirrors the primary monitor (today's behavior). pub capture_target: Arc>, @@ -199,6 +204,7 @@ impl SharedControl { ))))), force_next_idr: Arc::new(AtomicBool::new(false)), encoder_override: Arc::new(Mutex::new(None)), + wake_adapter: Arc::new(Mutex::new(None)), capture_target: Arc::new(Mutex::new(CaptureTarget::PrimaryAuto)), vdd_status: Arc::new(Mutex::new(VddStatus::Inactive)), vdd_problem: Arc::new(Mutex::new(None)), diff --git a/host/src/encoder/mod.rs b/host/src/encoder/mod.rs index 25b80c82..64b4fb7b 100644 --- a/host/src/encoder/mod.rs +++ b/host/src/encoder/mod.rs @@ -352,7 +352,7 @@ fn run_encode_loop( { PIPELINE_STATS.lock().set_software_fallback(false); } - note_encoder_opened(&shared, &desired_encoder); + note_encoder_opened(&shared, &desired_encoder, &gpu); } // No picture yet, and the iPad would give up waiting for // it: libx264 streams while the open goes on. @@ -382,7 +382,7 @@ fn run_encode_loop( PIPELINE_STATS .lock() .set_software_fallback(encoder_name != "libx264"); - note_encoder_opened(&shared, "libx264"); + note_encoder_opened(&shared, "libx264", &gpu); } Err(e) if is_hevc_encoder(&desired_encoder) => { warn!( @@ -455,7 +455,7 @@ fn run_encode_loop( PIPELINE_STATS .lock() .set_software_fallback(name == "libx264"); - note_encoder_opened(&shared, name); + note_encoder_opened(&shared, name, &gpu); } Err(e) => return Err(e), } @@ -609,7 +609,7 @@ fn run_encode_loop( { PIPELINE_STATS.lock().set_software_fallback(false); } - note_encoder_opened(&shared, &reopen.encoder); + note_encoder_opened(&shared, &reopen.encoder, &gpu); } // The old session cannot take a frame of the new size. A // pipeline restart opens a fresh encoder, with the software @@ -2604,7 +2604,8 @@ fn codec_display_name_for(encoder_name: &str) -> &str { /// After every successful encoder open: report the live codec on the wire /// (HELLO_ACK/heartbeat `StreamConfig.codec`) and in the GUI. -fn note_encoder_opened(shared: &SharedControl, encoder_name: &str) { +fn note_encoder_opened(shared: &SharedControl, encoder_name: &str, gpu: &GpuInfo) { + *shared.wake_adapter.lock() = crate::gpu_wake::wake_adapter_for(encoder_name, gpu); let codec = if is_hevc_encoder(encoder_name) { eternal_wire::v2::control::CODEC_HEVC } else { @@ -6104,6 +6105,26 @@ mod tests { /// Automatic's BGRA open failing on NVENC opens YUV420P at once and keeps /// it for later opens; a picked input, a stall, or a YUV420P open that /// fails is not tried again. + /// NVENC failing over to Quick Sync streams from the Intel card: the + /// NVIDIA card detection chose is unused and is not woken for iPads. + #[test] + fn an_encoder_open_publishes_the_card_to_wake() { + let shared = SharedControl::new(0, 8_000_000); + let gpu = GpuInfo { + encoder_name: "h264_nvenc".to_string(), + adapter_index: 1, + ..GpuInfo::software_fallback() + }; + note_encoder_opened(&shared, "h264_nvenc", &gpu); + assert_eq!(*shared.wake_adapter.lock(), Some(1)); + note_encoder_opened(&shared, "h264_qsv", &gpu); + assert_eq!(*shared.wake_adapter.lock(), None, "a cross-vendor fallback"); + note_encoder_opened(&shared, "libx264", &gpu); + assert_eq!(*shared.wake_adapter.lock(), None, "the CPU"); + note_encoder_opened(&shared, "hevc_nvenc", &gpu); + assert_eq!(*shared.wake_adapter.lock(), Some(1), "back on the card"); + } + #[test] fn a_failed_automatic_bgra_open_falls_back_to_yuv420_and_stays_there() { use ffmpeg_next::format::Pixel; diff --git a/host/src/gpu_wake.rs b/host/src/gpu_wake.rs index 3718178d..5828f715 100644 --- a/host/src/gpu_wake.rs +++ b/host/src/gpu_wake.rs @@ -70,18 +70,20 @@ impl WakeGate { } } -/// Whether the encoder that will stream runs on the card detection chose -/// (`detected`): the override in Settings (`encoder_override`) if any, and -/// not a software fallback. Only that card is woken; a CPU encoder, or one -/// on another card, needs none. -pub fn wakes_card(encoder_override: Option<&str>, detected: &str, software_fallback: bool) -> bool { +/// The adapter to wake for `opened`, the encoder that opened in a pipeline +/// using `gpu`: the card detection chose, when `opened` is a hardware encoder +/// of its family (its HEVC sibling too). None for a CPU encoder, or for a +/// fallback to another card's encoder (NVENC or AMF failing over to Quick +/// Sync): the card detection chose is then unused, and waking it costs power +/// for nothing. +pub fn wake_adapter_for(opened: &str, gpu: &crate::gpu::GpuInfo) -> Option { let family = |name: &str| { name.strip_prefix("h264_") .or_else(|| name.strip_prefix("hevc_")) .map(str::to_owned) }; - let effective = encoder_override.unwrap_or(detected); - !software_fallback && family(effective).is_some() && family(effective) == family(detected) + (family(opened).is_some() && family(opened) == family(&gpu.encoder_name)) + .then_some(gpu.adapter_index) } /// Runs `touch` unless `still_wanted`, checked just before, says a session @@ -188,27 +190,31 @@ mod tests { assert!(gate.browse(at(start, 75), false, 9)); } + fn gpu(encoder: &str, adapter_index: u32) -> crate::gpu::GpuInfo { + crate::gpu::GpuInfo { + encoder_name: encoder.to_string(), + adapter_index, + ..crate::gpu::GpuInfo::software_fallback() + } + } + #[test] - fn only_the_encoder_that_will_stream_on_the_detected_card_wakes_it() { - assert!(wakes_card(None, "h264_amf", false)); - assert!( - wakes_card(Some("hevc_amf"), "h264_amf", false), + fn only_the_encoder_that_streams_on_the_detected_card_wakes_it() { + let nvenc = gpu("h264_nvenc", 1); + assert_eq!(wake_adapter_for("h264_nvenc", &nvenc), Some(1)); + assert_eq!( + wake_adapter_for("hevc_nvenc", &nvenc), + Some(1), "its HEVC sibling" ); - assert!( - !wakes_card(Some("libx264"), "h264_amf", false), - "a CPU override" - ); - assert!(!wakes_card(None, "h264_amf", true), "a software fallback"); - assert!(!wakes_card(None, "libx264", false)); - assert!( - !wakes_card(Some("h264_qsv"), "h264_amf", false), - "another card" - ); - assert!( - !wakes_card(Some("h264_nvenc"), "h264_amf", false), - "another card" + assert_eq!( + wake_adapter_for("h264_qsv", &nvenc), + None, + "a Quick Sync fallback" ); + assert_eq!(wake_adapter_for("h264_amf", &nvenc), None, "another card"); + assert_eq!(wake_adapter_for("libx264", &nvenc), None, "the CPU"); + assert_eq!(wake_adapter_for("libx264", &gpu("libx264", 0)), None); } #[test] diff --git a/host/src/main.rs b/host/src/main.rs index e25333f0..b5934d11 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -262,21 +262,13 @@ fn main() -> Result<(), Box> { // An iPad looking for PCs starts waking the encoder's graphics card, which // a laptop powers off when idle (see `gpu_wake`). - let (wake_adapter, detected_encoder) = (gpu_info.adapter_index, gpu_info.encoder_name.clone()); let wake_shared = shared.clone(); let mut wake_gate = eternal_host::gpu_wake::WakeGate::default(); let on_browse = move || { - // The encoder that will stream: the override, unless the pipeline - // fell back to the CPU. - let encoder_override = wake_shared.encoder_override.lock().clone(); - let software_fallback = stats::PIPELINE_STATS.lock().using_software_fallback; - if !eternal_host::gpu_wake::wakes_card( - encoder_override.as_deref(), - &detected_encoder, - software_fallback, - ) { + // The card of the encoder that streams, as its last open published. + let Some(adapter_index) = *wake_shared.wake_adapter.lock() else { return; - } + }; let sessions = wake_shared.session.lock().sessions_started(); if wake_gate.browse( std::time::Instant::now(), @@ -285,7 +277,7 @@ fn main() -> Result<(), Box> { ) { // A HELLO between here and the wake's device takes priority. let shared = wake_shared.clone(); - eternal_host::gpu_wake::wake(wake_adapter, move || { + eternal_host::gpu_wake::wake(adapter_index, move || { !shared.client_connected() && shared.session.lock().sessions_started() == sessions }); } diff --git a/host/src/supervisor.rs b/host/src/supervisor.rs index 7f79733b..076238d8 100644 --- a/host/src/supervisor.rs +++ b/host/src/supervisor.rs @@ -471,7 +471,7 @@ pub fn run( machine.note_started(generation); settle(&machine, &shared, &mut standby, listen_port); healthy_since = Instant::now(); - if take_late_detection(&mut gpu_info, crate::gpu::take_late_detection) { + if adopt_late_detection(&mut gpu_info, &shared, crate::gpu::take_late_detection) { let mut stats = PIPELINE_STATS.lock(); stats.set_gpu_name(gpu_info.name.clone()); stats.set_codec_name(gpu_info.codec_display_name.clone()); @@ -526,6 +526,21 @@ fn take_late_detection(gpu: &mut GpuInfo, late: impl FnOnce() -> Option true } +/// [`take_late_detection`], and the card it found becomes the one to wake +/// for an iPad that looks for PCs, until the new pipeline's encoder opens +/// and says which it uses. +fn adopt_late_detection( + gpu: &mut GpuInfo, + shared: &SharedControl, + late: impl FnOnce() -> Option, +) -> bool { + if !take_late_detection(gpu, late) { + return false; + } + *shared.wake_adapter.lock() = crate::gpu_wake::wake_adapter_for(&gpu.encoder_name, gpu); + true +} + /// Makes what iPads hear match `machine`: the session's answer to a HELLO2, /// and the standby responder on the port while no generation streams. fn settle( @@ -839,6 +854,27 @@ mod tests { assert!(!take_late_detection(&mut gpu, late)); assert_eq!(gpu.name, "Software"); } + #[test] + fn an_adopted_late_detection_becomes_the_card_to_wake() { + let shared = SharedControl::new(0, 8_000_000); + // Startup gave up and encodes on the CPU: nothing to wake. + let mut gpu = GpuInfo { + detection_stalled: true, + ..GpuInfo::software_fallback() + }; + assert!(!adopt_late_detection(&mut gpu, &shared, || None)); + assert_eq!(*shared.wake_adapter.lock(), None); + let late = || { + Some(GpuInfo { + encoder_name: "h264_amf".to_string(), + adapter_index: 1, + ..GpuInfo::software_fallback() + }) + }; + assert!(adopt_late_detection(&mut gpu, &shared, late)); + assert_eq!(*shared.wake_adapter.lock(), Some(1)); + } + #[test] fn audio_failures_never_restart_or_exhaust_the_video_failure_budget() { let mut machine = Machine::new(7);