Files
Panama/bin/panama-crash-watch

144 lines
6.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# Tell somebody when a program crashes.
#
# On GNOME, ABRT says so. Under a hand-assembled Hyprland desktop nothing does,
# and applications die silently -- which is most of how "Linux is flaky" gets
# earned. Fedora ships systemd-coredump by default, so the information is
# already there; nobody is reading it.
#
# Follows the journal for systemd-coredump's own message id and reports each
# program once per session.
#
# ONCE PER SESSION IS THE WHOLE DESIGN. This machine's portal backend crashes
# between eleven and sixty times a day -- see the portal-stability check in
# panama-doctor -- and a notification per crash would be a notification every
# few minutes for something the user can do nothing about. The first one is
# news; the fortieth is why people turn notifications off. The health page
# carries the running count for anyone who wants it.
#
# When an agent has been chosen, the notification stops being a dead end. It
# carries the diagnosis command as data in a `panama-exec` hint, which the shell
# runs on click. Command-as-data rather than a libnotify action, because an
# action would tie the click to this process still being alive to hear it, and
# this process is a `journalctl -f` that outlives nothing in particular. The
# hint survives a shell restart and never blocks the watcher.
set -uo pipefail
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
SETTINGS="${PANAMA_AGENT_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
# systemd-coredump's MESSAGE_ID. Matching on this rather than on text keeps
# working when the wording changes and never matches a program that merely
# mentions the word "crash" in its own logs.
readonly COREDUMP_MESSAGE_ID='fc2e22bc6ee647b6b90729ab34a250b1'
command -v journalctl >/dev/null 2>&1 || exit 0
command -v notify-send >/dev/null 2>&1 || exit 0
# The shell owns org.freedesktop.Notifications, and the crash most worth
# reporting is the one that took the shell with it. Waiting means that report
# arrives rather than vanishing into a bus nobody is serving.
for _ in $(seq 1 60); do
busctl --user status org.freedesktop.Notifications >/dev/null 2>&1 && break
sleep 1
done
# Read per crash rather than once at startup, so choosing an agent in Settings
# takes effect on the next crash instead of on the next login. This service runs
# for the life of the session; nothing restarts it when a preference changes.
read_setting() {
local key="$1" fallback="$2"
[[ -r "$SETTINGS" ]] || { printf '%s' "$fallback"; return; }
command -v jq >/dev/null 2>&1 || { printf '%s' "$fallback"; return; }
jq -r --arg k "$key" --arg d "$fallback" \
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
"$SETTINGS" 2>/dev/null || printf '%s' "$fallback"
}
# What to call the agent in a sentence aimed at a person.
agent_label() {
case "$1" in
claude) printf 'Claude Code' ;;
codex) printf 'Codex' ;;
*) printf '%s' "$1" ;;
esac
}
declare -A reported=()
# -f from now, not from the boot: a session that starts after a crash should
# not open with a notification about something the user has already lived
# through and cannot act on.
journalctl --user -f -n 0 --output=json MESSAGE_ID="$COREDUMP_MESSAGE_ID" 2>/dev/null \
| while IFS= read -r line; do
[[ -n "$line" ]] || continue
# One jq per entry rather than one per field: the fields are read
# together, and the click payload needs all of them.
IFS=$'\t' read -r uid exe comm pid signal < <(
jq -r '[(.COREDUMP_UID // ""),
(.COREDUMP_EXE // ""),
(.COREDUMP_COMM // ""),
(.COREDUMP_PID // ""),
(.COREDUMP_SIGNAL_NAME // "")] | @tsv' <<<"$line" 2>/dev/null
)
# Another user's crash is not this session's business, and reporting it
# would leak what they are running.
[[ "$uid" == "$(id -u)" ]] || continue
[[ -n "$exe" || -n "$comm" ]] || continue
# The executable name first: COREDUMP_COMM is the kernel's comm field
# and is truncated to fifteen characters, so it reports
# "panama-test-cra" for a program called panama-test-crasher.
if [[ -n "$exe" ]]; then
program="$(basename "$exe")"
else
program="$comm"
fi
# Never announce our own machinery. A crash watcher that notifies about
# the crash watcher, or about the agent it just launched to investigate
# the last crash, is a loop with a toast in it.
[[ "$program" == panama-crash-* || "$program" == panama-agent* ]] && continue
[[ -z "${reported[$program]:-}" ]] || continue
reported[$program]=1
# The toast can only offer a diagnosis if there is something to diagnose
# with. No agent, or the offer switched off, and it stays exactly the
# actionless notification it has always been.
agent="$(read_setting preferredAgent none)"
offer="$(read_setting crashDiagnoseOffer true)"
if [[ -n "$agent" && "$agent" != "none" && "$offer" != "false" && "$pid" =~ ^[0-9]+$ ]]; then
# By absolute path, not by name. The shell runs this hint, and the
# shell is started by systemd -- whose environment does not carry
# the repository's bin directory on PATH, so a bare name would
# click into "command not found".
exec_command="$(printf '%q %q %q %q %q' \
"$PANAMA_PATH/bin/panama-agent-crash" \
"$pid" "${comm:-$program}" "${exe:-unknown}" "${signal:-unknown}")"
# The hint is data, not privilege. Any process on this session bus
# could send one, and running it grants nothing a local process
# could not already do for itself.
# Same urgency as the plain report, deliberately. Making the
# clickable one critical would let a crash break through Do Not
# Disturb, which is a louder desktop than anybody asked for in
# exchange for an offer that keeps until it is read anyway.
notify-send --icon=dialog-error-symbolic --app-name=Panama \
--hint="string:panama-exec:$exec_command" \
"$program stopped unexpectedly" \
"Click to diagnose with $(agent_label "$agent")." \
2>/dev/null || true
else
notify-send --icon=dialog-error-symbolic --app-name=Panama \
"$program stopped unexpectedly" \
"It crashed and was not able to recover. System Health has the details." \
2>/dev/null || true
fi
done