panic-triage.sh 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. #!/usr/bin/env bash
  2. # mac-ops :: panic-triage.sh
  3. # Decode the most recent kernel panic (or one specified by path/time).
  4. # Emits panic string, suspect kext, and the pre-panic timeline window.
  5. #
  6. # Usage:
  7. # scripts/panic-triage.sh # most recent panic
  8. # scripts/panic-triage.sh -f <path> # specific report file
  9. # scripts/panic-triage.sh -t '2026-05-14 03:14:22' # by timestamp (UTC)
  10. # scripts/panic-triage.sh -m 15 # widen pre-panic window to 15 min
  11. set -u
  12. PANIC_FILE=""
  13. PANIC_TIME=""
  14. WINDOW_MIN=10
  15. while [[ $# -gt 0 ]]; do
  16. case "$1" in
  17. -f|--file) PANIC_FILE="$2"; shift 2 ;;
  18. -t|--time) PANIC_TIME="$2"; shift 2 ;;
  19. -m|--minutes) WINDOW_MIN="$2"; shift 2 ;;
  20. --help|-h)
  21. cat <<EOF
  22. Usage: $0 [options]
  23. -f, --file PATH Specific .panic or Kernel*.ips file to decode
  24. -t, --time 'YYYY-MM-DD HH:MM:SS' Timestamp anchor for pre-panic window
  25. -m, --minutes N Pre-panic window in minutes (default: 10)
  26. --json, --redact, --quiet, --verbose Standard flags
  27. Exit codes:
  28. 0 success
  29. 3 no panic reports found
  30. EOF
  31. exit 0 ;;
  32. *) shift ;;
  33. esac
  34. done
  35. source "$(dirname "$0")/_lib/common.sh"
  36. parse_common_flags "$@"
  37. maybe_filter_self "$@"
  38. panic_dir="/Library/Logs/DiagnosticReports"
  39. # ----------------------------------------------------------------------------
  40. section "1. PANIC REPORT SELECTION"
  41. # ----------------------------------------------------------------------------
  42. if [[ -z "$PANIC_FILE" ]]; then
  43. # Find newest panic report
  44. PANIC_FILE=$(find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) 2>/dev/null \
  45. | xargs ls -t 2>/dev/null | head -1)
  46. fi
  47. if [[ -z "$PANIC_FILE" ]] || [[ ! -f "$PANIC_FILE" ]]; then
  48. log_info "Panic reports" "none found in $panic_dir"
  49. emit_summary
  50. exit "$EXIT_NOT_FOUND"
  51. fi
  52. log_pass "Panic report selected" "$PANIC_FILE"
  53. panic_mtime=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$PANIC_FILE" 2>/dev/null)
  54. note " Last modified: $panic_mtime"
  55. # ----------------------------------------------------------------------------
  56. section "2. PANIC STRING + KEXT EXTRACTION"
  57. # ----------------------------------------------------------------------------
  58. if [[ "$PANIC_FILE" == *.ips ]]; then
  59. # .ips files are JSON-with-extras. The first line is a JSON header,
  60. # the rest of the file is structured but not strict JSON.
  61. panic_string=$(head -200 "$PANIC_FILE" | grep -m1 "panic(" | head -1)
  62. # Extract the bundleID of the panicking kext (best-effort)
  63. suspect_kext=$(grep -m1 -oE '"bundleID":"[^"]+"' "$PANIC_FILE" | head -1 | sed 's/.*"://; s/"//g')
  64. else
  65. panic_string=$(grep -m1 "^panic(" "$PANIC_FILE")
  66. # In old .panic format the "Kernel Extensions in backtrace" line lists suspects
  67. suspect_kext=$(awk '/Kernel Extensions in backtrace:/{getline; print; exit}' "$PANIC_FILE" | awk -F'[()]' '{print $2}')
  68. fi
  69. if [[ -n "$panic_string" ]]; then
  70. log_pass "Panic string extracted"
  71. note " $panic_string"
  72. fi
  73. if [[ -n "$suspect_kext" ]]; then
  74. case "$suspect_kext" in
  75. com.apple.*) log_warn "Suspect kext" "$suspect_kext (Apple — harder to fix; check macOS update)" ;;
  76. *) log_fail "Suspect kext" "$suspect_kext (third-party — primary suspect)" ;;
  77. esac
  78. else
  79. log_info "Suspect kext" "could not extract from report — check report manually"
  80. fi
  81. # Match panic string against the common-causes catalog
  82. note " Pattern match (quick lookup; see references/panic-codes.md for full catalog):"
  83. case "$panic_string" in
  84. *"Sleep wake failure"*)
  85. note " → Driver power-state bug. Often USB / Bluetooth / GPU. Check kext list around panic." ;;
  86. *"Unresponsive bootstrap subsystem"*)
  87. note " → launchd deadlock. Usually a third-party LaunchDaemon. Audit /Library/LaunchDaemons/." ;;
  88. *"WindowServer"*)
  89. note " → GPU driver / display kext fault. Try disabling external display, alternative GPU mode." ;;
  90. *"double_fault"*|*"page_fault"*)
  91. note " → Kernel-mode memory corruption. Bad RAM or buggy kext. Run memtest from recoveryOS." ;;
  92. *"panic_kthread"*)
  93. note " → Kernel watchdog timeout. A driver hung in infinite loop. Examine pre-panic kext activity." ;;
  94. *"Unable to find driver"*)
  95. note " → Boot-time kext failed to load. Often after macOS update. Try safe-boot." ;;
  96. *)
  97. note " → No quick-pattern match. See references/panic-codes.md." ;;
  98. esac
  99. # ----------------------------------------------------------------------------
  100. section "3. PRE-PANIC TIMELINE"
  101. # ----------------------------------------------------------------------------
  102. if [[ -z "$PANIC_TIME" ]]; then
  103. PANIC_TIME=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$PANIC_FILE" 2>/dev/null)
  104. fi
  105. note " Anchor: $PANIC_TIME (window: ${WINDOW_MIN} min before)"
  106. # Convert anchor to epoch, compute start
  107. if anchor_epoch=$(date -j -f "%Y-%m-%d %H:%M:%S" "$PANIC_TIME" "+%s" 2>/dev/null); then
  108. start_epoch=$((anchor_epoch - WINDOW_MIN * 60))
  109. start_str=$(date -r "$start_epoch" "+%Y-%m-%d %H:%M:%S")
  110. note " Searching unified log from $start_str to $PANIC_TIME ..."
  111. # Filter the noisy stuff out; surface kernel + kext + IO + power events
  112. log show --start "$start_str" --end "$PANIC_TIME" --style compact \
  113. --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")' \
  114. 2>/dev/null | tail -50 | sed 's/^/ /'
  115. log_info "Pre-panic events captured" "${WINDOW_MIN} min window"
  116. else
  117. log_warn "Pre-panic timeline" "could not parse panic timestamp; pass -t explicitly"
  118. fi
  119. # ----------------------------------------------------------------------------
  120. section "4. CONTEXT: RECENT PANICS"
  121. # ----------------------------------------------------------------------------
  122. recent_panics=$(find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) \
  123. -mtime -30 2>/dev/null | wc -l | tr -d ' ')
  124. log_info "Panics in last 30 days" "$recent_panics"
  125. if [[ "$recent_panics" -gt 1 ]]; then
  126. note " Recent panic files:"
  127. find "$panic_dir" -maxdepth 1 \( -name "*.panic" -o -name "Kernel*.ips" \) -mtime -30 2>/dev/null \
  128. | xargs ls -lt 2>/dev/null | head -5 | awk '{print " "$NF" — "$6" "$7" "$8}'
  129. fi
  130. # ----------------------------------------------------------------------------
  131. emit_summary