fix(cef): graceful shutdown on signal and stale singleton cleanup
- Keep signal handler registered between first and second Ctrl+C so GTK shutdown + CEF cleanup can complete before process exit - Clean up stale CEF SingletonLock/Socket/Cookie files on startup when the owning process is dead (prevents SIGSEGV on restart) - Guard persistTitleUpdate against empty titles
- commit
- 04220c184d9b33790565030876b8edf675704e52
- author
- brice · · 2026-03-20 15:41 UTC
- committer
- brice · · 2026-03-28 04:36 UTC
- parent
- 030c8ba
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +62 -3
M cmd/dumber/main.go +10 -3
| @@ -406,13 +406,20 @@ func handleAutoRestore( | |||
| 406 | 406 | ||
| 407 | 407 | func setupSignalHandler(ctx context.Context, app *ui.App) { | |
| 408 | 408 | log := logging.FromContext(ctx) | |
| 409 | - | sigCh := make(chan os.Signal, 1) | |
| 409 | + | sigCh := make(chan os.Signal, 2) | |
| 410 | 410 | signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | |
| 411 | 411 | go func() { | |
| 412 | 412 | sig := <-sigCh | |
| 413 | - | signal.Stop(sigCh) | |
| 414 | - | log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting") | |
| 413 | + | log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting gracefully") | |
| 415 | 414 | app.Quit() | |
| 415 | + | ||
| 416 | + | // Second signal: force exit. Keep listening so a second Ctrl+C | |
| 417 | + | // doesn't go to the default handler (immediate process kill) | |
| 418 | + | // before GTK shutdown + CEF cleanup can finish. | |
| 419 | + | sig = <-sigCh | |
| 420 | + | log.Warn().Str("signal", sig.String()).Msg("received second interrupt, forcing exit") | |
| 421 | + | signal.Stop(sigCh) | |
| 422 | + | os.Exit(1) | |
| 416 | 423 | }() | |
| 417 | 424 | } | |
| 418 | 425 | ||
| @@ -406,13 +406,20 @@ func handleAutoRestore( | |||||
| 406 | 406 | ||||
| 407 | func setupSignalHandler(ctx context.Context, app *ui.App) { | 407 | func setupSignalHandler(ctx context.Context, app *ui.App) { | ||
| 408 | log := logging.FromContext(ctx) | 408 | log := logging.FromContext(ctx) | ||
| 409 | - | sigCh := make(chan os.Signal, 1) | 409 | + | sigCh := make(chan os.Signal, 2) |
| 410 | signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | 410 | signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) | ||
| 411 | go func() { | 411 | go func() { | ||
| 412 | sig := <-sigCh | 412 | sig := <-sigCh | ||
| 413 | - | signal.Stop(sigCh) | 413 | + | log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting gracefully") |
| 414 | - | log.Info().Str("signal", sig.String()).Msg("received interrupt, quitting") | |||
| 415 | app.Quit() | 414 | app.Quit() | ||
| 415 | + | ||||
| 416 | + | // Second signal: force exit. Keep listening so a second Ctrl+C | |||
| 417 | + | // doesn't go to the default handler (immediate process kill) | |||
| 418 | + | // before GTK shutdown + CEF cleanup can finish. | |||
| 419 | + | sig = <-sigCh | |||
| 420 | + | log.Warn().Str("signal", sig.String()).Msg("received second interrupt, forcing exit") | |||
| 421 | + | signal.Stop(sigCh) | |||
| 422 | + | os.Exit(1) | |||
| 416 | }() | 423 | }() | ||
| 417 | } | 424 | } | ||
| 418 | 425 | ||||
M internal/application/usecase/navigate.go +1 -0
| @@ -241,6 +241,7 @@ func (uc *NavigateUseCase) historyWorker() { | |||
| 241 | 241 | } | |
| 242 | 242 | ||
| 243 | 243 | flushPending := func() { | |
| 244 | + | ||
| 244 | 245 | for historyURL, visits := range pendingVisits { | |
| 245 | 246 | uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits}) | |
| 246 | 247 | } | |
| @@ -241,6 +241,7 @@ func (uc *NavigateUseCase) historyWorker() { | |||||
| 241 | } | 241 | } | ||
| 242 | 242 | ||||
| 243 | flushPending := func() { | 243 | flushPending := func() { | ||
| 244 | + | ||||
| 244 | for historyURL, visits := range pendingVisits { | 245 | for historyURL, visits := range pendingVisits { | ||
| 245 | uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits}) | 246 | uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits}) | ||
| 246 | } | 247 | } | ||
M internal/infrastructure/cef/engine_init.go +51 -0
| @@ -5,8 +5,12 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "strconv" | |
| 9 | + | "strings" | |
| 10 | + | "syscall" | |
| 8 | 11 | ||
| 9 | 12 | purecef "github.com/bnema/purego-cef/cef" | |
| 13 | + | "github.com/rs/zerolog" | |
| 10 | 14 | ||
| 11 | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | 16 | "github.com/bnema/dumber/internal/logging" | |
| @@ -16,6 +20,11 @@ import ( | |||
| 16 | 20 | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) { | |
| 17 | 21 | logger := logging.FromContext(ctx) | |
| 18 | 22 | ||
| 23 | + | // Clean up stale CEF singleton locks from previous unclean shutdowns. | |
| 24 | + | // When the process is killed (Ctrl+C, SIGSEGV), CEF leaves SingletonLock | |
| 25 | + | // files that cause the next instance to crash on startup. | |
| 26 | + | cleanStaleSingletonLocks(logger) | |
| 27 | + | ||
| 19 | 28 | // Resolve pump mode from config (with defaults). | |
| 20 | 29 | multiThreaded := cfg.CEFMultiThreadedMessageLoop() | |
| 21 | 30 | manualPumpMs := cfg.CEFManualPumpIntervalMs() | |
| @@ -117,6 +126,48 @@ func appendIfMissing(args []string, flag string) []string { | |||
| 117 | 126 | ||
| 118 | 127 | // findHelperBinary looks for the cef-helper binary next to the running | |
| 119 | 128 | // executable. Returns empty string if not found. | |
| 129 | + | // cleanStaleSingletonLocks removes CEF's SingletonLock/Socket/Cookie files | |
| 130 | + | // if the owning process is no longer running. CEF leaves these behind on | |
| 131 | + | // unclean shutdown (SIGKILL, SIGSEGV) and the next instance crashes trying | |
| 132 | + | // to connect to the dead process. | |
| 133 | + | func cleanStaleSingletonLocks(logger *zerolog.Logger) { | |
| 134 | + | // CEF defaults to ~/.config/cef_user_data when no root_cache_path is set. | |
| 135 | + | dir := filepath.Join(os.Getenv("HOME"), ".config", "cef_user_data") | |
| 136 | + | ||
| 137 | + | lockPath := filepath.Join(dir, "SingletonLock") | |
| 138 | + | target, err := os.Readlink(lockPath) | |
| 139 | + | if err != nil { | |
| 140 | + | return // No lock file or not a symlink — nothing to clean. | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // SingletonLock is a symlink to "hostname-pid". | |
| 144 | + | parts := strings.SplitN(target, "-", 2) | |
| 145 | + | if len(parts) != 2 { | |
| 146 | + | return | |
| 147 | + | } | |
| 148 | + | pid, err := strconv.Atoi(parts[1]) | |
| 149 | + | if err != nil || pid <= 0 { | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // Check if the process is still alive. | |
| 154 | + | proc, err := os.FindProcess(pid) | |
| 155 | + | if err == nil { | |
| 156 | + | if err := proc.Signal(syscall.Signal(0)); err == nil { | |
| 157 | + | // Process is alive — don't touch the lock. | |
| 158 | + | return | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // Stale lock — remove singleton files. | |
| 163 | + | for _, name := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} { | |
| 164 | + | p := filepath.Join(dir, name) | |
| 165 | + | if err := os.Remove(p); err == nil { | |
| 166 | + | logger.Info().Str("path", p).Msg("cef: removed stale singleton file") | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 120 | 171 | func findHelperBinary() string { | |
| 121 | 172 | exe, err := os.Executable() | |
| 122 | 173 | if err != nil { | |
| @@ -5,8 +5,12 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "strconv" | |||
| 9 | + | "strings" | |||
| 10 | + | "syscall" | |||
| 8 | 11 | ||||
| 9 | purecef "github.com/bnema/purego-cef/cef" | 12 | purecef "github.com/bnema/purego-cef/cef" | ||
| 13 | + | "github.com/rs/zerolog" | |||
| 10 | 14 | ||||
| 11 | "github.com/bnema/dumber/internal/infrastructure/config" | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 12 | "github.com/bnema/dumber/internal/logging" | 16 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -16,6 +20,11 @@ import ( | |||||
| 16 | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) { | 20 | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) { | ||
| 17 | logger := logging.FromContext(ctx) | 21 | logger := logging.FromContext(ctx) | ||
| 18 | 22 | ||||
| 23 | + | // Clean up stale CEF singleton locks from previous unclean shutdowns. | |||
| 24 | + | // When the process is killed (Ctrl+C, SIGSEGV), CEF leaves SingletonLock | |||
| 25 | + | // files that cause the next instance to crash on startup. | |||
| 26 | + | cleanStaleSingletonLocks(logger) | |||
| 27 | + | ||||
| 19 | // Resolve pump mode from config (with defaults). | 28 | // Resolve pump mode from config (with defaults). | ||
| 20 | multiThreaded := cfg.CEFMultiThreadedMessageLoop() | 29 | multiThreaded := cfg.CEFMultiThreadedMessageLoop() | ||
| 21 | manualPumpMs := cfg.CEFManualPumpIntervalMs() | 30 | manualPumpMs := cfg.CEFManualPumpIntervalMs() | ||
| @@ -117,6 +126,48 @@ func appendIfMissing(args []string, flag string) []string { | |||||
| 117 | 126 | ||||
| 118 | // findHelperBinary looks for the cef-helper binary next to the running | 127 | // findHelperBinary looks for the cef-helper binary next to the running | ||
| 119 | // executable. Returns empty string if not found. | 128 | // executable. Returns empty string if not found. | ||
| 129 | + | // cleanStaleSingletonLocks removes CEF's SingletonLock/Socket/Cookie files | |||
| 130 | + | // if the owning process is no longer running. CEF leaves these behind on | |||
| 131 | + | // unclean shutdown (SIGKILL, SIGSEGV) and the next instance crashes trying | |||
| 132 | + | // to connect to the dead process. | |||
| 133 | + | func cleanStaleSingletonLocks(logger *zerolog.Logger) { | |||
| 134 | + | // CEF defaults to ~/.config/cef_user_data when no root_cache_path is set. | |||
| 135 | + | dir := filepath.Join(os.Getenv("HOME"), ".config", "cef_user_data") | |||
| 136 | + | ||||
| 137 | + | lockPath := filepath.Join(dir, "SingletonLock") | |||
| 138 | + | target, err := os.Readlink(lockPath) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return // No lock file or not a symlink — nothing to clean. | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // SingletonLock is a symlink to "hostname-pid". | |||
| 144 | + | parts := strings.SplitN(target, "-", 2) | |||
| 145 | + | if len(parts) != 2 { | |||
| 146 | + | return | |||
| 147 | + | } | |||
| 148 | + | pid, err := strconv.Atoi(parts[1]) | |||
| 149 | + | if err != nil || pid <= 0 { | |||
| 150 | + | return | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // Check if the process is still alive. | |||
| 154 | + | proc, err := os.FindProcess(pid) | |||
| 155 | + | if err == nil { | |||
| 156 | + | if err := proc.Signal(syscall.Signal(0)); err == nil { | |||
| 157 | + | // Process is alive — don't touch the lock. | |||
| 158 | + | return | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // Stale lock — remove singleton files. | |||
| 163 | + | for _, name := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} { | |||
| 164 | + | p := filepath.Join(dir, name) | |||
| 165 | + | if err := os.Remove(p); err == nil { | |||
| 166 | + | logger.Info().Str("path", p).Msg("cef: removed stale singleton file") | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 120 | func findHelperBinary() string { | 171 | func findHelperBinary() string { | ||
| 121 | exe, err := os.Executable() | 172 | exe, err := os.Executable() | ||
| 122 | if err != nil { | 173 | if err != nil { | ||