diff --git a/bin/omarchy-crash-watch b/bin/omarchy-crash-watch index ee1ed82d..09dd32d3 100755 --- a/bin/omarchy-crash-watch +++ b/bin/omarchy-crash-watch @@ -71,11 +71,28 @@ journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | name=$comm [[ $exe == /* ]] && name=${exe##*/} + # A process can set its own comm to anything prctl takes, slashes included, + # and a crash with no recorded executable falls back to it. The mute below + # turns this name into a path, so keep it one component: a crash must not + # reach a flag outside crash-ignore/, nor have a diagnosis write one there. + name=${name##*/} + + # What that leaves is not always a name. "/" leaves nothing, which is no + # kind of array subscript and no kind of toast, and a dot component names a + # directory rather than a flag, so a mute on it would touch that directory + # and then never match. + [[ -n $name && $name != "." && $name != ".." ]] || name=unknown + [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue # Never announce our own machinery, or it notifies about itself. [[ $name == omarchy-crash-* || $name == omarchy-agent-* ]] && continue + # Muted at the end of a diagnosis, when the user was offered it and said + # yes. A flag per program rather than one list, so it un-mutes with + # `omarchy-toggle crash-ignore/ off` and reads with `ls -A`. + omarchy-toggle-enabled "crash-ignore/$name" && continue + now=$EPOCHSECONDS (((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue diff --git a/default/agents/skills/diagnose-crash/SKILL.md b/default/agents/skills/diagnose-crash/SKILL.md index 7859c6ec..7faadb1f 100644 --- a/default/agents/skills/diagnose-crash/SKILL.md +++ b/default/agents/skills/diagnose-crash/SKILL.md @@ -87,7 +87,60 @@ ambiguous, say so rather than assembling confidence out of guesswork. **Leave the system as you found it.** Diagnosis reads; it does not fix, tidy, or reconfigure. The one thing to clean up is your own: delete the core you extracted -above, which is a copy of the crashed process's memory. +above, which is a copy of the crashed process's memory. The single change a +diagnosis may make is the mute below, and only when the user asks for it. + +## Offer to stop the notifications for this program + +A crash that is now understood keeps announcing itself, and understanding it +rarely stops it happening: an upstream bug waiting on a release, a program that +dumps core every time it exits, a driver that misbehaves on this hardware. Finish +by offering to silence crash notifications for **that one program**: + +```bash +omarchy-toggle 'crash-ignore/' on +``` + +`` is the `process:` name in the crash facts, verbatim. The watcher works +that name out and then announces it, so what you were handed is already the exact +string the mute is keyed on — do not re-derive it from `coredumpctl` when you were +given it, because the two agree for ordinary names and not for strange ones. + +A diagnosis started by hand from `omarchy agent crash ` is given no name, so +there you do have to work it out the way the watcher does: the executable's +basename when an absolute `Executable:` was recorded, otherwise the process name +with everything up to the last `/` dropped, and `unknown` when that leaves +nothing, `.` or `..`. Prefer the executable — the kernel truncates the process +name to 15 characters and does not truncate the basename, so a mute on the +truncated one matches nothing, forever, while looking like it worked. + +The name is whatever the crashed program's author chose to call a file, so handle +it as hostile text rather than as a word. Single quotes hold a space or a `$(...)`, +but a name containing a single quote closes them and the rest of it runs as your +shell — escape it, or the program that just crashed chooses the command. Then +check the flag actually arrived, which is also how you learn a name was too long +for the filesystem to keep: + +```bash +omarchy-toggle-enabled 'crash-ignore/' && echo muted +``` + +Offer it; never run it unprompted. The user may well want to keep being told. + +Say how to undo it in the same breath, so it is not a one-way door: the same +command with `off` un-mutes, and each mute is one file in +`~/.local/state/omarchy/toggles/crash-ignore/`, which `ls -A` lists — the +directory appears with the first mute, so before that there is nothing to list. + +The key is a bare name, so programs sharing one share a mute, and anything run +through an interpreter is keyed as the interpreter. Muting `python3.13` or `node` +silences every other Python or Node program on the machine, which is rarely what +the user means: say so rather than quietly doing it. + +This silences one program. Every other crash still notifies, and the muted +program still crashes — nothing here fixes anything, and a mute offered instead +of a fix that was within reach is the wrong answer. If the user wants crash +notifications off altogether, that is _Trigger > Toggle > Crash Capture_ instead. ## If it is an Omarchy bug diff --git a/manual/17-ai.md b/manual/17-ai.md index f5516b88..5588ef9b 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -39,6 +39,8 @@ Omarchy watches systemd-coredump for process crashes. When something segfaults, The watching is on by default. Turn it off under _Trigger > Toggle > Crash Capture_ (or with `omarchy toggle crash-capture`) and the notifications stop; `omarchy agent crash ` still works by hand. +Crashes can also be silenced one program at a time, which is what the diagnosis offers you at the end: `omarchy toggle 'crash-ignore/' on` stops the notifications for that program only, and the same command with `off` brings them back. Use `` exactly as the notification named it, in quotes, since a program name can carry spaces and punctuation your shell would otherwise read as its own. Each mute is a file in `~/.local/state/omarchy/toggles/crash-ignore/`, so `ls -A` there shows what you've muted once you've muted something. Everything else still notifies, and the muted program still crashes — this hides the reminder, it doesn't fix anything. + ### Desktop apps The _Install > AI_ menu also carries a couple of graphical AI apps: the ChatGPT desktop app, and Grok Bot for chatting with xAI's models. diff --git a/test/shell.d/crash-capture-test.sh b/test/shell.d/crash-capture-test.sh index d4119cd8..2f705310 100755 --- a/test/shell.d/crash-capture-test.sh +++ b/test/shell.d/crash-capture-test.sh @@ -54,6 +54,194 @@ grep -F 'omarchy-crash-watch.service' "$ROOT/install/user/first-run/enable-user- fail "crash capture is no longer on by default for new installs" pass "crash capture is on by default" +require_command jq + +# The per-program mute, driven through the real watcher with a stubbed journal: +# these prove what a person sees -- a toast arriving or not -- where asserting +# that a flag file was read would prove only that a flag file was read. +watch_bin="$TMPDIR/watch-bin" +watch_home="$TMPDIR/watch-home" +NOTIFY_LOG="$TMPDIR/notify-log" +JOURNAL_ENTRIES="$TMPDIR/journal-entries" + +mkdir -p "$watch_bin" "$watch_home" + +cat >"$watch_bin/journalctl" <<'SH' +#!/bin/bash +cat "$JOURNAL_ENTRIES" +SH + +cat >"$watch_bin/omarchy-default-agent" <<'SH' +#!/bin/bash +echo claude +SH + +cat >"$watch_bin/omarchy-notification-wait" <<'SH' +#!/bin/bash +exit 0 +SH + +cat >"$watch_bin/omarchy-notification-send" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$NOTIFY_LOG" +SH + +chmod +x "$watch_bin/journalctl" "$watch_bin/omarchy-default-agent" \ + "$watch_bin/omarchy-notification-wait" "$watch_bin/omarchy-notification-send" + +reset_entries() { + : >"$JOURNAL_ENTRIES" +} + +# One core dump as systemd-coredump journals it. The UID must be this user's, or +# the watcher discards it as somebody else's crash before anything under test. +crash_entry() { + local comm="$1" exe="$2" + + jq -cn --arg uid "$UID" --arg comm "$comm" --arg exe "$exe" \ + '{_UID: $uid, COREDUMP_COMM: $comm, COREDUMP_PID: "4242", + COREDUMP_EXE: $exe, COREDUMP_SIGNAL_NAME: "SIGSEGV"}' >>"$JOURNAL_ENTRIES" +} + +# The stubbed journalctl ends after the entries, so the watcher's loop ends too. +# Its exit status is asserted rather than discarded: a watcher that dies on a +# muted crash notifies about nothing afterwards, which every assertion below +# that expects silence would otherwise read as success. +run_watch() { + local status=0 + + : >"$NOTIFY_LOG" + + PATH="$watch_bin:$ROOT/bin:$PATH" \ + JOURNAL_ENTRIES="$JOURNAL_ENTRIES" \ + NOTIFY_LOG="$NOTIFY_LOG" \ + HOME="$watch_home" \ + "$ROOT/bin/omarchy-crash-watch" || status=$? + + (( status == 0 )) || + fail "the watcher exited $status rather than carrying on, so a mute takes the service down with it" +} + +mute() { + HOME="$watch_home" "$ROOT/bin/omarchy-toggle" "crash-ignore/$1" "$2" +} + +announced() { + grep -Fq "Process crashed: $1" "$NOTIFY_LOG" +} + +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "a crash nobody muted still announces itself" +pass "a crash nobody muted still announces itself" + +mute hyprland on +run_watch +! announced hyprland || + fail "muting a program stops the crash notifications the diagnosis offered to stop" +pass "muting a program stops its crash notifications" + +reset_entries +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "muting one program silences every other program, which is the global toggle's job and not this one's" +pass "muting one program leaves every other program announcing" + +mute hyprland off +reset_entries +crash_entry hyprland /usr/bin/hyprland +run_watch +announced hyprland || + fail "un-muting a program brings its crash notifications back" +pass "un-muting a program brings its crash notifications back" + +# The diagnosis tells the user to mute the name the toast showed them, so the +# toast has to show the name the watcher checks. COMM is truncated to 15 +# characters and the executable's basename is not, and announcing the truncated +# one would leave a dutifully-followed mute matching nothing forever. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +run_watch +announced chromium-browser || + fail "the toast announces a name the mute cannot be keyed on, so following the diagnosis mutes nothing" +pass "the toast announces the name the mute is keyed on" + +mute chromium-browser on +run_watch +! announced chromium-browser || + fail "the mute is keyed on the name the notification announced, not on the truncated COMM" +pass "muting the announced name silences a program whose COMM was truncated" + +# A muted crash must not end the watcher. Restart=always would paper over it +# with a five-second gap, and the watcher restarts on `journalctl -n 0`, which +# never replays the crashes it missed while it was away. +reset_entries +crash_entry chromium-browse /usr/lib/chromium/chromium-browser +crash_entry nautilus /usr/bin/nautilus +run_watch +announced nautilus || + fail "a muted crash stops the watcher reading the journal, losing every crash after it" +pass "a muted crash does not stop the watcher reading the next one" + +# A process can set its own comm to anything prctl takes, slashes included, and +# a crash with no recorded executable falls back to it. A name that climbed out +# of crash-ignore/ would let a crashing program silence itself against an +# unrelated flag -- and have the diagnosis write one there on the user's behalf. +# The fixture carries two slashes so that dropping only the first is not mistaken +# for dropping all of them. +reset_entries +crash_entry a/../bar-off - +sibling_flag="$watch_home/.local/state/omarchy/toggles/bar-off" +touch "$sibling_flag" +run_watch +announced bar-off || + fail "a comm that climbs out of crash-ignore/ reads an unrelated toggle, letting a crash suppress its own notification" +pass "a comm that climbs out of crash-ignore/ cannot reach an unrelated toggle" +rm -f "$sibling_flag" + +# Stripping to the last component does not always leave a component. An empty +# name is no kind of array subscript and no kind of toast, and a dot component +# names a directory the mute would touch and then never match. +for empty_comm in / a/ . ..; do + reset_entries + crash_entry "$empty_comm" - + run_watch + announced unknown || + fail "a comm of '$empty_comm' leaves no usable name, so the toast cannot say what crashed and the mute has nothing to key on" +done +pass "a comm that strips down to nothing or a dot still announces under a name a mute can use" + +# Only "." and ".." are special. A leading dot is an ordinary filename, and +# folding those into the fallback would have one program's mute silence another. +for dotted_comm in .hidden ...; do + reset_entries + crash_entry "$dotted_comm" - + run_watch + announced "$dotted_comm" || + fail "'$dotted_comm' is an ordinary name, but it lands in the fallback, so muting it would silence unrelated crashes" +done +pass "a leading dot is an ordinary name rather than a special component" + +# And the name it settles on is mutable like any other. +mute unknown on +reset_entries +crash_entry / - +run_watch +! announced unknown || + fail "the fallback name cannot be muted, so the one crash most likely to repeat is the one that cannot be silenced" +pass "the fallback name can be muted like any other" +mute unknown off + +skill="$ROOT/default/agents/skills/diagnose-crash/SKILL.md" +grep -Fq 'crash-ignore/' "$skill" || + fail "the diagnosis no longer offers the mute under the name the watcher reads, so the two have drifted apart" +grep -Fq 'crash-ignore/$name' "$ROOT/bin/omarchy-crash-watch" || + fail "the watcher no longer reads the flag the diagnosis offers to write" +pass "the diagnosis and the watcher name the same flag" + run_node_test <<'JS' const fs = require('fs') const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')