A crash that is understood is not a crash that stops: an upstream bug waiting on a release, a program that dumps core every time it exits. The diagnosis explains it once and the toast keeps arriving, and the only answer Omarchy had was Crash Capture, which turns off every program's notifications in order to silence one. The watcher already resolves a name to dedupe on and announces that same name in the toast, so the mute is keyed on it: a flag file under toggles/crash-ignore/, written by the existing omarchy-toggle and read by the existing omarchy-toggle-enabled. One flag per name rather than one list, so `on` mutes, `off` un-mutes, and `ls -A` shows what is muted, with no new file format and nothing to parse. It is the executable's basename wherever one was recorded, falling back to the process name, which the kernel truncates to fifteen characters -- muting the truncated form would match nothing, forever, while looking like it worked. The name is not always a name, though, and the mute turns it into a path. A program picks its own comm and prctl takes anything, including slashes, and the watcher falls back to comm whenever a crash carries no absolute executable. So it is stripped to its last component first: without that, `a/../bar-off` is a legal comm aimed at an unrelated Omarchy flag, letting a crashing program suppress its own notification and letting a user who accepted the offered mute hide their bar instead. Stripping does not always leave a component either -- `/` leaves an empty string, which is no kind of array subscript and no kind of toast, and `.` or `..` names a directory that omarchy-toggle would touch and report success on, leaving a mute that never matches. Both fall back to `unknown`, the word omarchy-agent-crash already uses for a name it does not have, and which mutes like any other. The skill offers this at the end of a diagnosis and never runs it unprompted, which makes it the single change a diagnosis may make to a system it otherwise only reads. It tells the agent to use the name it was handed rather than re-derive one, since the watcher resolved that name already and the two agree for ordinary names and not for strange ones; a diagnosis started by hand from `omarchy agent crash <pid>` is given no name and gets the derivation instead. It also says to treat the name as hostile text rather than as a word -- it is whatever the crashed program's author called a file, so a single quote inside one closes the quotes around it and the rest runs as the shell -- and to check the flag arrived rather than assume it. Co-Authored-By: Codex XHigh <noreply@openai.com>
257 lines
9.1 KiB
Bash
Executable File
257 lines
9.1 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
|
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
|
|
TMPDIR=$(mktemp -d)
|
|
trap 'rm -rf "$TMPDIR"' EXIT
|
|
|
|
mkdir -p "$TMPDIR/bin"
|
|
SYSTEMCTL_LOG="$TMPDIR/systemctl-log"
|
|
|
|
cat >"$TMPDIR/bin/systemctl" <<'SH'
|
|
#!/bin/bash
|
|
printf '%s\n' "$*" >>"$SYSTEMCTL_LOG"
|
|
SH
|
|
|
|
cat >"$TMPDIR/bin/omarchy-notification-send" <<'SH'
|
|
#!/bin/bash
|
|
exit 0
|
|
SH
|
|
|
|
chmod +x "$TMPDIR/bin/systemctl" "$TMPDIR/bin/omarchy-notification-send"
|
|
|
|
test_home="$TMPDIR/home"
|
|
flag="$test_home/.local/state/omarchy/toggles/crash-capture-off"
|
|
|
|
toggle_crash_capture() {
|
|
PATH="$TMPDIR/bin:$ROOT/bin:$PATH" \
|
|
SYSTEMCTL_LOG="$SYSTEMCTL_LOG" \
|
|
HOME="$test_home" \
|
|
"$ROOT/bin/omarchy-toggle-crash-capture"
|
|
}
|
|
|
|
toggle_crash_capture
|
|
[[ -f $flag ]] || fail "crash capture toggle disables the watcher"
|
|
grep -Fqx -- "--user stop omarchy-crash-watch.service" "$SYSTEMCTL_LOG" ||
|
|
fail "crash capture toggle stops the running watcher, so disabling takes effect before the next login"
|
|
pass "crash capture toggle disables the watcher"
|
|
|
|
: >"$SYSTEMCTL_LOG"
|
|
toggle_crash_capture
|
|
[[ ! -f $flag ]] || fail "crash capture toggle re-enables the watcher"
|
|
grep -Fqx -- "--user start omarchy-crash-watch.service" "$SYSTEMCTL_LOG" ||
|
|
fail "crash capture toggle starts the watcher, so enabling takes effect before the next login"
|
|
pass "crash capture toggle re-enables the watcher"
|
|
|
|
service="$ROOT/default/systemd/user/omarchy-crash-watch.service"
|
|
grep -Fx 'ConditionPathExists=!%h/.local/state/omarchy/toggles/crash-capture-off' "$service" >/dev/null ||
|
|
fail "the watcher is pulled back in at every login, so disabling it never survives a logout"
|
|
pass "crash watcher stays disabled across logins"
|
|
|
|
grep -F 'omarchy-crash-watch.service' "$ROOT/install/user/first-run/enable-user-units.sh" >/dev/null ||
|
|
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')
|
|
const items = menu.parseMenuJsonc(fs.readFileSync(path.join(root, 'default/omarchy/omarchy-menu.jsonc'), 'utf8'))
|
|
const byId = Object.fromEntries(items.map(item => [item.id, item]))
|
|
|
|
assertEqual(
|
|
byId['trigger.toggle.crash-capture'].action,
|
|
'omarchy-toggle-crash-capture',
|
|
'menu toggles crash capture from Trigger > Toggle'
|
|
)
|
|
JS
|