detached f14455f

Merge branch 'main' into feat/permission-system

commit
f14455ffac15afe8c05ac97a78c867927b9597d8
author
bnema · · 2026-02-08 12:22 UTC
committer
GitHub · · 2026-02-08 12:22 UTC
merge of
f91ca27 + d90bc72 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
18 files · +1251 -97
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 CHANGELOG.md +4 -0
@@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file.
66
77 ### Added
88
9+- **Unexpected-close crash reports**: Automatically detects prior abrupt sessions on startup and generates per-session crash reports (JSON + Markdown) with metadata, core-dump diagnostics, and redacted log tails. Reports are capped at 20 to prevent unbounded disk growth.
10+- **`dumber crashes` CLI**: New CLI surface to list, view, and extract GitHub issue payloads from crash reports (`dumber crashes`, `dumber crashes show latest`, `dumber crashes issue latest`).
11+- **Crash report UI notification**: Startup toast guides users to run `dumber crashes issue latest` when unexpected-close reports are detected.
12+- **Unexpected-close GitHub issue template**: Structured issue template for reporting crashes with crash report payload, context, and reproduction steps.
913 - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths.
1014 - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling.
1115 - **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior.
@@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file.
6 6
7 ### Added7 ### Added
8 8
9+- **Unexpected-close crash reports**: Automatically detects prior abrupt sessions on startup and generates per-session crash reports (JSON + Markdown) with metadata, core-dump diagnostics, and redacted log tails. Reports are capped at 20 to prevent unbounded disk growth.
10+- **`dumber crashes` CLI**: New CLI surface to list, view, and extract GitHub issue payloads from crash reports (`dumber crashes`, `dumber crashes show latest`, `dumber crashes issue latest`).
11+- **Crash report UI notification**: Startup toast guides users to run `dumber crashes issue latest` when unexpected-close reports are detected.
12+- **Unexpected-close GitHub issue template**: Structured issue template for reporting crashes with crash report payload, context, and reproduction steps.
9 - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths.13 - **Session exit classification runbook and diagnostics docs**: Added dedicated operational documentation for abrupt/clean exit classification and troubleshooting paths.
10 - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling.14 - **Session/bootstrap resilience coverage**: Added tests and supporting logic for startup markers, WebKit stack bootstrap, and exit classification handling.
11 - **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior.15 - **Omnibox async guard tests**: Added focused tests for ghost text async/state safety and normalization behavior.
M cmd/dumber/main.go +48 -3
@@ -47,6 +47,8 @@ var initialURL string
4747 var restoreSessionID string
4848
4949 func main() {
50+ enableCrashForensics()
51+
5052 // Run GUI mode for browse command
5153 if len(os.Args) > 1 && os.Args[1] == "browse" {
5254 if len(os.Args) > 2 {
@@ -105,6 +107,7 @@ func runGUI() int {
105107
106108 log := logging.FromContext(ctx)
107109 logging.Trace().UpdateLogger(log)
110+ logCoreDumpLimits(ctx)
108111
109112 if stack.MessageRouter != nil {
110113 stack.MessageRouter.SetBaseContext(ctx)
@@ -198,7 +201,7 @@ func buildAndConfigureApp(
198201 uiDeps := buildUIDependencies(
199202 ctx, cfg, initResult.ThemeManager,
200203 initResult.ColorResolver, initResult.AdwaitaDetector,
201- stack, repos, useCases, idleInhibitor, browserSession.Session.ID,
204+ stack, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(),
202205 )
203206 configureDeferredInit(uiDeps, cfg, browserSession)
204207 return ui.New(uiDeps)
@@ -295,8 +298,15 @@ func configureDeferredInit(
295298 go func() {
296299 if persistErr := session.Persist(bgCtx); persistErr != nil {
297300 logger.Error().Err(persistErr).Msg("deferred session persistence failed")
298- } else if uiDeps.OnSessionPersisted != nil {
299- uiDeps.OnSessionPersisted()
301+ } else {
302+ if uiDeps.OnCrashReportsDetected != nil {
303+ if reports := session.UnexpectedCloseReports(); len(reports) > 0 {
304+ uiDeps.OnCrashReportsDetected(reports)
305+ }
306+ }
307+ if uiDeps.OnSessionPersisted != nil {
308+ uiDeps.OnSessionPersisted()
309+ }
300310 }
301311 }()
302312 }
@@ -494,6 +504,7 @@ func buildUIDependencies(
494504 uc *useCases,
495505 idleInhibitor port.IdleInhibitor,
496506 currentSessionID entity.SessionID,
507+ startupCrashReports []string,
497508 ) *ui.Dependencies {
498509 return &ui.Dependencies{
499510 Ctx: ctx,
@@ -531,5 +542,39 @@ func buildUIDependencies(
531542 SnapshotUC: uc.snapshot,
532543 CheckUpdateUC: uc.checkUpdate,
533544 ApplyUpdateUC: uc.applyUpdate,
545+ Ctx: ctx,
546+ Config: cfg,
547+ InitialURL: initialURL,
548+ RestoreSessionID: restoreSessionID,
549+ StartupCrashReports: startupCrashReports,
550+ Theme: themeManager,
551+ ColorResolver: colorResolver,
552+ AdwaitaDetector: adwaitaDetector,
553+ XDG: xdg.New(),
554+ WebContext: stack.Context,
555+ Pool: stack.Pool,
556+ Settings: stack.Settings,
557+ Injector: stack.Injector,
558+ MessageRouter: stack.MessageRouter,
559+ FilterManager: stack.FilterManager,
560+ HistoryRepo: repos.history,
561+ FavoriteRepo: repos.favorite,
562+ ZoomRepo: repos.zoom,
563+ TabsUC: uc.tabs,
564+ PanesUC: uc.panes,
565+ HistoryUC: uc.history,
566+ FavoritesUC: uc.favorites,
567+ ZoomUC: uc.zoom,
568+ NavigateUC: uc.navigate,
569+ CopyURLUC: uc.copyURL,
570+ Clipboard: uc.clipboard,
571+ FaviconService: uc.favicon,
572+ IdleInhibitor: idleInhibitor,
573+ SessionRepo: repos.session,
574+ SessionStateRepo: repos.sessionState,
575+ CurrentSessionID: currentSessionID,
576+ SnapshotUC: uc.snapshot,
577+ CheckUpdateUC: uc.checkUpdate,
578+ ApplyUpdateUC: uc.applyUpdate,
534579 }
535580 }
@@ -47,6 +47,8 @@ var initialURL string
47 var restoreSessionID string47 var restoreSessionID string
48 48
49 func main() {49 func main() {
50+ enableCrashForensics()
51+
50 // Run GUI mode for browse command52 // Run GUI mode for browse command
51 if len(os.Args) > 1 && os.Args[1] == "browse" {53 if len(os.Args) > 1 && os.Args[1] == "browse" {
52 if len(os.Args) > 2 {54 if len(os.Args) > 2 {
@@ -105,6 +107,7 @@ func runGUI() int {
105 107
106 log := logging.FromContext(ctx)108 log := logging.FromContext(ctx)
107 logging.Trace().UpdateLogger(log)109 logging.Trace().UpdateLogger(log)
110+ logCoreDumpLimits(ctx)
108 111
109 if stack.MessageRouter != nil {112 if stack.MessageRouter != nil {
110 stack.MessageRouter.SetBaseContext(ctx)113 stack.MessageRouter.SetBaseContext(ctx)
@@ -198,7 +201,7 @@ func buildAndConfigureApp(
198 uiDeps := buildUIDependencies(201 uiDeps := buildUIDependencies(
199 ctx, cfg, initResult.ThemeManager,202 ctx, cfg, initResult.ThemeManager,
200 initResult.ColorResolver, initResult.AdwaitaDetector,203 initResult.ColorResolver, initResult.AdwaitaDetector,
201- stack, repos, useCases, idleInhibitor, browserSession.Session.ID,204+ stack, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(),
202 )205 )
203 configureDeferredInit(uiDeps, cfg, browserSession)206 configureDeferredInit(uiDeps, cfg, browserSession)
204 return ui.New(uiDeps)207 return ui.New(uiDeps)
@@ -295,8 +298,15 @@ func configureDeferredInit(
295 go func() {298 go func() {
296 if persistErr := session.Persist(bgCtx); persistErr != nil {299 if persistErr := session.Persist(bgCtx); persistErr != nil {
297 logger.Error().Err(persistErr).Msg("deferred session persistence failed")300 logger.Error().Err(persistErr).Msg("deferred session persistence failed")
298- } else if uiDeps.OnSessionPersisted != nil {301+ } else {
299- uiDeps.OnSessionPersisted()302+ if uiDeps.OnCrashReportsDetected != nil {
303+ if reports := session.UnexpectedCloseReports(); len(reports) > 0 {
304+ uiDeps.OnCrashReportsDetected(reports)
305+ }
306+ }
307+ if uiDeps.OnSessionPersisted != nil {
308+ uiDeps.OnSessionPersisted()
309+ }
300 }310 }
301 }()311 }()
302 }312 }
@@ -494,6 +504,7 @@ func buildUIDependencies(
494 uc *useCases,504 uc *useCases,
495 idleInhibitor port.IdleInhibitor,505 idleInhibitor port.IdleInhibitor,
496 currentSessionID entity.SessionID,506 currentSessionID entity.SessionID,
507+ startupCrashReports []string,
497 ) *ui.Dependencies {508 ) *ui.Dependencies {
498 return &ui.Dependencies{509 return &ui.Dependencies{
499 Ctx: ctx,510 Ctx: ctx,
@@ -531,5 +542,39 @@ func buildUIDependencies(
531 SnapshotUC: uc.snapshot,542 SnapshotUC: uc.snapshot,
532 CheckUpdateUC: uc.checkUpdate,543 CheckUpdateUC: uc.checkUpdate,
533 ApplyUpdateUC: uc.applyUpdate,544 ApplyUpdateUC: uc.applyUpdate,
545+ Ctx: ctx,
546+ Config: cfg,
547+ InitialURL: initialURL,
548+ RestoreSessionID: restoreSessionID,
549+ StartupCrashReports: startupCrashReports,
550+ Theme: themeManager,
551+ ColorResolver: colorResolver,
552+ AdwaitaDetector: adwaitaDetector,
553+ XDG: xdg.New(),
554+ WebContext: stack.Context,
555+ Pool: stack.Pool,
556+ Settings: stack.Settings,
557+ Injector: stack.Injector,
558+ MessageRouter: stack.MessageRouter,
559+ FilterManager: stack.FilterManager,
560+ HistoryRepo: repos.history,
561+ FavoriteRepo: repos.favorite,
562+ ZoomRepo: repos.zoom,
563+ TabsUC: uc.tabs,
564+ PanesUC: uc.panes,
565+ HistoryUC: uc.history,
566+ FavoritesUC: uc.favorites,
567+ ZoomUC: uc.zoom,
568+ NavigateUC: uc.navigate,
569+ CopyURLUC: uc.copyURL,
570+ Clipboard: uc.clipboard,
571+ FaviconService: uc.favicon,
572+ IdleInhibitor: idleInhibitor,
573+ SessionRepo: repos.session,
574+ SessionStateRepo: repos.sessionState,
575+ CurrentSessionID: currentSessionID,
576+ SnapshotUC: uc.snapshot,
577+ CheckUpdateUC: uc.checkUpdate,
578+ ApplyUpdateUC: uc.applyUpdate,
534 }579 }
535 }580 }
A cmd/dumber/main_other.go +9 -0
@@ -0,0 +1,9 @@
1+//go:build !linux && !darwin
2+
3+package main
4+
5+import "context"
6+
7+func enableCrashForensics() {}
8+
9+func logCoreDumpLimits(_ context.Context) {}
@@ -0,0 +1,9 @@
1+//go:build !linux && !darwin
2+
3+package main
4+
5+import "context"
6+
7+func enableCrashForensics() {}
8+
9+func logCoreDumpLimits(_ context.Context) {}
A cmd/dumber/main_unix.go +47 -0
@@ -0,0 +1,47 @@
1+//go:build linux || darwin
2+
3+package main
4+
5+import (
6+ "context"
7+ "runtime/debug"
8+ "strconv"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ "golang.org/x/sys/unix"
12+)
13+
14+func enableCrashForensics() {
15+ debug.SetTraceback("crash")
16+
17+ var limit unix.Rlimit
18+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
19+ return
20+ }
21+ if limit.Cur >= limit.Max {
22+ return
23+ }
24+ limit.Cur = limit.Max
25+ _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit)
26+}
27+
28+func logCoreDumpLimits(ctx context.Context) {
29+ log := logging.FromContext(ctx)
30+ var limit unix.Rlimit
31+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
32+ log.Debug().Err(err).Msg("failed to read RLIMIT_CORE")
33+ return
34+ }
35+
36+ log.Debug().
37+ Str("soft", formatRlimit(limit.Cur)).
38+ Str("hard", formatRlimit(limit.Max)).
39+ Msg("core dump limits")
40+}
41+
42+func formatRlimit(value uint64) string {
43+ if value == unix.RLIM_INFINITY {
44+ return "infinity"
45+ }
46+ return strconv.FormatUint(value, 10)
47+}
@@ -0,0 +1,47 @@
1+//go:build linux || darwin
2+
3+package main
4+
5+import (
6+ "context"
7+ "runtime/debug"
8+ "strconv"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+ "golang.org/x/sys/unix"
12+)
13+
14+func enableCrashForensics() {
15+ debug.SetTraceback("crash")
16+
17+ var limit unix.Rlimit
18+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
19+ return
20+ }
21+ if limit.Cur >= limit.Max {
22+ return
23+ }
24+ limit.Cur = limit.Max
25+ _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit)
26+}
27+
28+func logCoreDumpLimits(ctx context.Context) {
29+ log := logging.FromContext(ctx)
30+ var limit unix.Rlimit
31+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
32+ log.Debug().Err(err).Msg("failed to read RLIMIT_CORE")
33+ return
34+ }
35+
36+ log.Debug().
37+ Str("soft", formatRlimit(limit.Cur)).
38+ Str("hard", formatRlimit(limit.Max)).
39+ Msg("core dump limits")
40+}
41+
42+func formatRlimit(value uint64) string {
43+ if value == unix.RLIM_INFINITY {
44+ return "infinity"
45+ }
46+ return strconv.FormatUint(value, 10)
47+}
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 +338 -0
@@ -0,0 +1,338 @@
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+ "sort"
14+ "strconv"
15+ "strings"
16+ "time"
17+
18+ corelogging "github.com/bnema/dumber/internal/logging"
19+)
20+
21+const (
22+ crashReportsDirName = "crashes"
23+ logTailLineCount = 120
24+ maxCrashReportsKept = 20
25+ scannerMaxTokenSize = 256 * 1024
26+)
27+
28+type unexpectedCloseReport struct {
29+ ReportVersion int `json:"report_version"`
30+ GeneratedAt string `json:"generated_at"`
31+ SessionID string `json:"session_id"`
32+ Classification SessionExitClassification `json:"classification"`
33+ StartupPID int `json:"startup_pid,omitempty"`
34+ StartupPPID int `json:"startup_ppid,omitempty"`
35+ MarkerFiles map[string]string `json:"marker_files"`
36+ SessionLogFile string `json:"session_log_file,omitempty"`
37+ SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
38+ ReporterProcess unexpectedCloseReporter `json:"reporter_process"`
39+ CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"`
40+ IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"`
41+ GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
42+}
43+
44+type unexpectedCloseReporter struct {
45+ GeneratedBy string `json:"generated_by"`
46+ GoVersion string `json:"go_version"`
47+ GOOS string `json:"goos"`
48+ GOARCH string `json:"goarch"`
49+ PID int `json:"pid"`
50+ PPID int `json:"ppid"`
51+}
52+
53+type unexpectedCloseCoreDump struct {
54+ RLimitCoreSoft string `json:"rlimit_core_soft"`
55+ RLimitCoreHard string `json:"rlimit_core_hard"`
56+ Hint string `json:"hint"`
57+}
58+
59+type unexpectedCloseIssueTemplate struct {
60+ Title string `json:"title"`
61+ Summary string `json:"summary"`
62+}
63+
64+func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
65+ if lockDir == "" || sessionID == "" {
66+ return "", errors.New("lockDir and sessionID are required")
67+ }
68+
69+ classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
70+ if classifyErr != nil {
71+ return "", classifyErr
72+ }
73+
74+ if classification.Class == SessionExitCleanExit {
75+ return "", nil
76+ }
77+
78+ reportsDir := filepath.Join(lockDir, crashReportsDirName)
79+ if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil {
80+ return "", err
81+ }
82+
83+ pruneOldCrashReports(reportsDir, maxCrashReportsKept)
84+
85+ jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
86+ markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
87+ if _, statErr := os.Stat(jsonPath); statErr == nil {
88+ if _, mdErr := os.Stat(markdownPath); mdErr == nil {
89+ return jsonPath, nil
90+ }
91+ raw, readErr := os.ReadFile(jsonPath)
92+ if readErr != nil {
93+ return "", readErr
94+ }
95+ var existing unexpectedCloseReport
96+ if err := json.Unmarshal(raw, &existing); err != nil {
97+ return "", err
98+ }
99+ markdown := buildUnexpectedCloseMarkdown(existing)
100+ if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
101+ return "", err
102+ }
103+ return jsonPath, nil
104+ } else if !os.IsNotExist(statErr) {
105+ return "", statErr
106+ }
107+
108+ report := unexpectedCloseReport{
109+ ReportVersion: 1,
110+ GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
111+ SessionID: sessionID,
112+ Classification: classification,
113+ MarkerFiles: reportMarkerFiles(lockDir, sessionID),
114+ ReporterProcess: currentReporter(),
115+ CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
116+ IssueTemplate: unexpectedCloseIssueTemplate{
117+ Title: fmt.Sprintf("Unexpected close in session %s", sessionID),
118+ Summary: "Dumber closed unexpectedly and generated this report automatically.",
119+ },
120+ }
121+
122+ startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID)
123+ report.StartupPID = startupPID
124+ report.StartupPPID = startupPPID
125+
126+ logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
127+ if _, statErr := os.Stat(logPath); statErr == nil {
128+ report.SessionLogFile = logPath
129+ report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
130+ }
131+
132+ report.GeneratedMarkdownRef = markdownPath
133+
134+ payload, err := json.MarshalIndent(report, "", " ")
135+ if err != nil {
136+ return "", err
137+ }
138+ payload = append(payload, '\n')
139+ if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil {
140+ return "", err
141+ }
142+
143+ markdown := buildUnexpectedCloseMarkdown(report)
144+ if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
145+ return "", err
146+ }
147+
148+ return jsonPath, nil
149+}
150+
151+func reportMarkerFiles(lockDir, sessionID string) map[string]string {
152+ return map[string]string{
153+ "startup": startupMarkerPath(lockDir, sessionID),
154+ "shutdown": shutdownMarkerPath(lockDir, sessionID),
155+ "abrupt": abruptMarkerPath(lockDir, sessionID),
156+ }
157+}
158+
159+func currentReporter() unexpectedCloseReporter {
160+ return unexpectedCloseReporter{
161+ GeneratedBy: "dumber",
162+ GoVersion: runtime.Version(),
163+ GOOS: runtime.GOOS,
164+ GOARCH: runtime.GOARCH,
165+ PID: os.Getpid(),
166+ PPID: os.Getppid(),
167+ }
168+}
169+
170+func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
171+ raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
172+ if err != nil {
173+ raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID))
174+ if err != nil {
175+ return 0, 0
176+ }
177+ }
178+
179+ pid, _ := strconv.Atoi(markerValue(raw, "pid="))
180+ ppid, _ := strconv.Atoi(markerValue(raw, "ppid="))
181+ return pid, ppid
182+}
183+
184+func readRedactedLogTail(path string, lines int) []string {
185+ if lines <= 0 {
186+ return nil
187+ }
188+
189+ file, err := os.Open(path)
190+ if err != nil {
191+ return nil
192+ }
193+ defer func() { _ = file.Close() }()
194+
195+ ring := make([]string, lines)
196+ count := 0
197+ scanner := bufio.NewScanner(file)
198+ scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
199+ for scanner.Scan() {
200+ ring[count%lines] = redactSensitiveContent(scanner.Text())
201+ count++
202+ }
203+
204+ var result []string
205+ if count <= lines {
206+ result = make([]string, count)
207+ copy(result, ring[:count])
208+ } else {
209+ result = make([]string, lines)
210+ start := count % lines
211+ copy(result, ring[start:])
212+ copy(result[lines-start:], ring[:start])
213+ }
214+
215+ if scanner.Err() != nil {
216+ result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
217+ }
218+ return result
219+}
220+
221+func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
222+ lines := []string{
223+ "# Unexpected Close Report",
224+ "",
225+ fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
226+ fmt.Sprintf("Session: `%s`", report.SessionID),
227+ fmt.Sprintf("Class: `%s`", report.Classification.Class),
228+ fmt.Sprintf("Inference: `%s`", report.Classification.Inference),
229+ fmt.Sprintf("Reason: `%s`", report.Classification.Reason),
230+ "",
231+ "## Process Context",
232+ fmt.Sprintf("- startup pid: `%d`", report.StartupPID),
233+ fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID),
234+ fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID),
235+ fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID),
236+ "",
237+ "## Core Dump Diagnostics",
238+ fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
239+ fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
240+ fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
241+ "",
242+ "## Marker Files",
243+ fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]),
244+ fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]),
245+ fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]),
246+ }
247+
248+ if len(report.SessionLogTail) > 0 {
249+ lines = append(lines,
250+ "",
251+ "## Redacted Log Tail",
252+ "```text",
253+ )
254+ lines = append(lines, report.SessionLogTail...)
255+ lines = append(lines, "```")
256+ }
257+
258+ lines = append(lines,
259+ "",
260+ "## GitHub Issue Template",
261+ fmt.Sprintf("Title: %s", report.IssueTemplate.Title),
262+ "",
263+ "```markdown",
264+ "### What happened",
265+ "Describe what you were doing just before Dumber closed unexpectedly.",
266+ "",
267+ "### Crash report",
268+ fmt.Sprintf("- session id: `%s`", report.SessionID),
269+ fmt.Sprintf("- class: `%s`", report.Classification.Class),
270+ fmt.Sprintf("- reason: `%s`", report.Classification.Reason),
271+ "",
272+ "### Additional context",
273+ "- distro:",
274+ "- compositor/window manager:",
275+ "- steps to reproduce:",
276+ "```",
277+ )
278+
279+ return strings.Join(lines, "\n") + "\n"
280+}
281+
282+var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
283+
284+var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
285+
286+var secretJSONRegex = regexp.MustCompile(`(?i)"(token|access_token|id_token|password|secret)"\s*:\s*"(.*?)"`)
287+
288+var secretHeaderRegex = regexp.MustCompile(`(?i)(authorization|auth):\s*(\S[^\n,;]*)`)
289+
290+func redactSensitiveContent(line string) string {
291+ redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
292+ redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
293+ redacted = secretJSONRegex.ReplaceAllString(redacted, `"$1":"[REDACTED]"`)
294+ redacted = secretHeaderRegex.ReplaceAllString(redacted, "$1: [REDACTED]")
295+ return redacted
296+}
297+
298+func redactURLString(raw string) string {
299+ trimmed := strings.TrimSpace(raw)
300+ trimmed = strings.TrimRight(trimmed, ".,;:")
301+ u, err := url.Parse(trimmed)
302+ if err != nil {
303+ return trimmed
304+ }
305+ u.RawQuery = ""
306+ u.Fragment = ""
307+ return u.String()
308+}
309+
310+func pruneOldCrashReports(reportsDir string, maxKeep int) {
311+ matches, err := filepath.Glob(filepath.Join(reportsDir, "session_*.crash.json"))
312+ if err != nil || len(matches) <= maxKeep {
313+ return
314+ }
315+
316+ type reportEntry struct {
317+ path string
318+ modTime time.Time
319+ }
320+ entries := make([]reportEntry, 0, len(matches))
321+ for _, p := range matches {
322+ info, statErr := os.Stat(p)
323+ if statErr != nil {
324+ continue
325+ }
326+ entries = append(entries, reportEntry{path: p, modTime: info.ModTime()})
327+ }
328+
329+ sort.Slice(entries, func(i, j int) bool {
330+ return entries[i].modTime.After(entries[j].modTime)
331+ })
332+
333+ for _, e := range entries[maxKeep:] {
334+ _ = os.Remove(e.path)
335+ mdPath := strings.TrimSuffix(e.path, ".json") + ".md"
336+ _ = os.Remove(mdPath)
337+ }
338+}
@@ -0,0 +1,338 @@
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+ "sort"
14+ "strconv"
15+ "strings"
16+ "time"
17+
18+ corelogging "github.com/bnema/dumber/internal/logging"
19+)
20+
21+const (
22+ crashReportsDirName = "crashes"
23+ logTailLineCount = 120
24+ maxCrashReportsKept = 20
25+ scannerMaxTokenSize = 256 * 1024
26+)
27+
28+type unexpectedCloseReport struct {
29+ ReportVersion int `json:"report_version"`
30+ GeneratedAt string `json:"generated_at"`
31+ SessionID string `json:"session_id"`
32+ Classification SessionExitClassification `json:"classification"`
33+ StartupPID int `json:"startup_pid,omitempty"`
34+ StartupPPID int `json:"startup_ppid,omitempty"`
35+ MarkerFiles map[string]string `json:"marker_files"`
36+ SessionLogFile string `json:"session_log_file,omitempty"`
37+ SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
38+ ReporterProcess unexpectedCloseReporter `json:"reporter_process"`
39+ CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"`
40+ IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"`
41+ GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
42+}
43+
44+type unexpectedCloseReporter struct {
45+ GeneratedBy string `json:"generated_by"`
46+ GoVersion string `json:"go_version"`
47+ GOOS string `json:"goos"`
48+ GOARCH string `json:"goarch"`
49+ PID int `json:"pid"`
50+ PPID int `json:"ppid"`
51+}
52+
53+type unexpectedCloseCoreDump struct {
54+ RLimitCoreSoft string `json:"rlimit_core_soft"`
55+ RLimitCoreHard string `json:"rlimit_core_hard"`
56+ Hint string `json:"hint"`
57+}
58+
59+type unexpectedCloseIssueTemplate struct {
60+ Title string `json:"title"`
61+ Summary string `json:"summary"`
62+}
63+
64+func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
65+ if lockDir == "" || sessionID == "" {
66+ return "", errors.New("lockDir and sessionID are required")
67+ }
68+
69+ classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
70+ if classifyErr != nil {
71+ return "", classifyErr
72+ }
73+
74+ if classification.Class == SessionExitCleanExit {
75+ return "", nil
76+ }
77+
78+ reportsDir := filepath.Join(lockDir, crashReportsDirName)
79+ if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil {
80+ return "", err
81+ }
82+
83+ pruneOldCrashReports(reportsDir, maxCrashReportsKept)
84+
85+ jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
86+ markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
87+ if _, statErr := os.Stat(jsonPath); statErr == nil {
88+ if _, mdErr := os.Stat(markdownPath); mdErr == nil {
89+ return jsonPath, nil
90+ }
91+ raw, readErr := os.ReadFile(jsonPath)
92+ if readErr != nil {
93+ return "", readErr
94+ }
95+ var existing unexpectedCloseReport
96+ if err := json.Unmarshal(raw, &existing); err != nil {
97+ return "", err
98+ }
99+ markdown := buildUnexpectedCloseMarkdown(existing)
100+ if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
101+ return "", err
102+ }
103+ return jsonPath, nil
104+ } else if !os.IsNotExist(statErr) {
105+ return "", statErr
106+ }
107+
108+ report := unexpectedCloseReport{
109+ ReportVersion: 1,
110+ GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
111+ SessionID: sessionID,
112+ Classification: classification,
113+ MarkerFiles: reportMarkerFiles(lockDir, sessionID),
114+ ReporterProcess: currentReporter(),
115+ CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
116+ IssueTemplate: unexpectedCloseIssueTemplate{
117+ Title: fmt.Sprintf("Unexpected close in session %s", sessionID),
118+ Summary: "Dumber closed unexpectedly and generated this report automatically.",
119+ },
120+ }
121+
122+ startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID)
123+ report.StartupPID = startupPID
124+ report.StartupPPID = startupPPID
125+
126+ logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
127+ if _, statErr := os.Stat(logPath); statErr == nil {
128+ report.SessionLogFile = logPath
129+ report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
130+ }
131+
132+ report.GeneratedMarkdownRef = markdownPath
133+
134+ payload, err := json.MarshalIndent(report, "", " ")
135+ if err != nil {
136+ return "", err
137+ }
138+ payload = append(payload, '\n')
139+ if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil {
140+ return "", err
141+ }
142+
143+ markdown := buildUnexpectedCloseMarkdown(report)
144+ if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
145+ return "", err
146+ }
147+
148+ return jsonPath, nil
149+}
150+
151+func reportMarkerFiles(lockDir, sessionID string) map[string]string {
152+ return map[string]string{
153+ "startup": startupMarkerPath(lockDir, sessionID),
154+ "shutdown": shutdownMarkerPath(lockDir, sessionID),
155+ "abrupt": abruptMarkerPath(lockDir, sessionID),
156+ }
157+}
158+
159+func currentReporter() unexpectedCloseReporter {
160+ return unexpectedCloseReporter{
161+ GeneratedBy: "dumber",
162+ GoVersion: runtime.Version(),
163+ GOOS: runtime.GOOS,
164+ GOARCH: runtime.GOARCH,
165+ PID: os.Getpid(),
166+ PPID: os.Getppid(),
167+ }
168+}
169+
170+func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
171+ raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
172+ if err != nil {
173+ raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID))
174+ if err != nil {
175+ return 0, 0
176+ }
177+ }
178+
179+ pid, _ := strconv.Atoi(markerValue(raw, "pid="))
180+ ppid, _ := strconv.Atoi(markerValue(raw, "ppid="))
181+ return pid, ppid
182+}
183+
184+func readRedactedLogTail(path string, lines int) []string {
185+ if lines <= 0 {
186+ return nil
187+ }
188+
189+ file, err := os.Open(path)
190+ if err != nil {
191+ return nil
192+ }
193+ defer func() { _ = file.Close() }()
194+
195+ ring := make([]string, lines)
196+ count := 0
197+ scanner := bufio.NewScanner(file)
198+ scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
199+ for scanner.Scan() {
200+ ring[count%lines] = redactSensitiveContent(scanner.Text())
201+ count++
202+ }
203+
204+ var result []string
205+ if count <= lines {
206+ result = make([]string, count)
207+ copy(result, ring[:count])
208+ } else {
209+ result = make([]string, lines)
210+ start := count % lines
211+ copy(result, ring[start:])
212+ copy(result[lines-start:], ring[:start])
213+ }
214+
215+ if scanner.Err() != nil {
216+ result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
217+ }
218+ return result
219+}
220+
221+func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
222+ lines := []string{
223+ "# Unexpected Close Report",
224+ "",
225+ fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
226+ fmt.Sprintf("Session: `%s`", report.SessionID),
227+ fmt.Sprintf("Class: `%s`", report.Classification.Class),
228+ fmt.Sprintf("Inference: `%s`", report.Classification.Inference),
229+ fmt.Sprintf("Reason: `%s`", report.Classification.Reason),
230+ "",
231+ "## Process Context",
232+ fmt.Sprintf("- startup pid: `%d`", report.StartupPID),
233+ fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID),
234+ fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID),
235+ fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID),
236+ "",
237+ "## Core Dump Diagnostics",
238+ fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
239+ fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
240+ fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
241+ "",
242+ "## Marker Files",
243+ fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]),
244+ fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]),
245+ fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]),
246+ }
247+
248+ if len(report.SessionLogTail) > 0 {
249+ lines = append(lines,
250+ "",
251+ "## Redacted Log Tail",
252+ "```text",
253+ )
254+ lines = append(lines, report.SessionLogTail...)
255+ lines = append(lines, "```")
256+ }
257+
258+ lines = append(lines,
259+ "",
260+ "## GitHub Issue Template",
261+ fmt.Sprintf("Title: %s", report.IssueTemplate.Title),
262+ "",
263+ "```markdown",
264+ "### What happened",
265+ "Describe what you were doing just before Dumber closed unexpectedly.",
266+ "",
267+ "### Crash report",
268+ fmt.Sprintf("- session id: `%s`", report.SessionID),
269+ fmt.Sprintf("- class: `%s`", report.Classification.Class),
270+ fmt.Sprintf("- reason: `%s`", report.Classification.Reason),
271+ "",
272+ "### Additional context",
273+ "- distro:",
274+ "- compositor/window manager:",
275+ "- steps to reproduce:",
276+ "```",
277+ )
278+
279+ return strings.Join(lines, "\n") + "\n"
280+}
281+
282+var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
283+
284+var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
285+
286+var secretJSONRegex = regexp.MustCompile(`(?i)"(token|access_token|id_token|password|secret)"\s*:\s*"(.*?)"`)
287+
288+var secretHeaderRegex = regexp.MustCompile(`(?i)(authorization|auth):\s*(\S[^\n,;]*)`)
289+
290+func redactSensitiveContent(line string) string {
291+ redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString)
292+ redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]")
293+ redacted = secretJSONRegex.ReplaceAllString(redacted, `"$1":"[REDACTED]"`)
294+ redacted = secretHeaderRegex.ReplaceAllString(redacted, "$1: [REDACTED]")
295+ return redacted
296+}
297+
298+func redactURLString(raw string) string {
299+ trimmed := strings.TrimSpace(raw)
300+ trimmed = strings.TrimRight(trimmed, ".,;:")
301+ u, err := url.Parse(trimmed)
302+ if err != nil {
303+ return trimmed
304+ }
305+ u.RawQuery = ""
306+ u.Fragment = ""
307+ return u.String()
308+}
309+
310+func pruneOldCrashReports(reportsDir string, maxKeep int) {
311+ matches, err := filepath.Glob(filepath.Join(reportsDir, "session_*.crash.json"))
312+ if err != nil || len(matches) <= maxKeep {
313+ return
314+ }
315+
316+ type reportEntry struct {
317+ path string
318+ modTime time.Time
319+ }
320+ entries := make([]reportEntry, 0, len(matches))
321+ for _, p := range matches {
322+ info, statErr := os.Stat(p)
323+ if statErr != nil {
324+ continue
325+ }
326+ entries = append(entries, reportEntry{path: p, modTime: info.ModTime()})
327+ }
328+
329+ sort.Slice(entries, func(i, j int) bool {
330+ return entries[i].modTime.After(entries[j].modTime)
331+ })
332+
333+ for _, e := range entries[maxKeep:] {
334+ _ = os.Remove(e.path)
335+ mdPath := strings.TrimSuffix(e.path, ".json") + ".md"
336+ _ = os.Remove(mdPath)
337+ }
338+}
A internal/bootstrap/crash_report_other.go +11 -0
@@ -0,0 +1,11 @@
1+//go:build !linux && !darwin
2+
3+package bootstrap
4+
5+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
6+ return unexpectedCloseCoreDump{
7+ RLimitCoreSoft: "unsupported",
8+ RLimitCoreHard: "unsupported",
9+ Hint: "Core dump diagnostics are only available on Linux and macOS.",
10+ }
11+}
@@ -0,0 +1,11 @@
1+//go:build !linux && !darwin
2+
3+package bootstrap
4+
5+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
6+ return unexpectedCloseCoreDump{
7+ RLimitCoreSoft: "unsupported",
8+ RLimitCoreHard: "unsupported",
9+ Hint: "Core dump diagnostics are only available on Linux and macOS.",
10+ }
11+}
A internal/bootstrap/crash_report_test.go +134 -0
@@ -0,0 +1,134 @@
1+package bootstrap
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ corelogging "github.com/bnema/dumber/internal/logging"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestRedactSensitiveContent(t *testing.T) {
18+ t.Run("url_query_params", func(t *testing.T) {
19+ line := "redirect to https://example.com/cb?code=abc123&token=secret#frag"
20+ redacted := redactSensitiveContent(line)
21+ assert.Contains(t, redacted, "https://example.com/cb")
22+ assert.NotContains(t, redacted, "code=abc123")
23+ assert.NotContains(t, redacted, "token=secret")
24+ assert.NotContains(t, redacted, "#frag")
25+ })
26+
27+ t.Run("json_secret_fields", func(t *testing.T) {
28+ line := `{"token":"eyJhbGciOi","access_token":"sk-abc123","password":"hunter2"}`
29+ redacted := redactSensitiveContent(line)
30+ assert.NotContains(t, redacted, "eyJhbGciOi")
31+ assert.NotContains(t, redacted, "sk-abc123")
32+ assert.NotContains(t, redacted, "hunter2")
33+ assert.Contains(t, redacted, `"token":"[REDACTED]"`)
34+ assert.Contains(t, redacted, `"access_token":"[REDACTED]"`)
35+ assert.Contains(t, redacted, `"password":"[REDACTED]"`)
36+ })
37+
38+ t.Run("key_value_params", func(t *testing.T) {
39+ line := "callback received password=secret123 token=abc456"
40+ redacted := redactSensitiveContent(line)
41+ assert.NotContains(t, redacted, "secret123")
42+ assert.NotContains(t, redacted, "abc456")
43+ assert.Contains(t, redacted, "password=[REDACTED]")
44+ assert.Contains(t, redacted, "token=[REDACTED]")
45+ })
46+
47+ t.Run("authorization_headers", func(t *testing.T) {
48+ line := "Authorization: Bearer eyJhbGciOiJSUz"
49+ redacted := redactSensitiveContent(line)
50+ assert.NotContains(t, redacted, "eyJhbGciOiJSUz")
51+ assert.Contains(t, redacted, "[REDACTED]")
52+
53+ line2 := "auth: basic dXNlcjpwYXNz"
54+ redacted2 := redactSensitiveContent(line2)
55+ assert.NotContains(t, redacted2, "dXNlcjpwYXNz")
56+ assert.Contains(t, redacted2, "[REDACTED]")
57+ })
58+}
59+
60+func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
61+ dir := t.TempDir()
62+ path := filepath.Join(dir, "test.log")
63+
64+ var lines []string
65+ for i := 0; i < 10; i++ {
66+ lines = append(lines, fmt.Sprintf("line %d", i))
67+ }
68+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), markerFilePerm))
69+
70+ t.Run("fewer_lines_than_requested", func(t *testing.T) {
71+ result := readRedactedLogTail(path, 20)
72+ assert.Len(t, result, 10)
73+ assert.Equal(t, "line 0", result[0])
74+ assert.Equal(t, "line 9", result[9])
75+ })
76+
77+ t.Run("exact_tail", func(t *testing.T) {
78+ result := readRedactedLogTail(path, 3)
79+ assert.Len(t, result, 3)
80+ assert.Equal(t, "line 7", result[0])
81+ assert.Equal(t, "line 8", result[1])
82+ assert.Equal(t, "line 9", result[2])
83+ })
84+}
85+
86+func TestWriteUnexpectedCloseReport(t *testing.T) {
87+ lockDir := t.TempDir()
88+ sessionID := "crash-test-1"
89+ startedAt := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
90+
91+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
92+ _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil)
93+ require.NoError(t, err)
94+
95+ logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
96+ logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` +
97+ "\n" +
98+ `{"level":"warn","message":"callback code=abc token=def"}` +
99+ "\n"
100+ require.NoError(t, os.WriteFile(logPath, []byte(logBody), markerFilePerm))
101+
102+ jsonPath, err := writeUnexpectedCloseReport(lockDir, sessionID)
103+ require.NoError(t, err)
104+ require.NotEmpty(t, jsonPath)
105+ assert.FileExists(t, jsonPath)
106+
107+ mdPath := strings.TrimSuffix(jsonPath, ".json") + ".md"
108+ assert.FileExists(t, mdPath)
109+
110+ raw, err := os.ReadFile(jsonPath)
111+ require.NoError(t, err)
112+
113+ var payload map[string]any
114+ require.NoError(t, json.Unmarshal(raw, &payload))
115+ assert.Equal(t, sessionID, payload["session_id"])
116+
117+ classificationRaw, ok := payload["classification"].(map[string]any)
118+ require.True(t, ok, "classification should be a map[string]any")
119+ classValue, ok := classificationRaw["class"].(string)
120+ require.True(t, ok, "classification.class should be a string")
121+ assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
122+
123+ tail, ok := payload["session_log_tail_redacted"].([]any)
124+ require.True(t, ok, "session_log_tail_redacted should be a []any")
125+ require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries")
126+ first, ok := tail[0].(string)
127+ require.True(t, ok, "first tail entry should be a string")
128+ last, ok := tail[len(tail)-1].(string)
129+ require.True(t, ok, "last tail entry should be a string")
130+ text := first + last
131+ assert.NotContains(t, text, "?a=1&b=2")
132+ assert.NotContains(t, text, "code=abc")
133+ assert.NotContains(t, text, "token=def")
134+}
@@ -0,0 +1,134 @@
1+package bootstrap
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ corelogging "github.com/bnema/dumber/internal/logging"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestRedactSensitiveContent(t *testing.T) {
18+ t.Run("url_query_params", func(t *testing.T) {
19+ line := "redirect to https://example.com/cb?code=abc123&token=secret#frag"
20+ redacted := redactSensitiveContent(line)
21+ assert.Contains(t, redacted, "https://example.com/cb")
22+ assert.NotContains(t, redacted, "code=abc123")
23+ assert.NotContains(t, redacted, "token=secret")
24+ assert.NotContains(t, redacted, "#frag")
25+ })
26+
27+ t.Run("json_secret_fields", func(t *testing.T) {
28+ line := `{"token":"eyJhbGciOi","access_token":"sk-abc123","password":"hunter2"}`
29+ redacted := redactSensitiveContent(line)
30+ assert.NotContains(t, redacted, "eyJhbGciOi")
31+ assert.NotContains(t, redacted, "sk-abc123")
32+ assert.NotContains(t, redacted, "hunter2")
33+ assert.Contains(t, redacted, `"token":"[REDACTED]"`)
34+ assert.Contains(t, redacted, `"access_token":"[REDACTED]"`)
35+ assert.Contains(t, redacted, `"password":"[REDACTED]"`)
36+ })
37+
38+ t.Run("key_value_params", func(t *testing.T) {
39+ line := "callback received password=secret123 token=abc456"
40+ redacted := redactSensitiveContent(line)
41+ assert.NotContains(t, redacted, "secret123")
42+ assert.NotContains(t, redacted, "abc456")
43+ assert.Contains(t, redacted, "password=[REDACTED]")
44+ assert.Contains(t, redacted, "token=[REDACTED]")
45+ })
46+
47+ t.Run("authorization_headers", func(t *testing.T) {
48+ line := "Authorization: Bearer eyJhbGciOiJSUz"
49+ redacted := redactSensitiveContent(line)
50+ assert.NotContains(t, redacted, "eyJhbGciOiJSUz")
51+ assert.Contains(t, redacted, "[REDACTED]")
52+
53+ line2 := "auth: basic dXNlcjpwYXNz"
54+ redacted2 := redactSensitiveContent(line2)
55+ assert.NotContains(t, redacted2, "dXNlcjpwYXNz")
56+ assert.Contains(t, redacted2, "[REDACTED]")
57+ })
58+}
59+
60+func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
61+ dir := t.TempDir()
62+ path := filepath.Join(dir, "test.log")
63+
64+ var lines []string
65+ for i := 0; i < 10; i++ {
66+ lines = append(lines, fmt.Sprintf("line %d", i))
67+ }
68+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), markerFilePerm))
69+
70+ t.Run("fewer_lines_than_requested", func(t *testing.T) {
71+ result := readRedactedLogTail(path, 20)
72+ assert.Len(t, result, 10)
73+ assert.Equal(t, "line 0", result[0])
74+ assert.Equal(t, "line 9", result[9])
75+ })
76+
77+ t.Run("exact_tail", func(t *testing.T) {
78+ result := readRedactedLogTail(path, 3)
79+ assert.Len(t, result, 3)
80+ assert.Equal(t, "line 7", result[0])
81+ assert.Equal(t, "line 8", result[1])
82+ assert.Equal(t, "line 9", result[2])
83+ })
84+}
85+
86+func TestWriteUnexpectedCloseReport(t *testing.T) {
87+ lockDir := t.TempDir()
88+ sessionID := "crash-test-1"
89+ startedAt := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
90+
91+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
92+ _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil)
93+ require.NoError(t, err)
94+
95+ logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
96+ logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` +
97+ "\n" +
98+ `{"level":"warn","message":"callback code=abc token=def"}` +
99+ "\n"
100+ require.NoError(t, os.WriteFile(logPath, []byte(logBody), markerFilePerm))
101+
102+ jsonPath, err := writeUnexpectedCloseReport(lockDir, sessionID)
103+ require.NoError(t, err)
104+ require.NotEmpty(t, jsonPath)
105+ assert.FileExists(t, jsonPath)
106+
107+ mdPath := strings.TrimSuffix(jsonPath, ".json") + ".md"
108+ assert.FileExists(t, mdPath)
109+
110+ raw, err := os.ReadFile(jsonPath)
111+ require.NoError(t, err)
112+
113+ var payload map[string]any
114+ require.NoError(t, json.Unmarshal(raw, &payload))
115+ assert.Equal(t, sessionID, payload["session_id"])
116+
117+ classificationRaw, ok := payload["classification"].(map[string]any)
118+ require.True(t, ok, "classification should be a map[string]any")
119+ classValue, ok := classificationRaw["class"].(string)
120+ require.True(t, ok, "classification.class should be a string")
121+ assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
122+
123+ tail, ok := payload["session_log_tail_redacted"].([]any)
124+ require.True(t, ok, "session_log_tail_redacted should be a []any")
125+ require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries")
126+ first, ok := tail[0].(string)
127+ require.True(t, ok, "first tail entry should be a string")
128+ last, ok := tail[len(tail)-1].(string)
129+ require.True(t, ok, "last tail entry should be a string")
130+ text := first + last
131+ assert.NotContains(t, text, "?a=1&b=2")
132+ assert.NotContains(t, text, "code=abc")
133+ assert.NotContains(t, text, "token=def")
134+}
A internal/bootstrap/crash_report_unix.go +31 -0
@@ -0,0 +1,31 @@
1+//go:build linux || darwin
2+
3+package bootstrap
4+
5+import (
6+ "strconv"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
12+ d := unexpectedCloseCoreDump{
13+ Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
14+ }
15+ var limit unix.Rlimit
16+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
17+ d.RLimitCoreSoft = "unknown"
18+ d.RLimitCoreHard = "unknown"
19+ return d
20+ }
21+ d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
22+ d.RLimitCoreHard = formatRlimitCore(limit.Max)
23+ return d
24+}
25+
26+func formatRlimitCore(value uint64) string {
27+ if value == unix.RLIM_INFINITY {
28+ return "infinity"
29+ }
30+ return strconv.FormatUint(value, 10)
31+}
@@ -0,0 +1,31 @@
1+//go:build linux || darwin
2+
3+package bootstrap
4+
5+import (
6+ "strconv"
7+
8+ "golang.org/x/sys/unix"
9+)
10+
11+func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
12+ d := unexpectedCloseCoreDump{
13+ Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
14+ }
15+ var limit unix.Rlimit
16+ if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
17+ d.RLimitCoreSoft = "unknown"
18+ d.RLimitCoreHard = "unknown"
19+ return d
20+ }
21+ d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
22+ d.RLimitCoreHard = formatRlimitCore(limit.Max)
23+ return d
24+}
25+
26+func formatRlimitCore(value uint64) string {
27+ if value == unix.RLIM_INFINITY {
28+ return "infinity"
29+ }
30+ return strconv.FormatUint(value, 10)
31+}
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_exit_classification.go +8 -8
@@ -19,14 +19,14 @@ const (
1919 )
2020
2121 type SessionExitClassification struct {
22- SessionID string
23- Class SessionExitClass
24- Inference string
25- Reason string
26- StartupAt *time.Time
27- ShutdownAt *time.Time
28- AbruptDetectedAt *time.Time
29- LastMarkerObserved time.Time
22+ SessionID string `json:"session_id"`
23+ Class SessionExitClass `json:"class"`
24+ Inference string `json:"inference"`
25+ Reason string `json:"reason"`
26+ StartupAt *time.Time `json:"startup_at,omitempty"`
27+ ShutdownAt *time.Time `json:"shutdown_at,omitempty"`
28+ AbruptDetectedAt *time.Time `json:"abrupt_detected_at,omitempty"`
29+ LastMarkerObserved time.Time `json:"last_marker_observed"`
3030 }
3131
3232 func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) {
@@ -19,14 +19,14 @@ const (
19 )19 )
20 20
21 type SessionExitClassification struct {21 type SessionExitClassification struct {
22- SessionID string22+ SessionID string `json:"session_id"`
23- Class SessionExitClass23+ Class SessionExitClass `json:"class"`
24- Inference string24+ Inference string `json:"inference"`
25- Reason string25+ Reason string `json:"reason"`
26- StartupAt *time.Time26+ StartupAt *time.Time `json:"startup_at,omitempty"`
27- ShutdownAt *time.Time27+ ShutdownAt *time.Time `json:"shutdown_at,omitempty"`
28- AbruptDetectedAt *time.Time28+ AbruptDetectedAt *time.Time `json:"abrupt_detected_at,omitempty"`
29- LastMarkerObserved time.Time29+ LastMarkerObserved time.Time `json:"last_marker_observed"`
30 }30 }
31 31
32 func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) {32 func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) {
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
@@ -42,6 +42,8 @@ import (
4242 const (
4343 // AppID is the application identifier for GTK.
4444 AppID = "com.github.bnema.dumber"
45+ // crashReportToastDurationMs keeps startup crash-report toast visible longer.
46+ crashReportToastDurationMs = 5000
4547 )
4648
4749 // App wraps the GTK Application and manages the browser lifecycle.
@@ -250,6 +252,7 @@ func (a *App) onActivate(ctx context.Context) {
250252
251253 a.initLayoutInfrastructure()
252254 a.initAppToasterOverlay()
255+ a.installCrashReportNotifier(ctx)
253256 a.initFocusAndBorderOverlay()
254257 a.initAccentPicker(ctx)
255258 a.initDownloadHandler(ctx)
@@ -427,6 +430,16 @@ func (a *App) initAppToasterOverlay() {
427430 a.mainWindow.AddOverlay(modeGtkWidget)
428431 }
429432
433+func (a *App) installCrashReportNotifier(ctx context.Context) {
434+ if a.deps == nil {
435+ return
436+ }
437+
438+ a.deps.OnCrashReportsDetected = func(paths []string) {
439+ a.showCrashReportToast(ctx, paths)
440+ }
441+}
442+
430443 func (a *App) initFocusAndBorderOverlay() {
431444 // Initialize focus manager for geometric navigation.
432445 a.focusMgr = focus.NewManager(a.panesUC)
@@ -1208,6 +1221,10 @@ func (a *App) finalizeActivation(ctx context.Context) {
12081221 }
12091222 log.Info().Msg("main window displayed")
12101223
1224+ if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
1225+ a.showCrashReportToast(ctx, a.deps.StartupCrashReports)
1226+ }
1227+
12111228 // Defer non-critical initialization until after first navigation starts.
12121229 // This keeps pool prewarm, config watcher, and filter loading from
12131230 // competing with the initial page load.
@@ -1219,6 +1236,26 @@ func (a *App) finalizeActivation(ctx context.Context) {
12191236 })
12201237 }
12211238
1239+func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
1240+ if len(paths) == 0 {
1241+ return
1242+ }
1243+ log := logging.FromContext(ctx)
1244+ log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
1245+
1246+ cb := glib.SourceFunc(func(_ uintptr) bool {
1247+ if a.appToaster != nil {
1248+ msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1249+ a.appToaster.Show(ctx, msg, component.ToastWarning,
1250+ component.WithDuration(crashReportToastDurationMs),
1251+ component.WithPosition(component.ToastPositionBottomRight),
1252+ )
1253+ }
1254+ return false
1255+ })
1256+ glib.IdleAdd(&cb, 0)
1257+}
1258+
12221259 // runAfterFirstLoadStarted schedules work to run after the first navigation starts.
12231260 // This keeps the GTK main loop free to process the initial load_uri() quickly,
12241261 // reducing the gap between webview_attached and load_started.
@@ -42,6 +42,8 @@ import (
42 const (42 const (
43 // AppID is the application identifier for GTK.43 // AppID is the application identifier for GTK.
44 AppID = "com.github.bnema.dumber"44 AppID = "com.github.bnema.dumber"
45+ // crashReportToastDurationMs keeps startup crash-report toast visible longer.
46+ crashReportToastDurationMs = 5000
45 )47 )
46 48
47 // App wraps the GTK Application and manages the browser lifecycle.49 // App wraps the GTK Application and manages the browser lifecycle.
@@ -250,6 +252,7 @@ func (a *App) onActivate(ctx context.Context) {
250 252
251 a.initLayoutInfrastructure()253 a.initLayoutInfrastructure()
252 a.initAppToasterOverlay()254 a.initAppToasterOverlay()
255+ a.installCrashReportNotifier(ctx)
253 a.initFocusAndBorderOverlay()256 a.initFocusAndBorderOverlay()
254 a.initAccentPicker(ctx)257 a.initAccentPicker(ctx)
255 a.initDownloadHandler(ctx)258 a.initDownloadHandler(ctx)
@@ -427,6 +430,16 @@ func (a *App) initAppToasterOverlay() {
427 a.mainWindow.AddOverlay(modeGtkWidget)430 a.mainWindow.AddOverlay(modeGtkWidget)
428 }431 }
429 432
433+func (a *App) installCrashReportNotifier(ctx context.Context) {
434+ if a.deps == nil {
435+ return
436+ }
437+
438+ a.deps.OnCrashReportsDetected = func(paths []string) {
439+ a.showCrashReportToast(ctx, paths)
440+ }
441+}
442+
430 func (a *App) initFocusAndBorderOverlay() {443 func (a *App) initFocusAndBorderOverlay() {
431 // Initialize focus manager for geometric navigation.444 // Initialize focus manager for geometric navigation.
432 a.focusMgr = focus.NewManager(a.panesUC)445 a.focusMgr = focus.NewManager(a.panesUC)
@@ -1208,6 +1221,10 @@ func (a *App) finalizeActivation(ctx context.Context) {
1208 }1221 }
1209 log.Info().Msg("main window displayed")1222 log.Info().Msg("main window displayed")
1210 1223
1224+ if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
1225+ a.showCrashReportToast(ctx, a.deps.StartupCrashReports)
1226+ }
1227+
1211 // Defer non-critical initialization until after first navigation starts.1228 // Defer non-critical initialization until after first navigation starts.
1212 // This keeps pool prewarm, config watcher, and filter loading from1229 // This keeps pool prewarm, config watcher, and filter loading from
1213 // competing with the initial page load.1230 // competing with the initial page load.
@@ -1219,6 +1236,26 @@ func (a *App) finalizeActivation(ctx context.Context) {
1219 })1236 })
1220 }1237 }
1221 1238
1239+func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
1240+ if len(paths) == 0 {
1241+ return
1242+ }
1243+ log := logging.FromContext(ctx)
1244+ log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
1245+
1246+ cb := glib.SourceFunc(func(_ uintptr) bool {
1247+ if a.appToaster != nil {
1248+ msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1249+ a.appToaster.Show(ctx, msg, component.ToastWarning,
1250+ component.WithDuration(crashReportToastDurationMs),
1251+ component.WithPosition(component.ToastPositionBottomRight),
1252+ )
1253+ }
1254+ return false
1255+ })
1256+ glib.IdleAdd(&cb, 0)
1257+}
1258+
1222 // runAfterFirstLoadStarted schedules work to run after the first navigation starts.1259 // runAfterFirstLoadStarted schedules work to run after the first navigation starts.
1223 // This keeps the GTK main loop free to process the initial load_uri() quickly,1260 // This keeps the GTK main loop free to process the initial load_uri() quickly,
1224 // reducing the gap between webview_attached and load_started.1261 // 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