fix(crash): review fixes and report pruning from Copilot/CodeRabbit feedback
- Regenerate missing .md when .json already exists (CodeRabbit) - Add json tags to SessionExitClassification for consistent snake_case schema (Copilot) - Increase bufio.Scanner buffer to 256KB and handle scanner.Err() (Copilot) - Cap crash reports at 20, prune oldest .json+.md pairs on new report - Add crash reports feature to CHANGELOG.md [Unreleased]
- commit
- c582f015e682fcede9c193a3bf88492aa20c3a53
- author
- brice · · 2026-02-08 07:48 UTC
- parent
- 25342a3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +66 -9
M CHANGELOG.md +4 -0
| @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. | |||
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | 8 | ||
| 9 | + | - **Unexpected-close crash reports**: Automatically detects prior abrupt sessions on startup and generates per-session crash reports (JSON + Markdown) with metadata, core-dump diagnostics, and redacted log tails. Reports are capped at 20 to prevent unbounded disk growth. | |
| 10 | + | - **`dumber crashes` CLI**: New CLI surface to list, view, and extract GitHub issue payloads from crash reports (`dumber crashes`, `dumber crashes show latest`, `dumber crashes issue latest`). | |
| 11 | + | - **Crash report UI notification**: Startup toast guides users to run `dumber crashes issue latest` when unexpected-close reports are detected. | |
| 12 | + | - **Unexpected-close GitHub issue template**: Structured issue template for reporting crashes with crash report payload, context, and reproduction steps. | |
| 9 | 13 | - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths. | |
| 10 | 14 | - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling. | |
| 11 | 15 | - **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior. | |
| @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. | |||||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | 8 | ||||
| 9 | + | - **Unexpected-close crash reports**: Automatically detects prior abrupt sessions on startup and generates per-session crash reports (JSON + Markdown) with metadata, core-dump diagnostics, and redacted log tails. Reports are capped at 20 to prevent unbounded disk growth. | |||
| 10 | + | - **`dumber crashes` CLI**: New CLI surface to list, view, and extract GitHub issue payloads from crash reports (`dumber crashes`, `dumber crashes show latest`, `dumber crashes issue latest`). | |||
| 11 | + | - **Crash report UI notification**: Startup toast guides users to run `dumber crashes issue latest` when unexpected-close reports are detected. | |||
| 12 | + | - **Unexpected-close GitHub issue template**: Structured issue template for reporting crashes with crash report payload, context, and reproduction steps. | |||
| 9 | - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths. | 13 | - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths. | ||
| 10 | - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling. | 14 | - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling. | ||
| 11 | - **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior. | 15 | - **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior. | ||
M internal/bootstrap/crash_report.go +53 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "path/filepath" | |
| 11 | 11 | "regexp" | |
| 12 | 12 | "runtime" | |
| 13 | + | "sort" | |
| 13 | 14 | "strconv" | |
| 14 | 15 | "strings" | |
| 15 | 16 | "time" | |
| @@ -21,6 +22,7 @@ import ( | |||
| 21 | 22 | const ( | |
| 22 | 23 | crashReportsDirName = "crashes" | |
| 23 | 24 | logTailLineCount = 120 | |
| 25 | + | maxCrashReportsKept = 20 | |
| 24 | 26 | ) | |
| 25 | 27 | ||
| 26 | 28 | type unexpectedCloseReport struct { | |
| @@ -78,9 +80,26 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) { | |||
| 78 | 80 | return "", err | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 83 | + | pruneOldCrashReports(reportsDir, maxCrashReportsKept) | |
| 84 | + | ||
| 81 | 85 | jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID)) | |
| 82 | 86 | markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID)) | |
| 83 | 87 | if _, statErr := os.Stat(jsonPath); statErr == nil { | |
| 88 | + | if _, mdErr := os.Stat(markdownPath); mdErr == nil { | |
| 89 | + | return jsonPath, nil | |
| 90 | + | } | |
| 91 | + | raw, readErr := os.ReadFile(jsonPath) | |
| 92 | + | if readErr != nil { | |
| 93 | + | return "", readErr | |
| 94 | + | } | |
| 95 | + | var existing unexpectedCloseReport | |
| 96 | + | if err := json.Unmarshal(raw, &existing); err != nil { | |
| 97 | + | return "", err | |
| 98 | + | } | |
| 99 | + | markdown := buildUnexpectedCloseMarkdown(existing) | |
| 100 | + | if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil { | |
| 101 | + | return "", err | |
| 102 | + | } | |
| 84 | 103 | return jsonPath, nil | |
| 85 | 104 | } else if !os.IsNotExist(statErr) { | |
| 86 | 105 | return "", statErr | |
| @@ -197,9 +216,13 @@ func readRedactedLogTail(path string, lines int) []string { | |||
| 197 | 216 | ||
| 198 | 217 | all := make([]string, 0, lines) | |
| 199 | 218 | scanner := bufio.NewScanner(file) | |
| 219 | + | scanner.Buffer(make([]byte, 0, 256*1024), 256*1024) | |
| 200 | 220 | for scanner.Scan() { | |
| 201 | 221 | all = append(all, redactSensitiveContent(scanner.Text())) | |
| 202 | 222 | } | |
| 223 | + | if scanner.Err() != nil { | |
| 224 | + | all = append(all, fmt.Sprintf("[log tail truncated: %v]", scanner.Err())) | |
| 225 | + | } | |
| 203 | 226 | if len(all) <= lines { | |
| 204 | 227 | return all | |
| 205 | 228 | } | |
| @@ -288,3 +311,33 @@ func redactURLString(raw string) string { | |||
| 288 | 311 | u.Fragment = "" | |
| 289 | 312 | return u.String() | |
| 290 | 313 | } | |
| 314 | + | ||
| 315 | + | func pruneOldCrashReports(reportsDir string, maxKeep int) { | |
| 316 | + | matches, err := filepath.Glob(filepath.Join(reportsDir, "session_*.crash.json")) | |
| 317 | + | if err != nil || len(matches) <= maxKeep { | |
| 318 | + | return | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | type reportEntry struct { | |
| 322 | + | path string | |
| 323 | + | modTime time.Time | |
| 324 | + | } | |
| 325 | + | entries := make([]reportEntry, 0, len(matches)) | |
| 326 | + | for _, p := range matches { | |
| 327 | + | info, statErr := os.Stat(p) | |
| 328 | + | if statErr != nil { | |
| 329 | + | continue | |
| 330 | + | } | |
| 331 | + | entries = append(entries, reportEntry{path: p, modTime: info.ModTime()}) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | sort.Slice(entries, func(i, j int) bool { | |
| 335 | + | return entries[i].modTime.After(entries[j].modTime) | |
| 336 | + | }) | |
| 337 | + | ||
| 338 | + | for _, e := range entries[maxKeep:] { | |
| 339 | + | _ = os.Remove(e.path) | |
| 340 | + | mdPath := strings.TrimSuffix(e.path, ".json") + ".md" | |
| 341 | + | _ = os.Remove(mdPath) | |
| 342 | + | } | |
| 343 | + | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "path/filepath" | 10 | "path/filepath" | ||
| 11 | "regexp" | 11 | "regexp" | ||
| 12 | "runtime" | 12 | "runtime" | ||
| 13 | + | "sort" | |||
| 13 | "strconv" | 14 | "strconv" | ||
| 14 | "strings" | 15 | "strings" | ||
| 15 | "time" | 16 | "time" | ||
| @@ -21,6 +22,7 @@ import ( | |||||
| 21 | const ( | 22 | const ( | ||
| 22 | crashReportsDirName = "crashes" | 23 | crashReportsDirName = "crashes" | ||
| 23 | logTailLineCount = 120 | 24 | logTailLineCount = 120 | ||
| 25 | + | maxCrashReportsKept = 20 | |||
| 24 | ) | 26 | ) | ||
| 25 | 27 | ||||
| 26 | type unexpectedCloseReport struct { | 28 | type unexpectedCloseReport struct { | ||
| @@ -78,9 +80,26 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) { | |||||
| 78 | return "", err | 80 | return "", err | ||
| 79 | } | 81 | } | ||
| 80 | 82 | ||||
| 83 | + | pruneOldCrashReports(reportsDir, maxCrashReportsKept) | |||
| 84 | + | ||||
| 81 | jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID)) | 85 | jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID)) | ||
| 82 | markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID)) | 86 | markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID)) | ||
| 83 | if _, statErr := os.Stat(jsonPath); statErr == nil { | 87 | if _, statErr := os.Stat(jsonPath); statErr == nil { | ||
| 88 | + | if _, mdErr := os.Stat(markdownPath); mdErr == nil { | |||
| 89 | + | return jsonPath, nil | |||
| 90 | + | } | |||
| 91 | + | raw, readErr := os.ReadFile(jsonPath) | |||
| 92 | + | if readErr != nil { | |||
| 93 | + | return "", readErr | |||
| 94 | + | } | |||
| 95 | + | var existing unexpectedCloseReport | |||
| 96 | + | if err := json.Unmarshal(raw, &existing); err != nil { | |||
| 97 | + | return "", err | |||
| 98 | + | } | |||
| 99 | + | markdown := buildUnexpectedCloseMarkdown(existing) | |||
| 100 | + | if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil { | |||
| 101 | + | return "", err | |||
| 102 | + | } | |||
| 84 | return jsonPath, nil | 103 | return jsonPath, nil | ||
| 85 | } else if !os.IsNotExist(statErr) { | 104 | } else if !os.IsNotExist(statErr) { | ||
| 86 | return "", statErr | 105 | return "", statErr | ||
| @@ -197,9 +216,13 @@ func readRedactedLogTail(path string, lines int) []string { | |||||
| 197 | 216 | ||||
| 198 | all := make([]string, 0, lines) | 217 | all := make([]string, 0, lines) | ||
| 199 | scanner := bufio.NewScanner(file) | 218 | scanner := bufio.NewScanner(file) | ||
| 219 | + | scanner.Buffer(make([]byte, 0, 256*1024), 256*1024) | |||
| 200 | for scanner.Scan() { | 220 | for scanner.Scan() { | ||
| 201 | all = append(all, redactSensitiveContent(scanner.Text())) | 221 | all = append(all, redactSensitiveContent(scanner.Text())) | ||
| 202 | } | 222 | } | ||
| 223 | + | if scanner.Err() != nil { | |||
| 224 | + | all = append(all, fmt.Sprintf("[log tail truncated: %v]", scanner.Err())) | |||
| 225 | + | } | |||
| 203 | if len(all) <= lines { | 226 | if len(all) <= lines { | ||
| 204 | return all | 227 | return all | ||
| 205 | } | 228 | } | ||
| @@ -288,3 +311,33 @@ func redactURLString(raw string) string { | |||||
| 288 | u.Fragment = "" | 311 | u.Fragment = "" | ||
| 289 | return u.String() | 312 | return u.String() | ||
| 290 | } | 313 | } | ||
| 314 | + | ||||
| 315 | + | func pruneOldCrashReports(reportsDir string, maxKeep int) { | |||
| 316 | + | matches, err := filepath.Glob(filepath.Join(reportsDir, "session_*.crash.json")) | |||
| 317 | + | if err != nil || len(matches) <= maxKeep { | |||
| 318 | + | return | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | type reportEntry struct { | |||
| 322 | + | path string | |||
| 323 | + | modTime time.Time | |||
| 324 | + | } | |||
| 325 | + | entries := make([]reportEntry, 0, len(matches)) | |||
| 326 | + | for _, p := range matches { | |||
| 327 | + | info, statErr := os.Stat(p) | |||
| 328 | + | if statErr != nil { | |||
| 329 | + | continue | |||
| 330 | + | } | |||
| 331 | + | entries = append(entries, reportEntry{path: p, modTime: info.ModTime()}) | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | sort.Slice(entries, func(i, j int) bool { | |||
| 335 | + | return entries[i].modTime.After(entries[j].modTime) | |||
| 336 | + | }) | |||
| 337 | + | ||||
| 338 | + | for _, e := range entries[maxKeep:] { | |||
| 339 | + | _ = os.Remove(e.path) | |||
| 340 | + | mdPath := strings.TrimSuffix(e.path, ".json") + ".md" | |||
| 341 | + | _ = os.Remove(mdPath) | |||
| 342 | + | } | |||
| 343 | + | } | |||
M internal/bootstrap/crash_report_test.go +1 -1
| @@ -55,7 +55,7 @@ func TestWriteUnexpectedCloseReport(t *testing.T) { | |||
| 55 | 55 | assert.Equal(t, sessionID, payload["session_id"]) | |
| 56 | 56 | ||
| 57 | 57 | classification := payload["classification"].(map[string]any) | |
| 58 | - | classValue, _ := classification["Class"].(string) | |
| 58 | + | classValue, _ := classification["class"].(string) | |
| 59 | 59 | assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue) | |
| 60 | 60 | ||
| 61 | 61 | tail := payload["session_log_tail_redacted"].([]any) | |
| @@ -55,7 +55,7 @@ func TestWriteUnexpectedCloseReport(t *testing.T) { | |||||
| 55 | assert.Equal(t, sessionID, payload["session_id"]) | 55 | assert.Equal(t, sessionID, payload["session_id"]) | ||
| 56 | 56 | ||||
| 57 | classification := payload["classification"].(map[string]any) | 57 | classification := payload["classification"].(map[string]any) | ||
| 58 | - | classValue, _ := classification["Class"].(string) | 58 | + | classValue, _ := classification["class"].(string) |
| 59 | assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue) | 59 | assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue) | ||
| 60 | 60 | ||||
| 61 | tail := payload["session_log_tail_redacted"].([]any) | 61 | tail := payload["session_log_tail_redacted"].([]any) | ||
M internal/bootstrap/session_exit_classification.go +8 -8
| @@ -19,14 +19,14 @@ const ( | |||
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | type SessionExitClassification struct { | |
| 22 | - | SessionID string | |
| 23 | - | Class SessionExitClass | |
| 24 | - | Inference string | |
| 25 | - | Reason string | |
| 26 | - | StartupAt *time.Time | |
| 27 | - | ShutdownAt *time.Time | |
| 28 | - | AbruptDetectedAt *time.Time | |
| 29 | - | LastMarkerObserved time.Time | |
| 22 | + | SessionID string `json:"session_id"` | |
| 23 | + | Class SessionExitClass `json:"class"` | |
| 24 | + | Inference string `json:"inference"` | |
| 25 | + | Reason string `json:"reason"` | |
| 26 | + | StartupAt *time.Time `json:"startup_at,omitempty"` | |
| 27 | + | ShutdownAt *time.Time `json:"shutdown_at,omitempty"` | |
| 28 | + | AbruptDetectedAt *time.Time `json:"abrupt_detected_at,omitempty"` | |
| 29 | + | LastMarkerObserved time.Time `json:"last_marker_observed"` | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) { | |
| @@ -19,14 +19,14 @@ const ( | |||||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | type SessionExitClassification struct { | 21 | type SessionExitClassification struct { | ||
| 22 | - | SessionID string | 22 | + | SessionID string `json:"session_id"` |
| 23 | - | Class SessionExitClass | 23 | + | Class SessionExitClass `json:"class"` |
| 24 | - | Inference string | 24 | + | Inference string `json:"inference"` |
| 25 | - | Reason string | 25 | + | Reason string `json:"reason"` |
| 26 | - | StartupAt *time.Time | 26 | + | StartupAt *time.Time `json:"startup_at,omitempty"` |
| 27 | - | ShutdownAt *time.Time | 27 | + | ShutdownAt *time.Time `json:"shutdown_at,omitempty"` |
| 28 | - | AbruptDetectedAt *time.Time | 28 | + | AbruptDetectedAt *time.Time `json:"abrupt_detected_at,omitempty"` |
| 29 | - | LastMarkerObserved time.Time | 29 | + | LastMarkerObserved time.Time `json:"last_marker_observed"` |
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) { | 32 | func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) { | ||