feat(crash): add unexpected-close reports with CLI and issue template
- commit
- 25342a3475cb21ed74c5dce59605e9e26c5e255f
- author
- bnema · · 2026-02-08 03:25 UTC
- parent
- 64ad4fc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +1064 -122
A .github/ISSUE_TEMPLATE/unexpected-close.yml +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | name: Unexpected Close Report | |
| 2 | + | description: Report an unexpected Dumber close with auto-generated crash report | |
| 3 | + | title: "Unexpected close: " | |
| 4 | + | labels: | |
| 5 | + | - bug | |
| 6 | + | body: | |
| 7 | + | - type: markdown | |
| 8 | + | attributes: | |
| 9 | + | value: | | |
| 10 | + | Thanks for helping improve Dumber alpha stability. | |
| 11 | + | ||
| 12 | + | Before submitting, run: | |
| 13 | + | ||
| 14 | + | ```bash | |
| 15 | + | dumber crashes issue latest | |
| 16 | + | ``` | |
| 17 | + | ||
| 18 | + | Paste the output in the "Crash report" field below. | |
| 19 | + | ||
| 20 | + | - type: textarea | |
| 21 | + | id: happened | |
| 22 | + | attributes: | |
| 23 | + | label: What happened? | |
| 24 | + | description: What were you doing just before Dumber closed? | |
| 25 | + | placeholder: "I had 3 tabs open, played a video, and Dumber closed after alt-tabbing..." | |
| 26 | + | validations: | |
| 27 | + | required: true | |
| 28 | + | ||
| 29 | + | - type: textarea | |
| 30 | + | id: crash_report | |
| 31 | + | attributes: | |
| 32 | + | label: Crash report | |
| 33 | + | description: Paste output from `dumber crashes issue latest` | |
| 34 | + | render: markdown | |
| 35 | + | placeholder: "## GitHub Issue Template ..." | |
| 36 | + | validations: | |
| 37 | + | required: true | |
| 38 | + | ||
| 39 | + | - type: input | |
| 40 | + | id: distro | |
| 41 | + | attributes: | |
| 42 | + | label: Linux distro | |
| 43 | + | placeholder: "Arch Linux" | |
| 44 | + | validations: | |
| 45 | + | required: true | |
| 46 | + | ||
| 47 | + | - type: input | |
| 48 | + | id: wm | |
| 49 | + | attributes: | |
| 50 | + | label: Compositor / window manager | |
| 51 | + | placeholder: "Hyprland / Sway / River / Niri ..." | |
| 52 | + | validations: | |
| 53 | + | required: true | |
| 54 | + | ||
| 55 | + | - type: textarea | |
| 56 | + | id: repro | |
| 57 | + | attributes: | |
| 58 | + | label: Reproduction steps | |
| 59 | + | description: Short, numbered steps help a lot. | |
| 60 | + | placeholder: | | |
| 61 | + | 1. Open Dumber | |
| 62 | + | 2. ... | |
| 63 | + | 3. ... | |
| 64 | + | validations: | |
| 65 | + | required: false | |
| 66 | + | ||
| 67 | + | - type: textarea | |
| 68 | + | id: extra | |
| 69 | + | attributes: | |
| 70 | + | label: Additional context | |
| 71 | + | description: Anything else useful (screenshots, coredumpctl snippets, etc.) | |
| 72 | + | validations: | |
| 73 | + | required: false | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | name: Unexpected Close Report | |||
| 2 | + | description: Report an unexpected Dumber close with auto-generated crash report | |||
| 3 | + | title: "Unexpected close: " | |||
| 4 | + | labels: | |||
| 5 | + | - bug | |||
| 6 | + | body: | |||
| 7 | + | - type: markdown | |||
| 8 | + | attributes: | |||
| 9 | + | value: | | |||
| 10 | + | Thanks for helping improve Dumber alpha stability. | |||
| 11 | + | ||||
| 12 | + | Before submitting, run: | |||
| 13 | + | ||||
| 14 | + | ```bash | |||
| 15 | + | dumber crashes issue latest | |||
| 16 | + | ``` | |||
| 17 | + | ||||
| 18 | + | Paste the output in the "Crash report" field below. | |||
| 19 | + | ||||
| 20 | + | - type: textarea | |||
| 21 | + | id: happened | |||
| 22 | + | attributes: | |||
| 23 | + | label: What happened? | |||
| 24 | + | description: What were you doing just before Dumber closed? | |||
| 25 | + | placeholder: "I had 3 tabs open, played a video, and Dumber closed after alt-tabbing..." | |||
| 26 | + | validations: | |||
| 27 | + | required: true | |||
| 28 | + | ||||
| 29 | + | - type: textarea | |||
| 30 | + | id: crash_report | |||
| 31 | + | attributes: | |||
| 32 | + | label: Crash report | |||
| 33 | + | description: Paste output from `dumber crashes issue latest` | |||
| 34 | + | render: markdown | |||
| 35 | + | placeholder: "## GitHub Issue Template ..." | |||
| 36 | + | validations: | |||
| 37 | + | required: true | |||
| 38 | + | ||||
| 39 | + | - type: input | |||
| 40 | + | id: distro | |||
| 41 | + | attributes: | |||
| 42 | + | label: Linux distro | |||
| 43 | + | placeholder: "Arch Linux" | |||
| 44 | + | validations: | |||
| 45 | + | required: true | |||
| 46 | + | ||||
| 47 | + | - type: input | |||
| 48 | + | id: wm | |||
| 49 | + | attributes: | |||
| 50 | + | label: Compositor / window manager | |||
| 51 | + | placeholder: "Hyprland / Sway / River / Niri ..." | |||
| 52 | + | validations: | |||
| 53 | + | required: true | |||
| 54 | + | ||||
| 55 | + | - type: textarea | |||
| 56 | + | id: repro | |||
| 57 | + | attributes: | |||
| 58 | + | label: Reproduction steps | |||
| 59 | + | description: Short, numbered steps help a lot. | |||
| 60 | + | placeholder: | | |||
| 61 | + | 1. Open Dumber | |||
| 62 | + | 2. ... | |||
| 63 | + | 3. ... | |||
| 64 | + | validations: | |||
| 65 | + | required: false | |||
| 66 | + | ||||
| 67 | + | - type: textarea | |||
| 68 | + | id: extra | |||
| 69 | + | attributes: | |||
| 70 | + | label: Additional context | |||
| 71 | + | description: Anything else useful (screenshots, coredumpctl snippets, etc.) | |||
| 72 | + | validations: | |||
| 73 | + | required: false | |||
M cmd/dumber/main.go +86 -36
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "os/signal" | |
| 9 | 9 | "runtime" | |
| 10 | + | "runtime/debug" | |
| 11 | + | "strconv" | |
| 10 | 12 | "syscall" | |
| 11 | 13 | ||
| 12 | 14 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -31,6 +33,7 @@ import ( | |||
| 31 | 33 | "github.com/bnema/dumber/internal/ui/component" | |
| 32 | 34 | "github.com/bnema/dumber/internal/ui/theme" | |
| 33 | 35 | "github.com/rs/zerolog" | |
| 36 | + | "golang.org/x/sys/unix" | |
| 34 | 37 | ) | |
| 35 | 38 | ||
| 36 | 39 | // Build-time variables (set via ldflags). | |
| @@ -47,6 +50,8 @@ var initialURL string | |||
| 47 | 50 | var restoreSessionID string | |
| 48 | 51 | ||
| 49 | 52 | func main() { | |
| 53 | + | enableCrashForensics() | |
| 54 | + | ||
| 50 | 55 | // Run GUI mode for browse command | |
| 51 | 56 | if len(os.Args) > 1 && os.Args[1] == "browse" { | |
| 52 | 57 | if len(os.Args) > 2 { | |
| @@ -105,6 +110,7 @@ func runGUI() int { | |||
| 105 | 110 | ||
| 106 | 111 | log := logging.FromContext(ctx) | |
| 107 | 112 | logging.Trace().UpdateLogger(log) | |
| 113 | + | logCoreDumpLimits(ctx) | |
| 108 | 114 | ||
| 109 | 115 | if stack.MessageRouter != nil { | |
| 110 | 116 | stack.MessageRouter.SetBaseContext(ctx) | |
| @@ -198,7 +204,7 @@ func buildAndConfigureApp( | |||
| 198 | 204 | uiDeps := buildUIDependencies( | |
| 199 | 205 | ctx, cfg, initResult.ThemeManager, | |
| 200 | 206 | initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 201 | - | stack, repos, useCases, idleInhibitor, browserSession.Session.ID, | |
| 207 | + | stack, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(), | |
| 202 | 208 | ) | |
| 203 | 209 | configureDeferredInit(uiDeps, cfg, browserSession) | |
| 204 | 210 | return ui.New(uiDeps) | |
| @@ -295,8 +301,15 @@ func configureDeferredInit( | |||
| 295 | 301 | go func() { | |
| 296 | 302 | if persistErr := session.Persist(bgCtx); persistErr != nil { | |
| 297 | 303 | logger.Error().Err(persistErr).Msg("deferred session persistence failed") | |
| 298 | - | } else if uiDeps.OnSessionPersisted != nil { | |
| 299 | - | uiDeps.OnSessionPersisted() | |
| 304 | + | } else { | |
| 305 | + | if uiDeps.OnCrashReportsDetected != nil { | |
| 306 | + | if reports := session.UnexpectedCloseReports(); len(reports) > 0 { | |
| 307 | + | uiDeps.OnCrashReportsDetected(reports) | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | if uiDeps.OnSessionPersisted != nil { | |
| 311 | + | uiDeps.OnSessionPersisted() | |
| 312 | + | } | |
| 300 | 313 | } | |
| 301 | 314 | }() | |
| 302 | 315 | } | |
| @@ -381,6 +394,41 @@ func setupSignalHandler(ctx context.Context, app *ui.App) { | |||
| 381 | 394 | }() | |
| 382 | 395 | } | |
| 383 | 396 | ||
| 397 | + | func enableCrashForensics() { | |
| 398 | + | debug.SetTraceback("crash") | |
| 399 | + | ||
| 400 | + | var limit unix.Rlimit | |
| 401 | + | if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil { | |
| 402 | + | return | |
| 403 | + | } | |
| 404 | + | if limit.Cur >= limit.Max { | |
| 405 | + | return | |
| 406 | + | } | |
| 407 | + | limit.Cur = limit.Max | |
| 408 | + | _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | func logCoreDumpLimits(ctx context.Context) { | |
| 412 | + | log := logging.FromContext(ctx) | |
| 413 | + | var limit unix.Rlimit | |
| 414 | + | if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil { | |
| 415 | + | log.Debug().Err(err).Msg("failed to read RLIMIT_CORE") | |
| 416 | + | return | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | log.Debug(). | |
| 420 | + | Str("soft", formatRlimit(limit.Cur)). | |
| 421 | + | Str("hard", formatRlimit(limit.Max)). | |
| 422 | + | Msg("core dump limits") | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func formatRlimit(value uint64) string { | |
| 426 | + | if value == unix.RLIM_INFINITY { | |
| 427 | + | return "infinity" | |
| 428 | + | } | |
| 429 | + | return strconv.FormatUint(value, 10) | |
| 430 | + | } | |
| 431 | + | ||
| 384 | 432 | // repositories groups infrastructure layer repository implementations. | |
| 385 | 433 | type repositories struct { | |
| 386 | 434 | history repository.HistoryRepository | |
| @@ -486,40 +534,42 @@ func buildUIDependencies( | |||
| 486 | 534 | uc *useCases, | |
| 487 | 535 | idleInhibitor port.IdleInhibitor, | |
| 488 | 536 | currentSessionID entity.SessionID, | |
| 537 | + | startupCrashReports []string, | |
| 489 | 538 | ) *ui.Dependencies { | |
| 490 | 539 | return &ui.Dependencies{ | |
| 491 | - | Ctx: ctx, | |
| 492 | - | Config: cfg, | |
| 493 | - | InitialURL: initialURL, | |
| 494 | - | RestoreSessionID: restoreSessionID, | |
| 495 | - | Theme: themeManager, | |
| 496 | - | ColorResolver: colorResolver, | |
| 497 | - | AdwaitaDetector: adwaitaDetector, | |
| 498 | - | XDG: xdg.New(), | |
| 499 | - | WebContext: stack.Context, | |
| 500 | - | Pool: stack.Pool, | |
| 501 | - | Settings: stack.Settings, | |
| 502 | - | Injector: stack.Injector, | |
| 503 | - | MessageRouter: stack.MessageRouter, | |
| 504 | - | FilterManager: stack.FilterManager, | |
| 505 | - | HistoryRepo: repos.history, | |
| 506 | - | FavoriteRepo: repos.favorite, | |
| 507 | - | ZoomRepo: repos.zoom, | |
| 508 | - | TabsUC: uc.tabs, | |
| 509 | - | PanesUC: uc.panes, | |
| 510 | - | HistoryUC: uc.history, | |
| 511 | - | FavoritesUC: uc.favorites, | |
| 512 | - | ZoomUC: uc.zoom, | |
| 513 | - | NavigateUC: uc.navigate, | |
| 514 | - | CopyURLUC: uc.copyURL, | |
| 515 | - | Clipboard: uc.clipboard, | |
| 516 | - | FaviconService: uc.favicon, | |
| 517 | - | IdleInhibitor: idleInhibitor, | |
| 518 | - | SessionRepo: repos.session, | |
| 519 | - | SessionStateRepo: repos.sessionState, | |
| 520 | - | CurrentSessionID: currentSessionID, | |
| 521 | - | SnapshotUC: uc.snapshot, | |
| 522 | - | CheckUpdateUC: uc.checkUpdate, | |
| 523 | - | ApplyUpdateUC: uc.applyUpdate, | |
| 540 | + | Ctx: ctx, | |
| 541 | + | Config: cfg, | |
| 542 | + | InitialURL: initialURL, | |
| 543 | + | RestoreSessionID: restoreSessionID, | |
| 544 | + | StartupCrashReports: startupCrashReports, | |
| 545 | + | Theme: themeManager, | |
| 546 | + | ColorResolver: colorResolver, | |
| 547 | + | AdwaitaDetector: adwaitaDetector, | |
| 548 | + | XDG: xdg.New(), | |
| 549 | + | WebContext: stack.Context, | |
| 550 | + | Pool: stack.Pool, | |
| 551 | + | Settings: stack.Settings, | |
| 552 | + | Injector: stack.Injector, | |
| 553 | + | MessageRouter: stack.MessageRouter, | |
| 554 | + | FilterManager: stack.FilterManager, | |
| 555 | + | HistoryRepo: repos.history, | |
| 556 | + | FavoriteRepo: repos.favorite, | |
| 557 | + | ZoomRepo: repos.zoom, | |
| 558 | + | TabsUC: uc.tabs, | |
| 559 | + | PanesUC: uc.panes, | |
| 560 | + | HistoryUC: uc.history, | |
| 561 | + | FavoritesUC: uc.favorites, | |
| 562 | + | ZoomUC: uc.zoom, | |
| 563 | + | NavigateUC: uc.navigate, | |
| 564 | + | CopyURLUC: uc.copyURL, | |
| 565 | + | Clipboard: uc.clipboard, | |
| 566 | + | FaviconService: uc.favicon, | |
| 567 | + | IdleInhibitor: idleInhibitor, | |
| 568 | + | SessionRepo: repos.session, | |
| 569 | + | SessionStateRepo: repos.sessionState, | |
| 570 | + | CurrentSessionID: currentSessionID, | |
| 571 | + | SnapshotUC: uc.snapshot, | |
| 572 | + | CheckUpdateUC: uc.checkUpdate, | |
| 573 | + | ApplyUpdateUC: uc.applyUpdate, | |
| 524 | 574 | } | |
| 525 | 575 | } | |
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | "os" | 7 | "os" | ||
| 8 | "os/signal" | 8 | "os/signal" | ||
| 9 | "runtime" | 9 | "runtime" | ||
| 10 | + | "runtime/debug" | |||
| 11 | + | "strconv" | |||
| 10 | "syscall" | 12 | "syscall" | ||
| 11 | 13 | ||||
| 12 | "github.com/bnema/dumber/internal/application/port" | 14 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -31,6 +33,7 @@ import ( | |||||
| 31 | "github.com/bnema/dumber/internal/ui/component" | 33 | "github.com/bnema/dumber/internal/ui/component" | ||
| 32 | "github.com/bnema/dumber/internal/ui/theme" | 34 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 33 | "github.com/rs/zerolog" | 35 | "github.com/rs/zerolog" | ||
| 36 | + | "golang.org/x/sys/unix" | |||
| 34 | ) | 37 | ) | ||
| 35 | 38 | ||||
| 36 | // Build-time variables (set via ldflags). | 39 | // Build-time variables (set via ldflags). | ||
| @@ -47,6 +50,8 @@ var initialURL string | |||||
| 47 | var restoreSessionID string | 50 | var restoreSessionID string | ||
| 48 | 51 | ||||
| 49 | func main() { | 52 | func main() { | ||
| 53 | + | enableCrashForensics() | |||
| 54 | + | ||||
| 50 | // Run GUI mode for browse command | 55 | // Run GUI mode for browse command | ||
| 51 | if len(os.Args) > 1 && os.Args[1] == "browse" { | 56 | if len(os.Args) > 1 && os.Args[1] == "browse" { | ||
| 52 | if len(os.Args) > 2 { | 57 | if len(os.Args) > 2 { | ||
| @@ -105,6 +110,7 @@ func runGUI() int { | |||||
| 105 | 110 | ||||
| 106 | log := logging.FromContext(ctx) | 111 | log := logging.FromContext(ctx) | ||
| 107 | logging.Trace().UpdateLogger(log) | 112 | logging.Trace().UpdateLogger(log) | ||
| 113 | + | logCoreDumpLimits(ctx) | |||
| 108 | 114 | ||||
| 109 | if stack.MessageRouter != nil { | 115 | if stack.MessageRouter != nil { | ||
| 110 | stack.MessageRouter.SetBaseContext(ctx) | 116 | stack.MessageRouter.SetBaseContext(ctx) | ||
| @@ -198,7 +204,7 @@ func buildAndConfigureApp( | |||||
| 198 | uiDeps := buildUIDependencies( | 204 | uiDeps := buildUIDependencies( | ||
| 199 | ctx, cfg, initResult.ThemeManager, | 205 | ctx, cfg, initResult.ThemeManager, | ||
| 200 | initResult.ColorResolver, initResult.AdwaitaDetector, | 206 | initResult.ColorResolver, initResult.AdwaitaDetector, | ||
| 201 | - | stack, repos, useCases, idleInhibitor, browserSession.Session.ID, | 207 | + | stack, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(), |
| 202 | ) | 208 | ) | ||
| 203 | configureDeferredInit(uiDeps, cfg, browserSession) | 209 | configureDeferredInit(uiDeps, cfg, browserSession) | ||
| 204 | return ui.New(uiDeps) | 210 | return ui.New(uiDeps) | ||
| @@ -295,8 +301,15 @@ func configureDeferredInit( | |||||
| 295 | go func() { | 301 | go func() { | ||
| 296 | if persistErr := session.Persist(bgCtx); persistErr != nil { | 302 | if persistErr := session.Persist(bgCtx); persistErr != nil { | ||
| 297 | logger.Error().Err(persistErr).Msg("deferred session persistence failed") | 303 | logger.Error().Err(persistErr).Msg("deferred session persistence failed") | ||
| 298 | - | } else if uiDeps.OnSessionPersisted != nil { | 304 | + | } else { |
| 299 | - | uiDeps.OnSessionPersisted() | 305 | + | if uiDeps.OnCrashReportsDetected != nil { |
| 306 | + | if reports := session.UnexpectedCloseReports(); len(reports) > 0 { | |||
| 307 | + | uiDeps.OnCrashReportsDetected(reports) | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | if uiDeps.OnSessionPersisted != nil { | |||
| 311 | + | uiDeps.OnSessionPersisted() | |||
| 312 | + | } | |||
| 300 | } | 313 | } | ||
| 301 | }() | 314 | }() | ||
| 302 | } | 315 | } | ||
| @@ -381,6 +394,41 @@ func setupSignalHandler(ctx context.Context, app *ui.App) { | |||||
| 381 | }() | 394 | }() | ||
| 382 | } | 395 | } | ||
| 383 | 396 | ||||
| 397 | + | func enableCrashForensics() { | |||
| 398 | + | debug.SetTraceback("crash") | |||
| 399 | + | ||||
| 400 | + | var limit unix.Rlimit | |||
| 401 | + | if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil { | |||
| 402 | + | return | |||
| 403 | + | } | |||
| 404 | + | if limit.Cur >= limit.Max { | |||
| 405 | + | return | |||
| 406 | + | } | |||
| 407 | + | limit.Cur = limit.Max | |||
| 408 | + | _ = unix.Setrlimit(unix.RLIMIT_CORE, &limit) | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func logCoreDumpLimits(ctx context.Context) { | |||
| 412 | + | log := logging.FromContext(ctx) | |||
| 413 | + | var limit unix.Rlimit | |||
| 414 | + | if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil { | |||
| 415 | + | log.Debug().Err(err).Msg("failed to read RLIMIT_CORE") | |||
| 416 | + | return | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | log.Debug(). | |||
| 420 | + | Str("soft", formatRlimit(limit.Cur)). | |||
| 421 | + | Str("hard", formatRlimit(limit.Max)). | |||
| 422 | + | Msg("core dump limits") | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func formatRlimit(value uint64) string { | |||
| 426 | + | if value == unix.RLIM_INFINITY { | |||
| 427 | + | return "infinity" | |||
| 428 | + | } | |||
| 429 | + | return strconv.FormatUint(value, 10) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 384 | // repositories groups infrastructure layer repository implementations. | 432 | // repositories groups infrastructure layer repository implementations. | ||
| 385 | type repositories struct { | 433 | type repositories struct { | ||
| 386 | history repository.HistoryRepository | 434 | history repository.HistoryRepository | ||
| @@ -486,40 +534,42 @@ func buildUIDependencies( | |||||
| 486 | uc *useCases, | 534 | uc *useCases, | ||
| 487 | idleInhibitor port.IdleInhibitor, | 535 | idleInhibitor port.IdleInhibitor, | ||
| 488 | currentSessionID entity.SessionID, | 536 | currentSessionID entity.SessionID, | ||
| 537 | + | startupCrashReports []string, | |||
| 489 | ) *ui.Dependencies { | 538 | ) *ui.Dependencies { | ||
| 490 | return &ui.Dependencies{ | 539 | return &ui.Dependencies{ | ||
| 491 | - | Ctx: ctx, | 540 | + | Ctx: ctx, |
| 492 | - | Config: cfg, | 541 | + | Config: cfg, |
| 493 | - | InitialURL: initialURL, | 542 | + | InitialURL: initialURL, |
| 494 | - | RestoreSessionID: restoreSessionID, | 543 | + | RestoreSessionID: restoreSessionID, |
| 495 | - | Theme: themeManager, | 544 | + | StartupCrashReports: startupCrashReports, |
| 496 | - | ColorResolver: colorResolver, | 545 | + | Theme: themeManager, |
| 497 | - | AdwaitaDetector: adwaitaDetector, | 546 | + | ColorResolver: colorResolver, |
| 498 | - | XDG: xdg.New(), | 547 | + | AdwaitaDetector: adwaitaDetector, |
| 499 | - | WebContext: stack.Context, | 548 | + | XDG: xdg.New(), |
| 500 | - | Pool: stack.Pool, | 549 | + | WebContext: stack.Context, |
| 501 | - | Settings: stack.Settings, | 550 | + | Pool: stack.Pool, |
| 502 | - | Injector: stack.Injector, | 551 | + | Settings: stack.Settings, |
| 503 | - | MessageRouter: stack.MessageRouter, | 552 | + | Injector: stack.Injector, |
| 504 | - | FilterManager: stack.FilterManager, | 553 | + | MessageRouter: stack.MessageRouter, |
| 505 | - | HistoryRepo: repos.history, | 554 | + | FilterManager: stack.FilterManager, |
| 506 | - | FavoriteRepo: repos.favorite, | 555 | + | HistoryRepo: repos.history, |
| 507 | - | ZoomRepo: repos.zoom, | 556 | + | FavoriteRepo: repos.favorite, |
| 508 | - | TabsUC: uc.tabs, | 557 | + | ZoomRepo: repos.zoom, |
| 509 | - | PanesUC: uc.panes, | 558 | + | TabsUC: uc.tabs, |
| 510 | - | HistoryUC: uc.history, | 559 | + | PanesUC: uc.panes, |
| 511 | - | FavoritesUC: uc.favorites, | 560 | + | HistoryUC: uc.history, |
| 512 | - | ZoomUC: uc.zoom, | 561 | + | FavoritesUC: uc.favorites, |
| 513 | - | NavigateUC: uc.navigate, | 562 | + | ZoomUC: uc.zoom, |
| 514 | - | CopyURLUC: uc.copyURL, | 563 | + | NavigateUC: uc.navigate, |
| 515 | - | Clipboard: uc.clipboard, | 564 | + | CopyURLUC: uc.copyURL, |
| 516 | - | FaviconService: uc.favicon, | 565 | + | Clipboard: uc.clipboard, |
| 517 | - | IdleInhibitor: idleInhibitor, | 566 | + | FaviconService: uc.favicon, |
| 518 | - | SessionRepo: repos.session, | 567 | + | IdleInhibitor: idleInhibitor, |
| 519 | - | SessionStateRepo: repos.sessionState, | 568 | + | SessionRepo: repos.session, |
| 520 | - | CurrentSessionID: currentSessionID, | 569 | + | SessionStateRepo: repos.sessionState, |
| 521 | - | SnapshotUC: uc.snapshot, | 570 | + | CurrentSessionID: currentSessionID, |
| 522 | - | CheckUpdateUC: uc.checkUpdate, | 571 | + | SnapshotUC: uc.snapshot, |
| 523 | - | ApplyUpdateUC: uc.applyUpdate, | 572 | + | CheckUpdateUC: uc.checkUpdate, |
| 573 | + | ApplyUpdateUC: uc.applyUpdate, | |||
| 524 | } | 574 | } | ||
| 525 | } | 575 | } | ||
M docs/cli/index.md +18 -0
| @@ -15,6 +15,7 @@ Dumber provides commands for browser management, history, sessions, and diagnost | |||
| 15 | 15 | | `dumber setup` | Setup desktop integration | | |
| 16 | 16 | | `dumber update` | Check for and install updates | | |
| 17 | 17 | | `dumber logs` | View application logs | | |
| 18 | + | | `dumber crashes` | Inspect unexpected-close reports | | |
| 18 | 19 | | `dumber purge` | Remove data and configuration | | |
| 19 | 20 | | `dumber about` | Show version information | | |
| 20 | 21 | | `dumber gen-docs` | Generate documentation from CLI commands | | |
| @@ -195,6 +196,23 @@ dumber logs clear [flags] | |||
| 195 | 196 | |------|-------------| | |
| 196 | 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 | 216 | ### purge | |
| 199 | 217 | ||
| 200 | 218 | 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 | ### purge | 216 | ### 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): | |||
| 12 | 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 | 21 | ## Classification Model | |
| 16 | 22 | ||
| 17 | 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 | 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 | 101 | Interpretation guidance: | |
| 90 | 102 | ||
| 91 | 103 | - 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 Model | 21 | ## 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: | |||
| 125 | 125 | dumber logs # List sessions | |
| 126 | 126 | dumber logs <session-id> # View specific session | |
| 127 | 127 | 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 | 133 | ## Reset Configuration | |
| @@ -147,4 +150,5 @@ dumber purge | |||
| 147 | 150 | ## Getting Help | |
| 148 | 151 | ||
| 149 | 152 | - Check logs: `dumber logs` | |
| 153 | + | - Check crash reports: `dumber crashes` | |
| 150 | 154 | - [GitHub Issues](https://github.com/bnema/dumber/issues) | |
| @@ -125,6 +125,9 @@ View application logs for debugging: | |||||
| 125 | dumber logs # List sessions | 125 | dumber logs # List sessions | ||
| 126 | dumber logs <session-id> # View specific session | 126 | dumber logs <session-id> # View specific session | ||
| 127 | dumber logs -f <id> # Follow in real-time | 127 | 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 Configuration | 133 | ## Reset Configuration | ||
| @@ -147,4 +150,5 @@ dumber purge | |||||
| 147 | ## Getting Help | 150 | ## Getting Help | ||
| 148 | 151 | ||||
| 149 | - Check logs: `dumber logs` | 152 | - Check logs: `dumber logs` | ||
| 153 | + | - Check crash reports: `dumber crashes` | |||
| 150 | - [GitHub Issues](https://github.com/bnema/dumber/issues) | 154 | - [GitHub Issues](https://github.com/bnema/dumber/issues) | ||
A internal/bootstrap/crash_report.go +290 -0
| @@ -0,0 +1,290 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "net/url" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "regexp" | |
| 12 | + | "runtime" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | corelogging "github.com/bnema/dumber/internal/logging" | |
| 18 | + | "golang.org/x/sys/unix" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | crashReportsDirName = "crashes" | |
| 23 | + | logTailLineCount = 120 | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | type unexpectedCloseReport struct { | |
| 27 | + | ReportVersion int `json:"report_version"` | |
| 28 | + | GeneratedAt string `json:"generated_at"` | |
| 29 | + | SessionID string `json:"session_id"` | |
| 30 | + | Classification SessionExitClassification `json:"classification"` | |
| 31 | + | StartupPID int `json:"startup_pid,omitempty"` | |
| 32 | + | StartupPPID int `json:"startup_ppid,omitempty"` | |
| 33 | + | MarkerFiles map[string]string `json:"marker_files"` | |
| 34 | + | SessionLogFile string `json:"session_log_file,omitempty"` | |
| 35 | + | SessionLogTail []string `json:"session_log_tail_redacted,omitempty"` | |
| 36 | + | ReporterProcess unexpectedCloseReporter `json:"reporter_process"` | |
| 37 | + | CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"` | |
| 38 | + | IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"` | |
| 39 | + | GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"` | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type unexpectedCloseReporter struct { | |
| 43 | + | GeneratedBy string `json:"generated_by"` | |
| 44 | + | GoVersion string `json:"go_version"` | |
| 45 | + | GOOS string `json:"goos"` | |
| 46 | + | GOARCH string `json:"goarch"` | |
| 47 | + | PID int `json:"pid"` | |
| 48 | + | PPID int `json:"ppid"` | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type unexpectedCloseCoreDump struct { | |
| 52 | + | RLimitCoreSoft string `json:"rlimit_core_soft"` | |
| 53 | + | RLimitCoreHard string `json:"rlimit_core_hard"` | |
| 54 | + | Hint string `json:"hint"` | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | type unexpectedCloseIssueTemplate struct { | |
| 58 | + | Title string `json:"title"` | |
| 59 | + | Summary string `json:"summary"` | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) { | |
| 63 | + | if lockDir == "" || sessionID == "" { | |
| 64 | + | return "", errors.New("lockDir and sessionID are required") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID) | |
| 68 | + | if classifyErr != nil { | |
| 69 | + | return "", classifyErr | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | if classification.Class == SessionExitCleanExit { | |
| 73 | + | return "", nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | reportsDir := filepath.Join(lockDir, crashReportsDirName) | |
| 77 | + | if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil { | |
| 78 | + | return "", err | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID)) | |
| 82 | + | markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID)) | |
| 83 | + | if _, statErr := os.Stat(jsonPath); statErr == nil { | |
| 84 | + | return jsonPath, nil | |
| 85 | + | } else if !os.IsNotExist(statErr) { | |
| 86 | + | return "", statErr | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | report := unexpectedCloseReport{ | |
| 90 | + | ReportVersion: 1, | |
| 91 | + | GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), | |
| 92 | + | SessionID: sessionID, | |
| 93 | + | Classification: classification, | |
| 94 | + | MarkerFiles: reportMarkerFiles(lockDir, sessionID), | |
| 95 | + | ReporterProcess: currentReporter(), | |
| 96 | + | CoreDumpDiagnostics: collectCoreDumpDiagnostics(), | |
| 97 | + | IssueTemplate: unexpectedCloseIssueTemplate{ | |
| 98 | + | Title: fmt.Sprintf("Unexpected close in session %s", sessionID), | |
| 99 | + | Summary: "Dumber closed unexpectedly and generated this report automatically.", | |
| 100 | + | }, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID) | |
| 104 | + | report.StartupPID = startupPID | |
| 105 | + | report.StartupPPID = startupPPID | |
| 106 | + | ||
| 107 | + | logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID)) | |
| 108 | + | if _, statErr := os.Stat(logPath); statErr == nil { | |
| 109 | + | report.SessionLogFile = logPath | |
| 110 | + | report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | report.GeneratedMarkdownRef = markdownPath | |
| 114 | + | ||
| 115 | + | payload, err := json.MarshalIndent(report, "", " ") | |
| 116 | + | if err != nil { | |
| 117 | + | return "", err | |
| 118 | + | } | |
| 119 | + | payload = append(payload, '\n') | |
| 120 | + | if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil { | |
| 121 | + | return "", err | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | markdown := buildUnexpectedCloseMarkdown(report) | |
| 125 | + | if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil { | |
| 126 | + | return "", err | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | return jsonPath, nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func reportMarkerFiles(lockDir, sessionID string) map[string]string { | |
| 133 | + | return map[string]string{ | |
| 134 | + | "startup": startupMarkerPath(lockDir, sessionID), | |
| 135 | + | "shutdown": shutdownMarkerPath(lockDir, sessionID), | |
| 136 | + | "abrupt": abruptMarkerPath(lockDir, sessionID), | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func currentReporter() unexpectedCloseReporter { | |
| 141 | + | return unexpectedCloseReporter{ | |
| 142 | + | GeneratedBy: "dumber", | |
| 143 | + | GoVersion: runtime.Version(), | |
| 144 | + | GOOS: runtime.GOOS, | |
| 145 | + | GOARCH: runtime.GOARCH, | |
| 146 | + | PID: os.Getpid(), | |
| 147 | + | PPID: os.Getppid(), | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func collectCoreDumpDiagnostics() unexpectedCloseCoreDump { | |
| 152 | + | d := unexpectedCloseCoreDump{ | |
| 153 | + | Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.", | |
| 154 | + | } | |
| 155 | + | var limit unix.Rlimit | |
| 156 | + | if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil { | |
| 157 | + | d.RLimitCoreSoft = "unknown" | |
| 158 | + | d.RLimitCoreHard = "unknown" | |
| 159 | + | return d | |
| 160 | + | } | |
| 161 | + | d.RLimitCoreSoft = formatRlimitCore(limit.Cur) | |
| 162 | + | d.RLimitCoreHard = formatRlimitCore(limit.Max) | |
| 163 | + | return d | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func formatRlimitCore(value uint64) string { | |
| 167 | + | if value == unix.RLIM_INFINITY { | |
| 168 | + | return "infinity" | |
| 169 | + | } | |
| 170 | + | return strconv.FormatUint(value, 10) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func readStartupProcessIDs(lockDir, sessionID string) (int, int) { | |
| 174 | + | raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID)) | |
| 175 | + | if err != nil { | |
| 176 | + | raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID)) | |
| 177 | + | if err != nil { | |
| 178 | + | return 0, 0 | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | pid, _ := strconv.Atoi(markerValue(raw, "pid=")) | |
| 183 | + | ppid, _ := strconv.Atoi(markerValue(raw, "ppid=")) | |
| 184 | + | return pid, ppid | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func readRedactedLogTail(path string, lines int) []string { | |
| 188 | + | if lines <= 0 { | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | file, err := os.Open(path) | |
| 193 | + | if err != nil { | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | defer func() { _ = file.Close() }() | |
| 197 | + | ||
| 198 | + | all := make([]string, 0, lines) | |
| 199 | + | scanner := bufio.NewScanner(file) | |
| 200 | + | for scanner.Scan() { | |
| 201 | + | all = append(all, redactSensitiveContent(scanner.Text())) | |
| 202 | + | } | |
| 203 | + | if len(all) <= lines { | |
| 204 | + | return all | |
| 205 | + | } | |
| 206 | + | return all[len(all)-lines:] | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string { | |
| 210 | + | lines := []string{ | |
| 211 | + | "# Unexpected Close Report", | |
| 212 | + | "", | |
| 213 | + | fmt.Sprintf("Generated: `%s`", report.GeneratedAt), | |
| 214 | + | fmt.Sprintf("Session: `%s`", report.SessionID), | |
| 215 | + | fmt.Sprintf("Class: `%s`", report.Classification.Class), | |
| 216 | + | fmt.Sprintf("Inference: `%s`", report.Classification.Inference), | |
| 217 | + | fmt.Sprintf("Reason: `%s`", report.Classification.Reason), | |
| 218 | + | "", | |
| 219 | + | "## Process Context", | |
| 220 | + | fmt.Sprintf("- startup pid: `%d`", report.StartupPID), | |
| 221 | + | fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID), | |
| 222 | + | fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID), | |
| 223 | + | fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID), | |
| 224 | + | "", | |
| 225 | + | "## Core Dump Diagnostics", | |
| 226 | + | fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft), | |
| 227 | + | fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard), | |
| 228 | + | fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint), | |
| 229 | + | "", | |
| 230 | + | "## Marker Files", | |
| 231 | + | fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]), | |
| 232 | + | fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]), | |
| 233 | + | fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]), | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | if len(report.SessionLogTail) > 0 { | |
| 237 | + | lines = append(lines, | |
| 238 | + | "", | |
| 239 | + | "## Redacted Log Tail", | |
| 240 | + | "```text", | |
| 241 | + | ) | |
| 242 | + | lines = append(lines, report.SessionLogTail...) | |
| 243 | + | lines = append(lines, "```") | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | lines = append(lines, | |
| 247 | + | "", | |
| 248 | + | "## GitHub Issue Template", | |
| 249 | + | fmt.Sprintf("Title: %s", report.IssueTemplate.Title), | |
| 250 | + | "", | |
| 251 | + | "```markdown", | |
| 252 | + | "### What happened", | |
| 253 | + | "Describe what you were doing just before Dumber closed unexpectedly.", | |
| 254 | + | "", | |
| 255 | + | "### Crash report", | |
| 256 | + | fmt.Sprintf("- session id: `%s`", report.SessionID), | |
| 257 | + | fmt.Sprintf("- class: `%s`", report.Classification.Class), | |
| 258 | + | fmt.Sprintf("- reason: `%s`", report.Classification.Reason), | |
| 259 | + | "", | |
| 260 | + | "### Additional context", | |
| 261 | + | "- distro:", | |
| 262 | + | "- compositor/window manager:", | |
| 263 | + | "- steps to reproduce:", | |
| 264 | + | "```", | |
| 265 | + | ) | |
| 266 | + | ||
| 267 | + | return strings.Join(lines, "\n") + "\n" | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`) | |
| 271 | + | ||
| 272 | + | var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`) | |
| 273 | + | ||
| 274 | + | func redactSensitiveContent(line string) string { | |
| 275 | + | redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString) | |
| 276 | + | redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]") | |
| 277 | + | return redacted | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func redactURLString(raw string) string { | |
| 281 | + | trimmed := strings.TrimSpace(raw) | |
| 282 | + | trimmed = strings.TrimRight(trimmed, ".,;:") | |
| 283 | + | u, err := url.Parse(trimmed) | |
| 284 | + | if err != nil { | |
| 285 | + | return trimmed | |
| 286 | + | } | |
| 287 | + | u.RawQuery = "" | |
| 288 | + | u.Fragment = "" | |
| 289 | + | return u.String() | |
| 290 | + | } | |
| @@ -0,0 +1,290 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "net/url" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "regexp" | |||
| 12 | + | "runtime" | |||
| 13 | + | "strconv" | |||
| 14 | + | "strings" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | corelogging "github.com/bnema/dumber/internal/logging" | |||
| 18 | + | "golang.org/x/sys/unix" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | crashReportsDirName = "crashes" | |||
| 23 | + | logTailLineCount = 120 | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | type unexpectedCloseReport struct { | |||
| 27 | + | ReportVersion int `json:"report_version"` | |||
| 28 | + | GeneratedAt string `json:"generated_at"` | |||
| 29 | + | SessionID string `json:"session_id"` | |||
| 30 | + | Classification SessionExitClassification `json:"classification"` | |||
| 31 | + | StartupPID int `json:"startup_pid,omitempty"` | |||
| 32 | + | StartupPPID int `json:"startup_ppid,omitempty"` | |||
| 33 | + | MarkerFiles map[string]string `json:"marker_files"` | |||
| 34 | + | SessionLogFile string `json:"session_log_file,omitempty"` | |||
| 35 | + | SessionLogTail []string `json:"session_log_tail_redacted,omitempty"` | |||
| 36 | + | ReporterProcess unexpectedCloseReporter `json:"reporter_process"` | |||
| 37 | + | CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"` | |||
| 38 | + | IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"` | |||
| 39 | + | GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"` | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type unexpectedCloseReporter struct { | |||
| 43 | + | GeneratedBy string `json:"generated_by"` | |||
| 44 | + | GoVersion string `json:"go_version"` | |||
| 45 | + | GOOS string `json:"goos"` | |||
| 46 | + | GOARCH string `json:"goarch"` | |||
| 47 | + | PID int `json:"pid"` | |||
| 48 | + | PPID int `json:"ppid"` | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | type unexpectedCloseCoreDump struct { | |||
| 52 | + | RLimitCoreSoft string `json:"rlimit_core_soft"` | |||
| 53 | + | RLimitCoreHard string `json:"rlimit_core_hard"` | |||
| 54 | + | Hint string `json:"hint"` | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | type unexpectedCloseIssueTemplate struct { | |||
| 58 | + | Title string `json:"title"` | |||
| 59 | + | Summary string `json:"summary"` | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) { | |||
| 63 | + | if lockDir == "" || sessionID == "" { | |||
| 64 | + | return "", errors.New("lockDir and sessionID are required") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID) | |||
| 68 | + | if classifyErr != nil { | |||
| 69 | + | return "", classifyErr | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | if classification.Class == SessionExitCleanExit { | |||
| 73 | + | return "", nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | reportsDir := filepath.Join(lockDir, crashReportsDirName) | |||
| 77 | + | if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil { | |||
| 78 | + | return "", err | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID)) | |||
| 82 | + | markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID)) | |||
| 83 | + | if _, statErr := os.Stat(jsonPath); statErr == nil { | |||
| 84 | + | return jsonPath, nil | |||
| 85 | + | } else if !os.IsNotExist(statErr) { | |||
| 86 | + | return "", statErr | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | report := unexpectedCloseReport{ | |||
| 90 | + | ReportVersion: 1, | |||
| 91 | + | GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), | |||
| 92 | + | SessionID: sessionID, | |||
| 93 | + | Classification: classification, | |||
| 94 | + | MarkerFiles: reportMarkerFiles(lockDir, sessionID), | |||
| 95 | + | ReporterProcess: currentReporter(), | |||
| 96 | + | CoreDumpDiagnostics: collectCoreDumpDiagnostics(), | |||
| 97 | + | IssueTemplate: unexpectedCloseIssueTemplate{ | |||
| 98 | + | Title: fmt.Sprintf("Unexpected close in session %s", sessionID), | |||
| 99 | + | Summary: "Dumber closed unexpectedly and generated this report automatically.", | |||
| 100 | + | }, | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID) | |||
| 104 | + | report.StartupPID = startupPID | |||
| 105 | + | report.StartupPPID = startupPPID | |||
| 106 | + | ||||
| 107 | + | logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID)) | |||
| 108 | + | if _, statErr := os.Stat(logPath); statErr == nil { | |||
| 109 | + | report.SessionLogFile = logPath | |||
| 110 | + | report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | report.GeneratedMarkdownRef = markdownPath | |||
| 114 | + | ||||
| 115 | + | payload, err := json.MarshalIndent(report, "", " ") | |||
| 116 | + | if err != nil { | |||
| 117 | + | return "", err | |||
| 118 | + | } | |||
| 119 | + | payload = append(payload, '\n') | |||
| 120 | + | if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil { | |||
| 121 | + | return "", err | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | markdown := buildUnexpectedCloseMarkdown(report) | |||
| 125 | + | if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil { | |||
| 126 | + | return "", err | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | return jsonPath, nil | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func reportMarkerFiles(lockDir, sessionID string) map[string]string { | |||
| 133 | + | return map[string]string{ | |||
| 134 | + | "startup": startupMarkerPath(lockDir, sessionID), | |||
| 135 | + | "shutdown": shutdownMarkerPath(lockDir, sessionID), | |||
| 136 | + | "abrupt": abruptMarkerPath(lockDir, sessionID), | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func currentReporter() unexpectedCloseReporter { | |||
| 141 | + | return unexpectedCloseReporter{ | |||
| 142 | + | GeneratedBy: "dumber", | |||
| 143 | + | GoVersion: runtime.Version(), | |||
| 144 | + | GOOS: runtime.GOOS, | |||
| 145 | + | GOARCH: runtime.GOARCH, | |||
| 146 | + | PID: os.Getpid(), | |||
| 147 | + | PPID: os.Getppid(), | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func collectCoreDumpDiagnostics() unexpectedCloseCoreDump { | |||
| 152 | + | d := unexpectedCloseCoreDump{ | |||
| 153 | + | Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.", | |||
| 154 | + | } | |||
| 155 | + | var limit unix.Rlimit | |||
| 156 | + | if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil { | |||
| 157 | + | d.RLimitCoreSoft = "unknown" | |||
| 158 | + | d.RLimitCoreHard = "unknown" | |||
| 159 | + | return d | |||
| 160 | + | } | |||
| 161 | + | d.RLimitCoreSoft = formatRlimitCore(limit.Cur) | |||
| 162 | + | d.RLimitCoreHard = formatRlimitCore(limit.Max) | |||
| 163 | + | return d | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func formatRlimitCore(value uint64) string { | |||
| 167 | + | if value == unix.RLIM_INFINITY { | |||
| 168 | + | return "infinity" | |||
| 169 | + | } | |||
| 170 | + | return strconv.FormatUint(value, 10) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func readStartupProcessIDs(lockDir, sessionID string) (int, int) { | |||
| 174 | + | raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID)) | |||
| 175 | + | if err != nil { | |||
| 176 | + | raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID)) | |||
| 177 | + | if err != nil { | |||
| 178 | + | return 0, 0 | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | pid, _ := strconv.Atoi(markerValue(raw, "pid=")) | |||
| 183 | + | ppid, _ := strconv.Atoi(markerValue(raw, "ppid=")) | |||
| 184 | + | return pid, ppid | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func readRedactedLogTail(path string, lines int) []string { | |||
| 188 | + | if lines <= 0 { | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | file, err := os.Open(path) | |||
| 193 | + | if err != nil { | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | defer func() { _ = file.Close() }() | |||
| 197 | + | ||||
| 198 | + | all := make([]string, 0, lines) | |||
| 199 | + | scanner := bufio.NewScanner(file) | |||
| 200 | + | for scanner.Scan() { | |||
| 201 | + | all = append(all, redactSensitiveContent(scanner.Text())) | |||
| 202 | + | } | |||
| 203 | + | if len(all) <= lines { | |||
| 204 | + | return all | |||
| 205 | + | } | |||
| 206 | + | return all[len(all)-lines:] | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string { | |||
| 210 | + | lines := []string{ | |||
| 211 | + | "# Unexpected Close Report", | |||
| 212 | + | "", | |||
| 213 | + | fmt.Sprintf("Generated: `%s`", report.GeneratedAt), | |||
| 214 | + | fmt.Sprintf("Session: `%s`", report.SessionID), | |||
| 215 | + | fmt.Sprintf("Class: `%s`", report.Classification.Class), | |||
| 216 | + | fmt.Sprintf("Inference: `%s`", report.Classification.Inference), | |||
| 217 | + | fmt.Sprintf("Reason: `%s`", report.Classification.Reason), | |||
| 218 | + | "", | |||
| 219 | + | "## Process Context", | |||
| 220 | + | fmt.Sprintf("- startup pid: `%d`", report.StartupPID), | |||
| 221 | + | fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID), | |||
| 222 | + | fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID), | |||
| 223 | + | fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID), | |||
| 224 | + | "", | |||
| 225 | + | "## Core Dump Diagnostics", | |||
| 226 | + | fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft), | |||
| 227 | + | fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard), | |||
| 228 | + | fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint), | |||
| 229 | + | "", | |||
| 230 | + | "## Marker Files", | |||
| 231 | + | fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]), | |||
| 232 | + | fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]), | |||
| 233 | + | fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]), | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | if len(report.SessionLogTail) > 0 { | |||
| 237 | + | lines = append(lines, | |||
| 238 | + | "", | |||
| 239 | + | "## Redacted Log Tail", | |||
| 240 | + | "```text", | |||
| 241 | + | ) | |||
| 242 | + | lines = append(lines, report.SessionLogTail...) | |||
| 243 | + | lines = append(lines, "```") | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | lines = append(lines, | |||
| 247 | + | "", | |||
| 248 | + | "## GitHub Issue Template", | |||
| 249 | + | fmt.Sprintf("Title: %s", report.IssueTemplate.Title), | |||
| 250 | + | "", | |||
| 251 | + | "```markdown", | |||
| 252 | + | "### What happened", | |||
| 253 | + | "Describe what you were doing just before Dumber closed unexpectedly.", | |||
| 254 | + | "", | |||
| 255 | + | "### Crash report", | |||
| 256 | + | fmt.Sprintf("- session id: `%s`", report.SessionID), | |||
| 257 | + | fmt.Sprintf("- class: `%s`", report.Classification.Class), | |||
| 258 | + | fmt.Sprintf("- reason: `%s`", report.Classification.Reason), | |||
| 259 | + | "", | |||
| 260 | + | "### Additional context", | |||
| 261 | + | "- distro:", | |||
| 262 | + | "- compositor/window manager:", | |||
| 263 | + | "- steps to reproduce:", | |||
| 264 | + | "```", | |||
| 265 | + | ) | |||
| 266 | + | ||||
| 267 | + | return strings.Join(lines, "\n") + "\n" | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`) | |||
| 271 | + | ||||
| 272 | + | var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`) | |||
| 273 | + | ||||
| 274 | + | func redactSensitiveContent(line string) string { | |||
| 275 | + | redacted := urlRegex.ReplaceAllStringFunc(line, redactURLString) | |||
| 276 | + | redacted = secretKeyRegex.ReplaceAllString(redacted, "$1=[REDACTED]") | |||
| 277 | + | return redacted | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func redactURLString(raw string) string { | |||
| 281 | + | trimmed := strings.TrimSpace(raw) | |||
| 282 | + | trimmed = strings.TrimRight(trimmed, ".,;:") | |||
| 283 | + | u, err := url.Parse(trimmed) | |||
| 284 | + | if err != nil { | |||
| 285 | + | return trimmed | |||
| 286 | + | } | |||
| 287 | + | u.RawQuery = "" | |||
| 288 | + | u.Fragment = "" | |||
| 289 | + | return u.String() | |||
| 290 | + | } | |||
A internal/bootstrap/crash_report_test.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | corelogging "github.com/bnema/dumber/internal/logging" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestRedactSensitiveContent(t *testing.T) { | |
| 17 | + | line := "redirect to https://example.com/cb?code=abc123&token=secret#frag" | |
| 18 | + | redacted := redactSensitiveContent(line) | |
| 19 | + | ||
| 20 | + | assert.Contains(t, redacted, "https://example.com/cb") | |
| 21 | + | assert.NotContains(t, redacted, "code=abc123") | |
| 22 | + | assert.NotContains(t, redacted, "token=secret") | |
| 23 | + | assert.NotContains(t, redacted, "#frag") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestWriteUnexpectedCloseReport(t *testing.T) { | |
| 27 | + | lockDir := t.TempDir() | |
| 28 | + | sessionID := "crash-test-1" | |
| 29 | + | startedAt := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 30 | + | ||
| 31 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |
| 32 | + | _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | ||
| 35 | + | logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID)) | |
| 36 | + | logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` + | |
| 37 | + | "\n" + | |
| 38 | + | `{"level":"warn","message":"callback code=abc token=def"}` + | |
| 39 | + | "\n" | |
| 40 | + | require.NoError(t, os.WriteFile(logPath, []byte(logBody), markerFilePerm)) | |
| 41 | + | ||
| 42 | + | jsonPath, err := writeUnexpectedCloseReport(lockDir, sessionID) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.NotEmpty(t, jsonPath) | |
| 45 | + | assert.FileExists(t, jsonPath) | |
| 46 | + | ||
| 47 | + | mdPath := strings.TrimSuffix(jsonPath, ".json") + ".md" | |
| 48 | + | assert.FileExists(t, mdPath) | |
| 49 | + | ||
| 50 | + | raw, err := os.ReadFile(jsonPath) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | ||
| 53 | + | var payload map[string]any | |
| 54 | + | require.NoError(t, json.Unmarshal(raw, &payload)) | |
| 55 | + | assert.Equal(t, sessionID, payload["session_id"]) | |
| 56 | + | ||
| 57 | + | classification := payload["classification"].(map[string]any) | |
| 58 | + | classValue, _ := classification["Class"].(string) | |
| 59 | + | assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue) | |
| 60 | + | ||
| 61 | + | tail := payload["session_log_tail_redacted"].([]any) | |
| 62 | + | require.NotEmpty(t, tail) | |
| 63 | + | text := tail[0].(string) + tail[len(tail)-1].(string) | |
| 64 | + | assert.NotContains(t, text, "?a=1&b=2") | |
| 65 | + | assert.NotContains(t, text, "code=abc") | |
| 66 | + | assert.NotContains(t, text, "token=def") | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | corelogging "github.com/bnema/dumber/internal/logging" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestRedactSensitiveContent(t *testing.T) { | |||
| 17 | + | line := "redirect to https://example.com/cb?code=abc123&token=secret#frag" | |||
| 18 | + | redacted := redactSensitiveContent(line) | |||
| 19 | + | ||||
| 20 | + | assert.Contains(t, redacted, "https://example.com/cb") | |||
| 21 | + | assert.NotContains(t, redacted, "code=abc123") | |||
| 22 | + | assert.NotContains(t, redacted, "token=secret") | |||
| 23 | + | assert.NotContains(t, redacted, "#frag") | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestWriteUnexpectedCloseReport(t *testing.T) { | |||
| 27 | + | lockDir := t.TempDir() | |||
| 28 | + | sessionID := "crash-test-1" | |||
| 29 | + | startedAt := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 30 | + | ||||
| 31 | + | require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt)) | |||
| 32 | + | _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | ||||
| 35 | + | logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID)) | |||
| 36 | + | logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` + | |||
| 37 | + | "\n" + | |||
| 38 | + | `{"level":"warn","message":"callback code=abc token=def"}` + | |||
| 39 | + | "\n" | |||
| 40 | + | require.NoError(t, os.WriteFile(logPath, []byte(logBody), markerFilePerm)) | |||
| 41 | + | ||||
| 42 | + | jsonPath, err := writeUnexpectedCloseReport(lockDir, sessionID) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | require.NotEmpty(t, jsonPath) | |||
| 45 | + | assert.FileExists(t, jsonPath) | |||
| 46 | + | ||||
| 47 | + | mdPath := strings.TrimSuffix(jsonPath, ".json") + ".md" | |||
| 48 | + | assert.FileExists(t, mdPath) | |||
| 49 | + | ||||
| 50 | + | raw, err := os.ReadFile(jsonPath) | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | ||||
| 53 | + | var payload map[string]any | |||
| 54 | + | require.NoError(t, json.Unmarshal(raw, &payload)) | |||
| 55 | + | assert.Equal(t, sessionID, payload["session_id"]) | |||
| 56 | + | ||||
| 57 | + | classification := payload["classification"].(map[string]any) | |||
| 58 | + | classValue, _ := classification["Class"].(string) | |||
| 59 | + | assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue) | |||
| 60 | + | ||||
| 61 | + | tail := payload["session_log_tail_redacted"].([]any) | |||
| 62 | + | require.NotEmpty(t, tail) | |||
| 63 | + | text := tail[0].(string) + tail[len(tail)-1].(string) | |||
| 64 | + | assert.NotContains(t, text, "?a=1&b=2") | |||
| 65 | + | assert.NotContains(t, text, "code=abc") | |||
| 66 | + | assert.NotContains(t, text, "token=def") | |||
| 67 | + | } | |||
M internal/bootstrap/session.go +207 -80
| @@ -33,10 +33,31 @@ type BrowserSession struct { | |||
| 33 | 33 | Logger zerolog.Logger | |
| 34 | 34 | LogCleanup func() | |
| 35 | 35 | ||
| 36 | + | reportsMu sync.RWMutex | |
| 37 | + | unexpectedCloseReportPath []string | |
| 38 | + | ||
| 36 | 39 | Persist func(context.Context) error | |
| 37 | 40 | 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 | 61 | func StartBrowserSession( | |
| 41 | 62 | ctx context.Context, | |
| 42 | 63 | cfg *config.Config, | |
| @@ -91,6 +112,12 @@ func StartBrowserSession( | |||
| 91 | 112 | persistErr error | |
| 92 | 113 | ) | |
| 93 | 114 | ||
| 115 | + | browserSession := &BrowserSession{ | |
| 116 | + | Session: session, | |
| 117 | + | Logger: logger, | |
| 118 | + | LogCleanup: cleanup, | |
| 119 | + | } | |
| 120 | + | ||
| 94 | 121 | // persistFn ensures the session is saved exactly once. Using sync.Once is intentional: | |
| 95 | 122 | // if the first persist fails, we cache the error and return it on subsequent calls | |
| 96 | 123 | // rather than retrying. This prevents duplicate session records and ensures consistent | |
| @@ -98,15 +125,11 @@ func StartBrowserSession( | |||
| 98 | 125 | persistFn := func(persistCtx context.Context) error { | |
| 99 | 126 | persistMu.Do(func() { | |
| 100 | 127 | if lockDir != "" { | |
| 101 | - | abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), &logger) | |
| 128 | + | generatedReports, abruptErr := detectAbruptSessionsAndBuildReports(lockDir, &logger) | |
| 102 | 129 | if abruptErr != nil { | |
| 103 | 130 | 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) | |
| 110 | 133 | } | |
| 111 | 134 | } | |
| 112 | 135 | ||
| @@ -167,12 +190,7 @@ func StartBrowserSession( | |||
| 167 | 190 | return endErr | |
| 168 | 191 | } | |
| 169 | 192 | ||
| 170 | - | browserSession := &BrowserSession{ | |
| 171 | - | Session: session, | |
| 172 | - | Logger: logger, | |
| 173 | - | LogCleanup: cleanup, | |
| 174 | - | End: endFn, | |
| 175 | - | } | |
| 193 | + | browserSession.End = endFn | |
| 176 | 194 | ||
| 177 | 195 | if deferPersist { | |
| 178 | 196 | browserSession.Persist = persistFn | |
| @@ -188,6 +206,44 @@ func StartBrowserSession( | |||
| 188 | 206 | 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 | 247 | func endStaleActiveBrowserSessions( | |
| 192 | 248 | ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger, | |
| 193 | 249 | ) error { | |
| @@ -309,7 +365,12 @@ func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error { | |||
| 309 | 365 | if err := os.MkdirAll(lockDir, lockDirPerm); err != nil { | |
| 310 | 366 | 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 | 374 | if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil { | |
| 314 | 375 | return err | |
| 315 | 376 | } | |
| @@ -330,14 +391,22 @@ func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error { | |||
| 330 | 391 | // ClassifySessionExitFromMarkers can still read it after the | |
| 331 | 392 | // startup marker file is removed. | |
| 332 | 393 | var startupLine string | |
| 394 | + | var pidLine string | |
| 395 | + | var ppidLine string | |
| 333 | 396 | startupPath := startupMarkerPath(lockDir, sessionID) | |
| 334 | 397 | if raw, readErr := os.ReadFile(startupPath); readErr == nil { | |
| 335 | - | if t := strings.TrimSpace(string(raw)); t != "" { | |
| 398 | + | if t := firstNonEmptyLine(raw); t != "" { | |
| 336 | 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 | 410 | if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil { | |
| 342 | 411 | return err | |
| 343 | 412 | } | |
| @@ -359,81 +428,35 @@ func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logge | |||
| 359 | 428 | ||
| 360 | 429 | abruptSessions := make([]string, 0, len(startupMarkers)) | |
| 361 | 430 | 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) | |
| 367 | 432 | if sessionID == "" { | |
| 368 | 433 | continue | |
| 369 | 434 | } | |
| 370 | 435 | ||
| 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 { | |
| 412 | 438 | 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") | |
| 418 | 440 | } | |
| 419 | 441 | continue | |
| 420 | 442 | } | |
| 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 { | |
| 432 | 444 | continue | |
| 433 | 445 | } | |
| 434 | 446 | ||
| 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 { | |
| 437 | 460 | return abruptSessions, err | |
| 438 | 461 | } | |
| 439 | 462 | abruptSessions = append(abruptSessions, sessionID) | |
| @@ -442,6 +465,110 @@ func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logge | |||
| 442 | 465 | 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 | 572 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | |
| 446 | 573 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | |
| 447 | 574 | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | |
| @@ -33,10 +33,31 @@ type BrowserSession struct { | |||||
| 33 | Logger zerolog.Logger | 33 | Logger zerolog.Logger | ||
| 34 | LogCleanup func() | 34 | LogCleanup func() | ||
| 35 | 35 | ||||
| 36 | + | reportsMu sync.RWMutex | |||
| 37 | + | unexpectedCloseReportPath []string | |||
| 38 | + | ||||
| 36 | Persist func(context.Context) error | 39 | Persist func(context.Context) error | ||
| 37 | End func(context.Context) error | 40 | 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 error | 112 | 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 calls | 122 | // 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 consistent | 123 | // 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 endErr | 190 | 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 = persistFn | 196 | browserSession.Persist = persistFn | ||
| @@ -188,6 +206,44 @@ func StartBrowserSession( | |||||
| 188 | return browserSession, sessionCtx, nil | 206 | 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 err | 366 | 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 err | 375 | return err | ||
| 315 | } | 376 | } | ||
| @@ -330,14 +391,22 @@ func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error { | |||||
| 330 | // ClassifySessionExitFromMarkers can still read it after the | 391 | // ClassifySessionExitFromMarkers can still read it after the | ||
| 331 | // startup marker file is removed. | 392 | // startup marker file is removed. | ||
| 332 | var startupLine string | 393 | 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 err | 411 | 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 | continue | 433 | 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 | continue | 441 | 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 | continue | 444 | 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, err | 460 | 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, nil | 465 | 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 pruning | 572 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | ||
| 446 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | 573 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | ||
| 447 | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | 574 | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | ||
M internal/bootstrap/session_markers_test.go +8 -0
| @@ -22,6 +22,8 @@ func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) { | |||
| 22 | 22 | startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID)) | |
| 23 | 23 | require.NoError(t, err) | |
| 24 | 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 | 28 | err = writeShutdownMarker(lockDir, sessionID, endedAt) | |
| 27 | 29 | require.NoError(t, err) | |
| @@ -29,6 +31,9 @@ func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) { | |||
| 29 | 31 | shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID)) | |
| 30 | 32 | require.NoError(t, err) | |
| 31 | 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 | 39 | func TestSessionMarkers_MarkAbruptExits(t *testing.T) { | |
| @@ -46,6 +51,9 @@ func TestSessionMarkers_MarkAbruptExits(t *testing.T) { | |||
| 46 | 51 | abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a")) | |
| 47 | 52 | require.NoError(t, err) | |
| 48 | 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 | 57 | assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker")) | |
| 50 | 58 | } | |
| 51 | 59 | ||
| @@ -22,6 +22,8 @@ func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) { | |||||
| 22 | startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID)) | 22 | startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID)) | ||
| 23 | require.NoError(t, err) | 23 | require.NoError(t, err) | ||
| 24 | assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano)) | 24 | assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano)) | ||
| 25 | + | assert.Contains(t, string(startupContent), "pid=") | |||
| 26 | + | assert.Contains(t, string(startupContent), "ppid=") | |||
| 25 | 27 | ||||
| 26 | err = writeShutdownMarker(lockDir, sessionID, endedAt) | 28 | err = writeShutdownMarker(lockDir, sessionID, endedAt) | ||
| 27 | require.NoError(t, err) | 29 | require.NoError(t, err) | ||
| @@ -29,6 +31,9 @@ func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) { | |||||
| 29 | shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID)) | 31 | shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID)) | ||
| 30 | require.NoError(t, err) | 32 | require.NoError(t, err) | ||
| 31 | assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano)) | 33 | assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano)) | ||
| 34 | + | assert.Contains(t, string(shutdownContent), "started_at="+startedAt.Format(time.RFC3339Nano)) | |||
| 35 | + | assert.Contains(t, string(shutdownContent), "pid=") | |||
| 36 | + | assert.Contains(t, string(shutdownContent), "ppid=") | |||
| 32 | } | 37 | } | ||
| 33 | 38 | ||||
| 34 | func TestSessionMarkers_MarkAbruptExits(t *testing.T) { | 39 | func TestSessionMarkers_MarkAbruptExits(t *testing.T) { | ||
| @@ -46,6 +51,9 @@ func TestSessionMarkers_MarkAbruptExits(t *testing.T) { | |||||
| 46 | abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a")) | 51 | abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a")) | ||
| 47 | require.NoError(t, err) | 52 | require.NoError(t, err) | ||
| 48 | assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano)) | 53 | assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano)) | ||
| 54 | + | assert.Contains(t, string(abruptMarkerData), "started_at=") | |||
| 55 | + | assert.Contains(t, string(abruptMarkerData), "pid=") | |||
| 56 | + | assert.Contains(t, string(abruptMarkerData), "ppid=") | |||
| 49 | assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker")) | 57 | assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker")) | ||
| 50 | } | 58 | } | ||
| 51 | 59 | ||||
A internal/cli/cmd/crashes.go +254 -0
| @@ -0,0 +1,254 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sort" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/spf13/cobra" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type crashReportSummary struct { | |
| 18 | + | SessionID string `json:"session_id"` | |
| 19 | + | GeneratedAt string `json:"generated_at"` | |
| 20 | + | Classification struct { | |
| 21 | + | Class string `json:"class"` | |
| 22 | + | Reason string `json:"reason"` | |
| 23 | + | } `json:"classification"` | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | var crashesCmd = &cobra.Command{ | |
| 27 | + | Use: "crashes", | |
| 28 | + | Short: "List unexpected-close crash reports", | |
| 29 | + | Long: `List crash reports generated after unexpected browser closes. | |
| 30 | + | ||
| 31 | + | Use subcommands to inspect full details or print an issue template. | |
| 32 | + | ||
| 33 | + | Examples: | |
| 34 | + | dumber crashes | |
| 35 | + | dumber crashes show latest | |
| 36 | + | dumber crashes issue latest`, | |
| 37 | + | RunE: runCrashes, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | var crashesShowCmd = &cobra.Command{ | |
| 41 | + | Use: "show <report|latest>", | |
| 42 | + | Short: "Show a crash report", | |
| 43 | + | Args: cobra.ExactArgs(1), | |
| 44 | + | RunE: runCrashesShow, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | var crashesIssueCmd = &cobra.Command{ | |
| 48 | + | Use: "issue <report|latest>", | |
| 49 | + | Short: "Print GitHub-ready issue body from report", | |
| 50 | + | Args: cobra.ExactArgs(1), | |
| 51 | + | RunE: runCrashesIssue, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func init() { | |
| 55 | + | rootCmd.AddCommand(crashesCmd) | |
| 56 | + | crashesCmd.AddCommand(crashesShowCmd) | |
| 57 | + | crashesCmd.AddCommand(crashesIssueCmd) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func runCrashes(_ *cobra.Command, _ []string) error { | |
| 61 | + | app := GetApp() | |
| 62 | + | if app == nil { | |
| 63 | + | return fmt.Errorf("app not initialized") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | crashDir := getCrashReportsDir(app.Config) | |
| 67 | + | reports, err := loadCrashReports(crashDir) | |
| 68 | + | if err != nil { | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | if len(reports) == 0 { | |
| 72 | + | fmt.Println(app.Theme.Subtle.Render("No crash reports found.")) | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | fmt.Println(app.Theme.Title.Render("Crash reports (newest first):")) | |
| 77 | + | fmt.Println() | |
| 78 | + | for _, item := range reports { | |
| 79 | + | generated := item.GeneratedAt | |
| 80 | + | if ts, err := time.Parse(time.RFC3339Nano, item.GeneratedAt); err == nil { | |
| 81 | + | generated = ts.Format("2006-01-02 15:04:05") | |
| 82 | + | } | |
| 83 | + | shortID := shortCrashID(item.Path) | |
| 84 | + | line := fmt.Sprintf(" %s %s %s", app.Theme.Highlight.Render(shortID), app.Theme.Subtle.Render(generated), item.Classification.Class) | |
| 85 | + | fmt.Println(line) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | fmt.Println() | |
| 89 | + | fmt.Println(app.Theme.Subtle.Render("Use 'dumber crashes show <id|latest>' for details")) | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func runCrashesShow(_ *cobra.Command, args []string) error { | |
| 94 | + | app := GetApp() | |
| 95 | + | if app == nil { | |
| 96 | + | return fmt.Errorf("app not initialized") | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | |
| 100 | + | if err != nil { | |
| 101 | + | return err | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | body, err := os.ReadFile(report.MarkdownPath) | |
| 105 | + | if err != nil { | |
| 106 | + | return fmt.Errorf("read crash report markdown: %w", err) | |
| 107 | + | } | |
| 108 | + | fmt.Print(string(body)) | |
| 109 | + | return nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func runCrashesIssue(_ *cobra.Command, args []string) error { | |
| 113 | + | app := GetApp() | |
| 114 | + | if app == nil { | |
| 115 | + | return fmt.Errorf("app not initialized") | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | |
| 119 | + | if err != nil { | |
| 120 | + | return err | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | body, err := os.ReadFile(report.MarkdownPath) | |
| 124 | + | if err != nil { | |
| 125 | + | return fmt.Errorf("read crash report markdown: %w", err) | |
| 126 | + | } | |
| 127 | + | all := string(body) | |
| 128 | + | start := strings.Index(all, "## GitHub Issue Template") | |
| 129 | + | if start < 0 { | |
| 130 | + | fmt.Print(all) | |
| 131 | + | return nil | |
| 132 | + | } | |
| 133 | + | fmt.Print(strings.TrimSpace(all[start:]) + "\n") | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | type crashReportFile struct { | |
| 138 | + | Path string | |
| 139 | + | MarkdownPath string | |
| 140 | + | crashReportSummary | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func getCrashReportsDir(cfg *config.Config) string { | |
| 144 | + | logDir := "" | |
| 145 | + | if cfg != nil { | |
| 146 | + | logDir = cfg.Logging.LogDir | |
| 147 | + | } | |
| 148 | + | if logDir == "" { | |
| 149 | + | resolved, err := config.GetLogDir() | |
| 150 | + | if err == nil { | |
| 151 | + | logDir = resolved | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | if logDir == "" { | |
| 155 | + | stateDir := os.Getenv("XDG_STATE_HOME") | |
| 156 | + | if stateDir == "" { | |
| 157 | + | home, _ := os.UserHomeDir() | |
| 158 | + | stateDir = filepath.Join(home, ".local", "state") | |
| 159 | + | } | |
| 160 | + | logDir = filepath.Join(stateDir, "dumber", "logs") | |
| 161 | + | } | |
| 162 | + | return filepath.Join(logDir, "crashes") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func loadCrashReports(crashDir string) ([]crashReportFile, error) { | |
| 166 | + | entries, err := os.ReadDir(crashDir) | |
| 167 | + | if err != nil { | |
| 168 | + | if os.IsNotExist(err) { | |
| 169 | + | return nil, nil | |
| 170 | + | } | |
| 171 | + | return nil, err | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | reports := make([]crashReportFile, 0, len(entries)) | |
| 175 | + | for _, entry := range entries { | |
| 176 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".crash.json") { | |
| 177 | + | continue | |
| 178 | + | } | |
| 179 | + | path := filepath.Join(crashDir, entry.Name()) | |
| 180 | + | raw, readErr := os.ReadFile(path) | |
| 181 | + | if readErr != nil { | |
| 182 | + | continue | |
| 183 | + | } | |
| 184 | + | var parsed crashReportSummary | |
| 185 | + | if unmarshalErr := json.Unmarshal(raw, &parsed); unmarshalErr != nil { | |
| 186 | + | continue | |
| 187 | + | } | |
| 188 | + | reports = append(reports, crashReportFile{ | |
| 189 | + | Path: path, | |
| 190 | + | MarkdownPath: strings.TrimSuffix(path, ".json") + ".md", | |
| 191 | + | crashReportSummary: parsed, | |
| 192 | + | }) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | sort.Slice(reports, func(i, j int) bool { | |
| 196 | + | left, errLeft := time.Parse(time.RFC3339Nano, reports[i].GeneratedAt) | |
| 197 | + | right, errRight := time.Parse(time.RFC3339Nano, reports[j].GeneratedAt) | |
| 198 | + | if errLeft == nil && errRight == nil { | |
| 199 | + | return left.After(right) | |
| 200 | + | } | |
| 201 | + | return reports[i].Path > reports[j].Path | |
| 202 | + | }) | |
| 203 | + | ||
| 204 | + | return reports, nil | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func resolveCrashReport(crashDir, query string) (*crashReportFile, error) { | |
| 208 | + | reports, err := loadCrashReports(crashDir) | |
| 209 | + | if err != nil { | |
| 210 | + | return nil, err | |
| 211 | + | } | |
| 212 | + | if len(reports) == 0 { | |
| 213 | + | return nil, fmt.Errorf("no crash reports found") | |
| 214 | + | } | |
| 215 | + | if strings.EqualFold(query, "latest") { | |
| 216 | + | return &reports[0], nil | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | query = strings.TrimSpace(strings.ToLower(query)) | |
| 220 | + | for i := range reports { | |
| 221 | + | id := strings.ToLower(shortCrashID(reports[i].Path)) | |
| 222 | + | if id == query { | |
| 223 | + | return &reports[i], nil | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | matches := make([]int, 0, len(reports)) | |
| 228 | + | for i := range reports { | |
| 229 | + | full := strings.ToLower(filepath.Base(reports[i].Path)) | |
| 230 | + | if strings.Contains(full, query) || strings.Contains(strings.ToLower(reports[i].SessionID), query) { | |
| 231 | + | matches = append(matches, i) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | if len(matches) == 1 { | |
| 236 | + | return &reports[matches[0]], nil | |
| 237 | + | } | |
| 238 | + | if len(matches) > 1 { | |
| 239 | + | ids := make([]string, 0, len(matches)) | |
| 240 | + | for _, idx := range matches { | |
| 241 | + | ids = append(ids, shortCrashID(reports[idx].Path)) | |
| 242 | + | } | |
| 243 | + | return nil, fmt.Errorf("multiple reports match '%s': %s", query, strings.Join(ids, ", ")) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | return nil, fmt.Errorf("no crash report matching '%s' found", query) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func shortCrashID(path string) string { | |
| 250 | + | base := filepath.Base(path) | |
| 251 | + | base = strings.TrimSuffix(base, ".crash.json") | |
| 252 | + | base = strings.TrimPrefix(base, "session_") | |
| 253 | + | return base | |
| 254 | + | } | |
| @@ -0,0 +1,254 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sort" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/spf13/cobra" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type crashReportSummary struct { | |||
| 18 | + | SessionID string `json:"session_id"` | |||
| 19 | + | GeneratedAt string `json:"generated_at"` | |||
| 20 | + | Classification struct { | |||
| 21 | + | Class string `json:"class"` | |||
| 22 | + | Reason string `json:"reason"` | |||
| 23 | + | } `json:"classification"` | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | var crashesCmd = &cobra.Command{ | |||
| 27 | + | Use: "crashes", | |||
| 28 | + | Short: "List unexpected-close crash reports", | |||
| 29 | + | Long: `List crash reports generated after unexpected browser closes. | |||
| 30 | + | ||||
| 31 | + | Use subcommands to inspect full details or print an issue template. | |||
| 32 | + | ||||
| 33 | + | Examples: | |||
| 34 | + | dumber crashes | |||
| 35 | + | dumber crashes show latest | |||
| 36 | + | dumber crashes issue latest`, | |||
| 37 | + | RunE: runCrashes, | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | var crashesShowCmd = &cobra.Command{ | |||
| 41 | + | Use: "show <report|latest>", | |||
| 42 | + | Short: "Show a crash report", | |||
| 43 | + | Args: cobra.ExactArgs(1), | |||
| 44 | + | RunE: runCrashesShow, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | var crashesIssueCmd = &cobra.Command{ | |||
| 48 | + | Use: "issue <report|latest>", | |||
| 49 | + | Short: "Print GitHub-ready issue body from report", | |||
| 50 | + | Args: cobra.ExactArgs(1), | |||
| 51 | + | RunE: runCrashesIssue, | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func init() { | |||
| 55 | + | rootCmd.AddCommand(crashesCmd) | |||
| 56 | + | crashesCmd.AddCommand(crashesShowCmd) | |||
| 57 | + | crashesCmd.AddCommand(crashesIssueCmd) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func runCrashes(_ *cobra.Command, _ []string) error { | |||
| 61 | + | app := GetApp() | |||
| 62 | + | if app == nil { | |||
| 63 | + | return fmt.Errorf("app not initialized") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | crashDir := getCrashReportsDir(app.Config) | |||
| 67 | + | reports, err := loadCrashReports(crashDir) | |||
| 68 | + | if err != nil { | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | if len(reports) == 0 { | |||
| 72 | + | fmt.Println(app.Theme.Subtle.Render("No crash reports found.")) | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | fmt.Println(app.Theme.Title.Render("Crash reports (newest first):")) | |||
| 77 | + | fmt.Println() | |||
| 78 | + | for _, item := range reports { | |||
| 79 | + | generated := item.GeneratedAt | |||
| 80 | + | if ts, err := time.Parse(time.RFC3339Nano, item.GeneratedAt); err == nil { | |||
| 81 | + | generated = ts.Format("2006-01-02 15:04:05") | |||
| 82 | + | } | |||
| 83 | + | shortID := shortCrashID(item.Path) | |||
| 84 | + | line := fmt.Sprintf(" %s %s %s", app.Theme.Highlight.Render(shortID), app.Theme.Subtle.Render(generated), item.Classification.Class) | |||
| 85 | + | fmt.Println(line) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | fmt.Println() | |||
| 89 | + | fmt.Println(app.Theme.Subtle.Render("Use 'dumber crashes show <id|latest>' for details")) | |||
| 90 | + | return nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func runCrashesShow(_ *cobra.Command, args []string) error { | |||
| 94 | + | app := GetApp() | |||
| 95 | + | if app == nil { | |||
| 96 | + | return fmt.Errorf("app not initialized") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | |||
| 100 | + | if err != nil { | |||
| 101 | + | return err | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | body, err := os.ReadFile(report.MarkdownPath) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return fmt.Errorf("read crash report markdown: %w", err) | |||
| 107 | + | } | |||
| 108 | + | fmt.Print(string(body)) | |||
| 109 | + | return nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func runCrashesIssue(_ *cobra.Command, args []string) error { | |||
| 113 | + | app := GetApp() | |||
| 114 | + | if app == nil { | |||
| 115 | + | return fmt.Errorf("app not initialized") | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | report, err := resolveCrashReport(getCrashReportsDir(app.Config), args[0]) | |||
| 119 | + | if err != nil { | |||
| 120 | + | return err | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | body, err := os.ReadFile(report.MarkdownPath) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return fmt.Errorf("read crash report markdown: %w", err) | |||
| 126 | + | } | |||
| 127 | + | all := string(body) | |||
| 128 | + | start := strings.Index(all, "## GitHub Issue Template") | |||
| 129 | + | if start < 0 { | |||
| 130 | + | fmt.Print(all) | |||
| 131 | + | return nil | |||
| 132 | + | } | |||
| 133 | + | fmt.Print(strings.TrimSpace(all[start:]) + "\n") | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | type crashReportFile struct { | |||
| 138 | + | Path string | |||
| 139 | + | MarkdownPath string | |||
| 140 | + | crashReportSummary | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func getCrashReportsDir(cfg *config.Config) string { | |||
| 144 | + | logDir := "" | |||
| 145 | + | if cfg != nil { | |||
| 146 | + | logDir = cfg.Logging.LogDir | |||
| 147 | + | } | |||
| 148 | + | if logDir == "" { | |||
| 149 | + | resolved, err := config.GetLogDir() | |||
| 150 | + | if err == nil { | |||
| 151 | + | logDir = resolved | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | if logDir == "" { | |||
| 155 | + | stateDir := os.Getenv("XDG_STATE_HOME") | |||
| 156 | + | if stateDir == "" { | |||
| 157 | + | home, _ := os.UserHomeDir() | |||
| 158 | + | stateDir = filepath.Join(home, ".local", "state") | |||
| 159 | + | } | |||
| 160 | + | logDir = filepath.Join(stateDir, "dumber", "logs") | |||
| 161 | + | } | |||
| 162 | + | return filepath.Join(logDir, "crashes") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func loadCrashReports(crashDir string) ([]crashReportFile, error) { | |||
| 166 | + | entries, err := os.ReadDir(crashDir) | |||
| 167 | + | if err != nil { | |||
| 168 | + | if os.IsNotExist(err) { | |||
| 169 | + | return nil, nil | |||
| 170 | + | } | |||
| 171 | + | return nil, err | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | reports := make([]crashReportFile, 0, len(entries)) | |||
| 175 | + | for _, entry := range entries { | |||
| 176 | + | if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".crash.json") { | |||
| 177 | + | continue | |||
| 178 | + | } | |||
| 179 | + | path := filepath.Join(crashDir, entry.Name()) | |||
| 180 | + | raw, readErr := os.ReadFile(path) | |||
| 181 | + | if readErr != nil { | |||
| 182 | + | continue | |||
| 183 | + | } | |||
| 184 | + | var parsed crashReportSummary | |||
| 185 | + | if unmarshalErr := json.Unmarshal(raw, &parsed); unmarshalErr != nil { | |||
| 186 | + | continue | |||
| 187 | + | } | |||
| 188 | + | reports = append(reports, crashReportFile{ | |||
| 189 | + | Path: path, | |||
| 190 | + | MarkdownPath: strings.TrimSuffix(path, ".json") + ".md", | |||
| 191 | + | crashReportSummary: parsed, | |||
| 192 | + | }) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | sort.Slice(reports, func(i, j int) bool { | |||
| 196 | + | left, errLeft := time.Parse(time.RFC3339Nano, reports[i].GeneratedAt) | |||
| 197 | + | right, errRight := time.Parse(time.RFC3339Nano, reports[j].GeneratedAt) | |||
| 198 | + | if errLeft == nil && errRight == nil { | |||
| 199 | + | return left.After(right) | |||
| 200 | + | } | |||
| 201 | + | return reports[i].Path > reports[j].Path | |||
| 202 | + | }) | |||
| 203 | + | ||||
| 204 | + | return reports, nil | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func resolveCrashReport(crashDir, query string) (*crashReportFile, error) { | |||
| 208 | + | reports, err := loadCrashReports(crashDir) | |||
| 209 | + | if err != nil { | |||
| 210 | + | return nil, err | |||
| 211 | + | } | |||
| 212 | + | if len(reports) == 0 { | |||
| 213 | + | return nil, fmt.Errorf("no crash reports found") | |||
| 214 | + | } | |||
| 215 | + | if strings.EqualFold(query, "latest") { | |||
| 216 | + | return &reports[0], nil | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | query = strings.TrimSpace(strings.ToLower(query)) | |||
| 220 | + | for i := range reports { | |||
| 221 | + | id := strings.ToLower(shortCrashID(reports[i].Path)) | |||
| 222 | + | if id == query { | |||
| 223 | + | return &reports[i], nil | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | matches := make([]int, 0, len(reports)) | |||
| 228 | + | for i := range reports { | |||
| 229 | + | full := strings.ToLower(filepath.Base(reports[i].Path)) | |||
| 230 | + | if strings.Contains(full, query) || strings.Contains(strings.ToLower(reports[i].SessionID), query) { | |||
| 231 | + | matches = append(matches, i) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | if len(matches) == 1 { | |||
| 236 | + | return &reports[matches[0]], nil | |||
| 237 | + | } | |||
| 238 | + | if len(matches) > 1 { | |||
| 239 | + | ids := make([]string, 0, len(matches)) | |||
| 240 | + | for _, idx := range matches { | |||
| 241 | + | ids = append(ids, shortCrashID(reports[idx].Path)) | |||
| 242 | + | } | |||
| 243 | + | return nil, fmt.Errorf("multiple reports match '%s': %s", query, strings.Join(ids, ", ")) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | return nil, fmt.Errorf("no crash report matching '%s' found", query) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func shortCrashID(path string) string { | |||
| 250 | + | base := filepath.Base(path) | |||
| 251 | + | base = strings.TrimSuffix(base, ".crash.json") | |||
| 252 | + | base = strings.TrimPrefix(base, "session_") | |||
| 253 | + | return base | |||
| 254 | + | } | |||
M internal/ui/app.go +37 -0
| @@ -40,6 +40,8 @@ import ( | |||
| 40 | 40 | const ( | |
| 41 | 41 | // AppID is the application identifier for GTK. | |
| 42 | 42 | AppID = "com.github.bnema.dumber" | |
| 43 | + | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | |
| 44 | + | crashReportToastDurationMs = 5000 | |
| 43 | 45 | ) | |
| 44 | 46 | ||
| 45 | 47 | // App wraps the GTK Application and manages the browser lifecycle. | |
| @@ -246,6 +248,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 246 | 248 | ||
| 247 | 249 | a.initLayoutInfrastructure() | |
| 248 | 250 | a.initAppToasterOverlay() | |
| 251 | + | a.installCrashReportNotifier(ctx) | |
| 249 | 252 | a.initFocusAndBorderOverlay() | |
| 250 | 253 | a.initAccentPicker(ctx) | |
| 251 | 254 | a.initDownloadHandler(ctx) | |
| @@ -396,6 +399,16 @@ func (a *App) initAppToasterOverlay() { | |||
| 396 | 399 | a.mainWindow.AddOverlay(modeGtkWidget) | |
| 397 | 400 | } | |
| 398 | 401 | ||
| 402 | + | func (a *App) installCrashReportNotifier(ctx context.Context) { | |
| 403 | + | if a.deps == nil { | |
| 404 | + | return | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | a.deps.OnCrashReportsDetected = func(paths []string) { | |
| 408 | + | a.showCrashReportToast(ctx, paths) | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | ||
| 399 | 412 | func (a *App) initFocusAndBorderOverlay() { | |
| 400 | 413 | // Initialize focus manager for geometric navigation. | |
| 401 | 414 | a.focusMgr = focus.NewManager(a.panesUC) | |
| @@ -1177,6 +1190,10 @@ func (a *App) finalizeActivation(ctx context.Context) { | |||
| 1177 | 1190 | } | |
| 1178 | 1191 | log.Info().Msg("main window displayed") | |
| 1179 | 1192 | ||
| 1193 | + | if a.deps != nil && len(a.deps.StartupCrashReports) > 0 { | |
| 1194 | + | a.showCrashReportToast(ctx, a.deps.StartupCrashReports) | |
| 1195 | + | } | |
| 1196 | + | ||
| 1180 | 1197 | // Defer non-critical initialization until after first navigation starts. | |
| 1181 | 1198 | // This keeps pool prewarm, config watcher, and filter loading from | |
| 1182 | 1199 | // competing with the initial page load. | |
| @@ -1188,6 +1205,26 @@ func (a *App) finalizeActivation(ctx context.Context) { | |||
| 1188 | 1205 | }) | |
| 1189 | 1206 | } | |
| 1190 | 1207 | ||
| 1208 | + | func (a *App) showCrashReportToast(ctx context.Context, paths []string) { | |
| 1209 | + | if len(paths) == 0 { | |
| 1210 | + | return | |
| 1211 | + | } | |
| 1212 | + | log := logging.FromContext(ctx) | |
| 1213 | + | log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available") | |
| 1214 | + | ||
| 1215 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1216 | + | if a.appToaster != nil { | |
| 1217 | + | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) | |
| 1218 | + | a.appToaster.Show(ctx, msg, component.ToastWarning, | |
| 1219 | + | component.WithDuration(crashReportToastDurationMs), | |
| 1220 | + | component.WithPosition(component.ToastPositionBottomRight), | |
| 1221 | + | ) | |
| 1222 | + | } | |
| 1223 | + | return false | |
| 1224 | + | }) | |
| 1225 | + | glib.IdleAdd(&cb, 0) | |
| 1226 | + | } | |
| 1227 | + | ||
| 1191 | 1228 | // runAfterFirstLoadStarted schedules work to run after the first navigation starts. | |
| 1192 | 1229 | // This keeps the GTK main loop free to process the initial load_uri() quickly, | |
| 1193 | 1230 | // reducing the gap between webview_attached and load_started. | |
| @@ -40,6 +40,8 @@ import ( | |||||
| 40 | const ( | 40 | const ( | ||
| 41 | // AppID is the application identifier for GTK. | 41 | // AppID is the application identifier for GTK. | ||
| 42 | AppID = "com.github.bnema.dumber" | 42 | AppID = "com.github.bnema.dumber" | ||
| 43 | + | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | |||
| 44 | + | crashReportToastDurationMs = 5000 | |||
| 43 | ) | 45 | ) | ||
| 44 | 46 | ||||
| 45 | // App wraps the GTK Application and manages the browser lifecycle. | 47 | // App wraps the GTK Application and manages the browser lifecycle. | ||
| @@ -246,6 +248,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 246 | 248 | ||||
| 247 | a.initLayoutInfrastructure() | 249 | a.initLayoutInfrastructure() | ||
| 248 | a.initAppToasterOverlay() | 250 | a.initAppToasterOverlay() | ||
| 251 | + | a.installCrashReportNotifier(ctx) | |||
| 249 | a.initFocusAndBorderOverlay() | 252 | a.initFocusAndBorderOverlay() | ||
| 250 | a.initAccentPicker(ctx) | 253 | a.initAccentPicker(ctx) | ||
| 251 | a.initDownloadHandler(ctx) | 254 | a.initDownloadHandler(ctx) | ||
| @@ -396,6 +399,16 @@ func (a *App) initAppToasterOverlay() { | |||||
| 396 | a.mainWindow.AddOverlay(modeGtkWidget) | 399 | a.mainWindow.AddOverlay(modeGtkWidget) | ||
| 397 | } | 400 | } | ||
| 398 | 401 | ||||
| 402 | + | func (a *App) installCrashReportNotifier(ctx context.Context) { | |||
| 403 | + | if a.deps == nil { | |||
| 404 | + | return | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | a.deps.OnCrashReportsDetected = func(paths []string) { | |||
| 408 | + | a.showCrashReportToast(ctx, paths) | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | ||||
| 399 | func (a *App) initFocusAndBorderOverlay() { | 412 | func (a *App) initFocusAndBorderOverlay() { | ||
| 400 | // Initialize focus manager for geometric navigation. | 413 | // Initialize focus manager for geometric navigation. | ||
| 401 | a.focusMgr = focus.NewManager(a.panesUC) | 414 | a.focusMgr = focus.NewManager(a.panesUC) | ||
| @@ -1177,6 +1190,10 @@ func (a *App) finalizeActivation(ctx context.Context) { | |||||
| 1177 | } | 1190 | } | ||
| 1178 | log.Info().Msg("main window displayed") | 1191 | log.Info().Msg("main window displayed") | ||
| 1179 | 1192 | ||||
| 1193 | + | if a.deps != nil && len(a.deps.StartupCrashReports) > 0 { | |||
| 1194 | + | a.showCrashReportToast(ctx, a.deps.StartupCrashReports) | |||
| 1195 | + | } | |||
| 1196 | + | ||||
| 1180 | // Defer non-critical initialization until after first navigation starts. | 1197 | // Defer non-critical initialization until after first navigation starts. | ||
| 1181 | // This keeps pool prewarm, config watcher, and filter loading from | 1198 | // This keeps pool prewarm, config watcher, and filter loading from | ||
| 1182 | // competing with the initial page load. | 1199 | // competing with the initial page load. | ||
| @@ -1188,6 +1205,26 @@ func (a *App) finalizeActivation(ctx context.Context) { | |||||
| 1188 | }) | 1205 | }) | ||
| 1189 | } | 1206 | } | ||
| 1190 | 1207 | ||||
| 1208 | + | func (a *App) showCrashReportToast(ctx context.Context, paths []string) { | |||
| 1209 | + | if len(paths) == 0 { | |||
| 1210 | + | return | |||
| 1211 | + | } | |||
| 1212 | + | log := logging.FromContext(ctx) | |||
| 1213 | + | log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available") | |||
| 1214 | + | ||||
| 1215 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 1216 | + | if a.appToaster != nil { | |||
| 1217 | + | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) | |||
| 1218 | + | a.appToaster.Show(ctx, msg, component.ToastWarning, | |||
| 1219 | + | component.WithDuration(crashReportToastDurationMs), | |||
| 1220 | + | component.WithPosition(component.ToastPositionBottomRight), | |||
| 1221 | + | ) | |||
| 1222 | + | } | |||
| 1223 | + | return false | |||
| 1224 | + | }) | |||
| 1225 | + | glib.IdleAdd(&cb, 0) | |||
| 1226 | + | } | |||
| 1227 | + | ||||
| 1191 | // runAfterFirstLoadStarted schedules work to run after the first navigation starts. | 1228 | // runAfterFirstLoadStarted schedules work to run after the first navigation starts. | ||
| 1192 | // This keeps the GTK main loop free to process the initial load_uri() quickly, | 1229 | // This keeps the GTK main loop free to process the initial load_uri() quickly, | ||
| 1193 | // reducing the gap between webview_attached and load_started. | 1230 | // reducing the gap between webview_attached and load_started. | ||
M internal/ui/deps.go +8 -6
| @@ -20,12 +20,14 @@ import ( | |||
| 20 | 20 | // This struct is created once at startup and passed to UI components. | |
| 21 | 21 | type Dependencies struct { | |
| 22 | 22 | // 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) | |
| 29 | 31 | ||
| 30 | 32 | // Theme and color scheme management | |
| 31 | 33 | 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 configuration | 22 | // Core context and configuration | ||
| 23 | - | Ctx context.Context | 23 | + | Ctx context.Context |
| 24 | - | Config *config.Config | 24 | + | 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 DB | 28 | + | 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 management | 32 | // Theme and color scheme management | ||
| 31 | Theme *theme.Manager | 33 | Theme *theme.Manager | ||