#!/usr/bin/env bash # mac-ops :: panic-triage.sh # Decode the most recent kernel panic (or one specified by path/time). # Emits panic string, suspect kext, and the pre-panic timeline window. # # Usage: # scripts/panic-triage.sh # most recent panic # scripts/panic-triage.sh -f # specific report file # scripts/panic-triage.sh -t '2026-05-14 03:14:22' # by timestamp (UTC) # scripts/panic-triage.sh -m 15 # widen pre-panic window to 15 min set -u PANIC_FILE="" PANIC_TIME="" WINDOW_MIN=10 while [[ $# -gt 0 ]]; do case "$1" in -f|--file) PANIC_FILE="$2"; shift 2 ;; -t|--time) PANIC_TIME="$2"; shift 2 ;; -m|--minutes) WINDOW_MIN="$2"; shift 2 ;; --help|-h) cat </dev/null \ | xargs ls -t 2>/dev/null | head -1) fi if [[ -z "$PANIC_FILE" ]] || [[ ! -f "$PANIC_FILE" ]]; then log_info "Panic reports" "none found in $panic_dir" emit_summary exit "$EXIT_NOT_FOUND" fi log_pass "Panic report selected" "$PANIC_FILE" panic_mtime=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$PANIC_FILE" 2>/dev/null) note " Last modified: $panic_mtime" # ---------------------------------------------------------------------------- section "2. PANIC STRING + KEXT EXTRACTION" # ---------------------------------------------------------------------------- if [[ "$PANIC_FILE" == *.ips ]]; then # .ips files are JSON-with-extras. The first line is a JSON header, # the rest of the file is structured but not strict JSON. panic_string=$(head -200 "$PANIC_FILE" | grep -m1 "panic(" | head -1) # Extract the bundleID of the panicking kext (best-effort) suspect_kext=$(grep -m1 -oE '"bundleID":"[^"]+"' "$PANIC_FILE" | head -1 | sed 's/.*"://; s/"//g') else panic_string=$(grep -m1 "^panic(" "$PANIC_FILE") # In old .panic format the "Kernel Extensions in backtrace" line lists suspects suspect_kext=$(awk '/Kernel Extensions in backtrace:/{getline; print; exit}' "$PANIC_FILE" | awk -F'[()]' '{print $2}') fi if [[ -n "$panic_string" ]]; then log_pass "Panic string extracted" note " $panic_string" fi if [[ -n "$suspect_kext" ]]; then case "$suspect_kext" in com.apple.*) log_warn "Suspect kext" "$suspect_kext (Apple — harder to fix; check macOS update)" ;; *) log_fail "Suspect kext" "$suspect_kext (third-party — primary suspect)" ;; esac else log_info "Suspect kext" "could not extract from report — check report manually" fi # Match panic string against the common-causes catalog note " Pattern match (quick lookup; see references/panic-codes.md for full catalog):" case "$panic_string" in *"Sleep wake failure"*) note " → Driver power-state bug. Often USB / Bluetooth / GPU. Check kext list around panic." ;; *"Unresponsive bootstrap subsystem"*) note " → launchd deadlock. Usually a third-party LaunchDaemon. Audit /Library/LaunchDaemons/." ;; *"WindowServer"*) note " → GPU driver / display kext fault. Try disabling external display, alternative GPU mode." ;; *"double_fault"*|*"page_fault"*) note " → Kernel-mode memory corruption. Bad RAM or buggy kext. Run memtest from recoveryOS." ;; *"panic_kthread"*) note " → Kernel watchdog timeout. A driver hung in infinite loop. Examine pre-panic kext activity." ;; *"Unable to find driver"*) note " → Boot-time kext failed to load. Often after macOS update. Try safe-boot." ;; *) note " → No quick-pattern match. See references/panic-codes.md." ;; esac # ---------------------------------------------------------------------------- section "3. PRE-PANIC TIMELINE" # ---------------------------------------------------------------------------- if [[ -z "$PANIC_TIME" ]]; then PANIC_TIME=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$PANIC_FILE" 2>/dev/null) fi note " Anchor: $PANIC_TIME (window: ${WINDOW_MIN} min before)" # Convert anchor to epoch, compute start if anchor_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$PANIC_TIME" "+%s" 2>/dev/null); then start_epoch=$((anchor_epoch - WINDOW_MIN * 60)) start_str=$(date -r "$start_epoch" "+%Y-%m-%d %H:%M:%S") note " Searching unified log from $start_str to $PANIC_TIME ..." # Filter the noisy stuff out; surface kernel + kext + IO + power events log show --start "$start_str" --end "$PANIC_TIME" --style compact \ --predicate '(subsystem == "com.apple.kernel" OR subsystem == "com.apple.iokit" OR processImagePath CONTAINS "kernel" OR senderImagePath CONTAINS ".kext") AND (messageType == "Default" OR messageType == "Error" OR messageType == "Fault")' \ 2>/dev/null | tail -50 | sed 's/^/ /' log_info "Pre-panic events captured" "${WINDOW_MIN} min window" else log_warn "Pre-panic timeline" "could not parse panic timestamp; pass -t explicitly" fi # ---------------------------------------------------------------------------- section "4. CONTEXT: RECENT PANICS" # ---------------------------------------------------------------------------- recent_panics=$(find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) \ -mtime -30 2>/dev/null | wc -l | tr -d ' ') log_info "Panics in last 30 days" "$recent_panics" if [[ "$recent_panics" -gt 1 ]]; then note " Recent panic files:" find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) -mtime -30 2>/dev/null \ | xargs ls -lt 2>/dev/null | head -5 | awk '{print " "$NF" — "$6" "$7" "$8}' fi # ---------------------------------------------------------------------------- emit_summary