Package: kwin-wayland
Version: 4:6.3.6-1
Severity: important
Tags: upstream fixed-upstream patch
Forwarded: https://bugs.kde.org/show_bug.cgi?id=495702
X-Debbugs-Cc: [email protected]

Dear Maintainer,

kwin_wayland crashes reliably when active outputs are hot-removed, e.g.
when unplugging a Thunderbolt dock that drives the enabled displays. On
Wayland this takes down the entire session (every client loses its
compositor connection), so all unsaved work in the session is lost and
the user ends up on a black screen.

Reproducibility here: 2 out of 2 dock unplugs since June crashed the
compositor within one second (2026-06-24 08:44, 2026-07-21 12:11).
Setup: Lenovo ThinkPad TB4 Dock (40B0), three DisplayPort MST monitors,
internal panel disabled while docked - so the unplug removes all
enabled outputs at once. Intel Raptor Lake iGPU (i915), kernel
6.12.95+deb13 at the time of the incidents, Plasma 6.3.6 on Wayland.

Journal signature (identical in both incidents):

  12:11:05 kernel: thunderbolt 1-1: device disconnected
  12:11:06 kwin_wayland_wrapper: KCrash: Application 'kwin_wayland'
           crashing... crashRecursionCounter = 2
  12:11:06 kwin_wayland_wrapper: kwin_wayland_drm: atomic commit
           failed: Bad file descriptor
  (all Wayland clients then report "The Wayland connection broke.
   Did the Wayland compositor die?")

This is upstream KDE bug 495702 (17 duplicates): since the layer-shell
change in kwin 6.2, Workspace::outputRemoved() can trigger
activateNextWindow(), which may set the active output to the output
that is just being removed, leaving Workspace with a dangling pointer.
Duplicate 511230 describes exactly this Thunderbolt dock scenario.

Consistent with that diagnosis, the crash is avoidable by not removing
an active output: if the external outputs are disabled and the internal
panel is re-enabled via kscreen-doctor *before* the cable is pulled,
no crash occurs. I have used that as a workaround since 2026-07-22,
most recently on 2026-07-29/30, with no further incidents - which
supports output removal, rather than the Thunderbolt disconnect as
such, being the trigger.

Fixed upstream by moving the active-output cleanup after the
outputRemoved() signal:

  master:      5aa070d5f4a4c0ce1802aa94eec6ebf9bc9edca9
  Plasma/6.4:  63edb977e99a860a5a167e210e6222a24115c593
               (released in Plasma 6.4.6 and 6.5.1)

The 6.3 series is end-of-line upstream, so trixie will not receive this
fix through any 6.3.x release. The code change itself is 6 lines in
src/workspace.cpp (plus an autotest). I verified that the Plasma/6.4
cherry-pick applies cleanly to the 6.3.6 source, offsets only, no fuzz:

  Hunk #1 succeeded at 1229 (offset 63 lines).
  Hunk #2 succeeded at 2176 (offset 92 lines).

Please consider cherry-picking this for a trixie point release. The
patch (the upstream Plasma/6.4 commit, applies as-is) is included
inline below.

For transparency: the machine runs MX Linux 25's rebuild of this exact
Debian source (4:6.3.6-1mx25); the code is unmodified 4:6.3.6-1 in the
affected paths. I have not attached a backtrace, since the crash is
already identified upstream with a released fix, but I am happy to
provide one from a controlled reproduction against the Debian build if
that would be useful.

-- System Information:
Debian Release: 13 (trixie) / MX Linux 25
Architecture: amd64
Kernel: Linux 6.12.96+deb13-amd64 (incidents on 6.12.95+deb13-amd64)
Desktop: KDE Plasma 6.3.6 (Wayland)
Versions of kwin packages: 4:6.3.6-1mx25 (MX rebuild of 4:6.3.6-1)

-- Patch (upstream Plasma/6.4 commit 63edb977, applies to 6.3.6):

>From 63edb977e99a860a5a167e210e6222a24115c593 Mon Sep 17 00:00:00 2001
From: Vlad Zahorodnii <[email protected]>
Subject: [PATCH] Fix Workspace::activateNextWindow() corrupting active output
 on hotplug

After 4ee32d7f71de7f7f9a24c44b9dc4dd31fd3be98f, layer shell windows are
removed when the Workspace::outputRemoved() signal is emitted.

The side-effect of that change is that the activateNextWindow() may be
called when the Workspace::outputRemoved() signal is emitted.

The activateNextWindow() function is interesting because it can set the
active output to the one of the Window.

If the next candidate window is on a just removed output, the Workspace
can end up with a dangling active output.

To prevent that case, this change moves active output cleanup code so it
is after the outputRemoved() signal is emitted and after windows are
re-arranged.

BUG: 495702
(cherry picked from commit 5aa070d5f4a4c0ce1802aa94eec6ebf9bc9edca9)
---
 autotests/integration/workspace_test.cpp | 116 +++++++++++++++++++++++
 src/workspace.cpp                        |   6 +-
 2 files changed, 121 insertions(+), 1 deletion(-)

diff --git a/autotests/integration/workspace_test.cpp 
b/autotests/integration/workspace_test.cpp
index 769c8cd1a49..7a3db954444 100644
--- a/autotests/integration/workspace_test.cpp
+++ b/autotests/integration/workspace_test.cpp
@@ -34,6 +34,9 @@ private Q_SLOTS:
     void activeOutputFollowsActiveWindow();
     void activeOutputDoesntFollowInactiveWindow();
     void disableActiveOutput();
+    void activeOutputAfterActivateNextWindowOnOutputAdded();
+    void activeOutputAfterActivateNextWindowOnOutputRemoved_data();
+    void activeOutputAfterActivateNextWindowOnOutputRemoved();
 };
 
 void WorkspaceTest::initTestCase()
@@ -278,5 +281,118 @@ void WorkspaceTest::disableActiveOutput()
     QCOMPARE(workspace()->activeOutput(), secondOutput);
 }
 
+void WorkspaceTest::activeOutputAfterActivateNextWindowOnOutputAdded()
+{
+    // This test verifies that the workspace doesn't end up with corrupted 
state when the Workspace::outputAdded() signal is emitted.
+    // activateNextWindow() is interesting because it changes the active 
output.
+
+    const auto firstOutput = workspace()->outputs()[0];
+    const auto secondOutput = workspace()->outputs()[1];
+
+    {
+        OutputConfiguration config;
+        {
+            auto changeSet = config.changeSet(firstOutput);
+            changeSet->enabled = false;
+        }
+        {
+            auto changeSet = config.changeSet(secondOutput);
+            changeSet->enabled = false;
+        }
+        workspace()->applyOutputConfiguration(config);
+    }
+
+    std::unique_ptr<KWayland::Client::Surface> 
firstSurface(Test::createSurface());
+    std::unique_ptr<Test::XdgToplevel> 
firstShellSurface(Test::createXdgToplevelSurface(firstSurface.get()));
+    auto firstWindow = Test::renderAndWaitForShown(firstSurface.get(), 
QSize(100, 50), Qt::blue);
+
+    std::unique_ptr<KWayland::Client::Surface> 
secondSurface(Test::createSurface());
+    std::unique_ptr<Test::XdgToplevel> 
secondShellSurface(Test::createXdgToplevelSurface(secondSurface.get()));
+    auto secondWindow = Test::renderAndWaitForShown(secondSurface.get(), 
QSize(100, 50), Qt::red);
+    QCOMPARE(workspace()->activeWindow(), secondWindow);
+
+    connect(workspace(), &Workspace::outputAdded, secondWindow, 
[secondWindow]() {
+        workspace()->activateNextWindow(secondWindow);
+    });
+
+    {
+        OutputConfiguration config;
+        {
+            auto changeSet = config.changeSet(firstOutput);
+            changeSet->enabled = true;
+        }
+        workspace()->applyOutputConfiguration(config);
+    }
+
+    QCOMPARE(workspace()->activeWindow(), firstWindow);
+    QCOMPARE(workspace()->activeOutput(), firstOutput);
+}
+
+void WorkspaceTest::activeOutputAfterActivateNextWindowOnOutputRemoved_data()
+{
+    QTest::addColumn<bool>("separateScreenFocus");
+
+    QTest::addRow("split screen focus") << true;
+    QTest::addRow("unified screen focus") << false;
+}
+
+void WorkspaceTest::activeOutputAfterActivateNextWindowOnOutputRemoved()
+{
+    // This test verifies that the workspace doesn't end up with corrupted 
state when the Workspace::outputAdded() signal is emitted.
+    // activateNextWindow() is interesting because it changes the active 
output.
+
+    QFETCH(bool, separateScreenFocus);
+    options->setSeparateScreenFocus(separateScreenFocus);
+
+    Test::setOutputConfig({
+        QRect(0, 0, 1280, 1024),
+        QRect(1280, 0, 1280, 1024),
+        QRect(2560, 0, 1280, 1024),
+    });
+
+    const auto firstOutput = workspace()->outputs()[0];
+    const auto secondOutput = workspace()->outputs()[1];
+    const auto thirdOutput = workspace()->outputs()[2];
+
+    std::unique_ptr<KWayland::Client::Surface> 
firstSurface(Test::createSurface());
+    std::unique_ptr<Test::XdgToplevel> 
firstShellSurface(Test::createXdgToplevelSurface(firstSurface.get()));
+    auto firstWindow = Test::renderAndWaitForShown(firstSurface.get(), 
QSize(100, 50), Qt::blue);
+    firstWindow->sendToOutput(firstOutput);
+
+    std::unique_ptr<KWayland::Client::Surface> 
secondSurface(Test::createSurface());
+    std::unique_ptr<Test::XdgToplevel> 
secondShellSurface(Test::createXdgToplevelSurface(secondSurface.get()));
+    auto secondWindow = Test::renderAndWaitForShown(secondSurface.get(), 
QSize(100, 50), Qt::red);
+    secondWindow->sendToOutput(secondOutput);
+
+    workspace()->activateWindow(firstWindow);
+    QCOMPARE(workspace()->activeWindow(), firstWindow);
+
+    connect(workspace(), &Workspace::outputRemoved, firstWindow, [firstOutput, 
firstWindow](Output *output) {
+        if (output == firstOutput) {
+            workspace()->activateNextWindow(firstWindow);
+        }
+    });
+
+    {
+        OutputConfiguration config;
+        {
+            auto changeSet = config.changeSet(firstOutput);
+            changeSet->enabled = false;
+        }
+        {
+            auto changeSet = config.changeSet(secondOutput);
+            changeSet->enabled = false;
+        }
+        {
+            auto changeSet = config.changeSet(thirdOutput);
+            changeSet->pos = QPoint(0, 0);
+        }
+        workspace()->applyOutputConfiguration(config);
+    }
+
+    QCOMPARE(workspace()->activeWindow(), separateScreenFocus ? nullptr : 
secondWindow);
+    QCOMPARE(workspace()->activeOutput(), thirdOutput);
+}
+
 WAYLANDTEST_MAIN(WorkspaceTest)
 #include "workspace_test.moc"
diff --git a/src/workspace.cpp b/src/workspace.cpp
index 0283608c890..dcef3a57f44 100644
--- a/src/workspace.cpp
+++ b/src/workspace.cpp
@@ -1166,7 +1166,7 @@ void Workspace::updateOutputs(const 
std::optional<QList<Output *>> &outputOrder)
         }
     }
 
-    if (!m_activeOutput || !m_outputs.contains(m_activeOutput)) {
+    if (!m_activeOutput) {
         setActiveOutput(m_outputs[0]);
     }
 
@@ -2084,6 +2084,10 @@ void Workspace::desktopResized()
 
     rearrange();
 
+    if (!m_outputs.contains(m_activeOutput)) {
+        setActiveOutput(m_outputs[0]);
+    }
+
     const auto stack = stackingOrder();
     for (Window *window : stack) {
         
window->setMoveResizeOutput(outputAt(window->moveResizeGeometry().center()));
-- 
GitLab

Reply via email to