Hyprland is now verified streaming on glass, repeatedly, with a working pointer — confirmed by the operator on home-nix-1 (NixOS 26.05, Hyprland 0.55.4, xdph 1.3.12, RTX 5070 Ti). Every fix here was measured on that box; #216 got us past the cursor-mode refusal and straight into the next six.
Each commit stands alone and is written to be taken independently.
1. dea63957 — capture: xdph offers BGRA on its dmabuf pod, we offered BGRx
The client went black with no error of ours. PipeWire failed the link itself:
pw.link: (73.0.0 -> 81.0.0) negotiating -> error no more input formats (-22)
Both EnumFormat pods, dumped from the PipeWire daemon (PIPEWIRE_DEBUG=*:1,pw.link:5 — they are not in our process's log, which is why this hid):
format
modifiers
flags
us
BGRx only
12 NVIDIA tiled + 0 (LINEAR)
MANDATORY
xdph dmabuf
BGRA only
the same 12 + MOD_INVALID
MANDATORY|DONT_FIXATE
xdph SHM
BGRAorBGRx
—
—
The modifiers intersect perfectly. Only the fourcc never does — which is exactly why the failure reads as a GPU/modifier problem and is not one. Our own message ("the compositor never accepted the dmabuf-only offer (EGL→CUDA GPU import)") points at the GPU, and the advert line prints only the first 6 of 13 modifiers so LINEAR is invisible. Both misled a full day.
Adds a BGRA pod beside the BGRx one, listed after it so a producer offering both still lands on the existing path — purely additive. Vendor-neutral by construction: the two modifier lists are enumerated per fourcc (XR24 and AR24 asked separately), because EGL and libva answer per format. On the VAAPI passthrough path both are LINEAR, so AMD and Intel get the BGRA pod on the same terms as NVIDIA rather than an NVIDIA-shaped guess.
2. 9ce347e4 — vdisplay: we removed the captured output before closing the cast, and xdph spun on the wreckage
StopGuard::drop only set an atomic and returned; the portal thread noticed 200 ms later. So hyprctl output remove ran on an output xdph was still capturing — every teardown. And nothing ever closed the session: xdph destroys one on exactly one event, an explicit Session.Close (shared/Session.cpp:37), and has no peer-vanished watcher. xdph then wedged in an unbounded while (nodeID == SPA_ID_INVALID) pw_loop_iterate(loop, 0) — a hot spin on its only event-loop thread, holding its event lock.
Measured, not inferred: 231.971 s of CPU against 232.70 s of wall clock for the wedged interval — one core pinned solid.
Teardown now closes the session and waits for confirmation before removing the output, bounded 3 s each side. Same change in wlroots, on evidence rather than symmetry: xdpw's session.c likewise exposes only Close, and its screencast.c:599 is the identical spin.
Also fixes the picker line to [SELECTION]/screen:<NAME>. xdph splits on the first / (ScreencopyShared.cpp:86); we never sent one, so FLAGS became the whole payload and SEL did too — only because npos + 1 wraps to 0. That accident is why the name still parsed. What didn't hide: the flag loop walked screen:<name> a character at a time and hit the r of "screen", setting allowToken, so xdph returned a restore token we never asked for. Format moved to portal_picker.rs with xdph's parser transcribed into the tests — the old line had one assertion and it passed the entire time it was wrong.
3. 5e5d6904 — vdisplay: a hung handshake leaked its thread, and one leak poisoned every later cast
select_sources/start await a reply a wedged portal never sends, and that await cannot be cancelled by the stop flag — the flag is only read by the park loop further down, which a stuck thread never reaches. One host accumulated nine live cast threads and 28 tokio workers, each holding a half-created session on the process's shared D-Bus connection.
Bounded at 15 s, under select_and_cast's 20 s, so the failure is reported with a reason and — the point — the thread exits.
4. cf4c12ea — vdisplay: a per-cast tokio runtime orphaned ashpd's process-global connection
The reason the first stream of a host process worked and every later one was black. ashpd caches its connection process-globally:
zbus spawns that connection's background reader on whichever runtime is current when it is created. Both wlr backends built their own runtime per cast and dropped it at teardown — so the first cast created the cached connection on a runtime that died with it, and the OnceLock kept handing every later Screencast::new() a connection with no executor left to read its replies.
The discriminator that pins it on us: a freshly spawned process completed the identical handshake against the identical xdph, repeatedly, while the long-lived host completed none — with xdph idle at 28 ms of CPU, so it was never the wedged party. Teardown was already correct by then, so a clean teardown log does not mean the cast machinery is healthy.
One shared portal_rt runtime, built once, never dropped, block_on(&self) from every cast thread. Screencast::new() is bounded too — with the connection orphaned that call is exactly where the thread hung, so the earlier bound started one step too late.
5. 6863f814 — input: the wlr injector aimed absolute motion at the operator's head
create_virtual_pointer_with_output(seat, globals.output, …) passed the first advertisedwl_output — registry globals arrive in creation order, so the oldest output, i.e. the operator's physical head. Every absolute sample from every session drove a screen nobody was streaming. Field symptom: the cursor clamped at the left edge and vanished past the middle.
Not a race: the same build shows the opposite injector/output ordering in other sessions (4 s before, 24 ms, 198 ms apart) with the bug in all of them.
The output name now travels out of the backends (VirtualOutput::output_name, the counterpart of Windows' win_capture), the host publishes it at capture bring-up, and the injector binds every wl_output at v4 and matches on name, re-creating the pointer on change (releasing held buttons first). No fallback on a failed match — the old "first output" behaviour was the fallback.
Second defect fixed in the same commit: inject() only called dispatch_pending, which wayland-client documents as not reading the socket. Any retarget scheme would have been dead whenever the display came up after the injector, and everything the compositor sent had piled up unread for the host's lifetime — including the protocol errors that code claimed to surface.
⚠️Known limitation, deliberate: one slot per process, so with concurrent sessions the last capture bring-up wins for everyone's absolute input — the same trade Windows' stream_target already documents, and strictly better than every session aiming at a head no session was streaming. set_absolute_anchor is untouched and still not called from a session path.
6. 2832b5d0 — host: the park schedule read a missing cursor overlay as a lost pointer
park_pointer warps the seat pointer to the streamed surface's centre, because a pointer-locked client sends only relative deltas and nothing else would move it onto a fresh virtual output. Past its two unconditional attempts it continued while a host-composite session still had no live cursor overlay — reading "no overlay" as "the pointer has not reached the output".
Sound on Mutter, which suppresses SPA_META_Cursor while the pointer is off the recorded view. Meaningless on the whole wlr family: xdph and xdpw advertise AvailableCursorModes = 3 (Hidden|Embedded), so a session asking for metadata is served Embedded — the compositor paints the pointer into the frames and sends no metadata ever, wherever the pointer is. The host re-centred the user's cursor once a second for the full 10-attempt cap. Field report: "the cursor jumps to the screen centre at the beginning of the stream."
Same fact, second consequence: metadata_composite had the host planning to composite a metadata cursor on a backend that can never deliver one — logged as host-composite active but the capture has no live cursor overlay yet — the stream is cursorless until one arrives, once per session, forever. That is why an earlier session showed no cursor at all.
The negotiated mode is now surfaced per session via VirtualDisplay::last_portal_cursor_mode() (modelled on last_identity_slot; no process-global state). KWin, Mutter, gamescope and Windows report None, which explicitly means "this says nothing" — so the GNOME behaviour park_pointer exists for is untouched by construction, with a test pinning the None case.
A steering (absolute) client keeps the single bring-up park and loses the 1 s retry: one park at t≈0 is invisible, the repeat is what fights a user who has started moving, and an absolute client re-aims with its own next move — while the bring-up park still protects a session's first click with no preceding move. Relative-only sessions keep both. Net: 2 parks in the first second instead of 11 over ten.
⚠️5 and 6 are both required — neither makes the other redundant. With only 5 the schedule still fires 11 times, now re-centring on the streamed output. With only 6 the two surviving parks still aim at the wrong head.
Verification
On glass, by the operator: repeated connects all stream, and the cursor behaves. This is the first end-to-end proof of #216's cursor-mode ladder too — the box's portal still advertises u 3, so the failing precondition is genuinely present.
cargo fmt --all --check; scripts/xcheck.sh linux clippyandwindows clippy at -D warnings; cargo test -p pf-vdisplay 123 pass.
⚠️scripts/xcheck.sh covers neither pf-capture nor punktfunk-host (libpipewire/ring/opus/xkbcommon), and punktfunk-host does not build on macOS at all. Those crates were checked in the ci/rust-ci.Dockerfile image: cargo clippy -p punktfunk-host -p pf-vdisplay -p pf-inject --all-targets -- -D warnings exit 0, pf-inject 130 + 7 pass, and a NixOS nix build of the whole host on the test box.
Two mgmt tests are flaky on main already (verified by running the identical command on the base commit): they share process-global session-registry state that SESSION_REGISTRY_LOCK does not contain under the container's emulated timing. Each passes in isolation, all 48 pass as a group. Not from this branch, but worth a separate look.
Upstream bugs worth filing
The unbounded node-id spin — xdph Screencopy.cpp:307 and xdpw screencast.c:599. A portal must not be permanently wedgeable by one bad stream.
xdph's picker parser silently reads a whole selection as flags when the line has no /.
Hyprland is now **verified streaming on glass**, repeatedly, with a working pointer — confirmed by the operator on `home-nix-1` (NixOS 26.05, Hyprland 0.55.4, xdph 1.3.12, RTX 5070 Ti). Every fix here was measured on that box; #216 got us past the cursor-mode refusal and straight into the next six.
Each commit stands alone and is written to be taken independently.
## 1. `dea63957` — capture: xdph offers **BGRA** on its dmabuf pod, we offered **BGRx**
The client went black with no error of ours. PipeWire failed the link itself:
```
pw.link: (73.0.0 -> 81.0.0) negotiating -> error no more input formats (-22)
```
Both `EnumFormat` pods, dumped from the PipeWire **daemon** (`PIPEWIRE_DEBUG=*:1,pw.link:5` — they are not in our process's log, which is why this hid):
| | format | modifiers | flags |
|---|---|---|---|
| us | `BGRx` only | 12 NVIDIA tiled + `0` (LINEAR) | MANDATORY |
| xdph dmabuf | `BGRA` only | the same 12 + MOD_INVALID | MANDATORY\|DONT_FIXATE |
| xdph SHM | `BGRA` **or** `BGRx` | — | — |
The modifiers intersect perfectly. Only the fourcc never does — which is exactly why the failure reads as a GPU/modifier problem and is not one. Our own message ("the compositor never accepted the dmabuf-only offer (EGL→CUDA GPU import)") points at the GPU, and the advert line prints only the first 6 of 13 modifiers so LINEAR is invisible. Both misled a full day.
Adds a BGRA pod beside the BGRx one, listed **after** it so a producer offering both still lands on the existing path — purely additive. Vendor-neutral by construction: the two modifier lists are enumerated **per fourcc** (`XR24` and `AR24` asked separately), because EGL and libva answer per format. On the VAAPI passthrough path both are LINEAR, so AMD and Intel get the BGRA pod on the same terms as NVIDIA rather than an NVIDIA-shaped guess.
## 2. `9ce347e4` — vdisplay: we removed the captured output before closing the cast, and xdph spun on the wreckage
`StopGuard::drop` only set an atomic and returned; the portal thread noticed 200 ms later. So `hyprctl output remove` ran on an output xdph was still capturing — every teardown. And nothing ever closed the session: xdph destroys one on exactly one event, an explicit `Session.Close` (`shared/Session.cpp:37`), and has no peer-vanished watcher. xdph then wedged in an unbounded `while (nodeID == SPA_ID_INVALID) pw_loop_iterate(loop, 0)` — a hot spin on its only event-loop thread, holding its event lock.
Measured, not inferred: **231.971 s of CPU against 232.70 s of wall clock** for the wedged interval — one core pinned solid.
Teardown now closes the session and **waits** for confirmation before removing the output, bounded 3 s each side. Same change in wlroots, on evidence rather than symmetry: xdpw's `session.c` likewise exposes only `Close`, and its `screencast.c:599` is the identical spin.
Also fixes the picker line to **`[SELECTION]/screen:<NAME>`**. xdph splits on the first `/` (`ScreencopyShared.cpp:86`); we never sent one, so `FLAGS` became the whole payload and `SEL` did too — only because `npos + 1` wraps to `0`. That accident is why the name still parsed. What didn't hide: the flag loop walked `screen:<name>` a character at a time and hit the **`r` of "sc*r*een"**, setting `allowToken`, so xdph returned a restore token we never asked for. Format moved to `portal_picker.rs` with xdph's parser transcribed into the tests — the old line had one assertion and it passed the entire time it was wrong.
## 3. `5e5d6904` — vdisplay: a hung handshake leaked its thread, and one leak poisoned every later cast
`select_sources`/`start` await a reply a wedged portal never sends, and that await **cannot be cancelled by the `stop` flag** — the flag is only read by the park loop further down, which a stuck thread never reaches. One host accumulated **nine live cast threads and 28 tokio workers**, each holding a half-created session on the process's shared D-Bus connection.
Bounded at 15 s, under `select_and_cast`'s 20 s, so the failure is reported with a reason and — the point — the thread **exits**.
## 4. `cf4c12ea` — vdisplay: a per-cast tokio runtime orphaned ashpd's process-global connection
**The reason the first stream of a host process worked and every later one was black.** ashpd caches its connection process-globally:
```rust
static SESSION: OnceLock<zbus::Connection> // ashpd 0.13.13, src/proxy.rs:27
```
zbus spawns that connection's background reader on whichever runtime is current when it is created. Both wlr backends built their **own** runtime per cast and dropped it at teardown — so the first cast created the cached connection on a runtime that died with it, and the `OnceLock` kept handing every later `Screencast::new()` a connection with no executor left to read its replies.
The discriminator that pins it on us: a freshly spawned process completed the identical handshake against the identical xdph, repeatedly, while the long-lived host completed none — with xdph **idle at 28 ms of CPU**, so it was never the wedged party. Teardown was already correct by then, so a clean teardown log does not mean the cast machinery is healthy.
One shared `portal_rt` runtime, built once, **never dropped**, `block_on(&self)` from every cast thread. `Screencast::new()` is bounded too — with the connection orphaned that call is exactly where the thread hung, so the earlier bound started one step too late.
## 5. `6863f814` — input: the wlr injector aimed absolute motion at the operator's head
`create_virtual_pointer_with_output(seat, globals.output, …)` passed the **first advertised** `wl_output` — registry globals arrive in creation order, so the oldest output, i.e. the operator's physical head. Every absolute sample from every session drove a screen nobody was streaming. Field symptom: the cursor clamped at the left edge and vanished past the middle.
Not a race: the same build shows the opposite injector/output ordering in other sessions (4 s before, 24 ms, 198 ms apart) with the bug in all of them.
The output name now travels out of the backends (`VirtualOutput::output_name`, the counterpart of Windows' `win_capture`), the host publishes it at capture bring-up, and the injector binds every `wl_output` at v4 and matches on name, re-creating the pointer on change (releasing held buttons first). **No fallback on a failed match** — the old "first output" behaviour *was* the fallback.
Second defect fixed in the same commit: `inject()` only called `dispatch_pending`, which wayland-client documents as *not reading the socket*. Any retarget scheme would have been dead whenever the display came up after the injector, and everything the compositor sent had piled up unread for the host's lifetime — including the protocol errors that code claimed to surface.
⚠️ **Known limitation, deliberate:** one slot per process, so with concurrent sessions the last capture bring-up wins for everyone's absolute input — the same trade Windows' `stream_target` already documents, and strictly better than every session aiming at a head no session was streaming. `set_absolute_anchor` is untouched and still not called from a session path.
## 6. `2832b5d0` — host: the park schedule read a missing cursor overlay as a lost pointer
`park_pointer` warps the seat pointer to the streamed surface's centre, because a pointer-locked client sends only relative deltas and nothing else would move it onto a fresh virtual output. Past its two unconditional attempts it continued while a host-composite session *still had no live cursor overlay* — reading "no overlay" as "the pointer has not reached the output".
Sound on Mutter, which suppresses `SPA_META_Cursor` while the pointer is off the recorded view. **Meaningless on the whole wlr family**: xdph and xdpw advertise `AvailableCursorModes = 3` (Hidden|Embedded), so a session asking for metadata is served **Embedded** — the compositor paints the pointer into the frames and sends no metadata *ever, wherever the pointer is*. The host re-centred the user's cursor once a second for the full 10-attempt cap. Field report: *"the cursor jumps to the screen centre at the beginning of the stream."*
Same fact, second consequence: `metadata_composite` had the host planning to composite a metadata cursor on a backend that can never deliver one — logged as `host-composite active but the capture has no live cursor overlay yet — the stream is cursorless until one arrives`, once per session, forever. That is why an earlier session showed **no cursor at all**.
The negotiated mode is now surfaced per session via `VirtualDisplay::last_portal_cursor_mode()` (modelled on `last_identity_slot`; no process-global state). **KWin, Mutter, gamescope and Windows report `None`, which explicitly means "this says nothing"** — so the GNOME behaviour `park_pointer` exists for is untouched by construction, with a test pinning the `None` case.
A steering (absolute) client keeps the single bring-up park and loses the 1 s retry: one park at t≈0 is invisible, the repeat is what fights a user who has started moving, and an absolute client re-aims with its own next move — while the bring-up park still protects a session's first *click* with no preceding move. Relative-only sessions keep both. Net: **2 parks in the first second instead of 11 over ten**.
⚠️ **5 and 6 are both required — neither makes the other redundant.** With only 5 the schedule still fires 11 times, now re-centring on the *streamed* output. With only 6 the two surviving parks still aim at the wrong head.
## Verification
- **On glass, by the operator**: repeated connects all stream, and the cursor behaves. This is the first end-to-end proof of #216's cursor-mode ladder too — the box's portal still advertises `u 3`, so the failing precondition is genuinely present.
- `cargo fmt --all --check`; `scripts/xcheck.sh linux clippy` **and** `windows clippy` at `-D warnings`; `cargo test -p pf-vdisplay` 123 pass.
- ⚠️ `scripts/xcheck.sh` covers **neither `pf-capture` nor `punktfunk-host`** (libpipewire/ring/opus/xkbcommon), and `punktfunk-host` does not build on macOS at all. Those crates were checked in the `ci/rust-ci.Dockerfile` image: `cargo clippy -p punktfunk-host -p pf-vdisplay -p pf-inject --all-targets -- -D warnings` exit 0, `pf-inject` 130 + 7 pass, and a NixOS `nix build` of the whole host on the test box.
- Two `mgmt` tests are flaky **on main already** (verified by running the identical command on the base commit): they share process-global session-registry state that `SESSION_REGISTRY_LOCK` does not contain under the container's emulated timing. Each passes in isolation, all 48 pass as a group. Not from this branch, but worth a separate look.
## Upstream bugs worth filing
1. The unbounded node-id spin — xdph `Screencopy.cpp:307` and xdpw `screencast.c:599`. A portal must not be permanently wedgeable by one bad stream.
2. xdph's picker parser silently reads a whole selection as flags when the line has no `/`.
A Hyprland/sway client went black with no error of ours: PipeWire failed the
link itself with
pw.link: (73.0.0 -> 81.0.0) negotiating -> error no more input formats (-22)
Measured on Hyprland 0.55.4 + xdph 1.3.12 by dumping both EnumFormat pods from
the PipeWire DAEMON (`PIPEWIRE_DEBUG=*:1,pw.link:5` — the pods are not in our
own process's log, which is why this hid for so long):
ours BGRx only | 12 NVIDIA tiled + 0 (LINEAR) | MANDATORY
xdph BGRA only | the same 12 + MOD_INVALID | MANDATORY|DONT_FIXATE
xdph BGRA or BGRx, no modifier | (the SHM pod)
The modifiers intersect perfectly. Only the fourcc never does, which is exactly
why the failure reads as a GPU/modifier problem and is not one — the host's own
message ("the compositor never accepted the dmabuf-only offer (EGL->CUDA GPU
import)") points at the GPU, and our advert line prints only the first 6 of 13
modifiers so LINEAR is invisible. Both misled a full session of debugging.
Since our offer is dmabuf-only, xdph's mixed SHM pod could not rescue it.
Offer a BGRA dmabuf pod beside the BGRx one. BGRA and BGRx are the same 32-bit
layout, the alpha byte is ignored all the way to the encoder (`vk_util` maps
both to B8G8R8A8_UNORM, VAAPI both to Pixel::BGRA), and the import is driven by
the NEGOTIATED format's fourcc, so an AR24 frame imports as AR24.
Vendor-neutral by construction: the two modifier lists are enumerated PER FOURCC
(`XR24` and `AR24` asked separately), because EGL and libva answer per format and
nothing entitles us to assume a driver importing one imports the other. On the
VAAPI passthrough path there is no importer, so both lists are LINEAR (plus the
PyroWave Vulkan set when armed) — AMD and Intel get the BGRA pod on the same
terms as NVIDIA rather than an NVIDIA-shaped guess.
The BGRA pod is listed AFTER BGRx, so a producer offering both still lands on
the pre-existing path — first compatible consumer pod wins, so this is purely
additive. Both pods are now guarded on a non-empty list (`build_dmabuf_format`
indexes `modifiers[0]`).
Also name `linear_offered` and both counts in the advert log, so the truncated
`sample` can no longer be misread as the whole offer.
Only the FIRST stream after a portal start ever worked on Hyprland; every
one after it died in `select_and_cast` with
create virtual output: timed out waiting for the ScreenCast portal on PF-…
The mitigation on `worktree-capture-bgra-dmabuf-pod` chased the symptom. This
is the mechanism, read out of xdph 1.3.12's source and the box's own journal.
WE YANK THE OUTPUT OUT FROM UNDER A LIVE CAST. `Keepalive` drops `StopGuard`
then `OutputGuard`, and `StopGuard::drop` only SET an atomic and returned. The
portal thread noticed 200 ms later and merely dropped its zbus connection. So
`hyprctl output remove` ran — synchronously, microseconds later — on an output
xdph was still capturing, every single teardown.
Nothing closed the session either. xdph destroys one on exactly one event, an
explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`); it has
no peer-vanished watcher. The frontend does (`xdg-desktop-portal.c:230`
`peer_died_cb` → `close_sessions_for_sender`), but only once our bus name goes
away — after the poll, asynchronously, on a GTask thread. Long after the output
is gone. Proof from the box: xdph's toplevel lock stayed at 2 for 4.5 minutes
after our stream ended and its output was removed, and that session's
`Session destroyed` never came.
XDPH THEN SPINS AT 100% CPU, FOREVER. Handed that wreckage, `startSharing`
falls into `Screencopy.cpp:307-313`
while (pSession->sharingData.nodeID == SPA_ID_INVALID) {
int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0);
— timeout 0, i.e. NON-blocking, i.e. an unbounded hot spin on xdph's only
event-loop thread, inside the `Start` handler, holding its `m_mEventLock`. From
there it answers no D-Bus, no Wayland, no PipeWire, ever again. MEASURED: the
wedged instance's unit reported `Consumed 3min 51.971s CPU time over 23min
41.092s wall clock`, and there were 232.70 s of wall clock between its last log
flush and its restart — 231.971 s of CPU against 232.70 s of wall, one core
pinned solid for precisely the wedged interval.
Everything after that is queueing. Our next handshake gets nothing, times out at
20 s, and `SelectionFile` deletes the per-session selection file on its way out;
if xdph is restarted mid-queue it finally runs the picker for that stale request
and reads an empty file — the `SHAREDATA returned selection -1` in the log.
THE FIX IS THE ORDER. `StopGuard::drop` now signals and then WAITS for the
portal thread to have closed the ScreenCast session, and only then does
`OutputGuard` remove the output. The close is answered synchronously by the
frontend (`xdp-session.c:217` `handle_close` → `xdp_dbus_impl_session_call_close_sync`),
so when it returns xdph has already run `destroyStream`. The output we remove
next is one nobody is capturing. Bounded at 3 s on each side — an already-wedged
portal must not be able to wedge our teardown with it — and the park poll drops
to 20 ms now that teardown waits on it.
wlroots gets the same change, and NOT on an assumption of symmetry: xdpw was
read to confirm both preconditions. `src/core/session.c` gives its session
object exactly one method, `Close`; and `src/screencast/screencast.c:599-605` is
the identical unbounded `while (cast->node_id == SPA_ID_INVALID)` spin — xdph's
copy is that code. sway's `output unplug` yanks a captured output exactly the
way Hyprland's `output remove` did. Not observed on glass; no sway box.
THE PICKER LINE WAS ALSO MALFORMED, AND IS A RED HERRING FOR THE STALL. xdph
splits the picker's line on the first `/` into flags and selection
(`ScreencopyShared.cpp:86-87`) and we never sent one. `find_first_of` then
returns npos, so FLAGS became the whole payload — and SEL became the whole
payload too, purely because `npos + 1` wraps to 0, which is why the output name
still parsed and why this hid. What did not hide is the flag loop walking
`screen:<name>` one character at a time (`unknown flag from share-picker: s`,
`c`, `e`, …) and setting `allowToken` on the `r` of `sc*r*een`, so xdph answered
every Start with a `restore_data` + `persist_mode: 2` we never asked for. The
reference picker prints the separator unconditionally
(`hyprland-share-picker/main.cpp:133-136`), so empty flags are a bare leading
`/`. Fixed to `[SELECTION]/screen:<NAME>`.
It is NOT what stalled anything: the sessions that streamed fine logged the
identical flag spam and the identical restore token, so it never discriminated.
The format moves to `portal_picker.rs`, declared unconditionally like
`portal_config` and `portal_cursor`, with xdph's parser transcribed into the
tests — including the npos arithmetic. A wire format with no schema and no error
report is invisible from the string alone: the old line's one assertion passed
the entire time it was wrong. Those tests now run on every platform's CI rather
than only the leg that compiles `mod hyprland`.
Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy AND windows
clippy (-D warnings), cargo test -p pf-vdisplay 122 passed (5 new). Not verified:
on-glass behaviour — the box is in use for live testing and read-only to me.
Upstream bugs worth filing, both wlr-family: the unbounded node-id spin
(hyprwm/xdg-desktop-portal-hyprland Screencopy.cpp:307,
emersion/xdg-desktop-portal-wlr screencast.c:599) should be bounded and fail the
request rather than pinning a core forever; and xdph's picker parse should reject
a line with no `/` instead of reading the whole selection as flags.
MEASURED 2026-08-14 on the Hyprland box, with the ordered-teardown fix already
in place. The first cast of a host process streamed:
hyprland headless output ready … output=PF-44694-1 w=5120 h=1440 hz=240
pipewire stream state old=Paused new=Streaming
and every cast after it timed out in select_and_cast. The host had NINE live
`punktfunk-hypr-cast` threads and 28 tokio workers at that point.
`select_sources`/`start` await a D-Bus reply that a wedged portal never sends.
That await cannot be cancelled by the `stop` flag, because the flag is only
read by the park loop further down — a thread stuck in the handshake never
reaches it. So every timed-out attempt left a thread parked forever on a
half-created portal session, holding this process's shared D-Bus connection,
and from the first hang onwards every later request from the SAME process hung
too.
The discriminator that proves it is the process, not the portal: a freshly
spawned process (`punktfunk-host spike --source portal`, driven through the
same custom picker) completed the identical handshake against the very same
xdph — repeatedly — while the long-lived host could not complete any. xdph
itself was idle, 28 ms of CPU since start, so it was not spinning.
Bound the handshake at 15 s, under select_and_cast's 20 s wait so the failure
is reported by the thread that owns it, with a reason, and — the point — so
that thread EXITS instead of leaking. Same change in the wlroots/sway backend:
xdpw carries the identical unbounded node-id spin (screencast.c), so it can
wedge the same way.
Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings),
cargo test -p pf-vdisplay 122 passed. Not verified: on-glass — needs the box.
THE reason the first stream of a host process worked and every later one was
black. Not xdph, not the compositor, not the formats — ours, and a lifetime
mistake.
ashpd caches its D-Bus connection process-globally:
static SESSION: OnceLock<zbus::Connection> // ashpd 0.13.13, src/proxy.rs:27
The first `Screencast::new()` in the process creates that connection, and zbus
spawns its background reader as a task on whichever tokio runtime is current at
that moment. Both wlr backends built their OWN multi-thread runtime per cast and
dropped it at teardown — so the first cast created the cached connection on a
runtime that was then destroyed with it, and the OnceLock went on handing the
same executor-less connection to every later `Screencast::new()`, which awaited
a reply nothing was alive to read.
Measured 2026-08-14 (Hyprland 0.55.4, xdph 1.3.12): first cast of a host process
streamed, every cast after it hung, and the surviving cast thread sat in
futex_do_wait inside runtime shutdown. The discriminator that pins it on us: a
freshly spawned process completed the identical handshake against the identical
xdph, repeatedly, while the long-lived host completed none — with xdph itself
idle at 28 ms of CPU, so it was never the one wedged. Teardown was already
correct by then: the log shows `hyprland headless output removed` in the right
order.
One shared runtime (`portal_rt`), built once, never dropped, `block_on(&self)`
from every cast thread. It outlives the cached connection because it must.
Also bound `Screencast::new()` itself, not just the handshake after it: with the
connection orphaned that call is exactly where the thread hung, so the earlier
bound started one step too late and the failure still surfaced as the caller's
generic 20 s timeout.
Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings),
cargo test -p pf-vdisplay 122 passed. Not verified: on-glass.
`WlrootsInjector::open` created its virtual pointer with `globals.output` — whatever
`wl_output` the registry roundtrip had bound, which was `state.output.is_none()`, i.e. the
FIRST one advertised. Registry globals arrive in creation order, so "first" is the
compositor's oldest output: the operator's physical head, never the per-session headless one
the client is looking at. The wlr protocol maps `motion_absolute` onto the output the pointer
was CREATED with ("if the output argument is set, the compositor should map the input device
to the requested output"), so every absolute sample from every session drove a screen nobody
was streaming. On the EXTEND backends — Hyprland and wlroots/sway, where the streamed head
sits beside the operator's — that is the field report "no cursor was visible in the session",
and it is also why `park_pointer`'s opening warp put the seat cursor on the operator's
desktop once a second instead of on the stream.
Not a startup race, though it looks like one. The host journal on the Hyprland box shows
BOTH orderings across sessions of the same build — the injector opening 3.7 s before the
headless output in one, 24 ms after it in another — and the bug in both, because
"first advertised" is the oldest global either way. `hyprctl monitors` on that box:
`HDMI-A-1` (ID 0) at +0+0, `PF-87756-3` (ID 1) at +1920+0. ID 0 is always first.
Three parts.
1. pf-vdisplay carries the head's compositor name out on `VirtualOutput::output_name`, the
Linux counterpart of what `win_capture` already carries on Windows. Set by hyprland and
wlroots (the two EXTEND backends) and by the monitor mirror; `None` on KWin/Mutter (they
inject through libei, which selects by region) and gamescope (it owns its whole seat).
Threaded through the registry pool so a keep-alive reuse answers with the same name a
fresh create would — no poolable backend sets it today, and this is so that stops being a
silent trap the day one does.
2. The host publishes it at capture bring-up, `pf_inject::set_stream_output`, in the same
place and shape as the Windows arm's existing `set_stream_target`.
3. The wlr injector binds EVERY `wl_output` at v4 (for the `name` event), matches the
published name, and re-creates its virtual pointer bound to that output whenever the
target changes — releasing any held button on the old device first, because nothing else
would and a virtual pointer destroyed mid-press leaves the host with a stuck button.
Matching is by NAME, with NO fallback, and the absence of the fallback is the fix: the old
"first output" behaviour WAS the fallback. Size could not stand in for it either —
`MouseMoveAbs`'s extent is the client's letterboxed video rect in its own window, not the
streamed mode, so no size ladder can identify the head. An unresolved target binds NO output,
which maps absolute coordinates over the whole layout: on a single-output compositor that is
identical to binding that output, and on a multi-head one it at least keeps the streamed head
reachable, unlike a pin to the wrong one.
`inject` now also READS the Wayland socket. It only ever called `dispatch_pending`, which is
documented to "not perform reads on the Wayland socket", so the queue held nothing but what
`open`'s roundtrips put there. Without this the retarget above would have been dead in
exactly the sessions that need it most — the injector could never learn about a `wl_output`
created after it opened — and, separately, everything the compositor sent had been piling up
unread in the socket buffer for the host's lifetime, including the protocol errors the
comment there claimed to be surfacing.
Concurrency, stated plainly: ONE slot per process. The injector is host-lifetime (in fact
there are two `InjectorService`s — the native plane's and one per GameStream control
listener) and `InputEvent` is an 18-byte `#[repr(C)]` ABI struct with no session field, so
with parallel sessions (up to `max_concurrent`, default 4) the LAST capture bring-up wins for
everyone's absolute input. That is the same trade `stream_target` already documents on
Windows, and it is strictly better than what it replaces, where every session aimed at a head
NO session was streaming. Making injection genuinely session-aware is the real fix and a much
larger one — it needs source-tagged input events through both control planes.
`set_absolute_anchor`'s warning is amended rather than quietly violated: it still must not be
called from a session path, and it now says which mechanism took the per-session trade, why
that is a separate slot (this one is the operator's host-wide capture pin, recomputed from
policy whenever the console writes it — which would wipe a per-session value), and where the
trade is written down.
Gates: `cargo clippy --all-targets -p pf-inject -- -D warnings`, `cargo build -p pf-inject`
and `cargo test -p pf-inject` (137 tests, incl. 3 new) on x86_64-unknown-linux-gnu in
`punktfunk-rust-ci`; `cargo check -p punktfunk-host` likewise; `scripts/xcheck.sh linux
clippy` plus 230 `pf-vdisplay` tests; `cargo fmt --all --check`. The two new `wlr` tests pin
the regression directly — an unknown target must bind NOTHING rather than fall back to the
first advertised output.
Not verified here: no on-glass run. The box at .138 is in live use and read-only to me, so
the change is unproven against a real Hyprland seat. Two smaller things also rest on reading
rather than observation — that Hyprland's `hyprctl` monitor name is byte-identical to its
`wl_output.name` (the protocol says the name is "the same for all clients", and xdph already
resolves our `hyprctl`-minted name to the same output for screencast, which is field-proven),
and the exact on-screen arithmetic of the "moves to mid-screen then jumps back" symptom,
which does not follow from the protocol's normalize-by-extent mapping and would need
Hyprland's own source to pin down.
Field report, working Hyprland stream: the pointer jumps to the screen
centre once a second for ~10 s at the start of every session, fighting
every mouse movement, then settles.
That is `park_pointer`'s schedule running its full cap. Parking exists
for a good reason — a pointer-locked client sends only RELATIVE deltas,
so nothing would ever move the seat pointer onto a freshly created
virtual output — and past its two unconditional attempts it keeps going
only while a host-composite session STILL has no live cursor overlay.
"No overlay ⇒ the pointer has not reached the streamed output" is sound
on Mutter, which suppresses `SPA_META_Cursor` while the pointer is off
the recorded view. It is meaningless on the whole wlr family: xdph and
xdpw advertise `AvailableCursorModes = 3` (Hidden|Embedded), so a session
that asks for metadata is served EMBEDDED — the compositor paints the
pointer into the frames and sends no cursor metadata, ever, wherever the
pointer is. The heuristic was reading noise and warping the user's
pointer over it.
Distinct from — and complementary to — 5a5397ca, which fixed WHERE the
warp landed (the wlr virtual pointer was bound to the operator's head,
so the park drove a screen nobody was streaming). That one makes the
park work; this one stops it repeating on evidence that does not exist.
Both are needed: with only 5a5397ca the pointer would be re-centred on
the *streamed* output once a second instead, which is the field report's
symptom exactly.
The same fact broke a second thing next to it. `metadata_composite` had
the host plan a metadata cursor composite on a backend that can never
deliver metadata: the stream logged "host-composite active but the
capture has no live cursor overlay" for its whole life and drew no host
pointer, which is why an earlier session on this box looked cursorless.
Under Embedded the compositor's burnt-in pointer IS the cursor, and the
host must not plan a composite at all.
So surface what the portal actually negotiated instead of inferring it:
`portal_cursor::negotiate` now returns our own `Mode` (re-exported as
`pf_vdisplay::PortalCursorMode`), the hyprland/wlroots portal threads
carry it back beside the fd and node id — alongside, not instead of, the
`closed_tx` teardown handshake and inside the same `HANDSHAKE_BUDGET`
bound — and the backends, plus the monitor mirror that delegates to
them, report it per session as `VirtualDisplay::last_portal_cursor_mode`.
`None` is the default and what every non-portal backend reports (KWin
`zkde_screencast`, Mutter `RecordVirtual`, gamescope, Windows all get
the mode they ask for), so nothing about the GNOME behaviour this was
built for changes.
The host settles both consequences from that one fact in
`settle_portal_cursor`, at bring-up and again after every capture-loss
rebuild (the retarget arm has to recompute `metadata_composite` from the
compositor alone, because it runs before the rebuild to set `hw_cursor`).
`plan.cursor_blend` is deliberately left alone: it is resolved before any
display exists, and pre-judging it would mean re-asserting what the wlr
portals advertise — the exact hardcode `portal_cursor` exists to have
deleted. It costs a colour conversion, not correctness.
Also cuts the park RETRY for a client that steers the seat pointer
itself. The doc claimed a desktop-model client "overrides it with its
first absolute move, so the jump is invisible in practice" — one park at
bring-up is, a repeat is not: such a client sends absolute positions,
the very same event the park synthesizes, only aimed where the user is
actually pointing. It keeps the single bring-up park, so the session's
first click cannot land on whatever monitor the seat pointer was left
on, and loses the retry that fights the user. A cold EIS connection
swallows the client's own moves too, and those keep coming.
On the reported session this is 2 parks in the first second instead of
11 over ten, and no phantom composite.
Verified: `cargo fmt --all --check`; `scripts/xcheck.sh linux clippy`
and `windows clippy`; `cargo clippy -p punktfunk-host -p pf-vdisplay
-p pf-inject --all-targets --locked -- -D warnings` and `cargo test` for
the three, run for Linux in the ci/rust-ci.Dockerfile image (this crate
does not build on macOS at all — opus, zerocopy and the Linux-only
vdisplay entry points are cfg'd out there, so the container is the only
way to compile it). pf-vdisplay 231 tests, pf-inject 130+7, and both new
tests pass. punktfunk-host's suite has two pre-existing failures under
that emulated container — `gamestream::stream::tests::sender_delivers_
batches` (EINTR on a socket recv) and one of the two `mgmt` local-summary
tests, which share process-global session state — and the SAME two fail
on this branch's parent without this commit; each passes in isolation.
Not verified on glass: the .138 Hyprland box is read-only and in use.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Hyprland is now verified streaming on glass, repeatedly, with a working pointer — confirmed by the operator on
home-nix-1(NixOS 26.05, Hyprland 0.55.4, xdph 1.3.12, RTX 5070 Ti). Every fix here was measured on that box; #216 got us past the cursor-mode refusal and straight into the next six.Each commit stands alone and is written to be taken independently.
1.
dea63957— capture: xdph offers BGRA on its dmabuf pod, we offered BGRxThe client went black with no error of ours. PipeWire failed the link itself:
Both
EnumFormatpods, dumped from the PipeWire daemon (PIPEWIRE_DEBUG=*:1,pw.link:5— they are not in our process's log, which is why this hid):BGRxonly0(LINEAR)BGRAonlyBGRAorBGRxThe modifiers intersect perfectly. Only the fourcc never does — which is exactly why the failure reads as a GPU/modifier problem and is not one. Our own message ("the compositor never accepted the dmabuf-only offer (EGL→CUDA GPU import)") points at the GPU, and the advert line prints only the first 6 of 13 modifiers so LINEAR is invisible. Both misled a full day.
Adds a BGRA pod beside the BGRx one, listed after it so a producer offering both still lands on the existing path — purely additive. Vendor-neutral by construction: the two modifier lists are enumerated per fourcc (
XR24andAR24asked separately), because EGL and libva answer per format. On the VAAPI passthrough path both are LINEAR, so AMD and Intel get the BGRA pod on the same terms as NVIDIA rather than an NVIDIA-shaped guess.2.
9ce347e4— vdisplay: we removed the captured output before closing the cast, and xdph spun on the wreckageStopGuard::droponly set an atomic and returned; the portal thread noticed 200 ms later. Sohyprctl output removeran on an output xdph was still capturing — every teardown. And nothing ever closed the session: xdph destroys one on exactly one event, an explicitSession.Close(shared/Session.cpp:37), and has no peer-vanished watcher. xdph then wedged in an unboundedwhile (nodeID == SPA_ID_INVALID) pw_loop_iterate(loop, 0)— a hot spin on its only event-loop thread, holding its event lock.Measured, not inferred: 231.971 s of CPU against 232.70 s of wall clock for the wedged interval — one core pinned solid.
Teardown now closes the session and waits for confirmation before removing the output, bounded 3 s each side. Same change in wlroots, on evidence rather than symmetry: xdpw's
session.clikewise exposes onlyClose, and itsscreencast.c:599is the identical spin.Also fixes the picker line to
[SELECTION]/screen:<NAME>. xdph splits on the first/(ScreencopyShared.cpp:86); we never sent one, soFLAGSbecame the whole payload andSELdid too — only becausenpos + 1wraps to0. That accident is why the name still parsed. What didn't hide: the flag loop walkedscreen:<name>a character at a time and hit therof "screen", settingallowToken, so xdph returned a restore token we never asked for. Format moved toportal_picker.rswith xdph's parser transcribed into the tests — the old line had one assertion and it passed the entire time it was wrong.3.
5e5d6904— vdisplay: a hung handshake leaked its thread, and one leak poisoned every later castselect_sources/startawait a reply a wedged portal never sends, and that await cannot be cancelled by thestopflag — the flag is only read by the park loop further down, which a stuck thread never reaches. One host accumulated nine live cast threads and 28 tokio workers, each holding a half-created session on the process's shared D-Bus connection.Bounded at 15 s, under
select_and_cast's 20 s, so the failure is reported with a reason and — the point — the thread exits.4.
cf4c12ea— vdisplay: a per-cast tokio runtime orphaned ashpd's process-global connectionThe reason the first stream of a host process worked and every later one was black. ashpd caches its connection process-globally:
zbus spawns that connection's background reader on whichever runtime is current when it is created. Both wlr backends built their own runtime per cast and dropped it at teardown — so the first cast created the cached connection on a runtime that died with it, and the
OnceLockkept handing every laterScreencast::new()a connection with no executor left to read its replies.The discriminator that pins it on us: a freshly spawned process completed the identical handshake against the identical xdph, repeatedly, while the long-lived host completed none — with xdph idle at 28 ms of CPU, so it was never the wedged party. Teardown was already correct by then, so a clean teardown log does not mean the cast machinery is healthy.
One shared
portal_rtruntime, built once, never dropped,block_on(&self)from every cast thread.Screencast::new()is bounded too — with the connection orphaned that call is exactly where the thread hung, so the earlier bound started one step too late.5.
6863f814— input: the wlr injector aimed absolute motion at the operator's headcreate_virtual_pointer_with_output(seat, globals.output, …)passed the first advertisedwl_output— registry globals arrive in creation order, so the oldest output, i.e. the operator's physical head. Every absolute sample from every session drove a screen nobody was streaming. Field symptom: the cursor clamped at the left edge and vanished past the middle.Not a race: the same build shows the opposite injector/output ordering in other sessions (4 s before, 24 ms, 198 ms apart) with the bug in all of them.
The output name now travels out of the backends (
VirtualOutput::output_name, the counterpart of Windows'win_capture), the host publishes it at capture bring-up, and the injector binds everywl_outputat v4 and matches on name, re-creating the pointer on change (releasing held buttons first). No fallback on a failed match — the old "first output" behaviour was the fallback.Second defect fixed in the same commit:
inject()only calleddispatch_pending, which wayland-client documents as not reading the socket. Any retarget scheme would have been dead whenever the display came up after the injector, and everything the compositor sent had piled up unread for the host's lifetime — including the protocol errors that code claimed to surface.⚠️ Known limitation, deliberate: one slot per process, so with concurrent sessions the last capture bring-up wins for everyone's absolute input — the same trade Windows'
stream_targetalready documents, and strictly better than every session aiming at a head no session was streaming.set_absolute_anchoris untouched and still not called from a session path.6.
2832b5d0— host: the park schedule read a missing cursor overlay as a lost pointerpark_pointerwarps the seat pointer to the streamed surface's centre, because a pointer-locked client sends only relative deltas and nothing else would move it onto a fresh virtual output. Past its two unconditional attempts it continued while a host-composite session still had no live cursor overlay — reading "no overlay" as "the pointer has not reached the output".Sound on Mutter, which suppresses
SPA_META_Cursorwhile the pointer is off the recorded view. Meaningless on the whole wlr family: xdph and xdpw advertiseAvailableCursorModes = 3(Hidden|Embedded), so a session asking for metadata is served Embedded — the compositor paints the pointer into the frames and sends no metadata ever, wherever the pointer is. The host re-centred the user's cursor once a second for the full 10-attempt cap. Field report: "the cursor jumps to the screen centre at the beginning of the stream."Same fact, second consequence:
metadata_compositehad the host planning to composite a metadata cursor on a backend that can never deliver one — logged ashost-composite active but the capture has no live cursor overlay yet — the stream is cursorless until one arrives, once per session, forever. That is why an earlier session showed no cursor at all.The negotiated mode is now surfaced per session via
VirtualDisplay::last_portal_cursor_mode()(modelled onlast_identity_slot; no process-global state). KWin, Mutter, gamescope and Windows reportNone, which explicitly means "this says nothing" — so the GNOME behaviourpark_pointerexists for is untouched by construction, with a test pinning theNonecase.A steering (absolute) client keeps the single bring-up park and loses the 1 s retry: one park at t≈0 is invisible, the repeat is what fights a user who has started moving, and an absolute client re-aims with its own next move — while the bring-up park still protects a session's first click with no preceding move. Relative-only sessions keep both. Net: 2 parks in the first second instead of 11 over ten.
⚠️ 5 and 6 are both required — neither makes the other redundant. With only 5 the schedule still fires 11 times, now re-centring on the streamed output. With only 6 the two surviving parks still aim at the wrong head.
Verification
u 3, so the failing precondition is genuinely present.cargo fmt --all --check;scripts/xcheck.sh linux clippyandwindows clippyat-D warnings;cargo test -p pf-vdisplay123 pass.scripts/xcheck.shcovers neitherpf-capturenorpunktfunk-host(libpipewire/ring/opus/xkbcommon), andpunktfunk-hostdoes not build on macOS at all. Those crates were checked in theci/rust-ci.Dockerfileimage:cargo clippy -p punktfunk-host -p pf-vdisplay -p pf-inject --all-targets -- -D warningsexit 0,pf-inject130 + 7 pass, and a NixOSnix buildof the whole host on the test box.mgmttests are flaky on main already (verified by running the identical command on the base commit): they share process-global session-registry state thatSESSION_REGISTRY_LOCKdoes not contain under the container's emulated timing. Each passes in isolation, all 48 pass as a group. Not from this branch, but worth a separate look.Upstream bugs worth filing
Screencopy.cpp:307and xdpwscreencast.c:599. A portal must not be permanently wedgeable by one bad stream./.A Hyprland/sway client went black with no error of ours: PipeWire failed the link itself with pw.link: (73.0.0 -> 81.0.0) negotiating -> error no more input formats (-22) Measured on Hyprland 0.55.4 + xdph 1.3.12 by dumping both EnumFormat pods from the PipeWire DAEMON (`PIPEWIRE_DEBUG=*:1,pw.link:5` — the pods are not in our own process's log, which is why this hid for so long): ours BGRx only | 12 NVIDIA tiled + 0 (LINEAR) | MANDATORY xdph BGRA only | the same 12 + MOD_INVALID | MANDATORY|DONT_FIXATE xdph BGRA or BGRx, no modifier | (the SHM pod) The modifiers intersect perfectly. Only the fourcc never does, which is exactly why the failure reads as a GPU/modifier problem and is not one — the host's own message ("the compositor never accepted the dmabuf-only offer (EGL->CUDA GPU import)") points at the GPU, and our advert line prints only the first 6 of 13 modifiers so LINEAR is invisible. Both misled a full session of debugging. Since our offer is dmabuf-only, xdph's mixed SHM pod could not rescue it. Offer a BGRA dmabuf pod beside the BGRx one. BGRA and BGRx are the same 32-bit layout, the alpha byte is ignored all the way to the encoder (`vk_util` maps both to B8G8R8A8_UNORM, VAAPI both to Pixel::BGRA), and the import is driven by the NEGOTIATED format's fourcc, so an AR24 frame imports as AR24. Vendor-neutral by construction: the two modifier lists are enumerated PER FOURCC (`XR24` and `AR24` asked separately), because EGL and libva answer per format and nothing entitles us to assume a driver importing one imports the other. On the VAAPI passthrough path there is no importer, so both lists are LINEAR (plus the PyroWave Vulkan set when armed) — AMD and Intel get the BGRA pod on the same terms as NVIDIA rather than an NVIDIA-shaped guess. The BGRA pod is listed AFTER BGRx, so a producer offering both still lands on the pre-existing path — first compatible consumer pod wins, so this is purely additive. Both pods are now guarded on a non-empty list (`build_dmabuf_format` indexes `modifiers[0]`). Also name `linear_offered` and both counts in the advert log, so the truncated `sample` can no longer be misread as the whole offer.Only the FIRST stream after a portal start ever worked on Hyprland; every one after it died in `select_and_cast` with create virtual output: timed out waiting for the ScreenCast portal on PF-… The mitigation on `worktree-capture-bgra-dmabuf-pod` chased the symptom. This is the mechanism, read out of xdph 1.3.12's source and the box's own journal. WE YANK THE OUTPUT OUT FROM UNDER A LIVE CAST. `Keepalive` drops `StopGuard` then `OutputGuard`, and `StopGuard::drop` only SET an atomic and returned. The portal thread noticed 200 ms later and merely dropped its zbus connection. So `hyprctl output remove` ran — synchronously, microseconds later — on an output xdph was still capturing, every single teardown. Nothing closed the session either. xdph destroys one on exactly one event, an explicit `org.freedesktop.impl.portal.Session.Close` (`Session.cpp:37`); it has no peer-vanished watcher. The frontend does (`xdg-desktop-portal.c:230` `peer_died_cb` → `close_sessions_for_sender`), but only once our bus name goes away — after the poll, asynchronously, on a GTask thread. Long after the output is gone. Proof from the box: xdph's toplevel lock stayed at 2 for 4.5 minutes after our stream ended and its output was removed, and that session's `Session destroyed` never came. XDPH THEN SPINS AT 100% CPU, FOREVER. Handed that wreckage, `startSharing` falls into `Screencopy.cpp:307-313` while (pSession->sharingData.nodeID == SPA_ID_INVALID) { int ret = pw_loop_iterate(g_pPortalManager->m_sPipewire.loop, 0); — timeout 0, i.e. NON-blocking, i.e. an unbounded hot spin on xdph's only event-loop thread, inside the `Start` handler, holding its `m_mEventLock`. From there it answers no D-Bus, no Wayland, no PipeWire, ever again. MEASURED: the wedged instance's unit reported `Consumed 3min 51.971s CPU time over 23min 41.092s wall clock`, and there were 232.70 s of wall clock between its last log flush and its restart — 231.971 s of CPU against 232.70 s of wall, one core pinned solid for precisely the wedged interval. Everything after that is queueing. Our next handshake gets nothing, times out at 20 s, and `SelectionFile` deletes the per-session selection file on its way out; if xdph is restarted mid-queue it finally runs the picker for that stale request and reads an empty file — the `SHAREDATA returned selection -1` in the log. THE FIX IS THE ORDER. `StopGuard::drop` now signals and then WAITS for the portal thread to have closed the ScreenCast session, and only then does `OutputGuard` remove the output. The close is answered synchronously by the frontend (`xdp-session.c:217` `handle_close` → `xdp_dbus_impl_session_call_close_sync`), so when it returns xdph has already run `destroyStream`. The output we remove next is one nobody is capturing. Bounded at 3 s on each side — an already-wedged portal must not be able to wedge our teardown with it — and the park poll drops to 20 ms now that teardown waits on it. wlroots gets the same change, and NOT on an assumption of symmetry: xdpw was read to confirm both preconditions. `src/core/session.c` gives its session object exactly one method, `Close`; and `src/screencast/screencast.c:599-605` is the identical unbounded `while (cast->node_id == SPA_ID_INVALID)` spin — xdph's copy is that code. sway's `output unplug` yanks a captured output exactly the way Hyprland's `output remove` did. Not observed on glass; no sway box. THE PICKER LINE WAS ALSO MALFORMED, AND IS A RED HERRING FOR THE STALL. xdph splits the picker's line on the first `/` into flags and selection (`ScreencopyShared.cpp:86-87`) and we never sent one. `find_first_of` then returns npos, so FLAGS became the whole payload — and SEL became the whole payload too, purely because `npos + 1` wraps to 0, which is why the output name still parsed and why this hid. What did not hide is the flag loop walking `screen:<name>` one character at a time (`unknown flag from share-picker: s`, `c`, `e`, …) and setting `allowToken` on the `r` of `sc*r*een`, so xdph answered every Start with a `restore_data` + `persist_mode: 2` we never asked for. The reference picker prints the separator unconditionally (`hyprland-share-picker/main.cpp:133-136`), so empty flags are a bare leading `/`. Fixed to `[SELECTION]/screen:<NAME>`. It is NOT what stalled anything: the sessions that streamed fine logged the identical flag spam and the identical restore token, so it never discriminated. The format moves to `portal_picker.rs`, declared unconditionally like `portal_config` and `portal_cursor`, with xdph's parser transcribed into the tests — including the npos arithmetic. A wire format with no schema and no error report is invisible from the string alone: the old line's one assertion passed the entire time it was wrong. Those tests now run on every platform's CI rather than only the leg that compiles `mod hyprland`. Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy AND windows clippy (-D warnings), cargo test -p pf-vdisplay 122 passed (5 new). Not verified: on-glass behaviour — the box is in use for live testing and read-only to me. Upstream bugs worth filing, both wlr-family: the unbounded node-id spin (hyprwm/xdg-desktop-portal-hyprland Screencopy.cpp:307, emersion/xdg-desktop-portal-wlr screencast.c:599) should be bounded and fail the request rather than pinning a core forever; and xdph's picker parse should reject a line with no `/` instead of reading the whole selection as flags.MEASURED 2026-08-14 on the Hyprland box, with the ordered-teardown fix already in place. The first cast of a host process streamed: hyprland headless output ready … output=PF-44694-1 w=5120 h=1440 hz=240 pipewire stream state old=Paused new=Streaming and every cast after it timed out in select_and_cast. The host had NINE live `punktfunk-hypr-cast` threads and 28 tokio workers at that point. `select_sources`/`start` await a D-Bus reply that a wedged portal never sends. That await cannot be cancelled by the `stop` flag, because the flag is only read by the park loop further down — a thread stuck in the handshake never reaches it. So every timed-out attempt left a thread parked forever on a half-created portal session, holding this process's shared D-Bus connection, and from the first hang onwards every later request from the SAME process hung too. The discriminator that proves it is the process, not the portal: a freshly spawned process (`punktfunk-host spike --source portal`, driven through the same custom picker) completed the identical handshake against the very same xdph — repeatedly — while the long-lived host could not complete any. xdph itself was idle, 28 ms of CPU since start, so it was not spinning. Bound the handshake at 15 s, under select_and_cast's 20 s wait so the failure is reported by the thread that owns it, with a reason, and — the point — so that thread EXITS instead of leaking. Same change in the wlroots/sway backend: xdpw carries the identical unbounded node-id spin (screencast.c), so it can wedge the same way. Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings), cargo test -p pf-vdisplay 122 passed. Not verified: on-glass — needs the box.THE reason the first stream of a host process worked and every later one was black. Not xdph, not the compositor, not the formats — ours, and a lifetime mistake. ashpd caches its D-Bus connection process-globally: static SESSION: OnceLock<zbus::Connection> // ashpd 0.13.13, src/proxy.rs:27 The first `Screencast::new()` in the process creates that connection, and zbus spawns its background reader as a task on whichever tokio runtime is current at that moment. Both wlr backends built their OWN multi-thread runtime per cast and dropped it at teardown — so the first cast created the cached connection on a runtime that was then destroyed with it, and the OnceLock went on handing the same executor-less connection to every later `Screencast::new()`, which awaited a reply nothing was alive to read. Measured 2026-08-14 (Hyprland 0.55.4, xdph 1.3.12): first cast of a host process streamed, every cast after it hung, and the surviving cast thread sat in futex_do_wait inside runtime shutdown. The discriminator that pins it on us: a freshly spawned process completed the identical handshake against the identical xdph, repeatedly, while the long-lived host completed none — with xdph itself idle at 28 ms of CPU, so it was never the one wedged. Teardown was already correct by then: the log shows `hyprland headless output removed` in the right order. One shared runtime (`portal_rt`), built once, never dropped, `block_on(&self)` from every cast thread. It outlives the cached connection because it must. Also bound `Screencast::new()` itself, not just the handshake after it: with the connection orphaned that call is exactly where the thread hung, so the earlier bound started one step too late and the failure still surfaced as the caller's generic 20 s timeout. Verified: cargo fmt --all --check, scripts/xcheck.sh linux clippy (-D warnings), cargo test -p pf-vdisplay 122 passed. Not verified: on-glass.`WlrootsInjector::open` created its virtual pointer with `globals.output` — whatever `wl_output` the registry roundtrip had bound, which was `state.output.is_none()`, i.e. the FIRST one advertised. Registry globals arrive in creation order, so "first" is the compositor's oldest output: the operator's physical head, never the per-session headless one the client is looking at. The wlr protocol maps `motion_absolute` onto the output the pointer was CREATED with ("if the output argument is set, the compositor should map the input device to the requested output"), so every absolute sample from every session drove a screen nobody was streaming. On the EXTEND backends — Hyprland and wlroots/sway, where the streamed head sits beside the operator's — that is the field report "no cursor was visible in the session", and it is also why `park_pointer`'s opening warp put the seat cursor on the operator's desktop once a second instead of on the stream. Not a startup race, though it looks like one. The host journal on the Hyprland box shows BOTH orderings across sessions of the same build — the injector opening 3.7 s before the headless output in one, 24 ms after it in another — and the bug in both, because "first advertised" is the oldest global either way. `hyprctl monitors` on that box: `HDMI-A-1` (ID 0) at +0+0, `PF-87756-3` (ID 1) at +1920+0. ID 0 is always first. Three parts. 1. pf-vdisplay carries the head's compositor name out on `VirtualOutput::output_name`, the Linux counterpart of what `win_capture` already carries on Windows. Set by hyprland and wlroots (the two EXTEND backends) and by the monitor mirror; `None` on KWin/Mutter (they inject through libei, which selects by region) and gamescope (it owns its whole seat). Threaded through the registry pool so a keep-alive reuse answers with the same name a fresh create would — no poolable backend sets it today, and this is so that stops being a silent trap the day one does. 2. The host publishes it at capture bring-up, `pf_inject::set_stream_output`, in the same place and shape as the Windows arm's existing `set_stream_target`. 3. The wlr injector binds EVERY `wl_output` at v4 (for the `name` event), matches the published name, and re-creates its virtual pointer bound to that output whenever the target changes — releasing any held button on the old device first, because nothing else would and a virtual pointer destroyed mid-press leaves the host with a stuck button. Matching is by NAME, with NO fallback, and the absence of the fallback is the fix: the old "first output" behaviour WAS the fallback. Size could not stand in for it either — `MouseMoveAbs`'s extent is the client's letterboxed video rect in its own window, not the streamed mode, so no size ladder can identify the head. An unresolved target binds NO output, which maps absolute coordinates over the whole layout: on a single-output compositor that is identical to binding that output, and on a multi-head one it at least keeps the streamed head reachable, unlike a pin to the wrong one. `inject` now also READS the Wayland socket. It only ever called `dispatch_pending`, which is documented to "not perform reads on the Wayland socket", so the queue held nothing but what `open`'s roundtrips put there. Without this the retarget above would have been dead in exactly the sessions that need it most — the injector could never learn about a `wl_output` created after it opened — and, separately, everything the compositor sent had been piling up unread in the socket buffer for the host's lifetime, including the protocol errors the comment there claimed to be surfacing. Concurrency, stated plainly: ONE slot per process. The injector is host-lifetime (in fact there are two `InjectorService`s — the native plane's and one per GameStream control listener) and `InputEvent` is an 18-byte `#[repr(C)]` ABI struct with no session field, so with parallel sessions (up to `max_concurrent`, default 4) the LAST capture bring-up wins for everyone's absolute input. That is the same trade `stream_target` already documents on Windows, and it is strictly better than what it replaces, where every session aimed at a head NO session was streaming. Making injection genuinely session-aware is the real fix and a much larger one — it needs source-tagged input events through both control planes. `set_absolute_anchor`'s warning is amended rather than quietly violated: it still must not be called from a session path, and it now says which mechanism took the per-session trade, why that is a separate slot (this one is the operator's host-wide capture pin, recomputed from policy whenever the console writes it — which would wipe a per-session value), and where the trade is written down. Gates: `cargo clippy --all-targets -p pf-inject -- -D warnings`, `cargo build -p pf-inject` and `cargo test -p pf-inject` (137 tests, incl. 3 new) on x86_64-unknown-linux-gnu in `punktfunk-rust-ci`; `cargo check -p punktfunk-host` likewise; `scripts/xcheck.sh linux clippy` plus 230 `pf-vdisplay` tests; `cargo fmt --all --check`. The two new `wlr` tests pin the regression directly — an unknown target must bind NOTHING rather than fall back to the first advertised output. Not verified here: no on-glass run. The box at .138 is in live use and read-only to me, so the change is unproven against a real Hyprland seat. Two smaller things also rest on reading rather than observation — that Hyprland's `hyprctl` monitor name is byte-identical to its `wl_output.name` (the protocol says the name is "the same for all clients", and xdph already resolves our `hyprctl`-minted name to the same output for screencast, which is field-proven), and the exact on-screen arithmetic of the "moves to mid-screen then jumps back" symptom, which does not follow from the protocol's normalize-by-extent mapping and would need Hyprland's own source to pin down.