detached 7ab7181

perf(bootstrap): optimize cold start with parallel init and WebView prewarming

commit
7ab718136974ca9b09f335d4a7a14a2e3f05a663
author
bnema · · 2025-12-27 20:43 UTC
parent
c13e49e
signature
GPG Unverified2B82207D370127A0 The key is unknown
6 files · +436 -150
M cmd/dumber/main.go +83 -129
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "os"
88 "os/signal"
9- "path/filepath"
109 "runtime"
1110 "syscall"
1211
@@ -23,16 +22,14 @@ import (
2322 "github.com/bnema/dumber/internal/infrastructure/deps"
2423 "github.com/bnema/dumber/internal/infrastructure/favicon"
2524 "github.com/bnema/dumber/internal/infrastructure/idle"
26- "github.com/bnema/dumber/internal/infrastructure/media"
2725 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2826 "github.com/bnema/dumber/internal/infrastructure/updater"
2927 "github.com/bnema/dumber/internal/logging"
3028 "github.com/bnema/dumber/internal/ui"
3129 "github.com/bnema/dumber/internal/ui/theme"
32- "github.com/rs/zerolog"
3330 )
3431
35-// Build-time variables (set via ldflags)
32+// Build-time variables (set via ldflags).
3633 var (
3734 version = "dev"
3835 commit = "unknown"
@@ -48,13 +45,10 @@ var restoreSessionID string
4845 func main() {
4946 // Run GUI mode for browse command
5047 if len(os.Args) > 1 && os.Args[1] == "browse" {
51- // Extract URL if provided
5248 if len(os.Args) > 2 {
5349 initialURL = os.Args[2]
5450 }
55- // Check for session restoration via environment variable
5651 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
57- // Strip "browse" and URL from args so GTK doesn't see them
5852 os.Args = os.Args[:1]
5953 os.Exit(runGUI())
6054 return
@@ -73,29 +67,42 @@ func main() {
7367 }
7468
7569 func runGUI() int {
76- // GTK requires all GTK calls to be made from the main thread
7770 runtime.LockOSThread()
71+ timer := bootstrap.NewStartupTimer()
7872
7973 cfg := initConfig()
74+ timer.Mark("config")
8075
81- // Apply optional /opt-style runtime prefix overrides (if configured)
8276 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
83-
8477 bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
8578 bootstrapLogger.Info().
8679 Str("version", version).
8780 Str("commit", commit).
8881 Str("build_date", buildDate).
8982 Msg("starting dumber")
90-
9183 ctx := logging.WithContext(context.Background(), bootstrapLogger)
84+ timer.Mark("logger")
9285
93- dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)
86+ // Parallel phase: directories, runtime/media checks, theme
87+ initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
88+ Ctx: ctx,
89+ Config: cfg,
90+ })
91+ if err != nil {
92+ handleParallelInitError(ctx, err)
93+ return 1
94+ }
95+ timer.MarkDuration("parallel_phase", initResult.Duration)
9496
95- db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir)
97+ // Database and session
98+ db, dbCleanup, dbErr := bootstrap.OpenDatabase(ctx, initResult.DataDir)
99+ if dbErr != nil {
100+ bootstrapLogger.Fatal().Err(dbErr).Msg("failed to initialize database")
101+ }
96102 if dbCleanup != nil {
97103 defer dbCleanup()
98104 }
105+ timer.Mark("database")
99106
100107 browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
101108 if err != nil {
@@ -104,45 +111,20 @@ func runGUI() int {
104111 if browserSession.LogCleanup != nil {
105112 defer browserSession.LogCleanup()
106113 }
107- defer func() {
108- _ = browserSession.End(sessionCtx)
109- }()
114+ defer func() { _ = browserSession.End(sessionCtx) }()
115+ timer.Mark("session")
110116
111117 ctx = sessionCtx
112118 log := logging.FromContext(ctx)
113119
114- checkRuntimeRequirements(ctx, cfg, *log)
115- checkMediaRequirements(ctx, cfg, *log)
120+ // WebKit stack
121+ stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
122+ timer.Mark("webkit_stack")
116123
117- themeManager := theme.NewManager(ctx, cfg)
118- stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log)
124+ // Repositories and use cases
119125 repos := createRepositories(db)
120-
121- stateDir, _ := config.GetStateDir()
122- buildInfo := build.Info{
123- Version: version,
124- Commit: commit,
125- BuildDate: buildDate,
126- GoVersion: runtime.Version(),
127- }
128- useCases := createUseCases(repos, cfg, stateDir, buildInfo)
129-
130- // Auto-restore: find last restorable session if enabled and no explicit restore requested
131- if cfg.Session.AutoRestore && restoreSessionID == "" {
132- var out *usecase.GetLastRestorableSessionOutput
133- out, err = useCases.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
134- ExcludeSessionID: browserSession.Session.ID,
135- })
136- if err != nil {
137- log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
138- } else if out.SessionID != "" {
139- restoreSessionID = string(out.SessionID)
140- log.Info().
141- Str("session_id", restoreSessionID).
142- Int("tabs", len(out.State.Tabs)).
143- Msg("auto-restore: found last session")
144- }
145- }
126+ useCases := createUseCases(repos, cfg)
127+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
146128
147129 idleInhibitor := idle.NewPortalInhibitor(ctx)
148130 defer func() {
@@ -150,29 +132,22 @@ func runGUI() int {
150132 _ = idleInhibitor.Close()
151133 }
152134 }()
153- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
135+ timer.Mark("use_cases")
154136
137+ // Build UI
138+ uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
155139 app, err := ui.New(uiDeps)
156140 if err != nil {
157141 log.Error().Err(err).Msg("failed to create application")
158142 return 1
159143 }
144+ timer.Mark("ui_deps")
145+ timer.Log(ctx)
160146
161- // Ensure sessions are closed on Ctrl+C / SIGTERM.
162- // Without this, the process may exit immediately and skip defers,
163- // leaving the DB session stuck in "active" state.
164- sigCh := make(chan os.Signal, 1)
165- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
166- defer signal.Stop(sigCh)
167- go func() {
168- <-sigCh
169- log.Info().Msg("received interrupt, quitting")
170- app.Quit()
171- }()
147+ // Signal handling
148+ setupSignalHandler(ctx, app)
172149
173- // Run the application
174- exitCode := app.Run(ctx, os.Args)
175- return exitCode
150+ return app.Run(ctx, os.Args)
176151 }
177152
178153 func initConfig() *config.Config {
@@ -183,77 +158,52 @@ func initConfig() *config.Config {
183158 return config.Get()
184159 }
185160
186-func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
187- probe := deps.NewPkgConfigProbe()
188- checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
189- runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
190- Prefix: cfg.Runtime.Prefix,
191- })
192- if err != nil {
193- logger.Fatal().Err(err).Msg("runtime requirements check failed")
194- }
195- if runtimeOut.OK {
196- return
197- }
198- for _, c := range runtimeOut.Checks {
199- if c.Installed {
200- logger.Error().
201- Str("dependency", c.PkgConfigName).
202- Str("have", c.Version).
203- Str("need", c.RequiredVersion).
204- Bool("ok", c.MeetsRequirement).
205- Msg("runtime dependency")
206- } else {
207- logger.Error().
208- Str("dependency", c.PkgConfigName).
209- Str("need", c.RequiredVersion).
210- Msg("runtime dependency missing")
211- }
212- }
213- logger.Fatal().
214- Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
215- Msg("runtime requirements not met")
216-}
217-
218-func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
219- mediaDiagAdapter := media.New()
220- checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
221- if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
222- ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
223- }); mediaErr != nil {
224- logger.Fatal().Err(mediaErr).Msg("media requirements check failed")
161+func handleParallelInitError(ctx context.Context, err error) {
162+ log := logging.FromContext(ctx)
163+ if err, ok := err.(*bootstrap.RuntimeRequirementsError); ok {
164+ err.LogDetails(ctx)
165+ log.Fatal().Err(err).
166+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
167+ Msg("runtime requirements not met")
225168 }
169+ log.Fatal().Err(err).Msg("initialization failed")
226170 }
227171
228-func resolveWebKitDirs(logger zerolog.Logger) (string, string) {
229- const cacheDirPerm = 0o755
230- dataDir, err := config.GetDataDir()
231- if err != nil {
232- logger.Fatal().Err(err).Msg("failed to resolve data directory")
172+func handleAutoRestore(
173+ ctx context.Context,
174+ cfg *config.Config,
175+ uc *useCases,
176+ currentSessionID entity.SessionID,
177+) {
178+ if !cfg.Session.AutoRestore || restoreSessionID != "" {
179+ return
233180 }
234- stateDir, err := config.GetStateDir()
181+ log := logging.FromContext(ctx)
182+ out, err := uc.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
183+ ExcludeSessionID: currentSessionID,
184+ })
235185 if err != nil {
236- logger.Fatal().Err(err).Msg("failed to resolve state directory")
186+ log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
187+ return
237188 }
238- cacheDir := filepath.Join(stateDir, "webkit-cache")
239- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
240- logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")
189+ if out.SessionID != "" {
190+ restoreSessionID = string(out.SessionID)
191+ log.Info().
192+ Str("session_id", restoreSessionID).
193+ Int("tabs", len(out.State.Tabs)).
194+ Msg("auto-restore: found last session")
241195 }
242- return dataDir, cacheDir
243196 }
244197
245-func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) {
246- dbPath := filepath.Join(dataDir, "dumber.db")
247- db, err := sqlite.NewConnection(ctx, dbPath)
248- if err != nil {
249- logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")
250- }
251- cleanup := func() {
252- if err := sqlite.Close(db); err != nil {
253- logger.Error().Err(err).Msg("failed to close database")
254- }
255- }
256- return db, cleanup
198+func setupSignalHandler(ctx context.Context, app *ui.App) {
199+ log := logging.FromContext(ctx)
200+ sigCh := make(chan os.Signal, 1)
201+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
202+ go func() {
203+ <-sigCh
204+ log.Info().Msg("received interrupt, quitting")
205+ app.Quit()
206+ }()
257207 }
258208
259209 // repositories groups infrastructure layer repository implementations.
@@ -296,7 +246,7 @@ type useCases struct {
296246 favicon *favicon.Service
297247 }
298248
299-func createUseCases(repos *repositories, cfg *config.Config, stateDir string, buildInfo build.Info) *useCases {
249+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
300250 const idAlphabetSize = 26
301251 idCounter := uint64(0)
302252 idGenerator := func() string {
@@ -306,13 +256,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
306256
307257 clipboardAdapter := clipboard.New()
308258 faviconCacheDir, _ := config.GetFaviconCacheDir()
309- cacheDir, _ := config.GetXDGDirs()
259+ xdgDirs, _ := config.GetXDGDirs()
260+ stateDir, _ := config.GetStateDir()
310261 defaultZoom := cfg.DefaultWebpageZoom
311-
312- // Create LRU cache for zoom levels to avoid database queries on repeat visits
313262 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
314263
315- // Create update infrastructure
264+ buildInfo := build.Info{
265+ Version: version,
266+ Commit: commit,
267+ BuildDate: buildDate,
268+ GoVersion: runtime.Version(),
269+ }
316270 updateChecker := updater.NewGitHubChecker()
317271 updateDownloader := updater.NewGitHubDownloader()
318272 updateApplier := updater.NewApplier(stateDir)
@@ -328,7 +282,7 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
328282 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
329283 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
330284 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
331- applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome),
285+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
332286 clipboard: clipboardAdapter,
333287 favicon: favicon.NewService(faviconCacheDir),
334288 }
@@ -6,7 +6,6 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "os/signal"8 "os/signal"
9- "path/filepath"
10 "runtime"9 "runtime"
11 "syscall"10 "syscall"
12 11
@@ -23,16 +22,14 @@ import (
23 "github.com/bnema/dumber/internal/infrastructure/deps"22 "github.com/bnema/dumber/internal/infrastructure/deps"
24 "github.com/bnema/dumber/internal/infrastructure/favicon"23 "github.com/bnema/dumber/internal/infrastructure/favicon"
25 "github.com/bnema/dumber/internal/infrastructure/idle"24 "github.com/bnema/dumber/internal/infrastructure/idle"
26- "github.com/bnema/dumber/internal/infrastructure/media"
27 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"25 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
28 "github.com/bnema/dumber/internal/infrastructure/updater"26 "github.com/bnema/dumber/internal/infrastructure/updater"
29 "github.com/bnema/dumber/internal/logging"27 "github.com/bnema/dumber/internal/logging"
30 "github.com/bnema/dumber/internal/ui"28 "github.com/bnema/dumber/internal/ui"
31 "github.com/bnema/dumber/internal/ui/theme"29 "github.com/bnema/dumber/internal/ui/theme"
32- "github.com/rs/zerolog"
33 )30 )
34 31
35-// Build-time variables (set via ldflags)32+// Build-time variables (set via ldflags).
36 var (33 var (
37 version = "dev"34 version = "dev"
38 commit = "unknown"35 commit = "unknown"
@@ -48,13 +45,10 @@ var restoreSessionID string
48 func main() {45 func main() {
49 // Run GUI mode for browse command46 // Run GUI mode for browse command
50 if len(os.Args) > 1 && os.Args[1] == "browse" {47 if len(os.Args) > 1 && os.Args[1] == "browse" {
51- // Extract URL if provided
52 if len(os.Args) > 2 {48 if len(os.Args) > 2 {
53 initialURL = os.Args[2]49 initialURL = os.Args[2]
54 }50 }
55- // Check for session restoration via environment variable
56 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")51 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
57- // Strip "browse" and URL from args so GTK doesn't see them
58 os.Args = os.Args[:1]52 os.Args = os.Args[:1]
59 os.Exit(runGUI())53 os.Exit(runGUI())
60 return54 return
@@ -73,29 +67,42 @@ func main() {
73 }67 }
74 68
75 func runGUI() int {69 func runGUI() int {
76- // GTK requires all GTK calls to be made from the main thread
77 runtime.LockOSThread()70 runtime.LockOSThread()
71+ timer := bootstrap.NewStartupTimer()
78 72
79 cfg := initConfig()73 cfg := initConfig()
74+ timer.Mark("config")
80 75
81- // Apply optional /opt-style runtime prefix overrides (if configured)
82 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)76 deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
83-
84 bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)77 bootstrapLogger := logging.NewFromConfigValues(cfg.Logging.Level, cfg.Logging.Format)
85 bootstrapLogger.Info().78 bootstrapLogger.Info().
86 Str("version", version).79 Str("version", version).
87 Str("commit", commit).80 Str("commit", commit).
88 Str("build_date", buildDate).81 Str("build_date", buildDate).
89 Msg("starting dumber")82 Msg("starting dumber")
90-
91 ctx := logging.WithContext(context.Background(), bootstrapLogger)83 ctx := logging.WithContext(context.Background(), bootstrapLogger)
84+ timer.Mark("logger")
92 85
93- dataDir, cacheDir := resolveWebKitDirs(bootstrapLogger)86+ // Parallel phase: directories, runtime/media checks, theme
87+ initResult, err := bootstrap.RunParallelInit(bootstrap.ParallelInitInput{
88+ Ctx: ctx,
89+ Config: cfg,
90+ })
91+ if err != nil {
92+ handleParallelInitError(ctx, err)
93+ return 1
94+ }
95+ timer.MarkDuration("parallel_phase", initResult.Duration)
94 96
95- db, dbCleanup := openDatabase(ctx, bootstrapLogger, dataDir)97+ // Database and session
98+ db, dbCleanup, dbErr := bootstrap.OpenDatabase(ctx, initResult.DataDir)
99+ if dbErr != nil {
100+ bootstrapLogger.Fatal().Err(dbErr).Msg("failed to initialize database")
101+ }
96 if dbCleanup != nil {102 if dbCleanup != nil {
97 defer dbCleanup()103 defer dbCleanup()
98 }104 }
105+ timer.Mark("database")
99 106
100 browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)107 browserSession, sessionCtx, err := bootstrap.StartBrowserSession(ctx, cfg, db)
101 if err != nil {108 if err != nil {
@@ -104,45 +111,20 @@ func runGUI() int {
104 if browserSession.LogCleanup != nil {111 if browserSession.LogCleanup != nil {
105 defer browserSession.LogCleanup()112 defer browserSession.LogCleanup()
106 }113 }
107- defer func() {114+ defer func() { _ = browserSession.End(sessionCtx) }()
108- _ = browserSession.End(sessionCtx)115+ timer.Mark("session")
109- }()
110 116
111 ctx = sessionCtx117 ctx = sessionCtx
112 log := logging.FromContext(ctx)118 log := logging.FromContext(ctx)
113 119
114- checkRuntimeRequirements(ctx, cfg, *log)120+ // WebKit stack
115- checkMediaRequirements(ctx, cfg, *log)121+ stack := bootstrap.BuildWebKitStack(ctx, cfg, initResult.DataDir, initResult.CacheDir, initResult.ThemeManager, *log)
122+ timer.Mark("webkit_stack")
116 123
117- themeManager := theme.NewManager(ctx, cfg)124+ // Repositories and use cases
118- stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log)
119 repos := createRepositories(db)125 repos := createRepositories(db)
120-126+ useCases := createUseCases(repos, cfg)
121- stateDir, _ := config.GetStateDir()127+ handleAutoRestore(ctx, cfg, useCases, browserSession.Session.ID)
122- buildInfo := build.Info{
123- Version: version,
124- Commit: commit,
125- BuildDate: buildDate,
126- GoVersion: runtime.Version(),
127- }
128- useCases := createUseCases(repos, cfg, stateDir, buildInfo)
129-
130- // Auto-restore: find last restorable session if enabled and no explicit restore requested
131- if cfg.Session.AutoRestore && restoreSessionID == "" {
132- var out *usecase.GetLastRestorableSessionOutput
133- out, err = useCases.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
134- ExcludeSessionID: browserSession.Session.ID,
135- })
136- if err != nil {
137- log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
138- } else if out.SessionID != "" {
139- restoreSessionID = string(out.SessionID)
140- log.Info().
141- Str("session_id", restoreSessionID).
142- Int("tabs", len(out.State.Tabs)).
143- Msg("auto-restore: found last session")
144- }
145- }
146 128
147 idleInhibitor := idle.NewPortalInhibitor(ctx)129 idleInhibitor := idle.NewPortalInhibitor(ctx)
148 defer func() {130 defer func() {
@@ -150,29 +132,22 @@ func runGUI() int {
150 _ = idleInhibitor.Close()132 _ = idleInhibitor.Close()
151 }133 }
152 }()134 }()
153- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)135+ timer.Mark("use_cases")
154 136
137+ // Build UI
138+ uiDeps := buildUIDependencies(ctx, cfg, initResult.ThemeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
155 app, err := ui.New(uiDeps)139 app, err := ui.New(uiDeps)
156 if err != nil {140 if err != nil {
157 log.Error().Err(err).Msg("failed to create application")141 log.Error().Err(err).Msg("failed to create application")
158 return 1142 return 1
159 }143 }
144+ timer.Mark("ui_deps")
145+ timer.Log(ctx)
160 146
161- // Ensure sessions are closed on Ctrl+C / SIGTERM.147+ // Signal handling
162- // Without this, the process may exit immediately and skip defers,148+ setupSignalHandler(ctx, app)
163- // leaving the DB session stuck in "active" state.
164- sigCh := make(chan os.Signal, 1)
165- signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
166- defer signal.Stop(sigCh)
167- go func() {
168- <-sigCh
169- log.Info().Msg("received interrupt, quitting")
170- app.Quit()
171- }()
172 149
173- // Run the application150+ return app.Run(ctx, os.Args)
174- exitCode := app.Run(ctx, os.Args)
175- return exitCode
176 }151 }
177 152
178 func initConfig() *config.Config {153 func initConfig() *config.Config {
@@ -183,77 +158,52 @@ func initConfig() *config.Config {
183 return config.Get()158 return config.Get()
184 }159 }
185 160
186-func checkRuntimeRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {161+func handleParallelInitError(ctx context.Context, err error) {
187- probe := deps.NewPkgConfigProbe()162+ log := logging.FromContext(ctx)
188- checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)163+ if err, ok := err.(*bootstrap.RuntimeRequirementsError); ok {
189- runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{164+ err.LogDetails(ctx)
190- Prefix: cfg.Runtime.Prefix,165+ log.Fatal().Err(err).
191- })166+ Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
192- if err != nil {167+ Msg("runtime requirements not met")
193- logger.Fatal().Err(err).Msg("runtime requirements check failed")
194- }
195- if runtimeOut.OK {
196- return
197- }
198- for _, c := range runtimeOut.Checks {
199- if c.Installed {
200- logger.Error().
201- Str("dependency", c.PkgConfigName).
202- Str("have", c.Version).
203- Str("need", c.RequiredVersion).
204- Bool("ok", c.MeetsRequirement).
205- Msg("runtime dependency")
206- } else {
207- logger.Error().
208- Str("dependency", c.PkgConfigName).
209- Str("need", c.RequiredVersion).
210- Msg("runtime dependency missing")
211- }
212- }
213- logger.Fatal().
214- Str("hint", "Run: dumber doctor (and set runtime.prefix for /opt installs)").
215- Msg("runtime requirements not met")
216-}
217-
218-func checkMediaRequirements(ctx context.Context, cfg *config.Config, logger zerolog.Logger) {
219- mediaDiagAdapter := media.New()
220- checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
221- if _, mediaErr := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
222- ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
223- }); mediaErr != nil {
224- logger.Fatal().Err(mediaErr).Msg("media requirements check failed")
225 }168 }
169+ log.Fatal().Err(err).Msg("initialization failed")
226 }170 }
227 171
228-func resolveWebKitDirs(logger zerolog.Logger) (string, string) {172+func handleAutoRestore(
229- const cacheDirPerm = 0o755173+ ctx context.Context,
230- dataDir, err := config.GetDataDir()174+ cfg *config.Config,
231- if err != nil {175+ uc *useCases,
232- logger.Fatal().Err(err).Msg("failed to resolve data directory")176+ currentSessionID entity.SessionID,
177+) {
178+ if !cfg.Session.AutoRestore || restoreSessionID != "" {
179+ return
233 }180 }
234- stateDir, err := config.GetStateDir()181+ log := logging.FromContext(ctx)
182+ out, err := uc.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
183+ ExcludeSessionID: currentSessionID,
184+ })
235 if err != nil {185 if err != nil {
236- logger.Fatal().Err(err).Msg("failed to resolve state directory")186+ log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
187+ return
237 }188 }
238- cacheDir := filepath.Join(stateDir, "webkit-cache")189+ if out.SessionID != "" {
239- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {190+ restoreSessionID = string(out.SessionID)
240- logger.Fatal().Err(mkErr).Str("path", cacheDir).Msg("failed to create cache directory")191+ log.Info().
192+ Str("session_id", restoreSessionID).
193+ Int("tabs", len(out.State.Tabs)).
194+ Msg("auto-restore: found last session")
241 }195 }
242- return dataDir, cacheDir
243 }196 }
244 197
245-func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*sql.DB, func()) {198+func setupSignalHandler(ctx context.Context, app *ui.App) {
246- dbPath := filepath.Join(dataDir, "dumber.db")199+ log := logging.FromContext(ctx)
247- db, err := sqlite.NewConnection(ctx, dbPath)200+ sigCh := make(chan os.Signal, 1)
248- if err != nil {201+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
249- logger.Fatal().Err(err).Str("path", dbPath).Msg("failed to initialize database")202+ go func() {
250- }203+ <-sigCh
251- cleanup := func() {204+ log.Info().Msg("received interrupt, quitting")
252- if err := sqlite.Close(db); err != nil {205+ app.Quit()
253- logger.Error().Err(err).Msg("failed to close database")206+ }()
254- }
255- }
256- return db, cleanup
257 }207 }
258 208
259 // repositories groups infrastructure layer repository implementations.209 // repositories groups infrastructure layer repository implementations.
@@ -296,7 +246,7 @@ type useCases struct {
296 favicon *favicon.Service246 favicon *favicon.Service
297 }247 }
298 248
299-func createUseCases(repos *repositories, cfg *config.Config, stateDir string, buildInfo build.Info) *useCases {249+func createUseCases(repos *repositories, cfg *config.Config) *useCases {
300 const idAlphabetSize = 26250 const idAlphabetSize = 26
301 idCounter := uint64(0)251 idCounter := uint64(0)
302 idGenerator := func() string {252 idGenerator := func() string {
@@ -306,13 +256,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
306 256
307 clipboardAdapter := clipboard.New()257 clipboardAdapter := clipboard.New()
308 faviconCacheDir, _ := config.GetFaviconCacheDir()258 faviconCacheDir, _ := config.GetFaviconCacheDir()
309- cacheDir, _ := config.GetXDGDirs()259+ xdgDirs, _ := config.GetXDGDirs()
260+ stateDir, _ := config.GetStateDir()
310 defaultZoom := cfg.DefaultWebpageZoom261 defaultZoom := cfg.DefaultWebpageZoom
311-
312- // Create LRU cache for zoom levels to avoid database queries on repeat visits
313 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)262 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
314 263
315- // Create update infrastructure264+ buildInfo := build.Info{
265+ Version: version,
266+ Commit: commit,
267+ BuildDate: buildDate,
268+ GoVersion: runtime.Version(),
269+ }
316 updateChecker := updater.NewGitHubChecker()270 updateChecker := updater.NewGitHubChecker()
317 updateDownloader := updater.NewGitHubDownloader()271 updateDownloader := updater.NewGitHubDownloader()
318 updateApplier := updater.NewApplier(stateDir)272 updateApplier := updater.NewApplier(stateDir)
@@ -328,7 +282,7 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string, bu
328 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),282 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
329 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),283 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
330 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),284 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
331- applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome),285+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
332 clipboard: clipboardAdapter,286 clipboard: clipboardAdapter,
333 favicon: favicon.NewService(faviconCacheDir),287 favicon: favicon.NewService(faviconCacheDir),
334 }288 }
A internal/bootstrap/gui.go +186 -0
@@ -0,0 +1,186 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/infrastructure/deps"
16+ "github.com/bnema/dumber/internal/infrastructure/media"
17+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
18+ "github.com/bnema/dumber/internal/logging"
19+ "github.com/bnema/dumber/internal/ui/theme"
20+)
21+
22+// ParallelInitResult holds the results of parallel initialization phase.
23+type ParallelInitResult struct {
24+ DataDir string
25+ CacheDir string
26+ ThemeManager *theme.Manager
27+ Duration time.Duration
28+}
29+
30+// ParallelInitInput holds the input for parallel initialization.
31+type ParallelInitInput struct {
32+ Ctx context.Context
33+ Config *config.Config
34+}
35+
36+// RuntimeRequirementsError contains details about missing runtime dependencies.
37+type RuntimeRequirementsError struct {
38+ Checks []usecase.RuntimeDependencyStatus
39+}
40+
41+func (*RuntimeRequirementsError) Error() string {
42+ return "runtime requirements not met"
43+}
44+
45+// LogDetails logs detailed information about missing dependencies.
46+func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
47+ log := logging.FromContext(ctx)
48+ for _, c := range e.Checks {
49+ if c.Installed {
50+ log.Error().
51+ Str("dependency", c.PkgConfigName).
52+ Str("have", c.Version).
53+ Str("need", c.RequiredVersion).
54+ Bool("ok", c.MeetsRequirement).
55+ Msg("runtime dependency")
56+ } else {
57+ log.Error().
58+ Str("dependency", c.PkgConfigName).
59+ Str("need", c.RequiredVersion).
60+ Msg("runtime dependency missing")
61+ }
62+ }
63+}
64+
65+// RunParallelInit runs the parallel initialization phase.
66+// This includes directory resolution, runtime/media checks, and theme creation.
67+// Returns the first fatal error encountered, or nil with the results.
68+func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
69+ var (
70+ dataDir, cacheDir string
71+ themeManager *theme.Manager
72+ dirsErr error
73+ runtimeErr error
74+ mediaErr error
75+ wg sync.WaitGroup
76+ )
77+
78+ start := time.Now()
79+ wg.Add(4)
80+
81+ // Resolve directories
82+ go func() {
83+ defer wg.Done()
84+ dataDir, cacheDir, dirsErr = resolveWebKitDirs()
85+ }()
86+
87+ // Runtime checks (pkg-config subprocess)
88+ go func() {
89+ defer wg.Done()
90+ runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
91+ }()
92+
93+ // Media checks (GStreamer subprocess)
94+ go func() {
95+ defer wg.Done()
96+ mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
97+ }()
98+
99+ // Theme manager (CPU-bound, no I/O)
100+ go func() {
101+ defer wg.Done()
102+ themeManager = theme.NewManager(input.Ctx, input.Config)
103+ }()
104+
105+ wg.Wait()
106+ duration := time.Since(start)
107+
108+ // Return first fatal error
109+ switch {
110+ case dirsErr != nil:
111+ return nil, fmt.Errorf("resolve directories: %w", dirsErr)
112+ case runtimeErr != nil:
113+ return nil, runtimeErr
114+ case mediaErr != nil:
115+ return nil, fmt.Errorf("media check: %w", mediaErr)
116+ }
117+
118+ return &ParallelInitResult{
119+ DataDir: dataDir,
120+ CacheDir: cacheDir,
121+ ThemeManager: themeManager,
122+ Duration: duration,
123+ }, nil
124+}
125+
126+// CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.
127+// Returns error if requirements are not met; caller should log details and exit.
128+func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
129+ probe := deps.NewPkgConfigProbe()
130+ checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
131+ runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
132+ Prefix: cfg.Runtime.Prefix,
133+ })
134+ if err != nil {
135+ return fmt.Errorf("runtime check execution failed: %w", err)
136+ }
137+ if runtimeOut.OK {
138+ return nil
139+ }
140+ return &RuntimeRequirementsError{Checks: runtimeOut.Checks}
141+}
142+
143+// CheckMediaRequirements verifies GStreamer and media codec availability.
144+// Returns error if media subsystem initialization fails.
145+func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
146+ mediaDiagAdapter := media.New()
147+ checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
148+ if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
149+ ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
150+ }); err != nil {
151+ return fmt.Errorf("media check failed: %w", err)
152+ }
153+ return nil
154+}
155+
156+// resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
157+func resolveWebKitDirs() (dataDir, cacheDir string, err error) {
158+ const cacheDirPerm = 0o755
159+ dataDir, err = config.GetDataDir()
160+ if err != nil {
161+ return "", "", fmt.Errorf("resolve data directory: %w", err)
162+ }
163+ stateDir, err := config.GetStateDir()
164+ if err != nil {
165+ return "", "", fmt.Errorf("resolve state directory: %w", err)
166+ }
167+ cacheDir = filepath.Join(stateDir, "webkit-cache")
168+ if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
169+ return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
170+ }
171+ return dataDir, cacheDir, nil
172+}
173+
174+// OpenDatabase opens and initializes the SQLite database.
175+// Returns error if database cannot be opened or migrations fail.
176+func OpenDatabase(ctx context.Context, dataDir string) (*sql.DB, func(), error) {
177+ dbPath := filepath.Join(dataDir, "dumber.db")
178+ db, err := sqlite.NewConnection(ctx, dbPath)
179+ if err != nil {
180+ return nil, nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
181+ }
182+ cleanup := func() {
183+ _ = sqlite.Close(db)
184+ }
185+ return db, cleanup, nil
186+}
@@ -0,0 +1,186 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "database/sql"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/infrastructure/deps"
16+ "github.com/bnema/dumber/internal/infrastructure/media"
17+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
18+ "github.com/bnema/dumber/internal/logging"
19+ "github.com/bnema/dumber/internal/ui/theme"
20+)
21+
22+// ParallelInitResult holds the results of parallel initialization phase.
23+type ParallelInitResult struct {
24+ DataDir string
25+ CacheDir string
26+ ThemeManager *theme.Manager
27+ Duration time.Duration
28+}
29+
30+// ParallelInitInput holds the input for parallel initialization.
31+type ParallelInitInput struct {
32+ Ctx context.Context
33+ Config *config.Config
34+}
35+
36+// RuntimeRequirementsError contains details about missing runtime dependencies.
37+type RuntimeRequirementsError struct {
38+ Checks []usecase.RuntimeDependencyStatus
39+}
40+
41+func (*RuntimeRequirementsError) Error() string {
42+ return "runtime requirements not met"
43+}
44+
45+// LogDetails logs detailed information about missing dependencies.
46+func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
47+ log := logging.FromContext(ctx)
48+ for _, c := range e.Checks {
49+ if c.Installed {
50+ log.Error().
51+ Str("dependency", c.PkgConfigName).
52+ Str("have", c.Version).
53+ Str("need", c.RequiredVersion).
54+ Bool("ok", c.MeetsRequirement).
55+ Msg("runtime dependency")
56+ } else {
57+ log.Error().
58+ Str("dependency", c.PkgConfigName).
59+ Str("need", c.RequiredVersion).
60+ Msg("runtime dependency missing")
61+ }
62+ }
63+}
64+
65+// RunParallelInit runs the parallel initialization phase.
66+// This includes directory resolution, runtime/media checks, and theme creation.
67+// Returns the first fatal error encountered, or nil with the results.
68+func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
69+ var (
70+ dataDir, cacheDir string
71+ themeManager *theme.Manager
72+ dirsErr error
73+ runtimeErr error
74+ mediaErr error
75+ wg sync.WaitGroup
76+ )
77+
78+ start := time.Now()
79+ wg.Add(4)
80+
81+ // Resolve directories
82+ go func() {
83+ defer wg.Done()
84+ dataDir, cacheDir, dirsErr = resolveWebKitDirs()
85+ }()
86+
87+ // Runtime checks (pkg-config subprocess)
88+ go func() {
89+ defer wg.Done()
90+ runtimeErr = CheckRuntimeRequirements(input.Ctx, input.Config)
91+ }()
92+
93+ // Media checks (GStreamer subprocess)
94+ go func() {
95+ defer wg.Done()
96+ mediaErr = CheckMediaRequirements(input.Ctx, input.Config)
97+ }()
98+
99+ // Theme manager (CPU-bound, no I/O)
100+ go func() {
101+ defer wg.Done()
102+ themeManager = theme.NewManager(input.Ctx, input.Config)
103+ }()
104+
105+ wg.Wait()
106+ duration := time.Since(start)
107+
108+ // Return first fatal error
109+ switch {
110+ case dirsErr != nil:
111+ return nil, fmt.Errorf("resolve directories: %w", dirsErr)
112+ case runtimeErr != nil:
113+ return nil, runtimeErr
114+ case mediaErr != nil:
115+ return nil, fmt.Errorf("media check: %w", mediaErr)
116+ }
117+
118+ return &ParallelInitResult{
119+ DataDir: dataDir,
120+ CacheDir: cacheDir,
121+ ThemeManager: themeManager,
122+ Duration: duration,
123+ }, nil
124+}
125+
126+// CheckRuntimeRequirements verifies WebKitGTK and other runtime dependencies.
127+// Returns error if requirements are not met; caller should log details and exit.
128+func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
129+ probe := deps.NewPkgConfigProbe()
130+ checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
131+ runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
132+ Prefix: cfg.Runtime.Prefix,
133+ })
134+ if err != nil {
135+ return fmt.Errorf("runtime check execution failed: %w", err)
136+ }
137+ if runtimeOut.OK {
138+ return nil
139+ }
140+ return &RuntimeRequirementsError{Checks: runtimeOut.Checks}
141+}
142+
143+// CheckMediaRequirements verifies GStreamer and media codec availability.
144+// Returns error if media subsystem initialization fails.
145+func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
146+ mediaDiagAdapter := media.New()
147+ checkMediaUC := usecase.NewCheckMediaUseCase(mediaDiagAdapter)
148+ if _, err := checkMediaUC.Execute(ctx, usecase.CheckMediaInput{
149+ ShowDiagnostics: cfg.Media.ShowDiagnosticsOnStartup,
150+ }); err != nil {
151+ return fmt.Errorf("media check failed: %w", err)
152+ }
153+ return nil
154+}
155+
156+// resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
157+func resolveWebKitDirs() (dataDir, cacheDir string, err error) {
158+ const cacheDirPerm = 0o755
159+ dataDir, err = config.GetDataDir()
160+ if err != nil {
161+ return "", "", fmt.Errorf("resolve data directory: %w", err)
162+ }
163+ stateDir, err := config.GetStateDir()
164+ if err != nil {
165+ return "", "", fmt.Errorf("resolve state directory: %w", err)
166+ }
167+ cacheDir = filepath.Join(stateDir, "webkit-cache")
168+ if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
169+ return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
170+ }
171+ return dataDir, cacheDir, nil
172+}
173+
174+// OpenDatabase opens and initializes the SQLite database.
175+// Returns error if database cannot be opened or migrations fail.
176+func OpenDatabase(ctx context.Context, dataDir string) (*sql.DB, func(), error) {
177+ dbPath := filepath.Join(dataDir, "dumber.db")
178+ db, err := sqlite.NewConnection(ctx, dbPath)
179+ if err != nil {
180+ return nil, nil, fmt.Errorf("initialize database at %s: %w", dbPath, err)
181+ }
182+ cleanup := func() {
183+ _ = sqlite.Close(db)
184+ }
185+ return db, cleanup, nil
186+}
M internal/bootstrap/session.go +41 -21
@@ -47,28 +47,11 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
4747 sessionRepo := sqlite.NewSessionRepository(db)
4848 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
4949 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
50-
51- // If a previous browser process died without cleanup, it may have left sessions "active".
52- // Use per-session lock files to distinguish truly running sessions from stale ones.
53- if lockDir != "" {
54- if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
55- log.Warn().Err(err).Msg("failed to mark stale sessions ended")
56- }
57- }
58-
59- // Clean up old exited sessions based on config limits
6050 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
61- cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{
62- MaxExitedSessions: cfg.Session.MaxExitedSessions,
63- MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
64- })
65- if err != nil {
66- log.Warn().Err(err).Msg("failed to cleanup old sessions")
67- } else if cleanupOutput.TotalDeleted > 0 {
68- log.Info().
69- Int64("deleted", cleanupOutput.TotalDeleted).
70- Msg("cleaned up old sessions on startup")
71- }
51+
52+ // Defer stale session cleanup and old session pruning to background.
53+ // This avoids blocking startup for non-critical housekeeping tasks.
54+ runSessionCleanupAsync(ctx, sessionUC, cleanupUC, cfg, lockDir, log)
7255
7356 now := time.Now()
7457 out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
@@ -213,3 +196,40 @@ func unlockAndClose(f *os.File) error {
213196 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
214197 return f.Close()
215198 }
199+
200+// runSessionCleanupAsync performs stale session cleanup and old session pruning
201+// in a background goroutine. This avoids blocking startup for non-critical tasks.
202+func runSessionCleanupAsync(
203+ ctx context.Context,
204+ sessionUC *usecase.ManageSessionUseCase,
205+ cleanupUC *usecase.CleanupSessionsUseCase,
206+ cfg *config.Config,
207+ lockDir string,
208+ log *zerolog.Logger,
209+) {
210+ go func() {
211+ // End stale active sessions (orphaned from crashed processes)
212+ if lockDir != "" {
213+ if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
214+ if log != nil {
215+ log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
216+ }
217+ }
218+ }
219+
220+ // Clean up old exited sessions based on config limits
221+ cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{
222+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
223+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
224+ })
225+ if err != nil {
226+ if log != nil {
227+ log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
228+ }
229+ } else if cleanupOutput.TotalDeleted > 0 {
230+ if log != nil {
231+ log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
232+ }
233+ }
234+ }()
235+}
@@ -47,28 +47,11 @@ func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*
47 sessionRepo := sqlite.NewSessionRepository(db)47 sessionRepo := sqlite.NewSessionRepository(db)
48 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()48 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
49 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)49 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
50-
51- // If a previous browser process died without cleanup, it may have left sessions "active".
52- // Use per-session lock files to distinguish truly running sessions from stale ones.
53- if lockDir != "" {
54- if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
55- log.Warn().Err(err).Msg("failed to mark stale sessions ended")
56- }
57- }
58-
59- // Clean up old exited sessions based on config limits
60 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)50 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
61- cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{51+
62- MaxExitedSessions: cfg.Session.MaxExitedSessions,52+ // Defer stale session cleanup and old session pruning to background.
63- MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,53+ // This avoids blocking startup for non-critical housekeeping tasks.
64- })54+ runSessionCleanupAsync(ctx, sessionUC, cleanupUC, cfg, lockDir, log)
65- if err != nil {
66- log.Warn().Err(err).Msg("failed to cleanup old sessions")
67- } else if cleanupOutput.TotalDeleted > 0 {
68- log.Info().
69- Int64("deleted", cleanupOutput.TotalDeleted).
70- Msg("cleaned up old sessions on startup")
71- }
72 55
73 now := time.Now()56 now := time.Now()
74 out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{57 out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
@@ -213,3 +196,40 @@ func unlockAndClose(f *os.File) error {
213 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)196 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
214 return f.Close()197 return f.Close()
215 }198 }
199+
200+// runSessionCleanupAsync performs stale session cleanup and old session pruning
201+// in a background goroutine. This avoids blocking startup for non-critical tasks.
202+func runSessionCleanupAsync(
203+ ctx context.Context,
204+ sessionUC *usecase.ManageSessionUseCase,
205+ cleanupUC *usecase.CleanupSessionsUseCase,
206+ cfg *config.Config,
207+ lockDir string,
208+ log *zerolog.Logger,
209+) {
210+ go func() {
211+ // End stale active sessions (orphaned from crashed processes)
212+ if lockDir != "" {
213+ if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
214+ if log != nil {
215+ log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
216+ }
217+ }
218+ }
219+
220+ // Clean up old exited sessions based on config limits
221+ cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{
222+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
223+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
224+ })
225+ if err != nil {
226+ if log != nil {
227+ log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
228+ }
229+ } else if cleanupOutput.TotalDeleted > 0 {
230+ if log != nil {
231+ log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
232+ }
233+ }
234+ }()
235+}
A internal/bootstrap/timing.go +91 -0
@@ -0,0 +1,91 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// StartupTimer tracks timing for cold start phases.
13+// Thread-safe for use with parallel initialization.
14+type StartupTimer struct {
15+ start time.Time
16+ phases map[string]time.Duration
17+ order []string // Track insertion order for logging
18+ last time.Time
19+ mu sync.Mutex
20+}
21+
22+// NewStartupTimer creates a new timer starting from now.
23+func NewStartupTimer() *StartupTimer {
24+ now := time.Now()
25+ return &StartupTimer{
26+ start: now,
27+ phases: make(map[string]time.Duration),
28+ order: make([]string, 0),
29+ last: now,
30+ }
31+}
32+
33+// Mark records the duration since the last mark (or start) for the given phase.
34+func (t *StartupTimer) Mark(phase string) {
35+ t.mu.Lock()
36+ defer t.mu.Unlock()
37+
38+ now := time.Now()
39+ t.phases[phase] = now.Sub(t.last)
40+ t.order = append(t.order, phase)
41+ t.last = now
42+}
43+
44+// MarkDuration records a specific duration for a phase.
45+// Useful for operations timed independently (e.g., parallel goroutines).
46+func (t *StartupTimer) MarkDuration(phase string, d time.Duration) {
47+ t.mu.Lock()
48+ defer t.mu.Unlock()
49+
50+ t.phases[phase] = d
51+ t.order = append(t.order, phase)
52+}
53+
54+// Total returns the total elapsed time since timer creation.
55+func (t *StartupTimer) Total() time.Duration {
56+ return time.Since(t.start)
57+}
58+
59+// Log outputs all timing information to the context logger.
60+func (t *StartupTimer) Log(ctx context.Context) {
61+ t.mu.Lock()
62+ defer t.mu.Unlock()
63+
64+ log := logging.FromContext(ctx)
65+ total := time.Since(t.start)
66+
67+ event := log.Info().Dur("total", total)
68+ for _, phase := range t.order {
69+ if dur, ok := t.phases[phase]; ok {
70+ event = event.Dur(phase, dur)
71+ }
72+ }
73+ event.Msg("startup timing")
74+}
75+
76+// LogDebug outputs timing as debug level (for less verbose production logs).
77+func (t *StartupTimer) LogDebug(ctx context.Context) {
78+ t.mu.Lock()
79+ defer t.mu.Unlock()
80+
81+ log := logging.FromContext(ctx)
82+ total := time.Since(t.start)
83+
84+ event := log.Debug().Dur("total", total)
85+ for _, phase := range t.order {
86+ if dur, ok := t.phases[phase]; ok {
87+ event = event.Dur(phase, dur)
88+ }
89+ }
90+ event.Msg("startup timing")
91+}
@@ -0,0 +1,91 @@
1+// Package bootstrap provides initialization utilities for the browser.
2+package bootstrap
3+
4+import (
5+ "context"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// StartupTimer tracks timing for cold start phases.
13+// Thread-safe for use with parallel initialization.
14+type StartupTimer struct {
15+ start time.Time
16+ phases map[string]time.Duration
17+ order []string // Track insertion order for logging
18+ last time.Time
19+ mu sync.Mutex
20+}
21+
22+// NewStartupTimer creates a new timer starting from now.
23+func NewStartupTimer() *StartupTimer {
24+ now := time.Now()
25+ return &StartupTimer{
26+ start: now,
27+ phases: make(map[string]time.Duration),
28+ order: make([]string, 0),
29+ last: now,
30+ }
31+}
32+
33+// Mark records the duration since the last mark (or start) for the given phase.
34+func (t *StartupTimer) Mark(phase string) {
35+ t.mu.Lock()
36+ defer t.mu.Unlock()
37+
38+ now := time.Now()
39+ t.phases[phase] = now.Sub(t.last)
40+ t.order = append(t.order, phase)
41+ t.last = now
42+}
43+
44+// MarkDuration records a specific duration for a phase.
45+// Useful for operations timed independently (e.g., parallel goroutines).
46+func (t *StartupTimer) MarkDuration(phase string, d time.Duration) {
47+ t.mu.Lock()
48+ defer t.mu.Unlock()
49+
50+ t.phases[phase] = d
51+ t.order = append(t.order, phase)
52+}
53+
54+// Total returns the total elapsed time since timer creation.
55+func (t *StartupTimer) Total() time.Duration {
56+ return time.Since(t.start)
57+}
58+
59+// Log outputs all timing information to the context logger.
60+func (t *StartupTimer) Log(ctx context.Context) {
61+ t.mu.Lock()
62+ defer t.mu.Unlock()
63+
64+ log := logging.FromContext(ctx)
65+ total := time.Since(t.start)
66+
67+ event := log.Info().Dur("total", total)
68+ for _, phase := range t.order {
69+ if dur, ok := t.phases[phase]; ok {
70+ event = event.Dur(phase, dur)
71+ }
72+ }
73+ event.Msg("startup timing")
74+}
75+
76+// LogDebug outputs timing as debug level (for less verbose production logs).
77+func (t *StartupTimer) LogDebug(ctx context.Context) {
78+ t.mu.Lock()
79+ defer t.mu.Unlock()
80+
81+ log := logging.FromContext(ctx)
82+ total := time.Since(t.start)
83+
84+ event := log.Debug().Dur("total", total)
85+ for _, phase := range t.order {
86+ if dur, ok := t.phases[phase]; ok {
87+ event = event.Dur(phase, dur)
88+ }
89+ }
90+ event.Msg("startup timing")
91+}
M internal/bootstrap/webkit_stack.go +7 -0
@@ -113,6 +113,13 @@ func BuildWebKitStack(
113113 pool.SetFilterApplier(filterManager)
114114 }
115115
116+ // Pre-create ONE WebView synchronously for instant first navigation.
117+ // This is the heaviest operation but happens before window is shown,
118+ // so users perceive it as part of the normal "loading" phase.
119+ if err := pool.PrewarmFirst(ctx); err != nil {
120+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
121+ }
122+
116123 return WebKitStack{
117124 Context: wkCtx,
118125 Settings: settings,
@@ -113,6 +113,13 @@ func BuildWebKitStack(
113 pool.SetFilterApplier(filterManager)113 pool.SetFilterApplier(filterManager)
114 }114 }
115 115
116+ // Pre-create ONE WebView synchronously for instant first navigation.
117+ // This is the heaviest operation but happens before window is shown,
118+ // so users perceive it as part of the normal "loading" phase.
119+ if err := pool.PrewarmFirst(ctx); err != nil {
120+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
121+ }
122+
116 return WebKitStack{123 return WebKitStack{
117 Context: wkCtx,124 Context: wkCtx,
118 Settings: settings,125 Settings: settings,
M internal/infrastructure/webkit/pool.go +28 -0
@@ -196,6 +196,34 @@ func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
196196 wv.Destroy()
197197 }
198198
199+// PrewarmFirst creates exactly one WebView synchronously.
200+// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
201+// This is the most impactful optimization for cold start since WebView creation
202+// is the heaviest operation (spawns WebKit web process).
203+// Returns error if WebView creation fails; caller should log and continue.
204+func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
205+ if p.closed.Load() {
206+ return context.Canceled
207+ }
208+ if len(p.pool) > 0 {
209+ return nil // Already have at least one
210+ }
211+
212+ log := logging.FromContext(ctx)
213+ wv, err := p.createWebView(ctx)
214+ if err != nil {
215+ return err
216+ }
217+
218+ select {
219+ case p.pool <- wv:
220+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
221+ default:
222+ wv.Destroy() // Pool somehow full (shouldn't happen)
223+ }
224+ return nil
225+}
226+
199227 // Prewarm creates WebViews synchronously to populate the pool.
200228 // Must be called from the GTK main thread (after GTK application is initialized).
201229 func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
@@ -196,6 +196,34 @@ func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
196 wv.Destroy()196 wv.Destroy()
197 }197 }
198 198
199+// PrewarmFirst creates exactly one WebView synchronously.
200+// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
201+// This is the most impactful optimization for cold start since WebView creation
202+// is the heaviest operation (spawns WebKit web process).
203+// Returns error if WebView creation fails; caller should log and continue.
204+func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
205+ if p.closed.Load() {
206+ return context.Canceled
207+ }
208+ if len(p.pool) > 0 {
209+ return nil // Already have at least one
210+ }
211+
212+ log := logging.FromContext(ctx)
213+ wv, err := p.createWebView(ctx)
214+ if err != nil {
215+ return err
216+ }
217+
218+ select {
219+ case p.pool <- wv:
220+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
221+ default:
222+ wv.Destroy() // Pool somehow full (shouldn't happen)
223+ }
224+ return nil
225+}
226+
199 // Prewarm creates WebViews synchronously to populate the pool.227 // Prewarm creates WebViews synchronously to populate the pool.
200 // Must be called from the GTK main thread (after GTK application is initialized).228 // Must be called from the GTK main thread (after GTK application is initialized).
201 func (p *WebViewPool) Prewarm(ctx context.Context, count int) {229 func (p *WebViewPool) Prewarm(ctx context.Context, count int) {

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