mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-06 08:01:03 +03:00
drm: wake idle-disabled displays and settle the topology before the client is promised a list
a compositor that idles long enough does not merely blank a panel: it
disables the connector, leaving no scanout for any capture backend to
read - not drm, not pipewire, not x11. on an unattended box that meant
connecting to whatever was still scanning out (on an apple t2, the
60x2170 touch bar strip) with the real panel sitting disabled next to
it, or a stale cached list advertising a display with nothing behind it
("waiting for image").
the fix has three parts, and where the wake runs is the load-bearing
one:
- the root service answers every _drm handshake with a fresh, settled
enumeration (drm_enumerate_settled): enumerate, and if a CONNECTED
display has no crtc, inject one synthetic 1px pointer round trip over
uinput (rate limited to one per 20s, one winner via compare_exchange)
and hold the answer until nothing wakeable is left undriven or a 3s
deadline passes. rate-limited losers wait for the outcome too while a
wake is recent - answering with the pre-wake list is exactly the
mid-transition state that produced duplicate, misindexed monitors.
connectors a wake could not bring back are latched by connector
identity (device:connector) and the latch is self-refuting: an entry
later seen scanning out is dropped, so one slow modeset cannot
disable the wake for the life of the service, and a dummy plug cannot
suppress the wake for a different panel that idles later.
- the login path refreshes the cached display list over a live
handshake (refresh_displays_for_login) before peer info is built, so
the list the client is promised is the post-wake truth and never
changes under it seconds later. the publish is generation-checked
against concurrent writers; every failure mode keeps the previous
cache, so a login can never get harder than before, only truer.
- the capture handshake resolves the display index the client chose by
connector identity against the handshake list (the service enumerates
fresh per connection, so an index alone is only meaningful against
the list it came from), fails the build cleanly when that monitor is
gone, and no longer republishes its handshake list into the
availability cache - that unordered write could clobber a newer
settled list with pre-wake data and re-advertise a reordered list
under a live session.
the display-list read timeout grows to cover the settle budget
(DISPLAY_LIST_TIMEOUT_MS), or a wake that needs the full recheck would
turn into a spurious handshake timeout on exactly the host it exists
for. removing the display cache from the handshake path also retires
DRM_CACHE_WARMED; the cache still feeds the topology push and the udev
listener.
measured on the t2 (amdgpu panel idle-disabled, appletbdrm touch bar
still scanning out): connect -> wake fires with undriven=1 -> panel
returns in ~330ms -> the same probe answers 2 displays -> the client
starts on the panel. with the panel awake: zero wakes. the root service
still never maps libEGL/libGLESv2.
This commit is contained in:
448
src/ipc/drm.rs
448
src/ipc/drm.rs
@@ -218,14 +218,22 @@ static DRM_DISPLAY_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic:
|
||||
|
||||
/// Snapshot a reader's enumerated displays as the IPC `DrmDisplayInfo` form. `displays()` lists all
|
||||
/// device outputs regardless of the reader's target CRTC, so a capture reader can refresh the cache.
|
||||
/// Returns the displays this reader can serve, plus the identity (`device:connector`) of every
|
||||
/// CONNECTED output dropped for having no CRTC. The identities are RETURNED rather than accumulated
|
||||
/// in a static: several handshakes enumerate concurrently (a multi-monitor client opens one `_drm`
|
||||
/// connection per display), and a shared counter meant their looks at the hardware added together --
|
||||
/// observed as "2 connected display(s) had no CRTC" on a machine with exactly one. They are
|
||||
/// identities rather than a count so the wake bookkeeping can reason about WHICH output stayed dark
|
||||
/// (see DRM_WAKE_HOPELESS), not merely how many.
|
||||
fn drm_displays_from_reader(
|
||||
reader: &mut scrap::drm_reader::DrmReader,
|
||||
device: &str,
|
||||
) -> Vec<DrmDisplayInfo> {
|
||||
) -> (Vec<DrmDisplayInfo>, Vec<String>) {
|
||||
// Every display this reader enumerates belongs to the reader's device, so they
|
||||
// all share its render node. Resolved once here rather than per display.
|
||||
let render_node = reader.render_node().unwrap_or_default();
|
||||
reader
|
||||
let mut undriven = Vec::new();
|
||||
let displays: Vec<DrmDisplayInfo> = reader
|
||||
.displays()
|
||||
.into_iter()
|
||||
// Only offer outputs actually bound to a CRTC (i.e. scanning out). A
|
||||
@@ -238,7 +246,17 @@ fn drm_displays_from_reader(
|
||||
// `src rect > dst rect`), which failed every frame and drove a ~1/sec
|
||||
// capturer restart loop (the flap that leaked EGL contexts to OOM). Drop
|
||||
// these here so they are never offered; the client keeps its real monitors.
|
||||
.filter(|d| d.active && d.crtc_id != 0)
|
||||
.filter(|d| {
|
||||
if !d.active || d.crtc_id == 0 {
|
||||
// Also the signal that a display EXISTS but is not being driven, which is what an
|
||||
// idle compositor leaves behind when it disables an output. Recorded so the
|
||||
// handshake can tell "this host has no monitors" apart from "this host has a
|
||||
// monitor that is switched off", which look identical once they are filtered out.
|
||||
undriven.push(format!("{device}:{name}", name = d.name));
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.map(|d| DrmDisplayInfo {
|
||||
name: d.name,
|
||||
crtc_id: d.crtc_id,
|
||||
@@ -250,7 +268,8 @@ fn drm_displays_from_reader(
|
||||
render_node: render_node.clone(),
|
||||
device: device.to_owned(),
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(displays, undriven)
|
||||
}
|
||||
|
||||
/// Enumerate the active displays of EVERY DRM device, so a multi-GPU host advertises
|
||||
@@ -260,7 +279,11 @@ fn drm_displays_from_reader(
|
||||
/// auto-detected device when libdrmtap cannot enumerate (a pre-0.4.15 `.so`) or found
|
||||
/// nothing to open -- in which case `device` is left empty and capture reopens with
|
||||
/// auto-detect, exactly the previous behaviour.
|
||||
fn drm_enumerate_all_displays() -> Vec<DrmDisplayInfo> {
|
||||
/// Every active display of every DRM device, plus the identities of the CONNECTED outputs currently
|
||||
/// NOT being driven. Both come from the SAME look at the hardware, which is the point: the two were
|
||||
/// once a list and a separate static, and a handshake could act on a count that belonged to somebody
|
||||
/// else's enumeration.
|
||||
fn drm_enumerate_all_displays() -> (Vec<DrmDisplayInfo>, Vec<String>) {
|
||||
if let Some(devices) = scrap::drm_reader::list_devices() {
|
||||
if devices.len() > 1 {
|
||||
log::info!(
|
||||
@@ -279,33 +302,348 @@ fn drm_enumerate_all_displays() -> Vec<DrmDisplayInfo> {
|
||||
);
|
||||
}
|
||||
let mut all = Vec::new();
|
||||
let mut undriven_total = Vec::new();
|
||||
let mut any_opened = false;
|
||||
for dev in devices {
|
||||
// Skip a card with no active CRTC (a compute/offload GPU, or one whose
|
||||
// monitors are all off): opening it and enumerating would add nothing.
|
||||
if dev.display_count == 0 {
|
||||
continue;
|
||||
}
|
||||
// A card with no active CRTC used to be SKIPPED here, on the grounds that opening it and
|
||||
// enumerating "would add nothing". That was true while the only question was which
|
||||
// displays can be captured. It is false now: a card whose monitors are all off is exactly
|
||||
// where a display sits that could be captured if the compositor switched it back on, and
|
||||
// drm_displays_from_reader records those and RETURNS them, so the handshake can
|
||||
// wake them. Skipping the card meant they were never seen and the wake never fired, which
|
||||
// is how an Apple T2 handed a client its Touch Bar strip while the 2880x1800 panel sat
|
||||
// disabled next to it.
|
||||
//
|
||||
// Opening such a card is fine and was measured: the context opens, list_displays reports
|
||||
// the connector as `crtc=0 (inactive)`, and only a GRAB would fail with "no active CRTC".
|
||||
// It contributes zero entries to the list, exactly as before -- the only difference is
|
||||
// that we now know it is there. The cost is one device open per idle card per
|
||||
// enumeration, which happens off the capture path.
|
||||
if let Some(mut r) = scrap::drm_reader::DrmReader::open(Some(&dev.path), 0) {
|
||||
all.append(&mut drm_displays_from_reader(&mut r, &dev.path));
|
||||
any_opened = true;
|
||||
let (mut got, mut undriven) = drm_displays_from_reader(&mut r, &dev.path);
|
||||
all.append(&mut got);
|
||||
undriven_total.append(&mut undriven);
|
||||
} else if dev.display_count == 0 {
|
||||
log::debug!(
|
||||
"drm: {} has no active display and did not open; cannot tell whether it has a \
|
||||
connected output that is merely switched off",
|
||||
dev.path
|
||||
);
|
||||
}
|
||||
}
|
||||
if !all.is_empty() {
|
||||
return all;
|
||||
// Answer from the per-device enumeration whenever ANY card opened -- including when the
|
||||
// active list is EMPTY. "Every connected output is idle-disabled" is exactly the state the
|
||||
// wake exists for, and it used to fall through to the auto-detect path below, which
|
||||
// enumerates the same connectors under `device = ""`. That re-keying is not cosmetic: the
|
||||
// undriven identities feed DRM_WAKE_HOPELESS, and an entry latched under the fallback key
|
||||
// (":eDP-1") could never be refuted by an enumeration that sees the panel driven, because a
|
||||
// driven panel makes this list non-empty and its identity is then "/dev/dri/cardN:eDP-1" --
|
||||
// a permanent latch on any single-GPU host whose only panel idles, i.e. the most common
|
||||
// machine there is. (The fall-through also silently threw undriven_total away.) The
|
||||
// auto-detect fallback below now runs only when NO card opened (no rights, or a
|
||||
// pre-0.4.15 .so without list_devices), regimes in which every enumeration consistently
|
||||
// uses the "" key, so identities still match each other.
|
||||
if any_opened {
|
||||
return (all, undriven_total);
|
||||
}
|
||||
// list_devices worked but nothing opened/enumerated (e.g. no rights on any
|
||||
// card): fall through to the single auto-detected device rather than return
|
||||
// an empty list that would read as "no displays".
|
||||
}
|
||||
match scrap::drm_reader::DrmReader::open(None, 0) {
|
||||
Some(mut r) => drm_displays_from_reader(&mut r, ""),
|
||||
None => Vec::new(),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// True once DRM_DISPLAY_CACHE has been populated at least once, so an EMPTY cache can be told apart
|
||||
/// from an unwarmed one: a warmed-but-empty cache (all monitors off) is served directly, while an
|
||||
/// unwarmed cache triggers a synchronous enumeration.
|
||||
static DRM_CACHE_WARMED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Connector identities (`device:connector`) a wake was already tried on and did NOT bring back:
|
||||
/// whatever is still undriven when a fired wake's recheck window closes lands here, and later
|
||||
/// handshakes no longer count those connectors as a reason to wake (or to wait). A connected
|
||||
/// connector the compositor will never drive -- a dummy HDMI plug, a lid-closed docked laptop's
|
||||
/// eDP, a monitor the user disabled in display settings -- would otherwise invite a wake plus a
|
||||
/// full recheck wait on every connection, forever, for a display that is never coming.
|
||||
///
|
||||
/// The set is SELF-REFUTING, which is what makes it safe where a single global latch was not: an
|
||||
/// entry that a later enumeration sees DRIVEN is removed (drm_wakeable_undriven), because a lit
|
||||
/// panel is direct proof the "never coming" verdict was wrong -- a modeset that outran the recheck
|
||||
/// deadline, or a lid that opened. One slow wake therefore costs one stale entry until that panel
|
||||
/// is next seen alight, not the whole feature for the rest of the service's life. And because the
|
||||
/// latch is per-connector, a permanently dark connector cannot suppress the wake for a DIFFERENT
|
||||
/// panel that idles later, which a global flag deterministically did (latched by the dock's closed
|
||||
/// lid, it would have refused to wake the real monitor).
|
||||
///
|
||||
/// A Vec, not a HashSet, for const init; it holds at most a handful of entries.
|
||||
static DRM_WAKE_HOPELESS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// The undriven connectors still worth waking: `undriven` minus the hopeless set. Also where the
|
||||
/// hopeless set is REFUTED: any entry the current enumeration shows driven is removed, so the latch
|
||||
/// heals itself the moment reality disproves it.
|
||||
fn drm_wakeable_undriven(displays: &[DrmDisplayInfo], undriven: &[String]) -> Vec<String> {
|
||||
let mut hopeless = DRM_WAKE_HOPELESS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !hopeless.is_empty() {
|
||||
hopeless.retain(|id| {
|
||||
let driven_now = displays
|
||||
.iter()
|
||||
.any(|d| format!("{}:{}", d.device, d.name) == *id);
|
||||
if driven_now {
|
||||
log::info!("drm: {id} is scanning out after all; treating it as wakeable again");
|
||||
}
|
||||
!driven_now
|
||||
});
|
||||
}
|
||||
undriven
|
||||
.iter()
|
||||
.filter(|id| !hopeless.iter().any(|h| h == *id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Last time a display wake was emitted, as seconds since the service started, so a reconnect storm
|
||||
/// cannot turn into an input-injection storm. 0 = never.
|
||||
static DRM_LAST_WAKE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
/// Set once /dev/uinput has been found unusable, so the diagnosis is logged once instead of per
|
||||
/// connection. A host without uinput cannot inject input at all on Wayland (there is no XTEST), so a
|
||||
/// failure here means the session was already view-only -- it is not a new failure mode.
|
||||
static DRM_WAKE_UNAVAILABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Minimum gap between two wakes. Long enough that a client reconnecting in a loop cannot flood the
|
||||
/// compositor with synthetic activity, short enough to be useless as a way to keep a screen lit.
|
||||
const DRM_WAKE_MIN_GAP: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
/// How long to let udev bind a freshly created uinput device before writing to it. Measured, not
|
||||
/// guessed: see drm_wake_displays.
|
||||
const DRM_WAKE_DEVICE_SETTLE: std::time::Duration = std::time::Duration::from_millis(400);
|
||||
/// How long to keep re-enumerating after a wake before giving up on the outputs coming back. A
|
||||
/// modeset is asynchronous: the compositor has to see the input, decide to un-idle, and commit.
|
||||
const DRM_WAKE_RECHECK_TOTAL: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
/// How long after a wake its outcome may still be developing: the device-bind pause, the emits, the
|
||||
/// full recheck, plus slack for the enumerations in between. A handshake that was rate-limited away
|
||||
/// from waking looks at this to decide whether the recent wake is still in flight (then it waits for
|
||||
/// the outcome like the winner does) or is old news (then the current state IS the settled state).
|
||||
const DRM_WAKE_SETTLE_WINDOW: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Seconds since the service started, on a monotonic clock. This is the clock the wake rate limiter
|
||||
/// stores in DRM_LAST_WAKE; SystemTime would let a clock step re-open the gate.
|
||||
fn drm_wake_clock_secs() -> u64 {
|
||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
START.get_or_init(std::time::Instant::now).elapsed().as_secs()
|
||||
}
|
||||
|
||||
/// Ask the compositor to bring its outputs back, by looking like user activity for one pixel.
|
||||
///
|
||||
/// A compositor that has been idle long enough does not merely blank the panel: it DISABLES the
|
||||
/// connector, i.e. commits a modeset that leaves it with no CRTC. At that point there is no scanout
|
||||
/// anywhere, so there is nothing for ANY capture backend to read -- not this one, not PipeWire, not
|
||||
/// X11. The image does not exist rather than being unreadable. (X11 does not have this problem for a
|
||||
/// different reason: its root window is a software surface the X server keeps regardless of what the
|
||||
/// physical output is doing.)
|
||||
///
|
||||
/// Measured on an Apple T2 MacBook whose greeter had idled: `card2-eDP-1 dpms=Off enabled=disabled`
|
||||
/// and zero active displays enumerated on that card; one synthetic relative move restored
|
||||
/// `dpms=On enabled=enabled` with a full 2880x1800 scanout, and capture then worked.
|
||||
///
|
||||
/// A relative +1/-1 round trip is deliberate: it nets ZERO displacement, so the pointer does not
|
||||
/// actually move, and it needs no knowledge of the desktop rect (an absolute device would have to
|
||||
/// invent a coordinate). The device is created and destroyed around the emit rather than kept alive,
|
||||
/// so nothing persists in the input stack between wakes.
|
||||
///
|
||||
/// Two alternatives were measured and rejected. The connector `dpms` attribute in sysfs is read-only,
|
||||
/// and a modeset of our own would need DRM master, which the compositor holds. A third,
|
||||
/// `org.gnome.ScreenSaver.SetActive(false)`, does work and does NOT fake input -- but the session bus
|
||||
/// authenticates by uid and refuses root even though the socket is world-writable, so the root
|
||||
/// service would have to drop privilege to the session user, and the interface is GNOME-specific.
|
||||
/// uinput is the only route that works from where this code already runs, on any desktop.
|
||||
///
|
||||
/// Returns true when a wake was actually emitted.
|
||||
fn drm_wake_displays(reason: &str) -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
// Rate limit on the shared monotonic clock (see drm_wake_clock_secs).
|
||||
let now = drm_wake_clock_secs();
|
||||
// CLAIM the slot before emitting, not after. A multi-monitor client opens one `_drm` connection per
|
||||
// captured display, so several handshakes run concurrently and a check-then-emit lets all of them
|
||||
// through: two wakes were observed in the SAME millisecond. compare_exchange makes exactly one
|
||||
// winner, and the losers log at debug and move on -- the winner's wake serves them all.
|
||||
loop {
|
||||
let last = DRM_LAST_WAKE.load(Ordering::Acquire);
|
||||
if last != 0 && now.saturating_sub(last) < DRM_WAKE_MIN_GAP.as_secs() {
|
||||
log::debug!(
|
||||
"drm: not waking displays ({reason}): a wake {}s ago is still recent",
|
||||
now.saturating_sub(last)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if DRM_LAST_WAKE
|
||||
.compare_exchange(last, now.max(1), Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
break;
|
||||
}
|
||||
// Another thread claimed it between our load and our exchange; re-read and let the rate-limit
|
||||
// branch above turn us away.
|
||||
}
|
||||
|
||||
// It has to look like a MOUSE, not merely like something that emits a relative axis. libinput
|
||||
// classifies devices before it will treat their events as pointer activity, and a device with a
|
||||
// single relative axis and no buttons does not qualify: it is ignored outright, so the events go
|
||||
// nowhere and the compositor never un-idles. Measured three ways on the same machine in the same
|
||||
// state -- REL_X + REL_Y + BTN_LEFT woke the panel, REL_X alone did not, and neither did REL_X
|
||||
// with the settle removed. Declaring both axes and a button is the part that makes it real.
|
||||
let mut axes = evdev::AttributeSet::<evdev::RelativeAxisType>::new();
|
||||
axes.insert(evdev::RelativeAxisType::REL_X);
|
||||
axes.insert(evdev::RelativeAxisType::REL_Y);
|
||||
let mut keys = evdev::AttributeSet::<evdev::Key>::new();
|
||||
keys.insert(evdev::Key::BTN_LEFT);
|
||||
let built = evdev::uinput::VirtualDeviceBuilder::new()
|
||||
.and_then(|b| b.name("RustDesk DRM display wake").with_relative_axes(&axes))
|
||||
.and_then(|b| b.with_keys(&keys))
|
||||
.and_then(|b| b.build());
|
||||
let mut dev = match built {
|
||||
Ok(d) => d,
|
||||
Err(err) => {
|
||||
// Sticky: without /dev/uinput this can never succeed, and retrying it per connection
|
||||
// would log the same failure forever.
|
||||
DRM_WAKE_UNAVAILABLE.store(true, Ordering::Relaxed);
|
||||
log::warn!(
|
||||
"drm: cannot wake displays ({reason}): no uinput device ({err}). A compositor that disabled its outputs will keep them disabled, so there is no scanout to capture until something else generates input. Note input injection needs uinput too, so this session cannot control the host either."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// A FRESH uinput device is not listening yet: udev has to notice it and the compositor's input
|
||||
// stack has to open it, and until that happens the events are written to a device nobody reads and
|
||||
// are simply lost. Measured on the same machine in the same state, back to back: with this pause
|
||||
// the panel went `disabled -> enabled`, without it `disabled -> disabled`. This is the entire
|
||||
// difference between the wake working and silently doing nothing, so it is not a "give it a
|
||||
// moment" superstition -- it is the binding window.
|
||||
std::thread::sleep(DRM_WAKE_DEVICE_SETTLE);
|
||||
|
||||
// +1 then -1 on the same axis: activity without displacement. evdev's emit() appends the
|
||||
// SYN_REPORT itself, so each call is a complete packet.
|
||||
let step = |v: i32| {
|
||||
evdev::InputEvent::new(
|
||||
evdev::EventType::RELATIVE,
|
||||
evdev::RelativeAxisType::REL_X.0,
|
||||
v,
|
||||
)
|
||||
};
|
||||
let ok = dev.emit(&[step(1)]).and_then(|_| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
dev.emit(&[step(-1)])
|
||||
});
|
||||
if let Err(err) = ok {
|
||||
log::warn!("drm: display wake ({reason}) failed to emit: {err}");
|
||||
return false;
|
||||
}
|
||||
log::info!("drm: no display was scanning out ({reason}); asked the compositor to wake up");
|
||||
true
|
||||
}
|
||||
|
||||
/// One enumeration a handshake can answer with: enumerate, wake sleeping displays if that could
|
||||
/// help, and WAIT for the outcome before returning. The wait is the load-bearing part, and it
|
||||
/// applies to every handshake that saw a connected-but-undriven display -- not only the one whose
|
||||
/// wake attempt won the rate limit. The losers used to return immediately with the pre-wake list,
|
||||
/// which is exactly the intermediate state the winner was waiting out: with one `_drm` connection
|
||||
/// per captured display plus the consumer's availability refresher, a wake in flight had its
|
||||
/// half-done topology served to whichever consumer asked at the wrong moment, and the client ended
|
||||
/// up with duplicate, misindexed monitors. Every caller of this function gets the settled truth or
|
||||
/// a bounded timeout -- never the transition.
|
||||
///
|
||||
/// A compositor that has idled long enough DISABLES its outputs, and a disabled output has no
|
||||
/// scanout for anything to read -- not this backend, not PipeWire, not X11. The trigger is "a
|
||||
/// connected display is not being driven", NOT "no display at all": on a laptop with a second DRM
|
||||
/// card (an Apple T2's Touch Bar strip) the list is never empty, so an emptiness check never fires
|
||||
/// and the client would be handed whatever is still scanning out. libdrmtap does report the idle
|
||||
/// panel (`crtc=0 (inactive)`); it is our own active-CRTC filter that drops it, so the count of
|
||||
/// what was dropped is exactly the right signal.
|
||||
fn drm_enumerate_settled(reason: &str) -> Vec<DrmDisplayInfo> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (displays, undriven) = drm_enumerate_all_displays();
|
||||
let wakeable = drm_wakeable_undriven(&displays, &undriven);
|
||||
if wakeable.is_empty() {
|
||||
return displays;
|
||||
}
|
||||
let fired = drm_wake_displays(&format!(
|
||||
"{reason} and {n} connected display(s) had no CRTC",
|
||||
n = wakeable.len()
|
||||
));
|
||||
if !fired {
|
||||
if DRM_WAKE_UNAVAILABLE.load(Ordering::Relaxed) {
|
||||
// No uinput on this host: nothing will ever wake these displays, so the pre-wake list
|
||||
// is not "pre" anything -- it is the state of the world.
|
||||
return displays;
|
||||
}
|
||||
// Rate-limited: somebody woke recently. If that wake may still be developing, wait for its
|
||||
// outcome below, exactly like the winner. If it is old news (the panel re-idled inside
|
||||
// DRM_WAKE_MIN_GAP, or the winner's recheck expired long ago), what we enumerated IS the
|
||||
// settled state and waiting would just tax this handshake for nothing.
|
||||
let last = DRM_LAST_WAKE.load(Ordering::Acquire);
|
||||
if last == 0
|
||||
|| drm_wake_clock_secs().saturating_sub(last) > DRM_WAKE_SETTLE_WINDOW.as_secs()
|
||||
{
|
||||
return displays;
|
||||
}
|
||||
}
|
||||
// Poll for the outcome rather than looking once at a fixed delay: the wake is asynchronous on
|
||||
// the compositor side, and a single re-enumeration is a bet on how long a modeset takes. Two
|
||||
// exits: nothing WAKEABLE is left undriven (connectors already latched hopeless do not hold
|
||||
// the answer hostage -- on a host with a dummy plug next to a real panel, the poll ends when
|
||||
// the panel lights, because the plug stopped counting after its first failed wake), or the
|
||||
// deadline (the wake failed, or the winner's did and we were waiting on it).
|
||||
let before_len = displays.len();
|
||||
let deadline = std::time::Instant::now() + DRM_WAKE_RECHECK_TOTAL;
|
||||
let mut cur = displays;
|
||||
let mut cur_wakeable = wakeable;
|
||||
while !cur_wakeable.is_empty() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
let (next, next_undriven) = drm_enumerate_all_displays();
|
||||
cur_wakeable = drm_wakeable_undriven(&next, &next_undriven);
|
||||
cur = next;
|
||||
}
|
||||
if cur.len() > before_len {
|
||||
log::info!(
|
||||
"drm: {} display(s) came back after the wake ({} -> {}{})",
|
||||
cur.len() - before_len,
|
||||
before_len,
|
||||
cur.len(),
|
||||
if cur_wakeable.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {} still undriven", cur_wakeable.len())
|
||||
}
|
||||
);
|
||||
// Publish through the single cache writer so the topology push and the udev listener's
|
||||
// cache converge on the woken state without another consumer having to repeat the wake.
|
||||
schedule_drm_cache_refresh();
|
||||
}
|
||||
if fired && !cur_wakeable.is_empty() {
|
||||
// Whatever OUR OWN wake could not bring back inside its window is latched hopeless, so the
|
||||
// next connection neither wakes for it nor waits on it. Only the handshake that fired
|
||||
// latches (a loser timing out says nothing: its baseline was taken mid-transition), and
|
||||
// the latch is per-connector and self-refuting -- see DRM_WAKE_HOPELESS for why both of
|
||||
// those properties are load-bearing.
|
||||
let mut hopeless = DRM_WAKE_HOPELESS
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
for id in &cur_wakeable {
|
||||
if !hopeless.iter().any(|h| h == id) {
|
||||
hopeless.push(id.clone());
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"drm: the wake did not bring back {list}; not asking again for {these} until {it_is} \
|
||||
seen scanning out",
|
||||
list = cur_wakeable.join(", "),
|
||||
these = if cur_wakeable.len() == 1 { "it" } else { "them" },
|
||||
it_is = if cur_wakeable.len() == 1 { "it is" } else { "they are" },
|
||||
);
|
||||
}
|
||||
cur
|
||||
}
|
||||
|
||||
/// The SINGLE writer of DRM_DISPLAY_CACHE (+ DRM_DISPLAY_GENERATION): enumerate every card, diff
|
||||
/// against the cache, and on a real change swap it and bump the generation so live consumers get the
|
||||
@@ -358,10 +696,14 @@ fn schedule_drm_cache_refresh() {
|
||||
PENDING.store(false, Ordering::Release);
|
||||
// Panic-safety, two layers: enumeration panics are caught here so a flaky driver does
|
||||
// not lose the refresh; anything else that unwinds is covered by `slot`'s Drop.
|
||||
let fresh = std::panic::catch_unwind(drm_enumerate_all_displays).unwrap_or_else(|_| {
|
||||
log::error!("drm: display enumeration panicked; treating as no displays");
|
||||
Vec::new()
|
||||
});
|
||||
// Only the LIST matters to the cache: the undriven identities belong to the moment they
|
||||
// were taken, and caching them is exactly the desynchronisation this refactor removed.
|
||||
let fresh = std::panic::catch_unwind(drm_enumerate_all_displays)
|
||||
.unwrap_or_else(|_| {
|
||||
log::error!("drm: display enumeration panicked; treating as no displays");
|
||||
(Vec::new(), Vec::new())
|
||||
})
|
||||
.0;
|
||||
let changed = {
|
||||
let mut cache = match DRM_DISPLAY_CACHE.lock() {
|
||||
Ok(g) => g,
|
||||
@@ -374,7 +716,6 @@ fn schedule_drm_cache_refresh() {
|
||||
false
|
||||
}
|
||||
};
|
||||
DRM_CACHE_WARMED.store(true, Ordering::Release);
|
||||
if changed {
|
||||
DRM_DISPLAY_GENERATION.fetch_add(1, Ordering::Release);
|
||||
log::info!("drm: display cache refreshed (topology changed)");
|
||||
@@ -742,9 +1083,9 @@ async fn handle_drm_conn(stream: Connection) -> ResultType<()> {
|
||||
let worker_gate = frames_gated.clone();
|
||||
std::thread::spawn(move || drm_capture_worker(frame_tx, crtc_rx, worker_stop, worker_gate));
|
||||
|
||||
// Handshake: the worker sends the display list (from the pre-warmed cache, or a throwaway
|
||||
// enumeration open if the cache is empty). A closed channel (no Displays) means the reader was
|
||||
// unavailable, so let the client fall back.
|
||||
// Handshake: the worker sends the display list -- a fresh, settled enumeration
|
||||
// (drm_enumerate_settled), possibly held back while a display wake completes. A closed channel
|
||||
// (no Displays) means the reader was unavailable, so let the client fall back.
|
||||
let displays = match frame_rx.recv().await {
|
||||
Some(DrmProducerMsg::Displays(d)) => d,
|
||||
_ => {
|
||||
@@ -1014,18 +1355,27 @@ fn drm_capture_worker(
|
||||
|
||||
let t_conn = std::time::Instant::now();
|
||||
|
||||
// Send the display list. Serve the cache once it has been warmed at least once -- INCLUDING when
|
||||
// it is empty (all monitors off), which is a real state, not "not ready". Only an unwarmed cache
|
||||
// (a connection racing the pre-warm) triggers a synchronous per-connection enumeration.
|
||||
let displays = if DRM_CACHE_WARMED.load(Ordering::Acquire) {
|
||||
// Poison-recovery for the same reason as the topology push above.
|
||||
DRM_DISPLAY_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
} else {
|
||||
drm_enumerate_all_displays()
|
||||
};
|
||||
// Enumerate FRESH here rather than serving the warm cache, wake anything sleeping, and answer
|
||||
// only once the topology has settled (drm_enumerate_settled). The cache exists to keep this
|
||||
// handshake fast, and it cost us correctness twice over:
|
||||
//
|
||||
// - It can offer a display that is no longer being driven. A cache published while the panel was
|
||||
// awake still lists it after the compositor disables it; the consumer picks it, the capture
|
||||
// reader OPENS (opening tolerates an inactive CRTC) and then never produces a frame, and the
|
||||
// client sits on "waiting for image" -- the exact symptom this backend was built to remove.
|
||||
// - The wake decision reads a count of connected-but-undriven displays, and when the list came
|
||||
// from the cache that count belonged to some OTHER enumeration. Two things that must agree were
|
||||
// never synchronised, so the wake did not fire on a stale-cache connection.
|
||||
//
|
||||
// The saving was small and measured: the whole prewarm, which is this work plus priming the export
|
||||
// path, runs in 1.4-16 ms. Paying it per connection to always tell the client the truth is the
|
||||
// right trade. The cache still serves the topology-change push and the udev listener.
|
||||
//
|
||||
// Holding the answer back while the wake settles is deliberate, and the consumer's list-read
|
||||
// timeout is sized for it (drm_capturer's DISPLAY_LIST_TIMEOUT_MS): one stable truth per
|
||||
// handshake beats a fast answer that changes seconds later, because the consumer publishes this
|
||||
// list to the client at login and every revision after that walks the hotplug path.
|
||||
let displays = drm_enumerate_settled("a consumer connected");
|
||||
// Send even an empty list: the consumer treats "0 displays" as Unavailable and falls back
|
||||
// promptly, rather than waiting out repeated probe failures.
|
||||
if frame_tx
|
||||
@@ -1054,19 +1404,17 @@ fn drm_capture_worker(
|
||||
"drm: failed to open crtc {target_crtc} on {}; closing _drm connection",
|
||||
if target_device.is_empty() { "auto" } else { &target_device }
|
||||
);
|
||||
// The cached display list handed out a CRTC that no longer opens (a hotplug/modeset
|
||||
// likely invalidated it). Mark the cache unwarmed so the next connection re-enumerates
|
||||
// synchronously from the live device instead of serving the same stale, unopenable CRTC
|
||||
// on every reconnect; also kick an async refresh so the cache converges even without a
|
||||
// new connection.
|
||||
DRM_CACHE_WARMED.store(false, Ordering::Release);
|
||||
// The display list handed out a CRTC that no longer opens (a hotplug/modeset likely
|
||||
// invalidated it between the handshake and here). Kick an async refresh so the cache the
|
||||
// topology push reads converges even without a new connection. The next connection
|
||||
// enumerates fresh regardless, so there is no stale-list flag to clear any more.
|
||||
schedule_drm_cache_refresh();
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Refresh the cache for the NEXT consumer's handshake, off this connection's first-frame path
|
||||
// and single-flight (see schedule_drm_cache_refresh) so a reconnect storm cannot spawn unbounded
|
||||
// enumeration threads.
|
||||
// Refresh the cache the topology push and the udev listener read (handshakes enumerate fresh
|
||||
// and never read it), off this connection's first-frame path and single-flight (see
|
||||
// schedule_drm_cache_refresh) so a reconnect storm cannot spawn unbounded enumeration threads.
|
||||
schedule_drm_cache_refresh();
|
||||
log::debug!(
|
||||
"drm: capture reader for crtc {target_crtc} opened in {:?}",
|
||||
|
||||
@@ -33,17 +33,24 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// Upper bound on how long the receive thread waits for the service to answer with the display list.
|
||||
// Upper bound on how long the receive thread waits for an ordinary handshake message.
|
||||
const HANDSHAKE_TIMEOUT_MS: u64 = 3000;
|
||||
// How long that thread may spend connecting to `_drm` before the handshake starts.
|
||||
const DRM_CONNECT_TIMEOUT_MS: u64 = 1000;
|
||||
/// How long to wait for the display list specifically. The service may hold it back while it wakes
|
||||
/// sleeping displays and waits for the topology to settle -- uinput device bind, the emits, then up
|
||||
/// to DRM_WAKE_RECHECK_TOTAL of re-enumeration (see the DRM_WAKE_* constants in ipc/drm.rs), about
|
||||
/// 3.6s end to end. The list read must outlive that budget on top of the ordinary handshake
|
||||
/// allowance, or the wake turns into a spurious handshake timeout on exactly the host it exists
|
||||
/// for: the one whose panel was asleep when the client connected.
|
||||
const DISPLAY_LIST_TIMEOUT_MS: u64 = HANDSHAKE_TIMEOUT_MS + 4000;
|
||||
/// How long a caller waits for the receive thread to hand back the display list. It must DOMINATE
|
||||
/// what that thread is allowed to spend, or the outer timer fires first and abandons a handshake
|
||||
/// that was still inside its own budget: the thread spends up to the connect timeout, then
|
||||
/// `recv_msg_timeout2` applies HANDSHAKE_TIMEOUT_MS TWICE in the worst case (once waiting for the
|
||||
/// first byte, once for the body). Derived from those parts rather than written as a constant, so a
|
||||
/// change to either one cannot silently invert the relationship again.
|
||||
const HANDSHAKE_WAIT_MS: u64 = DRM_CONNECT_TIMEOUT_MS + HANDSHAKE_TIMEOUT_MS * 2 + 500;
|
||||
/// `recv_msg_timeout2` applies DISPLAY_LIST_TIMEOUT_MS TWICE in the worst case (once waiting for
|
||||
/// the first byte, once for the body). Derived from those parts rather than written as a constant,
|
||||
/// so a change to either one cannot silently invert the relationship again.
|
||||
const HANDSHAKE_WAIT_MS: u64 = DRM_CONNECT_TIMEOUT_MS + DISPLAY_LIST_TIMEOUT_MS * 2 + 500;
|
||||
|
||||
struct FrameSlot {
|
||||
// (width, height, pixel format, packed pixels) of the newest frame not yet consumed by
|
||||
@@ -113,13 +120,13 @@ fn connector_key(d: &DrmDisplayInfo) -> String {
|
||||
format!("{}:{}", d.device, d.name)
|
||||
}
|
||||
|
||||
/// Resolve a list index to that identity against the currently advertised topology. `None` when no
|
||||
/// list is available or the index is out of range; callers then simply do not consult the per-display
|
||||
/// memory, which costs one retry rather than applying someone else's verdict.
|
||||
/// Takes DRM_STATE, so never call it while holding one of the maps below.
|
||||
fn connector_key_of(display: i32) -> Option<String> {
|
||||
/// The advertised display at a list index, cloned out of DRM_STATE -- the monitor the client MEANS
|
||||
/// when it names that index. `None` when no list is available or the index is out of range; callers
|
||||
/// then simply do not consult the per-display memory, which costs one retry rather than applying
|
||||
/// someone else's verdict. Takes DRM_STATE, so never call it while holding one of the maps below.
|
||||
fn display_info_of(display: i32) -> Option<DrmDisplayInfo> {
|
||||
match &*DRM_STATE.lock().unwrap() {
|
||||
ProbeState::Available(_, list) => list.get(display.max(0) as usize).map(connector_key),
|
||||
ProbeState::Available(_, list) => list.get(display.max(0) as usize).cloned(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -291,10 +298,24 @@ static UINPUT_REFRESH_BUSY: std::sync::atomic::AtomicBool = std::sync::atomic::A
|
||||
|
||||
impl IpcDrmCapturer {
|
||||
/// Connect to the service `_drm` channel, complete the handshake (receive the display list, then
|
||||
/// request `display`), and start streaming on a background thread. Returns the capturer plus the
|
||||
/// enumerated displays so the caller can populate `display_service`. `Err` if the service has no
|
||||
/// DRM capture available or the handshake fails — the caller then falls back to PipeWire/portal.
|
||||
pub fn new(display: i32) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>)> {
|
||||
/// request the display), and start streaming on a background thread. Returns the capturer plus
|
||||
/// the enumerated displays so the caller can populate `display_service`. `Err` if the service has
|
||||
/// no DRM capture available or the handshake fails — the caller then falls back to PipeWire/portal.
|
||||
///
|
||||
/// `expected` is the monitor the client actually MEANS by `display` (the entry at that index in
|
||||
/// the list the client chose from). The service resolves the index it receives against ITS OWN
|
||||
/// fresh enumeration, and the two lists can disagree whenever the topology moved between the
|
||||
/// login list and this handshake (a woken panel inserting itself ahead of the Touch Bar is the
|
||||
/// measured case) -- so the receive thread re-resolves `expected` by connector identity in the
|
||||
/// handshake list and requests THAT index, or fails the build cleanly when the monitor is gone,
|
||||
/// instead of silently streaming whichever monitor now occupies the number.
|
||||
/// Returns the capturer, the handshake display list, and the index WITHIN THAT LIST that the
|
||||
/// stream was actually bound to (the identity-resolved one) -- geometry read out of the
|
||||
/// handshake list must use that index, never the client's.
|
||||
pub fn new(
|
||||
display: i32,
|
||||
expected: Option<DrmDisplayInfo>,
|
||||
) -> ResultType<(IpcDrmCapturer, Vec<DrmDisplayInfo>, usize)> {
|
||||
let shared = Arc::new(Shared {
|
||||
slot: Mutex::new(FrameSlot {
|
||||
latest: None,
|
||||
@@ -304,13 +325,13 @@ impl IpcDrmCapturer {
|
||||
cv: Condvar::new(),
|
||||
});
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = std::sync::mpsc::channel::<ResultType<Vec<DrmDisplayInfo>>>();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<ResultType<(Vec<DrmDisplayInfo>, usize)>>();
|
||||
{
|
||||
let shared = shared.clone();
|
||||
let stop = stop.clone();
|
||||
std::thread::spawn(move || recv_thread(display, shared, stop, tx));
|
||||
std::thread::spawn(move || recv_thread(display, expected, shared, stop, tx));
|
||||
}
|
||||
let displays = match rx.recv_timeout(Duration::from_millis(HANDSHAKE_WAIT_MS)) {
|
||||
let (displays, wire_idx) = match rx.recv_timeout(Duration::from_millis(HANDSHAKE_WAIT_MS)) {
|
||||
Ok(res) => res?,
|
||||
Err(_) => {
|
||||
// The recv thread still has its own connect/handshake budget. If we just returned,
|
||||
@@ -326,9 +347,12 @@ impl IpcDrmCapturer {
|
||||
shared,
|
||||
stop,
|
||||
display,
|
||||
connector: displays.get(display.max(0) as usize).map(connector_key),
|
||||
// Identity and geometry come from the entry actually REQUESTED (the identity-resolved
|
||||
// index in the handshake list), not from `display`, whose meaning belongs to the
|
||||
// client's list.
|
||||
connector: displays.get(wire_idx).map(connector_key),
|
||||
session_size: displays
|
||||
.get(display.max(0) as usize)
|
||||
.get(wire_idx)
|
||||
.map(|d| (d.width as usize, d.height as usize)),
|
||||
cur: Vec::new(),
|
||||
cur_w: 0,
|
||||
@@ -337,6 +361,7 @@ impl IpcDrmCapturer {
|
||||
got_frame: false,
|
||||
},
|
||||
displays,
|
||||
wire_idx,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -483,9 +508,10 @@ impl TraitCapturer for IpcDrmCapturer {
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn recv_thread(
|
||||
display: i32,
|
||||
expected: Option<DrmDisplayInfo>,
|
||||
shared: Arc<Shared>,
|
||||
stop: Arc<AtomicBool>,
|
||||
tx: std::sync::mpsc::Sender<ResultType<Vec<DrmDisplayInfo>>>,
|
||||
tx: std::sync::mpsc::Sender<ResultType<(Vec<DrmDisplayInfo>, usize)>>,
|
||||
) {
|
||||
// Unique tag for this stream's cursor entries so teardown only erases its own (see
|
||||
// remove_drm_cursor); a rebuilt stream for the same display index gets a newer epoch.
|
||||
@@ -498,7 +524,7 @@ async fn recv_thread(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let displays = match conn.recv_msg_timeout2(HANDSHAKE_TIMEOUT_MS).await {
|
||||
let displays = match conn.recv_msg_timeout2(DISPLAY_LIST_TIMEOUT_MS).await {
|
||||
Some(Ok((Data::DrmDisplayList(v), _fd))) => v,
|
||||
Some(Ok((other, _fd))) => {
|
||||
let _ = tx.send(Err(anyhow!("expected DrmDisplayList, got {:?}", other)));
|
||||
@@ -513,15 +539,45 @@ async fn recv_thread(
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The index that names our monitor IN THIS CONNECTION'S LIST. The service resolves the DrmStart
|
||||
// index against the fresh enumeration it just sent us, while `display` is an index into the list
|
||||
// the CLIENT chose from -- and between those two lists a wake or hotplug may have inserted or
|
||||
// removed entries (a woken 2880x1800 panel re-enters AHEAD of the Touch Bar's card order on the
|
||||
// measured T2, so "index 0" flips from the Touch Bar to the panel). Resolve the intended monitor
|
||||
// by connector identity in the list we were just handed and request THAT index. If the monitor is
|
||||
// gone from the handshake list entirely, fail the build: streaming whichever monitor now holds
|
||||
// the number would put the wrong pixels under the client's geometry and route its input by the
|
||||
// wrong rect, which is precisely the class of bug the login-time settle exists to end.
|
||||
let wire_idx = match &expected {
|
||||
Some(e) => {
|
||||
match displays
|
||||
.iter()
|
||||
.position(|d| d.device == e.device && d.name == e.name)
|
||||
{
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let _ = tx.send(Err(anyhow!(
|
||||
"display {display} ({}) is no longer in the service's list; \
|
||||
the video service will rebuild against the fresh topology",
|
||||
e.name
|
||||
)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No identity to hold the service to (a caller without a cached list); the raw index is
|
||||
// all there is, exactly the pre-identity behaviour.
|
||||
None => display.max(0) as usize,
|
||||
};
|
||||
// The service binds this stream to (device, crtc_id) at DrmStart, which survives a topology change.
|
||||
// Everything on this side is addressed by LIST INDEX, which does not: drm_enumerate_all_displays
|
||||
// concatenates per-card lists, so plugging or unplugging a monitor renumbers them. Record what this
|
||||
// stream was actually bound to, so a renumbering can be detected on the next hotplug instead of the
|
||||
// client being shown, and having its clicks mapped to, whichever monitor now occupies this index.
|
||||
let bound_to = displays
|
||||
.get(display.max(0) as usize)
|
||||
.get(wire_idx)
|
||||
.map(|d| (d.device.clone(), d.crtc_id));
|
||||
let our_key = displays.get(display.max(0) as usize).map(connector_key);
|
||||
let our_key = displays.get(wire_idx).map(connector_key);
|
||||
// Open the unprivileged render-node convert context ONCE, on THIS thread, before we answer the
|
||||
// display list with DrmStart; it is dropped on this same thread when the loop exits (its EGL
|
||||
// state + import-once cache are thread-local). `None` means no usable render node (a locked-down
|
||||
@@ -540,7 +596,7 @@ async fn recv_thread(
|
||||
// the ambiguity check below. Every display of one device carries the same node, so a display
|
||||
// index that does not resolve still gets the right answer from the first entry.
|
||||
let render_node = displays
|
||||
.get(display.max(0) as usize)
|
||||
.get(wire_idx)
|
||||
.or_else(|| displays.first())
|
||||
.map(|d| d.render_node.clone())
|
||||
.unwrap_or_default();
|
||||
@@ -577,13 +633,19 @@ async fn recv_thread(
|
||||
);
|
||||
}
|
||||
if let Err(err) = conn
|
||||
.send_msg(&Data::DrmStart { display, need_cpu }, None)
|
||||
.send_msg(
|
||||
&Data::DrmStart {
|
||||
display: wire_idx as i32,
|
||||
need_cpu,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = tx.send(Err(err));
|
||||
return;
|
||||
}
|
||||
let _ = tx.send(Ok(displays));
|
||||
let _ = tx.send(Ok((displays, wire_idx)));
|
||||
|
||||
// Stream until stopped or the connection ends. Poll the header read with a short timeout (rather
|
||||
// than blocking indefinitely) so a dropped capturer re-checks `stop` and tears down promptly even
|
||||
@@ -947,11 +1009,12 @@ pub fn drm_cursor() -> Option<DrmCursorData> {
|
||||
// Server capture-path integration (the parallel, gated DRM path)
|
||||
//
|
||||
// The `--server` selects DRM/KMS capture over PipeWire when the root service offers the `_drm`
|
||||
// channel. Availability + the display list are probed once and cached: the `_drm` listener now
|
||||
// serves consumers concurrently (one connection per captured display), but re-probing on every
|
||||
// enumeration still churns connections needlessly and briefly tripped a restart loop in testing, so
|
||||
// the result is cached durably. The cache is seeded before capture starts (display enumeration) and
|
||||
// by the capturer handshake, and only reset by `clear()` on teardown.
|
||||
// channel. Availability + the display list are probed and cached: the `_drm` listener serves
|
||||
// consumers concurrently (one connection per captured display), but re-probing on every
|
||||
// enumeration would churn connections needlessly and briefly tripped a restart loop in testing, so
|
||||
// the result is cached durably. The cache is written only by ordered, fresh-by-construction
|
||||
// sources: the startup warm, the login refresh, the DrmDisplaysChanged push, and the TTL
|
||||
// refresher. The capture handshake deliberately does NOT write it (see get_capturer_info).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum ProbeState {
|
||||
@@ -989,8 +1052,16 @@ fn query_displays() -> ResultType<Vec<DrmDisplayInfo>> {
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn query_displays_async() -> ResultType<Vec<DrmDisplayInfo>> {
|
||||
query_displays_inner().await
|
||||
}
|
||||
|
||||
/// The probe itself, as a plain future so an async caller already on a runtime (the login path's
|
||||
/// `refresh_displays_for_login`) can await it directly instead of paying a thread + a nested
|
||||
/// runtime. The list read uses DISPLAY_LIST_TIMEOUT_MS because the service may hold the answer
|
||||
/// back while it wakes sleeping displays (see the constant).
|
||||
async fn query_displays_inner() -> ResultType<Vec<DrmDisplayInfo>> {
|
||||
let mut conn = connect_drm(DRM_CONNECT_TIMEOUT_MS).await?;
|
||||
match conn.recv_msg_timeout2(HANDSHAKE_TIMEOUT_MS).await {
|
||||
match conn.recv_msg_timeout2(DISPLAY_LIST_TIMEOUT_MS).await {
|
||||
Some(Ok((Data::DrmDisplayList(v), _fd))) => Ok(v),
|
||||
Some(Ok((other, _fd))) => Err(anyhow!("expected DrmDisplayList, got {:?}", other)),
|
||||
Some(Err(err)) => Err(err),
|
||||
@@ -1236,7 +1307,21 @@ fn refresh_available_async() {
|
||||
match refresh_outcome(result.as_ref().ok().map(|l| l.len()), failures) {
|
||||
RefreshOutcome::Publish => {
|
||||
let fresh = result.unwrap_or_default();
|
||||
let changed = match &*st {
|
||||
ProbeState::Available(_, old) => *old != fresh,
|
||||
_ => true,
|
||||
};
|
||||
publish_probe_state(&mut st, ProbeState::Available(Instant::now(), fresh));
|
||||
if changed {
|
||||
// The topology moved under an idle session. The compositor's logical
|
||||
// layout (which scrap caches process-wide) moved with it, so invalidate
|
||||
// that cache exactly like the hotplug push and the login refresh do --
|
||||
// this writer used to be the ONE list-replacing publisher that skipped it,
|
||||
// leaving the geometry augmentation married to stale origins until some
|
||||
// other writer happened to clear the cache.
|
||||
drop(st);
|
||||
scrap::wayland::display::clear_wayland_displays_cache();
|
||||
}
|
||||
}
|
||||
RefreshOutcome::Unavailable => {
|
||||
log::info!("drm: refresh -> 0 displays, marking DRM unavailable");
|
||||
@@ -1297,6 +1382,85 @@ pub(super) fn warm_availability() {
|
||||
log::info!("drm: consumer cache warm found no producer at startup (will probe lazily)");
|
||||
}
|
||||
|
||||
/// Refresh the cached display list over a live `_drm` handshake, for the one moment where a stale
|
||||
/// list does damage that lasts the whole session: login, right before the client is promised its
|
||||
/// display list. The service side wakes sleeping displays and holds its answer until the topology
|
||||
/// settles (see `drm_enumerate_settled` in ipc/drm.rs), so the list published here is the list
|
||||
/// capture will actually find. Serving the cache here instead was how a client connected to a
|
||||
/// laptop with an idle panel: the login list, the wake firing later inside the capture handshake,
|
||||
/// and the resulting topology push each told the client something different, and it ended up with
|
||||
/// duplicate, misindexed monitors all showing the Touch Bar.
|
||||
///
|
||||
/// Replaces only an `Available` verdict and only with a non-empty list; every failure mode leaves
|
||||
/// the cache alone, so a login can never get HARDER than it is today, only truer. Concurrent
|
||||
/// publishers are already ordered: the background refresher discards its result when the
|
||||
/// generation moved (see `refresh_available_async`), and a later publish through
|
||||
/// `publish_probe_state` simply wins over this one.
|
||||
pub(super) async fn refresh_displays_for_login() {
|
||||
// Generation of the verdict this refresh is refreshing, sampled under the lock together with
|
||||
// the Available check, for the same reason refresh_available_async does it: the probe below is
|
||||
// slow and UNLOCKED (the service may hold the answer for seconds while a wake settles), so a
|
||||
// hotplug push or a concurrent login can publish a NEWER list meanwhile, and publishing over it
|
||||
// with our older probe would re-serve exactly the staleness this function exists to remove.
|
||||
let sampled_gen = {
|
||||
let st = DRM_STATE.lock().unwrap();
|
||||
if !matches!(&*st, ProbeState::Available(..)) {
|
||||
// Establishing availability is the probe path's job (warm_availability / is_available);
|
||||
// this refresh only keeps an existing verdict truthful.
|
||||
return;
|
||||
}
|
||||
DRM_STATE_GEN.load(Ordering::Acquire)
|
||||
};
|
||||
let t = Instant::now();
|
||||
match query_displays_inner().await {
|
||||
Ok(list) if !list.is_empty() => {
|
||||
let changed = {
|
||||
let mut st = DRM_STATE.lock().unwrap();
|
||||
if DRM_STATE_GEN.load(Ordering::Acquire) != sampled_gen {
|
||||
// Someone republished while we probed; their list is newer than ours. The
|
||||
// login still serves fresh data -- theirs.
|
||||
log::debug!(
|
||||
"drm: login display refresh superseded while probing; keeping the newer list"
|
||||
);
|
||||
return;
|
||||
}
|
||||
match &*st {
|
||||
ProbeState::Available(_, old) => {
|
||||
let changed = *old != list;
|
||||
log::debug!(
|
||||
"drm: login display refresh -> {} display(s) in {:?}{}",
|
||||
list.len(),
|
||||
t.elapsed(),
|
||||
if changed { " (list changed)" } else { "" }
|
||||
);
|
||||
publish_probe_state(&mut st, ProbeState::Available(Instant::now(), list));
|
||||
changed
|
||||
}
|
||||
// The verdict moved while we probed (a hotplug drop to Unavailable, a GiveUp
|
||||
// to Unknown). That verdict is newer evidence than our list; leave it.
|
||||
_ => return,
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
// The compositor's logical layout usually changed with the topology -- re-enabling
|
||||
// a panel is a modeset -- and scrap caches that layout process-wide. Invalidate it
|
||||
// so the geometry augmentation marries the fresh DRM list to fresh logical origins
|
||||
// instead of stale ones. Same invalidation the hotplug push does; the uinput range
|
||||
// converges through the display service's periodic refresh, as it already does.
|
||||
scrap::wayland::display::clear_wayland_displays_cache();
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!(
|
||||
"drm: login display refresh found no displays in {:?}; keeping the cached list",
|
||||
t.elapsed()
|
||||
),
|
||||
Err(err) => log::debug!(
|
||||
"drm: login display refresh failed in {:?} ({err}); keeping the cached list",
|
||||
t.elapsed()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The cached DRM displays as protobuf `DisplayInfo`, augmented with the compositor's logical layout
|
||||
/// (per-monitor position + scale). `None` until probed/available.
|
||||
pub(super) fn get_display_infos() -> Option<Vec<DisplayInfo>> {
|
||||
@@ -1524,20 +1688,34 @@ fn display_info_from_drm(d: &DrmDisplayInfo) -> DisplayInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `CapturerInfo` backed by a DRM-IPC capturer for `display_idx`, refreshing the cached
|
||||
/// display list from the capturer's handshake so mid-capture enumeration uses fresh geometry.
|
||||
/// Build a `CapturerInfo` backed by a DRM-IPC capturer for `display_idx`.
|
||||
///
|
||||
/// Deliberately does NOT publish the handshake list into DRM_STATE, although it used to. Freshness
|
||||
/// is already supplied by writers that are ordered and coherent -- the login refresh (every login),
|
||||
/// the DrmDisplaysChanged push (every topology change, straight to live streams), and the TTL
|
||||
/// refresher -- while THIS list is read off the wire before a possibly seconds-long stall (the
|
||||
/// health-map work and the augment's compositor roundtrip below), so publishing it here could
|
||||
/// clobber a newer settled list with pre-wake data. Worse, when `wire_idx != display_idx` the
|
||||
/// handshake list is ordered differently from the list the client's indices mean, and publishing
|
||||
/// it would make the 300ms display sync re-advertise a list in which this very session's index
|
||||
/// names a DIFFERENT monitor than the stream it is watching.
|
||||
pub(super) fn get_capturer_info(
|
||||
display_idx: usize,
|
||||
) -> ResultType<super::video_service::CapturerInfo> {
|
||||
// Identity of the display being asked for, resolved ONCE and before any of the per-display maps
|
||||
// are locked: connector_key_of takes DRM_STATE, and nesting that inside a map lock would be the
|
||||
// one lock order this file does not otherwise have.
|
||||
// The display being asked for, resolved ONCE out of DRM_STATE and before any of the per-display
|
||||
// maps are locked: display_info_of takes DRM_STATE, and nesting that inside a map lock would be
|
||||
// the one lock order this file does not otherwise have. The full entry is kept (not just its
|
||||
// key) because the handshake below re-resolves it BY IDENTITY in the service's fresh list --
|
||||
// the service answers each connection with its own enumeration, and an index is only meaningful
|
||||
// against the list it came from.
|
||||
// `None` when the display list does not describe this index (not enumerated yet, or out of
|
||||
// range). Kept as an Option rather than collapsed to "": an empty key is a REAL key in the map,
|
||||
// so two unidentifiable displays would share one entry and one could demote the other. That is
|
||||
// the aliasing frame() already refuses to take part in, and both blocks below skip on None for
|
||||
// the same reason. A display with no identity simply carries no health.
|
||||
let key = connector_key_of(display_idx as i32);
|
||||
// range). The derived key is kept as an Option rather than collapsed to "": an empty key is a
|
||||
// REAL key in the map, so two unidentifiable displays would share one entry and one could
|
||||
// demote the other. That is the aliasing frame() already refuses to take part in, and both
|
||||
// blocks below skip on None for the same reason. A display with no identity simply carries no
|
||||
// health.
|
||||
let expected = display_info_of(display_idx as i32);
|
||||
let key = expected.as_ref().map(connector_key);
|
||||
// Refuse a display already demoted (repeated zero-frame sessions, or a detected flap below), so
|
||||
// the video service uses PipeWire for it instead of rebuilding onto DRM forever. Per-display, not
|
||||
// a global DRM disable.
|
||||
@@ -1562,7 +1740,7 @@ pub(super) fn get_capturer_info(
|
||||
// Build the capturer FIRST. A transient `_drm` outage (e.g. the root --service restarting) makes
|
||||
// this fail, and such a failure must NOT count toward the flap threshold — it self-heals once the
|
||||
// service returns. Only a SUCCESSFUL (re)build reaches the rapid-rebuild guard below.
|
||||
let (capturer, displays) = IpcDrmCapturer::new(display_idx as i32)?;
|
||||
let (capturer, displays, wire_idx) = IpcDrmCapturer::new(display_idx as i32, expected)?;
|
||||
// Rapid-rebuild guard (defense-in-depth): a display whose capturer is successfully rebuilt many
|
||||
// times in a short window is flapping (delivering a first frame then failing downstream every
|
||||
// cycle, which the got_frame streak alone cannot catch). Count the cadence of successful builds
|
||||
@@ -1593,18 +1771,23 @@ pub(super) fn get_capturer_info(
|
||||
}
|
||||
}
|
||||
let ndisplay = displays.len();
|
||||
// Geometry comes from the entry the stream was actually BOUND to (wire_idx, the
|
||||
// identity-resolved index in the handshake list). `display_idx` means "position in the list the
|
||||
// client chose from", and between that list and this handshake a wake or hotplug can have
|
||||
// renumbered entries -- indexing the handshake list with it would stream the right monitor
|
||||
// under the WRONG monitor's advertised geometry, the same class of bug the identity resolution
|
||||
// in the handshake exists to end.
|
||||
let d = displays
|
||||
.get(display_idx)
|
||||
.ok_or_else(|| anyhow!("drm display index {display_idx} out of range ({ndisplay})"))?
|
||||
.get(wire_idx)
|
||||
.ok_or_else(|| anyhow!("drm display index {wire_idx} out of range ({ndisplay})"))?
|
||||
.clone();
|
||||
// Publish the compositor's LOGICAL origin (the same augmentation get_display_infos advertises)
|
||||
// so the video service's origin matches the reported display geometry on multi-monitor / scaled
|
||||
// layouts; keep the raw physical dimensions for the capture buffer.
|
||||
let origin = augment_with_wayland_geometry(&displays)
|
||||
.get(display_idx)
|
||||
.get(wire_idx)
|
||||
.map(|di| (di.x, di.y))
|
||||
.unwrap_or((d.x, d.y));
|
||||
publish_probe_state(&mut DRM_STATE.lock().unwrap(), ProbeState::Available(Instant::now(), displays));
|
||||
Ok(super::video_service::CapturerInfo {
|
||||
origin,
|
||||
width: d.width as usize,
|
||||
|
||||
@@ -321,6 +321,16 @@ pub(super) async fn check_init() -> ResultType<()> {
|
||||
pub(super) async fn get_displays_and_primary() -> ResultType<(Vec<DisplayInfo>, usize)> {
|
||||
#[cfg(feature = "drm")]
|
||||
if super::drm_capturer::is_available_cached() {
|
||||
// This function runs once per login (update_get_sync_displays_on_login is its only
|
||||
// caller), and login is the moment the client is PROMISED a display list -- so refresh
|
||||
// that list over a live `_drm` handshake first. The service wakes sleeping displays and
|
||||
// answers with the settled truth, which is what makes an unattended box with an idled,
|
||||
// DISABLED panel connectable at all: the cached list would either omit the panel (probed
|
||||
// while asleep) or advertise a display with no scanout behind it (probed while awake), and
|
||||
// either way the wake then firing inside the capture handshake would change the list the
|
||||
// client had already been given. Properly async, so the executor is never blocked; on any
|
||||
// failure the cache serves as before.
|
||||
super::drm_capturer::refresh_displays_for_login().await;
|
||||
if let Some(displays) = super::drm_capturer::get_display_infos() {
|
||||
// DRM connector order is not the compositor's primary; resolve the real primary from
|
||||
// the compositor layout (matched by normalized connector name), not a hardcoded index 0.
|
||||
|
||||
Reference in New Issue
Block a user