perf(host): in-place mid-stream resize — mode-set the live monitor, keep the capturer
Latency plan P2.2/P2.3: against a v4 driver the manager's resize branch now runs UPDATE_MODES -> wait-mode-advertised (the OS re-enumerates async) -> set_active_mode -> verified-state settle (P0.2) on the SAME monitor — no REMOVE->ADD hotplug, no departure settle, no activation ladder, no re-isolate; Windows keeps the per-monitor DPI (identity preserved). Any failure (v3 driver, mode never advertised, settle miss) falls back to the proven re-arrival path unchanged. On top of that the session's resize handler keeps the WHOLE capture pipeline: the IDD-push capturer re-sizes its ring immediately (Capturer::resize_output — no DescriptorPoller two-strike debounce, which stays for EXTERNAL changes), the driver re-attaches and the mode-set full redraw provides the first frame; only the encoder is swapped once the first new-size frame arrives (open_video is ms-scale — P2.4 deliberately skipped). The capturer, send thread and session transport all survive; every decline routes to the full rebuild. Resize-trace stages (display_resized, ring_recreated, first_new_frame, encoder_open) extend the P0.1 timeline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -246,6 +246,24 @@ pub trait Capturer: Send {
|
|||||||
fn pipeline_depth(&self) -> usize {
|
fn pipeline_depth(&self) -> usize {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path
|
||||||
|
/// can verify the display it just reconfigured is STILL the one this capturer serves (an
|
||||||
|
/// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a
|
||||||
|
/// fresh capturer). `None` = the backend has no such identity (every non-IDD backend).
|
||||||
|
fn capture_target_id(&self) -> Option<u32> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HOST-INITIATED output resize (latency plan P2.3): the session's resize handler has ALREADY
|
||||||
|
/// committed the display's new mode (the manager's in-place mode set), so a capable capturer
|
||||||
|
/// re-sizes its capture surface NOW — no descriptor-poll debounce (that machinery stays, for
|
||||||
|
/// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive;
|
||||||
|
/// only the encoder is swapped by the caller once the first new-size frame arrives. Returns
|
||||||
|
/// `true` when handled; `false` (the default) routes the caller to the full-rebuild path.
|
||||||
|
fn resize_output(&mut self, _width: u32, _height: u32) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
/// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file →
|
||||||
|
|||||||
@@ -1903,6 +1903,39 @@ impl Capturer for IddPushCapturer {
|
|||||||
// always has its own texture).
|
// always has its own texture).
|
||||||
crate::config::config().idd_depth.clamp(1, OUT_RING)
|
crate::config::config().idd_depth.clamp(1, OUT_RING)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn capture_target_id(&self) -> Option<u32> {
|
||||||
|
Some(self.target_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize_output(&mut self, width: u32, height: u32) -> bool {
|
||||||
|
// Host-initiated resize (latency plan P2.3): the session's resize handler has already
|
||||||
|
// committed the display's new mode (the manager's in-place mode set), so recreate the ring
|
||||||
|
// at the new size NOW — no DescriptorPoller two-strike debounce (that stays, unchanged,
|
||||||
|
// for EXTERNAL changes: HDR flips, game mode-sets). The driver re-attaches to the fresh
|
||||||
|
// ring and republishes; on an in-place mode set the OS's mode-set full redraw gives the
|
||||||
|
// stash/first frame within the recover window. Same recover-or-drop arming as the
|
||||||
|
// poller-driven recreate, so a ring that can't re-attach still fails the session cleanly
|
||||||
|
// instead of freezing.
|
||||||
|
if (width, height) == (self.width, self.height) {
|
||||||
|
return true; // already at the requested size (refresh-only change) — nothing to do
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
target_id = self.target_id,
|
||||||
|
from = format!("{}x{}", self.width, self.height),
|
||||||
|
to = format!("{width}x{height}"),
|
||||||
|
"IDD push: host-initiated resize — recreating the ring at the new mode"
|
||||||
|
);
|
||||||
|
self.recovering_since.get_or_insert_with(Instant::now);
|
||||||
|
if let Err(e) = self.recreate_ring(self.display_hdr, width, height) {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"IDD push: host-initiated ring recreate failed — falling back to a full rebuild"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16
|
/// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16
|
||||||
|
|||||||
@@ -4616,81 +4616,111 @@ fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDisplay>) -> Res
|
|||||||
} else {
|
} else {
|
||||||
bitrate_kbps
|
bitrate_kbps
|
||||||
};
|
};
|
||||||
// Build the new pipeline BEFORE dropping the old one: the host already acked
|
// IN-PLACE fast path first (latency plan P2.3, Windows IDD-push): keep the capturer +
|
||||||
// the switch as accepted, so a rebuild failure must not kill an otherwise
|
// send thread, mode-set the SAME monitor in place (P2.1/P2.2), resize the ring, swap
|
||||||
|
// only the encoder. Any decline (v3 driver → the manager re-arrived, ring recreate
|
||||||
|
// failed, no new-size frame) falls through to the full rebuild below.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let fast_done = plan.capture == crate::session_plan::CaptureBackend::IddPush
|
||||||
|
&& try_inplace_resize(
|
||||||
|
&mut vd,
|
||||||
|
&mut capturer,
|
||||||
|
&mut enc,
|
||||||
|
&mut frame,
|
||||||
|
&mut interval,
|
||||||
|
new_mode,
|
||||||
|
mode_bitrate,
|
||||||
|
bit_depth,
|
||||||
|
plan,
|
||||||
|
&quit,
|
||||||
|
resize_trace.as_ref(),
|
||||||
|
);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let fast_done = false;
|
||||||
|
// Full rebuild — build the new pipeline BEFORE dropping the old one: the host already
|
||||||
|
// acked the switch as accepted, so a rebuild failure must not kill an otherwise
|
||||||
// healthy session — keep streaming the current mode and log instead.
|
// healthy session — keep streaming the current mode and log instead.
|
||||||
match build_pipeline(
|
let rebuilt = fast_done
|
||||||
&mut vd,
|
|| match build_pipeline(
|
||||||
new_mode,
|
&mut vd,
|
||||||
mode_bitrate,
|
new_mode,
|
||||||
bit_depth,
|
mode_bitrate,
|
||||||
plan,
|
bit_depth,
|
||||||
&quit,
|
plan,
|
||||||
Some(resize_trace.as_ref()),
|
&quit,
|
||||||
) {
|
Some(resize_trace.as_ref()),
|
||||||
Ok(next_pipe) => {
|
) {
|
||||||
if mode_bitrate != bitrate_kbps {
|
Ok(next_pipe) => {
|
||||||
tracing::info!(
|
let old_display_gen = cur_display_gen;
|
||||||
from_kbps = bitrate_kbps,
|
// The destructuring assignment drops the OLD capturer (→ its display lease)
|
||||||
to_kbps = mode_bitrate,
|
// as each binding is replaced — the new pipeline is already up
|
||||||
"pinned PyroWave bitrate re-resolved for the new mode"
|
// (create-before-drop).
|
||||||
);
|
(capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe;
|
||||||
bitrate_kbps = mode_bitrate;
|
// H4: the old display's lease drop above is indistinguishable from a
|
||||||
live_bitrate.store(mode_bitrate, Ordering::Relaxed);
|
// disconnect to the keep-alive machinery — under linger/forever policies
|
||||||
|
// every resize would ACCUMULATE kept monitors at stale modes. Retire the
|
||||||
|
// superseded entry now (a no-op when it was already torn down under
|
||||||
|
// `immediate`, or off Linux; the in-place fast path keeps the SAME display,
|
||||||
|
// so it has nothing to retire).
|
||||||
|
if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) {
|
||||||
|
crate::vdisplay::registry::retire(g);
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
let old_display_gen = cur_display_gen;
|
Err(e) => {
|
||||||
// The destructuring assignment drops the OLD capturer (→ its display lease) as
|
tracing::warn!(error = %format!("{e:#}"), ?new_mode,
|
||||||
// each binding is replaced — the new pipeline is already up (create-before-drop).
|
"mode-switch rebuild failed — staying on the current mode");
|
||||||
(capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe;
|
// H2 rollback: the control task acked the switch BEFORE this rebuild, so the
|
||||||
cur_mode = new_mode;
|
// client's mode slot already flipped to `new_mode`. A second accepted ack
|
||||||
next = std::time::Instant::now();
|
// carrying the still-live mode corrects it (any accepted ack means "the
|
||||||
// H4: the old display's lease drop above is indistinguishable from a disconnect
|
// active mode is now X" client-side; old clients just log it). `frame` is
|
||||||
// to the keep-alive machinery — under linger/forever policies every resize would
|
// untouched here (the fast path returned false before swapping anything and
|
||||||
// ACCUMULATE kept monitors at stale modes. Retire the superseded entry now (a
|
// the destructure only runs on the Ok arm), so it's still the OLD
|
||||||
// no-op when it was already torn down under `immediate`, or off Linux).
|
// pipeline's frame — its real dims + interval are what's still on glass.
|
||||||
if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) {
|
|
||||||
crate::vdisplay::registry::retire(g);
|
|
||||||
}
|
|
||||||
// H2/H3: the backend may have honored a different mode than requested — KWin
|
|
||||||
// caps a virtual output's refresh, or Windows pf-vdisplay rejects an in-place
|
|
||||||
// SetMode to a resolution its running monitor doesn't advertise and the host
|
|
||||||
// falls back to the actual display mode. `frame` is the NEW pipeline's first
|
|
||||||
// frame (just rebound above), so its dims are what the client actually decodes.
|
|
||||||
// Publish that ACTUAL mode to the live stats slot, and correct the client's mode
|
|
||||||
// slot when it differs from the accept ack it already got.
|
|
||||||
let actual = delivered_mode(frame.width, frame.height, interval);
|
|
||||||
live_mode.store(
|
|
||||||
pack_mode(actual.width, actual.height, actual.refresh_hz),
|
|
||||||
Ordering::Relaxed,
|
|
||||||
);
|
|
||||||
if actual != new_mode {
|
|
||||||
let _ = reconfig_result_tx.send(Reconfigured {
|
let _ = reconfig_result_tx.send(Reconfigured {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
mode: actual,
|
mode: delivered_mode(frame.width, frame.height, interval),
|
||||||
});
|
});
|
||||||
|
false
|
||||||
}
|
}
|
||||||
// The owed AUs died with the old encoder — drop their in-flight records
|
};
|
||||||
// and restart the encode-stall clock for the fresh one.
|
if rebuilt {
|
||||||
inflight.clear();
|
if mode_bitrate != bitrate_kbps {
|
||||||
last_au_at = std::time::Instant::now();
|
tracing::info!(
|
||||||
encoder_resets = 0;
|
from_kbps = bitrate_kbps,
|
||||||
last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown
|
to_kbps = mode_bitrate,
|
||||||
resize_trace.finish("pipeline_rebuilt");
|
"pinned PyroWave bitrate re-resolved for the new mode"
|
||||||
|
);
|
||||||
|
bitrate_kbps = mode_bitrate;
|
||||||
|
live_bitrate.store(mode_bitrate, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
cur_mode = new_mode;
|
||||||
tracing::warn!(error = %format!("{e:#}"), ?new_mode,
|
next = std::time::Instant::now();
|
||||||
"mode-switch rebuild failed — staying on the current mode");
|
// H2/H3: the backend may have honored a different mode than requested — KWin caps
|
||||||
// H2 rollback: the control task acked the switch BEFORE this rebuild, so the
|
// a virtual output's refresh, or Windows pf-vdisplay rejects a resolution its
|
||||||
// client's mode slot already flipped to `new_mode`. A second accepted ack
|
// running monitor doesn't advertise and the host falls back to the actual display
|
||||||
// carrying the still-live mode corrects it (any accepted ack means "the active
|
// mode. `frame` is the NEW pipeline's first frame (just rebound above), so its
|
||||||
// mode is now X" client-side; old clients just log it). `frame` is untouched
|
// dims are what the client actually decodes. Publish that ACTUAL mode to the live
|
||||||
// here (the destructure only runs on the Ok arm), so it's still the OLD
|
// stats slot, and correct the client's mode slot when it differs from the accept
|
||||||
// pipeline's frame — its real dims + interval are exactly what's still on glass.
|
// ack it already got.
|
||||||
|
let actual = delivered_mode(frame.width, frame.height, interval);
|
||||||
|
live_mode.store(
|
||||||
|
pack_mode(actual.width, actual.height, actual.refresh_hz),
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
if actual != new_mode {
|
||||||
let _ = reconfig_result_tx.send(Reconfigured {
|
let _ = reconfig_result_tx.send(Reconfigured {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
mode: delivered_mode(frame.width, frame.height, interval),
|
mode: actual,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// The owed AUs died with the old encoder — drop their in-flight records
|
||||||
|
// and restart the encode-stall clock for the fresh one.
|
||||||
|
inflight.clear();
|
||||||
|
last_au_at = std::time::Instant::now();
|
||||||
|
encoder_resets = 0;
|
||||||
|
last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown
|
||||||
|
resize_trace.finish("pipeline_rebuilt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
||||||
@@ -5346,6 +5376,115 @@ type Pipeline = (
|
|||||||
/// error chain is classified and permanent ones short-circuit. Each failed attempt drops its
|
/// error chain is classified and permanent ones short-circuit. Each failed attempt drops its
|
||||||
/// capturer, which (via `PortalCapturer::Drop`) tears the PipeWire thread + virtual output down
|
/// capturer, which (via `PortalCapturer::Drop`) tears the PipeWire thread + virtual output down
|
||||||
/// before the next attempt — no leak across retries.
|
/// before the next attempt — no leak across retries.
|
||||||
|
/// The in-place resize fast path (latency plan P2.3, Windows IDD-push): the manager mode-sets the
|
||||||
|
/// SAME monitor in place (driver protocol v4 — `IOCTL_UPDATE_MODES`; internally falls back to
|
||||||
|
/// re-arrival against an older driver), then the existing capturer re-sizes its ring immediately
|
||||||
|
/// (no descriptor-poll debounce) and only the ENCODER is swapped once the first new-size frame
|
||||||
|
/// arrives — the capture pipeline, its send thread and the whole session transport survive.
|
||||||
|
/// Returns `true` when the stream is now delivering the new mode on the same capturer; `false`
|
||||||
|
/// routes the caller to the full rebuild (which is also the correct path when the manager had to
|
||||||
|
/// re-arrive a fresh monitor — this capturer's ring/broker are bound to the departed target).
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn try_inplace_resize(
|
||||||
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
|
capturer: &mut Box<dyn crate::capture::Capturer>,
|
||||||
|
enc: &mut Box<dyn crate::encode::Encoder>,
|
||||||
|
frame: &mut crate::capture::CapturedFrame,
|
||||||
|
interval: &mut std::time::Duration,
|
||||||
|
new_mode: punktfunk_core::Mode,
|
||||||
|
bitrate_kbps: u32,
|
||||||
|
bit_depth: u8,
|
||||||
|
plan: crate::session_plan::SessionPlan,
|
||||||
|
quit: &Arc<AtomicBool>,
|
||||||
|
trace: &crate::bringup::Trace,
|
||||||
|
) -> bool {
|
||||||
|
let Some(cur_target) = capturer.capture_target_id() else {
|
||||||
|
return false; // not an IDD-push capturer — nothing to reuse
|
||||||
|
};
|
||||||
|
// Acquire at the new mode: the manager's resize branch runs the in-place mode set (or its
|
||||||
|
// re-arrival fallback) and returns a +1-ref lease, released again when `vout` drops below —
|
||||||
|
// the capturer keeps holding its own original lease (`gen` is preserved by both paths).
|
||||||
|
let vout = match crate::vdisplay::registry::acquire(vd, new_mode, quit.clone()) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "in-place resize: acquire failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trace.mark("display_resized");
|
||||||
|
let effective_hz = vout
|
||||||
|
.preferred_mode
|
||||||
|
.map(|(_, _, hz)| hz)
|
||||||
|
.filter(|&hz| hz > 0)
|
||||||
|
.unwrap_or(new_mode.refresh_hz);
|
||||||
|
if vout.win_capture.as_ref().map(|t| t.target_id) != Some(cur_target) {
|
||||||
|
// The manager re-arrived a fresh monitor (old driver / in-place failure): this capturer is
|
||||||
|
// bound to the departed target. The full rebuild re-acquires (JOINing the already-resized
|
||||||
|
// monitor) with a fresh capturer.
|
||||||
|
tracing::info!(
|
||||||
|
"resize: monitor re-arrived (no in-place support) — running the full pipeline rebuild"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !capturer.resize_output(new_mode.width, new_mode.height) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
trace.mark("ring_recreated");
|
||||||
|
// Bounded wait for the first frame at the new size (the driver re-attaches to the fresh ring;
|
||||||
|
// the mode-set full redraw composes promptly). Mirrors the capturer's own 3 s recover-or-drop.
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||||
|
let new_frame = loop {
|
||||||
|
match capturer.try_latest() {
|
||||||
|
Ok(Some(f)) if (f.width, f.height) == (new_mode.width, new_mode.height) => break f,
|
||||||
|
Ok(_) => {
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
tracing::warn!(
|
||||||
|
"resize: no new-size frame within 3s of the in-place mode set — running \
|
||||||
|
the full pipeline rebuild"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
|
"resize: capture failed after the in-place mode set — running the full rebuild");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trace.mark("first_new_frame");
|
||||||
|
// Fresh encoder at the delivered size — the one component that can't follow a resolution
|
||||||
|
// change in place today (P2.4 stays unimplemented: `open_video` is ms-scale, measured).
|
||||||
|
let mut new_enc = match crate::encode::open_video(
|
||||||
|
plan.codec,
|
||||||
|
new_frame.format,
|
||||||
|
new_frame.width,
|
||||||
|
new_frame.height,
|
||||||
|
effective_hz,
|
||||||
|
bitrate_kbps as u64 * 1000,
|
||||||
|
new_frame.is_cuda(),
|
||||||
|
bit_depth,
|
||||||
|
plan.chroma,
|
||||||
|
) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"),
|
||||||
|
"resize: encoder open failed after the in-place mode set — running the full rebuild");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(c) = plan.wire_chunk {
|
||||||
|
new_enc.set_wire_chunking(c);
|
||||||
|
}
|
||||||
|
*enc = new_enc;
|
||||||
|
*frame = new_frame;
|
||||||
|
*interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
|
||||||
|
trace.mark("encoder_open");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// The Welcome-time display-prep hand-off (latency plan P1.1/P1.2): the opened vdisplay backend +
|
/// The Welcome-time display-prep hand-off (latency plan P1.1/P1.2): the opened vdisplay backend +
|
||||||
/// the fully built pipeline — monitor create, activation, settle, capture attach, first frame,
|
/// the fully built pipeline — monitor create, activation, settle, capture attach, first frame,
|
||||||
/// encoder open — produced on the prep/stream thread while the client's Start round-trip and the
|
/// encoder open — produced on the prep/stream thread while the client's Start round-trip and the
|
||||||
|
|||||||
@@ -68,11 +68,12 @@ pub(crate) trait VdisplayDriver: Send + Sync {
|
|||||||
/// timeout. `reap_orphans` (the FIRST open of the process only) additionally `CLEAR_ALL`s
|
/// timeout. `reap_orphans` (the FIRST open of the process only) additionally `CLEAR_ALL`s
|
||||||
/// monitors orphaned by a crashed previous host — a REOPEN (after a dead handle was retired)
|
/// monitors orphaned by a crashed previous host — a REOPEN (after a dead handle was retired)
|
||||||
/// must NOT, since sessions this process still considers live may be racing it. Returns the
|
/// must NOT, since sessions this process still considers live may be racing it. Returns the
|
||||||
/// owned handle + watchdog seconds.
|
/// owned handle + watchdog seconds + the driver's reported protocol version (the in-place
|
||||||
|
/// resize gates on it).
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
|
/// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment.
|
||||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>;
|
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)>;
|
||||||
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
|
/// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and
|
||||||
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr`
|
/// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr`
|
||||||
/// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the
|
/// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the
|
||||||
@@ -90,6 +91,17 @@ pub(crate) trait VdisplayDriver: Send + Sync {
|
|||||||
preferred_monitor_id: u32,
|
preferred_monitor_id: u32,
|
||||||
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
|
||||||
) -> Result<AddedMonitor>;
|
) -> Result<AddedMonitor>;
|
||||||
|
/// Refresh the LIVE monitor `key`'s advertised mode list to lead with `mode` (the in-place
|
||||||
|
/// mid-stream resize, latency plan P2 — pf-vdisplay `IOCTL_UPDATE_MODES`, driver protocol v4).
|
||||||
|
/// The monitor is NOT departed; the caller CCD-forces the freshly-advertised mode afterwards.
|
||||||
|
/// The default errs so a backend without support routes to the re-arrival fallback.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `dev` must be the live control handle.
|
||||||
|
unsafe fn update_modes(&self, dev: HANDLE, key: &MonitorKey, mode: Mode) -> Result<()> {
|
||||||
|
let _ = (dev, key, mode);
|
||||||
|
anyhow::bail!("backend does not support in-place mode updates")
|
||||||
|
}
|
||||||
/// REMOVE the monitor identified by `key`.
|
/// REMOVE the monitor identified by `key`.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
@@ -255,6 +267,9 @@ pub(crate) struct VirtualDisplayManager {
|
|||||||
/// `&'static` singleton with no raw-handle smuggling.
|
/// `&'static` singleton with no raw-handle smuggling.
|
||||||
device: Mutex<DeviceSlot>,
|
device: Mutex<DeviceSlot>,
|
||||||
watchdog_s: AtomicU32,
|
watchdog_s: AtomicU32,
|
||||||
|
/// The driver's handshake-reported protocol version (0 until the first open). The in-place
|
||||||
|
/// resize (latency plan P2) gates on `>= 4`; a v3 driver keeps the re-arrival path.
|
||||||
|
driver_proto: AtomicU32,
|
||||||
/// Monotonic lease-generation counter (was the `MON_GEN` global).
|
/// Monotonic lease-generation counter (was the `MON_GEN` global).
|
||||||
gen: AtomicU64,
|
gen: AtomicU64,
|
||||||
state: Mutex<MgrInner>,
|
state: Mutex<MgrInner>,
|
||||||
@@ -285,6 +300,7 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
|
|||||||
driver,
|
driver,
|
||||||
device: Mutex::new(DeviceSlot::default()),
|
device: Mutex::new(DeviceSlot::default()),
|
||||||
watchdog_s: AtomicU32::new(3),
|
watchdog_s: AtomicU32::new(3),
|
||||||
|
driver_proto: AtomicU32::new(0),
|
||||||
gen: AtomicU64::new(1),
|
gen: AtomicU64::new(1),
|
||||||
state: Mutex::new(MgrInner::default()),
|
state: Mutex::new(MgrInner::default()),
|
||||||
setup_lock: Mutex::new(()),
|
setup_lock: Mutex::new(()),
|
||||||
@@ -431,9 +447,10 @@ impl VirtualDisplayManager {
|
|||||||
// FFI in the caller's apartment; the `device` mutex (held here) serializes it, so there is no
|
// FFI in the caller's apartment; the `device` mutex (held here) serializes it, so there is no
|
||||||
// concurrent open. `open` has no handle precondition to uphold, and the `OwnedHandle` it
|
// concurrent open. `open` has no handle precondition to uphold, and the `OwnedHandle` it
|
||||||
// returns is the sole owner of the device.
|
// returns is the sole owner of the device.
|
||||||
let (handle, watchdog_s) = unsafe { self.driver.open(reap)? };
|
let (handle, watchdog_s, driver_proto) = unsafe { self.driver.open(reap)? };
|
||||||
slot.opened_once = true;
|
slot.opened_once = true;
|
||||||
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
self.watchdog_s.store(watchdog_s, Ordering::Relaxed);
|
||||||
|
self.driver_proto.store(driver_proto, Ordering::Relaxed);
|
||||||
let raw = HANDLE(handle.as_raw_handle());
|
let raw = HANDLE(handle.as_raw_handle());
|
||||||
slot.current = Some(Arc::new(handle));
|
slot.current = Some(Arc::new(handle));
|
||||||
if !reap {
|
if !reap {
|
||||||
@@ -581,6 +598,53 @@ impl VirtualDisplayManager {
|
|||||||
_ => unreachable!("just matched Active"),
|
_ => unreachable!("just matched Active"),
|
||||||
};
|
};
|
||||||
if cur_mode != mode {
|
if cur_mode != mode {
|
||||||
|
// IN-PLACE mode set first (latency plan P2, driver protocol >= 4): refresh the
|
||||||
|
// live monitor's advertised modes (IOCTL_UPDATE_MODES) + CCD-force the new mode —
|
||||||
|
// no REMOVE→ADD, so the monitor's OS identity (saved per-monitor DPI), the
|
||||||
|
// driver-side swap-chain machinery and the retained frame stash all survive, and
|
||||||
|
// the whole hotplug cost (departure settle + activation ladder + re-isolate)
|
||||||
|
// disappears. Any failure falls through to the proven re-arrival below.
|
||||||
|
if self.driver_proto.load(Ordering::Relaxed) >= 4 {
|
||||||
|
let in_place = {
|
||||||
|
let Some(SlotState::Active { mon, refs }) = inner.slots.get_mut(&slot)
|
||||||
|
else {
|
||||||
|
unreachable!("just matched Active");
|
||||||
|
};
|
||||||
|
// SAFETY: `dev` is the handle `ensure_device()` returned above; the CCD
|
||||||
|
// waits inside run under the held `state` lock (this fn's discipline).
|
||||||
|
match unsafe { self.resize_in_place(dev, mon, mode) } {
|
||||||
|
Ok(()) => {
|
||||||
|
// Same join semantics as the re-arrival: +1 ref for the new
|
||||||
|
// (build-then-drop overlap) lease; `gen` untouched, so the old
|
||||||
|
// session's lease stays valid.
|
||||||
|
*refs += 1;
|
||||||
|
let refs = *refs;
|
||||||
|
let out = self.output_for(slot, mon, quit.clone());
|
||||||
|
tracing::info!(
|
||||||
|
slot,
|
||||||
|
refs,
|
||||||
|
backend = self.driver.name(),
|
||||||
|
"virtual monitor resized IN PLACE (identity + swap-chain kept)"
|
||||||
|
);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
slot,
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
"in-place resize failed — falling back to monitor re-arrival"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(out) = in_place {
|
||||||
|
// The width changed — re-arrange the group so auto-row siblings don't
|
||||||
|
// overlap the resized display (no-op for a single member).
|
||||||
|
self.apply_group_layout(&mut inner);
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else {
|
||||||
unreachable!("just matched Active");
|
unreachable!("just matched Active");
|
||||||
};
|
};
|
||||||
@@ -1096,6 +1160,68 @@ impl VirtualDisplayManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mid-stream resize IN PLACE (latency plan P2): the driver refreshes the LIVE monitor's
|
||||||
|
/// advertised target-mode list to lead with `mode` (`IOCTL_UPDATE_MODES` →
|
||||||
|
/// `IddCxMonitorUpdateModes2`, protocol v4), the OS re-enumerates the target's settable modes
|
||||||
|
/// (waited on, bounded), and the usual CCD/GDI force-set + verified settle (P0.2) commit it —
|
||||||
|
/// on the SAME monitor: target id, GDI name, saved per-monitor DPI, the driver's swap-chain
|
||||||
|
/// worker and its retained frame stash all survive (the OS reassigns the swap-chain across a
|
||||||
|
/// mode set; the preserved-publisher/stash hand-off covers that flap — what it was built for).
|
||||||
|
/// On success `mon.mode` is updated in place; any failure leaves `mon` untouched (still at the
|
||||||
|
/// old mode) and the caller falls back to [`re_add`](Self::re_add).
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `dev` must be the live control handle; runs the CCD/GDI FFI under the `state` lock.
|
||||||
|
unsafe fn resize_in_place(&self, dev: HANDLE, mon: &mut Monitor, mode: Mode) -> Result<()> {
|
||||||
|
let gdi = mon
|
||||||
|
.gdi_name
|
||||||
|
.clone()
|
||||||
|
.context("in-place resize needs a resolved GDI name")?;
|
||||||
|
tracing::info!(
|
||||||
|
old = format!(
|
||||||
|
"{}x{}@{}",
|
||||||
|
mon.mode.width, mon.mode.height, mon.mode.refresh_hz
|
||||||
|
),
|
||||||
|
new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz),
|
||||||
|
target = mon.target_id,
|
||||||
|
"virtual-display: updating the live monitor's modes for an in-place resize"
|
||||||
|
);
|
||||||
|
// SAFETY: `dev` is the live control handle (this fn's contract); `update_modes` forwards it
|
||||||
|
// to a synchronous IOCTL with owned/borrowed locals only.
|
||||||
|
unsafe { self.driver.update_modes(dev, &mon.key, mode) }?;
|
||||||
|
// The OS re-evaluates the target's settable modes asynchronously after UpdateModes2 — wait
|
||||||
|
// (bounded) for the new resolution to become enumerable before forcing it, else the
|
||||||
|
// CDS_TEST inside `set_active_mode` would reject it and silently keep the old mode.
|
||||||
|
let t0 = Instant::now();
|
||||||
|
if !crate::win_display::wait_mode_advertised(&gdi, mode, Duration::from_millis(2000)) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"OS did not advertise {}x{} within 2s of the driver mode-list update",
|
||||||
|
mode.width,
|
||||||
|
mode.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let advertised_ms = t0.elapsed().as_millis() as u64;
|
||||||
|
set_active_mode(&gdi, mode);
|
||||||
|
// Verified-state settle (P0.2): the same committed-state predicate as the create paths. A
|
||||||
|
// mode set that did not commit within the ceiling routes to the re-arrival fallback.
|
||||||
|
let settle_start = Instant::now();
|
||||||
|
// SAFETY: CCD/GDI query FFI over a `Copy` target id, under the held `state` lock.
|
||||||
|
let settled =
|
||||||
|
unsafe { wait_mode_settled(mon.target_id, mode, Duration::from_millis(1500)) };
|
||||||
|
if !settled {
|
||||||
|
anyhow::bail!(
|
||||||
|
"in-place mode set did not commit within 1.5s (advertised after {advertised_ms} ms)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
advertised_ms,
|
||||||
|
settle_ms = settle_start.elapsed().as_millis() as u64,
|
||||||
|
"in-place resize committed (verified-state wait)"
|
||||||
|
);
|
||||||
|
mon.mode = mode;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Mid-stream resize by monitor RE-ARRIVAL (`design/midstream-resolution-resize.md` Fix 1).
|
/// Mid-stream resize by monitor RE-ARRIVAL (`design/midstream-resolution-resize.md` Fix 1).
|
||||||
///
|
///
|
||||||
/// The pf-vdisplay driver freezes a monitor's advertised mode list at `IOCTL_ADD` time (the
|
/// The pf-vdisplay driver freezes a monitor's advertised mode list at `IOCTL_ADD` time (the
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
"pf-vdisplay"
|
"pf-vdisplay"
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)> {
|
unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> {
|
||||||
// SAFETY: `open_device` is `unsafe` only because it issues SetupAPI enumeration + `CreateFileW`
|
// SAFETY: `open_device` is `unsafe` only because it issues SetupAPI enumeration + `CreateFileW`
|
||||||
// FFI; it takes no arguments and returns an owned raw `HANDLE` (or `Err`). Called here on the
|
// FFI; it takes no arguments and returns an owned raw `HANDLE` (or `Err`). Called here on the
|
||||||
// backend-init thread, with no precondition beyond a valid thread context.
|
// backend-init thread, with no precondition beyond a valid thread context.
|
||||||
@@ -390,27 +390,43 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
.context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?;
|
.context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?;
|
||||||
let info: control::InfoReply =
|
let info: control::InfoReply =
|
||||||
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
|
bytemuck::pod_read_unaligned(&info_buf[..size_of::<control::InfoReply>()]);
|
||||||
if info.protocol_version != pf_driver_proto::PROTOCOL_VERSION {
|
// HARD floor/ceiling instead of strict equality since v4: v4 is ADDITIVE over v3
|
||||||
|
// (IOCTL_UPDATE_MODES — the in-place resize), so this host still drives a v3 driver and
|
||||||
|
// simply gates the in-place path on the reported version (re-arrival fallback). Anything
|
||||||
|
// below the floor or ABOVE this host's own version stays a loud failure.
|
||||||
|
if info.protocol_version < pf_driver_proto::MIN_DRIVER_PROTOCOL_VERSION
|
||||||
|
|| info.protocol_version > pf_driver_proto::PROTOCOL_VERSION
|
||||||
|
{
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \
|
"pf-vdisplay protocol mismatch: host drives {}..={}, driver reports {} — install \
|
||||||
host + driver",
|
matching host + driver",
|
||||||
|
pf_driver_proto::MIN_DRIVER_PROTOCOL_VERSION,
|
||||||
pf_driver_proto::PROTOCOL_VERSION,
|
pf_driver_proto::PROTOCOL_VERSION,
|
||||||
info.protocol_version
|
info.protocol_version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let watchdog_s = info.watchdog_timeout_s.max(1);
|
let watchdog_s = info.watchdog_timeout_s.max(1);
|
||||||
tracing::info!(
|
if info.protocol_version < pf_driver_proto::PROTOCOL_VERSION {
|
||||||
"pf-vdisplay protocol {} (watchdog timeout {}s)",
|
tracing::warn!(
|
||||||
info.protocol_version,
|
"pf-vdisplay protocol {} (host supports {}): driver lacks the in-place resize — \
|
||||||
watchdog_s
|
mid-stream resizes use the monitor re-arrival path until the driver is updated",
|
||||||
);
|
info.protocol_version,
|
||||||
|
pf_driver_proto::PROTOCOL_VERSION
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
"pf-vdisplay protocol {} (watchdog timeout {}s)",
|
||||||
|
info.protocol_version,
|
||||||
|
watchdog_s
|
||||||
|
);
|
||||||
|
}
|
||||||
// Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns
|
// Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns
|
||||||
// SUCCESS). FIRST open of the process only: a REOPEN (the manager retired a dead handle after
|
// SUCCESS). FIRST open of the process only: a REOPEN (the manager retired a dead handle after
|
||||||
// a driver upgrade / WUDFHost restart) can race sessions that still believe they are live, and
|
// a driver upgrade / WUDFHost restart) can race sessions that still believe they are live, and
|
||||||
// an unconditional CLEAR_ALL there would raze them.
|
// an unconditional CLEAR_ALL there would raze them.
|
||||||
if !reap_orphans {
|
if !reap_orphans {
|
||||||
reap_ghost_monitors();
|
reap_ghost_monitors();
|
||||||
return Ok((device, watchdog_s));
|
return Ok((device, watchdog_s, info.protocol_version));
|
||||||
}
|
}
|
||||||
let mut none: [u8; 0] = [];
|
let mut none: [u8; 0] = [];
|
||||||
// SAFETY: `raw` borrows the live `OwnedHandle` above. `IOCTL_CLEAR_ALL` has no input and no
|
// SAFETY: `raw` borrows the live `OwnedHandle` above. `IOCTL_CLEAR_ALL` has no input and no
|
||||||
@@ -427,7 +443,7 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
// monitor-slot budget — prevents the 0x80070490 slot-exhaustion wedge from carrying across
|
// monitor-slot budget — prevents the 0x80070490 slot-exhaustion wedge from carrying across
|
||||||
// restarts (the reason a restart's CLEAR_ALL alone never recovered it before).
|
// restarts (the reason a restart's CLEAR_ALL alone never recovered it before).
|
||||||
reap_ghost_monitors();
|
reap_ghost_monitors();
|
||||||
Ok((device, watchdog_s))
|
Ok((device, watchdog_s, info.protocol_version))
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn add_monitor(
|
unsafe fn add_monitor(
|
||||||
@@ -577,6 +593,38 @@ impl VdisplayDriver for PfVdisplayDriver {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsafe fn update_modes(&self, dev: HANDLE, key: &MonitorKey, mode: Mode) -> Result<()> {
|
||||||
|
let MonitorKey::Session(session_id) = key else {
|
||||||
|
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
|
||||||
|
};
|
||||||
|
let req = control::UpdateModesRequest {
|
||||||
|
session_id: *session_id,
|
||||||
|
width: mode.width,
|
||||||
|
height: mode.height,
|
||||||
|
refresh_hz: mode.refresh_hz,
|
||||||
|
_reserved: 0,
|
||||||
|
};
|
||||||
|
let mut none: [u8; 0] = [];
|
||||||
|
// SAFETY: per `update_modes`'s contract `dev` is the live control handle. `bytes_of(&req)`
|
||||||
|
// borrows the local `UpdateModesRequest` for the duration of this synchronous call as the
|
||||||
|
// input bytes; `none` is empty, so there is no output buffer.
|
||||||
|
unsafe {
|
||||||
|
ioctl(
|
||||||
|
dev,
|
||||||
|
control::IOCTL_UPDATE_MODES,
|
||||||
|
bytemuck::bytes_of(&req),
|
||||||
|
&mut none,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map(|_| ())
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"pf-vdisplay UPDATE_MODES {}x{}@{}",
|
||||||
|
mode.width, mode.height, mode.refresh_hz
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> {
|
unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> {
|
||||||
let MonitorKey::Session(session_id) = key else {
|
let MonitorKey::Session(session_id) = key else {
|
||||||
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
|
anyhow::bail!("pf-vdisplay: unexpected monitor key kind");
|
||||||
|
|||||||
@@ -258,6 +258,51 @@ pub(crate) unsafe fn wait_mode_settled(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wait (bounded) until `gdi_name` ADVERTISES `mode`'s resolution in its display-mode list — the
|
||||||
|
/// gate between a driver-side mode-list refresh (`IOCTL_UPDATE_MODES`, latency plan P2) and the
|
||||||
|
/// CCD/GDI force-set: the OS re-evaluates an indirect display's settable modes asynchronously after
|
||||||
|
/// `IddCxMonitorUpdateModes2`, so an immediate `set_active_mode` could race the re-enumeration and
|
||||||
|
/// silently leave the old mode. Returns `true` once any refresh at the requested WxH is enumerable.
|
||||||
|
pub(crate) fn wait_mode_advertised(
|
||||||
|
gdi_name: &str,
|
||||||
|
mode: Mode,
|
||||||
|
ceiling: std::time::Duration,
|
||||||
|
) -> bool {
|
||||||
|
let wname: Vec<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||||
|
let deadline = std::time::Instant::now() + ceiling;
|
||||||
|
loop {
|
||||||
|
let mut i = 0u32;
|
||||||
|
loop {
|
||||||
|
let mut dm = DEVMODEW {
|
||||||
|
dmSize: size_of::<DEVMODEW>() as u16,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// SAFETY: `wname` is a live NUL-terminated UTF-16 device name whose pointer stays valid
|
||||||
|
// for the call; `&mut dm` is a live, size-stamped DEVMODEW the API fills for mode index
|
||||||
|
// `i`. Both outlive this synchronous call.
|
||||||
|
let ok = unsafe {
|
||||||
|
EnumDisplaySettingsW(
|
||||||
|
PCWSTR(wname.as_ptr()),
|
||||||
|
ENUM_DISPLAY_SETTINGS_MODE(i),
|
||||||
|
&mut dm,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.as_bool();
|
||||||
|
if !ok {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if dm.dmPelsWidth == mode.width && dm.dmPelsHeight == mode.height {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Monitor-departure wait (latency plan P0.3): after a REMOVE, poll until the target has left the
|
/// Monitor-departure wait (latency plan P0.3): after a REMOVE, poll until the target has left the
|
||||||
/// ACTIVE CCD set — two consecutive absent samples, so one transient query failure mid-teardown
|
/// ACTIVE CCD set — two consecutive absent samples, so one transient query failure mid-teardown
|
||||||
/// can't read as "gone" — instead of sleeping the fixed departure settle. `ceiling` (the old fixed
|
/// can't read as "gone" — instead of sleeping the fixed departure settle. `ceiling` (the old fixed
|
||||||
|
|||||||
Reference in New Issue
Block a user