diff --git a/config/cosmic.conf b/config/cosmic.conf index 11ccd2c..fd2d56d 100644 --- a/config/cosmic.conf +++ b/config/cosmic.conf @@ -28,7 +28,9 @@ $mainMod = SUPER # 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. -# 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, slash, exec, rofi -show drun bind = $mainMod, A, exec, rofi -show drun diff --git a/docs/superpowers/specs/2026-08-09-hyprcosmic-design.md b/docs/superpowers/specs/2026-08-09-hyprcosmic-design.md index 3eeb47f..54ecf9d 100644 --- a/docs/superpowers/specs/2026-08-09-hyprcosmic-design.md +++ b/docs/superpowers/specs/2026-08-09-hyprcosmic-design.md @@ -251,7 +251,7 @@ the required state. Unlocks waybar's `wlr/taskbar`. Plausibly upstreamable. **Re ### Patch B — Hyprland-compatible IPC socket 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). - Requests: `workspaces`, `activeworkspace`, `activewindow`, `clients`, `monitors` diff --git a/docs/unreproducible-dead-input-2026-08-10.md b/docs/unreproducible-dead-input-2026-08-10.md new file mode 100644 index 0000000..bdfed01 --- /dev/null +++ b/docs/unreproducible-dead-input-2026-08-10.md @@ -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//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//exe` first. diff --git a/tools/verify-hypr-ipc.py b/tools/verify-hypr-ipc.py new file mode 100755 index 0000000..8350d00 --- /dev/null +++ b/tools/verify-hypr-ipc.py @@ -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())