v0.27.0 dcb4c51

crash_report.go RawHistoryFolder

1package bootstrap
2
3import (
4	"bufio"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"net/url"
9	"os"
10	"path/filepath"
11	"regexp"
12	"runtime"
13	"sort"
14	"strconv"
15	"strings"
16	"time"
17
18	corelogging "github.com/bnema/dumber/internal/logging"
19)
20
21const (
22	crashReportsDirName = "crashes"
23	logTailLineCount    = 120
24	maxCrashReportsKept = 20
25	scannerMaxTokenSize = 256 * 1024
26)
27
28type unexpectedCloseReport struct {
29	ReportVersion        int                          `json:"report_version"`
30	GeneratedAt          string                       `json:"generated_at"`
31	SessionID            string                       `json:"session_id"`
32	Classification       SessionExitClassification    `json:"classification"`
33	StartupPID           int                          `json:"startup_pid,omitempty"`
34	StartupPPID          int                          `json:"startup_ppid,omitempty"`
35	MarkerFiles          map[string]string            `json:"marker_files"`
36	SessionLogFile       string                       `json:"session_log_file,omitempty"`
37	SessionLogTail       []string                     `json:"session_log_tail_redacted,omitempty"`
38	ReporterProcess      unexpectedCloseReporter      `json:"reporter_process"`
39	CoreDumpDiagnostics  unexpectedCloseCoreDump      `json:"core_dump_diagnostics"`
40	IssueTemplate        unexpectedCloseIssueTemplate `json:"issue_template"`
41	GeneratedMarkdownRef string                       `json:"generated_markdown_ref,omitempty"`
42}
43
44type unexpectedCloseReporter struct {
45	GeneratedBy string `json:"generated_by"`
46	GoVersion   string `json:"go_version"`
47	GOOS        string `json:"goos"`
48	GOARCH      string `json:"goarch"`
49	PID         int    `json:"pid"`
50	PPID        int    `json:"ppid"`
51}
52
53type unexpectedCloseCoreDump struct {
54	RLimitCoreSoft string `json:"rlimit_core_soft"`
55	RLimitCoreHard string `json:"rlimit_core_hard"`
56	Hint           string `json:"hint"`
57}
58
59type unexpectedCloseIssueTemplate struct {
60	Title   string `json:"title"`
61	Summary string `json:"summary"`
62}
63
64func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
65	if lockDir == "" || sessionID == "" {
66		return "", errors.New("lockDir and sessionID are required")
67	}
68
69	classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
70	if classifyErr != nil {
71		return "", classifyErr
72	}
73
74	if classification.Class == SessionExitCleanExit {
75		return "", nil
76	}
77
78	reportsDir := filepath.Join(lockDir, crashReportsDirName)
79	if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil {
80		return "", err
81	}
82
83	pruneOldCrashReports(reportsDir, maxCrashReportsKept)
84
85	jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
86	markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
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		}
103		return jsonPath, nil
104	} else if !os.IsNotExist(statErr) {
105		return "", statErr
106	}
107
108	report := unexpectedCloseReport{
109		ReportVersion:       1,
110		GeneratedAt:         time.Now().UTC().Format(time.RFC3339Nano),
111		SessionID:           sessionID,
112		Classification:      classification,
113		MarkerFiles:         reportMarkerFiles(lockDir, sessionID),
114		ReporterProcess:     currentReporter(),
115		CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
116		IssueTemplate: unexpectedCloseIssueTemplate{
117			Title:   fmt.Sprintf("Unexpected close in session %s", sessionID),
118			Summary: "Dumber closed unexpectedly and generated this report automatically.",
119		},
120	}
121
122	startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID)
123	report.StartupPID = startupPID
124	report.StartupPPID = startupPPID
125
126	logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
127	if _, statErr := os.Stat(logPath); statErr == nil {
128		report.SessionLogFile = logPath
129		report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
130	}
131
132	report.GeneratedMarkdownRef = markdownPath
133
134	payload, err := json.MarshalIndent(report, "", "  ")
135	if err != nil {
136		return "", err
137	}
138	payload = append(payload, '\n')
139	if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil {
140		return "", err
141	}
142
143	markdown := buildUnexpectedCloseMarkdown(report)
144	if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
145		return "", err
146	}
147
148	return jsonPath, nil
149}
150
151func reportMarkerFiles(lockDir, sessionID string) map[string]string {
152	return map[string]string{
153		"startup":  startupMarkerPath(lockDir, sessionID),
154		"shutdown": shutdownMarkerPath(lockDir, sessionID),
155		"abrupt":   abruptMarkerPath(lockDir, sessionID),
156	}
157}
158
159func currentReporter() unexpectedCloseReporter {
160	return unexpectedCloseReporter{
161		GeneratedBy: "dumber",
162		GoVersion:   runtime.Version(),
163		GOOS:        runtime.GOOS,
164		GOARCH:      runtime.GOARCH,
165		PID:         os.Getpid(),
166		PPID:        os.Getppid(),
167	}
168}
169
170func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
171	raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
172	if err != nil {
173		raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID))
174		if err != nil {
175			return 0, 0
176		}
177	}
178
179	pid, _ := strconv.Atoi(markerValue(raw, "pid="))
180	ppid, _ := strconv.Atoi(markerValue(raw, "ppid="))
181	return pid, ppid
182}
183
184func readRedactedLogTail(path string, lines int) []string {
185	if lines <= 0 {
186		return nil
187	}
188
189	file, err := os.Open(path)
190	if err != nil {
191		return nil
192	}
193	defer func() { _ = file.Close() }()
194
195	ring := make([]string, lines)
196	count := 0
197	scanner := bufio.NewScanner(file)
198	scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
199	for scanner.Scan() {
200		ring[count%lines] = redactSensitiveContent(scanner.Text())
201		count++
202	}
203
204	var result []string
205	if count <= lines {
206		result = make([]string, count)
207		copy(result, ring[:count])
208	} else {
209		result = make([]string, lines)
210		start := count % lines
211		copy(result, ring[start:])
212		copy(result[lines-start:], ring[:start])
213	}
214
215	if scanner.Err() != nil {
216		result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
217	}
218	return result
219}
220
221func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
222	lines := []string{
223		"# Unexpected Close Report",
224		"",
225		fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
226		fmt.Sprintf("Session: `%s`", report.SessionID),
227		fmt.Sprintf("Class: `%s`", report.Classification.Class),
228		fmt.Sprintf("Inference: `%s`", report.Classification.Inference),
229		fmt.Sprintf("Reason: `%s`", report.Classification.Reason),
230		"",
231		"## Process Context",
232		fmt.Sprintf("- startup pid: `%d`", report.StartupPID),
233		fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID),
234		fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID),
235		fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID),
236		"",
237		"## Core Dump Diagnostics",
238		fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
239		fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
240		fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
241		"",
242		"## Marker Files",
243		fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]),
244		fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]),
245		fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]),
246	}
247
248	if len(report.SessionLogTail) > 0 {
249		lines = append(lines,
250			"",
251			"## Redacted Log Tail",
252			"```text",
253		)
254		lines = append(lines, report.SessionLogTail...)
255		lines = append(lines, "```")
256	}
257
258	lines = append(lines,
259		"",
260		"## GitHub Issue Template",
261		fmt.Sprintf("Title: %s", report.IssueTemplate.Title),
262		"",
263		"```markdown",
264		"### What happened",
265		"Describe what you were doing just before Dumber closed unexpectedly.",
266		"",
267		"### Crash report",
268		fmt.Sprintf("- session id: `%s`", report.SessionID),
269		fmt.Sprintf("- class: `%s`", report.Classification.Class),
270		fmt.Sprintf("- reason: `%s`", report.Classification.Reason),
271		"",
272		"### Additional context",
273		"- distro:",
274		"- compositor/window manager:",
275		"- steps to reproduce:",
276		"```",
277	)
278
279	return strings.Join(lines, "\n") + "\n"
280}
281
282var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
283
284var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
285
286var secretJSONRegex = regexp.MustCompile(`(?i)"(token|access_token|id_token|password|secret)"\s*:\s*"(.*?)"`)
287
288var secretHeaderRegex = regexp.MustCompile(`(?i)(authorization|auth):\s*(\S[^\n,;]*)`)
289
290func redactSensitiveContent(line string) string {
291	redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
292	redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
293	redacted = secretJSONRegex.ReplaceAllString(redacted, `"$1":"[REDACTED]"`)
294	redacted = secretHeaderRegex.ReplaceAllString(redacted, "$1: [REDACTED]")
295	return redacted
296}
297
298func redactURLString(raw string) string {
299	trimmed := strings.TrimSpace(raw)
300	trimmed = strings.TrimRight(trimmed, ".,;:")
301	u, err := url.Parse(trimmed)
302	if err != nil {
303		return trimmed
304	}
305	u.RawQuery = ""
306	u.Fragment = ""
307	return u.String()
308}
309
310func pruneOldCrashReports(reportsDir string, maxKeep int) {
311	matches, err := filepath.Glob(filepath.Join(reportsDir, "session_*.crash.json"))
312	if err != nil || len(matches) <= maxKeep {
313		return
314	}
315
316	type reportEntry struct {
317		path    string
318		modTime time.Time
319	}
320	entries := make([]reportEntry, 0, len(matches))
321	for _, p := range matches {
322		info, statErr := os.Stat(p)
323		if statErr != nil {
324			continue
325		}
326		entries = append(entries, reportEntry{path: p, modTime: info.ModTime()})
327	}
328
329	sort.Slice(entries, func(i, j int) bool {
330		return entries[i].modTime.After(entries[j].modTime)
331	})
332
333	for _, e := range entries[maxKeep:] {
334		_ = os.Remove(e.path)
335		mdPath := strings.TrimSuffix(e.path, ".json") + ".md"
336		_ = os.Remove(mdPath)
337	}
338}

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