AvengeMedia / AvengeMedia/DankMaterialShell

Settings window stops mapping after EGL_BAD_SURFACE; show() is a silent no-op because visible and backingWindowVisible both stay true.

Open
#3,140 2 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
QML
Stars
8.1k
Forks
515
Avg merge
1d 8h
Merged PRs (30d)
102

Description

### Compositor

Hyprland

### Distribution

Arch Linux

### If Other, please specify

_No response_

### Select your Installation Method

DankInstaller

### Was this your original Installation method?

Yes

### If no, specify

_No response_

### dms doctor -vC

## DMS Doctor Report

**System**
- [ok] Operating System: Arch Linux
- ID: arch, Version: rolling, Arch: amd64
- [ok] Architecture: amd64
- [ok] Display Server: Wayland
- WAYLAND_DISPLAY=wayland-1

**Versions**
- [ok] DMS CLI: dms v1.5.3
- /usr/bin/dms
- [ok] Quickshell: Quickshell 0.3.0 (revision , distributed by Arch Linux)
- /usr/bin/qs
- [ok] DMS Shell: v1.5.3
- /usr/share/quickshell/dms

**Installation**
- [ok] DMS Configuration: Found
- /usr/share/quickshell/dms
- [ok] shell.qml: Present
- /usr/share/quickshell/dms/shell.qml
- [info] Install Type: System package
- /usr/share/quickshell/dms

**Compositor**
- [ok] Hyprland: 0.56.2
- /usr/bin/hyprland
- [info] Active: Hyprland
- [ok] Background Blur: Supported
- Compositor supports ext-background-effect-v1

**Quickshell Features**
- [ok] Polkit: Available
- Authentication prompts
- [ok] IdleMonitor: Available
- Idle detection
- [ok] IdleInhibitor: Available
- Prevent idle/sleep
- [ok] ShortcutInhibitor: Available
- Allow shortcut management (niri)
- [ok] BackgroundBlur: Available
- Background blur API support in Quickshell

**Optional Features**
- [ok] accountsservice: Available
- User accounts
- [ok] power-profiles-daemon: Available
- Power profile management
- [ok] logind: Available
- Session management
- [ok] cups-pk-helper: Available
- Printer management
- [info] I2C/DDC: No monitors detected
- External monitor brightness control
- [ok] qt6-imageformats: Installed (5 formats)
- Formats: WebP, TIFF, GIF, JP2, ICNS (/usr/lib/qt6/plugins/imageformats:/usr/lib64/qt6/plugins/imageformats)
- [warn] kimageformats: Not installed
- AVIF, HEIF, JXL support
- [ok] Terminal: ghostty
- [ok] Network: NetworkManager
- NetworkManager present. Using NM API.
- [ok] matugen: Installed
- Dynamic theming
- [ok] dgop: Installed
- System monitoring
- [ok] cava: Installed
- Audio visualizer
- [info] khal: Not installed
- Calendar events
- [ok] danksearch: Installed
- File search
- [ok] dankcalendar: Installed
- Calendar app
- [info] fprintd: Not installed
- Fingerprint auth

**Config Files**
- [ok] settings.json: Present
- /home/r2titan/.config/DankMaterialShell/settings.json
- [info] clsettings.json: Not yet created
- /home/r2titan/.config/DankMaterialShell/clsettings.json
- [ok] plugin_settings.json: Present
- /home/r2titan/.config/DankMaterialShell/plugin_settings.json
- [ok] session.json: Present
- /home/r2titan/.local/state/DankMaterialShell/session.json
- [ok] dms-colors.json: Present
- /home/r2titan/.cache/DankMaterialShell/dms-colors.json

**Services**
- [ok] dms.service: enabled, active
- [ok] greetd: enabled

**Environment**
- [info] QT_QPA_PLATFORMTHEME: Not set
- [info] QS_ICON_THEME: Not set
- [warn] XDG_MENU_PREFIX: hyprland- (menu file not found)
- Dolphin 'Open with…' dialog may be empty. Ensure /etc/xdg/menus/hyprland-applications.menu exists.

**Fonts**
- [warn] Normal Font: 'Inter Variable' not found
- Font is not registered. Try running 'fc-cache -fv' or install the font.
- [warn] Monospace Font: 'Fira Code' not found
- Font is not registered. Try running 'fc-cache -fv' or install the font.

---
**Summary:** 0 error(s), 4 warning(s), 33 ok

### Description

## Summary

`DMSService` re-subscribes by dropping and re-dialing its event socket, and it does that on
every change to the server's capability set. The server's `cups` capability reports whether a
CUPS manager is currently instantiated, and that manager is ref-counted by subscriber — so the
redial itself drops the last cups subscriber and immediately re-takes it, which produces two
more capability changes. Under a timing race those land on the freshly dialed connection and it
redials again for what it just did.

The result is a sustained reconnect loop at roughly one reconnect every 200–350 ms, each one
tearing down and rebuilding all ~17 per-connection subscriptions server-side. It ends with:

```
WARN qt.qpa.wayland: eglSwapBuffers failed with 0x300d, surface: 0x7f1549655100
```

(`0x300d` = `EGL_BAD_SURFACE`), and from that point the Settings window can never be mapped
again. `dms ipc call settings open` still returns `SETTINGS_OPEN_SUCCESS`, clicking the bar icon
does nothing visible, and no window with `class: com.danklinux.dms` appears in `hyprctl clients`.
Only restarting dms brings it back.

The rest of the shell is unaffected — the bar, popouts and everything else keep working normally,
in the same process. `SettingsModal` is instantiated in the shell's QML tree
(`quickshell/DMSShell.qml:633`), so it is a separate window but not a separate process, and there
is still exactly one `qs` running afterwards. That fits the error naming a single surface: the
Settings window is the only `FloatingWindow` (xdg_toplevel) in the shell and the only surface
created and destroyed on show/hide, while the bar and popouts are layer-shell surfaces that
persist for the session. From then on that window never maps again for the lifetime of the
process — neither the IPC call nor the bar icon brings it back — and only restarting dms
recovers it.

The reconnect loop is the part diagnosed below with certainty. Its link to the surface loss is
correlation: it immediately precedes the EGL error in every capture, on both 1.5.2 and 1.5.3, and
nothing else in the logs does.

Line references below are against master @ `20aafeb`.

## Environment

- DMS CLI 1.5.3 (Arch system package), also seen on 1.5.2
- Quickshell 0.3.0
- Hyprland 0.56.0
- Arch Linux, Intel i5-9400F, Nvidia GTX 1070 (proprietary 580.173.02), 16 GB RAM

## Root cause, step by step

**1. The Settings window ref-counts the cups subscription.**

`quickshell/Modals/Settings/SettingsModal.qml:128`

```qml
Loader {
active: settingsModal.visible
sourceComponent: Component {
Ref { service: CupsService }
}
}
```

`Ref` (`quickshell/Common/Ref.qml`) does `service.refCount++` on creation and `--` on
destruction, and `CupsService.onRefCountChanged` (`quickshell/Services/CupsService.qml:15`) turns
that into `DMSService.addSubscription("cups")` / `removeSubscription("cups")`. So `cups` is in
`activeSubscriptions` exactly while the Settings window is visible.

**2. Any subscription change re-dials the event socket.**

`quickshell/Services/DMSService.qml:233`

```qml
function subscribe(services) {
activeSubscriptions = services;
if (subscribeConnected) {
subscribeSocket.connected = false;
Qt.callLater(() => { subscribeSocket.connected = true; });
}
}
```

There is no way to amend a subscription in place — the only mechanism is dropping the connection
and dialing a new one, which makes the server tear down and rebuild every per-connection
subscription.

**3. The server's `cups` capability is scoped to the manager's lifetime, not the system.**

`core/internal/server/server.go:205`

```go
func cupsAvailable() bool {
cupsMu.Lock()
defer cupsMu.Unlock()
return cupsManager != nil
}
```

and `server.go:420` puts `cups` in the capability list based on that. The manager is created
lazily on the first cups subscriber and destroyed by `releaseCupsSubscriber()` (`server.go:211`)
when the last one goes away — and that function calls `notifyCapabilityChange()`.

So the capability set encodes "is somebody subscribed to cups right now", and the only client is
the shell itself.

**4. The client re-dials on any capability delta.**

`quickshell/Services/DMSService.qml:305`

```js
const capabilitiesChanged = prevCapabilities.length !== capabilities.length
|| capabilities.some(c => !prevCapabilities.includes(c));
if (prevCapabilities.length > 0 && capabilitiesChanged) {
log.info("Capabilities changed, resubscribing");
subscribe(activeSubscriptions);
}
```

Every subscribe connection is also handed the full capability set up front — `server.go:1202`
writes `getServerInfo()` before it starts draining the event channel — so the client compares
across connections, not just within one.

**5. Steps 2–4 close the loop.**

With Settings open (`cups` in `activeSubscriptions`), one redial does this:

- the old connection dies; its cups goroutine's defer runs `releaseCupsSubscriber()`, the count
drops to 0, the manager is closed, `notifyCapabilityChange()` fires with `cups` **gone**
- the new connection's `handleSubscribe` takes the cups refcount back from 0 to 1, creates a new
manager, `notifyCapabilityChange()` fires with `cups` **back**

Whether those two events reach the new connection is a race. `handleSubscribe` registers the
capability channel at `server.go:514`, near the top:

```go
capChan := make(chan ServerInfo, 64)
capabilitySubscribers.Store(clientID+"-capabilities", capChan)
```

but it does not take the cups refcount until `server.go:874`. If the previous connection's
release lands in that window, the fresh connection receives "cups gone" followed by "cups back",
sees two deltas against the set it was handed at connect time, and re-dials again — for churn it
caused itself.

Each redial can therefore regenerate itself. The loop's period is the cost of destroying and
re-creating the CUPS manager, which is the 200–350 ms cadence in the logs below.

## Why it only reproduces with the Settings window open and real interaction

Two things have to be true at once, and scripted IPC repros never satisfy both:

- **`cups` has to be in `activeSubscriptions`** — i.e. the Settings window has to be visible. That
is the only capability in the set whose presence is a function of the shell's own connection.
- **Something has to fire the first capability change while it is open.** Opening
the window is itself one, but the loop also needs to win the race in step 5, and one attempt
usually does not.

A script that opens and closes Settings returns to the no-cups state each cycle and gives the race
very few attempts. Manual use leaves the window open for minutes, during which any unrelated
capability change re-dials the socket with cups subscribed. Every one of those is another roll of
the dice. That matches the reported behaviour: the number of open/close cycles before the failure
is random, not a fixed count.

For completeness, this is a repro attempt that does **not** trigger it — 20 cycles at each of six
spacings, clean service restart between runs, window survived every time:

```bash
for delay in 0.1 0.2 0.3 0.5 1 2; do
systemctl --user restart dms.service
sleep 3
for i in $(seq 1 20); do
dms ipc call settings open >/dev/null
sleep $delay
dms ipc call settings close >/dev/null
sleep $delay
done
dms ipc call settings open >/dev/null
sleep 1
hyprctl clients | grep -c "class: com.danklinux.dms"
done
```

## Log evidence

Captured with `journalctl --user -u dms -o short-precise` while opening and closing Settings by
hand. Whole sequence, from a clean `systemctl --user restart dms.service` to the failure, took 13
seconds:

```
04:33:54.390 CUPS subscription 311 created
04:33:54.474 DMSService reconnect
04:33:54.475 DMSService reconnect
04:33:54.480 DMSService reconnect
04:33:54.829 DMSService reconnect (+349ms)
04:33:55.168 DMSService reconnect (+339ms)
04:33:55.439 DMSService reconnect (+271ms)
04:33:55.760 DMSService reconnect (+321ms)
04:33:56.040 DMSService reconnect (+280ms)
04:33:56.291 DMSService reconnect (+251ms)
04:33:56.495 DMSService reconnect (+204ms)
04:33:56.803 DMSService reconnect (+308ms)
04:33:57.015 DMSService reconnect (+212ms)
04:33:57.282 DMSService reconnect (+267ms)
04:33:57.415 DMSService reconnect (+133ms)
04:33:57.629 WARN qt.qpa.wayland: eglSwapBuffers failed with 0x300d (EGL_BAD_SURFACE)
04:33:57.629 DMSService reconnect (+441µs)
[no further DMSService reconnect lines; the shell keeps running normally otherwise]
```

Two things to note:

- The initial burst of 3 within 6 ms is the startup capability arrivals — `notifyCapabilityChange()`
is called from ~8 separate goroutines as managers come up (`server.go:1487`, `1507`, `1525`,
`1535`, `1571`, `1620`, `1669`, `1695`). Each one is a full socket redial on the client. That
burst is what ignites the loop.
- After that the cadence is 200–350 ms and steady for ~2.8 s. No human clicks that evenly, and it
is not exponential backoff either (`DankSocket._scheduleReconnect` in dank-qml-common would give 400/800/1600 ms) —
it is the round-trip cost of the CUPS manager being destroyed and rebuilt.

An earlier run on 1.5.2 shows the same shape at a coarser resolution: CUPS subscriptions being
created and cancelled with the gap between them shrinking from ~5 min to 13 s to 3 s, then 8
`DMSService` reconnects in 5 s, then the same `eglSwapBuffers ... 0x300d`.

State collected after the failure, without restarting:

- `dms ipc call settings open` → `SETTINGS_OPEN_SUCCESS`, but `hyprctl clients` shows no window
with `class: com.danklinux.dms`
- exactly one `qs` process, no orphans
- 199 open file descriptors on the `qs` process against a 1024 `ulimit -n`, so this is not FD
exhaustion

## Ruled out

- **Process leaks.** Always exactly one `dms` and one `qs`, never orphans or zombies.
- **FD exhaustion.** 199 of 1024 at the moment of failure.
- **The CUPS subscription churn as a cause.** It is the loop's period, not an independent problem.
- **`backingWindowVisible` / the `show()` and `toggle()` changes from 5854327c.** Those branches
only run when the window is already desynced, and the close-IPC fix they shipped with does work —
`dms ipc call settings close` now removes the window from `hyprctl clients`, and 15 consecutive
IPC opens no longer break anything. This is a separate mechanism that was present in 1.5.2 too.
- **Plugin roster.** Reproduced with two plugins (`vaultManager`, `dankActions`) and with four
(`pacmanWorkspaces`, `ipIndicator`, `vaultManager`, `linuxWallpaperEngine`). A heavier roster
seems to shorten time-to-failure but is not required.

`ERROR: Cannot close destroyed notification qs::service::notifications::Notification(0x...)`
appears several times per session in the journal but does not correlate with the failure.

## Suggested fix

Two independent changes, either of which breaks the loop on its own:

**Server — make the `cups` capability describe the system, not the current manager.** Set a sticky
flag the first time `cups.NewManager()` succeeds and report that from `cupsAvailable()`.
`releaseCupsSubscriber()` should still shut the manager down when nobody is listening, but should
not call `notifyCapabilityChange()` — the manager going away says nothing about whether CUPS is
available. (`cupsSubscriberCount--` there could also use a `> 0` guard: `ensureCupsManager()` from
the `cups.*` request path in `router.go:131` creates a manager without taking a refcount.)

**Client — only re-dial for a capability that was actually missing when this connection was made.**
Snapshot the capability set from the initial `getServerInfo()` event on each subscribe connection,
and re-dial only when a later event adds a capability that is in `activeSubscriptions` and was not
in that snapshot. A capability that flaps underneath the connection then cannot trigger anything.
Coalescing re-dials behind a short timer with a

### Expected Behavior

Settings opens every time it is asked to, for the lifetime of the shell process.

### Steps to Reproduce

Environment note: 1.5.3, Hyprland, single monitor. The trigger is a race, so the
cycle count before it fires is not fixed. Two shapes below; the first is the one
that reproduced fastest here (13 seconds from a clean restart).

Fast path — open Settings while the backend managers are still coming up:

1. systemctl --user restart dms.service
2. In another terminal: journalctl --user -u dms -o short-precise -f
3. Within the first few seconds of the restart, while managers are still
initializing, open Settings by clicking the bar icon.
4. Leave it open and click around the tabs.
5. Watch the journal for DMSService "Connected (API v..." lines. One at startup is
normal. The failure is preceded by a continuous run of them roughly every
200-350 ms for a couple of seconds.
6. That run ends with:
WARN qt.qpa.wayland: eglSwapBuffers failed with 0x300d
7. Close Settings with Escape and try to reopen it:
- clicking the bar icon does nothing
- dms ipc call settings open returns SETTINGS_OPEN_SUCCESS
- hyprctl clients | grep "class: com.danklinux.dms" returns nothing
The rest of the shell keeps working normally.
8. systemctl --user restart dms.service brings Settings back.

Slow path — normal daily use:

Open and close Settings by hand over a session, leaving it open for a minute or
two at a time. It fires after a random number of cycles, anywhere from a few to
many minutes of use. Same log signature, same end state.

Does NOT reproduce with dms ipc call alone, at any timing. Twenty open/close
cycles at each of 0.1s, 0.2s, 0.3s, 0.5s, 1s and 2s spacing, with a clean service
restart between runs, never failed once.

### Error Messages/Logs

_No response_

### Screenshots/Recordings

_No response_

Contributor guide

Open the contributing guide

Research direction

Start with quickshell/Services/DMSService.qml, especially subscribe() and capability-change handling, then trace the CUPS lifecycle in core/internal/server/server.go and the Settings Ref in quickshell/Modals/Settings/SettingsModal.qml. Reproduce with Settings open while watching the user service logs and the provided IPC loop. Done means capability changes no longer cause self-sustaining reconnect churn and the Settings window can map again after the failure.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
backend, desktop
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.