#!/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