detached 04220c1

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +62 -3
M cmd/dumber/main.go +10 -3
@@ -406,13 +406,20 @@ func handleAutoRestore(
406406
407407 func setupSignalHandler(ctx context.Context, app *ui.App) {
408408 log := logging.FromContext(ctx)
409- sigCh := make(chan os.Signal, 1)
409+ sigCh := make(chan os.Signal, 2)
410410 signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
411411 go func() {
412412 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")
415414 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)
416423 }()
417424 }
418425
@@ -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 := <-sigCh412 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() {
241241 }
242242
243243 flushPending := func() {
244+
244245 for historyURL, visits := range pendingVisits {
245246 uc.persistHistory(uc.ctx, historyRecord{url: historyURL, visits: visits})
246247 }
@@ -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 (
55 "fmt"
66 "os"
77 "path/filepath"
8+ "strconv"
9+ "strings"
10+ "syscall"
811
912 purecef "github.com/bnema/purego-cef/cef"
13+ "github.com/rs/zerolog"
1014
1115 "github.com/bnema/dumber/internal/infrastructure/config"
1216 "github.com/bnema/dumber/internal/logging"
@@ -16,6 +20,11 @@ import (
1620 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {
1721 logger := logging.FromContext(ctx)
1822
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+
1928 // Resolve pump mode from config (with defaults).
2029 multiThreaded := cfg.CEFMultiThreadedMessageLoop()
2130 manualPumpMs := cfg.CEFManualPumpIntervalMs()
@@ -117,6 +126,48 @@ func appendIfMissing(args []string, flag string) []string {
117126
118127 // findHelperBinary looks for the cef-helper binary next to the running
119128 // 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+
120171 func findHelperBinary() string {
121172 exe, err := os.Executable()
122173 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 running127 // 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 {

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