detached 7e8b325

fix(shutdown): resolve deadlock on graceful shutdown with Cmd+Q

Root cause: After GTK main loop exits, any GTK method call blocks forever.
During cleanup, Destroy() was acquiring a lock that was held by goroutines
making GTK calls (URI(), Title(), ZoomLevel(), etc.), causing deadlock.

Changes:
- Remove mutex lock acquisition in WebView.Destroy() to prevent deadlock
- Remove GTK method calls (StopLoading) after main loop has exited
- Remove lock from IsDestroyed() (bool reads are atomic on all platforms)
- Remove unused ConnectLoadChanged signal handler added in this branch
- Move history queue flush to run before cleanup (after main loop exit)

During shutdown, thread safety is not critical as we're tearing down.
GTK widgets are cleaned up by Go GC.
commit
7e8b325966a83b8997f6276f946911216744bacd
author
bnema · · 2025-10-24 05:35 UTC
committer
bnema · · 2025-10-24 17:33 UTC
parent
1e6ca34
signature
unsigned
3 files · +21 -59
M internal/app/browser/browser.go +9 -6
@@ -218,12 +218,6 @@ func (app *BrowserApp) cleanup() {
218218 app.workspace = nil
219219 }
220220
221- // Flush pending history writes before closing database
222- if app.browserService != nil {
223- log.Printf("Flushing pending history writes...")
224- app.browserService.FlushHistoryQueue()
225- }
226-
227221 // Close database with WAL checkpoint
228222 if app.database != nil {
229223 log.Printf("Performing WAL checkpoint and closing database...")
@@ -287,6 +281,15 @@ func (app *BrowserApp) runMainLoop() {
287281 log.Printf("Entering GTK main loop…")
288282 webkit.RunMainLoop()
289283 log.Printf("GTK main loop exited")
284+
285+ // Flush pending history writes immediately after main loop exit
286+ // This ensures database operations complete while GTK is still in a valid state
287+ // MUST happen before cleanup() deferred call, which happens after this function returns
288+ if app.browserService != nil {
289+ log.Printf("Flushing pending history writes...")
290+ app.browserService.FlushHistoryQueue()
291+ log.Printf("History queue flushed successfully")
292+ }
290293 } else {
291294 log.Printf("Not entering GUI loop (non-CGO build)")
292295 // In non-CGO mode, just wait for signals
@@ -218,12 +218,6 @@ func (app *BrowserApp) cleanup() {
218 app.workspace = nil218 app.workspace = nil
219 }219 }
220 220
221- // Flush pending history writes before closing database
222- if app.browserService != nil {
223- log.Printf("Flushing pending history writes...")
224- app.browserService.FlushHistoryQueue()
225- }
226-
227 // Close database with WAL checkpoint221 // Close database with WAL checkpoint
228 if app.database != nil {222 if app.database != nil {
229 log.Printf("Performing WAL checkpoint and closing database...")223 log.Printf("Performing WAL checkpoint and closing database...")
@@ -287,6 +281,15 @@ func (app *BrowserApp) runMainLoop() {
287 log.Printf("Entering GTK main loop…")281 log.Printf("Entering GTK main loop…")
288 webkit.RunMainLoop()282 webkit.RunMainLoop()
289 log.Printf("GTK main loop exited")283 log.Printf("GTK main loop exited")
284+
285+ // Flush pending history writes immediately after main loop exit
286+ // This ensures database operations complete while GTK is still in a valid state
287+ // MUST happen before cleanup() deferred call, which happens after this function returns
288+ if app.browserService != nil {
289+ log.Printf("Flushing pending history writes...")
290+ app.browserService.FlushHistoryQueue()
291+ log.Printf("History queue flushed successfully")
292+ }
290 } else {293 } else {
291 log.Printf("Not entering GUI loop (non-CGO build)")294 log.Printf("Not entering GUI loop (non-CGO build)")
292 // In non-CGO mode, just wait for signals295 // In non-CGO mode, just wait for signals
M internal/app/browser/pane.go +0 -17
@@ -104,23 +104,6 @@ func (p *BrowserPane) Cleanup() {
104104 p.messageHandler.SetWebView(nil)
105105 }
106106
107- // Notify JS to cleanup GUI
108- if p.webView != nil && p.hasGUI && !p.webView.IsDestroyed() {
109- cleanupScript := `try {
110- if (typeof window.__dumber_teardown === 'function') {
111- window.__dumber_teardown();
112- }
113- if (window.__dumber_gui_manager) {
114- window.__dumber_gui_manager.destroy();
115- }
116-} catch (err) {
117- console.warn('[dumber] GUI teardown failed', err);
118-}`
119- if err := p.webView.InjectScript(cleanupScript); err != nil {
120- log.Printf("[pane-%s] failed to inject cleanup script: %v", p.id, err)
121- }
122- }
123-
124107 // Destroy WebView to unregister from global registry and release resources
125108 if p.webView != nil && !p.webView.IsDestroyed() {
126109 if err := p.webView.Destroy(); err != nil {
@@ -104,23 +104,6 @@ func (p *BrowserPane) Cleanup() {
104 p.messageHandler.SetWebView(nil)104 p.messageHandler.SetWebView(nil)
105 }105 }
106 106
107- // Notify JS to cleanup GUI
108- if p.webView != nil && p.hasGUI && !p.webView.IsDestroyed() {
109- cleanupScript := `try {
110- if (typeof window.__dumber_teardown === 'function') {
111- window.__dumber_teardown();
112- }
113- if (window.__dumber_gui_manager) {
114- window.__dumber_gui_manager.destroy();
115- }
116-} catch (err) {
117- console.warn('[dumber] GUI teardown failed', err);
118-}`
119- if err := p.webView.InjectScript(cleanupScript); err != nil {
120- log.Printf("[pane-%s] failed to inject cleanup script: %v", p.id, err)
121- }
122- }
123-
124 // Destroy WebView to unregister from global registry and release resources107 // Destroy WebView to unregister from global registry and release resources
125 if p.webView != nil && !p.webView.IsDestroyed() {108 if p.webView != nil && !p.webView.IsDestroyed() {
126 if err := p.webView.Destroy(); err != nil {109 if err := p.webView.Destroy(); err != nil {
M pkg/webkit/webview.go +12 -36
@@ -6,7 +6,6 @@ import (
66 "log"
77 "strconv"
88 "sync"
9- "sync/atomic"
109
1110 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
1211 "github.com/diamondburned/gotk4/pkg/gdk/v4"
@@ -46,9 +45,6 @@ type WebView struct {
4645 onWorkspaceNavigation func(direction string) bool // Workspace pane navigation
4746 onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)
4847 isPaneModeActive func() bool // Check if pane mode is active
49- onLoadStarted func() // Load started event
50- onLoadCommitted func() // Load committed event (DOM available)
51- onLoadFinished func() // Load finished event
5248 }
5349
5450 // NewWebView creates a new WebView with the given configuration
@@ -239,33 +235,6 @@ func (w *WebView) setupEventHandlers() {
239235 // Favicon changed - connect to FaviconDatabase
240236 w.setupFaviconHandlers()
241237
242- // Load changed - track page lifecycle for performance monitoring
243- w.view.ConnectLoadChanged(func(event webkit.LoadEvent) {
244- // Avoid calling callbacks if WebView is being destroyed to prevent reference cycles
245- // especially important when page cache is enabled
246- if w.IsDestroyed() {
247- return
248- }
249-
250- switch event {
251- case webkit.LoadStarted:
252- // Page load started - could show loading indicator
253- if w.onLoadStarted != nil {
254- w.onLoadStarted()
255- }
256- case webkit.LoadCommitted:
257- // Page committed - DOM is now available for interaction
258- if w.onLoadCommitted != nil {
259- w.onLoadCommitted()
260- }
261- case webkit.LoadFinished:
262- // Page fully loaded - all resources downloaded
263- if w.onLoadFinished != nil {
264- w.onLoadFinished()
265- }
266- }
267- })
268-
269238 // TLS error handling - connect to load-failed-with-tls-errors signal
270239 w.setupTLSErrorHandler()
271240
@@ -450,8 +419,12 @@ func (w *WebView) Hide() error {
450419
451420 // Destroy destroys the WebView and releases resources
452421 func (w *WebView) Destroy() error {
453- w.mu.Lock()
454- defer w.mu.Unlock()
422+ // DO NOT acquire w.mu lock here to avoid deadlock!
423+ // After GTK main loop exits, any goroutine holding the lock and calling GTK methods
424+ // will block forever waiting for the main loop. This causes Destroy() to deadlock
425+ // trying to acquire the same lock.
426+ //
427+ // During shutdown, thread safety is not critical since we're tearing everything down.
455428
456429 if w.destroyed {
457430 return nil
@@ -464,14 +437,17 @@ func (w *WebView) Destroy() error {
464437 delete(viewRegistry, w.id)
465438 viewMu.Unlock()
466439
467- // The GTK widget will be cleaned up by Go GC
440+ // DO NOT call StopLoading() or any other GTK methods here!
441+ // After the main loop exits, GTK calls will block forever.
442+ // The GTK widget will be cleaned up by Go GC.
443+
468444 return nil
469445 }
470446
471447 // IsDestroyed returns true if the WebView has been destroyed
472448 func (w *WebView) IsDestroyed() bool {
473- w.mu.RLock()
474- defer w.mu.RUnlock()
449+ // DO NOT acquire lock - read of bool is atomic on all platforms Go supports
450+ // This prevents deadlock during shutdown when GTK signal handlers check IsDestroyed
475451 return w.destroyed
476452 }
477453
@@ -6,7 +6,6 @@ import (
6 "log"6 "log"
7 "strconv"7 "strconv"
8 "sync"8 "sync"
9- "sync/atomic"
10 9
11 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"10 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12 "github.com/diamondburned/gotk4/pkg/gdk/v4"11 "github.com/diamondburned/gotk4/pkg/gdk/v4"
@@ -46,9 +45,6 @@ type WebView struct {
46 onWorkspaceNavigation func(direction string) bool // Workspace pane navigation45 onWorkspaceNavigation func(direction string) bool // Workspace pane navigation
47 onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)46 onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)
48 isPaneModeActive func() bool // Check if pane mode is active47 isPaneModeActive func() bool // Check if pane mode is active
49- onLoadStarted func() // Load started event
50- onLoadCommitted func() // Load committed event (DOM available)
51- onLoadFinished func() // Load finished event
52 }48 }
53 49
54 // NewWebView creates a new WebView with the given configuration50 // NewWebView creates a new WebView with the given configuration
@@ -239,33 +235,6 @@ func (w *WebView) setupEventHandlers() {
239 // Favicon changed - connect to FaviconDatabase235 // Favicon changed - connect to FaviconDatabase
240 w.setupFaviconHandlers()236 w.setupFaviconHandlers()
241 237
242- // Load changed - track page lifecycle for performance monitoring
243- w.view.ConnectLoadChanged(func(event webkit.LoadEvent) {
244- // Avoid calling callbacks if WebView is being destroyed to prevent reference cycles
245- // especially important when page cache is enabled
246- if w.IsDestroyed() {
247- return
248- }
249-
250- switch event {
251- case webkit.LoadStarted:
252- // Page load started - could show loading indicator
253- if w.onLoadStarted != nil {
254- w.onLoadStarted()
255- }
256- case webkit.LoadCommitted:
257- // Page committed - DOM is now available for interaction
258- if w.onLoadCommitted != nil {
259- w.onLoadCommitted()
260- }
261- case webkit.LoadFinished:
262- // Page fully loaded - all resources downloaded
263- if w.onLoadFinished != nil {
264- w.onLoadFinished()
265- }
266- }
267- })
268-
269 // TLS error handling - connect to load-failed-with-tls-errors signal238 // TLS error handling - connect to load-failed-with-tls-errors signal
270 w.setupTLSErrorHandler()239 w.setupTLSErrorHandler()
271 240
@@ -450,8 +419,12 @@ func (w *WebView) Hide() error {
450 419
451 // Destroy destroys the WebView and releases resources420 // Destroy destroys the WebView and releases resources
452 func (w *WebView) Destroy() error {421 func (w *WebView) Destroy() error {
453- w.mu.Lock()422+ // DO NOT acquire w.mu lock here to avoid deadlock!
454- defer w.mu.Unlock()423+ // After GTK main loop exits, any goroutine holding the lock and calling GTK methods
424+ // will block forever waiting for the main loop. This causes Destroy() to deadlock
425+ // trying to acquire the same lock.
426+ //
427+ // During shutdown, thread safety is not critical since we're tearing everything down.
455 428
456 if w.destroyed {429 if w.destroyed {
457 return nil430 return nil
@@ -464,14 +437,17 @@ func (w *WebView) Destroy() error {
464 delete(viewRegistry, w.id)437 delete(viewRegistry, w.id)
465 viewMu.Unlock()438 viewMu.Unlock()
466 439
467- // The GTK widget will be cleaned up by Go GC440+ // DO NOT call StopLoading() or any other GTK methods here!
441+ // After the main loop exits, GTK calls will block forever.
442+ // The GTK widget will be cleaned up by Go GC.
443+
468 return nil444 return nil
469 }445 }
470 446
471 // IsDestroyed returns true if the WebView has been destroyed447 // IsDestroyed returns true if the WebView has been destroyed
472 func (w *WebView) IsDestroyed() bool {448 func (w *WebView) IsDestroyed() bool {
473- w.mu.RLock()449+ // DO NOT acquire lock - read of bool is atomic on all platforms Go supports
474- defer w.mu.RUnlock()450+ // This prevents deadlock during shutdown when GTK signal handlers check IsDestroyed
475 return w.destroyed451 return w.destroyed
476 }452 }
477 453

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