Compare commits

...
Author SHA1 Message Date
enricobuehler 981f32b8f6 Merge pull request 'Gamescope streams could tear pink at 120 fps — zero-copy handed the capture buffer back while the VCN was still reading it' (#188) from worktree-gamescope-tear-race into main
ci / bun-nix (push) Successful in 24s
ci / rust-arm64 (push) Successful in 1m29s
apple / swift (push) Successful in 1m44s
ci / web (push) Successful in 4m45s
ci / docs-site (push) Successful in 6m3s
ci / rust (push) Successful in 6m19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 30s
deb / build-publish (push) Successful in 7m18s
apple / screenshots (push) Successful in 6m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 26s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 39s
deb / build-publish-host (push) Successful in 8m3s
deb / build-publish-client-arm64 (push) Successful in 4m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m54s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m25s
android / android (push) Successful in 11m52s
arch / build-publish (push) Successful in 14m31s
windows-host / package (push) Successful in 13m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
docker / deploy-docs (push) Successful in 7m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m41s
Reviewed-on: #188
2026-08-13 07:24:07 +00:00
enricobuehler a9a1b923a2 fix(pf-capture): withhold zero-copy buffers from the producer until the encoder is done reading them
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 4m14s
ci / rust-arm64 (pull_request) Successful in 4m52s
ci / rust (pull_request) Successful in 4m57s
android / android (pull_request) Successful in 7m6s
The raw-dmabuf passthrough handed the SPA buffer back to gamescope at
.process return while the encode thread had not yet imported - let alone
read - its dmabuf, and nothing ordered the producer's writes against the
consumer's read (no explicit sync; the implicit-fence wait measures
NoFence on every compositor x vendor pairing we have). On the direct-VCN
arms (native NV12, RGB-direct EFC) the captured buffer IS the encode
source for the whole ring-2-deep encode plus the phase-lock hold, so at
120 fps gamescope cycles back into the buffer mid-encode and the stream
ships torn frames: luma/chroma desync (magenta tint) plus block
corruption propagating through the P-chain until the next intra. Field
report: Nobara, gamescope mode at 120 fps - KDE sessions were clean
because cursor_blend routes them to the compute-CSC copy arm whose read
window is microseconds.

The fix defers the requeue: a published passthrough frame carries a
FrameHold (new on DmabufFrame), and the buffer rejoins the producer's
pool only when the last clone drops. The Vulkan encoder clones the hold
into the ring slot at submit and releases it when the slot's fence
retires (poll/backpressure/reset), extending "the producer must not
rewrite this" across exactly the GPU read. The host loop's repeat path
is fixed by the same mechanism: a re-submitted frame now aliases a
buffer the producer never got back, instead of whatever gamescope last
composited into it.

Bookkeeping lives in a per-stream HoldBook (loop-thread mutations only):
holds release through a pw channel onto the loop thread, a generation
tag keeps a stale release from requeueing a renegotiated pool's reused
address, and at most pool_depth - HOLD_POOL_RESERVE buffers are ever
withheld - a pool at the old floor of 2 cannot spare any and falls back
(with one warn) to the previous racy contract. PUNKTFUNK_ZEROCOPY_HOLD=0
restores the old behavior outright for field bisects.

Gates (.25): cargo check + clippy --all-targets -D warnings on
pf-frame/pf-capture/pf-encode/punktfunk-host; pf-capture 68/68 (4 new
HoldBook tests), pf-encode 75/75 (+15 ignored, host-feature set);
workspace cargo fmt --check clean. punktfunk-host's
hooks::prep_runs_do_in_order_and_undo_in_reverse fails on that box on
pristine main too (pre-existing; crate untouched here).
2026-08-13 09:00:23 +02:00
enricobuehler 124cb66324 Merge pull request 'Wake-on-LAN: support WoWLAN so Wi-Fi hosts wake like wired ones' (#187) from worktree-wowlan-support into main
apple / swift (push) Successful in 1m40s
ci / rust-arm64 (push) Successful in 1m41s
ci / bun-nix (push) Successful in 2m2s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m45s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m1s
deb / build-publish-client-arm64 (push) Successful in 3m24s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 45s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
ci / rust (push) Successful in 5m55s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 22s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 23s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m59s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m17s
apple / screenshots (push) Successful in 6m3s
arch / build-publish (push) Successful in 8m55s
ci / web (push) Successful in 1m3s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m42s
ci / docs-site (push) Successful in 1m12s
docker / builders-arm64cross (push) Successful in 20s
docker / deploy-docs (push) Successful in 48s
deb / build-publish (push) Failing after 6m34s
deb / build-publish-host (push) Successful in 14m19s
android / android (push) Successful in 10m58s
release / apple (push) Successful in 10m0s
flatpak / build-publish (push) Successful in 12m38s
windows-host / package (push) Successful in 16m30s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 46s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m19s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m2s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m20s
2026-08-12 22:51:57 +00:00
enricobuehler 6774c4e7a2 feat(wol): support WoWLAN so Wi-Fi hosts wake like wired ones
ci / bun-nix (pull_request) Successful in 1m33s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 2m5s
ci / web (pull_request) Successful in 2m14s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m57s
ci / docs-site (pull_request) Successful in 3m4s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m41s
android / android (pull_request) Successful in 7m3s
ci / rust (pull_request) Successful in 10m23s
The host's arming check asked `ethtool` about every NIC, which is the wrong
question for Wi-Fi: the magic-packet trigger lives in nl80211's WoWLAN state,
and most wireless drivers print `Wake-on: d` whether or not it is armed. An
armed Wi-Fi host was therefore told it was NOT armed, and handed an
`ethtool -s wlan0 wol g` its driver rejects. A NIC with an nl80211 phy
(`/sys/class/net/<i>/phy80211`) is now asked `iw phy <phy> wowlan show`
instead, and the warning carries WoWLAN-correct guidance — `iw ... wowlan
enable magic-packet`, plus the NetworkManager
`802-11-wireless.wake-on-wlan magic` that survives a reconnect. Two fallbacks
for when `iw` can't answer (missing binary, driver without the command, or
privilege the user-level host service lacks): a POSITIVE ethtool reading
counts (brcmfmac & co do report there), a negative one never does, and sysfs
`device/power/wakeup` reading `disabled` is conclusive in the negative.

The client sender now emits from a socket bound to EACH non-loopback
interface's own address rather than leaving the path to the routing table. A
station in WoWLAN sleep stays associated and its AP buffers broadcast frames
for it until the next DTIM beacon — but only if the datagram reaches the
wireless segment at all, and with a VPN or mesh interface holding the default
route `255.255.255.255` never did. A failed bind falls back to the routed
socket, so no segment is lost.

Tests: `iw`/`ethtool` output parsing split from the commands so both are unit-
tested on any platform, and a new end-to-end test asserts a real listener
receives the 102 magic-packet bytes.

Verified on Linux (Ubuntu 26.04, 12 interfaces): `cargo fmt --all --check`,
`cargo clippy -p punktfunk-core -p punktfunk-host --all-targets --locked
-- -D warnings`, and both wol test sets green. NOT yet exercised against real
Wi-Fi hardware — no Wi-Fi Linux box was reachable.
2026-08-13 00:49:21 +02:00
11 changed files with 820 additions and 96 deletions
+318 -25
View File
@@ -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());
}
}
+8 -6
View File
@@ -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,
}
+30 -1
View File
@@ -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;
+3
View File
@@ -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,
};
+23 -2
View File
@@ -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.
+131 -42
View File
@@ -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));
}
}
+215 -6
View File
@@ -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
);
}
}
+5 -4
View File
@@ -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
+85 -10
View File
@@ -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