1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
|
# Desktop-shell / panel support - TODO
Findings from trying to run a GTK4 + gtk4-layer-shell panel (AGS/Astal) on srdwm.
Everything below was measured against the **running binary built from this
worktree at 2026-08-12 17:32**, not read out of the source - re-verified after
that rebuild, so it is current, and reproduced both on the real session and on a
nested `srdwm --wayland`.
The C++ tree on `main` is stale and misleading for this (its
`wayland_platform.cc` has `layer_shell_` commented out while the live binary
advertises `zwlr_layer_shell_v1` v4) - ignore it.
## What srdwm advertises today (16 globals)
```
ext_session_lock_manager_v1 (v1) wl_compositor (v5)
wl_data_device_manager (v3) wl_output (v4)
wl_seat (v9) wl_shm (v2)
wl_subcompositor (v1) wp_fractional_scale_manager_v1 (v1)
wp_viewporter (v1) xdg_wm_base (v6)
zwlr_data_control_manager_v1 (v2) zwlr_layer_shell_v1 (v4)
zwlr_screencopy_manager_v1 (v2) zwp_primary_selection_device_manager_v1 (v1)
zxdg_decoration_manager_v1 (v1) zxdg_output_manager_v1 (v3)
```
Layer-shell v4, session lock, data-control and xdg-output is a good base.
---
## P0 - the panel cannot stay connected
### 1. FIXED - smithay posted `invalid_size` on a commit after role destroy
**Root cause (smithay 0.7.0, upstream) and the local workaround, both verified
end to end on 2026-08-12 19:28. A GTK4 panel now runs on srdwm.**
#### What was wrong
`src/wayland/shell/wlr_layer/handlers.rs:125` registers a **pre-commit hook** on
the `wl_surface` at `get_layer_surface` time. That hook runs on every subsequent
commit of that surface, forever, and validates the layer-shell cached state:
```rust
if pending.size.w == 0 && !pending.anchor.anchored_horizontally() {
guard.surface.post_error(Error::InvalidSize,
"width 0 requested without setting left and right anchors");
}
```
When the client destroys the layer surface, smithay `.reset()`s
`LayerSurfaceCachedState` to `Default` (size `0x0`, no anchors) rather than
removing it, and does not unregister the hook. So the next commit on that
`wl_surface` validates the reset state, fails, posts a protocol error, and the
connection dies.
gtk4-layer-shell does create -> commit -> destroy -> commit as part of normal
setup, which is why every GTK4 panel died and waybar (one surface, never
destroyed) did not. Nothing to do with `wl_output` being named or nil.
#### Why it could not be fixed behind the hook
- Smithay's hook is registered at `get_layer_surface`, *before* our
`new_layer_surface` callback runs.
- `destroyed()` calls our `layer_destroyed` and then resets the cached state
unconditionally, so anything set there is overwritten.
- Smithay discards the `HookId` from its own `add_pre_commit_hook`, so its hook
cannot be removed.
#### The fix that works: get in front of it
Three facts from the 0.7.0 source make it possible:
1. `add_pre_commit_hook` / `remove_pre_commit_hook` are public API.
2. Hooks are a `Vec`, **pushed and iterated in registration order**
(`tree.rs`: `pre_commit_hooks.push(hook)`, `for hook in hooks`).
3. `CompositorHandler::new_surface` fires from `wl_compositor::CreateSurface`,
strictly before any `get_layer_surface` on that surface.
So a hook registered in `new_surface` sits at index 0 and runs *before*
smithay's. Implemented in `crates/wayland/src/{protocols,state}.rs`:
- `CompState` gains `dead_layer_surfaces: HashSet<WlSurface>`.
- `layer_destroyed` inserts the surface.
- `new_surface` registers a per-surface pre-commit hook that is a no-op unless
the surface is in that set, in which case it nudges `pending.size.w`/`.h` from
0 to 1 on whichever axis is unanchored - making the state self-consistent
before smithay validates it.
**Caveat, keep the comment in the code:** this depends on hook execution being
registration order. That holds in 0.7.0 and it is a plain `Vec`, but it is not a
documented API guarantee - a future smithay could reorder and this would silently
stop working. Pin the version, and remove this once upstream fixes the root bug.
0.7.0 (2025-06-24) is the newest release on crates.io, so there is nothing to
bump to today.
#### Verification
Against the exact binary (sha256 `7faf27f1...`):
```
minimal repro (lsmin.py, raw protocol, no GTK):
3 layer surfaces, no destroy SURVIVED
create, destroy, create SURVIVED
create, destroy, RECOMMIT surface, create SURVIVED <- was CONNECTION KILLED
invalid_size errors posted: 0
```
AGS end to end on a nested srdwm: **runs, 0 errors, all 5 gtk4-layer-shell
surfaces created (3 nil / 2 named output), 13 configures sent, 5 buffers
attached, still alive after 60s.** Screenshot confirms the bar and dock actually
render - clock, resources graph, tray, power button, dock icons. The workspace
pill is correctly absent, since srdwm exposes no workspace protocol (see P1).
### 2. Send a protocol error instead of closing the socket silently
When the connection does go, there is **no `wl_display.error` event at all** - the
socket simply closes. From the client that is indistinguishable from a compositor
crash. `wl_resource_post_error` (or smithay's equivalent) on any fault would have
turned this whole investigation into one log line.
### 3. FIXED - No dmabuf - every client was forced through shm
Neither `zwp_linux_dmabuf_v1` nor `wl_drm` was advertised, so no client could hand
over a GPU buffer. GTK4 defaults to its Vulkan renderer and died:
```
vkGetPhysicalDeviceSurfaceFormatsKHR(): A surface is no longer available. (VK_ERROR_SURFACE_LOST_KHR)
libEGL warning: failed to get driver name for fd -1
libEGL warning: MESA-LOADER: failed to retrieve device information
```
`GSK_RENDERER=cairo` was required for any GTK4 client to get as far as the
layer-shell hang above. On a 4-core / 3.8GB laptop, pushing every client
through software rendering is a large permanent cost.
**Fixed**: `zwp_linux_dmabuf_v1` v3 (`DmabufState::create_global`, `crates/wayland/src/protocols.rs`'s
`DmabufHandler` impl, `delegate_dmabuf!`). The non-obvious part is that this works
at all on the udev backend despite it rendering entirely in software
(`PixmanRenderer`, no GPU pipeline - see `udev.rs`'s module doc comment):
smithay's `PixmanRenderer` already implements `ImportDma`, importing a dmabuf by
mmap'ing it and reading the pixels directly, which only works for the
`Linear` modifier - exactly the one `PixmanRenderer::dmabuf_formats()`
advertises. A client (GTK4 included) still allocates through its own
EGL/gbm path against the real DRM render node - untouched by this
compositor either way - and hands over a Linear-modifier dmabuf, which
pixman reads straight off with no GPU involvement on the compositor side.
`dmabuf_imported` eagerly validates by actually importing on the udev
backend (`self.udev`'s `PixmanRenderer` is reachable from `CompState`
directly); the winit (nested/dev) backend's `GlesRenderer` lives outside
`CompState` (a sibling field on `WaylandPlatform`) and isn't reachable the
same way, so buffers are accepted there without eager validation and
imported lazily the first time they're actually rendered, same as every
other buffer type. Went with v3 (`create_global`, a plain format list)
over v4's `..._with_default_feedback` (which needs a `main_device` `dev_t`
to steer multi-GPU clients toward the right render node) - a real gap
worth closing later, not required for a single-GPU client to allocate and
hand over a Linear-modifier buffer, which is all this backend can use
regardless. Live-verified in an isolated nested instance (`SRDWM_NESTED=1`,
so the session's real autostart never fired): `zwp_linux_dmabuf_v1` version
3 shows up in a real client's (`wezterm`) registry bind over
`WAYLAND_DEBUG=1`, and the client connects and maps a window normally with
no protocol error or crash. Actually handing over a GPU-allocated dmabuf
buffer end to end (GTK4 with `GSK_RENDERER` unset, in particular) is not
yet re-verified live - this development sandbox has no working `/dev/dri`
render node to allocate one against (`eglGetPlatformDisplay: EGL_BAD_ALLOC`
even on the winit backend's own `GlesRenderer`), so that retest needs the
user's real machine.
---
## P1 - protocols a bar needs, none of them workaroundable client-side
The data never leaves the compositor, so a panel simply cannot show these.
Current `delegate_*` set in `crates/wayland/src/`: compositor, data_control,
data_device, fractional_scale, layer_shell, output, primary_selection, seat,
session_lock, shm, viewporter, xdg_decoration, xdg_shell, xwayland_shell.
- [x] DONE - **`zwlr_foreign_toplevel_management_v1`**'s `app_id`/`title` were
empty on the wire (state/activated worked) - live-verified by a peer
session's raw protocol capture against a running dock. Root cause:
`new_toplevel` fires at `xdg_surface.get_toplevel()` (role
assignment), which happens before a client's `set_title`/`set_app_id`/
first commit for essentially every real client, not racily but every
single time - so `new_managed_window` read `XdgToplevelSurfaceData`
before either was ever set. Fixed by re-reading both on every
`commit()` (`sync_toplevel_metadata` in `state.rs`) and re-announcing
to foreign-toplevel listeners when either changed.
- [x] DONE (not yet live-verified against AGS) - **`zwlr_foreign_toplevel_management_v1`**
- enumerate windows with title/app_id/state, and activate/close/
maximize/minimize them. Went with the wlr protocol over the newer
`ext_foreign_toplevel_list_v1` + a separate management protocol:
smithay 0.7 only has a built-in helper for the `ext_` list-only half,
but the wlr one covers enumeration *and* interactivity in a single
interface, which is what was actually asked for (see the downstream
report this was scoped against: "click-to-focus in the dock, the
focused-app highlight, alt-tab actually switching, middle-click-to-
close" - none of that is possible from list-only enumeration alone).
Hand-written against `wayland-protocols-wlr`'s raw server bindings,
same pattern as `screencopy.rs` (`crates/wayland/src/foreign_toplevel.rs`).
Override-redirect windows are deliberately never announced, matching
`_NET_CLIENT_LIST`/ICCCM. `Activated` state is kept live on every
focus change (the one thing that actually needed to be, per the
report above).
DONE - maximize/fullscreen/minimize changes that originate from the
pointer (titlebar button, double-click) or from a client's own
request (`xdg_toplevel`'s `set_maximized`/`unset_maximized`/
`set_fullscreen`/`unset_fullscreen`/`set_minimized`, and the matching
X11/XWayland WM hints) now all call `foreign_toplevel::send_state`,
so a dock/panel sees the change regardless of which of those three
triggered it.
FIXED - the fourth trigger, a *compositor keybinding*
(`srd.window.maximize()`/`.fullscreen()`/`.minimize()` from the Lua
config), did not re-broadcast: `crates/config` only ever holds a
`WindowManager` reference, never `CompState`, so it cannot reach
`foreign_toplevel::send_state` directly. Went with the periodic-diff
option this entry named as the alternative to a callback: `CompState::
tick_dirty_broadcasts` (called once a frame from both backends'
`poll_events`, same cadence as the animation tween's `tick_animations`)
diffs `maximized`/`minimized`/`fullscreen` per window against what was
last sent and re-broadcasts anything that changed, regardless of which
call site changed it - catches this gap and any future one the same
way, rather than one more call site to remember. See
`foreign_toplevel::broadcast_dirty_state`'s doc comment.
- [x] DONE - **`ext_workspace_manager_v1`** - enumerate workspaces,
which is active, and switch. This list entry was stale: the whole
protocol (`crates/wayland/src/workspace.rs`) was already implemented
and wired into both backends' globals - one flat `ext_workspace_
group_handle_v1` shared by every output (srdwm has no per-output
workspace concept), `activate` requests routed to `switch_workspace`,
`create_workspace`/`remove`/`assign`/`deactivate` deliberately not
advertised as capabilities (srdwm's workspace set is fixed at
startup). What *was* a real gap, found auditing every `switch_
workspace` call site the same way `foreign_toplevel`'s broadcast gap
was found: the `SUPER+scroll` workspace-cycle gesture in `input.rs`
called `switch_workspace` directly without calling `workspace::
broadcast_active_workspace` afterward, so a dock's workspace pill
only ever updated when a switch came through this protocol's own
`activate` request, going stale the moment the scroll gesture (or the
`srd.workspace.next()`/`.prev()`/`.switch()` Lua API, which has the
identical gap for the identical `crates/config`-has-no-`CompState`
reason `foreign_toplevel`'s keybinding gap does) changed it instead.
Fixed the `input.rs` scroll-gesture call site directly; the Lua-API
call sites are covered by the same `tick_dirty_broadcasts` fix as the
maximize/fullscreen/minimize keybinding gap above - `workspace::
broadcast_dirty_active` diffs the current workspace id once a frame
and only re-broadcasts on an actual change (unlike the foreign-
toplevel diff, calling `broadcast_active_workspace` unconditionally
would be real protocol traffic every frame, not a cheap comparison,
so this gates it first).
- [x] FIXED (not yet re-verified live) - **`zwlr_screencopy_manager_v1`: `grim`
hangs on the DRM/udev session but WORKS on the nested winit backend.**
Root cause found by reading `render_udev_frame` in
`crates/wayland/src/udev.rs`: it drains *all* of
`CompState::screencopy_pending` once per call and services the whole
batch against whichever head is first in that pass's `ready` list (heads
with a page-flip in flight are filtered out of `ready`). If `ready` is
empty on a given call - every head mid-flip - the drained captures were
dropped at function exit with neither `ready` nor `failed` ever sent,
which is indistinguishable from a hang to the client. Two related bugs
fixed together: (1) `PendingCapture`/`FrameData` now carry the `Output`
the capture actually targets, and captures are partitioned per-head
inside the loop instead of all going to the first head (also fixes
multi-monitor captures reading the wrong screen's framebuffer); (2)
anything left unserviced after the loop - wrong head not ready this
pass, or the two earlier-return paths (`udev` not yet initialized,
session inactive after a VT switch) - is put back into
`screencopy_pending` instead of dropped, so the next call gets another
chance rather than the client waiting forever. Needs a `grim` retest
against the real DRM session to confirm.
- [x] DONE - **`xdg_activation_v1`** - so a launcher can raise the app it just
spawned instead of it opening unfocused behind everything.
`XdgActivationState` + `XdgActivationHandler` (`crates/wayland/src/protocols.rs`),
`delegate_xdg_activation!`. No token bookkeeping of our own - the
default `token_created` already accepts every token, fine for a
single-user session with no cross-client trust boundary to enforce.
`request_activation` maps the activating surface to a `WindowId` via
`surface_to_id` and reuses the same `focus_window` path a dock's
foreign-toplevel "activate" request already goes through. A no-op,
not an error, if the surface isn't tracked yet (activation racing
ahead of the window's own mapping) - the protocol doesn't require
honoring every activation. Live-verified in an isolated nested
instance: `xdg_activation_v1` version 1 advertises correctly, a real
client connects and maps normally, no crash.
---
## P2 - full-desktop quality of life
- [x] DONE (built, tested, installed; not yet live-verified against a real
lock daemon) - **`ext_idle_notify_v1` + `zwp_idle_inhibit_manager_v1`**
- idle lock, and inhibiting it during video playback. Both use
smithay's own built-in modules; see `docs/IMPLEMENTATION_STATUS.md`
for the full writeup, including the one real gap (not workspace-
visibility-aware) and the winit-backend calloop-loop wrinkle this
needed to work around.
- [x] DONE (built, tested, live-verified end to end in a nested instance
against a real protocol client) - **`zwlr_output_management_v1`** -
display arrangement/scale from a settings panel. `crates/wayland/src/
output_management.rs`. Not `Option`-gated like DPMS/gamma-control --
enumeration and applying position/scale/transform work identically
on both backends via `Output::change_current_state`. Deliberately
does not support disabling/enabling a head or switching resolution/
refresh rate (real DRM mode-setting, hardware-dependent work this
pass didn't attempt) - both fail honestly (`failed` event) rather
than silently no-op. Live-verified with a `pywayland`-based test
client generated from the real protocol XML: binds the manager,
receives correct `head`/`mode` events (name, description, make,
model, 8 accumulated modes, current mode, position, transform,
scale) matching the running nested instance; `create_configuration`
-> `enable_head` -> `set_position(777, 333)` -> `apply()` returns
`succeeded` and the head's position genuinely changes (confirmed via
a pushed `position` event on the same handle); `disable_head` ->
`apply()` correctly returns `failed` rather than silently doing
nothing.
- [x] DONE (built, tested; not yet live-verified against real hardware --
see `docs/IMPLEMENTATION_STATUS.md`) - **`zwlr_output_power_management_v1`**
- DPMS on/off per output, for an idle daemon or settings panel.
`crates/wayland/src/output_power.rs`, udev/DRM backend only.
- [x] DONE (built, tested; not yet live-verified against real hardware --
see `docs/IMPLEMENTATION_STATUS.md`) - **`zwlr_gamma_control_manager_v1`**
- night light / `gammastep`/`wlsunset`. `crates/wayland/src/
gamma_control.rs`, udev/DRM backend only.
- [ ] `zwlr_virtual_pointer_manager_v1` + `zwp_virtual_keyboard_manager_v1` -
required by `ydotool` and by any automated UI testing.
- [x] DONE - **`wp_cursor_shape_manager_v1`** - `protocols.rs`'s
`TabletSeatHandler` doc comment and `delegate_cursor_shape!(CompState)`
confirm this was already wired up (routes into `SeatHandler::
cursor_image`, same path a client-drawn cursor surface uses); this
list entry had simply never been updated. What actually needed work
was `cursor.rs`'s *art*, not the protocol - see `IMPLEMENTATION_
STATUS.md`'s entry on the new crosshair/move/pointer-hand bitmaps.
- [ ] `zwp_pointer_constraints_v1` + `zwp_relative_pointer_manager_v1` - games.
- [ ] `wp_presentation` - accurate frame timing.
- [x] DONE (built, tested; not yet live-verified against a real IME) -
**`zwp_text_input_manager_v3` + `zwp_input_method_v2`** - IME (fcitx5,
ibus: CJK/dead-key composition, emoji picker). Both are smithay
built-ins (`TextInputManagerState`/`InputMethodManagerState`),
registered in `state.rs`/`udev.rs`/`winit.rs`; `InputMethodHandler`
impl in `protocols.rs`. Focus tracking needed no wiring at all --
`CompState::KeyboardFocus` is a plain `WlSurface`, and smithay's own
blanket `impl KeyboardTarget for WlSurface` already drives `seat.
text_input()`/`seat.input_method()` from inside `enter`/`leave`,
which `set_keyboard_focus`'s existing `keyboard.set_focus(...)` call
already triggers. The IME's own composition/candidate popup is
tracked as a `PopupKind::InputMethod` in the same `PopupManager`
that already owns every `xdg_popup` - `elements.rs`'s
`popup_render_elements` renders both kinds identically, so the popup
actually draws rather than existing invisibly.
- [ ] `wp_single_pixel_buffer_v1`, `xdg_foreign_v2`.
---
## P3 - integration, not protocol
- [x] DONE - **An IPC/CLI.** `srd` (`crates/ctl/`) talks to a
per-display Unix socket (`$XDG_RUNTIME_DIR/srdwm-<display>.sock`,
`crates/platform/src/ipc.rs`'s `IpcServer` - moved out of
`crates/wayland` since it never touched anything Wayland-specific,
so both backends can share one implementation - polled once per
tick in all three backends now) with a JSON protocol: `clients`
(id/app_id/title/workspace/focused/minimized/visible/scratchpad/
floating/x/y/width/height per window - `floating` added for an AGS
dock's auto-hide overlap check, which needs to tell a layout-placed
tiled window flush against its reserved edge from a user-dragged
floating one actually overlapping it; geometry alone can't
distinguish the two), `dispatch <toggle_visibility|focus|
close> <id>`, and now `subscribe` - a full event-stream like `niri
msg -j`'s: the one connection stays open and gets pushed a fresh
`clients` snapshot every time the window list actually changes,
instead of a client having to poll and diff it themselves. This was
the single highest-leverage gap found comparing srdwm's IPC against
sway/Hyprland/i3/bspwm's own (all of which already have this) --
an AGS peer session had hit exactly this wall building a Python
helper to poll raw `wlr-foreign-toplevel` instead of using this
socket at all. Live-verified end to end against a real client
(Alacritty) in a nested instance. **X11 previously had no IPC server
of any kind** - the crate move fixed that too, along with
`X11Platform::poll_events` used to block indefinitely on
`wait_for_event()`, which would have left the socket unresponsive
between X11 events; now a bounded `poll(2)` (~16ms).
- [x] DONE (not yet live - built and installed, pending restart) - **Set
`XDG_CURRENT_DESKTOP=srdwm`** for the session. Set in `udev.rs`
(deliberately not `winit.rs`, since that's the nested/dev backend) --
no longer inherited from whatever compositor the session was
previously started under.
- [x] DONE (already correct, no change needed) - **Remove runtime sockets
on exit.** `IpcServer`'s `Drop` impl already removes its socket path
unconditionally; verified while auditing `foreign_toplevel`/protocol
cleanup paths this pass.
- [x] FIXED - `jq: parse error: Invalid numeric literal at line 1, column 28`
repeated continuously in `~/.local/state/wm-session-latest.log` --
`hyprctl`'s own "not running" message goes to stdout, not stderr, so
every script that still called it unconditionally after the
Hyprland-to-srdwm migration fed that sentence straight into `jq` as
if it were JSON. Fixed with an `[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]`
guard added to `~/.scripts/sys/night-light`, `reading-mode`, and
`~/.scripts/utils/toggle-blur`, `move_terminal` - confirmed present
in all four as of 2026-08-19.
---
## How to reproduce
```sh
# nested, so it doesn't disturb a running session
WAYLAND_DISPLAY=wayland-0 srdwm --wayland &
# panel, forced onto the software renderer (see P0.3)
WAYLAND_DISPLAY=wayland-1 GSK_RENDERER=cairo WAYLAND_DEBUG=1 ags run 2>&1 | tee trace.log
# the whole bug in one number - this prints 0, and must not:
grep -E '^\[[0-9]' trace.log | grep -v ' -> ' | grep -c layer_surface
```
For comparison, the same panel on niri: runs clean with zero errors and three
mapped `gtk4-layer-shell` surfaces.
|