detached 6588389

feat(gui): ensure graceful shutdown on interrupt signals

commit
6588389fd967ac197fdcc69aac2bf98f224c6d72
author
brice · · 2025-12-21 17:14 UTC
committer
brice · · 2025-12-21 19:35 UTC
parent
6fa0e3d
signature
unsigned
2 files · +77 -63
M cmd/dumber/main.go +43 -40
@@ -5,8 +5,10 @@ import (
55 "database/sql"
66 "fmt"
77 "os"
8+ "os/signal"
89 "path/filepath"
910 "runtime"
11+ "syscall"
1012
1113 "github.com/bnema/dumber/internal/application/port"
1214 "github.com/bnema/dumber/internal/application/usecase"
@@ -72,34 +74,39 @@ func runGUI() int {
7274 // Apply optional /opt-style runtime prefix overrides (if configured)
7375 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
7476
75- // Generate session ID for this browser run
76- sessionID := logging.GenerateSessionID()
77-
78- logger, logCleanup := initLogger(cfg, sessionID)
79- if logCleanup != nil {
80- defer logCleanup()
81- }
82-
83- logger.Info().
77+ bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
78+ bootstrapLogger.Info().
8479 Str("version", version).
8580 Str("commit", commit).
8681 Str("build_date", buildDate).
8782 Msg("starting dumber")
8883
89- // Create root context with logger
90- ctx := logging.WithContext(context.Background(), logger)
91-
92- checkRuntimeRequirements(ctx, cfg, logger)
93-
94- checkMediaRequirements(ctx, cfg, logger)
84+ ctx := logging.WithContext(context.Background(), bootstrapLogger)
9585
96- dataDir, cacheDir := resolveWebKitDirs(logger)
86+ dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)
9787
98- db, dbCleanup := openDatabase(ctx, logger, dataDir)
88+ db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir)
9989 if dbCleanup != nil {
10090 defer dbCleanup()
10191 }
10292
93+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
94+ if err != nil {
95+ bootstrapLogger.Fatal().Err(err).Msg("failed to start session")
96+ }
97+ if browserSession.LogCleanup != nil {
98+ defer browserSession.LogCleanup()
99+ }
100+ defer func() {
101+ _ = browserSession.End(sessionCtx)
102+ }()
103+
104+ logger := browserSession.Logger
105+ ctx = sessionCtx
106+
107+ checkRuntimeRequirements(ctx, cfg, logger)
108+ checkMediaRequirements(ctx, cfg, logger)
109+
103110 // Create repositories
104111 historyRepo := sqlite.NewHistoryRepository(db)
105112 favoriteRepo := sqlite.NewFavoriteRepository(db)
@@ -156,8 +163,26 @@ func runGUI() int {
156163 faviconService,
157164 )
158165
166+ app, err := ui.New(uiDeps)
167+ if err != nil {
168+ logger.Error().Err(err).Msg("failed to create application")
169+ return 1
170+ }
171+
172+ // Ensure sessions are closed on Ctrl+C / SIGTERM.
173+ // Without this, the process may exit immediately and skip defers,
174+ // leaving the DB session stuck in "active" state.
175+ sigCh := make(chan os.Signal, 1)
176+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
177+ defer signal.Stop(sigCh)
178+ go func() {
179+ <-sigCh
180+ logger.Info().Msg("received interrupt, quitting")
181+ app.Quit()
182+ }()
183+
159184 // Run the application
160- exitCode := ui.RunWithArgs(ctx, uiDeps)
185+ exitCode := app.Run(ctx, os.Args)
161186 return exitCode
162187 }
163188
@@ -169,28 +194,6 @@ func initConfig() *config.Config {
169194 return config.Get()
170195 }
171196
172-func initLogger(cfg *config.Config, sessionID string) (zerolog.Logger, func()) {
173- logger, logCleanup, err := logging.NewWithFile(
174- logging.Config{
175- Level: logging.ParseLevel(cfg.Logging.Level),
176- Format: cfg.Logging.Format,
177- TimeFormat: "15:04:05",
178- },
179- logging.FileConfig{
180- Enabled: cfg.Logging.EnableFileLog,
181- LogDir: cfg.Logging.LogDir,
182- SessionID: sessionID,
183- WriteToStderr: true, // GUI: keep stderr output for debugging
184- },
185- )
186- if err != nil {
187- logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
188- fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err)
189- return logger, nil
190- }
191- return logger, logCleanup
192-}
193-
194197 func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
195198 probe := deps.NewPkgConfigProbe()
196199 checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
@@ -5,8 +5,10 @@ import (
5 "database/sql"5 "database/sql"
6 "fmt"6 "fmt"
7 "os"7 "os"
8+ "os/signal"
8 "path/filepath"9 "path/filepath"
9 "runtime"10 "runtime"
11+ "syscall"
10 12
11 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"14 "github.com/bnema/dumber/internal/application/usecase"
@@ -72,34 +74,39 @@ func runGUI() int {
72 // Apply optional /opt-style runtime prefix overrides (if configured)74 // Apply optional /opt-style runtime prefix overrides (if configured)
73 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)75 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
74 76
75- // Generate session ID for this browser run77+ bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
76- sessionID := logging.GenerateSessionID()78+ bootstrapLogger.Info().
77-
78- logger, logCleanup := initLogger(cfg, sessionID)
79- if logCleanup != nil {
80- defer logCleanup()
81- }
82-
83- logger.Info().
84 Str("version", version).79 Str("version", version).
85 Str("commit", commit).80 Str("commit", commit).
86 Str("build_date", buildDate).81 Str("build_date", buildDate).
87 Msg("starting dumber")82 Msg("starting dumber")
88 83
89- // Create root context with logger84+ ctx := logging.WithContext(context.Background(), bootstrapLogger)
90- ctx := logging.WithContext(context.Background(), logger)
91-
92- checkRuntimeRequirements(ctx, cfg, logger)
93-
94- checkMediaRequirements(ctx, cfg, logger)
95 85
96- dataDir, cacheDir := resolveWebKitDirs(logger)86+ dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)
97 87
98- db, dbCleanup := openDatabase(ctx, logger, dataDir)88+ db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir)
99 if dbCleanup != nil {89 if dbCleanup != nil {
100 defer dbCleanup()90 defer dbCleanup()
101 }91 }
102 92
93+ browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
94+ if err != nil {
95+ bootstrapLogger.Fatal().Err(err).Msg("failed to start session")
96+ }
97+ if browserSession.LogCleanup != nil {
98+ defer browserSession.LogCleanup()
99+ }
100+ defer func() {
101+ _ = browserSession.End(sessionCtx)
102+ }()
103+
104+ logger := browserSession.Logger
105+ ctx = sessionCtx
106+
107+ checkRuntimeRequirements(ctx, cfg, logger)
108+ checkMediaRequirements(ctx, cfg, logger)
109+
103 // Create repositories110 // Create repositories
104 historyRepo := sqlite.NewHistoryRepository(db)111 historyRepo := sqlite.NewHistoryRepository(db)
105 favoriteRepo := sqlite.NewFavoriteRepository(db)112 favoriteRepo := sqlite.NewFavoriteRepository(db)
@@ -156,8 +163,26 @@ func runGUI() int {
156 faviconService,163 faviconService,
157 )164 )
158 165
166+ app, err := ui.New(uiDeps)
167+ if err != nil {
168+ logger.Error().Err(err).Msg("failed to create application")
169+ return 1
170+ }
171+
172+ // Ensure sessions are closed on Ctrl+C / SIGTERM.
173+ // Without this, the process may exit immediately and skip defers,
174+ // leaving the DB session stuck in "active" state.
175+ sigCh := make(chan os.Signal, 1)
176+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
177+ defer signal.Stop(sigCh)
178+ go func() {
179+ <-sigCh
180+ logger.Info().Msg("received interrupt, quitting")
181+ app.Quit()
182+ }()
183+
159 // Run the application184 // Run the application
160- exitCode := ui.RunWithArgs(ctx, uiDeps)185+ exitCode := app.Run(ctx, os.Args)
161 return exitCode186 return exitCode
162 }187 }
163 188
@@ -169,28 +194,6 @@ func initConfig() *config.Config {
169 return config.Get()194 return config.Get()
170 }195 }
171 196
172-func initLogger(cfg *config.Config, sessionID string) (zerolog.Logger, func()) {
173- logger, logCleanup, err := logging.NewWithFile(
174- logging.Config{
175- Level: logging.ParseLevel(cfg.Logging.Level),
176- Format: cfg.Logging.Format,
177- TimeFormat: "15:04:05",
178- },
179- logging.FileConfig{
180- Enabled: cfg.Logging.EnableFileLog,
181- LogDir: cfg.Logging.LogDir,
182- SessionID: sessionID,
183- WriteToStderr: true, // GUI: keep stderr output for debugging
184- },
185- )
186- if err != nil {
187- logger = logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
188- fmt.Fprintf(os.Stderr, "Warning: failed to create session log file: %v\n", err)
189- return logger, nil
190- }
191- return logger, logCleanup
192-}
193-
194 func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {197 func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
195 probe := deps.NewPkgConfigProbe()198 probe := deps.NewPkgConfigProbe()
196 checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)199 checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
M internal/cli/app.go +34 -23
@@ -8,11 +8,13 @@ import (
88 "os"
99 "path/filepath"
1010
11+ "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/application/usecase"
1213 "github.com/bnema/dumber/internal/cli/styles"
1314 "github.com/bnema/dumber/internal/domain/build"
1415 "github.com/bnema/dumber/internal/domain/repository"
1516 "github.com/bnema/dumber/internal/infrastructure/config"
17+ infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
1618 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
1719 "github.com/bnema/dumber/internal/logging"
1820 )
@@ -27,6 +29,7 @@ type App struct {
2729
2830 // Use cases
2931 SearchHistoryUC *usecase.SearchHistoryUseCase
32+ SessionUC *usecase.ManageSessionUseCase
3033
3134 // Context with logger
3235 ctx context.Context
@@ -36,40 +39,27 @@ type App struct {
3639 // NewApp creates a new CLI application with all dependencies.
3740 func NewApp() (*App, error) {
3841 const dataDirPerm = 0o755
42+ var err error
3943 // Load config
4044 cfg := loadConfig()
4145
4246 // Create theme from config
4347 theme := styles.NewTheme(cfg)
4448
45- // Initialize logger with file output for debugging
46- // Logs can be viewed with: tail -f ~/.local/state/dumber/logs/session_*.log
47- logCfg := logging.DefaultConfig()
48- if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {
49- logCfg.Level = logging.ParseLevel(level)
50- }
51-
52- // Create session log file
53- home, _ := os.UserHomeDir()
54- sessionID := logging.GenerateSessionID()
55- logDir := filepath.Join(home, ".local", "state", "dumber", "logs")
56-
57- logger, logCleanup, err := logging.NewWithFile(logCfg, logging.FileConfig{
58- Enabled: true,
59- LogDir: logDir,
60- SessionID: sessionID,
61- WriteToStderr: false, // CLI: log to file only, keep terminal clean
62- })
63- if err != nil {
64- // Fallback to no-file logging
65- logger = logging.NewFromEnv()
66- logCleanup = func() {}
49+ // Start with a quiet logger. If a browser session is active, we'll attach to it.
50+ logLevel := cfg.Logging.Level
51+ if envLevel := os.Getenv("DUMBER_LOG_LEVEL"); envLevel != "" {
52+ logLevel = envLevel
6753 }
6854
55+ logger, logCleanup, _ := logging.NewWithFile(
56+ logging.Config{Level: logging.ParseLevel(logLevel), Format: cfg.Logging.Format, TimeFormat: "15:04:05"},
57+ logging.FileConfig{Enabled: false, WriteToStderr: false},
58+ )
6959 ctx := logging.WithContext(context.Background(), logger)
70- logger.Info().Str("command", "cli").Msg("CLI session started")
7160
7261 // Determine database path - use share directory per XDG spec (user data)
62+ home, _ := os.UserHomeDir()
7363 dbFile := cfg.Database.Path
7464 if dbFile == "" {
7565 dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db")
@@ -87,6 +77,26 @@ func NewApp() (*App, error) {
8777 return nil, fmt.Errorf("open database: %w", err)
8878 }
8979
80+ // Attach CLI logs to the active browser session (if any).
81+ // This avoids creating extra tiny session log files for each CLI invocation.
82+ sessionRepo := sqlite.NewSessionRepository(db)
83+ sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
84+ sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
85+ if active, activeErr := sessionUC.GetActiveSession(ctx); activeErr == nil && active != nil {
86+ attachedLogger, attachedCleanup, _ := sessionLoggerAdapter.CreateLogger(ctx, active.ID, port.SessionLogConfig{
87+ Level: logLevel,
88+ Format: cfg.Logging.Format,
89+ TimeFormat: "15:04:05",
90+ LogDir: cfg.Logging.LogDir,
91+ WriteToStderr: false,
92+ EnableFileLog: cfg.Logging.EnableFileLog,
93+ })
94+ logger = attachedLogger
95+ logCleanup = attachedCleanup
96+ ctx = logging.WithContext(context.Background(), logger)
97+ logger.Info().Str("session_id", string(active.ID)).Msg("attached to active session")
98+ }
99+
90100 logger.Debug().Str("db_path", dbFile).Msg("database connected")
91101
92102 // Create repositories
@@ -101,6 +111,7 @@ func NewApp() (*App, error) {
101111 db: db,
102112 History: historyRepo,
103113 SearchHistoryUC: searchHistoryUC,
114+ SessionUC: sessionUC,
104115 ctx: ctx,
105116 logCleanup: logCleanup,
106117 }, nil
@@ -8,11 +8,13 @@ import (
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10 10
11+ "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/cli/styles"13 "github.com/bnema/dumber/internal/cli/styles"
13 "github.com/bnema/dumber/internal/domain/build"14 "github.com/bnema/dumber/internal/domain/build"
14 "github.com/bnema/dumber/internal/domain/repository"15 "github.com/bnema/dumber/internal/domain/repository"
15 "github.com/bnema/dumber/internal/infrastructure/config"16 "github.com/bnema/dumber/internal/infrastructure/config"
17+ infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
16 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"18 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
17 "github.com/bnema/dumber/internal/logging"19 "github.com/bnema/dumber/internal/logging"
18 )20 )
@@ -27,6 +29,7 @@ type App struct {
27 29
28 // Use cases30 // Use cases
29 SearchHistoryUC *usecase.SearchHistoryUseCase31 SearchHistoryUC *usecase.SearchHistoryUseCase
32+ SessionUC *usecase.ManageSessionUseCase
30 33
31 // Context with logger34 // Context with logger
32 ctx context.Context35 ctx context.Context
@@ -36,40 +39,27 @@ type App struct {
36 // NewApp creates a new CLI application with all dependencies.39 // NewApp creates a new CLI application with all dependencies.
37 func NewApp() (*App, error) {40 func NewApp() (*App, error) {
38 const dataDirPerm = 0o75541 const dataDirPerm = 0o755
42+ var err error
39 // Load config43 // Load config
40 cfg := loadConfig()44 cfg := loadConfig()
41 45
42 // Create theme from config46 // Create theme from config
43 theme := styles.NewTheme(cfg)47 theme := styles.NewTheme(cfg)
44 48
45- // Initialize logger with file output for debugging49+ // Start with a quiet logger. If a browser session is active, we'll attach to it.
46- // Logs can be viewed with: tail -f ~/.local/state/dumber/logs/session_*.log50+ logLevel := cfg.Logging.Level
47- logCfg := logging.DefaultConfig()51+ if envLevel := os.Getenv("DUMBER_LOG_LEVEL"); envLevel != "" {
48- if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {52+ logLevel = envLevel
49- logCfg.Level = logging.ParseLevel(level)
50- }
51-
52- // Create session log file
53- home, _ := os.UserHomeDir()
54- sessionID := logging.GenerateSessionID()
55- logDir := filepath.Join(home, ".local", "state", "dumber", "logs")
56-
57- logger, logCleanup, err := logging.NewWithFile(logCfg, logging.FileConfig{
58- Enabled: true,
59- LogDir: logDir,
60- SessionID: sessionID,
61- WriteToStderr: false, // CLI: log to file only, keep terminal clean
62- })
63- if err != nil {
64- // Fallback to no-file logging
65- logger = logging.NewFromEnv()
66- logCleanup = func() {}
67 }53 }
68 54
55+ logger, logCleanup, _ := logging.NewWithFile(
56+ logging.Config{Level: logging.ParseLevel(logLevel), Format: cfg.Logging.Format, TimeFormat: "15:04:05"},
57+ logging.FileConfig{Enabled: false, WriteToStderr: false},
58+ )
69 ctx := logging.WithContext(context.Background(), logger)59 ctx := logging.WithContext(context.Background(), logger)
70- logger.Info().Str("command", "cli").Msg("CLI session started")
71 60
72 // Determine database path - use share directory per XDG spec (user data)61 // Determine database path - use share directory per XDG spec (user data)
62+ home, _ := os.UserHomeDir()
73 dbFile := cfg.Database.Path63 dbFile := cfg.Database.Path
74 if dbFile == "" {64 if dbFile == "" {
75 dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db")65 dbFile = filepath.Join(home, ".local", "share", "dumber", "dumber.db")
@@ -87,6 +77,26 @@ func NewApp() (*App, error) {
87 return nil, fmt.Errorf("open database: %w", err)77 return nil, fmt.Errorf("open database: %w", err)
88 }78 }
89 79
80+ // Attach CLI logs to the active browser session (if any).
81+ // This avoids creating extra tiny session log files for each CLI invocation.
82+ sessionRepo := sqlite.NewSessionRepository(db)
83+ sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
84+ sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
85+ if active, activeErr := sessionUC.GetActiveSession(ctx); activeErr == nil && active != nil {
86+ attachedLogger, attachedCleanup, _ := sessionLoggerAdapter.CreateLogger(ctx, active.ID, port.SessionLogConfig{
87+ Level: logLevel,
88+ Format: cfg.Logging.Format,
89+ TimeFormat: "15:04:05",
90+ LogDir: cfg.Logging.LogDir,
91+ WriteToStderr: false,
92+ EnableFileLog: cfg.Logging.EnableFileLog,
93+ })
94+ logger = attachedLogger
95+ logCleanup = attachedCleanup
96+ ctx = logging.WithContext(context.Background(), logger)
97+ logger.Info().Str("session_id", string(active.ID)).Msg("attached to active session")
98+ }
99+
90 logger.Debug().Str("db_path", dbFile).Msg("database connected")100 logger.Debug().Str("db_path", dbFile).Msg("database connected")
91 101
92 // Create repositories102 // Create repositories
@@ -101,6 +111,7 @@ func NewApp() (*App, error) {
101 db: db,111 db: db,
102 History: historyRepo,112 History: historyRepo,
103 SearchHistoryUC: searchHistoryUC,113 SearchHistoryUC: searchHistoryUC,
114+ SessionUC: sessionUC,
104 ctx: ctx,115 ctx: ctx,
105 logCleanup: logCleanup,116 logCleanup: logCleanup,
106 }, nil117 }, nil

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close