Compare commits

...
Author SHA1 Message Date
enricobuehler cdacd5636e Merge remote-tracking branch 'origin/main' into worktree-kwin-vout-enable-repair
ci / bun-nix (pull_request) Successful in 32s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m43s
ci / rust-arm64 (pull_request) Successful in 2m20s
android / android (pull_request) Successful in 5m39s
ci / rust (pull_request) Successful in 6m20s
2026-08-19 22:39:11 +02:00
enricobuehler 47f01149bf Merge pull request 'Guided Linux installer (preview): one command from nothing to a pairable host, running exactly what platforms.json states, smoke-tested in CI per package family' (#345) from worktree-wp4-install-script into main
apple / swift (push) Successful in 2m10s
ci / web (push) Successful in 2m2s
ci / rust-arm64 (push) Successful in 3m4s
ci / bun-nix (push) Successful in 26s
ci / docs-drift (push) Successful in 25s
ci / docs-site (push) Successful in 2m12s
deb / build-publish-gamescope (push) Successful in 27s
decky / build-publish (push) Successful in 1m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m19s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
deb / build-publish (push) Successful in 4m5s
ci / rust (push) Successful in 8m17s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 27s
arch / build-publish (push) Successful in 10m11s
installer-smoke / smoke (arch) (push) Successful in 56s
installer-smoke / smoke (fedora-44) (push) Successful in 1m0s
installer-smoke / smoke (debian-13) (push) Successful in 1m16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m41s
docker / builders-arm64cross (push) Successful in 22s
android / android (push) Successful in 11m46s
docker / deploy-docs (push) Successful in 39s
deb / build-publish-host (push) Successful in 8m27s
apple / distribute (push) Successful in 12m59s
deb / smoke-install (push) Successful in 2m35s
apple / screenshots (push) Successful in 9m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m39s
2026-08-19 19:01:45 +00:00
enricobuehler 20568d988f fix(kwin): KWin creates our virtual output disabled and refuses to stream it — enable it and retry
ci / web (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 2m30s
ci / docs-site (pull_request) Successful in 1m14s
ci / docs-drift (pull_request) Failing after 19s
ci / bun-nix (pull_request) Successful in 1m42s
android / android (pull_request) Successful in 10m8s
ci / rust (pull_request) Failing after 10m48s
On KWin >= 6.6 `streamVirtualOutput` creates the output on the backend and then
passes `workspace()->findOutput(output)` to the stream, which is null for an
output the workspace does not manage (`wantsToManage` = `isEnabled() &&
!isNonDesktop()`). So an output KWin creates DISABLED is refused with
"Could not find output" — translated into the session's language, and logged
nowhere, because disabling an output is a perfectly valid configuration that
applies successfully. 6.4/6.5 passed the backend output straight through and
streamed it either way.

It repeats forever. The host asks for a STABLE per-client output name precisely
so KWin persists that client's scale and mode against it, so a stored setup
naming it `enabled: false` is reapplied to every future session for that client
— and the user cannot fix it in System Settings, because the output only exists
for the few milliseconds the request is alive.

Repair it instead. On a refusal, enable the head over kde_output_management_v2
and let the retry go again. Two properties of KWin make that possible, both read
off Plasma/6.7 rather than assumed:

  * `sendFailed` only sends the event — it does not emit `finished`, and
    `removeVirtualOutput` is wired to `finished`. The disabled output therefore
    stays alive for as long as we hold the failed stream open, which is the
    window the repair runs in (and why it must run inside the worker thread,
    before the connection drops).
  * `WaylandServer::handleOutputAdded` offers EVERY backend output to the
    output-device registry, gating only placeholders and non-desktop ones. Only
    `wl_output` is gated on being enabled, so a disabled output is invisible to
    `stream_output` but fully addressable over output management.

Enabling it is a user-applied configuration, so KWin persists it against that
output's identity: the retry's fresh request finds a stored setup that enables
it. The repair therefore fixes attempt N+1, never attempt N.

`REPAIRED_HINT` keeps that retry reachable. The opener wraps every worker error
with "KWin virtual output failed", which is the phrase the host's
`is_permanent_build_error` matches to short-circuit the retry loop — so a
repaired refusal carrying it would be classified permanent and the retry that
consumes the repair would never run, making the whole path dead code. A repaired
refusal is reported verbatim without that wrapper; an unrepairable one keeps it
and still fails fast, because nothing about the box changed. The host-side test
pins both halves.
2026-08-19 20:29:27 +02:00
3 changed files with 124 additions and 2 deletions
+43 -2
View File
@@ -66,6 +66,16 @@ use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
const POINTER_METADATA: u32 = 4;
const POINTER_EMBEDDED: u32 = 2;
/// Marks the one KWin refusal a retry can clear: the disabled-output repair ran and changed the
/// box between attempts ([`kwin_output_mgmt::enable_disabled_output`]).
///
/// It is load-bearing in TWO places and both are easy to break. The opener keys on it to skip the
/// `KWin virtual output failed` wrapper below — and that wrapper's prefix is exactly what the
/// host's `is_permanent_build_error` matches to short-circuit the retry loop, so a repaired
/// refusal carrying it would be classified permanent and the retry that consumes the repair would
/// never run. It is also the human-readable half of the message; keep it a phrase, not a code.
const REPAIRED_HINT: &str = "enabled it over output management";
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
const VOUT_NAME: &str = "punktfunk";
@@ -268,6 +278,10 @@ impl VirtualDisplay for KwinDisplay {
.context("spawn KWin virtual-output thread")?;
match setup_rx.recv_timeout(OPENER_BUDGET) {
Ok(Ok(v)) => Ok((v, stop)),
// Repaired: report it as-is. The wrapper below would prepend the phrase the host
// reads as "permanent, do not retry", and this is the one refusal whose retry is
// the entire point — the repair only fixes the NEXT request.
Ok(Err(e)) if e.contains(REPAIRED_HINT) => bail!("{e}"),
// KWin's reason is TRANSLATED into the session's language, so it is often
// unsearchable for the person reading the log. Say what it means once, here.
Ok(Err(e)) => bail!(
@@ -1793,14 +1807,41 @@ fn run(
);
// Pump events until KWin reports the node id (or an error, or the budget).
let node_id = await_created(
//
// A refusal here is where the KWin >= 6.6 disabled-output trap lands, and it is repairable
// FROM INSIDE THIS SCOPE and nowhere else: KWin destroys the output when our stream is
// destroyed, so the connection has to stay up while we enable it (see
// [`kwin_output_mgmt::enable_disabled_output`] for why the output is still alive at all, and
// why enabling it fixes the NEXT request rather than this one).
let node_id = match await_created(
&conn,
&mut queue,
&mut state,
stop,
"stream_virtual_output",
started,
)?;
) {
Ok(id) => id,
Err(e) => {
// `Virtual-<name>` is the address KWin exposes our output under (the same prefix the
// topology path resolves against).
match crate::kwin_output_mgmt::enable_disabled_output(&format!("Virtual-{name}")) {
// Deliberately does NOT carry the "KWin virtual output failed" prefix: that string
// is what marks a KWin refusal PERMANENT for the session's retry loop, and this is
// the one refusal where something DID change between attempts. Retrying is the
// whole point of repairing.
Some(repaired) => bail!(
"KWin created the virtual output disabled and refused to stream it ({e}); \
{REPAIRED_HINT} (head {repaired}) — the retry picks up the configuration \
KWin just persisted"
),
// Nothing to repair (no such head, already enabled, or the apply was refused):
// the refusal stands, and its own prefix keeps it permanent so the session fails
// fast instead of burning the retry budget on an unchanged box.
None => return Err(e),
}
}
};
setup_tx
.send(Ok(node_id))
.map_err(|_| anyhow!("virtual-output opener went away"))?;
@@ -1274,6 +1274,76 @@ pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
complete
}
/// Enable a virtual output KWin created but left DISABLED, addressed by the `Virtual-<name>`
/// prefix it exposes ours under. Returns the head's name when one matched, was disabled, and the
/// enable applied.
///
/// This is the repair for the KWin ≥ 6.6 refusal (`"Could not find output"`, translated into the
/// session's language). `streamVirtualOutput` there creates the output on the backend and then
/// hands `workspace()->findOutput(output)` to the stream — and that returns null for an output the
/// workspace does not manage, which `wantsToManage` defines as `isEnabled() && !isNonDesktop()`.
/// KWin 6.4/6.5 passed the backend output straight through, so a disabled one streamed anyway;
/// from 6.6 it is a hard refusal, and one that repeats forever: the host asks for a STABLE
/// per-client name so KWin persists that client's scale and mode, and a stored setup naming it
/// `enabled: false` is therefore reapplied to every future session.
///
/// Two properties of KWin make the repair possible, both verified against Plasma/6.7:
///
/// * `sendFailed` only sends the event — it does not emit `finished`, and `removeVirtualOutput` is
/// wired to `finished`. So the disabled output stays alive for exactly as long as the caller
/// holds its (failed) stream open, which is the window this runs in.
/// * `WaylandServer::handleOutputAdded` offers EVERY backend output to the output-device registry,
/// gating only placeholders and non-desktop ones. A disabled output has no `wl_output` — that
/// side is gated on the workspace — but it is addressable over `kde_output_management_v2`.
///
/// Enabling it through output management is a user-applied configuration, so KWin persists it
/// against that output's identity: the caller's next `stream_virtual_output` under the same name
/// finds a stored setup that enables it. Which is why the caller must RETRY after this returns
/// `Some` — the request that failed cannot be salvaged, only the one after it.
pub(crate) fn enable_disabled_output(prefix: &str) -> Option<String> {
let mut sess = Session::open("enable_disabled").ok()?;
let deadline = Instant::now() + OP_BUDGET;
// Newest-wins, exactly as the supersede resolve elsewhere in this file: a reconnect can leave
// a predecessor of the same name briefly announced, and enabling THAT one repairs an output
// that is already going away.
let dev = sess
.state
.devices
.values()
.filter(|d| d.name.as_deref().is_some_and(|n| n.starts_with(prefix)) && d.proxy.is_some())
.max_by_key(|d| (d.global, d.seq))
.cloned()?;
let name = dev.name.clone()?;
if dev.enabled {
// Not the shape we repair. Say so rather than applying a no-op config that would `applied`
// successfully and read as a fix — the caller decides whether to retry on this.
tracing::debug!(
%name,
"KWin output management: our virtual output is already enabled — nothing to repair"
);
return None;
}
let proxy = dev.proxy.as_ref()?;
let config = sess.new_config();
config.enable(proxy, 1);
let ok = sess.apply(&config, deadline);
config.destroy();
if !ok {
tracing::warn!(
%name,
reason = ?sess.state.failure_reason,
"KWin output management: could not enable the virtual output KWin created disabled"
);
return None;
}
tracing::info!(
%name,
"KWin output management: KWin created our virtual output DISABLED and refused to stream \
it; enabled it — KWin persists that, so the retry's request comes back enabled"
);
Some(name)
}
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
@@ -5345,6 +5345,17 @@ mod tests {
"spawn gamescope (is it installed? `apt install gamescope`)"
));
assert!(is_permanent_build_error("virtual displays require Linux"));
// The ONE KWin refusal that must stay retryable: pf-vdisplay repaired the box (it enabled
// the output KWin created disabled, which KWin persists), so the next attempt is not the
// same attempt. That path deliberately reports WITHOUT the `KWin virtual output failed`
// prefix above — if it ever regains it, the retry that consumes the repair stops running
// and the repair is dead code.
assert!(!is_permanent_build_error(
"create virtual output: KWin created the virtual output disabled and refused to \
stream it (stream_virtual_output failed: Não foi possível encontrar saída); enabled \
it over output management (head Virtual-punktfunk-a1b2) the retry picks up the \
configuration KWin just persisted"
));
// Transient: negotiation/timeout races — exactly what backoff is for.
assert!(!is_permanent_build_error(
"first frame: no PipeWire frame within 10s (node 42): format negotiation never completed"