Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
981f32b8f6 | ||
|
|
a9a1b923a2 | ||
|
|
124cb66324 | ||
|
|
6774c4e7a2 |
@@ -96,6 +96,10 @@ struct UserData {
|
||||
/// into the first-frame-timeout retry loop; the promised renegotiation normally lands
|
||||
/// within a frame or two).
|
||||
gate_since: Option<std::time::Instant>,
|
||||
/// Deferred requeue of raw-passthrough buffers (see [`DeferredRequeue`]): the encode thread
|
||||
/// reads the dmabuf long after `.process` returns, so the buffer must not rejoin the
|
||||
/// producer's pool until the frame's [`BufferHold`] drops.
|
||||
defer: std::sync::Arc<DeferredRequeue>,
|
||||
}
|
||||
|
||||
impl UserData {
|
||||
@@ -113,6 +117,46 @@ impl UserData {
|
||||
}
|
||||
let _ = self.wake.try_send(());
|
||||
}
|
||||
|
||||
/// Withhold the raw-passthrough buffer from the producer's pool until the returned hold
|
||||
/// drops — the deferred requeue that closes the rewrite-while-the-encoder-reads race.
|
||||
/// `None` (pool too shallow, or `PUNKTFUNK_ZEROCOPY_HOLD=0`) falls back to the immediate
|
||||
/// `.process`-epilogue requeue, i.e. the old racy contract; said once per session.
|
||||
fn try_defer(&mut self, pw_buf: *mut pw::sys::pw_buffer) -> Option<pf_frame::FrameHold> {
|
||||
if !zerocopy_hold_enabled() {
|
||||
return None;
|
||||
}
|
||||
let buf = pw_buf as usize;
|
||||
let pool_live = self.pool.live;
|
||||
let generation = self.defer.book.lock().ok()?.try_hold(buf, pool_live);
|
||||
let Some(generation) = generation else {
|
||||
if !self.defer.logged_shallow.swap(true, Ordering::Relaxed) {
|
||||
tracing::warn!(
|
||||
pool_depth = pool_live,
|
||||
reserve = HOLD_POOL_RESERVE,
|
||||
"zero-copy: the producer's buffer pool cannot spare a buffer to hold across \
|
||||
the encode — falling back to the immediate requeue, which the producer may \
|
||||
rewrite mid-encode (torn/discolored frames under load); PUNKTFUNK_FORCE_SHM=1 \
|
||||
trades CPU for a race-free capture if artifacts appear"
|
||||
);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
if !self.defer.logged_active.swap(true, Ordering::Relaxed) {
|
||||
tracing::info!(
|
||||
pool_depth = pool_live,
|
||||
reserve = HOLD_POOL_RESERVE,
|
||||
"zero-copy: withholding each published buffer from the producer until the \
|
||||
encoder releases it (deferred requeue — the producer can no longer rewrite a \
|
||||
frame mid-encode); PUNKTFUNK_ZEROCOPY_HOLD=0 restores the immediate requeue"
|
||||
);
|
||||
}
|
||||
Some(std::sync::Arc::new(BufferHold {
|
||||
defer: self.defer.clone(),
|
||||
buf,
|
||||
generation,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the zero-copy negotiation decision depends on, gathered at ONE point in time.
|
||||
@@ -510,11 +554,12 @@ impl FenceWaitStats {
|
||||
|
||||
/// PW5 stage 1: how many buffers the producer actually allocated for this stream.
|
||||
///
|
||||
/// **Nothing in this codebase had ever counted them.** The zero-copy path dups the dmabuf fd and
|
||||
/// publishes the frame while the SPA buffer is handed straight back to the producer at `.process`
|
||||
/// return — so the only thing keeping capture untorn is that the producer round-robins a pool
|
||||
/// deeper than our import+encode window. That depth was an unmeasured assumption; this makes it a
|
||||
/// logged number, on every producer, before anything is built on it.
|
||||
/// **Nothing in this codebase had ever counted them.** The zero-copy path used to hand the SPA
|
||||
/// buffer straight back to the producer at `.process` return, leaving pool depth as the only
|
||||
/// thing keeping capture untorn. The deferred requeue ([`DeferredRequeue`]) now withholds
|
||||
/// published buffers until the consumer is done, but the depth still matters twice over: it is
|
||||
/// the budget `HoldBook::try_hold` spends (a pool of ≤ [`HOLD_POOL_RESERVE`] cannot defer at
|
||||
/// all and runs the old race), and for un-deferred frames it remains the race window.
|
||||
///
|
||||
/// `live` is maintained by the `add_buffer`/`remove_buffer` stream callbacks, which PipeWire fires
|
||||
/// on the loop thread as the pool is allocated (and again, remove-then-add, on a renegotiation that
|
||||
@@ -586,6 +631,104 @@ impl PassthroughFallbacks {
|
||||
/// short streak of dropped frames the capturer fails loudly and the session renegotiates.
|
||||
const IMPORT_FAIL_POISON: u32 = 3;
|
||||
|
||||
/// Buffers the deferred requeue always leaves in the producer's pool. One for the frame the
|
||||
/// producer is rendering right now, one in transit — withholding past that would make the
|
||||
/// producer skip frames whenever our holds are at their worst (host frame + up to two encoder
|
||||
/// ring slots), which is a pacing hiccup, not corruption, but there is no reason to court it.
|
||||
const HOLD_POOL_RESERVE: u32 = 2;
|
||||
|
||||
/// `PUNKTFUNK_ZEROCOPY_HOLD=0` restores the immediate `.process`-return requeue (the racy
|
||||
/// pre-hold behavior) — a field bisect lever, not a tuning knob. `env_on` grammar like every
|
||||
/// other capture knob (a bare `== "0"` compare is the trap `PUNKTFUNK_FORCE_SHM` already fell in).
|
||||
fn zerocopy_hold_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| pf_host_config::env_on("PUNKTFUNK_ZEROCOPY_HOLD").unwrap_or(true))
|
||||
}
|
||||
|
||||
/// Pure bookkeeping for the deferred requeue: which buffers are currently withheld from the
|
||||
/// producer, each under a per-hold generation so a pointer-value reuse across a pool
|
||||
/// renegotiation can never satisfy a stale hold's release (see `complete`).
|
||||
///
|
||||
/// Threading contract (what makes the single-requeue invariant hold with no atomics): entries are
|
||||
/// INSERTED (`try_hold`) and REMOVED (`complete` via the requeue channel's callback, `purge` via
|
||||
/// `remove_buffer`) only on the PipeWire loop thread; a dropping [`BufferHold`] on any other
|
||||
/// thread only *sends* the release message. So between a hold's creation and the loop servicing
|
||||
/// its release, `contains` is stable — which is exactly what the `.process` epilogue relies on to
|
||||
/// decide "requeue now" vs "the hold owns the requeue".
|
||||
#[derive(Default)]
|
||||
struct HoldBook {
|
||||
/// Withheld buffers: `*mut pw_buffer` as usize → the generation of the hold that owns it.
|
||||
out: std::collections::HashMap<usize, u64>,
|
||||
/// Last issued hold generation (monotonic per stream).
|
||||
last_gen: u64,
|
||||
}
|
||||
|
||||
impl HoldBook {
|
||||
/// Withhold `buf` if the pool can spare it: at most `pool_live - HOLD_POOL_RESERVE` buffers
|
||||
/// out at once. Returns the generation to release with, or `None` (pool too shallow / buffer
|
||||
/// somehow already out — the caller falls back to the immediate requeue).
|
||||
fn try_hold(&mut self, buf: usize, pool_live: u32) -> Option<u64> {
|
||||
let cap = pool_live.saturating_sub(HOLD_POOL_RESERVE) as usize;
|
||||
if self.out.len() >= cap || self.out.contains_key(&buf) {
|
||||
return None;
|
||||
}
|
||||
self.last_gen += 1;
|
||||
self.out.insert(buf, self.last_gen);
|
||||
Some(self.last_gen)
|
||||
}
|
||||
|
||||
/// A hold released: take `buf` out of the book iff this generation still owns it. `true` ⇒
|
||||
/// the caller must requeue the buffer; `false` ⇒ the entry was purged (pool renegotiated —
|
||||
/// the pointer may even be a NEW buffer under a reused address) and the buffer must NOT be
|
||||
/// touched.
|
||||
fn complete(&mut self, buf: usize, generation: u64) -> bool {
|
||||
match self.out.get(&buf) {
|
||||
Some(&g) if g == generation => {
|
||||
self.out.remove(&buf);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `remove_buffer`: the buffer is being freed under us (renegotiation/teardown) — forget it.
|
||||
/// Its hold's later release finds the generation gone and becomes a no-op.
|
||||
fn purge(&mut self, buf: usize) {
|
||||
self.out.remove(&buf);
|
||||
}
|
||||
|
||||
fn contains(&self, buf: usize) -> bool {
|
||||
self.out.contains_key(&buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared between the loop thread ([`HoldBook`] ops) and the [`BufferHold`] guards riding
|
||||
/// published frames to the encode thread.
|
||||
struct DeferredRequeue {
|
||||
book: std::sync::Mutex<HoldBook>,
|
||||
/// Wakes the loop to requeue `(buffer, generation)`. Send failure = the loop (and with it
|
||||
/// the stream and every buffer) is gone — nothing to release.
|
||||
tx: pw::channel::Sender<(usize, u64)>,
|
||||
/// One-per-session lines: the first successful defer, and the shallow-pool fallback.
|
||||
logged_active: std::sync::atomic::AtomicBool,
|
||||
logged_shallow: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// The concrete [`pf_frame::FrameHold`]: releases its buffer back to the producer when the last
|
||||
/// clone drops. Send-only from the dropping thread — the actual `pw_stream_queue_buffer` runs in
|
||||
/// the requeue channel's loop-thread callback.
|
||||
struct BufferHold {
|
||||
defer: std::sync::Arc<DeferredRequeue>,
|
||||
buf: usize,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Drop for BufferHold {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.defer.tx.send((self.buf, self.generation));
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
|
||||
/// pipeline must say why without flooding).
|
||||
fn warn_once(msg: &'static str) {
|
||||
@@ -644,7 +787,14 @@ impl Drop for DmabufMap {
|
||||
/// `.process` callback with the NEWEST drained buffer (latest-frame-only). `datas` is sourced
|
||||
/// via the same transparent cast libspa's `Buffer::datas_mut` performs, so the safe `Data`
|
||||
/// accessors (`.type_()`, `.chunk()`, `.data()`, `.fd()`, `.as_raw()`) keep working.
|
||||
fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
///
|
||||
/// `pw_buf` is the buffer's `pw_buffer` handle (`spa_buf`'s owner), used only as the identity a
|
||||
/// raw-passthrough publish withholds via [`UserData::try_defer`] — never dereferenced here.
|
||||
fn consume_frame(
|
||||
ud: &mut UserData,
|
||||
spa_buf: *mut spa::sys::spa_buffer,
|
||||
pw_buf: *mut pw::sys::pw_buffer,
|
||||
) {
|
||||
// No active stream: release the buffer without the (expensive at 5K) de-pad.
|
||||
if !ud.signals.active.load(Ordering::Relaxed) {
|
||||
return;
|
||||
@@ -822,8 +972,11 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
None
|
||||
};
|
||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||
// imports it. Content stability across the brief import/encode window relies
|
||||
// on the compositor's buffer-pool depth, like any zero-copy capture.
|
||||
// imports it. Content stability across the read window comes from the deferred
|
||||
// requeue below (`try_defer` — the producer does not get this buffer back until
|
||||
// the frame's hold drops); with no hold (shallow pool / PUNKTFUNK_ZEROCOPY_HOLD=0)
|
||||
// it falls back to the compositor's pool depth outrunning the encode, the old
|
||||
// racy contract.
|
||||
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||
@@ -836,6 +989,7 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let hold = ud.try_defer(pw_buf);
|
||||
ud.publish(CapturedFrame {
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
@@ -852,6 +1006,7 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
|
||||
offset,
|
||||
stride,
|
||||
plane1,
|
||||
hold,
|
||||
}),
|
||||
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
|
||||
// embeds its pointer in the produced pixels, so native NV12 has none.
|
||||
@@ -1434,6 +1589,18 @@ pub fn pipewire_thread(
|
||||
);
|
||||
}
|
||||
|
||||
// Deferred requeue (the rewrite-while-encoding fix): holds riding published frames release
|
||||
// their buffers through this channel from whatever thread drops them last; the receiver —
|
||||
// attached to the loop below, after the stream exists — is the single place a withheld
|
||||
// buffer rejoins the producer's pool.
|
||||
let (requeue_tx, requeue_rx) = pw::channel::channel::<(usize, u64)>();
|
||||
let defer = std::sync::Arc::new(DeferredRequeue {
|
||||
book: std::sync::Mutex::new(HoldBook::default()),
|
||||
tx: requeue_tx,
|
||||
logged_active: std::sync::atomic::AtomicBool::new(false),
|
||||
logged_shallow: std::sync::atomic::AtomicBool::new(false),
|
||||
});
|
||||
|
||||
let data = UserData {
|
||||
info: VideoInfoRaw::default(),
|
||||
format: None,
|
||||
@@ -1459,6 +1626,7 @@ pub fn pipewire_thread(
|
||||
},
|
||||
gate_skips: 0,
|
||||
gate_since: None,
|
||||
defer: defer.clone(),
|
||||
};
|
||||
|
||||
let stream = pw::stream::StreamBox::new(
|
||||
@@ -1562,10 +1730,18 @@ pub fn pipewire_thread(
|
||||
}
|
||||
})
|
||||
// PW5 stage 1 — the pool census. PipeWire fires these on the loop thread as it allocates
|
||||
// (and, on a renegotiation, frees then re-allocates) the stream's buffers. Counting only:
|
||||
// the buffer pointer is not touched, so no lifetime question arises here.
|
||||
// (and, on a renegotiation, frees then re-allocates) the stream's buffers. The census only
|
||||
// counts; `remove_buffer` additionally purges the buffer from the deferred-requeue book —
|
||||
// the buffer is being freed under any hold still riding a frame, so that hold's later
|
||||
// release must become a no-op (the generation check in `HoldBook::complete` also covers
|
||||
// the freed address being reused by a new pool's buffer).
|
||||
.add_buffer(|_stream, ud, _buf| ud.pool.add())
|
||||
.remove_buffer(|_stream, ud, _buf| ud.pool.remove())
|
||||
.remove_buffer(|_stream, ud, buf| {
|
||||
ud.pool.remove();
|
||||
if let Ok(mut book) = ud.defer.book.lock() {
|
||||
book.purge(buf as usize);
|
||||
}
|
||||
})
|
||||
.process(|stream, ud| {
|
||||
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its
|
||||
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue
|
||||
@@ -1598,19 +1774,19 @@ pub fn pipewire_thread(
|
||||
// value. MEASURED, not requested: `build_dmabuf_buffers` asks for a range and the
|
||||
// producer picks — this line is the only place the picked number is visible.
|
||||
//
|
||||
// Why it matters beyond curiosity: `stream.queue_raw_buffer(newest)` at the end of this
|
||||
// callback hands the buffer back while the encode thread may still be importing and
|
||||
// reading its dmabuf, so content stability rests entirely on the producer not cycling
|
||||
// back to this buffer before we are done with it. That window is `pool_depth` buffer
|
||||
// periods wide. A pool of 2 has essentially none.
|
||||
// Why it matters beyond curiosity: the depth is the budget the deferred requeue
|
||||
// (`HoldBook::try_hold`) spends withholding published buffers from the producer
|
||||
// while the encoder reads them. A pool of ≤ HOLD_POOL_RESERVE cannot defer at all —
|
||||
// those sessions run the old contract, where a requeued buffer may be rewritten
|
||||
// mid-encode and only pool depth keeps frames untorn.
|
||||
if let Some(depth) = ud.pool.note_frame() {
|
||||
tracing::info!(
|
||||
pool_depth = depth,
|
||||
high_water = ud.pool.high_water,
|
||||
drained,
|
||||
"pipewire buffer pool negotiated — this is the producer's ACTUAL count \
|
||||
(add_buffer/remove_buffer), the window in which a buffer we handed back may \
|
||||
be rewritten while the encoder still reads it"
|
||||
"pipewire buffer pool negotiated — the producer's ACTUAL count \
|
||||
(add_buffer/remove_buffer): the deferred-requeue budget, and the rewrite \
|
||||
window for any frame published without a hold"
|
||||
);
|
||||
}
|
||||
// Sacrificial-mode gate (kwin.rs `create`): until the producer renegotiates to the
|
||||
@@ -1766,14 +1942,30 @@ pub fn pipewire_thread(
|
||||
return;
|
||||
}
|
||||
|
||||
consume_frame(ud, spa_buf);
|
||||
consume_frame(ud, spa_buf, newest);
|
||||
}));
|
||||
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
|
||||
// or a caught panic in the closure above. This single requeue is what keeps the fixed
|
||||
// buffer pool from draining.
|
||||
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed
|
||||
// inside the closure above; `newest` was dequeued from this stream and not yet requeued.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
// or a caught panic in the closure above — UNLESS a raw-passthrough publish withheld it
|
||||
// (`try_defer` put it in the hold book): then the requeue duty belongs to the frame's
|
||||
// `BufferHold`, and requeueing here too would hand the producer the same buffer twice.
|
||||
// The book is stable across this check: only this thread removes entries (the requeue
|
||||
// channel's callback / `remove_buffer`), and neither can run inside `.process` — a
|
||||
// consumer racing the frame to its drop merely queues the release message. A panic
|
||||
// AFTER the publish leaves the hold live on the published frame, so skipping the
|
||||
// immediate requeue remains correct on that path too.
|
||||
let withheld = ud
|
||||
.defer
|
||||
.book
|
||||
.lock()
|
||||
.map(|b| b.contains(newest as usize))
|
||||
.unwrap_or(false);
|
||||
if !withheld {
|
||||
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame)
|
||||
// completed inside the closure above; `newest` was dequeued from this stream,
|
||||
// not yet requeued, and — per the `withheld` check — carries no hold that would
|
||||
// requeue it a second time.
|
||||
unsafe { stream.queue_raw_buffer(newest) };
|
||||
}
|
||||
if outcome.is_err() {
|
||||
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
|
||||
// format) would fire this every frame, so power-of-two throttle it — enough to
|
||||
@@ -1789,6 +1981,34 @@ pub fn pipewire_thread(
|
||||
.register()
|
||||
.context("register stream listener")?;
|
||||
|
||||
// The deferred-requeue service. A `BufferHold` dropping on any thread only *sends*
|
||||
// `(buffer, generation)`; this callback — on the loop thread, like every other stream op —
|
||||
// is where a withheld buffer actually rejoins the producer's pool. `HoldBook::complete`
|
||||
// makes a release for a renegotiated-away buffer (or a freed address reused by a new
|
||||
// pool's buffer) a no-op, so a stale hold can never queue somebody else's buffer.
|
||||
let defer_cb = defer.clone();
|
||||
let stream_ptr = stream.as_raw_ptr() as usize;
|
||||
let _requeue_attach = requeue_rx.attach(mainloop.loop_(), move |(buf, generation)| {
|
||||
let requeue = defer_cb
|
||||
.book
|
||||
.lock()
|
||||
.map(|mut b| b.complete(buf, generation))
|
||||
.unwrap_or(false);
|
||||
if requeue {
|
||||
// SAFETY: `complete` returned true ⇒ this buffer was withheld by exactly this hold
|
||||
// and no `remove_buffer` has freed it since (that purges the book), so the pointer
|
||||
// is a live buffer of this stream that we own (dequeued, never requeued). The
|
||||
// stream outlives this attached receiver (declared after it, dropped before it),
|
||||
// and the loop stops dispatching once `run()` returns.
|
||||
let _ = unsafe {
|
||||
pw::sys::pw_stream_queue_buffer(
|
||||
stream_ptr as *mut pw::sys::pw_stream,
|
||||
buf as *mut pw::sys::pw_buffer,
|
||||
)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Debug knob: offer a single fixed format (PUNKTFUNK_PW_FIXED_POD="WxH") to bisect
|
||||
// negotiation failures against a producer's exact EnumFormat (e.g. gamescope).
|
||||
let fixed_pod: Option<(u32, u32)> = std::env::var("PUNKTFUNK_PW_FIXED_POD")
|
||||
@@ -2479,4 +2699,77 @@ mod tests {
|
||||
assert_eq!(p.note_frame(), Some(0));
|
||||
assert_eq!(p.high_water, 0);
|
||||
}
|
||||
|
||||
use super::{HoldBook, HOLD_POOL_RESERVE};
|
||||
|
||||
/// The book must always leave [`HOLD_POOL_RESERVE`] buffers with the producer: an 8-pool
|
||||
/// spares 6, and the pools at or below the reserve spare NOTHING — those sessions must fall
|
||||
/// back to the immediate requeue rather than starve the compositor of render targets.
|
||||
#[test]
|
||||
fn hold_book_spends_at_most_pool_minus_reserve() {
|
||||
let mut b = HoldBook::default();
|
||||
for i in 0..6 {
|
||||
assert!(
|
||||
b.try_hold(0x1000 + i, 8).is_some(),
|
||||
"hold {i} within budget"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
b.try_hold(0x2000, 8).is_none(),
|
||||
"7th of 8 exceeds the budget"
|
||||
);
|
||||
assert!(
|
||||
HoldBook::default()
|
||||
.try_hold(0x1000, HOLD_POOL_RESERVE)
|
||||
.is_none(),
|
||||
"a pool of exactly the reserve cannot spare a buffer"
|
||||
);
|
||||
assert!(
|
||||
HoldBook::default()
|
||||
.try_hold(0x1000, HOLD_POOL_RESERVE + 1)
|
||||
.is_some(),
|
||||
"one past the reserve spares exactly one"
|
||||
);
|
||||
}
|
||||
|
||||
/// One hold ⇒ one requeue: the first `complete` releases, a duplicate release (a bug shape,
|
||||
/// but also the benign stale-message case) must NOT requeue a second time — handing the
|
||||
/// producer the same buffer twice corrupts its pool.
|
||||
#[test]
|
||||
fn hold_book_releases_exactly_once() {
|
||||
let mut b = HoldBook::default();
|
||||
let g = b.try_hold(0x1000, 8).unwrap();
|
||||
assert!(b.complete(0x1000, g), "first release requeues");
|
||||
assert!(!b.complete(0x1000, g), "second release is a no-op");
|
||||
assert!(!b.contains(0x1000));
|
||||
}
|
||||
|
||||
/// The renegotiation hazard the generation exists for: the pool is replaced (`remove_buffer`
|
||||
/// purges), a NEW buffer lands on the SAME address and is withheld, and only then does the
|
||||
/// OLD hold's release arrive. Matching by pointer alone would requeue the new tenant while
|
||||
/// its own hold is still out — the mid-encode rewrite race, reintroduced by the fix itself.
|
||||
#[test]
|
||||
fn hold_book_generation_outlives_an_address_reuse() {
|
||||
let mut b = HoldBook::default();
|
||||
let old = b.try_hold(0x1000, 8).unwrap();
|
||||
b.purge(0x1000); // remove_buffer: pool renegotiated away under the hold
|
||||
assert!(!b.complete(0x1000, old), "purged hold releases nothing");
|
||||
let new = b.try_hold(0x1000, 8).unwrap(); // new pool's buffer, same address
|
||||
assert!(
|
||||
!b.complete(0x1000, old),
|
||||
"the OLD hold cannot release the NEW tenant"
|
||||
);
|
||||
assert!(b.contains(0x1000), "new tenant still withheld");
|
||||
assert!(b.complete(0x1000, new), "its own hold releases it");
|
||||
}
|
||||
|
||||
/// A buffer already out cannot be withheld again (one requeue duty per buffer): `.process`
|
||||
/// can only re-see an address after its requeue, so a duplicate try_hold means state
|
||||
/// confusion — refuse it and let the epilogue requeue immediately.
|
||||
#[test]
|
||||
fn hold_book_refuses_a_buffer_already_out() {
|
||||
let mut b = HoldBook::default();
|
||||
b.try_hold(0x1000, 8).unwrap();
|
||||
assert!(b.try_hold(0x1000, 8).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,12 +322,14 @@ pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
|
||||
|
||||
/// PW5 stage 2: the buffer-pool depth we ASK for on the zero-copy path, as a Choice range.
|
||||
///
|
||||
/// The zero-copy path hands the SPA buffer back to the producer at `.process` return, while the
|
||||
/// encode thread still holds a dup of its dmabuf fd and has not yet imported, let alone read, the
|
||||
/// contents. Nothing bounds that window — see the `queue_raw_buffer` comment in `pipewire.rs` — so
|
||||
/// the only thing that keeps capture untorn is the producer round-robining a pool deeper than our
|
||||
/// import+encode latency. Until PW5 stage 1 nobody had ever counted what that pool was; we never
|
||||
/// even asked for a size (`build_dmabuf_buffers` set `dataType` and nothing else).
|
||||
/// The raw-passthrough arm now WITHHOLDS each published buffer from the producer until the
|
||||
/// consumer's hold drops (`DeferredRequeue` in `pipewire.rs` — the fix for the producer
|
||||
/// rewriting a buffer mid-encode), spending up to `pool - HOLD_POOL_RESERVE` buffers of this
|
||||
/// depth. A pool at the old floor of 2 has nothing to spend and falls back to the racy
|
||||
/// immediate requeue, where only the producer round-robining a pool deeper than our
|
||||
/// import+encode latency keeps capture untorn. Until PW5 stage 1 nobody had ever counted what
|
||||
/// that pool was; we never even asked for a size (`build_dmabuf_buffers` set `dataType` and
|
||||
/// nothing else).
|
||||
///
|
||||
/// A **range**, deliberately, not a fixed count: SPA intersects the consumer's and producer's
|
||||
/// Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection
|
||||
|
||||
@@ -2803,6 +2803,7 @@ mod tests {
|
||||
plane1: None,
|
||||
offset: 0,
|
||||
stride: 64 * 4,
|
||||
hold: None,
|
||||
}
|
||||
};
|
||||
let fd_count = || std::fs::read_dir("/proc/self/fd").expect("procfs").count();
|
||||
|
||||
@@ -936,6 +936,7 @@ mod tests {
|
||||
plane1: None,
|
||||
offset: 0,
|
||||
stride: 1920 * 4,
|
||||
hold: None,
|
||||
}),
|
||||
cursor,
|
||||
}
|
||||
|
||||
@@ -586,6 +586,12 @@ struct Frame {
|
||||
pts_ns: u64,
|
||||
keyframe: bool,
|
||||
recovery_anchor: bool,
|
||||
/// The captured dmabuf's deferred-requeue hold ([`pf_frame::FrameHold`]), cloned at submit and
|
||||
/// dropped when this slot retires (fence signaled — `poll`/backpressure/`reset`). This is what
|
||||
/// extends "the producer must not rewrite the buffer" across the whole asynchronous GPU read:
|
||||
/// the host's own clone only lives until it takes the NEXT frame, which with a ring of 2 is
|
||||
/// before this slot's encode finished. `None` for non-dmabuf sources or un-held frames.
|
||||
src_hold: Option<pf_frame::FrameHold>,
|
||||
}
|
||||
|
||||
pub struct VulkanVideoEncoder {
|
||||
@@ -2274,7 +2280,9 @@ impl VulkanVideoEncoder {
|
||||
// First import: acquire from the foreign producer (UNDEFINED preserves the modifier-tiled
|
||||
// bytes). Cached re-read: we still own it, so no queue-family transfer — just a visibility
|
||||
// barrier so the shader read sees the content the producer wrote out-of-band this frame
|
||||
// (single-GPU coherent; the capture layer guarantees the buffer is ready at hand-off).
|
||||
// (single-GPU coherent). The barrier orders nothing against the PRODUCER — content
|
||||
// stability across this read is the frame's deferred-requeue hold (`Frame::src_hold`):
|
||||
// the producer does not get the buffer back to rewrite until this slot's fence retires.
|
||||
let (old, src_qf, dst_qf) = if fresh {
|
||||
(
|
||||
vk::ImageLayout::UNDEFINED,
|
||||
@@ -3875,11 +3883,24 @@ impl VulkanVideoEncoder {
|
||||
),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
// Fence signaled ⟹ the GPU is done reading this slot's captured dmabuf — release
|
||||
// its hold so the capture layer requeues the producer's buffer.
|
||||
self.frames[slot].src_hold = None;
|
||||
let done = self.read_slot(slot)?;
|
||||
self.pending.push_back(done);
|
||||
}
|
||||
let slot = self.ring;
|
||||
self.ring = (self.ring + 1) % self.frames.len();
|
||||
// Take over the frame's deferred-requeue hold for this occupancy BEFORE recording: the
|
||||
// producer must not get the buffer back until this slot's fence retires (poll /
|
||||
// backpressure / reset), because the encode reads the imported dmabuf for its whole
|
||||
// duration — the host's own clone drops as soon as it takes the next frame. Assigned
|
||||
// even if `record_submit` then fails: an over-hold until the slot's next tenant is
|
||||
// harmless, a released-while-referenced buffer is the exact race this closes.
|
||||
self.frames[slot].src_hold = match &frame.payload {
|
||||
FramePayload::Dmabuf(d) => d.hold.clone(),
|
||||
_ => None,
|
||||
};
|
||||
self.record_submit(slot, frame, wire)?;
|
||||
self.in_flight.push_back(slot);
|
||||
Ok(())
|
||||
@@ -4002,6 +4023,9 @@ impl Encoder for VulkanVideoEncoder {
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
self.in_flight.pop_front();
|
||||
// Fence signaled ⟹ the GPU is done reading this slot's captured dmabuf — release its
|
||||
// hold so the capture layer requeues the producer's buffer.
|
||||
self.frames[slot].src_hold = None;
|
||||
// SAFETY: fence signaled ⟹ this slot's CSC+encode is complete; read its bitstream.
|
||||
Ok(Some(unsafe { self.read_slot(slot)? }))
|
||||
}
|
||||
@@ -4064,6 +4088,11 @@ impl Encoder for VulkanVideoEncoder {
|
||||
}
|
||||
self.in_flight.clear();
|
||||
self.pending.clear();
|
||||
// The waits above proved every slot's GPU read is done — release the captured-dmabuf
|
||||
// holds so the capture layer (possibly mid-rebuild itself) gets its buffers back.
|
||||
for f in &mut self.frames {
|
||||
f.src_hold = None;
|
||||
}
|
||||
self.ring = 0;
|
||||
self.first_frame = true;
|
||||
self.force_kf = false;
|
||||
|
||||
@@ -692,6 +692,9 @@ fn encode_one(
|
||||
plane1: req.plane1,
|
||||
offset: req.offset,
|
||||
stride: req.stride,
|
||||
// The deferred-requeue hold stays host-side: this backend is synchronous at depth 1
|
||||
// (see below), so the host's frame — hold and all — outlives the whole encode.
|
||||
hold: None,
|
||||
}),
|
||||
cursor,
|
||||
};
|
||||
|
||||
@@ -231,6 +231,23 @@ pub struct CapturedFrame {
|
||||
pub cursor: Option<CursorOverlay>,
|
||||
}
|
||||
|
||||
/// Keeps the producer's buffer behind a zero-copy frame OUT of the producer's pool.
|
||||
///
|
||||
/// The fd on a [`DmabufFrame`] only keeps the buffer object from being *freed*; nothing stops the
|
||||
/// compositor from *re-rendering into it* once the capture layer hands the buffer back — which it
|
||||
/// used to do at `.process` return, before the encoder had even imported the dmabuf (the
|
||||
/// gamescope-at-120fps torn-frame race). This handle is the fix: the PipeWire capture attaches one
|
||||
/// to every raw-passthrough frame (pool depth permitting) and defers the requeue until the LAST
|
||||
/// clone drops. A consumer that reads the dmabuf asynchronously (the Vulkan encoder's ring) clones
|
||||
/// it into whatever tracks the read (its ring slot) and drops it when the GPU is provably done
|
||||
/// (the slot's fence), so content stability covers exactly the read window. Consumers that finish
|
||||
/// their read while the frame is alive need to do nothing — the frame's own clone is enough.
|
||||
///
|
||||
/// Opaque on purpose: the concrete guard lives in the capture crate; everyone else only clones and
|
||||
/// drops.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type FrameHold = std::sync::Arc<dyn std::any::Any + Send + Sync>;
|
||||
|
||||
/// A captured frame still living in a DMA-BUF. Packed RGB uses one plane. Native Linux NV12
|
||||
/// (gamescope PipeWire) travels in ONE fd: Y starts at `offset`, and the interleaved UV plane
|
||||
/// lives at `plane1`'s offset/stride when the producer reported them — else at the contiguous
|
||||
@@ -238,8 +255,9 @@ pub struct CapturedFrame {
|
||||
///
|
||||
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
|
||||
/// imported there without the compositor's buffer being closed underneath it. Content stability
|
||||
/// across the brief import window relies on the compositor's buffer pool depth, like any zero-copy
|
||||
/// capture.
|
||||
/// across the read window comes from [`hold`](Self::hold) when present (the producer does not get
|
||||
/// the buffer back until the hold drops); a `None` hold falls back to the old contract — the
|
||||
/// compositor's pool depth outrunning the import+encode window.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct DmabufFrame {
|
||||
pub fd: std::os::fd::OwnedFd,
|
||||
@@ -253,6 +271,9 @@ pub struct DmabufFrame {
|
||||
pub plane1: Option<(u32, u32)>,
|
||||
pub offset: u32,
|
||||
pub stride: u32,
|
||||
/// Deferred-requeue hold on the producer's buffer (see [`FrameHold`]); `None` when the
|
||||
/// capture could not spare a buffer from the pool (shallow pool, or `PUNKTFUNK_ZEROCOPY_HOLD=0`).
|
||||
pub hold: Option<FrameHold>,
|
||||
}
|
||||
|
||||
/// Where a captured frame's pixels live.
|
||||
|
||||
@@ -7,14 +7,22 @@
|
||||
//!
|
||||
//! Reliability (this is the whole point — a sleeping host has no ARP entry, so a plain unicast
|
||||
//! can't wake it, and `255.255.255.255` alone leaves only via the default route). For each
|
||||
//! known host MAC we send the 102-byte packet to:
|
||||
//! * every non-loopback IPv4 interface's **subnet-directed broadcast** (routes to that NIC's
|
||||
//! segment — this is what covers multi-homed clients on VPN/docker/multiple LANs), and
|
||||
//! * the **limited broadcast** `255.255.255.255`, and
|
||||
//! * optionally a **unicast** to the host's last-known IP (covers the brief window where the
|
||||
//! host is reachable but hasn't re-advertised, and NICs that wake on a directed unicast),
|
||||
//! known host MAC we send the 102-byte packet:
|
||||
//! * **out of every non-loopback IPv4 interface**, from a socket bound to that interface's own
|
||||
//! address, to both that NIC's **subnet-directed broadcast** and the **limited broadcast**
|
||||
//! `255.255.255.255` — binding the source is what forces the datagram onto that segment
|
||||
//! instead of whatever the default route happens to be (a VPN/mesh interface, typically), and
|
||||
//! * from an unbound socket to `255.255.255.255` and, when known, a **unicast** to the host's
|
||||
//! last-known IP (covers the brief window where the host is reachable but hasn't
|
||||
//! re-advertised, and NICs that wake on a directed unicast),
|
||||
//!
|
||||
//! on the two conventional WoL ports (9 and 7), repeated a few times to survive UDP loss.
|
||||
//!
|
||||
//! **Wi-Fi hosts (WoWLAN) ride the same path**, and the per-interface egress above is what makes
|
||||
//! them work: a station in WoWLAN sleep stays associated, and the AP buffers broadcast frames for
|
||||
//! its sleeping stations and flushes them on the next DTIM beacon — so the broadcast does reach
|
||||
//! the sleeping NIC, but only if the datagram actually leaves via the wireless interface. The
|
||||
//! host end of it (arming the NIC's magic-packet trigger) is `punktfunk-host`'s `wol` module.
|
||||
|
||||
use std::io;
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket};
|
||||
@@ -64,41 +72,63 @@ pub fn build_magic_packet(mac: Mac) -> [u8; 102] {
|
||||
/// directed broadcast with no route) doesn't fail the whole wake. Errors only if no socket
|
||||
/// could be opened or nothing could be sent at all.
|
||||
pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::Result<()> {
|
||||
send_magic_packet_on(macs, last_known_ip, &WOL_PORTS)
|
||||
}
|
||||
|
||||
/// [`send_magic_packet`] with the destination ports spelled out. Private because the ports are
|
||||
/// not a caller's business — it exists so the tests can aim a real send at a port they're allowed
|
||||
/// to bind (9 and 7 are privileged) and assert the bytes that come off the wire.
|
||||
fn send_magic_packet_on(
|
||||
macs: &[Mac],
|
||||
last_known_ip: Option<Ipv4Addr>,
|
||||
ports: &[u16],
|
||||
) -> io::Result<()> {
|
||||
if macs.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"no MAC addresses",
|
||||
));
|
||||
}
|
||||
let packets: Vec<[u8; 102]> = macs.iter().map(|m| build_magic_packet(*m)).collect();
|
||||
|
||||
// Build the target IP set: each interface's directed broadcast, the limited broadcast, and
|
||||
// the optional last-known unicast. Dedup so a single-NIC client doesn't send twice.
|
||||
let mut targets = broadcast_addrs();
|
||||
targets.push(Ipv4Addr::BROADCAST); // 255.255.255.255
|
||||
// Targets that go out the default route (or wherever the routing table sends them): the
|
||||
// limited broadcast as a baseline, plus the optional unicast — destination routing picks the
|
||||
// right NIC for a unicast, so it doesn't need per-interface treatment.
|
||||
let mut routed: Vec<Ipv4Addr> = vec![Ipv4Addr::BROADCAST];
|
||||
if let Some(ip) = last_known_ip {
|
||||
targets.push(ip);
|
||||
routed.push(ip);
|
||||
}
|
||||
targets.sort_unstable();
|
||||
targets.dedup();
|
||||
|
||||
// One broadcast-enabled socket bound to all interfaces. Directed broadcasts route to the
|
||||
// matching NIC via the routing table; the limited broadcast leaves via the default route.
|
||||
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
|
||||
sock.set_broadcast(true)?;
|
||||
|
||||
let mut sent_any = false;
|
||||
for _ in 0..BURST {
|
||||
for mac in macs {
|
||||
let pkt = build_magic_packet(*mac);
|
||||
for ip in &targets {
|
||||
for port in WOL_PORTS {
|
||||
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, port));
|
||||
if sock.send_to(&pkt, dst).is_ok() {
|
||||
sent_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-interface pass. One socket per non-loopback IPv4 address, bound to that address so the
|
||||
// datagram leaves on THAT segment: without this, `255.255.255.255` follows the default route
|
||||
// only (a VPN/mesh NIC on most of these machines) and never touches the LAN — or the Wi-Fi
|
||||
// segment the sleeping WoWLAN station is associated to.
|
||||
for (local, bcast) in local_v4_segments() {
|
||||
let Ok(sock) = UdpSocket::bind(SocketAddrV4::new(local, 0)) else {
|
||||
// Bind failed (address just went away, or the OS refuses it) — fall back to the
|
||||
// routed socket below, which still reaches this segment's directed broadcast.
|
||||
routed.push(bcast);
|
||||
continue;
|
||||
};
|
||||
if sock.set_broadcast(true).is_err() {
|
||||
routed.push(bcast);
|
||||
continue;
|
||||
}
|
||||
sent_any |= blast(&sock, &packets, &[bcast, Ipv4Addr::BROADCAST], ports);
|
||||
}
|
||||
|
||||
// Routed pass, and the only pass on a machine whose interfaces can't be enumerated.
|
||||
if let Ok(sock) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) {
|
||||
// A refused SO_BROADCAST doesn't abort the pass: the unicast target still goes out, and
|
||||
// the per-interface sockets above may already have carried the broadcast.
|
||||
let _ = sock.set_broadcast(true);
|
||||
routed.sort_unstable();
|
||||
routed.dedup();
|
||||
sent_any |= blast(&sock, &packets, &routed, ports);
|
||||
} else if !sent_any {
|
||||
return Err(io::Error::other("no socket could be opened for the wake"));
|
||||
}
|
||||
|
||||
if sent_any {
|
||||
@@ -108,10 +138,33 @@ pub fn send_magic_packet(macs: &[Mac], last_known_ip: Option<Ipv4Addr>) -> io::R
|
||||
}
|
||||
}
|
||||
|
||||
/// Subnet-directed broadcast address of every non-loopback IPv4 interface (`ip | !netmask`,
|
||||
/// or the OS-provided broadcast when present). Best-effort: interface enumeration failing
|
||||
/// (permissions, exotic platform) yields an empty list, and the limited broadcast still fires.
|
||||
fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||
/// Send every packet to every target, on every port, [`BURST`] times. Returns whether any
|
||||
/// single datagram made it out — an unroutable target is expected and never fails the wake.
|
||||
fn blast(sock: &UdpSocket, packets: &[[u8; 102]], targets: &[Ipv4Addr], ports: &[u16]) -> bool {
|
||||
let mut sent_any = false;
|
||||
for _ in 0..BURST {
|
||||
for pkt in packets {
|
||||
for ip in targets {
|
||||
// A degenerate 0.0.0.0 (unconfigured NIC) is not a destination.
|
||||
if ip.is_unspecified() {
|
||||
continue;
|
||||
}
|
||||
for port in ports {
|
||||
let dst = SocketAddr::V4(SocketAddrV4::new(*ip, *port));
|
||||
if sock.send_to(pkt, dst).is_ok() {
|
||||
sent_any = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sent_any
|
||||
}
|
||||
|
||||
/// Every non-loopback IPv4 interface as `(its own address, its subnet-directed broadcast)`. The
|
||||
/// broadcast is the OS-provided one where present, else `ip | !netmask`. Best-effort: enumeration
|
||||
/// failing (permissions, exotic platform) yields an empty list and the routed pass still fires.
|
||||
fn local_v4_segments() -> Vec<(Ipv4Addr, Ipv4Addr)> {
|
||||
let mut out = Vec::new();
|
||||
let ifaces = match if_addrs::get_if_addrs() {
|
||||
Ok(i) => i,
|
||||
@@ -122,14 +175,13 @@ fn broadcast_addrs() -> Vec<Ipv4Addr> {
|
||||
continue;
|
||||
}
|
||||
if let if_addrs::IfAddr::V4(v4) = iface.addr {
|
||||
if v4.ip.is_unspecified() {
|
||||
continue; // nothing to bind to
|
||||
}
|
||||
let bcast = v4
|
||||
.broadcast
|
||||
.unwrap_or_else(|| Ipv4Addr::from(u32::from(v4.ip) | !u32::from(v4.netmask)));
|
||||
// Skip a degenerate 0.0.0.0 (unconfigured) and the all-ones limited broadcast we
|
||||
// already add unconditionally.
|
||||
if !bcast.is_unspecified() && bcast != Ipv4Addr::BROADCAST {
|
||||
out.push(bcast);
|
||||
}
|
||||
out.push((v4.ip, bcast));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -183,10 +235,47 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_addrs_never_contains_limited_or_unspecified() {
|
||||
for b in broadcast_addrs() {
|
||||
assert_ne!(b, Ipv4Addr::BROADCAST);
|
||||
assert!(!b.is_unspecified());
|
||||
fn local_segments_are_bindable_and_have_a_broadcast() {
|
||||
for (local, bcast) in local_v4_segments() {
|
||||
// The local address is what we bind the per-interface socket to, so it must be a
|
||||
// real address — and it must never be the loopback (filtered) or unspecified.
|
||||
assert!(!local.is_unspecified());
|
||||
assert!(!local.is_loopback());
|
||||
assert!(!bcast.is_unspecified());
|
||||
// Binding to an address the OS just reported must work; a failure here would mean
|
||||
// the per-interface pass silently degrades to the routed one.
|
||||
assert!(UdpSocket::bind(SocketAddrV4::new(local, 0)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blast_reports_nothing_sent_for_an_empty_target_list() {
|
||||
let sock = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind loopback");
|
||||
let pkt = [build_magic_packet([1, 2, 3, 4, 5, 6])];
|
||||
assert!(!blast(&sock, &pkt, &[], &WOL_PORTS));
|
||||
// An unconfigured 0.0.0.0 target is skipped rather than sent to.
|
||||
assert!(!blast(&sock, &pkt, &[Ipv4Addr::UNSPECIFIED], &WOL_PORTS));
|
||||
// Loopback is a real destination — this one must go out.
|
||||
assert!(blast(&sock, &pkt, &[Ipv4Addr::LOCALHOST], &[9999]));
|
||||
}
|
||||
|
||||
/// The whole send path, end to end: a real receiver gets a real magic packet with the right
|
||||
/// bytes. Aimed at loopback on an unprivileged port (WoL's own 9 and 7 need root to bind),
|
||||
/// which exercises the routed pass's unicast leg — the one a WoWLAN host is woken by when
|
||||
/// the AP filters broadcast to sleeping stations.
|
||||
#[test]
|
||||
fn send_delivers_the_magic_packet_to_a_listener() {
|
||||
let rx = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind receiver");
|
||||
let port = rx.local_addr().expect("local addr").port();
|
||||
rx.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.expect("read timeout");
|
||||
|
||||
let mac: Mac = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
|
||||
send_magic_packet_on(&[mac], Some(Ipv4Addr::LOCALHOST), &[port]).expect("send");
|
||||
|
||||
let mut buf = [0u8; 256];
|
||||
let (n, _from) = rx.recv_from(&mut buf).expect("a magic packet must arrive");
|
||||
assert_eq!(n, 102);
|
||||
assert_eq!(buf[..102], build_magic_packet(mac));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
//! Host-side Wake-on-LAN support.
|
||||
//! Host-side Wake-on-LAN / Wake-on-Wireless-LAN support.
|
||||
//!
|
||||
//! Two jobs, both best-effort (a failure here never affects streaming):
|
||||
//! 1. [`wake_macs`] — report the host's wake-capable NIC MAC(s) so a client can persist them
|
||||
//! (from the mDNS `mac` TXT record, [`crate::discovery`]) and wake this host later, once it's
|
||||
//! asleep and no longer advertising.
|
||||
//! asleep and no longer advertising. Wired and Wi-Fi NICs alike: a magic packet is the same
|
||||
//! packet either way, and an associated station in WoWLAN sleep receives the broadcast the
|
||||
//! AP buffers for it.
|
||||
//! 2. [`warn_if_not_armed`] — *detect & warn only* whether the NIC is actually armed to wake on a
|
||||
//! magic packet. We never change NIC settings (that's the user's call); we just surface the
|
||||
//! single most common reason WoL silently fails.
|
||||
//!
|
||||
//! Wired and wireless are armed through completely different interfaces, so the check follows the
|
||||
//! NIC: `ethtool <iface>` reports the wired `Wake-on: g` bit, while a Wi-Fi NIC's magic-packet
|
||||
//! trigger lives in nl80211's WoWLAN state and is read with `iw phy <phy> wowlan show`. Asking
|
||||
//! ethtool about a Wi-Fi NIC is what the previous version did, and it is actively misleading:
|
||||
//! most wireless drivers print `Wake-on: d` whether or not WoWLAN is armed, so an armed host got
|
||||
//! warned that it wasn't — with a fix command (`ethtool -s wlan0 wol g`) that its driver rejects.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
@@ -61,8 +70,8 @@ pub fn wake_macs(primary_ip: IpAddr) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Log whether the host NIC bearing `primary_ip` is armed to wake on a magic packet. Detect &
|
||||
/// warn only — never modifies settings. Linux-only (reads `ethtool <iface>`); a no-op elsewhere
|
||||
/// and silent when it can't tell (no `ethtool`, insufficient privilege).
|
||||
/// warn only — never modifies settings. Linux-only (shells out to `iw`/`ethtool`); a no-op
|
||||
/// elsewhere and silent when it can't tell (tool missing, insufficient privilege).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
let ifaces = if_addrs::get_if_addrs().unwrap_or_default();
|
||||
@@ -73,6 +82,41 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// A NIC with an nl80211 phy is wireless: ask nl80211 about WoWLAN, not ethtool about WoL.
|
||||
if let Some(phy) = wireless_phy(&iface) {
|
||||
match wowlan_has_magic(phy.as_deref(), &iface) {
|
||||
Some(true) => tracing::info!(
|
||||
iface = %iface,
|
||||
phy = phy.as_deref().unwrap_or("?"),
|
||||
"Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC"
|
||||
),
|
||||
Some(false) => {
|
||||
let phy = phy.as_deref().unwrap_or("phy0");
|
||||
// A device the kernel won't arm can't wake on anything, so name that separately
|
||||
// — enabling a WoWLAN trigger alone would not fix it.
|
||||
let extra = if device_wakeup_enabled(&iface) == Some(false) {
|
||||
" The kernel also has wake-up switched off for this device \
|
||||
(/sys/class/net/<iface>/device/power/wakeup reads `disabled`), which blocks \
|
||||
a network wake by itself."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
tracing::warn!(
|
||||
iface = %iface,
|
||||
"Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it \
|
||||
from sleep. Enable it with: sudo iw phy {phy} wowlan enable magic-packet \
|
||||
(NetworkManager resets that on every re-connect; make it stick with: sudo \
|
||||
nmcli connection modify <connection> 802-11-wireless.wake-on-wlan magic). \
|
||||
The adapter must also stay powered and associated while the host sleeps, and \
|
||||
be allowed to wake the machine in BIOS/UEFI.{extra}",
|
||||
)
|
||||
}
|
||||
None => {} // couldn't determine — stay quiet rather than cry wolf
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match ethtool_wol_has_magic(&iface) {
|
||||
Some(true) => {
|
||||
tracing::info!(iface = %iface, "Wake-on-LAN armed (magic packet) on host NIC")
|
||||
@@ -81,7 +125,7 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
iface = %iface,
|
||||
"Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep. \
|
||||
Enable it with: sudo ethtool -s {iface} wol g (and turn on 'Wake on LAN'/'Wake on \
|
||||
PCIe' in BIOS). Wired Ethernet is required; Wi-Fi wake is unreliable.",
|
||||
PCIe' in BIOS).",
|
||||
),
|
||||
None => {} // couldn't determine — stay quiet rather than cry wolf
|
||||
}
|
||||
@@ -90,6 +134,80 @@ pub fn warn_if_not_armed(primary_ip: IpAddr) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn warn_if_not_armed(_primary_ip: IpAddr) {}
|
||||
|
||||
/// Is `iface` a Wi-Fi NIC, and if so which nl80211 phy backs it? `Some(Some("phy0"))` = wireless
|
||||
/// and we know the phy (so we can query and name it); `Some(None)` = wireless but the phy name
|
||||
/// couldn't be read; `None` = wired (or sysfs is unavailable, which reads the same way — the
|
||||
/// ethtool path then applies, exactly as before).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wireless_phy(iface: &str) -> Option<Option<String>> {
|
||||
let dir = format!("/sys/class/net/{iface}/phy80211");
|
||||
if !std::path::Path::new(&dir).exists() {
|
||||
return None;
|
||||
}
|
||||
let name = std::fs::read_to_string(format!("{dir}/name"))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
Some(name)
|
||||
}
|
||||
|
||||
/// Whether a Wi-Fi NIC is armed for a magic-packet wake. `iw` is authoritative — it reads the
|
||||
/// live nl80211 WoWLAN state, which is where the trigger actually lives.
|
||||
///
|
||||
/// Two fallbacks for when `iw` can't answer (binary missing, driver without the WoWLAN command,
|
||||
/// no phy name, or a kernel that wants privilege we don't have — the host runs as a plain user
|
||||
/// service, so that last one is not hypothetical):
|
||||
/// * a *positive* ethtool reading counts, a negative one never does — a handful of drivers
|
||||
/// (brcmfmac and friends, i.e. most Raspberry Pi / SoC Wi-Fi) really do expose the
|
||||
/// magic-packet bit through ethtool, while the far more common `Wake-on: d` from a wireless
|
||||
/// driver means nothing at all;
|
||||
/// * failing that, sysfs `device/power/wakeup` — world-readable, and a `disabled` there is
|
||||
/// conclusive in the negative direction: the kernel will not arm this device to wake the
|
||||
/// machine, so whatever WoWLAN triggers the firmware holds can never fire.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wowlan_has_magic(phy: Option<&str>, iface: &str) -> Option<bool> {
|
||||
if let Some(v) = phy.and_then(iw_wowlan_has_magic) {
|
||||
return Some(v);
|
||||
}
|
||||
if let Some(true) = ethtool_wol_has_magic(iface) {
|
||||
return Some(true);
|
||||
}
|
||||
// Only the negative is meaningful: `enabled` says the device may wake the machine, not that a
|
||||
// magic packet is one of the things that will do it.
|
||||
match device_wakeup_enabled(iface) {
|
||||
Some(false) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// sysfs `/sys/class/net/<iface>/device/power/wakeup` — `enabled`/`disabled`, i.e. whether the
|
||||
/// kernel will arm this device to wake the system at all. `None` when the attribute isn't there
|
||||
/// (platform/SDIO devices often have none) or can't be read.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn device_wakeup_enabled(iface: &str) -> Option<bool> {
|
||||
let text =
|
||||
std::fs::read_to_string(format!("/sys/class/net/{iface}/device/power/wakeup")).ok()?;
|
||||
match text.trim() {
|
||||
"enabled" => Some(true),
|
||||
"disabled" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask nl80211 (via `iw phy <phy> wowlan show`) whether the magic-packet trigger is enabled.
|
||||
/// `None` if `iw` is missing or the driver doesn't implement WoWLAN.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn iw_wowlan_has_magic(phy: &str) -> Option<bool> {
|
||||
let out = std::process::Command::new("iw")
|
||||
.args(["phy", phy, "wowlan", "show"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_iw_wowlan(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// Parse `ethtool <iface>` for the *current* Wake-on setting and report whether it includes `g`
|
||||
/// (wake on MagicPacket). Returns `None` if ethtool is missing/failed or the field is absent.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -101,7 +219,13 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
parse_ethtool_wol(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// `ethtool <iface>` output → does the *current* Wake-on setting include `g` (MagicPacket)?
|
||||
/// `None` when the field is absent. Split out from the command so it can be unit-tested on any
|
||||
/// platform.
|
||||
fn parse_ethtool_wol(text: &str) -> Option<bool> {
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
// The current setting is "Wake-on: <flags>"; skip the "Supports Wake-on: ..." capability
|
||||
@@ -112,3 +236,88 @@ fn ethtool_wol_has_magic(iface: &str) -> Option<bool> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `iw phy <phy> wowlan show` output → is the magic-packet trigger enabled? The two shapes are
|
||||
///
|
||||
/// ```text
|
||||
/// WoWLAN is disabled
|
||||
/// ```
|
||||
/// ```text
|
||||
/// WoWLAN is enabled:
|
||||
/// * wake up on magic packet
|
||||
/// * wake up on pattern match, up to 20 patterns of 16 - 128 bytes
|
||||
/// ```
|
||||
///
|
||||
/// `* wake up on anything` (the nl80211 `any` trigger) counts too — that NIC wakes on every frame
|
||||
/// it receives, magic packets included. Enabled with only other triggers reads as NOT armed,
|
||||
/// which is the honest answer: a magic packet won't wake it. `None` when the output says nothing
|
||||
/// about WoWLAN at all. Split out from the command so it can be unit-tested on any platform.
|
||||
fn parse_iw_wowlan(text: &str) -> Option<bool> {
|
||||
let mut seen = false;
|
||||
let mut magic = false;
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if let Some(state) = t.strip_prefix("WoWLAN is ") {
|
||||
seen = true;
|
||||
if state
|
||||
.trim()
|
||||
.trim_end_matches(':')
|
||||
.eq_ignore_ascii_case("disabled")
|
||||
{
|
||||
return Some(false);
|
||||
}
|
||||
} else if seen && t.starts_with('*') {
|
||||
let l = t.to_ascii_lowercase();
|
||||
if l.contains("magic packet") || l.contains("anything") {
|
||||
magic = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.then_some(magic)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_ethtool_wol, parse_iw_wowlan};
|
||||
|
||||
#[test]
|
||||
fn ethtool_current_setting_not_capability_line() {
|
||||
let armed =
|
||||
"Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: g\n\tLink detected: yes\n";
|
||||
assert_eq!(parse_ethtool_wol(armed), Some(true));
|
||||
// "Supports Wake-on: ...g..." must NOT be read as the current setting.
|
||||
let off = "Settings for enp5s0:\n\tSupports Wake-on: pumbg\n\tWake-on: d\n";
|
||||
assert_eq!(parse_ethtool_wol(off), Some(false));
|
||||
assert_eq!(
|
||||
parse_ethtool_wol("Settings for lo:\n\tLink detected: yes\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iw_wowlan_states() {
|
||||
assert_eq!(parse_iw_wowlan("WoWLAN is disabled\n"), Some(false));
|
||||
assert_eq!(
|
||||
parse_iw_wowlan("WoWLAN is enabled:\n * wake up on magic packet\n"),
|
||||
Some(true)
|
||||
);
|
||||
// Enabled, but not for magic packets — a magic packet will not wake this NIC.
|
||||
assert_eq!(
|
||||
parse_iw_wowlan(
|
||||
"WoWLAN is enabled:\n * wake up on pattern match, up to 20 patterns of 16 - 128 bytes\n"
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
// The `any` trigger wakes on every received frame, magic packets included.
|
||||
assert_eq!(
|
||||
parse_iw_wowlan("WoWLAN is enabled:\n * wake up on anything (device continues operating normally)\n"),
|
||||
Some(true)
|
||||
);
|
||||
// Nothing to go on — the driver has no WoWLAN command.
|
||||
assert_eq!(parse_iw_wowlan(""), None);
|
||||
assert_eq!(
|
||||
parse_iw_wowlan("Wiphy phy0\n\tmax # scan SSIDs: 20\n"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,10 +104,11 @@ and capture/display glitches.
|
||||
Clients wake a saved host by themselves — auto-wake is on by default — but only once they have seen
|
||||
it awake, which is how they learn its MAC address, and only if the machine is armed to answer a magic
|
||||
packet. The arming is what's usually missing, and a **Linux** host tells you outright: search the web
|
||||
console's **Logs** page for `Wake-on-LAN`, and the line either confirms the card is armed or names
|
||||
the interface and the exact command to arm it. Windows and macOS hosts don't run that check, so go
|
||||
straight to the BIOS/UEFI and network-card steps in
|
||||
[Arming the machine](/docs/wake-on-lan#arming-the-machine).
|
||||
console's **Logs** page for `Wake-on-` — `Wake-on-LAN` for a wired card, `Wake-on-WLAN` for a Wi-Fi
|
||||
one — and the line either confirms the card is armed or names the interface and the exact command to
|
||||
arm it. A Wi-Fi card is armed by a different command than a wired one, and the log line gives the
|
||||
right one. Windows and macOS hosts don't run that check, so go straight to the BIOS/UEFI and
|
||||
network-card steps in [Arming the machine](/docs/wake-on-lan#arming-the-machine).
|
||||
|
||||
## Video is slow to start, or fails across subnets
|
||||
|
||||
|
||||
@@ -30,14 +30,35 @@ That ordering is the whole prerequisite:
|
||||
> says so rather than pretending. On every client but the Linux one you can also type the MAC in by
|
||||
> hand; see the table below.
|
||||
|
||||
The packet goes to every local interface's subnet broadcast address *and* to `255.255.255.255`, on
|
||||
The packet goes **out of every one of the client's network interfaces** — from a socket bound to
|
||||
that interface's own address, aimed at both its subnet broadcast address and `255.255.255.255` — on
|
||||
UDP ports 9 and 7, repeated three times, plus a unicast to the host's last known address. That
|
||||
spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it.
|
||||
spread is deliberate: a sleeping machine has no ARP entry, so a plain unicast cannot find it, and a
|
||||
broadcast sent without binding an interface leaves by the default route only, which on a machine
|
||||
running a VPN or a mesh network is not the LAN the host sleeps on.
|
||||
|
||||
Neither the advert nor a magic packet is authenticated. That is fine here — a wrong address only
|
||||
makes the wake fail, and the host's certificate fingerprint still gates the actual connection. See
|
||||
[Security](/docs/security).
|
||||
|
||||
### Over Wi-Fi
|
||||
|
||||
A host on Wi-Fi wakes from the same packet. The mechanism is **WoWLAN** (Wake on Wireless LAN):
|
||||
the adapter stays associated to your access point while the machine sleeps, the access point holds
|
||||
broadcast frames for its sleeping stations and releases them on the next beacon, and the adapter
|
||||
wakes the machine when one of them is a magic packet. Punktfunk publishes a Wi-Fi card's address
|
||||
exactly like a wired one, so there is nothing different to do on the client — but the card has to be
|
||||
armed for it, which is a different switch from the wired one. See
|
||||
[Linux (Wi-Fi)](#linux-wi-fi) and [Windows](#windows) below.
|
||||
|
||||
Two things can still stop it, and neither is visible from Punktfunk:
|
||||
|
||||
- Some access points and mesh systems drop or rate-limit broadcast traffic to sleeping stations
|
||||
(often as "multicast enhancement", "broadcast filtering" or IGMP snooping). If wired hosts wake
|
||||
and a Wi-Fi one never does, that is the first thing to turn off.
|
||||
- Some laptops and adapters cut power to the Wi-Fi card in deeper sleep states, which drops the
|
||||
association and with it any chance of a wake.
|
||||
|
||||
## Waking from a client
|
||||
|
||||
**Auto-wake on connect** is a client setting, and it is **on by default**. You find it in Settings,
|
||||
@@ -135,7 +156,7 @@ whether a machine may be woken off the network is yours to make.
|
||||
### Check the host log first
|
||||
|
||||
This is the fastest diagnosis. On **Linux**, the host inspects the card carrying the address it
|
||||
advertises, each time it starts advertising, and writes one of two lines:
|
||||
advertises, each time it starts advertising, and writes one line about it. A wired card:
|
||||
|
||||
```text
|
||||
Wake-on-LAN armed (magic packet) on host NIC
|
||||
@@ -145,18 +166,29 @@ Wake-on-LAN armed (magic packet) on host NIC
|
||||
Wake-on-LAN is NOT armed on this host's NIC — clients cannot wake it from sleep.
|
||||
```
|
||||
|
||||
A Wi-Fi card, which is armed through an entirely different mechanism and is asked about separately
|
||||
(`iw phy … wowlan show`, not `ethtool`):
|
||||
|
||||
```text
|
||||
Wake-on-WLAN armed (magic packet) on host Wi-Fi NIC
|
||||
```
|
||||
|
||||
```text
|
||||
Wake-on-WLAN is NOT armed on this host's Wi-Fi NIC — clients cannot wake it from sleep.
|
||||
```
|
||||
|
||||
The warning line goes on to name the interface and the exact command to fix it. The host only
|
||||
reports; it never changes the card's settings. It stays silent when it cannot tell — `ethtool`
|
||||
missing, or not enough privilege — rather than guessing, and it says nothing at all when mDNS
|
||||
adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`), because then no address is published
|
||||
either.
|
||||
reports; it never changes the card's settings. It stays silent when it cannot tell — `iw` or
|
||||
`ethtool` missing, a driver that doesn't answer, or not enough privilege — rather than guessing, and
|
||||
it says nothing at all when mDNS adverts are switched off (`PUNKTFUNK_MDNS=0` or `--no-mdns`),
|
||||
because then no address is published either.
|
||||
|
||||
Read the line on the web console's **Logs** page, or in the journal with
|
||||
`journalctl --user -u punktfunk-host`. See [Troubleshooting](/docs/troubleshooting#still-stuck).
|
||||
|
||||
**Windows and macOS hosts do not run this check**, so there is no log line to look for there.
|
||||
|
||||
### Linux
|
||||
### Linux (wired)
|
||||
|
||||
Ask the card what it is doing. `Supports Wake-on:` is the capability; `Wake-on:` is the current
|
||||
setting. `g` means magic packet, `d` means disabled.
|
||||
@@ -174,6 +206,42 @@ sudo ethtool -s enp5s0 wol g
|
||||
On many systems that does not survive a reboot. Re-run `ethtool enp5s0` after the next boot to check,
|
||||
and make it permanent through your distribution's network configuration if it reset.
|
||||
|
||||
### Linux (Wi-Fi)
|
||||
|
||||
`ethtool` is the wrong tool here — most wireless drivers report `Wake-on: d` whether or not they are
|
||||
armed, because the trigger lives in the wireless stack instead. Ask `iw`, using the *phy* behind the
|
||||
interface (`/sys/class/net/wlan0/phy80211/name`, usually `phy0`):
|
||||
|
||||
```bash
|
||||
iw phy phy0 wowlan show
|
||||
```
|
||||
|
||||
`WoWLAN is disabled` means no wake. Armed looks like this, and the `* wake up on magic packet` line
|
||||
is the one that matters:
|
||||
|
||||
```text
|
||||
WoWLAN is enabled:
|
||||
* wake up on magic packet
|
||||
```
|
||||
|
||||
Arm it:
|
||||
|
||||
```bash
|
||||
sudo iw phy phy0 wowlan enable magic-packet
|
||||
```
|
||||
|
||||
That setting is per-phy and NetworkManager re-applies its own on every connection, so on a
|
||||
NetworkManager system make it stick on the connection instead — this survives reboots and
|
||||
reconnects:
|
||||
|
||||
```bash
|
||||
sudo nmcli connection modify <connection> 802-11-wireless.wake-on-wlan magic
|
||||
```
|
||||
|
||||
`iw phy phy0 wowlan show` reporting `command failed: Operation not supported` means the driver has no
|
||||
WoWLAN support at all; that adapter cannot be woken over Wi-Fi. Check `iw list | grep -A5 "WoWLAN"`
|
||||
for what the hardware claims to support.
|
||||
|
||||
### Windows
|
||||
|
||||
Open **Device Manager**, find the network adapter under **Network adapters**, and open its
|
||||
@@ -181,10 +249,17 @@ properties. On the **Power Management** tab, allow the device to wake the comput
|
||||
**Advanced** tab, enable the adapter's magic-packet wake property if it has one. Exact wording
|
||||
depends on the driver.
|
||||
|
||||
Wi-Fi adapters use the same two tabs. The **Advanced** property is often called **Wake on Magic
|
||||
Packet** there too, sometimes **Wake on Wireless LAN**; many Wi-Fi drivers expose neither, and those
|
||||
cannot be woken over Wi-Fi. `powercfg /devicequery wake_armed` lists every device currently allowed
|
||||
to wake the machine — if the adapter is not in it, nothing on the network can wake this host.
|
||||
|
||||
## Limits
|
||||
|
||||
- **Wired Ethernet is what works.** Waking over Wi-Fi is unreliable and depends entirely on the
|
||||
adapter and the platform.
|
||||
- **Wired Ethernet is the sure thing; Wi-Fi works when the adapter supports WoWLAN.** Punktfunk
|
||||
sends the same packet either way and publishes a Wi-Fi card's address like any other, but whether
|
||||
a sleeping adapter is still listening is the adapter's and the access point's decision —
|
||||
see [Over Wi-Fi](#over-wi-fi).
|
||||
- **Connect once while the host is awake**, on the same local network, before you rely on waking it.
|
||||
A host you only ever added by address, on a network where mDNS never reached it, has no learned
|
||||
address — the CLI will tell you so, and the apps will not offer the wake action. Typing the MAC in
|
||||
|
||||
Reference in New Issue
Block a user