detached edc44c9

chore: checkpoint baseline before wave1 multi-agent

commit
edc44c95d4f78470019568071bcb92b4b04944e6
author
brice · · 2026-02-07 04:53 UTC
parent
c803dee
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +288 -47
M .gitignore +3 -0
@@ -107,3 +107,6 @@ aur/*/*.pkg.tar.zst
107107 aur/*/LICENSE
108108 aur/*/dev.bnema.Dumber.desktop
109109 aur/*/logo-512.png
110+
111+# Planning/runbook notes stored outside product docs
112+docs/reference/stability-performance-multi-agent-runbook-*.md
@@ -107,3 +107,6 @@ aur/*/*.pkg.tar.zst
107 aur/*/LICENSE107 aur/*/LICENSE
108 aur/*/dev.bnema.Dumber.desktop108 aur/*/dev.bnema.Dumber.desktop
109 aur/*/logo-512.png109 aur/*/logo-512.png
110+
111+# Planning/runbook notes stored outside product docs
112+docs/reference/stability-performance-multi-agent-runbook-*.md
M go.mod +2 -2
@@ -3,7 +3,7 @@ module github.com/bnema/dumber
33 go 1.25.3
44
55 require (
6- github.com/bnema/puregotk-webkit v0.0.9
6+ github.com/bnema/puregotk-webkit v0.0.10
77 github.com/charmbracelet/bubbles v0.21.0
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
@@ -22,7 +22,7 @@ require (
2222 gopkg.in/yaml.v3 v3.0.1
2323 )
2424
25-replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3
25+replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3
2626
2727 require (
2828 github.com/atotto/clipboard v0.1.4 // indirect
@@ -3,7 +3,7 @@ module github.com/bnema/dumber
3 go 1.25.33 go 1.25.3
4 4
5 require (5 require (
6- github.com/bnema/puregotk-webkit v0.0.96+ github.com/bnema/puregotk-webkit v0.0.10
7 github.com/charmbracelet/bubbles v0.21.07 github.com/charmbracelet/bubbles v0.21.0
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
@@ -22,7 +22,7 @@ require (
22 gopkg.in/yaml.v3 v3.0.122 gopkg.in/yaml.v3 v3.0.1
23 )23 )
24 24
25-replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa325+replace github.com/jwijenbergh/puregotk => github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3
26 26
27 require (27 require (
28 github.com/atotto/clipboard v0.1.4 // indirect28 github.com/atotto/clipboard v0.1.4 // indirect
M go.sum +4 -4
@@ -4,10 +4,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
44 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
55 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
66 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
7-github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 h1:CXA7347oo/SI8NqQz9um1u5WRpume5Y63U6OK4cNJEg=
8-github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9-github.com/bnema/puregotk-webkit v0.0.9 h1:vE4mtgoQM/aOS3dz27PF2MMvaorXnrVEwQrawVj49u4=
10-github.com/bnema/puregotk-webkit v0.0.9/go.mod h1:vOXdcQ9LZmIhUn5Q8M/sHEwZ/NkvDtB4/8fnJaFbxS8=
7+github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3 h1:bT12Jdm3GsJyINzxsk+WrPiMuwLaTQbBc5Z8VidyBl0=
8+github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9+github.com/bnema/puregotk-webkit v0.0.10 h1:Qkwxo6UERwsLXFHtcJpV3McmAWpNswf/AGSV9JXWDOk=
10+github.com/bnema/puregotk-webkit v0.0.10/go.mod h1:Hs5dyovh6jPUzyj8iWweRmN0zjPyUlD+JWH7mjypQiw=
1111 github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
1212 github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
1313 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -4,10 +4,10 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=4 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=5 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
6 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=6 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
7-github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3 h1:CXA7347oo/SI8NqQz9um1u5WRpume5Y63U6OK4cNJEg=7+github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3 h1:bT12Jdm3GsJyINzxsk+WrPiMuwLaTQbBc5Z8VidyBl0=
8-github.com/bnema/puregotk v0.0.0-20251222174431-73930527faa3/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=8+github.com/bnema/puregotk v0.0.0-20260207041652-c591276fa9d3/go.mod h1:mZu8qSvCSqtVL+xoiebZuicnJijwrLfGRs6/eTVgFlc=
9-github.com/bnema/puregotk-webkit v0.0.9 h1:vE4mtgoQM/aOS3dz27PF2MMvaorXnrVEwQrawVj49u4=9+github.com/bnema/puregotk-webkit v0.0.10 h1:Qkwxo6UERwsLXFHtcJpV3McmAWpNswf/AGSV9JXWDOk=
10-github.com/bnema/puregotk-webkit v0.0.9/go.mod h1:vOXdcQ9LZmIhUn5Q8M/sHEwZ/NkvDtB4/8fnJaFbxS8=10+github.com/bnema/puregotk-webkit v0.0.10/go.mod h1:Hs5dyovh6jPUzyj8iWweRmN0zjPyUlD+JWH7mjypQiw=
11 github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=11 github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
12 github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=12 github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
13 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=13 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
M internal/bootstrap/session.go +110 -0
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "strings"
910 "sync"
1011 "syscall"
1112 "time"
@@ -24,6 +25,7 @@ const (
2425 recentSessionsLimit = 200
2526 lockDirPerm = 0o755
2627 lockFilePerm = 0o600
28+ markerFilePerm = 0o644
2729 )
2830
2931 type BrowserSession struct {
@@ -95,6 +97,19 @@ func StartBrowserSession(
9597 // error handling during shutdown sequences where persistFn may be called multiple times.
9698 persistFn := func(persistCtx context.Context) error {
9799 persistMu.Do(func() {
100+ if lockDir != "" {
101+ abruptSessions, abruptErr := markAbruptExits(lockDir, now.UTC())
102+ if abruptErr != nil {
103+ logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")
104+ } else {
105+ for _, abruptSessionID := range abruptSessions {
106+ logger.Warn().
107+ Str("session_id", abruptSessionID).
108+ Msg("abrupt-exit marker detected (startup without shutdown)")
109+ }
110+ }
111+ }
112+
98113 saveCtx := corelogging.WithContext(context.Background(), logger)
99114 if err := sessionRepo.Save(saveCtx, session); err != nil {
100115 persistErr = fmt.Errorf("save session: %w", err)
@@ -105,6 +120,11 @@ func StartBrowserSession(
105120 Str("session_id", string(session.ID)).
106121 Str("type", string(session.Type)).
107122 Msg("session started")
123+ if lockDir != "" {
124+ if err := writeStartupMarker(lockDir, string(session.ID), now.UTC()); err != nil {
125+ logger.Warn().Err(err).Str("session_id", string(session.ID)).Msg("failed to write startup marker")
126+ }
127+ }
108128
109129 lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
110130 if lockErr != nil {
@@ -133,6 +153,13 @@ func StartBrowserSession(
133153 lp := lockPath
134154 lockMu.Unlock()
135155
156+ if lockDir != "" {
157+ shutdownAt := time.Now().UTC()
158+ if markerErr := writeShutdownMarker(lockDir, string(session.ID), shutdownAt); markerErr != nil {
159+ logger.Warn().Err(markerErr).Str("session_id", string(session.ID)).Msg("failed to write clean shutdown marker")
160+ }
161+ }
162+
136163 if lf != nil {
137164 _ = unlockAndClose(lf)
138165 _ = os.Remove(lp)
@@ -263,6 +290,89 @@ func unlockAndClose(f *os.File) error {
263290 return f.Close()
264291 }
265292
293+func startupMarkerPath(lockDir, sessionID string) string {
294+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.startup.marker", sessionID))
295+}
296+
297+func shutdownMarkerPath(lockDir, sessionID string) string {
298+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.shutdown.marker", sessionID))
299+}
300+
301+func abruptMarkerPath(lockDir, sessionID string) string {
302+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.abrupt.marker", sessionID))
303+}
304+
305+func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error {
306+ if lockDir == "" || sessionID == "" {
307+ return errors.New("startup marker requires lockDir and sessionID")
308+ }
309+ if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
310+ return err
311+ }
312+ content := []byte(startedAt.Format(time.RFC3339Nano) + "\n")
313+ if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
314+ return err
315+ }
316+ _ = os.Remove(shutdownMarkerPath(lockDir, sessionID))
317+ _ = os.Remove(abruptMarkerPath(lockDir, sessionID))
318+ return nil
319+}
320+
321+func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
322+ if lockDir == "" || sessionID == "" {
323+ return errors.New("shutdown marker requires lockDir and sessionID")
324+ }
325+ if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
326+ return err
327+ }
328+ content := []byte(endedAt.Format(time.RFC3339Nano) + "\n")
329+ if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
330+ return err
331+ }
332+ return nil
333+}
334+
335+func markAbruptExits(lockDir string, detectedAt time.Time) ([]string, error) {
336+ if lockDir == "" {
337+ return nil, nil
338+ }
339+ pattern := filepath.Join(lockDir, "session_*.startup.marker")
340+ startupMarkers, err := filepath.Glob(pattern)
341+ if err != nil {
342+ return nil, err
343+ }
344+
345+ abruptSessions := make([]string, 0, len(startupMarkers))
346+ for _, startupPath := range startupMarkers {
347+ base := filepath.Base(startupPath)
348+ if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {
349+ continue
350+ }
351+ sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
352+ if sessionID == "" {
353+ continue
354+ }
355+ if _, err := os.Stat(shutdownMarkerPath(lockDir, sessionID)); err == nil {
356+ continue
357+ } else if !os.IsNotExist(err) {
358+ return abruptSessions, err
359+ }
360+ if _, err := os.Stat(abruptMarkerPath(lockDir, sessionID)); err == nil {
361+ continue
362+ } else if !os.IsNotExist(err) {
363+ return abruptSessions, err
364+ }
365+
366+ payload := []byte(fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath))
367+ if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), payload, markerFilePerm); err != nil {
368+ return abruptSessions, err
369+ }
370+ abruptSessions = append(abruptSessions, sessionID)
371+ }
372+
373+ return abruptSessions, nil
374+}
375+
266376 // runSessionCleanupAsync performs stale session cleanup and old session pruning
267377 // in a background goroutine. This avoids blocking startup for non-critical tasks.
268378 // Uses context.Background() to ensure cleanup completes even if startup context is canceled.
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strings"
9 "sync"10 "sync"
10 "syscall"11 "syscall"
11 "time"12 "time"
@@ -24,6 +25,7 @@ const (
24 recentSessionsLimit = 20025 recentSessionsLimit = 200
25 lockDirPerm = 0o75526 lockDirPerm = 0o755
26 lockFilePerm = 0o60027 lockFilePerm = 0o600
28+ markerFilePerm = 0o644
27 )29 )
28 30
29 type BrowserSession struct {31 type BrowserSession struct {
@@ -95,6 +97,19 @@ func StartBrowserSession(
95 // error handling during shutdown sequences where persistFn may be called multiple times.97 // error handling during shutdown sequences where persistFn may be called multiple times.
96 persistFn := func(persistCtx context.Context) error {98 persistFn := func(persistCtx context.Context) error {
97 persistMu.Do(func() {99 persistMu.Do(func() {
100+ if lockDir != "" {
101+ abruptSessions, abruptErr := markAbruptExits(lockDir, now.UTC())
102+ if abruptErr != nil {
103+ logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")
104+ } else {
105+ for _, abruptSessionID := range abruptSessions {
106+ logger.Warn().
107+ Str("session_id", abruptSessionID).
108+ Msg("abrupt-exit marker detected (startup without shutdown)")
109+ }
110+ }
111+ }
112+
98 saveCtx := corelogging.WithContext(context.Background(), logger)113 saveCtx := corelogging.WithContext(context.Background(), logger)
99 if err := sessionRepo.Save(saveCtx, session); err != nil {114 if err := sessionRepo.Save(saveCtx, session); err != nil {
100 persistErr = fmt.Errorf("save session: %w", err)115 persistErr = fmt.Errorf("save session: %w", err)
@@ -105,6 +120,11 @@ func StartBrowserSession(
105 Str("session_id", string(session.ID)).120 Str("session_id", string(session.ID)).
106 Str("type", string(session.Type)).121 Str("type", string(session.Type)).
107 Msg("session started")122 Msg("session started")
123+ if lockDir != "" {
124+ if err := writeStartupMarker(lockDir, string(session.ID), now.UTC()); err != nil {
125+ logger.Warn().Err(err).Str("session_id", string(session.ID)).Msg("failed to write startup marker")
126+ }
127+ }
108 128
109 lf, lp, lockErr := lockSessionFile(lockDir, session.ID)129 lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
110 if lockErr != nil {130 if lockErr != nil {
@@ -133,6 +153,13 @@ func StartBrowserSession(
133 lp := lockPath153 lp := lockPath
134 lockMu.Unlock()154 lockMu.Unlock()
135 155
156+ if lockDir != "" {
157+ shutdownAt := time.Now().UTC()
158+ if markerErr := writeShutdownMarker(lockDir, string(session.ID), shutdownAt); markerErr != nil {
159+ logger.Warn().Err(markerErr).Str("session_id", string(session.ID)).Msg("failed to write clean shutdown marker")
160+ }
161+ }
162+
136 if lf != nil {163 if lf != nil {
137 _ = unlockAndClose(lf)164 _ = unlockAndClose(lf)
138 _ = os.Remove(lp)165 _ = os.Remove(lp)
@@ -263,6 +290,89 @@ func unlockAndClose(f *os.File) error {
263 return f.Close()290 return f.Close()
264 }291 }
265 292
293+func startupMarkerPath(lockDir, sessionID string) string {
294+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.startup.marker", sessionID))
295+}
296+
297+func shutdownMarkerPath(lockDir, sessionID string) string {
298+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.shutdown.marker", sessionID))
299+}
300+
301+func abruptMarkerPath(lockDir, sessionID string) string {
302+ return filepath.Join(lockDir, fmt.Sprintf("session_%s.abrupt.marker", sessionID))
303+}
304+
305+func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error {
306+ if lockDir == "" || sessionID == "" {
307+ return errors.New("startup marker requires lockDir and sessionID")
308+ }
309+ if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
310+ return err
311+ }
312+ content := []byte(startedAt.Format(time.RFC3339Nano) + "\n")
313+ if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
314+ return err
315+ }
316+ _ = os.Remove(shutdownMarkerPath(lockDir, sessionID))
317+ _ = os.Remove(abruptMarkerPath(lockDir, sessionID))
318+ return nil
319+}
320+
321+func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
322+ if lockDir == "" || sessionID == "" {
323+ return errors.New("shutdown marker requires lockDir and sessionID")
324+ }
325+ if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
326+ return err
327+ }
328+ content := []byte(endedAt.Format(time.RFC3339Nano) + "\n")
329+ if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
330+ return err
331+ }
332+ return nil
333+}
334+
335+func markAbruptExits(lockDir string, detectedAt time.Time) ([]string, error) {
336+ if lockDir == "" {
337+ return nil, nil
338+ }
339+ pattern := filepath.Join(lockDir, "session_*.startup.marker")
340+ startupMarkers, err := filepath.Glob(pattern)
341+ if err != nil {
342+ return nil, err
343+ }
344+
345+ abruptSessions := make([]string, 0, len(startupMarkers))
346+ for _, startupPath := range startupMarkers {
347+ base := filepath.Base(startupPath)
348+ if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {
349+ continue
350+ }
351+ sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
352+ if sessionID == "" {
353+ continue
354+ }
355+ if _, err := os.Stat(shutdownMarkerPath(lockDir, sessionID)); err == nil {
356+ continue
357+ } else if !os.IsNotExist(err) {
358+ return abruptSessions, err
359+ }
360+ if _, err := os.Stat(abruptMarkerPath(lockDir, sessionID)); err == nil {
361+ continue
362+ } else if !os.IsNotExist(err) {
363+ return abruptSessions, err
364+ }
365+
366+ payload := []byte(fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath))
367+ if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), payload, markerFilePerm); err != nil {
368+ return abruptSessions, err
369+ }
370+ abruptSessions = append(abruptSessions, sessionID)
371+ }
372+
373+ return abruptSessions, nil
374+}
375+
266 // runSessionCleanupAsync performs stale session cleanup and old session pruning376 // runSessionCleanupAsync performs stale session cleanup and old session pruning
267 // in a background goroutine. This avoids blocking startup for non-critical tasks.377 // in a background goroutine. This avoids blocking startup for non-critical tasks.
268 // Uses context.Background() to ensure cleanup completes even if startup context is canceled.378 // Uses context.Background() to ensure cleanup completes even if startup context is canceled.
A internal/bootstrap/session_markers_test.go +65 -0
@@ -0,0 +1,65 @@
1+package bootstrap
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) {
14+ lockDir := t.TempDir()
15+ sessionID := "s123"
16+ startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
17+ endedAt := startedAt.Add(3 * time.Minute)
18+
19+ err := writeStartupMarker(lockDir, sessionID, startedAt)
20+ require.NoError(t, err)
21+
22+ startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
23+ require.NoError(t, err)
24+ assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano))
25+
26+ err = writeShutdownMarker(lockDir, sessionID, endedAt)
27+ require.NoError(t, err)
28+
29+ shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID))
30+ require.NoError(t, err)
31+ assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano))
32+}
33+
34+func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
35+ lockDir := t.TempDir()
36+ detectedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
37+
38+ require.NoError(t, writeStartupMarker(lockDir, "abrupt-a", detectedAt.Add(-5*time.Minute)))
39+ require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute)))
40+ require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute)))
41+
42+ abruptSessions, err := markAbruptExits(lockDir, detectedAt)
43+ require.NoError(t, err)
44+ assert.Equal(t, []string{"abrupt-a"}, abruptSessions)
45+
46+ abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a"))
47+ require.NoError(t, err)
48+ assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano))
49+ assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker"))
50+}
51+
52+func TestSessionMarkers_MarkAbruptExitsIdempotent(t *testing.T) {
53+ lockDir := t.TempDir()
54+ detectedAt := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
55+
56+ require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute)))
57+
58+ first, err := markAbruptExits(lockDir, detectedAt)
59+ require.NoError(t, err)
60+ assert.Equal(t, []string{"abrupt-c"}, first)
61+
62+ second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute))
63+ require.NoError(t, err)
64+ assert.Empty(t, second)
65+}
@@ -0,0 +1,65 @@
1+package bootstrap
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestSessionMarkers_WriteStartupAndShutdown(t *testing.T) {
14+ lockDir := t.TempDir()
15+ sessionID := "s123"
16+ startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
17+ endedAt := startedAt.Add(3 * time.Minute)
18+
19+ err := writeStartupMarker(lockDir, sessionID, startedAt)
20+ require.NoError(t, err)
21+
22+ startupContent, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
23+ require.NoError(t, err)
24+ assert.Contains(t, string(startupContent), startedAt.Format(time.RFC3339Nano))
25+
26+ err = writeShutdownMarker(lockDir, sessionID, endedAt)
27+ require.NoError(t, err)
28+
29+ shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID))
30+ require.NoError(t, err)
31+ assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano))
32+}
33+
34+func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
35+ lockDir := t.TempDir()
36+ detectedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
37+
38+ require.NoError(t, writeStartupMarker(lockDir, "abrupt-a", detectedAt.Add(-5*time.Minute)))
39+ require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute)))
40+ require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute)))
41+
42+ abruptSessions, err := markAbruptExits(lockDir, detectedAt)
43+ require.NoError(t, err)
44+ assert.Equal(t, []string{"abrupt-a"}, abruptSessions)
45+
46+ abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a"))
47+ require.NoError(t, err)
48+ assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano))
49+ assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker"))
50+}
51+
52+func TestSessionMarkers_MarkAbruptExitsIdempotent(t *testing.T) {
53+ lockDir := t.TempDir()
54+ detectedAt := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
55+
56+ require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute)))
57+
58+ first, err := markAbruptExits(lockDir, detectedAt)
59+ require.NoError(t, err)
60+ assert.Equal(t, []string{"abrupt-c"}, first)
61+
62+ second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute))
63+ require.NoError(t, err)
64+ assert.Empty(t, second)
65+}
A internal/infrastructure/config/defaults_test.go +13 -0
@@ -0,0 +1,13 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestDefaultConfig_RuntimeLoggingProfile(t *testing.T) {
10+ cfg := DefaultConfig()
11+ assert.Equal(t, "info", cfg.Logging.Level)
12+ assert.False(t, cfg.Logging.CaptureGTKLogs)
13+}
@@ -0,0 +1,13 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestDefaultConfig_RuntimeLoggingProfile(t *testing.T) {
10+ cfg := DefaultConfig()
11+ assert.Equal(t, "info", cfg.Logging.Level)
12+ assert.False(t, cfg.Logging.CaptureGTKLogs)
13+}
M internal/infrastructure/webkit/messaging.go +4 -4
@@ -146,10 +146,10 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
146146
147147 // Connect to "script-message-received::dumber" with handler name as signal detail
148148 signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)
149- log.Debug().Uint32("signal_id", signalID).Str("handler", MessageHandlerName).Msg("signal connected with detail")
149+ log.Debug().Uint32("signal_id", uint32(signalID)).Str("handler", MessageHandlerName).Msg("signal connected with detail")
150150
151151 r.mu.Lock()
152- r.signals = append(r.signals, signalID)
152+ r.signals = append(r.signals, uint32(signalID))
153153 r.mu.Unlock()
154154
155155 log.Debug().Msg("SetupMessageHandler: registering handler in main world")
@@ -164,10 +164,10 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
164164 log.Info().
165165 Str("handler", MessageHandlerName).
166166 Str("world", "main").
167- Uint32("signal_id", signalID).
167+ Uint32("signal_id", uint32(signalID)).
168168 Msg("script message handler connected")
169169
170- return signalID, nil
170+ return uint32(signalID), nil
171171 }
172172
173173 // handleScriptMessage decodes the JSC value and routes it to the correct handler.
@@ -146,10 +146,10 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
146 146
147 // Connect to "script-message-received::dumber" with handler name as signal detail147 // Connect to "script-message-received::dumber" with handler name as signal detail
148 signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)148 signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)
149- log.Debug().Uint32("signal_id", signalID).Str("handler", MessageHandlerName).Msg("signal connected with detail")149+ log.Debug().Uint32("signal_id", uint32(signalID)).Str("handler", MessageHandlerName).Msg("signal connected with detail")
150 150
151 r.mu.Lock()151 r.mu.Lock()
152- r.signals = append(r.signals, signalID)152+ r.signals = append(r.signals, uint32(signalID))
153 r.mu.Unlock()153 r.mu.Unlock()
154 154
155 log.Debug().Msg("SetupMessageHandler: registering handler in main world")155 log.Debug().Msg("SetupMessageHandler: registering handler in main world")
@@ -164,10 +164,10 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st
164 log.Info().164 log.Info().
165 Str("handler", MessageHandlerName).165 Str("handler", MessageHandlerName).
166 Str("world", "main").166 Str("world", "main").
167- Uint32("signal_id", signalID).167+ Uint32("signal_id", uint32(signalID)).
168 Msg("script message handler connected")168 Msg("script message handler connected")
169 169
170- return signalID, nil170+ return uint32(signalID), nil
171 }171 }
172 172
173 // handleScriptMessage decodes the JSC value and routes it to the correct handler.173 // handleScriptMessage decodes the JSC value and routes it to the correct handler.
M internal/infrastructure/webkit/webview.go +68 -33
@@ -138,19 +138,20 @@ type WebView struct {
138138 signalIDs []uint32
139139
140140 // Callbacks (set by UI layer)
141- OnLoadChanged func(LoadEvent)
142- OnTitleChanged func(string)
143- OnURIChanged func(string)
144- OnProgressChanged func(float64)
145- OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
146- OnClose func()
147- OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
148- OnReadyToShow func() // Called when popup is ready to display
149- OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
150- OnEnterFullscreen func() bool // Return true to prevent fullscreen
151- OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
152- OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
153- OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
141+ OnLoadChanged func(LoadEvent)
142+ OnTitleChanged func(string)
143+ OnURIChanged func(string)
144+ OnProgressChanged func(float64)
145+ OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
146+ OnClose func()
147+ OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
148+ OnReadyToShow func() // Called when popup is ready to display
149+ OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
150+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
151+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
152+ OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
153+ OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
154+ OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
154155
155156 logger zerolog.Logger
156157 mu sync.RWMutex
@@ -241,7 +242,7 @@ func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint
241242 cb := func(_ webkit.FindController, matchCount uint) {
242243 callback(matchCount)
243244 }
244- return a.fc.ConnectFoundText(&cb)
245+ return uint32(a.fc.ConnectFoundText(&cb))
245246 }
246247
247248 func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
@@ -251,7 +252,7 @@ func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
251252 cb := func(_ webkit.FindController) {
252253 callback()
253254 }
254- return a.fc.ConnectFailedToFindText(&cb)
255+ return uint32(a.fc.ConnectFailedToFindText(&cb))
255256 }
256257
257258 func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 {
@@ -261,7 +262,7 @@ func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint))
261262 cb := func(_ webkit.FindController, matchCount uint) {
262263 callback(matchCount)
263264 }
264- return a.fc.ConnectCountedMatches(&cb)
265+ return uint32(a.fc.ConnectCountedMatches(&cb))
265266 }
266267
267268 func (a *findControllerAdapter) DisconnectSignal(id uint32) {
@@ -269,7 +270,7 @@ func (a *findControllerAdapter) DisconnectSignal(id uint32) {
269270 return
270271 }
271272 obj := gobject.ObjectNewFromInternalPtr(a.fc.GoPointer())
272- gobject.SignalHandlerDisconnect(obj, id)
273+ gobject.SignalHandlerDisconnect(obj, uint(id))
273274 }
274275
275276 // NewWebView creates a new WebView with the given context and settings.
@@ -388,6 +389,7 @@ func (wv *WebView) connectSignals() {
388389 wv.connectAudioStateSignal()
389390 wv.connectMouseTargetChangedSignal()
390391 wv.connectBackForwardListChangedSignal()
392+ wv.connectWebProcessTerminatedSignal()
391393 }
392394
393395 func (wv *WebView) connectLoadChangedSignal() {
@@ -421,7 +423,7 @@ func (wv *WebView) connectLoadChangedSignal() {
421423 }
422424 }
423425 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
424- wv.signalIDs = append(wv.signalIDs, sigID)
426+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
425427 }
426428
427429 func (wv *WebView) connectCloseSignal() {
@@ -431,7 +433,7 @@ func (wv *WebView) connectCloseSignal() {
431433 }
432434 }
433435 sigID := wv.inner.ConnectClose(&closeCb)
434- wv.signalIDs = append(wv.signalIDs, sigID)
436+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
435437 }
436438
437439 func (wv *WebView) connectCreateSignal() {
@@ -473,7 +475,7 @@ func (wv *WebView) connectCreateSignal() {
473475 return newWV.inner.Widget
474476 }
475477 sigID := wv.inner.ConnectCreate(&createCb)
476- wv.signalIDs = append(wv.signalIDs, sigID)
478+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
477479 }
478480
479481 func (wv *WebView) connectReadyToShowSignal() {
@@ -483,7 +485,7 @@ func (wv *WebView) connectReadyToShowSignal() {
483485 }
484486 }
485487 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
486- wv.signalIDs = append(wv.signalIDs, sigID)
488+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
487489 }
488490
489491 func (wv *WebView) connectTitleSignal() {
@@ -498,7 +500,7 @@ func (wv *WebView) connectTitleSignal() {
498500 }
499501 }
500502 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
501- wv.signalIDs = append(wv.signalIDs, sigID)
503+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
502504 }
503505
504506 func (wv *WebView) connectURISignal() {
@@ -514,7 +516,7 @@ func (wv *WebView) connectURISignal() {
514516 }
515517 }
516518 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
517- wv.signalIDs = append(wv.signalIDs, sigID)
519+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
518520 }
519521
520522 func (wv *WebView) connectFaviconSignal() {
@@ -525,7 +527,7 @@ func (wv *WebView) connectFaviconSignal() {
525527 }
526528 }
527529 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))
528- wv.signalIDs = append(wv.signalIDs, sigID)
530+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
529531 }
530532
531533 // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
@@ -555,7 +557,7 @@ func (wv *WebView) connectProgressSignal() {
555557 }
556558 }
557559 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb))
558- wv.signalIDs = append(wv.signalIDs, sigID)
560+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
559561 }
560562
561563 func (wv *WebView) connectDecidePolicySignal() {
@@ -571,7 +573,7 @@ func (wv *WebView) connectDecidePolicySignal() {
571573 }
572574 }
573575 sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
574- wv.signalIDs = append(wv.signalIDs, sigID)
576+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
575577 }
576578
577579 // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
@@ -770,7 +772,7 @@ func (wv *WebView) connectEnterFullscreenSignal() {
770772 return false // Allow fullscreen
771773 }
772774 sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
773- wv.signalIDs = append(wv.signalIDs, sigID)
775+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
774776 }
775777
776778 func (wv *WebView) connectLeaveFullscreenSignal() {
@@ -783,7 +785,7 @@ func (wv *WebView) connectLeaveFullscreenSignal() {
783785 return false // Allow leaving fullscreen
784786 }
785787 sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
786- wv.signalIDs = append(wv.signalIDs, sigID)
788+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
787789 }
788790
789791 func (wv *WebView) connectAudioStateSignal() {
@@ -801,7 +803,7 @@ func (wv *WebView) connectAudioStateSignal() {
801803 }
802804 }
803805 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))
804- wv.signalIDs = append(wv.signalIDs, sigID)
806+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
805807 }
806808
807809 func (wv *WebView) connectMouseTargetChangedSignal() {
@@ -829,7 +831,7 @@ func (wv *WebView) connectMouseTargetChangedSignal() {
829831 wv.OnLinkHover(uri)
830832 }
831833 sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)
832- wv.signalIDs = append(wv.signalIDs, sigID)
834+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
833835 }
834836
835837 func (wv *WebView) connectBackForwardListChangedSignal() {
@@ -857,7 +859,39 @@ func (wv *WebView) connectBackForwardListChangedSignal() {
857859 }
858860 sigID := backForwardList.ConnectChanged(&changedCb)
859861 wv.backForwardList = backForwardList
860- wv.backForwardListSignalID = sigID
862+ wv.backForwardListSignalID = uint32(sigID)
863+}
864+
865+func webProcessTerminationReasonString(reason webkit.WebProcessTerminationReason) string {
866+ switch reason {
867+ case webkit.WebProcessCrashedValue:
868+ return "crashed"
869+ case webkit.WebProcessExceededMemoryLimitValue:
870+ return "exceeded_memory"
871+ case webkit.WebProcessTerminatedByApiValue:
872+ return "terminated_by_api"
873+ default:
874+ return "unknown"
875+ }
876+}
877+
878+func (wv *WebView) connectWebProcessTerminatedSignal() {
879+ terminatedCb := func(_ webkit.WebView, reason webkit.WebProcessTerminationReason) {
880+ uri := wv.URI()
881+ reasonLabel := webProcessTerminationReasonString(reason)
882+ wv.logger.Warn().
883+ Uint64("id", uint64(wv.id)).
884+ Str("reason", reasonLabel).
885+ Int("reason_code", int(reason)).
886+ Str("uri", uri).
887+ Msg("web process terminated")
888+
889+ if wv.OnWebProcessTerminated != nil {
890+ wv.OnWebProcessTerminated(reason, reasonLabel, uri)
891+ }
892+ }
893+ sigID := wv.inner.ConnectWebProcessTerminated(&terminatedCb)
894+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
861895 }
862896
863897 // ID returns the unique identifier for this WebView.
@@ -1236,13 +1270,13 @@ func (wv *WebView) DisconnectSignals() {
12361270
12371271 obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())
12381272 for _, sigID := range wv.signalIDs {
1239- gobject.SignalHandlerDisconnect(obj, sigID)
1273+ gobject.SignalHandlerDisconnect(obj, uint(sigID))
12401274 }
12411275 wv.signalIDs = wv.signalIDs[:0] // Clear the slice
12421276
12431277 if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
12441278 bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
1245- gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID)
1279+ gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID))
12461280 wv.backForwardListSignalID = 0
12471281 wv.backForwardList = nil
12481282 }
@@ -1275,6 +1309,7 @@ func (wv *WebView) Destroy() {
12751309 wv.OnLeaveFullscreen = nil
12761310 wv.OnAudioStateChanged = nil
12771311 wv.OnLinkHover = nil
1312+ wv.OnWebProcessTerminated = nil
12781313
12791314 // 3. Clear async callback references
12801315 wv.mu.Lock()
@@ -138,19 +138,20 @@ type WebView struct {
138 signalIDs []uint32138 signalIDs []uint32
139 139
140 // Callbacks (set by UI layer)140 // Callbacks (set by UI layer)
141- OnLoadChanged func(LoadEvent)141+ OnLoadChanged func(LoadEvent)
142- OnTitleChanged func(string)142+ OnTitleChanged func(string)
143- OnURIChanged func(string)143+ OnURIChanged func(string)
144- OnProgressChanged func(float64)144+ OnProgressChanged func(float64)
145- OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes145+ OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
146- OnClose func()146+ OnClose func()
147- OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup147+ OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
148- OnReadyToShow func() // Called when popup is ready to display148+ OnReadyToShow func() // Called when popup is ready to display
149- OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)149+ OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
150- OnEnterFullscreen func() bool // Return true to prevent fullscreen150+ OnEnterFullscreen func() bool // Return true to prevent fullscreen
151- OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen151+ OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
152- OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops152+ OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
153- OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)153+ OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
154+ OnWebProcessTerminated func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
154 155
155 logger zerolog.Logger156 logger zerolog.Logger
156 mu sync.RWMutex157 mu sync.RWMutex
@@ -241,7 +242,7 @@ func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint
241 cb := func(_ webkit.FindController, matchCount uint) {242 cb := func(_ webkit.FindController, matchCount uint) {
242 callback(matchCount)243 callback(matchCount)
243 }244 }
244- return a.fc.ConnectFoundText(&cb)245+ return uint32(a.fc.ConnectFoundText(&cb))
245 }246 }
246 247
247 func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {248 func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
@@ -251,7 +252,7 @@ func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
251 cb := func(_ webkit.FindController) {252 cb := func(_ webkit.FindController) {
252 callback()253 callback()
253 }254 }
254- return a.fc.ConnectFailedToFindText(&cb)255+ return uint32(a.fc.ConnectFailedToFindText(&cb))
255 }256 }
256 257
257 func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 {258 func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 {
@@ -261,7 +262,7 @@ func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint))
261 cb := func(_ webkit.FindController, matchCount uint) {262 cb := func(_ webkit.FindController, matchCount uint) {
262 callback(matchCount)263 callback(matchCount)
263 }264 }
264- return a.fc.ConnectCountedMatches(&cb)265+ return uint32(a.fc.ConnectCountedMatches(&cb))
265 }266 }
266 267
267 func (a *findControllerAdapter) DisconnectSignal(id uint32) {268 func (a *findControllerAdapter) DisconnectSignal(id uint32) {
@@ -269,7 +270,7 @@ func (a *findControllerAdapter) DisconnectSignal(id uint32) {
269 return270 return
270 }271 }
271 obj := gobject.ObjectNewFromInternalPtr(a.fc.GoPointer())272 obj := gobject.ObjectNewFromInternalPtr(a.fc.GoPointer())
272- gobject.SignalHandlerDisconnect(obj, id)273+ gobject.SignalHandlerDisconnect(obj, uint(id))
273 }274 }
274 275
275 // NewWebView creates a new WebView with the given context and settings.276 // NewWebView creates a new WebView with the given context and settings.
@@ -388,6 +389,7 @@ func (wv *WebView) connectSignals() {
388 wv.connectAudioStateSignal()389 wv.connectAudioStateSignal()
389 wv.connectMouseTargetChangedSignal()390 wv.connectMouseTargetChangedSignal()
390 wv.connectBackForwardListChangedSignal()391 wv.connectBackForwardListChangedSignal()
392+ wv.connectWebProcessTerminatedSignal()
391 }393 }
392 394
393 func (wv *WebView) connectLoadChangedSignal() {395 func (wv *WebView) connectLoadChangedSignal() {
@@ -421,7 +423,7 @@ func (wv *WebView) connectLoadChangedSignal() {
421 }423 }
422 }424 }
423 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)425 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
424- wv.signalIDs = append(wv.signalIDs, sigID)426+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
425 }427 }
426 428
427 func (wv *WebView) connectCloseSignal() {429 func (wv *WebView) connectCloseSignal() {
@@ -431,7 +433,7 @@ func (wv *WebView) connectCloseSignal() {
431 }433 }
432 }434 }
433 sigID := wv.inner.ConnectClose(&closeCb)435 sigID := wv.inner.ConnectClose(&closeCb)
434- wv.signalIDs = append(wv.signalIDs, sigID)436+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
435 }437 }
436 438
437 func (wv *WebView) connectCreateSignal() {439 func (wv *WebView) connectCreateSignal() {
@@ -473,7 +475,7 @@ func (wv *WebView) connectCreateSignal() {
473 return newWV.inner.Widget475 return newWV.inner.Widget
474 }476 }
475 sigID := wv.inner.ConnectCreate(&createCb)477 sigID := wv.inner.ConnectCreate(&createCb)
476- wv.signalIDs = append(wv.signalIDs, sigID)478+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
477 }479 }
478 480
479 func (wv *WebView) connectReadyToShowSignal() {481 func (wv *WebView) connectReadyToShowSignal() {
@@ -483,7 +485,7 @@ func (wv *WebView) connectReadyToShowSignal() {
483 }485 }
484 }486 }
485 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)487 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
486- wv.signalIDs = append(wv.signalIDs, sigID)488+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
487 }489 }
488 490
489 func (wv *WebView) connectTitleSignal() {491 func (wv *WebView) connectTitleSignal() {
@@ -498,7 +500,7 @@ func (wv *WebView) connectTitleSignal() {
498 }500 }
499 }501 }
500 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))502 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
501- wv.signalIDs = append(wv.signalIDs, sigID)503+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
502 }504 }
503 505
504 func (wv *WebView) connectURISignal() {506 func (wv *WebView) connectURISignal() {
@@ -514,7 +516,7 @@ func (wv *WebView) connectURISignal() {
514 }516 }
515 }517 }
516 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))518 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
517- wv.signalIDs = append(wv.signalIDs, sigID)519+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
518 }520 }
519 521
520 func (wv *WebView) connectFaviconSignal() {522 func (wv *WebView) connectFaviconSignal() {
@@ -525,7 +527,7 @@ func (wv *WebView) connectFaviconSignal() {
525 }527 }
526 }528 }
527 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))529 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))
528- wv.signalIDs = append(wv.signalIDs, sigID)530+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
529 }531 }
530 532
531 // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.533 // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
@@ -555,7 +557,7 @@ func (wv *WebView) connectProgressSignal() {
555 }557 }
556 }558 }
557 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb))559 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb))
558- wv.signalIDs = append(wv.signalIDs, sigID)560+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
559 }561 }
560 562
561 func (wv *WebView) connectDecidePolicySignal() {563 func (wv *WebView) connectDecidePolicySignal() {
@@ -571,7 +573,7 @@ func (wv *WebView) connectDecidePolicySignal() {
571 }573 }
572 }574 }
573 sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)575 sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
574- wv.signalIDs = append(wv.signalIDs, sigID)576+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
575 }577 }
576 578
577 // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).579 // handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
@@ -770,7 +772,7 @@ func (wv *WebView) connectEnterFullscreenSignal() {
770 return false // Allow fullscreen772 return false // Allow fullscreen
771 }773 }
772 sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)774 sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
773- wv.signalIDs = append(wv.signalIDs, sigID)775+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
774 }776 }
775 777
776 func (wv *WebView) connectLeaveFullscreenSignal() {778 func (wv *WebView) connectLeaveFullscreenSignal() {
@@ -783,7 +785,7 @@ func (wv *WebView) connectLeaveFullscreenSignal() {
783 return false // Allow leaving fullscreen785 return false // Allow leaving fullscreen
784 }786 }
785 sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)787 sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
786- wv.signalIDs = append(wv.signalIDs, sigID)788+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
787 }789 }
788 790
789 func (wv *WebView) connectAudioStateSignal() {791 func (wv *WebView) connectAudioStateSignal() {
@@ -801,7 +803,7 @@ func (wv *WebView) connectAudioStateSignal() {
801 }803 }
802 }804 }
803 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))805 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))
804- wv.signalIDs = append(wv.signalIDs, sigID)806+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
805 }807 }
806 808
807 func (wv *WebView) connectMouseTargetChangedSignal() {809 func (wv *WebView) connectMouseTargetChangedSignal() {
@@ -829,7 +831,7 @@ func (wv *WebView) connectMouseTargetChangedSignal() {
829 wv.OnLinkHover(uri)831 wv.OnLinkHover(uri)
830 }832 }
831 sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)833 sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)
832- wv.signalIDs = append(wv.signalIDs, sigID)834+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
833 }835 }
834 836
835 func (wv *WebView) connectBackForwardListChangedSignal() {837 func (wv *WebView) connectBackForwardListChangedSignal() {
@@ -857,7 +859,39 @@ func (wv *WebView) connectBackForwardListChangedSignal() {
857 }859 }
858 sigID := backForwardList.ConnectChanged(&changedCb)860 sigID := backForwardList.ConnectChanged(&changedCb)
859 wv.backForwardList = backForwardList861 wv.backForwardList = backForwardList
860- wv.backForwardListSignalID = sigID862+ wv.backForwardListSignalID = uint32(sigID)
863+}
864+
865+func webProcessTerminationReasonString(reason webkit.WebProcessTerminationReason) string {
866+ switch reason {
867+ case webkit.WebProcessCrashedValue:
868+ return "crashed"
869+ case webkit.WebProcessExceededMemoryLimitValue:
870+ return "exceeded_memory"
871+ case webkit.WebProcessTerminatedByApiValue:
872+ return "terminated_by_api"
873+ default:
874+ return "unknown"
875+ }
876+}
877+
878+func (wv *WebView) connectWebProcessTerminatedSignal() {
879+ terminatedCb := func(_ webkit.WebView, reason webkit.WebProcessTerminationReason) {
880+ uri := wv.URI()
881+ reasonLabel := webProcessTerminationReasonString(reason)
882+ wv.logger.Warn().
883+ Uint64("id", uint64(wv.id)).
884+ Str("reason", reasonLabel).
885+ Int("reason_code", int(reason)).
886+ Str("uri", uri).
887+ Msg("web process terminated")
888+
889+ if wv.OnWebProcessTerminated != nil {
890+ wv.OnWebProcessTerminated(reason, reasonLabel, uri)
891+ }
892+ }
893+ sigID := wv.inner.ConnectWebProcessTerminated(&terminatedCb)
894+ wv.signalIDs = append(wv.signalIDs, uint32(sigID))
861 }895 }
862 896
863 // ID returns the unique identifier for this WebView.897 // ID returns the unique identifier for this WebView.
@@ -1236,13 +1270,13 @@ func (wv *WebView) DisconnectSignals() {
1236 1270
1237 obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())1271 obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())
1238 for _, sigID := range wv.signalIDs {1272 for _, sigID := range wv.signalIDs {
1239- gobject.SignalHandlerDisconnect(obj, sigID)1273+ gobject.SignalHandlerDisconnect(obj, uint(sigID))
1240 }1274 }
1241 wv.signalIDs = wv.signalIDs[:0] // Clear the slice1275 wv.signalIDs = wv.signalIDs[:0] // Clear the slice
1242 1276
1243 if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {1277 if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
1244 bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())1278 bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
1245- gobject.SignalHandlerDisconnect(bfObj, wv.backForwardListSignalID)1279+ gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID))
1246 wv.backForwardListSignalID = 01280 wv.backForwardListSignalID = 0
1247 wv.backForwardList = nil1281 wv.backForwardList = nil
1248 }1282 }
@@ -1275,6 +1309,7 @@ func (wv *WebView) Destroy() {
1275 wv.OnLeaveFullscreen = nil1309 wv.OnLeaveFullscreen = nil
1276 wv.OnAudioStateChanged = nil1310 wv.OnAudioStateChanged = nil
1277 wv.OnLinkHover = nil1311 wv.OnLinkHover = nil
1312+ wv.OnWebProcessTerminated = nil
1278 1313
1279 // 3. Clear async callback references1314 // 3. Clear async callback references
1280 wv.mu.Lock()1315 wv.mu.Lock()
A internal/infrastructure/webkit/webview_termination_test.go +15 -0
@@ -0,0 +1,15 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ webkitlib "github.com/bnema/puregotk-webkit/webkit"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestWebProcessTerminationReasonString(t *testing.T) {
11+ assert.Equal(t, "crashed", webProcessTerminationReasonString(webkitlib.WebProcessCrashedValue))
12+ assert.Equal(t, "exceeded_memory", webProcessTerminationReasonString(webkitlib.WebProcessExceededMemoryLimitValue))
13+ assert.Equal(t, "terminated_by_api", webProcessTerminationReasonString(webkitlib.WebProcessTerminatedByApiValue))
14+ assert.Equal(t, "unknown", webProcessTerminationReasonString(webkitlib.WebProcessTerminationReason(99)))
15+}
@@ -0,0 +1,15 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ webkitlib "github.com/bnema/puregotk-webkit/webkit"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestWebProcessTerminationReasonString(t *testing.T) {
11+ assert.Equal(t, "crashed", webProcessTerminationReasonString(webkitlib.WebProcessCrashedValue))
12+ assert.Equal(t, "exceeded_memory", webProcessTerminationReasonString(webkitlib.WebProcessExceededMemoryLimitValue))
13+ assert.Equal(t, "terminated_by_api", webProcessTerminationReasonString(webkitlib.WebProcessTerminatedByApiValue))
14+ assert.Equal(t, "unknown", webProcessTerminationReasonString(webkitlib.WebProcessTerminationReason(99)))
15+}
M internal/ui/layout/gtk_widgets.go +3 -3
@@ -163,14 +163,14 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 {
163163 cb := func(_ gtk.Widget) {
164164 callback()
165165 }
166- return p.inner.ConnectMap(&cb)
166+ return uint32(p.inner.ConnectMap(&cb))
167167 }
168168
169169 func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 {
170170 cb := func(_ gobject.Object, _ uintptr) {
171171 callback()
172172 }
173- return p.inner.ConnectNotifyWithDetail("position", &cb)
173+ return uint32(p.inner.ConnectNotifyWithDetail("position", &cb))
174174 }
175175
176176 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
@@ -543,7 +543,7 @@ func (btn *gtkButton) ConnectClicked(callback func()) uint32 {
543543 cb := func(_ gtk.Button) {
544544 callback()
545545 }
546- return btn.inner.ConnectClicked(&cb)
546+ return uint32(btn.inner.ConnectClicked(&cb))
547547 }
548548
549549 func (btn *gtkButton) GetAllocatedWidth() int { return btn.inner.GetAllocatedWidth() }
@@ -163,14 +163,14 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 {
163 cb := func(_ gtk.Widget) {163 cb := func(_ gtk.Widget) {
164 callback()164 callback()
165 }165 }
166- return p.inner.ConnectMap(&cb)166+ return uint32(p.inner.ConnectMap(&cb))
167 }167 }
168 168
169 func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 {169 func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 {
170 cb := func(_ gobject.Object, _ uintptr) {170 cb := func(_ gobject.Object, _ uintptr) {
171 callback()171 callback()
172 }172 }
173- return p.inner.ConnectNotifyWithDetail("position", &cb)173+ return uint32(p.inner.ConnectNotifyWithDetail("position", &cb))
174 }174 }
175 175
176 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {176 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
@@ -543,7 +543,7 @@ func (btn *gtkButton) ConnectClicked(callback func()) uint32 {
543 cb := func(_ gtk.Button) {543 cb := func(_ gtk.Button) {
544 callback()544 callback()
545 }545 }
546- return btn.inner.ConnectClicked(&cb)546+ return uint32(btn.inner.ConnectClicked(&cb))
547 }547 }
548 548
549 func (btn *gtkButton) GetAllocatedWidth() int { return btn.inner.GetAllocatedWidth() }549 func (btn *gtkButton) GetAllocatedWidth() int { return btn.inner.GetAllocatedWidth() }
M internal/ui/layout/stacked.go +1 -1
@@ -275,7 +275,7 @@ func disconnectButtonSignal(btn ButtonWidget, signalID uint32) {
275275 }
276276
277277 obj := gobject.ObjectNewFromInternalPtr(ptr)
278- gobject.SignalHandlerDisconnect(obj, signalID)
278+ gobject.SignalHandlerDisconnect(obj, uint(signalID))
279279 }
280280
281281 // InsertPaneAfter inserts a new pane after the specified index position.
@@ -275,7 +275,7 @@ func disconnectButtonSignal(btn ButtonWidget, signalID uint32) {
275 }275 }
276 276
277 obj := gobject.ObjectNewFromInternalPtr(ptr)277 obj := gobject.ObjectNewFromInternalPtr(ptr)
278- gobject.SignalHandlerDisconnect(obj, signalID)278+ gobject.SignalHandlerDisconnect(obj, uint(signalID))
279 }279 }
280 280
281 // InsertPaneAfter inserts a new pane after the specified index position.281 // InsertPaneAfter inserts a new pane after the specified index position.

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