detached 25342a3

feat(crash): add unexpected-close reports with CLI and issue template

commit
25342a3475cb21ed74c5dce59605e9e26c5e255f
author
bnema · · 2026-02-08 03:25 UTC
parent
64ad4fc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1064 -122
A .github/ISSUE_TEMPLATE/unexpected-close.yml +73 -0
@@ -0,0 +1,73 @@
1+name: Unexpected Close Report
2+description: Report an unexpected Dumber close with auto-generated crash report
3+title: "Unexpected close: "
4+labels:
5+ - bug
6+body:
7+ - type: markdown
8+ attributes:
9+ value: |
10+ Thanks for helping improve Dumber alpha stability.
11+
12+ Before submitting, run:
13+
14+ ```bash
15+ dumber crashes issue latest
16+ ```
17+
18+ Paste the output in the "Crash report" field below.
19+
20+ - type: textarea
21+ id: happened
22+ attributes:
23+ label: What happened?
24+ description: What were you doing just before Dumber closed?
25+ placeholder: "I had 3 tabs open, played a video, and Dumber closed after alt-tabbing..."
26+ validations:
27+ required: true
28+
29+ - type: textarea
30+ id: crash_report
31+ attributes:
32+ label: Crash report
33+ description: Paste output from `dumber crashes issue latest`
34+ render: markdown
35+ placeholder: "## GitHub Issue Template ..."
36+ validations:
37+ required: true
38+
39+ - type: input
40+ id: distro
41+ attributes:
42+ label: Linux distro
43+ placeholder: "Arch Linux"
44+ validations:
45+ required: true
46+
47+ - type: input
48+ id: wm
49+ attributes:
50+ label: Compositor / window manager
51+ placeholder: "Hyprland / Sway / River / Niri ..."
52+ validations:
53+ required: true
54+
55+ - type: textarea
56+ id: repro
57+ attributes:
58+ label: Reproduction steps
59+ description: Short, numbered steps help a lot.
60+ placeholder: |
61+ 1. Open Dumber
62+ 2. ...
63+ 3. ...
64+ validations:
65+ required: false
66+
67+ - type: textarea
68+ id: extra
69+ attributes:
70+ label: Additional context
71+ description: Anything else useful (screenshots, coredumpctl snippets, etc.)
72+ validations:
73+ required: false
@@ -0,0 +1,73 @@
1+name: Unexpected Close Report
2+description: Report an unexpected Dumber close with auto-generated crash report
3+title: "Unexpected close: "
4+labels:
5+ - bug
6+body:
7+ - type: markdown
8+ attributes:
9+ value: |
10+ Thanks for helping improve Dumber alpha stability.
11+
12+ Before submitting, run:
13+
14+ ```bash
15+ dumber crashes issue latest
16+ ```
17+
18+ Paste the output in the "Crash report" field below.
19+
20+ - type: textarea
21+ id: happened
22+ attributes:
23+ label: What happened?
24+ description: What were you doing just before Dumber closed?
25+ placeholder: "I had 3 tabs open, played a video, and Dumber closed after alt-tabbing..."
26+ validations:
27+ required: true
28+
29+ - type: textarea
30+ id: crash_report
31+ attributes:
32+ label: Crash report
33+ description: Paste output from `dumber crashes issue latest`
34+ render: markdown
35+ placeholder: "## GitHub Issue Template ..."
36+ validations:
37+ required: true
38+
39+ - type: input
40+ id: distro
41+ attributes:
42+ label: Linux distro
43+ placeholder: "Arch Linux"
44+ validations:
45+ required: true
46+
47+ - type: input
48+ id: wm
49+ attributes:
50+ label: Compositor / window manager
51+ placeholder: "Hyprland / Sway / River / Niri ..."
52+ validations:
53+ required: true
54+
55+ - type: textarea
56+ id: repro
57+ attributes:
58+ label: Reproduction steps
59+ description: Short, numbered steps help a lot.
60+ placeholder: |
61+ 1. Open Dumber
62+ 2. ...
63+ 3. ...
64+ validations:
65+ required: false
66+
67+ - type: textarea
68+ id: extra
69+ attributes:
70+ label: Additional context
71+ description: Anything else useful (screenshots, coredumpctl snippets, etc.)
72+ validations:
73+ required: false
M cmd/dumber/main.go +86 -36
@@ -7,6 +7,8 @@ import (
77 "os"
88 "os/signal"
99 "runtime"
10+ "runtime/debug"
11+ "strconv"
1012 "syscall"
1113
1214 "github.com/bnema/dumber/internal/application/port"
@@ -31,6 +33,7 @@ import (
3133 "github.com/bnema/dumber/internal/ui/component"
3234 "github.com/bnema/dumber/internal/ui/theme"
3335 "github.com/rs/zerolog"
36+ "golang.org/x/sys/unix"
3437 )
3538
3639 // Build-time variables (set via ldflags).
@@ -47,6 +50,8 @@ var initialURL string
4750 var restoreSessionID string
4851
4952 func main() {
53+ enableCrashForensics()
54+
5055 // Run GUI mode for browse command
5156 if len(os.Args) > 1 && os.Args[1] == "browse" {
5257 if len(os.Args) > 2 {
@@ -105,6 +110,7 @@ func runGUI() int {
105110
106111 log := logging.FromContext(ctx)
107112 logging.Trace().UpdateLogger(log)
113+ logCoreDumpLimits(ctx)
108114
109115 if stack.MessageRouter != nil {
110116 stack.MessageRouter.SetBaseContext(ctx)
@@ -198,7 +204,7 @@ func buildAndConfigureApp(
198204 uiDeps := buildUIDependencies(
199205 ctx, cfg, initResult.ThemeManager,
200206 initResult.ColorResolver, initResult.AdwaitaDetector,
201- stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
207+ stack, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(),
202208 )
203209 configureDeferredInit(uiDeps, cfg, browserSession)
204210 return ui.New(uiDeps)
@@ -295,8 +301,15 @@ func configureDeferredInit(
295301 go func() {
296302 if persistErr := session.Persist(bgCtx); persistErr != nil {
297303 logger.Error().Err(persistErr).Msg("deferred session persistence failed")
298- } else if uiDeps.OnSessionPersisted != nil {
299- uiDeps.OnSessionPersisted()
304+ } else {
305+ if uiDeps.OnCrashReportsDetected != nil {
306+ if reports := session.UnexpectedCloseReports(); len(reports) > 0 {
307+ uiDeps.OnCrashReportsDetected(reports)
308+ }
309+ }
310+ if uiDeps.OnSessionPersisted != nil {
311+ uiDeps.OnSessionPersisted()
312+ }
300313 }
301314 }()
302315 }
@@ -381,6 +394,41 @@ func setupSignalHandler(ctx context.Context, app *ui.App) {
381394 }()
382395 }
383396
397+func enableCrashForensics() {
398+ debug.SetTraceback("crash")
399+
400+ var limit unix.Rlimit
401+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
402+ return
403+ }
404+ if limit.Cur >= limit.Max {
405+ return
406+ }
407+ limit.Cur = limit.Max
408+ _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit)
409+}
410+
411+func logCoreDumpLimits(ctx context.Context) {
412+ log := logging.FromContext(ctx)
413+ var limit unix.Rlimit
414+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
415+ log.Debug().Err(err).Msg("failed to read RLIMIT_CORE")
416+ return
417+ }
418+
419+ log.Debug().
420+ Str("soft", formatRlimit(limit.Cur)).
421+ Str("hard", formatRlimit(limit.Max)).
422+ Msg("core dump limits")
423+}
424+
425+func formatRlimit(value uint64) string {
426+ if value == unix.RLIM_INFINITY {
427+ return "infinity"
428+ }
429+ return strconv.FormatUint(value, 10)
430+}
431+
384432 // repositories groups infrastructure layer repository implementations.
385433 type repositories struct {
386434 history repository.HistoryRepository
@@ -486,40 +534,42 @@ func buildUIDependencies(
486534 uc *useCases,
487535 idleInhibitor port.IdleInhibitor,
488536 currentSessionID entity.SessionID,
537+ startupCrashReports []string,
489538 ) *ui.Dependencies {
490539 return &ui.Dependencies{
491- Ctx: ctx,
492- Config: cfg,
493- InitialURL: initialURL,
494- RestoreSessionID: restoreSessionID,
495- Theme: themeManager,
496- ColorResolver: colorResolver,
497- AdwaitaDetector: adwaitaDetector,
498- XDG: xdg.New(),
499- WebContext: stack.Context,
500- Pool: stack.Pool,
501- Settings: stack.Settings,
502- Injector: stack.Injector,
503- MessageRouter: stack.MessageRouter,
504- FilterManager: stack.FilterManager,
505- HistoryRepo: repos.history,
506- FavoriteRepo: repos.favorite,
507- ZoomRepo: repos.zoom,
508- TabsUC: uc.tabs,
509- PanesUC: uc.panes,
510- HistoryUC: uc.history,
511- FavoritesUC: uc.favorites,
512- ZoomUC: uc.zoom,
513- NavigateUC: uc.navigate,
514- CopyURLUC: uc.copyURL,
515- Clipboard: uc.clipboard,
516- FaviconService: uc.favicon,
517- IdleInhibitor: idleInhibitor,
518- SessionRepo: repos.session,
519- SessionStateRepo: repos.sessionState,
520- CurrentSessionID: currentSessionID,
521- SnapshotUC: uc.snapshot,
522- CheckUpdateUC: uc.checkUpdate,
523- ApplyUpdateUC: uc.applyUpdate,
540+ Ctx: ctx,
541+ Config: cfg,
542+ InitialURL: initialURL,
543+ RestoreSessionID: restoreSessionID,
544+ StartupCrashReports: startupCrashReports,
545+ Theme: themeManager,
546+ ColorResolver: colorResolver,
547+ AdwaitaDetector: adwaitaDetector,
548+ XDG: xdg.New(),
549+ WebContext: stack.Context,
550+ Pool: stack.Pool,
551+ Settings: stack.Settings,
552+ Injector: stack.Injector,
553+ MessageRouter: stack.MessageRouter,
554+ FilterManager: stack.FilterManager,
555+ HistoryRepo: repos.history,
556+ FavoriteRepo: repos.favorite,
557+ ZoomRepo: repos.zoom,
558+ TabsUC: uc.tabs,
559+ PanesUC: uc.panes,
560+ HistoryUC: uc.history,
561+ FavoritesUC: uc.favorites,
562+ ZoomUC: uc.zoom,
563+ NavigateUC: uc.navigate,
564+ CopyURLUC: uc.copyURL,
565+ Clipboard: uc.clipboard,
566+ FaviconService: uc.favicon,
567+ IdleInhibitor: idleInhibitor,
568+ SessionRepo: repos.session,
569+ SessionStateRepo: repos.sessionState,
570+ CurrentSessionID: currentSessionID,
571+ SnapshotUC: uc.snapshot,
572+ CheckUpdateUC: uc.checkUpdate,
573+ ApplyUpdateUC: uc.applyUpdate,
524574 }
525575 }
@@ -7,6 +7,8 @@ import (
7 "os"7 "os"
8 "os/signal"8 "os/signal"
9 "runtime"9 "runtime"
10+ "runtime/debug"
11+ "strconv"
10 "syscall"12 "syscall"
11 13
12 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
@@ -31,6 +33,7 @@ import (
31 "github.com/bnema/dumber/internal/ui/component"33 "github.com/bnema/dumber/internal/ui/component"
32 "github.com/bnema/dumber/internal/ui/theme"34 "github.com/bnema/dumber/internal/ui/theme"
33 "github.com/rs/zerolog"35 "github.com/rs/zerolog"
36+ "golang.org/x/sys/unix"
34 )37 )
35 38
36 // Build-time variables (set via ldflags).39 // Build-time variables (set via ldflags).
@@ -47,6 +50,8 @@ var initialURL string
47 var restoreSessionID string50 var restoreSessionID string
48 51
49 func main() {52 func main() {
53+ enableCrashForensics()
54+
50 // Run GUI mode for browse command55 // Run GUI mode for browse command
51 if len(os.Args) > 1 && os.Args[1] == "browse" {56 if len(os.Args) > 1 && os.Args[1] == "browse" {
52 if len(os.Args) > 2 {57 if len(os.Args) > 2 {
@@ -105,6 +110,7 @@ func runGUI() int {
105 110
106 log := logging.FromContext(ctx)111 log := logging.FromContext(ctx)
107 logging.Trace().UpdateLogger(log)112 logging.Trace().UpdateLogger(log)
113+ logCoreDumpLimits(ctx)
108 114
109 if stack.MessageRouter != nil {115 if stack.MessageRouter != nil {
110 stack.MessageRouter.SetBaseContext(ctx)116 stack.MessageRouter.SetBaseContext(ctx)
@@ -198,7 +204,7 @@ func buildAndConfigureApp(
198 uiDeps := buildUIDependencies(204 uiDeps := buildUIDependencies(
199 ctx, cfg, initResult.ThemeManager,205 ctx, cfg, initResult.ThemeManager,
200 initResult.ColorResolver, initResult.AdwaitaDetector,206 initResult.ColorResolver, initResult.AdwaitaDetector,
201- stack, repos, useCases, idleInhibitor, browserSession.Session.ID,207+ stack, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(),
202 )208 )
203 configureDeferredInit(uiDeps, cfg, browserSession)209 configureDeferredInit(uiDeps, cfg, browserSession)
204 return ui.New(uiDeps)210 return ui.New(uiDeps)
@@ -295,8 +301,15 @@ func configureDeferredInit(
295 go func() {301 go func() {
296 if persistErr := session.Persist(bgCtx); persistErr != nil {302 if persistErr := session.Persist(bgCtx); persistErr != nil {
297 logger.Error().Err(persistErr).Msg("deferred session persistence failed")303 logger.Error().Err(persistErr).Msg("deferred session persistence failed")
298- } else if uiDeps.OnSessionPersisted != nil {304+ } else {
299- uiDeps.OnSessionPersisted()305+ if uiDeps.OnCrashReportsDetected != nil {
306+ if reports := session.UnexpectedCloseReports(); len(reports) > 0 {
307+ uiDeps.OnCrashReportsDetected(reports)
308+ }
309+ }
310+ if uiDeps.OnSessionPersisted != nil {
311+ uiDeps.OnSessionPersisted()
312+ }
300 }313 }
301 }()314 }()
302 }315 }
@@ -381,6 +394,41 @@ func setupSignalHandler(ctx context.Context, app *ui.App) {
381 }()394 }()
382 }395 }
383 396
397+func enableCrashForensics() {
398+ debug.SetTraceback("crash")
399+
400+ var limit unix.Rlimit
401+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
402+ return
403+ }
404+ if limit.Cur >= limit.Max {
405+ return
406+ }
407+ limit.Cur = limit.Max
408+ _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit)
409+}
410+
411+func logCoreDumpLimits(ctx context.Context) {
412+ log := logging.FromContext(ctx)
413+ var limit unix.Rlimit
414+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
415+ log.Debug().Err(err).Msg("failed to read RLIMIT_CORE")
416+ return
417+ }
418+
419+ log.Debug().
420+ Str("soft", formatRlimit(limit.Cur)).
421+ Str("hard", formatRlimit(limit.Max)).
422+ Msg("core dump limits")
423+}
424+
425+func formatRlimit(value uint64) string {
426+ if value == unix.RLIM_INFINITY {
427+ return "infinity"
428+ }
429+ return strconv.FormatUint(value, 10)
430+}
431+
384 // repositories groups infrastructure layer repository implementations.432 // repositories groups infrastructure layer repository implementations.
385 type repositories struct {433 type repositories struct {
386 history repository.HistoryRepository434 history repository.HistoryRepository
@@ -486,40 +534,42 @@ func buildUIDependencies(
486 uc *useCases,534 uc *useCases,
487 idleInhibitor port.IdleInhibitor,535 idleInhibitor port.IdleInhibitor,
488 currentSessionID entity.SessionID,536 currentSessionID entity.SessionID,
537+ startupCrashReports []string,
489 ) *ui.Dependencies {538 ) *ui.Dependencies {
490 return &ui.Dependencies{539 return &ui.Dependencies{
491- Ctx: ctx,540+ Ctx: ctx,
492- Config: cfg,541+ Config: cfg,
493- InitialURL: initialURL,542+ InitialURL: initialURL,
494- RestoreSessionID: restoreSessionID,543+ RestoreSessionID: restoreSessionID,
495- Theme: themeManager,544+ StartupCrashReports: startupCrashReports,
496- ColorResolver: colorResolver,545+ Theme: themeManager,
497- AdwaitaDetector: adwaitaDetector,546+ ColorResolver: colorResolver,
498- XDG: xdg.New(),547+ AdwaitaDetector: adwaitaDetector,
499- WebContext: stack.Context,548+ XDG: xdg.New(),
500- Pool: stack.Pool,549+ WebContext: stack.Context,
501- Settings: stack.Settings,550+ Pool: stack.Pool,
502- Injector: stack.Injector,551+ Settings: stack.Settings,
503- MessageRouter: stack.MessageRouter,552+ Injector: stack.Injector,
504- FilterManager: stack.FilterManager,553+ MessageRouter: stack.MessageRouter,
505- HistoryRepo: repos.history,554+ FilterManager: stack.FilterManager,
506- FavoriteRepo: repos.favorite,555+ HistoryRepo: repos.history,
507- ZoomRepo: repos.zoom,556+ FavoriteRepo: repos.favorite,
508- TabsUC: uc.tabs,557+ ZoomRepo: repos.zoom,
509- PanesUC: uc.panes,558+ TabsUC: uc.tabs,
510- HistoryUC: uc.history,559+ PanesUC: uc.panes,
511- FavoritesUC: uc.favorites,560+ HistoryUC: uc.history,
512- ZoomUC: uc.zoom,561+ FavoritesUC: uc.favorites,
513- NavigateUC: uc.navigate,562+ ZoomUC: uc.zoom,
514- CopyURLUC: uc.copyURL,563+ NavigateUC: uc.navigate,
515- Clipboard: uc.clipboard,564+ CopyURLUC: uc.copyURL,
516- FaviconService: uc.favicon,565+ Clipboard: uc.clipboard,
517- IdleInhibitor: idleInhibitor,566+ FaviconService: uc.favicon,
518- SessionRepo: repos.session,567+ IdleInhibitor: idleInhibitor,
519- SessionStateRepo: repos.sessionState,568+ SessionRepo: repos.session,
520- CurrentSessionID: currentSessionID,569+ SessionStateRepo: repos.sessionState,
521- SnapshotUC: uc.snapshot,570+ CurrentSessionID: currentSessionID,
522- CheckUpdateUC: uc.checkUpdate,571+ SnapshotUC: uc.snapshot,
523- ApplyUpdateUC: uc.applyUpdate,572+ CheckUpdateUC: uc.checkUpdate,
573+ ApplyUpdateUC: uc.applyUpdate,
524 }574 }
525 }575 }
M docs/cli/index.md +18 -0
@@ -15,6 +15,7 @@ Dumber provides commands for browser management, history, sessions, and diagnost
1515 | `dumber setup` | Setup desktop integration |
1616 | `dumber update` | Check for and install updates |
1717 | `dumber logs` | View application logs |
18+| `dumber crashes` | Inspect unexpected-close reports |
1819 | `dumber purge` | Remove data and configuration |
1920 | `dumber about` | Show version information |
2021 | `dumber gen-docs` | Generate documentation from CLI commands |
@@ -195,6 +196,23 @@ dumber logs clear [flags]
195196 |------|-------------|
196197 | `--all` | Remove all session logs |
197198
199+### crashes
200+
201+Inspect crash reports generated automatically after unexpected closes.
202+
203+```bash
204+dumber crashes
205+dumber crashes show <report|latest>
206+dumber crashes issue <report|latest>
207+```
208+
209+**Subcommands:**
210+
211+| Subcommand | Description |
212+|------------|-------------|
213+| `show <report|latest>` | Show full crash report markdown |
214+| `issue <report|latest>` | Print GitHub-ready issue section |
215+
198216 ### purge
199217
200218 Remove dumber data and configuration.
@@ -15,6 +15,7 @@ Dumber provides commands for browser management, history, sessions, and diagnost
15 | `dumber setup` | Setup desktop integration |15 | `dumber setup` | Setup desktop integration |
16 | `dumber update` | Check for and install updates |16 | `dumber update` | Check for and install updates |
17 | `dumber logs` | View application logs |17 | `dumber logs` | View application logs |
18+| `dumber crashes` | Inspect unexpected-close reports |
18 | `dumber purge` | Remove data and configuration |19 | `dumber purge` | Remove data and configuration |
19 | `dumber about` | Show version information |20 | `dumber about` | Show version information |
20 | `dumber gen-docs` | Generate documentation from CLI commands |21 | `dumber gen-docs` | Generate documentation from CLI commands |
@@ -195,6 +196,23 @@ dumber logs clear [flags]
195 |------|-------------|196 |------|-------------|
196 | `--all` | Remove all session logs |197 | `--all` | Remove all session logs |
197 198
199+### crashes
200+
201+Inspect crash reports generated automatically after unexpected closes.
202+
203+```bash
204+dumber crashes
205+dumber crashes show <report|latest>
206+dumber crashes issue <report|latest>
207+```
208+
209+**Subcommands:**
210+
211+| Subcommand | Description |
212+|------------|-------------|
213+| `show <report|latest>` | Show full crash report markdown |
214+| `issue <report|latest>` | Print GitHub-ready issue section |
215+
198 ### purge216 ### purge
199 217
200 Remove dumber data and configuration.218 Remove dumber data and configuration.
M docs/reference/session-exit-classification-runbook.md +12 -0
@@ -12,6 +12,12 @@ Marker directory (default):
1212 LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
1313 ```
1414
15+Crash report directory (auto-generated on startup when abrupt exits are detected):
16+
17+```bash
18+CRASH_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs/crashes"
19+```
20+
1521 ## Classification Model
1622
1723 - `clean_exit` (`marker-confirmed`)
@@ -86,6 +92,12 @@ journalctl -k --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -
8692 coredumpctl list | rg -i "dumber"
8793 ```
8894
95+5. Print GitHub-ready issue payload from generated report:
96+
97+```bash
98+dumber crashes issue latest
99+```
100+
89101 Interpretation guidance:
90102
91103 - OOM/killed lines near the startup window strengthen `external_kill_or_oom_inferred`.
@@ -12,6 +12,12 @@ Marker directory (default):
12 LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"12 LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
13 ```13 ```
14 14
15+Crash report directory (auto-generated on startup when abrupt exits are detected):
16+
17+```bash
18+CRASH_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs/crashes"
19+```
20+
15 ## Classification Model21 ## Classification Model
16 22
17 - `clean_exit` (`marker-confirmed`)23 - `clean_exit` (`marker-confirmed`)
@@ -86,6 +92,12 @@ journalctl -k --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -
86 coredumpctl list | rg -i "dumber"92 coredumpctl list | rg -i "dumber"
87 ```93 ```
88 94
95+5. Print GitHub-ready issue payload from generated report:
96+
97+```bash
98+dumber crashes issue latest
99+```
100+
89 Interpretation guidance:101 Interpretation guidance:
90 102
91 - OOM/killed lines near the startup window strengthen `external_kill_or_oom_inferred`.103 - OOM/killed lines near the startup window strengthen `external_kill_or_oom_inferred`.
M docs/reference/troubleshooting.md +4 -0
@@ -125,6 +125,9 @@ View application logs for debugging:
125125 dumber logs # List sessions
126126 dumber logs <session-id> # View specific session
127127 dumber logs -f <id> # Follow in real-time
128+dumber crashes # List unexpected-close reports
129+dumber crashes show latest
130+dumber crashes issue latest
128131 ```
129132
130133 ## Reset Configuration
@@ -147,4 +150,5 @@ dumber purge
147150 ## Getting Help
148151
149152 - Check logs: `dumber logs`
153+- Check crash reports: `dumber crashes`
150154 - [GitHub Issues](https://github.com/bnema/dumber/issues)
@@ -125,6 +125,9 @@ View application logs for debugging:
125 dumber logs # List sessions125 dumber logs # List sessions
126 dumber logs <session-id> # View specific session126 dumber logs <session-id> # View specific session
127 dumber logs -f <id> # Follow in real-time127 dumber logs -f <id> # Follow in real-time
128+dumber crashes # List unexpected-close reports
129+dumber crashes show latest
130+dumber crashes issue latest
128 ```131 ```
129 132
130 ## Reset Configuration133 ## Reset Configuration
@@ -147,4 +150,5 @@ dumber purge
147 ## Getting Help150 ## Getting Help
148 151
149 - Check logs: `dumber logs`152 - Check logs: `dumber logs`
153+- Check crash reports: `dumber crashes`
150 - [GitHub Issues](https://github.com/bnema/dumber/issues)154 - [GitHub Issues](https://github.com/bnema/dumber/issues)
A internal/bootstrap/crash_report.go +290 -0
@@ -0,0 +1,290 @@
1+package bootstrap
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "net/url"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "runtime"
13+ "strconv"
14+ "strings"
15+ "time"
16+
17+ corelogging "github.com/bnema/dumber/internal/logging"
18+ "golang.org/x/sys/unix"
19+)
20+
21+const (
22+ crashReportsDirName = "crashes"
23+ logTailLineCount = 120
24+)
25+
26+type unexpectedCloseReport struct {
27+ ReportVersion int `json:"report_version"`
28+ GeneratedAt string `json:"generated_at"`
29+ SessionID string `json:"session_id"`
30+ Classification SessionExitClassification `json:"classification"`
31+ StartupPID int `json:"startup_pid,omitempty"`
32+ StartupPPID int `json:"startup_ppid,omitempty"`
33+ MarkerFiles map[string]string `json:"marker_files"`
34+ SessionLogFile string `json:"session_log_file,omitempty"`
35+ SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
36+ ReporterProcess unexpectedCloseReporter `json:"reporter_process"`
37+ CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"`
38+ IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"`
39+ GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
40+}
41+
42+type unexpectedCloseReporter struct {
43+ GeneratedBy string `json:"generated_by"`
44+ GoVersion string `json:"go_version"`
45+ GOOS string `json:"goos"`
46+ GOARCH string `json:"goarch"`
47+ PID int `json:"pid"`
48+ PPID int `json:"ppid"`
49+}
50+
51+type unexpectedCloseCoreDump struct {
52+ RLimitCoreSoft string `json:"rlimit_core_soft"`
53+ RLimitCoreHard string `json:"rlimit_core_hard"`
54+ Hint string `json:"hint"`
55+}
56+
57+type unexpectedCloseIssueTemplate struct {
58+ Title string `json:"title"`
59+ Summary string `json:"summary"`
60+}
61+
62+func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
63+ if lockDir == "" || sessionID == "" {
64+ return "", errors.New("lockDir and sessionID are required")
65+ }
66+
67+ classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
68+ if classifyErr != nil {
69+ return "", classifyErr
70+ }
71+
72+ if classification.Class == SessionExitCleanExit {
73+ return "", nil
74+ }
75+
76+ reportsDir := filepath.Join(lockDir, crashReportsDirName)
77+ if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil {
78+ return "", err
79+ }
80+
81+ jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
82+ markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
83+ if _, statErr := os.Stat(jsonPath); statErr == nil {
84+ return jsonPath, nil
85+ } else if !os.IsNotExist(statErr) {
86+ return "", statErr
87+ }
88+
89+ report := unexpectedCloseReport{
90+ ReportVersion: 1,
91+ GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
92+ SessionID: sessionID,
93+ Classification: classification,
94+ MarkerFiles: reportMarkerFiles(lockDir, sessionID),
95+ ReporterProcess: currentReporter(),
96+ CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
97+ IssueTemplate: unexpectedCloseIssueTemplate{
98+ Title: fmt.Sprintf("Unexpected close in session %s", sessionID),
99+ Summary: "Dumber closed unexpectedly and generated this report automatically.",
100+ },
101+ }
102+
103+ startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID)
104+ report.StartupPID = startupPID
105+ report.StartupPPID = startupPPID
106+
107+ logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
108+ if _, statErr := os.Stat(logPath); statErr == nil {
109+ report.SessionLogFile = logPath
110+ report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
111+ }
112+
113+ report.GeneratedMarkdownRef = markdownPath
114+
115+ payload, err := json.MarshalIndent(report, "", " ")
116+ if err != nil {
117+ return "", err
118+ }
119+ payload = append(payload, '\n')
120+ if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil {
121+ return "", err
122+ }
123+
124+ markdown := buildUnexpectedCloseMarkdown(report)
125+ if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
126+ return "", err
127+ }
128+
129+ return jsonPath, nil
130+}
131+
132+func reportMarkerFiles(lockDir, sessionID string) map[string]string {
133+ return map[string]string{
134+ "startup": startupMarkerPath(lockDir, sessionID),
135+ "shutdown": shutdownMarkerPath(lockDir, sessionID),
136+ "abrupt": abruptMarkerPath(lockDir, sessionID),
137+ }
138+}
139+
140+func currentReporter() unexpectedCloseReporter {
141+ return unexpectedCloseReporter{
142+ GeneratedBy: "dumber",
143+ GoVersion: runtime.Version(),
144+ GOOS: runtime.GOOS,
145+ GOARCH: runtime.GOARCH,
146+ PID: os.Getpid(),
147+ PPID: os.Getppid(),
148+ }
149+}
150+
151+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
152+ d := unexpectedCloseCoreDump{
153+ Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
154+ }
155+ var limit unix.Rlimit
156+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
157+ d.RLimitCoreSoft = "unknown"
158+ d.RLimitCoreHard = "unknown"
159+ return d
160+ }
161+ d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
162+ d.RLimitCoreHard = formatRlimitCore(limit.Max)
163+ return d
164+}
165+
166+func formatRlimitCore(value uint64) string {
167+ if value == unix.RLIM_INFINITY {
168+ return "infinity"
169+ }
170+ return strconv.FormatUint(value, 10)
171+}
172+
173+func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
174+ raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
175+ if err != nil {
176+ raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID))
177+ if err != nil {
178+ return 0, 0
179+ }
180+ }
181+
182+ pid, _ := strconv.Atoi(markerValue(raw, "pid="))
183+ ppid, _ := strconv.Atoi(markerValue(raw, "ppid="))
184+ return pid, ppid
185+}
186+
187+func readRedactedLogTail(path string, lines int) []string {
188+ if lines <= 0 {
189+ return nil
190+ }
191+
192+ file, err := os.Open(path)
193+ if err != nil {
194+ return nil
195+ }
196+ defer func() { _ = file.Close() }()
197+
198+ all := make([]string, 0, lines)
199+ scanner := bufio.NewScanner(file)
200+ for scanner.Scan() {
201+ all = append(all, redactSensitiveContent(scanner.Text()))
202+ }
203+ if len(all) <= lines {
204+ return all
205+ }
206+ return all[len(all)-lines:]
207+}
208+
209+func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
210+ lines := []string{
211+ "# Unexpected Close Report",
212+ "",
213+ fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
214+ fmt.Sprintf("Session: `%s`", report.SessionID),
215+ fmt.Sprintf("Class: `%s`", report.Classification.Class),
216+ fmt.Sprintf("Inference: `%s`", report.Classification.Inference),
217+ fmt.Sprintf("Reason: `%s`", report.Classification.Reason),
218+ "",
219+ "## Process Context",
220+ fmt.Sprintf("- startup pid: `%d`", report.StartupPID),
221+ fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID),
222+ fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID),
223+ fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID),
224+ "",
225+ "## Core Dump Diagnostics",
226+ fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
227+ fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
228+ fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
229+ "",
230+ "## Marker Files",
231+ fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]),
232+ fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]),
233+ fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]),
234+ }
235+
236+ if len(report.SessionLogTail) > 0 {
237+ lines = append(lines,
238+ "",
239+ "## Redacted Log Tail",
240+ "```text",
241+ )
242+ lines = append(lines, report.SessionLogTail...)
243+ lines = append(lines, "```")
244+ }
245+
246+ lines = append(lines,
247+ "",
248+ "## GitHub Issue Template",
249+ fmt.Sprintf("Title: %s", report.IssueTemplate.Title),
250+ "",
251+ "```markdown",
252+ "### What happened",
253+ "Describe what you were doing just before Dumber closed unexpectedly.",
254+ "",
255+ "### Crash report",
256+ fmt.Sprintf("- session id: `%s`", report.SessionID),
257+ fmt.Sprintf("- class: `%s`", report.Classification.Class),
258+ fmt.Sprintf("- reason: `%s`", report.Classification.Reason),
259+ "",
260+ "### Additional context",
261+ "- distro:",
262+ "- compositor/window manager:",
263+ "- steps to reproduce:",
264+ "```",
265+ )
266+
267+ return strings.Join(lines, "\n") + "\n"
268+}
269+
270+var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
271+
272+var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
273+
274+func redactSensitiveContent(line string) string {
275+ redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
276+ redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
277+ return redacted
278+}
279+
280+func redactURLString(raw string) string {
281+ trimmed := strings.TrimSpace(raw)
282+ trimmed = strings.TrimRight(trimmed, ".,;:")
283+ u, err := url.Parse(trimmed)
284+ if err != nil {
285+ return trimmed
286+ }
287+ u.RawQuery = ""
288+ u.Fragment = ""
289+ return u.String()
290+}
@@ -0,0 +1,290 @@
1+package bootstrap
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "net/url"
9+ "os"
10+ "path/filepath"
11+ "regexp"
12+ "runtime"
13+ "strconv"
14+ "strings"
15+ "time"
16+
17+ corelogging "github.com/bnema/dumber/internal/logging"
18+ "golang.org/x/sys/unix"
19+)
20+
21+const (
22+ crashReportsDirName = "crashes"
23+ logTailLineCount = 120
24+)
25+
26+type unexpectedCloseReport struct {
27+ ReportVersion int `json:"report_version"`
28+ GeneratedAt string `json:"generated_at"`
29+ SessionID string `json:"session_id"`
30+ Classification SessionExitClassification `json:"classification"`
31+ StartupPID int `json:"startup_pid,omitempty"`
32+ StartupPPID int `json:"startup_ppid,omitempty"`
33+ MarkerFiles map[string]string `json:"marker_files"`
34+ SessionLogFile string `json:"session_log_file,omitempty"`
35+ SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
36+ ReporterProcess unexpectedCloseReporter `json:"reporter_process"`
37+ CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"`
38+ IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"`
39+ GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
40+}
41+
42+type unexpectedCloseReporter struct {
43+ GeneratedBy string `json:"generated_by"`
44+ GoVersion string `json:"go_version"`
45+ GOOS string `json:"goos"`
46+ GOARCH string `json:"goarch"`
47+ PID int `json:"pid"`
48+ PPID int `json:"ppid"`
49+}
50+
51+type unexpectedCloseCoreDump struct {
52+ RLimitCoreSoft string `json:"rlimit_core_soft"`
53+ RLimitCoreHard string `json:"rlimit_core_hard"`
54+ Hint string `json:"hint"`
55+}
56+
57+type unexpectedCloseIssueTemplate struct {
58+ Title string `json:"title"`
59+ Summary string `json:"summary"`
60+}
61+
62+func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
63+ if lockDir == "" || sessionID == "" {
64+ return "", errors.New("lockDir and sessionID are required")
65+ }
66+
67+ classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
68+ if classifyErr != nil {
69+ return "", classifyErr
70+ }
71+
72+ if classification.Class == SessionExitCleanExit {
73+ return "", nil
74+ }
75+
76+ reportsDir := filepath.Join(lockDir, crashReportsDirName)
77+ if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil {
78+ return "", err
79+ }
80+
81+ jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
82+ markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
83+ if _, statErr := os.Stat(jsonPath); statErr == nil {
84+ return jsonPath, nil
85+ } else if !os.IsNotExist(statErr) {
86+ return "", statErr
87+ }
88+
89+ report := unexpectedCloseReport{
90+ ReportVersion: 1,
91+ GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
92+ SessionID: sessionID,
93+ Classification: classification,
94+ MarkerFiles: reportMarkerFiles(lockDir, sessionID),
95+ ReporterProcess: currentReporter(),
96+ CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
97+ IssueTemplate: unexpectedCloseIssueTemplate{
98+ Title: fmt.Sprintf("Unexpected close in session %s", sessionID),
99+ Summary: "Dumber closed unexpectedly and generated this report automatically.",
100+ },
101+ }
102+
103+ startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID)
104+ report.StartupPID = startupPID
105+ report.StartupPPID = startupPPID
106+
107+ logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
108+ if _, statErr := os.Stat(logPath); statErr == nil {
109+ report.SessionLogFile = logPath
110+ report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
111+ }
112+
113+ report.GeneratedMarkdownRef = markdownPath
114+
115+ payload, err := json.MarshalIndent(report, "", " ")
116+ if err != nil {
117+ return "", err
118+ }
119+ payload = append(payload, '\n')
120+ if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil {
121+ return "", err
122+ }
123+
124+ markdown := buildUnexpectedCloseMarkdown(report)
125+ if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
126+ return "", err
127+ }
128+
129+ return jsonPath, nil
130+}
131+
132+func reportMarkerFiles(lockDir, sessionID string) map[string]string {
133+ return map[string]string{
134+ "startup": startupMarkerPath(lockDir, sessionID),
135+ "shutdown": shutdownMarkerPath(lockDir, sessionID),
136+ "abrupt": abruptMarkerPath(lockDir, sessionID),
137+ }
138+}
139+
140+func currentReporter() unexpectedCloseReporter {
141+ return unexpectedCloseReporter{
142+ GeneratedBy: "dumber",
143+ GoVersion: runtime.Version(),
144+ GOOS: runtime.GOOS,
145+ GOARCH: runtime.GOARCH,
146+ PID: os.Getpid(),
147+ PPID: os.Getppid(),
148+ }
149+}
150+
151+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
152+ d := unexpectedCloseCoreDump{
153+ Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
154+ }
155+ var limit unix.Rlimit
156+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
157+ d.RLimitCoreSoft = "unknown"
158+ d.RLimitCoreHard = "unknown"
159+ return d
160+ }
161+ d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
162+ d.RLimitCoreHard = formatRlimitCore(limit.Max)
163+ return d
164+}
165+
166+func formatRlimitCore(value uint64) string {
167+ if value == unix.RLIM_INFINITY {
168+ return "infinity"
169+ }
170+ return strconv.FormatUint(value, 10)
171+}
172+
173+func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
174+ raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
175+ if err != nil {
176+ raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID))
177+ if err != nil {
178+ return 0, 0
179+ }
180+ }
181+
182+ pid, _ := strconv.Atoi(markerValue(raw, "pid="))
183+ ppid, _ := strconv.Atoi(markerValue(raw, "ppid="))
184+ return pid, ppid
185+}
186+
187+func readRedactedLogTail(path string, lines int) []string {
188+ if lines <= 0 {
189+ return nil
190+ }
191+
192+ file, err := os.Open(path)
193+ if err != nil {
194+ return nil
195+ }
196+ defer func() { _ = file.Close() }()
197+
198+ all := make([]string, 0, lines)
199+ scanner := bufio.NewScanner(file)
200+ for scanner.Scan() {
201+ all = append(all, redactSensitiveContent(scanner.Text()))
202+ }
203+ if len(all) <= lines {
204+ return all
205+ }
206+ return all[len(all)-lines:]
207+}
208+
209+func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
210+ lines := []string{
211+ "# Unexpected Close Report",
212+ "",
213+ fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
214+ fmt.Sprintf("Session: `%s`", report.SessionID),
215+ fmt.Sprintf("Class: `%s`", report.Classification.Class),
216+ fmt.Sprintf("Inference: `%s`", report.Classification.Inference),
217+ fmt.Sprintf("Reason: `%s`", report.Classification.Reason),
218+ "",
219+ "## Process Context",
220+ fmt.Sprintf("- startup pid: `%d`", report.StartupPID),
221+ fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID),
222+ fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID),
223+ fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID),
224+ "",
225+ "## Core Dump Diagnostics",
226+ fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
227+ fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
228+ fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
229+ "",
230+ "## Marker Files",
231+ fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]),
232+ fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]),
233+ fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]),
234+ }
235+
236+ if len(report.SessionLogTail) > 0 {
237+ lines = append(lines,
238+ "",
239+ "## Redacted Log Tail",
240+ "```text",
241+ )
242+ lines = append(lines, report.SessionLogTail...)
243+ lines = append(lines, "```")
244+ }
245+
246+ lines = append(lines,
247+ "",
248+ "## GitHub Issue Template",
249+ fmt.Sprintf("Title: %s", report.IssueTemplate.Title),
250+ "",
251+ "```markdown",
252+ "### What happened",
253+ "Describe what you were doing just before Dumber closed unexpectedly.",
254+ "",
255+ "### Crash report",
256+ fmt.Sprintf("- session id: `%s`", report.SessionID),
257+ fmt.Sprintf("- class: `%s`", report.Classification.Class),
258+ fmt.Sprintf("- reason: `%s`", report.Classification.Reason),
259+ "",
260+ "### Additional context",
261+ "- distro:",
262+ "- compositor/window manager:",
263+ "- steps to reproduce:",
264+ "```",
265+ )
266+
267+ return strings.Join(lines, "\n") + "\n"
268+}
269+
270+var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
271+
272+var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
273+
274+func redactSensitiveContent(line string) string {
275+ redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
276+ redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
277+ return redacted
278+}
279+
280+func redactURLString(raw string) string {
281+ trimmed := strings.TrimSpace(raw)
282+ trimmed = strings.TrimRight(trimmed, ".,;:")
283+ u, err := url.Parse(trimmed)
284+ if err != nil {
285+ return trimmed
286+ }
287+ u.RawQuery = ""
288+ u.Fragment = ""
289+ return u.String()
290+}
A internal/bootstrap/crash_report_test.go +67 -0
@@ -0,0 +1,67 @@
1+package bootstrap
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ corelogging "github.com/bnema/dumber/internal/logging"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestRedactSensitiveContent(t *testing.T) {
17+ line := "redirect to https://example.com/cb?code=abc123&token=secret#frag"
18+ redacted := redactSensitiveContent(line)
19+
20+ assert.Contains(t, redacted, "https://example.com/cb")
21+ assert.NotContains(t, redacted, "code=abc123")
22+ assert.NotContains(t, redacted, "token=secret")
23+ assert.NotContains(t, redacted, "#frag")
24+}
25+
26+func TestWriteUnexpectedCloseReport(t *testing.T) {
27+ lockDir := t.TempDir()
28+ sessionID := "crash-test-1"
29+ startedAt := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
30+
31+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
32+ _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil)
33+ require.NoError(t, err)
34+
35+ logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
36+ logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` +
37+ "\n" +
38+ `{"level":"warn","message":"callback code=abc token=def"}` +
39+ "\n"
40+ require.NoError(t, os.WriteFile(logPath, []byte(logBody), markerFilePerm))
41+
42+ jsonPath, err := writeUnexpectedCloseReport(lockDir, sessionID)
43+ require.NoError(t, err)
44+ require.NotEmpty(t, jsonPath)
45+ assert.FileExists(t, jsonPath)
46+
47+ mdPath := strings.TrimSuffix(jsonPath, ".json") + ".md"
48+ assert.FileExists(t, mdPath)
49+
50+ raw, err := os.ReadFile(jsonPath)
51+ require.NoError(t, err)
52+
53+ var payload map[string]any
54+ require.NoError(t, json.Unmarshal(raw, &payload))
55+ assert.Equal(t, sessionID, payload["session_id"])
56+
57+ classification := payload["classification"].(map[string]any)
58+ classValue, _ := classification["Class"].(string)
59+ assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
60+
61+ tail := payload["session_log_tail_redacted"].([]any)
62+ require.NotEmpty(t, tail)
63+ text := tail[0].(string) + tail[len(tail)-1].(string)
64+ assert.NotContains(t, text, "?a=1&b=2")
65+ assert.NotContains(t, text, "code=abc")
66+ assert.NotContains(t, text, "token=def")
67+}
@@ -0,0 +1,67 @@
1+package bootstrap
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ corelogging "github.com/bnema/dumber/internal/logging"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestRedactSensitiveContent(t *testing.T) {
17+ line := "redirect to https://example.com/cb?code=abc123&token=secret#frag"
18+ redacted := redactSensitiveContent(line)
19+
20+ assert.Contains(t, redacted, "https://example.com/cb")
21+ assert.NotContains(t, redacted, "code=abc123")
22+ assert.NotContains(t, redacted, "token=secret")
23+ assert.NotContains(t, redacted, "#frag")
24+}
25+
26+func TestWriteUnexpectedCloseReport(t *testing.T) {
27+ lockDir := t.TempDir()
28+ sessionID := "crash-test-1"
29+ startedAt := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
30+
31+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
32+ _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil)
33+ require.NoError(t, err)
34+
35+ logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
36+ logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` +
37+ "\n" +
38+ `{"level":"warn","message":"callback code=abc token=def"}` +
39+ "\n"
40+ require.NoError(t, os.WriteFile(logPath, []byte(logBody), markerFilePerm))
41+
42+ jsonPath, err := writeUnexpectedCloseReport(lockDir, sessionID)
43+ require.NoError(t, err)
44+ require.NotEmpty(t, jsonPath)
45+ assert.FileExists(t, jsonPath)
46+
47+ mdPath := strings.TrimSuffix(jsonPath, ".json") + ".md"
48+ assert.FileExists(t, mdPath)
49+
50+ raw, err := os.ReadFile(jsonPath)
51+ require.NoError(t, err)
52+
53+ var payload map[string]any
54+ require.NoError(t, json.Unmarshal(raw, &payload))
55+ assert.Equal(t, sessionID, payload["session_id"])
56+
57+ classification := payload["classification"].(map[string]any)
58+ classValue, _ := classification["Class"].(string)
59+ assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
60+
61+ tail := payload["session_log_tail_redacted"].([]any)
62+ require.NotEmpty(t, tail)
63+ text := tail[0].(string) + tail[len(tail)-1].(string)
64+ assert.NotContains(t, text, "?a=1&b=2")
65+ assert.NotContains(t, text, "code=abc")
66+ assert.NotContains(t, text, "token=def")
67+}
M internal/bootstrap/session.go +207 -80
@@ -33,10 +33,31 @@ type BrowserSession struct {
3333 Logger zerolog.Logger
3434 LogCleanup func()
3535
36+ reportsMu sync.RWMutex
37+ unexpectedCloseReportPath []string
38+
3639 Persist func(context.Context) error
3740 End func(context.Context) error
3841 }
3942
43+func (s *BrowserSession) SetUnexpectedCloseReports(paths []string) {
44+ if s == nil {
45+ return
46+ }
47+ s.reportsMu.Lock()
48+ defer s.reportsMu.Unlock()
49+ s.unexpectedCloseReportPath = append([]string(nil), paths...)
50+}
51+
52+func (s *BrowserSession) UnexpectedCloseReports() []string {
53+ if s == nil {
54+ return nil
55+ }
56+ s.reportsMu.RLock()
57+ defer s.reportsMu.RUnlock()
58+ return append([]string(nil), s.unexpectedCloseReportPath...)
59+}
60+
4061 func StartBrowserSession(
4162 ctx context.Context,
4263 cfg *config.Config,
@@ -91,6 +112,12 @@ func StartBrowserSession(
91112 persistErr error
92113 )
93114
115+ browserSession := &BrowserSession{
116+ Session: session,
117+ Logger: logger,
118+ LogCleanup: cleanup,
119+ }
120+
94121 // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:
95122 // if the first persist fails, we cache the error and return it on subsequent calls
96123 // rather than retrying. This prevents duplicate session records and ensures consistent
@@ -98,15 +125,11 @@ func StartBrowserSession(
98125 persistFn := func(persistCtx context.Context) error {
99126 persistMu.Do(func() {
100127 if lockDir != "" {
101- abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), &logger)
128+ generatedReports, abruptErr := detectAbruptSessionsAndBuildReports(lockDir, &logger)
102129 if abruptErr != nil {
103130 logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")
104- } else {
105- for _, abruptSessionID := range abruptSessions {
106- logger.Warn().
107- Str("session_id", abruptSessionID).
108- Msg("abrupt-exit marker detected (startup without shutdown)")
109- }
131+ } else if len(generatedReports) > 0 {
132+ browserSession.SetUnexpectedCloseReports(generatedReports)
110133 }
111134 }
112135
@@ -167,12 +190,7 @@ func StartBrowserSession(
167190 return endErr
168191 }
169192
170- browserSession := &BrowserSession{
171- Session: session,
172- Logger: logger,
173- LogCleanup: cleanup,
174- End: endFn,
175- }
193+ browserSession.End = endFn
176194
177195 if deferPersist {
178196 browserSession.Persist = persistFn
@@ -188,6 +206,44 @@ func StartBrowserSession(
188206 return browserSession, sessionCtx, nil
189207 }
190208
209+func detectAbruptSessionsAndBuildReports(lockDir string, logger *zerolog.Logger) ([]string, error) {
210+ abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), logger)
211+ if abruptErr != nil {
212+ return nil, abruptErr
213+ }
214+
215+ generatedReports := make([]string, 0, len(abruptSessions))
216+ for _, abruptSessionID := range abruptSessions {
217+ if logger != nil {
218+ logger.Warn().
219+ Str("session_id", abruptSessionID).
220+ Msg("abrupt-exit marker detected (startup without shutdown)")
221+ }
222+ reportPath, reportErr := writeUnexpectedCloseReport(lockDir, abruptSessionID)
223+ if reportErr != nil {
224+ if logger != nil {
225+ logger.Warn().
226+ Err(reportErr).
227+ Str("session_id", abruptSessionID).
228+ Msg("failed to write unexpected-close report")
229+ }
230+ continue
231+ }
232+ if reportPath == "" {
233+ continue
234+ }
235+ generatedReports = append(generatedReports, reportPath)
236+ if logger != nil {
237+ logger.Warn().
238+ Str("session_id", abruptSessionID).
239+ Str("report_path", reportPath).
240+ Msg("unexpected-close report generated")
241+ }
242+ }
243+
244+ return generatedReports, nil
245+}
246+
191247 func endStaleActiveBrowserSessions(
192248 ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,
193249 ) error {
@@ -309,7 +365,12 @@ func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error {
309365 if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
310366 return err
311367 }
312- content := []byte(startedAt.Format(time.RFC3339Nano) + "\n")
368+ content := []byte(fmt.Sprintf(
369+ "%s\npid=%d\nppid=%d\n",
370+ startedAt.Format(time.RFC3339Nano),
371+ os.Getpid(),
372+ os.Getppid(),
373+ ))
313374 if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
314375 return err
315376 }
@@ -330,14 +391,22 @@ func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
330391 // ClassifySessionExitFromMarkers can still read it after the
331392 // startup marker file is removed.
332393 var startupLine string
394+ var pidLine string
395+ var ppidLine string
333396 startupPath := startupMarkerPath(lockDir, sessionID)
334397 if raw, readErr := os.ReadFile(startupPath); readErr == nil {
335- if t := strings.TrimSpace(string(raw)); t != "" {
398+ if t := firstNonEmptyLine(raw); t != "" {
336399 startupLine = "started_at=" + t + "\n"
337400 }
401+ if pid := markerValue(raw, "pid="); pid != "" {
402+ pidLine = "pid=" + pid + "\n"
403+ }
404+ if ppid := markerValue(raw, "ppid="); ppid != "" {
405+ ppidLine = "ppid=" + ppid + "\n"
406+ }
338407 }
339408
340- content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine)
409+ content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine + pidLine + ppidLine)
341410 if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
342411 return err
343412 }
@@ -359,81 +428,35 @@ func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logge
359428
360429 abruptSessions := make([]string, 0, len(startupMarkers))
361430 for _, startupPath := range startupMarkers {
362- base := filepath.Base(startupPath)
363- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {
364- continue
365- }
366- sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
431+ sessionID := sessionIDFromStartupMarker(startupPath)
367432 if sessionID == "" {
368433 continue
369434 }
370435
371- lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID))
372- f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
373- if openErr != nil {
374- if os.IsNotExist(openErr) {
375- // No lock file: fall through to mark as abrupt.
376- } else {
377- if logger != nil {
378- logger.Warn().
379- Err(openErr).
380- Str("session_id", sessionID).
381- Str("path", lockPath).
382- Msg("markAbruptExits: open lock file failed")
383- }
384- continue
385- }
386- } else {
387- locked, lockErr := tryLockExclusiveNonBlocking(f)
388- if lockErr != nil {
389- _ = f.Close()
390- if logger != nil {
391- logger.Warn().
392- Err(lockErr).
393- Str("session_id", sessionID).
394- Str("path", lockPath).
395- Msg("markAbruptExits: lock probe failed")
396- }
397- continue
398- }
399- if !locked {
400- // Lock is held by another process: session still active.
401- _ = f.Close()
402- continue
403- }
404- // Lock acquired: no process holds it, session is stale.
405- _ = unlockAndClose(f)
406- }
407-
408- shutdownPath := shutdownMarkerPath(lockDir, sessionID)
409- if _, err := os.Stat(shutdownPath); err == nil {
410- continue
411- } else if !os.IsNotExist(err) {
436+ shouldMark, checkErr := shouldMarkAbruptExit(lockDir, sessionID, logger)
437+ if checkErr != nil {
412438 if logger != nil {
413- logger.Warn().
414- Err(err).
415- Str("session_id", sessionID).
416- Str("path", shutdownPath).
417- Msg("markAbruptExits: stat failed")
439+ logger.Warn().Err(checkErr).Str("session_id", sessionID).Msg("markAbruptExits: marker checks failed")
418440 }
419441 continue
420442 }
421- abruptPath := abruptMarkerPath(lockDir, sessionID)
422- if _, err := os.Stat(abruptPath); err == nil {
423- continue
424- } else if !os.IsNotExist(err) {
425- if logger != nil {
426- logger.Warn().
427- Err(err).
428- Str("session_id", sessionID).
429- Str("path", abruptPath).
430- Msg("markAbruptExits: stat failed")
431- }
443+ if !shouldMark {
432444 continue
433445 }
434446
435- payload := []byte(fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath))
436- if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), payload, markerFilePerm); err != nil {
447+ startupRaw, _ := os.ReadFile(startupPath)
448+ payload := fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath)
449+ if startupAt := firstNonEmptyLine(startupRaw); startupAt != "" {
450+ payload += "started_at=" + startupAt + "\n"
451+ }
452+ if pid := markerValue(startupRaw, "pid="); pid != "" {
453+ payload += "pid=" + pid + "\n"
454+ }
455+ if ppid := markerValue(startupRaw, "ppid="); ppid != "" {
456+ payload += "ppid=" + ppid + "\n"
457+ }
458+
459+ if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil {
437460 return abruptSessions, err
438461 }
439462 abruptSessions = append(abruptSessions, sessionID)
@@ -442,6 +465,110 @@ func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logge
442465 return abruptSessions, nil
443466 }
444467
468+func sessionIDFromStartupMarker(startupPath string) string {
469+ base := filepath.Base(startupPath)
470+ if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {
471+ return ""
472+ }
473+ return strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
474+}
475+
476+func shouldMarkAbruptExit(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
477+ active, probeErr := sessionLockHeldByLiveProcess(lockDir, sessionID, logger)
478+ if probeErr != nil {
479+ return false, probeErr
480+ }
481+ if active {
482+ return false, nil
483+ }
484+
485+ shutdownPath := shutdownMarkerPath(lockDir, sessionID)
486+ if exists, statErr := markerExists(shutdownPath); statErr != nil {
487+ return false, statErr
488+ } else if exists {
489+ return false, nil
490+ }
491+
492+ abruptPath := abruptMarkerPath(lockDir, sessionID)
493+ if exists, statErr := markerExists(abruptPath); statErr != nil {
494+ return false, statErr
495+ } else if exists {
496+ return false, nil
497+ }
498+
499+ return true, nil
500+}
501+
502+func sessionLockHeldByLiveProcess(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
503+ lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID))
504+ f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
505+ if openErr != nil {
506+ if os.IsNotExist(openErr) {
507+ return false, nil
508+ }
509+ if logger != nil {
510+ logger.Warn().
511+ Err(openErr).
512+ Str("session_id", sessionID).
513+ Str("path", lockPath).
514+ Msg("markAbruptExits: open lock file failed")
515+ }
516+ return false, openErr
517+ }
518+
519+ locked, lockErr := tryLockExclusiveNonBlocking(f)
520+ if lockErr != nil {
521+ _ = f.Close()
522+ if logger != nil {
523+ logger.Warn().
524+ Err(lockErr).
525+ Str("session_id", sessionID).
526+ Str("path", lockPath).
527+ Msg("markAbruptExits: lock probe failed")
528+ }
529+ return false, lockErr
530+ }
531+ if !locked {
532+ _ = f.Close()
533+ return true, nil
534+ }
535+ _ = unlockAndClose(f)
536+ return false, nil
537+}
538+
539+func markerExists(path string) (bool, error) {
540+ _, err := os.Stat(path)
541+ if err == nil {
542+ return true, nil
543+ }
544+ if os.IsNotExist(err) {
545+ return false, nil
546+ }
547+ return false, err
548+}
549+
550+func firstNonEmptyLine(raw []byte) string {
551+ trimmed := strings.TrimSpace(string(raw))
552+ if trimmed == "" {
553+ return ""
554+ }
555+ first, _, _ := strings.Cut(trimmed, "\n")
556+ return strings.TrimSpace(first)
557+}
558+
559+func markerValue(raw []byte, key string) string {
560+ if key == "" || len(raw) == 0 {
561+ return ""
562+ }
563+ for _, line := range strings.Split(string(raw), "\n") {
564+ line = strings.TrimSpace(line)
565+ if strings.HasPrefix(line, key) {
566+ return strings.TrimSpace(strings.TrimPrefix(line, key))
567+ }
568+ }
569+ return ""
570+}
571+
445572 // runSessionCleanupAsync performs stale session cleanup and old session pruning
446573 // in a background goroutine. This avoids blocking startup for non-critical tasks.
447574 // Uses context.Background() to ensure cleanup completes even if startup context is canceled.
@@ -33,10 +33,31 @@ type BrowserSession struct {
33 Logger zerolog.Logger33 Logger zerolog.Logger
34 LogCleanup func()34 LogCleanup func()
35 35
36+ reportsMu sync.RWMutex
37+ unexpectedCloseReportPath []string
38+
36 Persist func(context.Context) error39 Persist func(context.Context) error
37 End func(context.Context) error40 End func(context.Context) error
38 }41 }
39 42
43+func (s *BrowserSession) SetUnexpectedCloseReports(paths []string) {
44+ if s == nil {
45+ return
46+ }
47+ s.reportsMu.Lock()
48+ defer s.reportsMu.Unlock()
49+ s.unexpectedCloseReportPath = append([]string(nil), paths...)
50+}
51+
52+func (s *BrowserSession) UnexpectedCloseReports() []string {
53+ if s == nil {
54+ return nil
55+ }
56+ s.reportsMu.RLock()
57+ defer s.reportsMu.RUnlock()
58+ return append([]string(nil), s.unexpectedCloseReportPath...)
59+}
60+
40 func StartBrowserSession(61 func StartBrowserSession(
41 ctx context.Context,62 ctx context.Context,
42 cfg *config.Config,63 cfg *config.Config,
@@ -91,6 +112,12 @@ func StartBrowserSession(
91 persistErr error112 persistErr error
92 )113 )
93 114
115+ browserSession := &BrowserSession{
116+ Session: session,
117+ Logger: logger,
118+ LogCleanup: cleanup,
119+ }
120+
94 // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:121 // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:
95 // if the first persist fails, we cache the error and return it on subsequent calls122 // if the first persist fails, we cache the error and return it on subsequent calls
96 // rather than retrying. This prevents duplicate session records and ensures consistent123 // rather than retrying. This prevents duplicate session records and ensures consistent
@@ -98,15 +125,11 @@ func StartBrowserSession(
98 persistFn := func(persistCtx context.Context) error {125 persistFn := func(persistCtx context.Context) error {
99 persistMu.Do(func() {126 persistMu.Do(func() {
100 if lockDir != "" {127 if lockDir != "" {
101- abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), &logger)128+ generatedReports, abruptErr := detectAbruptSessionsAndBuildReports(lockDir, &logger)
102 if abruptErr != nil {129 if abruptErr != nil {
103 logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")130 logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")
104- } else {131+ } else if len(generatedReports) > 0 {
105- for _, abruptSessionID := range abruptSessions {132+ browserSession.SetUnexpectedCloseReports(generatedReports)
106- logger.Warn().
107- Str("session_id", abruptSessionID).
108- Msg("abrupt-exit marker detected (startup without shutdown)")
109- }
110 }133 }
111 }134 }
112 135
@@ -167,12 +190,7 @@ func StartBrowserSession(
167 return endErr190 return endErr
168 }191 }
169 192
170- browserSession := &BrowserSession{193+ browserSession.End = endFn
171- Session: session,
172- Logger: logger,
173- LogCleanup: cleanup,
174- End: endFn,
175- }
176 194
177 if deferPersist {195 if deferPersist {
178 browserSession.Persist = persistFn196 browserSession.Persist = persistFn
@@ -188,6 +206,44 @@ func StartBrowserSession(
188 return browserSession, sessionCtx, nil206 return browserSession, sessionCtx, nil
189 }207 }
190 208
209+func detectAbruptSessionsAndBuildReports(lockDir string, logger *zerolog.Logger) ([]string, error) {
210+ abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), logger)
211+ if abruptErr != nil {
212+ return nil, abruptErr
213+ }
214+
215+ generatedReports := make([]string, 0, len(abruptSessions))
216+ for _, abruptSessionID := range abruptSessions {
217+ if logger != nil {
218+ logger.Warn().
219+ Str("session_id", abruptSessionID).
220+ Msg("abrupt-exit marker detected (startup without shutdown)")
221+ }
222+ reportPath, reportErr := writeUnexpectedCloseReport(lockDir, abruptSessionID)
223+ if reportErr != nil {
224+ if logger != nil {
225+ logger.Warn().
226+ Err(reportErr).
227+ Str("session_id", abruptSessionID).
228+ Msg("failed to write unexpected-close report")
229+ }
230+ continue
231+ }
232+ if reportPath == "" {
233+ continue
234+ }
235+ generatedReports = append(generatedReports, reportPath)
236+ if logger != nil {
237+ logger.Warn().
238+ Str("session_id", abruptSessionID).
239+ Str("report_path", reportPath).
240+ Msg("unexpected-close report generated")
241+ }
242+ }
243+
244+ return generatedReports, nil
245+}
246+
191 func endStaleActiveBrowserSessions(247 func endStaleActiveBrowserSessions(
192 ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,248 ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,
193 ) error {249 ) error {
@@ -309,7 +365,12 @@ func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error {
309 if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {365 if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
310 return err366 return err
311 }367 }
312- content := []byte(startedAt.Format(time.RFC3339Nano) + "\n")368+ content := []byte(fmt.Sprintf(
369+ "%s\npid=%d\nppid=%d\n",
370+ startedAt.Format(time.RFC3339Nano),
371+ os.Getpid(),
372+ os.Getppid(),
373+ ))
313 if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {374 if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
314 return err375 return err
315 }376 }
@@ -330,14 +391,22 @@ func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
330 // ClassifySessionExitFromMarkers can still read it after the391 // ClassifySessionExitFromMarkers can still read it after the
331 // startup marker file is removed.392 // startup marker file is removed.
332 var startupLine string393 var startupLine string
394+ var pidLine string
395+ var ppidLine string
333 startupPath := startupMarkerPath(lockDir, sessionID)396 startupPath := startupMarkerPath(lockDir, sessionID)
334 if raw, readErr := os.ReadFile(startupPath); readErr == nil {397 if raw, readErr := os.ReadFile(startupPath); readErr == nil {
335- if t := strings.TrimSpace(string(raw)); t != "" {398+ if t := firstNonEmptyLine(raw); t != "" {
336 startupLine = "started_at=" + t + "\n"399 startupLine = "started_at=" + t + "\n"
337 }400 }
401+ if pid := markerValue(raw, "pid="); pid != "" {
402+ pidLine = "pid=" + pid + "\n"
403+ }
404+ if ppid := markerValue(raw, "ppid="); ppid != "" {
405+ ppidLine = "ppid=" + ppid + "\n"
406+ }
338 }407 }
339 408
340- content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine)409+ content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine + pidLine + ppidLine)
341 if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {410 if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
342 return err411 return err
343 }412 }
@@ -359,81 +428,35 @@ func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logge
359 428
360 abruptSessions := make([]string, 0, len(startupMarkers))429 abruptSessions := make([]string, 0, len(startupMarkers))
361 for _, startupPath := range startupMarkers {430 for _, startupPath := range startupMarkers {
362- base := filepath.Base(startupPath)431+ sessionID := sessionIDFromStartupMarker(startupPath)
363- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {
364- continue
365- }
366- sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
367 if sessionID == "" {432 if sessionID == "" {
368 continue433 continue
369 }434 }
370 435
371- lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID))436+ shouldMark, checkErr := shouldMarkAbruptExit(lockDir, sessionID, logger)
372- f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)437+ if checkErr != nil {
373- if openErr != nil {
374- if os.IsNotExist(openErr) {
375- // No lock file: fall through to mark as abrupt.
376- } else {
377- if logger != nil {
378- logger.Warn().
379- Err(openErr).
380- Str("session_id", sessionID).
381- Str("path", lockPath).
382- Msg("markAbruptExits: open lock file failed")
383- }
384- continue
385- }
386- } else {
387- locked, lockErr := tryLockExclusiveNonBlocking(f)
388- if lockErr != nil {
389- _ = f.Close()
390- if logger != nil {
391- logger.Warn().
392- Err(lockErr).
393- Str("session_id", sessionID).
394- Str("path", lockPath).
395- Msg("markAbruptExits: lock probe failed")
396- }
397- continue
398- }
399- if !locked {
400- // Lock is held by another process: session still active.
401- _ = f.Close()
402- continue
403- }
404- // Lock acquired: no process holds it, session is stale.
405- _ = unlockAndClose(f)
406- }
407-
408- shutdownPath := shutdownMarkerPath(lockDir, sessionID)
409- if _, err := os.Stat(shutdownPath); err == nil {
410- continue
411- } else if !os.IsNotExist(err) {
412 if logger != nil {438 if logger != nil {
413- logger.Warn().439+ logger.Warn().Err(checkErr).Str("session_id", sessionID).Msg("markAbruptExits: marker checks failed")
414- Err(err).
415- Str("session_id", sessionID).
416- Str("path", shutdownPath).
417- Msg("markAbruptExits: stat failed")
418 }440 }
419 continue441 continue
420 }442 }
421- abruptPath := abruptMarkerPath(lockDir, sessionID)443+ if !shouldMark {
422- if _, err := os.Stat(abruptPath); err == nil {
423- continue
424- } else if !os.IsNotExist(err) {
425- if logger != nil {
426- logger.Warn().
427- Err(err).
428- Str("session_id", sessionID).
429- Str("path", abruptPath).
430- Msg("markAbruptExits: stat failed")
431- }
432 continue444 continue
433 }445 }
434 446
435- payload := []byte(fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath))447+ startupRaw, _ := os.ReadFile(startupPath)
436- if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), payload, markerFilePerm); err != nil {448+ payload := fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath)
449+ if startupAt := firstNonEmptyLine(startupRaw); startupAt != "" {
450+ payload += "started_at=" + startupAt + "\n"
451+ }
452+ if pid := markerValue(startupRaw, "pid="); pid != "" {
453+ payload += "pid=" + pid + "\n"
454+ }
455+ if ppid := markerValue(startupRaw, "ppid="); ppid != "" {
456+ payload += "ppid=" + ppid + "\n"
457+ }
458+
459+ if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil {
437 return abruptSessions, err460 return abruptSessions, err
438 }461 }
439 abruptSessions = append(abruptSessions, sessionID)462 abruptSessions = append(abruptSessions, sessionID)
@@ -442,6 +465,110 @@ func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logge
442 return abruptSessions, nil465 return abruptSessions, nil
443 }466 }
444 467
468+func sessionIDFromStartupMarker(startupPath string) string {
469+ base := filepath.Base(startupPath)
470+ if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {
471+ return ""
472+ }
473+ return strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
474+}
475+
476+func shouldMarkAbruptExit(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
477+ active, probeErr := sessionLockHeldByLiveProcess(lockDir, sessionID, logger)
478+ if probeErr != nil {
479+ return false, probeErr
480+ }
481+ if active {
482+ return false, nil
483+ }
484+
485+ shutdownPath := shutdownMarkerPath(lockDir, sessionID)
486+ if exists, statErr := markerExists(shutdownPath); statErr != nil {
487+ return false, statErr
488+ } else if exists {
489+ return false, nil
490+ }
491+
492+ abruptPath := abruptMarkerPath(lockDir, sessionID)
493+ if exists, statErr := markerExists(abruptPath); statErr != nil {
494+ return false, statErr
495+ } else if exists {
496+ return false, nil
497+ }
498+
499+ return true, nil
500+}
501+
502+func sessionLockHeldByLiveProcess(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
503+ lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID))
504+ f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
505+ if openErr != nil {
506+ if os.IsNotExist(openErr) {
507+ return false, nil
508+ }
509+ if logger != nil {
510+ logger.Warn().
511+ Err(openErr).
512+ Str("session_id", sessionID).
513+ Str("path", lockPath).
514+ Msg("markAbruptExits: open lock file failed")
515+ }
516+ return false, openErr
517+ }
518+
519+ locked, lockErr := tryLockExclusiveNonBlocking(f)
520+ if lockErr != nil {
521+ _ = f.Close()
522+ if logger != nil {
523+ logger.Warn().
524+ Err(lockErr).
525+ Str("session_id", sessionID).
526+ Str("path", lockPath).
527+ Msg("markAbruptExits: lock probe failed")
528+ }
529+ return false, lockErr
530+ }
531+ if !locked {
532+ _ = f.Close()
533+ return true, nil
534+ }
535+ _ = unlockAndClose(f)
536+ return false, nil
537+}
538+
539+func markerExists(path string) (bool, error) {
540+ _, err := os.Stat(path)
541+ if err == nil {
542+ return true, nil
543+ }
544+ if os.IsNotExist(err) {
545+ return false, nil
546+ }
547+ return false, err
548+}
549+
550+func firstNonEmptyLine(raw []byte) string {
551+ trimmed := strings.TrimSpace(string(raw))
552+ if trimmed == "" {
553+ return ""
554+ }
555+ first, _, _ := strings.Cut(trimmed, "\n")
556+ return strings.TrimSpace(first)
557+}
558+
559+func markerValue(raw []byte, key string) string {
560+ if key == "" || len(raw) == 0 {
561+ return ""
562+ }
563+ for _, line := range strings.Split(string(raw), "\n") {
564+ line = strings.TrimSpace(line)
565+ if strings.HasPrefix(line, key) {
566+ return strings.TrimSpace(strings.TrimPrefix(line, key))
567+ }
568+ }
569+ return ""
570+}
571+
445 // runSessionCleanupAsync performs stale session cleanup and old session pruning572 // runSessionCleanupAsync performs stale session cleanup and old session pruning
446 // in a background goroutine. This avoids blocking startup for non-critical tasks.573 // in a background goroutine. This avoids blocking startup for non-critical tasks.
447 // Uses context.Background() to ensure cleanup completes even if startup context is canceled.574 // Uses context.Background() to ensure cleanup completes even if startup context is canceled.
M internal/bootstrap/session_markers_test.go +8 -0
@@ -22,6 +22,8 @@ func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) {
2222 startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
2323 require.NoError(t, err)
2424 assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano))
25+ assert.Contains(t, string(startupContent), "pid=")
26+ assert.Contains(t, string(startupContent), "ppid=")
2527
2628 err = writeShutdownMarker(lockDir, sessionID, endedAt)
2729 require.NoError(t, err)
@@ -29,6 +31,9 @@ func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) {
2931 shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID))
3032 require.NoError(t, err)
3133 assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano))
34+ assert.Contains(t, string(shutdownContent), "started_at="+startedAt.Format(time.RFC3339Nano))
35+ assert.Contains(t, string(shutdownContent), "pid=")
36+ assert.Contains(t, string(shutdownContent), "ppid=")
3237 }
3338
3439 func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
@@ -46,6 +51,9 @@ func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
4651 abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a"))
4752 require.NoError(t, err)
4853 assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano))
54+ assert.Contains(t, string(abruptMarkerData), "started_at=")
55+ assert.Contains(t, string(abruptMarkerData), "pid=")
56+ assert.Contains(t, string(abruptMarkerData), "ppid=")
4957 assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker"))
5058 }
5159
@@ -22,6 +22,8 @@ func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) {
22 startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))22 startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
23 require.NoError(t, err)23 require.NoError(t, err)
24 assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano))24 assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano))
25+ assert.Contains(t, string(startupContent), "pid=")
26+ assert.Contains(t, string(startupContent), "ppid=")
25 27
26 err = writeShutdownMarker(lockDir, sessionID, endedAt)28 err = writeShutdownMarker(lockDir, sessionID, endedAt)
27 require.NoError(t, err)29 require.NoError(t, err)
@@ -29,6 +31,9 @@ func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) {
29 shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID))31 shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID))
30 require.NoError(t, err)32 require.NoError(t, err)
31 assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano))33 assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano))
34+ assert.Contains(t, string(shutdownContent), "started_at="+startedAt.Format(time.RFC3339Nano))
35+ assert.Contains(t, string(shutdownContent), "pid=")
36+ assert.Contains(t, string(shutdownContent), "ppid=")
32 }37 }
33 38
34 func TestSessionMarkers_MarkAbruptExits(t *testing.T) {39 func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
@@ -46,6 +51,9 @@ func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
46 abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a"))51 abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a"))
47 require.NoError(t, err)52 require.NoError(t, err)
48 assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano))53 assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano))
54+ assert.Contains(t, string(abruptMarkerData), "started_at=")
55+ assert.Contains(t, string(abruptMarkerData), "pid=")
56+ assert.Contains(t, string(abruptMarkerData), "ppid=")
49 assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker"))57 assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker"))
50 }58 }
51 59
A internal/cli/cmd/crashes.go +254 -0
@@ -0,0 +1,254 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+ "time"
11+
12+ "github.com/spf13/cobra"
13+
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+)
16+
17+type crashReportSummary struct {
18+ SessionID string `json:"session_id"`
19+ GeneratedAt string `json:"generated_at"`
20+ Classification struct {
21+ Class string `json:"class"`
22+ Reason string `json:"reason"`
23+ } `json:"classification"`
24+}
25+
26+var crashesCmd = &cobra.Command{
27+ Use: "crashes",
28+ Short: "List unexpected-close crash reports",
29+ Long: `List crash reports generated after unexpected browser closes.
30+
31+Use subcommands to inspect full details or print an issue template.
32+
33+Examples:
34+ dumber crashes
35+ dumber crashes show latest
36+ dumber crashes issue latest`,
37+ RunE: runCrashes,
38+}
39+
40+var crashesShowCmd = &cobra.Command{
41+ Use: "show <report|latest>",
42+ Short: "Show a crash report",
43+ Args: cobra.ExactArgs(1),
44+ RunE: runCrashesShow,
45+}
46+
47+var crashesIssueCmd = &cobra.Command{
48+ Use: "issue <report|latest>",
49+ Short: "Print GitHub-ready issue body from report",
50+ Args: cobra.ExactArgs(1),
51+ RunE: runCrashesIssue,
52+}
53+
54+func init() {
55+ rootCmd.AddCommand(crashesCmd)
56+ crashesCmd.AddCommand(crashesShowCmd)
57+ crashesCmd.AddCommand(crashesIssueCmd)
58+}
59+
60+func runCrashes(_ *cobra.Command, _ []string) error {
61+ app := GetApp()
62+ if app == nil {
63+ return fmt.Errorf("app not initialized")
64+ }
65+
66+ crashDir := getCrashReportsDir(app.Config)
67+ reports, err := loadCrashReports(crashDir)
68+ if err != nil {
69+ return err
70+ }
71+ if len(reports) == 0 {
72+ fmt.Println(app.Theme.Subtle.Render("No crash reports found."))
73+ return nil
74+ }
75+
76+ fmt.Println(app.Theme.Title.Render("Crash reports (newest first):"))
77+ fmt.Println()
78+ for _, item := range reports {
79+ generated := item.GeneratedAt
80+ if ts, err := time.Parse(time.RFC3339Nano, item.GeneratedAt); err == nil {
81+ generated = ts.Format("2006-01-02 15:04:05")
82+ }
83+ shortID := shortCrashID(item.Path)
84+ line := fmt.Sprintf(" %s %s %s", app.Theme.Highlight.Render(shortID), app.Theme.Subtle.Render(generated), item.Classification.Class)
85+ fmt.Println(line)
86+ }
87+
88+ fmt.Println()
89+ fmt.Println(app.Theme.Subtle.Render("Use 'dumber crashes show <id|latest>' for details"))
90+ return nil
91+}
92+
93+func runCrashesShow(_ *cobra.Command, args []string) error {
94+ app := GetApp()
95+ if app == nil {
96+ return fmt.Errorf("app not initialized")
97+ }
98+
99+ report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
100+ if err != nil {
101+ return err
102+ }
103+
104+ body, err := os.ReadFile(report.MarkdownPath)
105+ if err != nil {
106+ return fmt.Errorf("read crash report markdown: %w", err)
107+ }
108+ fmt.Print(string(body))
109+ return nil
110+}
111+
112+func runCrashesIssue(_ *cobra.Command, args []string) error {
113+ app := GetApp()
114+ if app == nil {
115+ return fmt.Errorf("app not initialized")
116+ }
117+
118+ report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
119+ if err != nil {
120+ return err
121+ }
122+
123+ body, err := os.ReadFile(report.MarkdownPath)
124+ if err != nil {
125+ return fmt.Errorf("read crash report markdown: %w", err)
126+ }
127+ all := string(body)
128+ start := strings.Index(all, "## GitHub Issue Template")
129+ if start < 0 {
130+ fmt.Print(all)
131+ return nil
132+ }
133+ fmt.Print(strings.TrimSpace(all[start:]) + "\n")
134+ return nil
135+}
136+
137+type crashReportFile struct {
138+ Path string
139+ MarkdownPath string
140+ crashReportSummary
141+}
142+
143+func getCrashReportsDir(cfg *config.Config) string {
144+ logDir := ""
145+ if cfg != nil {
146+ logDir = cfg.Logging.LogDir
147+ }
148+ if logDir == "" {
149+ resolved, err := config.GetLogDir()
150+ if err == nil {
151+ logDir = resolved
152+ }
153+ }
154+ if logDir == "" {
155+ stateDir := os.Getenv("XDG_STATE_HOME")
156+ if stateDir == "" {
157+ home, _ := os.UserHomeDir()
158+ stateDir = filepath.Join(home, ".local", "state")
159+ }
160+ logDir = filepath.Join(stateDir, "dumber", "logs")
161+ }
162+ return filepath.Join(logDir, "crashes")
163+}
164+
165+func loadCrashReports(crashDir string) ([]crashReportFile, error) {
166+ entries, err := os.ReadDir(crashDir)
167+ if err != nil {
168+ if os.IsNotExist(err) {
169+ return nil, nil
170+ }
171+ return nil, err
172+ }
173+
174+ reports := make([]crashReportFile, 0, len(entries))
175+ for _, entry := range entries {
176+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".crash.json") {
177+ continue
178+ }
179+ path := filepath.Join(crashDir, entry.Name())
180+ raw, readErr := os.ReadFile(path)
181+ if readErr != nil {
182+ continue
183+ }
184+ var parsed crashReportSummary
185+ if unmarshalErr := json.Unmarshal(raw, &parsed); unmarshalErr != nil {
186+ continue
187+ }
188+ reports = append(reports, crashReportFile{
189+ Path: path,
190+ MarkdownPath: strings.TrimSuffix(path, ".json") + ".md",
191+ crashReportSummary: parsed,
192+ })
193+ }
194+
195+ sort.Slice(reports, func(i, j int) bool {
196+ left, errLeft := time.Parse(time.RFC3339Nano, reports[i].GeneratedAt)
197+ right, errRight := time.Parse(time.RFC3339Nano, reports[j].GeneratedAt)
198+ if errLeft == nil && errRight == nil {
199+ return left.After(right)
200+ }
201+ return reports[i].Path > reports[j].Path
202+ })
203+
204+ return reports, nil
205+}
206+
207+func resolveCrashReport(crashDir, query string) (*crashReportFile, error) {
208+ reports, err := loadCrashReports(crashDir)
209+ if err != nil {
210+ return nil, err
211+ }
212+ if len(reports) == 0 {
213+ return nil, fmt.Errorf("no crash reports found")
214+ }
215+ if strings.EqualFold(query, "latest") {
216+ return &reports[0], nil
217+ }
218+
219+ query = strings.TrimSpace(strings.ToLower(query))
220+ for i := range reports {
221+ id := strings.ToLower(shortCrashID(reports[i].Path))
222+ if id == query {
223+ return &reports[i], nil
224+ }
225+ }
226+
227+ matches := make([]int, 0, len(reports))
228+ for i := range reports {
229+ full := strings.ToLower(filepath.Base(reports[i].Path))
230+ if strings.Contains(full, query) || strings.Contains(strings.ToLower(reports[i].SessionID), query) {
231+ matches = append(matches, i)
232+ }
233+ }
234+
235+ if len(matches) == 1 {
236+ return &reports[matches[0]], nil
237+ }
238+ if len(matches) > 1 {
239+ ids := make([]string, 0, len(matches))
240+ for _, idx := range matches {
241+ ids = append(ids, shortCrashID(reports[idx].Path))
242+ }
243+ return nil, fmt.Errorf("multiple reports match '%s': %s", query, strings.Join(ids, ", "))
244+ }
245+
246+ return nil, fmt.Errorf("no crash report matching '%s' found", query)
247+}
248+
249+func shortCrashID(path string) string {
250+ base := filepath.Base(path)
251+ base = strings.TrimSuffix(base, ".crash.json")
252+ base = strings.TrimPrefix(base, "session_")
253+ return base
254+}
@@ -0,0 +1,254 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+ "time"
11+
12+ "github.com/spf13/cobra"
13+
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+)
16+
17+type crashReportSummary struct {
18+ SessionID string `json:"session_id"`
19+ GeneratedAt string `json:"generated_at"`
20+ Classification struct {
21+ Class string `json:"class"`
22+ Reason string `json:"reason"`
23+ } `json:"classification"`
24+}
25+
26+var crashesCmd = &cobra.Command{
27+ Use: "crashes",
28+ Short: "List unexpected-close crash reports",
29+ Long: `List crash reports generated after unexpected browser closes.
30+
31+Use subcommands to inspect full details or print an issue template.
32+
33+Examples:
34+ dumber crashes
35+ dumber crashes show latest
36+ dumber crashes issue latest`,
37+ RunE: runCrashes,
38+}
39+
40+var crashesShowCmd = &cobra.Command{
41+ Use: "show <report|latest>",
42+ Short: "Show a crash report",
43+ Args: cobra.ExactArgs(1),
44+ RunE: runCrashesShow,
45+}
46+
47+var crashesIssueCmd = &cobra.Command{
48+ Use: "issue <report|latest>",
49+ Short: "Print GitHub-ready issue body from report",
50+ Args: cobra.ExactArgs(1),
51+ RunE: runCrashesIssue,
52+}
53+
54+func init() {
55+ rootCmd.AddCommand(crashesCmd)
56+ crashesCmd.AddCommand(crashesShowCmd)
57+ crashesCmd.AddCommand(crashesIssueCmd)
58+}
59+
60+func runCrashes(_ *cobra.Command, _ []string) error {
61+ app := GetApp()
62+ if app == nil {
63+ return fmt.Errorf("app not initialized")
64+ }
65+
66+ crashDir := getCrashReportsDir(app.Config)
67+ reports, err := loadCrashReports(crashDir)
68+ if err != nil {
69+ return err
70+ }
71+ if len(reports) == 0 {
72+ fmt.Println(app.Theme.Subtle.Render("No crash reports found."))
73+ return nil
74+ }
75+
76+ fmt.Println(app.Theme.Title.Render("Crash reports (newest first):"))
77+ fmt.Println()
78+ for _, item := range reports {
79+ generated := item.GeneratedAt
80+ if ts, err := time.Parse(time.RFC3339Nano, item.GeneratedAt); err == nil {
81+ generated = ts.Format("2006-01-02 15:04:05")
82+ }
83+ shortID := shortCrashID(item.Path)
84+ line := fmt.Sprintf(" %s %s %s", app.Theme.Highlight.Render(shortID), app.Theme.Subtle.Render(generated), item.Classification.Class)
85+ fmt.Println(line)
86+ }
87+
88+ fmt.Println()
89+ fmt.Println(app.Theme.Subtle.Render("Use 'dumber crashes show <id|latest>' for details"))
90+ return nil
91+}
92+
93+func runCrashesShow(_ *cobra.Command, args []string) error {
94+ app := GetApp()
95+ if app == nil {
96+ return fmt.Errorf("app not initialized")
97+ }
98+
99+ report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
100+ if err != nil {
101+ return err
102+ }
103+
104+ body, err := os.ReadFile(report.MarkdownPath)
105+ if err != nil {
106+ return fmt.Errorf("read crash report markdown: %w", err)
107+ }
108+ fmt.Print(string(body))
109+ return nil
110+}
111+
112+func runCrashesIssue(_ *cobra.Command, args []string) error {
113+ app := GetApp()
114+ if app == nil {
115+ return fmt.Errorf("app not initialized")
116+ }
117+
118+ report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0])
119+ if err != nil {
120+ return err
121+ }
122+
123+ body, err := os.ReadFile(report.MarkdownPath)
124+ if err != nil {
125+ return fmt.Errorf("read crash report markdown: %w", err)
126+ }
127+ all := string(body)
128+ start := strings.Index(all, "## GitHub Issue Template")
129+ if start < 0 {
130+ fmt.Print(all)
131+ return nil
132+ }
133+ fmt.Print(strings.TrimSpace(all[start:]) + "\n")
134+ return nil
135+}
136+
137+type crashReportFile struct {
138+ Path string
139+ MarkdownPath string
140+ crashReportSummary
141+}
142+
143+func getCrashReportsDir(cfg *config.Config) string {
144+ logDir := ""
145+ if cfg != nil {
146+ logDir = cfg.Logging.LogDir
147+ }
148+ if logDir == "" {
149+ resolved, err := config.GetLogDir()
150+ if err == nil {
151+ logDir = resolved
152+ }
153+ }
154+ if logDir == "" {
155+ stateDir := os.Getenv("XDG_STATE_HOME")
156+ if stateDir == "" {
157+ home, _ := os.UserHomeDir()
158+ stateDir = filepath.Join(home, ".local", "state")
159+ }
160+ logDir = filepath.Join(stateDir, "dumber", "logs")
161+ }
162+ return filepath.Join(logDir, "crashes")
163+}
164+
165+func loadCrashReports(crashDir string) ([]crashReportFile, error) {
166+ entries, err := os.ReadDir(crashDir)
167+ if err != nil {
168+ if os.IsNotExist(err) {
169+ return nil, nil
170+ }
171+ return nil, err
172+ }
173+
174+ reports := make([]crashReportFile, 0, len(entries))
175+ for _, entry := range entries {
176+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".crash.json") {
177+ continue
178+ }
179+ path := filepath.Join(crashDir, entry.Name())
180+ raw, readErr := os.ReadFile(path)
181+ if readErr != nil {
182+ continue
183+ }
184+ var parsed crashReportSummary
185+ if unmarshalErr := json.Unmarshal(raw, &parsed); unmarshalErr != nil {
186+ continue
187+ }
188+ reports = append(reports, crashReportFile{
189+ Path: path,
190+ MarkdownPath: strings.TrimSuffix(path, ".json") + ".md",
191+ crashReportSummary: parsed,
192+ })
193+ }
194+
195+ sort.Slice(reports, func(i, j int) bool {
196+ left, errLeft := time.Parse(time.RFC3339Nano, reports[i].GeneratedAt)
197+ right, errRight := time.Parse(time.RFC3339Nano, reports[j].GeneratedAt)
198+ if errLeft == nil && errRight == nil {
199+ return left.After(right)
200+ }
201+ return reports[i].Path > reports[j].Path
202+ })
203+
204+ return reports, nil
205+}
206+
207+func resolveCrashReport(crashDir, query string) (*crashReportFile, error) {
208+ reports, err := loadCrashReports(crashDir)
209+ if err != nil {
210+ return nil, err
211+ }
212+ if len(reports) == 0 {
213+ return nil, fmt.Errorf("no crash reports found")
214+ }
215+ if strings.EqualFold(query, "latest") {
216+ return &reports[0], nil
217+ }
218+
219+ query = strings.TrimSpace(strings.ToLower(query))
220+ for i := range reports {
221+ id := strings.ToLower(shortCrashID(reports[i].Path))
222+ if id == query {
223+ return &reports[i], nil
224+ }
225+ }
226+
227+ matches := make([]int, 0, len(reports))
228+ for i := range reports {
229+ full := strings.ToLower(filepath.Base(reports[i].Path))
230+ if strings.Contains(full, query) || strings.Contains(strings.ToLower(reports[i].SessionID), query) {
231+ matches = append(matches, i)
232+ }
233+ }
234+
235+ if len(matches) == 1 {
236+ return &reports[matches[0]], nil
237+ }
238+ if len(matches) > 1 {
239+ ids := make([]string, 0, len(matches))
240+ for _, idx := range matches {
241+ ids = append(ids, shortCrashID(reports[idx].Path))
242+ }
243+ return nil, fmt.Errorf("multiple reports match '%s': %s", query, strings.Join(ids, ", "))
244+ }
245+
246+ return nil, fmt.Errorf("no crash report matching '%s' found", query)
247+}
248+
249+func shortCrashID(path string) string {
250+ base := filepath.Base(path)
251+ base = strings.TrimSuffix(base, ".crash.json")
252+ base = strings.TrimPrefix(base, "session_")
253+ return base
254+}
M internal/ui/app.go +37 -0
@@ -40,6 +40,8 @@ import (
4040 const (
4141 // AppID is the application identifier for GTK.
4242 AppID = "com.github.bnema.dumber"
43+ // crashReportToastDurationMs keeps startup crash-report toast visible longer.
44+ crashReportToastDurationMs = 5000
4345 )
4446
4547 // App wraps the GTK Application and manages the browser lifecycle.
@@ -246,6 +248,7 @@ func (a *App) onActivate(ctx context.Context) {
246248
247249 a.initLayoutInfrastructure()
248250 a.initAppToasterOverlay()
251+ a.installCrashReportNotifier(ctx)
249252 a.initFocusAndBorderOverlay()
250253 a.initAccentPicker(ctx)
251254 a.initDownloadHandler(ctx)
@@ -396,6 +399,16 @@ func (a *App) initAppToasterOverlay() {
396399 a.mainWindow.AddOverlay(modeGtkWidget)
397400 }
398401
402+func (a *App) installCrashReportNotifier(ctx context.Context) {
403+ if a.deps == nil {
404+ return
405+ }
406+
407+ a.deps.OnCrashReportsDetected = func(paths []string) {
408+ a.showCrashReportToast(ctx, paths)
409+ }
410+}
411+
399412 func (a *App) initFocusAndBorderOverlay() {
400413 // Initialize focus manager for geometric navigation.
401414 a.focusMgr = focus.NewManager(a.panesUC)
@@ -1177,6 +1190,10 @@ func (a *App) finalizeActivation(ctx context.Context) {
11771190 }
11781191 log.Info().Msg("main window displayed")
11791192
1193+ if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
1194+ a.showCrashReportToast(ctx, a.deps.StartupCrashReports)
1195+ }
1196+
11801197 // Defer non-critical initialization until after first navigation starts.
11811198 // This keeps pool prewarm, config watcher, and filter loading from
11821199 // competing with the initial page load.
@@ -1188,6 +1205,26 @@ func (a *App) finalizeActivation(ctx context.Context) {
11881205 })
11891206 }
11901207
1208+func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
1209+ if len(paths) == 0 {
1210+ return
1211+ }
1212+ log := logging.FromContext(ctx)
1213+ log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
1214+
1215+ cb := glib.SourceFunc(func(_ uintptr) bool {
1216+ if a.appToaster != nil {
1217+ msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1218+ a.appToaster.Show(ctx, msg, component.ToastWarning,
1219+ component.WithDuration(crashReportToastDurationMs),
1220+ component.WithPosition(component.ToastPositionBottomRight),
1221+ )
1222+ }
1223+ return false
1224+ })
1225+ glib.IdleAdd(&cb, 0)
1226+}
1227+
11911228 // runAfterFirstLoadStarted schedules work to run after the first navigation starts.
11921229 // This keeps the GTK main loop free to process the initial load_uri() quickly,
11931230 // reducing the gap between webview_attached and load_started.
@@ -40,6 +40,8 @@ import (
40 const (40 const (
41 // AppID is the application identifier for GTK.41 // AppID is the application identifier for GTK.
42 AppID = "com.github.bnema.dumber"42 AppID = "com.github.bnema.dumber"
43+ // crashReportToastDurationMs keeps startup crash-report toast visible longer.
44+ crashReportToastDurationMs = 5000
43 )45 )
44 46
45 // App wraps the GTK Application and manages the browser lifecycle.47 // App wraps the GTK Application and manages the browser lifecycle.
@@ -246,6 +248,7 @@ func (a *App) onActivate(ctx context.Context) {
246 248
247 a.initLayoutInfrastructure()249 a.initLayoutInfrastructure()
248 a.initAppToasterOverlay()250 a.initAppToasterOverlay()
251+ a.installCrashReportNotifier(ctx)
249 a.initFocusAndBorderOverlay()252 a.initFocusAndBorderOverlay()
250 a.initAccentPicker(ctx)253 a.initAccentPicker(ctx)
251 a.initDownloadHandler(ctx)254 a.initDownloadHandler(ctx)
@@ -396,6 +399,16 @@ func (a *App) initAppToasterOverlay() {
396 a.mainWindow.AddOverlay(modeGtkWidget)399 a.mainWindow.AddOverlay(modeGtkWidget)
397 }400 }
398 401
402+func (a *App) installCrashReportNotifier(ctx context.Context) {
403+ if a.deps == nil {
404+ return
405+ }
406+
407+ a.deps.OnCrashReportsDetected = func(paths []string) {
408+ a.showCrashReportToast(ctx, paths)
409+ }
410+}
411+
399 func (a *App) initFocusAndBorderOverlay() {412 func (a *App) initFocusAndBorderOverlay() {
400 // Initialize focus manager for geometric navigation.413 // Initialize focus manager for geometric navigation.
401 a.focusMgr = focus.NewManager(a.panesUC)414 a.focusMgr = focus.NewManager(a.panesUC)
@@ -1177,6 +1190,10 @@ func (a *App) finalizeActivation(ctx context.Context) {
1177 }1190 }
1178 log.Info().Msg("main window displayed")1191 log.Info().Msg("main window displayed")
1179 1192
1193+ if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
1194+ a.showCrashReportToast(ctx, a.deps.StartupCrashReports)
1195+ }
1196+
1180 // Defer non-critical initialization until after first navigation starts.1197 // Defer non-critical initialization until after first navigation starts.
1181 // This keeps pool prewarm, config watcher, and filter loading from1198 // This keeps pool prewarm, config watcher, and filter loading from
1182 // competing with the initial page load.1199 // competing with the initial page load.
@@ -1188,6 +1205,26 @@ func (a *App) finalizeActivation(ctx context.Context) {
1188 })1205 })
1189 }1206 }
1190 1207
1208+func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
1209+ if len(paths) == 0 {
1210+ return
1211+ }
1212+ log := logging.FromContext(ctx)
1213+ log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
1214+
1215+ cb := glib.SourceFunc(func(_ uintptr) bool {
1216+ if a.appToaster != nil {
1217+ msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1218+ a.appToaster.Show(ctx, msg, component.ToastWarning,
1219+ component.WithDuration(crashReportToastDurationMs),
1220+ component.WithPosition(component.ToastPositionBottomRight),
1221+ )
1222+ }
1223+ return false
1224+ })
1225+ glib.IdleAdd(&cb, 0)
1226+}
1227+
1191 // runAfterFirstLoadStarted schedules work to run after the first navigation starts.1228 // runAfterFirstLoadStarted schedules work to run after the first navigation starts.
1192 // This keeps the GTK main loop free to process the initial load_uri() quickly,1229 // This keeps the GTK main loop free to process the initial load_uri() quickly,
1193 // reducing the gap between webview_attached and load_started.1230 // reducing the gap between webview_attached and load_started.
M internal/ui/deps.go +8 -6
@@ -20,12 +20,14 @@ import (
2020 // This struct is created once at startup and passed to UI components.
2121 type Dependencies struct {
2222 // Core context and configuration
23- Ctx context.Context
24- Config *config.Config
25- InitialURL string // URL to open on startup (optional)
26- RestoreSessionID string // Session ID to restore on startup (optional)
27- OnFirstWebViewShown func(context.Context)
28- OnSessionPersisted func() // Called by main after session is persisted to DB
23+ Ctx context.Context
24+ Config *config.Config
25+ InitialURL string // URL to open on startup (optional)
26+ RestoreSessionID string // Session ID to restore on startup (optional)
27+ StartupCrashReports []string
28+ OnFirstWebViewShown func(context.Context)
29+ OnSessionPersisted func() // Called by main after session is persisted to DB
30+ OnCrashReportsDetected func([]string)
2931
3032 // Theme and color scheme management
3133 Theme *theme.Manager
@@ -20,12 +20,14 @@ import (
20 // This struct is created once at startup and passed to UI components.20 // This struct is created once at startup and passed to UI components.
21 type Dependencies struct {21 type Dependencies struct {
22 // Core context and configuration22 // Core context and configuration
23- Ctx context.Context23+ Ctx context.Context
24- Config *config.Config24+ Config *config.Config
25- InitialURL string // URL to open on startup (optional)25+ InitialURL string // URL to open on startup (optional)
26- RestoreSessionID string // Session ID to restore on startup (optional)26+ RestoreSessionID string // Session ID to restore on startup (optional)
27- OnFirstWebViewShown func(context.Context)27+ StartupCrashReports []string
28- OnSessionPersisted func() // Called by main after session is persisted to DB28+ OnFirstWebViewShown func(context.Context)
29+ OnSessionPersisted func() // Called by main after session is persisted to DB
30+ OnCrashReportsDetected func([]string)
29 31
30 // Theme and color scheme management32 // Theme and color scheme management
31 Theme *theme.Manager33 Theme *theme.Manager

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