detached c582f01

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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.
66
77 ### Added
88
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.
913 - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths.
1014 - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling.
1115 - **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 ### Added7 ### 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 (
1010 "path/filepath"
1111 "regexp"
1212 "runtime"
13+ "sort"
1314 "strconv"
1415 "strings"
1516 "time"
@@ -21,6 +22,7 @@ import (
2122 const (
2223 crashReportsDirName = "crashes"
2324 logTailLineCount = 120
25+ maxCrashReportsKept = 20
2426 )
2527
2628 type unexpectedCloseReport struct {
@@ -78,9 +80,26 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
7880 return "", err
7981 }
8082
83+ pruneOldCrashReports(reportsDir, maxCrashReportsKept)
84+
8185 jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
8286 markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
8387 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+ }
84103 return jsonPath, nil
85104 } else if !os.IsNotExist(statErr) {
86105 return "", statErr
@@ -197,9 +216,13 @@ func readRedactedLogTail(path string, lines int) []string {
197216
198217 all := make([]string, 0, lines)
199218 scanner := bufio.NewScanner(file)
219+ scanner.Buffer(make([]byte, 0, 256*1024), 256*1024)
200220 for scanner.Scan() {
201221 all = append(all, redactSensitiveContent(scanner.Text()))
202222 }
223+ if scanner.Err() != nil {
224+ all = append(all, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
225+ }
203226 if len(all) <= lines {
204227 return all
205228 }
@@ -288,3 +311,33 @@ func redactURLString(raw string) string {
288311 u.Fragment = ""
289312 return u.String()
290313 }
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 = 12024 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 "", err80 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, nil103 return jsonPath, nil
85 } else if !os.IsNotExist(statErr) {104 } else if !os.IsNotExist(statErr) {
86 return "", statErr105 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 all227 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) {
5555 assert.Equal(t, sessionID, payload["session_id"])
5656
5757 classification := payload["classification"].(map[string]any)
58- classValue, _ := classification["Class"].(string)
58+ classValue, _ := classification["class"].(string)
5959 assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
6060
6161 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 (
1919 )
2020
2121 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"`
3030 }
3131
3232 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 string22+ SessionID string `json:"session_id"`
23- Class SessionExitClass23+ Class SessionExitClass `json:"class"`
24- Inference string24+ Inference string `json:"inference"`
25- Reason string25+ Reason string `json:"reason"`
26- StartupAt *time.Time26+ StartupAt *time.Time `json:"startup_at,omitempty"`
27- ShutdownAt *time.Time27+ ShutdownAt *time.Time `json:"shutdown_at,omitempty"`
28- AbruptDetectedAt *time.Time28+ AbruptDetectedAt *time.Time `json:"abrupt_detected_at,omitempty"`
29- LastMarkerObserved time.Time29+ 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close