Check the IPC from outside, and write down the input bug we did not solve

tools/verify-hypr-ipc.py exercises the fork's Hyprland IPC the way a client
reaches it, which the compositor's own log cannot show you. It checks the
socket names, that the five read commands answer JSON, that `bogus`,
`dispatch exec rofi`, `dispatch killactive`, `dispatch workspace +1` and
`dispatch workspace 0` are all refused, and that a real switch returns `ok`.
It moves the focused workspace, so it returns to the one you started on.

It is a validated negative test, not a hopeful one: run against the old
compositor before the socket rename was installed, it failed on the names and
exited 1.

docs/unreproducible-dead-input-2026-08-10.md records the session that came up
with no keyboard or pointer at all, and did not come back after a reboot. It
is closed deliberately rather than fixed, and most of its value is the list of
things it is not -- the fork's patches, `seats.for_device()` returning None,
the modifier-only Super binding, a shortcuts-config race, and two scary log
lines that stock COSMIC prints too. The one suspicious fact is that it was the
fourth compositor start on that boot. If it recurs, there is a list of what to
collect before rebooting destroys it.

The design spec said `.socket` and `.socket2`; corrected to the names clients
actually open.

cosmic.conf's bare-Super binding gets a comment saying why the key field is
empty, since an empty field in a `bind` line reads like a typo. COSMIC
supports modifier-only bindings and Hyprland's `bind` cannot express one.
This commit is contained in:
2026-08-10 12:59:04 +07:00
parent b89e4be10a
commit b8b5ce546d
4 changed files with 239 additions and 2 deletions
+3 -1
View File
@@ -28,7 +28,9 @@ $mainMod = SUPER
# key, which cosmic-comp merges over `defaults`, so the system file is not # key, which cosmic-comp merges over `defaults`, so the system file is not
# touched and reverting is a matter of deleting these lines and re-applying. # touched and reverting is a matter of deleting these lines and re-applying.
# Bare Super, exactly where stock COSMIC puts its launcher. # Tap Super on its own to open the launcher.
# The key field is deliberately empty: COSMIC supports
# modifier-only bindings, which Hyprland's `bind` cannot express.
bind = $mainMod, , exec, rofi -show drun bind = $mainMod, , exec, rofi -show drun
bind = $mainMod, slash, exec, rofi -show drun bind = $mainMod, slash, exec, rofi -show drun
bind = $mainMod, A, exec, rofi -show drun bind = $mainMod, A, exec, rofi -show drun
@@ -251,7 +251,7 @@ the required state. Unlocks waybar's `wlr/taskbar`. Plausibly upstreamable. **Re
### Patch B — Hyprland-compatible IPC socket ### Patch B — Hyprland-compatible IPC socket
Implement a subset of Hyprland's IPC at Implement a subset of Hyprland's IPC at
`$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket` (request/response) and `.socket2` `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock` (request/response) and `.socket2.sock`
(event stream). (event stream).
- Requests: `workspaces`, `activeworkspace`, `activewindow`, `clients`, `monitors` - Requests: `workspaces`, `activeworkspace`, `activewindow`, `clients`, `monitors`
@@ -0,0 +1,88 @@
# A session that came up with no input, once, on 2026-08-10
**Status: not root-caused. Not reproduced since. Closed deliberately, not fixed.**
This is written down because the next person to hit it -- probably us -- will
otherwise start the same investigation from scratch, and because most of the
value here is the list of things it is *not*.
## What happened
One HyprCosmic session came up with a working bar and a blank desktop below it,
and no keyboard or pointer input reached the compositor at all. No binding
fired. The session had to be left via a VT switch.
After a reboot, the same configuration and the same binaries came up fine.
Super+Return, Super+A and a bare Super tap were all confirmed working by the
user, with independent evidence from a watcher process:
```
12:07:48 SPAWN pid=6264 exe=/usr/bin/rofi cmd=rofi -show drun
12:07:50 SPAWN pid=6433 exe=/usr/bin/rofi cmd=rofi -show drun
12:07:51 EVENT activewindow>>com.system76.CosmicTerm,dingo@fedora:~ - COSMIC Terminal
```
Super+Return shows as a window event rather than a spawn because cosmic-term is
single-instance: the binding fired, the existing process took the request.
## Ruled out, with the evidence
Each of these was a live hypothesis that turned out to be wrong. They are listed
so nobody re-runs them.
- **The fork's own patches.** The blank screen was a separate bug entirely (a
missing `awww img` call in autostart -- the daemon was running and drawing
nothing, so nothing reported a wallpaper missing). Patch B's IPC answered
queries correctly throughout. Neither patch touches input.
- **Events dropped by `seats.for_device()` returning `None`.** This does drop
input silently (`src/input/mod.rs:208-215`), which made it an attractive
theory. But input demonstrably works on the same build, so whatever the fault
was, it was not a permanent property of this code.
- **The modifier-only Super binding swallowing Super+Return.** It cannot. The
match loop at `src/input/mod.rs:1915-1955` sets `modifiers_shortcut_queue` on
press and fires on release, and critically it *does not early-return*, so a
modifier-only binding cannot consume a normal binding sharing its modifier.
- **A shortcuts-config race at login.** The config's mtime was 08:49; the
session started at 09:59:46. Nothing was being written during startup.
- **`Error reading from session socket` and `Unable to become drm master`.**
Both appear in the log. Both also appear in stock COSMIC logins on this
machine, so neither is a fork symptom. (An earlier claim in this project that
the DRM message was absent post-reboot was wrong; it is present twice, for
PID 1609.)
## The one thing that is suspicious
The broken session was the **fourth** compositor start on that boot: 08:06 (from
`target/debug`), 08:08, 08:19, and 09:59. Every other session on that boot, and
every session since a reboot, has been fine.
That points at accumulated per-boot session/seat state -- a previous compositor
not having fully released its seat, or logind still holding devices for a
session that had gone away -- rather than at anything in the configuration or
the code. It is a guess. It was not confirmed, and confirming it would mean
deliberately cycling compositors on a live desktop.
## If it happens again
Collect *before* rebooting, because a reboot destroys the only evidence:
1. `loginctl list-sessions` and `loginctl session-status` for each -- look for
more than one active session, or a session in state `closing`.
2. `ls -l /dev/input/by-path/` and whether the compositor's PID holds any of
them open (`ls -l /proc/<pid>/fd | grep event`).
3. The session log with `RUST_LOG=cosmic_comp::input=trace`. The logger honours
`RUST_LOG` via `EnvFilter::try_from_default_env()` before adding its own
`cosmic_comp={warn|debug}` directives, and those are less specific, so the
trace directive wins.
4. Whether `libinput debug-events` (as root, on a VT) sees the devices at all.
That splits the fault cleanly: if libinput sees nothing, it is below the
compositor and nothing in this repo can be the cause.
Do **not** try to clean up by name-matching processes. `pkill cosmic-*` and
friends have twice killed this user's live desktop. Kill by a PID captured at
spawn, or a process group after `setsid`, and confirm with
`readlink /proc/<pid>/exe` first.
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Check that the fork's Hyprland IPC is reachable the way a real client reaches it.
Run this inside a HyprCosmic session after installing a new cosmic-comp. It
exercises the two things that are easy to get wrong and impossible to see from
the compositor's own log:
1. The socket *names*. Every Hyprland client -- waybar's hyprland/* modules,
hyprctl, eww, ags -- opens `$XDG_RUNTIME_DIR/hypr/$HIS/.socket.sock` and
gives up if it is absent. waybar reports it once, at startup, as
"Couldn't connect to ... (3)" and then disables the module, so a bar with
a silently missing workspace widget is the only symptom you get.
2. The dispatch (write) endpoint, which is what makes clicking a workspace on
the bar actually switch to it rather than just look clickable.
Nothing here changes configuration. `dispatch workspace` moves the focused
workspace, which is runtime state, so this script does disturb what you are
looking at: it returns to the workspace you started on when it finishes.
Exit status is 0 only if every check passed.
"""
import os
import socket
import sys
RESET, RED, GREEN, DIM = "\033[0m", "\033[31m", "\033[32m", "\033[2m"
failures = []
def result(ok: bool, label: str, detail: str = "") -> bool:
mark = f"{GREEN}ok{RESET}" if ok else f"{RED}FAIL{RESET}"
print(f" [{mark}] {label}")
if detail:
for line in str(detail).splitlines():
print(f" {DIM}{line}{RESET}")
if not ok:
failures.append(label)
return ok
def socket_dir() -> str:
runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
base = os.path.join(runtime, "hypr")
if not os.path.isdir(base):
print(f"{RED}No {base}. Is this a HyprCosmic session?{RESET}")
sys.exit(2)
sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
if not sig:
# Newest instance, so this still works from a terminal that predates it.
entries = sorted(
(e for e in os.listdir(base) if os.path.isdir(os.path.join(base, e))),
key=lambda e: os.stat(os.path.join(base, e)).st_mtime,
)
if not entries:
print(f"{RED}No instance directory under {base}.{RESET}")
sys.exit(2)
sig = entries[-1]
print(f"{DIM}HYPRLAND_INSTANCE_SIGNATURE unset; using newest: {sig}{RESET}")
return os.path.join(base, sig)
def request(path: str, payload: str, timeout: float = 2.0) -> str:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
s.connect(path)
s.sendall(payload.encode())
chunks = []
while True:
data = s.recv(8192)
if not data:
break
chunks.append(data)
return b"".join(chunks).decode(errors="replace")
def main() -> int:
d = socket_dir()
req_path = os.path.join(d, ".socket.sock")
evt_path = os.path.join(d, ".socket2.sock")
print(f"\ninstance dir: {d}\n")
print("socket names (the names clients actually open)")
have_req = result(os.path.exists(req_path), ".socket.sock exists")
result(os.path.exists(evt_path), ".socket2.sock exists")
stale = [n for n in (".socket", ".socket2") if os.path.exists(os.path.join(d, n))]
result(not stale, "no unsuffixed leftovers", ", ".join(stale) if stale else "")
if not have_req:
print(f"\n{RED}Request socket missing; cannot go further.{RESET}")
print("An old cosmic-comp is probably still running -- the rename only")
print("takes effect for a session started after installing the binary.")
return 1
print("\nread endpoints")
active = None
for cmd in ("workspaces", "activeworkspace", "activewindow", "clients", "monitors"):
try:
reply = request(req_path, f"j/{cmd}")
ok = reply.strip().startswith(("{", "["))
result(ok, f"j/{cmd}", "" if ok else f"unexpected reply: {reply[:200]}")
if cmd == "activeworkspace" and ok:
import json
active = json.loads(reply).get("id")
except OSError as e:
result(False, f"j/{cmd}", e)
print("\nunknown commands are refused, not guessed at")
for cmd in ("bogus", "dispatch exec rofi", "dispatch killactive",
"dispatch workspace +1", "dispatch workspace 0"):
try:
reply = request(req_path, cmd).strip()
# An unparsed request gets no useful answer; what matters is that it
# is not silently treated as something else.
result(not reply.startswith("ok"), f"refuses {cmd!r}", f"reply: {reply[:120]}")
except OSError as e:
result(False, f"refuses {cmd!r}", e)
print("\nwrite endpoint")
if active is None:
result(False, "know the current workspace to return to")
else:
target = 2 if active != 2 else 1
try:
reply = request(req_path, f"dispatch workspace {target}").strip()
result(reply == "ok", f"dispatch workspace {target}", f"reply: {reply[:120]}")
back = request(req_path, f"dispatch workspace {active}").strip()
result(back == "ok", f"back to workspace {active}", f"reply: {back[:120]}")
except OSError as e:
result(False, "dispatch workspace", e)
print()
if failures:
print(f"{RED}{len(failures)} check(s) failed:{RESET}")
for f in failures:
print(f" - {f}")
return 1
print(f"{GREEN}All checks passed.{RESET}")
return 0
if __name__ == "__main__":
sys.exit(main())