v0.29.0 21b3150

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	"strings"
15	"time"
16
17	corelogging "github.com/bnema/dumber/internal/logging"
18)
19
20const (
21	crashReportsDirName = "crashes"
22	logTailLineCount    = 120
23	maxCrashReportsKept = 20
24	scannerMaxTokenSize = 256 * 1024
25)
26
27type crashReport struct {
28	ReportVersion        int                `json:"report_version"`
29	GeneratedAt          string             `json:"generated_at"`
30	SessionID            string             `json:"session_id"`
31	CrashedPID           int                `json:"crashed_pid,omitempty"`
32	SessionLogFile       string             `json:"session_log_file,omitempty"`
33	SessionLogTail       []string           `json:"session_log_tail_redacted,omitempty"`
34	ReporterProcess      crashReporter      `json:"reporter_process"`
35	CoreDumpDiagnostics  crashCoreDump      `json:"core_dump_diagnostics"`
36	IssueTemplate        crashIssueTemplate `json:"issue_template"`
37	GeneratedMarkdownRef string             `json:"generated_markdown_ref,omitempty"`
38}
39
40type crashReporter struct {
41	GeneratedBy string `json:"generated_by"`
42	GoVersion   string `json:"go_version"`
43	GOOS        string `json:"goos"`
44	GOARCH      string `json:"goarch"`
45	PID         int    `json:"pid"`
46	PPID        int    `json:"ppid"`
47}
48
49type crashCoreDump struct {
50	RLimitCoreSoft string `json:"rlimit_core_soft"`
51	RLimitCoreHard string `json:"rlimit_core_hard"`
52	Hint           string `json:"hint"`
53}
54
55type crashIssueTemplate struct {
56	Title   string `json:"title"`
57	Summary string `json:"summary"`
58}
59
60// writeCrashReport generates JSON + Markdown crash reports for a crashed session.
61// Returns the JSON report path, or empty string if the report already exists.
62func writeCrashReport(logDir, sessionID string, crashedPID int) (string, error) {
63	if logDir == "" || sessionID == "" {
64		return "", errors.New("logDir and sessionID are required")
65	}
66
67	reportsDir := filepath.Join(logDir, crashReportsDirName)
68	if err := os.MkdirAll(reportsDir, dirPerm); err != nil {
69		return "", err
70	}
71
72	pruneOldCrashReports(reportsDir, maxCrashReportsKept)
73
74	jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
75	markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
76
77	// Skip if report already exists.
78	if _, err := os.Stat(jsonPath); err == nil {
79		return jsonPath, nil
80	}
81
82	report := crashReport{
83		ReportVersion: 2,
84		GeneratedAt:   time.Now().UTC().Format(time.RFC3339Nano),
85		SessionID:     sessionID,
86		CrashedPID:    crashedPID,
87		ReporterProcess: crashReporter{
88			GeneratedBy: "dumber",
89			GoVersion:   runtime.Version(),
90			GOOS:        runtime.GOOS,
91			GOARCH:      runtime.GOARCH,
92			PID:         os.Getpid(),
93			PPID:        os.Getppid(),
94		},
95		CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
96		IssueTemplate: crashIssueTemplate{
97			Title:   fmt.Sprintf("Unexpected close in session %s", sessionID),
98			Summary: "Dumber closed unexpectedly and generated this report automatically.",
99		},
100	}
101
102	logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID))
103	if _, err := os.Stat(logPath); err == nil {
104		report.SessionLogFile = logPath
105		report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
106	}
107
108	report.GeneratedMarkdownRef = markdownPath
109
110	payload, err := json.MarshalIndent(report, "", "  ")
111	if err != nil {
112		return "", err
113	}
114	payload = append(payload, '\n')
115	if err := os.WriteFile(jsonPath, payload, filePerm); err != nil {
116		return "", err
117	}
118
119	markdown := buildCrashMarkdown(report)
120	if err := os.WriteFile(markdownPath, []byte(markdown), filePerm); err != nil {
121		return "", err
122	}
123
124	return jsonPath, nil
125}
126
127func buildCrashMarkdown(report crashReport) string {
128	lines := []string{
129		"# Unexpected Close Report",
130		"",
131		fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
132		fmt.Sprintf("Session: `%s`", report.SessionID),
133		fmt.Sprintf("Crashed PID: `%d`", report.CrashedPID),
134		"",
135		"## Reporter",
136		fmt.Sprintf("- pid: `%d`", report.ReporterProcess.PID),
137		fmt.Sprintf("- ppid: `%d`", report.ReporterProcess.PPID),
138		fmt.Sprintf("- go: `%s`", report.ReporterProcess.GoVersion),
139		"",
140		"## Core Dump Diagnostics",
141		fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
142		fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
143		fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
144	}
145
146	if len(report.SessionLogTail) > 0 {
147		lines = append(lines,
148			"",
149			"## Redacted Log Tail",
150			"```text",
151		)
152		lines = append(lines, report.SessionLogTail...)
153		lines = append(lines, "```")
154	}
155
156	lines = append(lines,
157		"",
158		"## GitHub Issue Template",
159		fmt.Sprintf("Title: %s", report.IssueTemplate.Title),
160		"",
161		"```markdown",
162		"### What happened",
163		"Describe what you were doing just before Dumber closed unexpectedly.",
164		"",
165		"### Crash report",
166		fmt.Sprintf("- session id: `%s`", report.SessionID),
167		fmt.Sprintf("- crashed pid: `%d`", report.CrashedPID),
168		"",
169		"### Additional context",
170		"- distro:",
171		"- compositor/window manager:",
172		"- steps to reproduce:",
173		"```",
174	)
175
176	return strings.Join(lines, "\n") + "\n"
177}
178
179// ---------------------------------------------------------------------------
180// Redaction helpers (kept from previous implementation)
181// ---------------------------------------------------------------------------
182
183func readRedactedLogTail(path string, lines int) []string {
184	if lines <= 0 {
185		return nil
186	}
187
188	file, err := os.Open(path)
189	if err != nil {
190		return nil
191	}
192	defer func() { _ = file.Close() }()
193
194	ring := make([]string, lines)
195	count := 0
196	scanner := bufio.NewScanner(file)
197	scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
198	for scanner.Scan() {
199		ring[count%lines] = redactSensitiveContent(scanner.Text())
200		count++
201	}
202
203	var result []string
204	if count <= lines {
205		result = make([]string, count)
206		copy(result, ring[:count])
207	} else {
208		result = make([]string, lines)
209		start := count % lines
210		copy(result, ring[start:])
211		copy(result[lines-start:], ring[:start])
212	}
213
214	if scanner.Err() != nil {
215		result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
216	}
217	return result
218}
219
220var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
221
222var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
223
224var secretJSONRegex = regexp.MustCompile(`(?i)"(token|access_token|id_token|password|secret)"\s*:\s*"(.*?)"`)
225
226var secretHeaderRegex = regexp.MustCompile(`(?i)(authorization|auth):\s*(\S[^\n,;]*)`)
227
228func redactSensitiveContent(line string) string {
229	redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
230	redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
231	redacted = secretJSONRegex.ReplaceAllString(redacted, `"$1":"[REDACTED]"`)
232	redacted = secretHeaderRegex.ReplaceAllString(redacted, "$1: [REDACTED]")
233	return redacted
234}
235
236func redactURLString(raw string) string {
237	trimmed := strings.TrimSpace(raw)
238	trimmed = strings.TrimRight(trimmed, ".,;:")
239	u, err := url.Parse(trimmed)
240	if err != nil {
241		return trimmed
242	}
243	u.RawQuery = ""
244	u.Fragment = ""
245	return u.String()
246}
247
248func pruneOldCrashReports(reportsDir string, maxKeep int) {
249	matches, err := filepath.Glob(filepath.Join(reportsDir, "session_*.crash.json"))
250	if err != nil || len(matches) <= maxKeep {
251		return
252	}
253
254	type reportEntry struct {
255		path    string
256		modTime time.Time
257	}
258	entries := make([]reportEntry, 0, len(matches))
259	for _, p := range matches {
260		info, statErr := os.Stat(p)
261		if statErr != nil {
262			continue
263		}
264		entries = append(entries, reportEntry{path: p, modTime: info.ModTime()})
265	}
266
267	sort.Slice(entries, func(i, j int) bool {
268		return entries[i].modTime.After(entries[j].modTime)
269	})
270
271	for _, e := range entries[maxKeep:] {
272		_ = os.Remove(e.path)
273		mdPath := strings.TrimSuffix(e.path, ".json") + ".md"
274		_ = os.Remove(mdPath)
275	}
276}

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