detached faa8558

feat: implement phase3 tier2 snapshot race downloader hardening and exit classification

commit
faa8558cdbd41631137f1434247e1dd03bc76d82
author
brice · · 2026-02-07 05:37 UTC
parent
00ab191
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +799 -16
M docs/reference/index.md +1 -0
@@ -6,6 +6,7 @@
66 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu
77 - [Keybindings](./keybindings.md) - All keyboard shortcuts
88 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
9+- [Session Exit Classification Runbook](./session-exit-classification-runbook.md) - Marker-based exit classification and optional system-log correlation
910
1011 ## Related
1112
@@ -6,6 +6,7 @@
6 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu6 - [Dependencies](./dependencies.md) - System dependencies for Arch and Debian/Ubuntu
7 - [Keybindings](./keybindings.md) - All keyboard shortcuts7 - [Keybindings](./keybindings.md) - All keyboard shortcuts
8 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions8 - [Troubleshooting](./troubleshooting.md) - Common issues and solutions
9+- [Session Exit Classification Runbook](./session-exit-classification-runbook.md) - Marker-based exit classification and optional system-log correlation
9 10
10 ## Related11 ## Related
11 12
A docs/reference/session-exit-classification-runbook.md +92 -0
@@ -0,0 +1,92 @@
1+# Session Exit Classification Runbook
2+
3+This runbook classifies browser sessions using existing P0 marker files in the log directory:
4+
5+- `session_<id>.startup.marker`
6+- `session_<id>.shutdown.marker`
7+- `session_<id>.abrupt.marker`
8+
9+Marker directory (default):
10+
11+```bash
12+LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
13+```
14+
15+## Classification Model
16+
17+- `clean_exit` (`marker-confirmed`)
18+ - `shutdown.marker` is present.
19+- `main_process_crash_or_abrupt` (`marker-confirmed`)
20+ - `abrupt.marker` is present and no `shutdown.marker` exists.
21+- `external_kill_or_oom_inferred` (`best-effort`)
22+ - `startup.marker` exists, but `shutdown.marker` and `abrupt.marker` are both missing.
23+ - This is explicitly inferred, not confirmed.
24+
25+Implementation path in code:
26+
27+- `internal/bootstrap/session_exit_classification.go`
28+ - `ClassifySessionExitFromMarkers(lockDir, sessionID)`
29+ - `BuildSessionExitReport(lockDir)`
30+
31+## Quick Marker Report (Shell)
32+
33+```bash
34+LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
35+
36+for startup in "$LOG_DIR"/session_*.startup.marker; do
37+ [ -e "$startup" ] || continue
38+ base="$(basename "$startup")"
39+ sid="${base#session_}"
40+ sid="${sid%.startup.marker}"
41+ shutdown="$LOG_DIR/session_${sid}.shutdown.marker"
42+ abrupt="$LOG_DIR/session_${sid}.abrupt.marker"
43+
44+ if [ -f "$shutdown" ]; then
45+ class="clean_exit"
46+ inf="marker-confirmed"
47+ elif [ -f "$abrupt" ]; then
48+ class="main_process_crash_or_abrupt"
49+ inf="marker-confirmed"
50+ else
51+ class="external_kill_or_oom_inferred"
52+ inf="best-effort"
53+ fi
54+
55+ printf "%s\t%s\t%s\n" "$sid" "$class" "$inf"
56+done | sort
57+```
58+
59+## Optional System-Log Correlation
60+
61+Use this only when `external_kill_or_oom_inferred` needs more evidence.
62+
63+1. Get the startup timestamp for a session:
64+
65+```bash
66+SID="<session-id>"
67+LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
68+cat "$LOG_DIR/session_${SID}.startup.marker"
69+```
70+
71+2. Inspect user journal around that window:
72+
73+```bash
74+journalctl --user --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "dumber|killed|oom|sigkill|signal"
75+```
76+
77+3. Inspect kernel/system OOM evidence:
78+
79+```bash
80+journalctl -k --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "oom|out of memory|killed process"
81+```
82+
83+4. Optional coredump check:
84+
85+```bash
86+coredumpctl list | rg -i "dumber"
87+```
88+
89+Interpretation guidance:
90+
91+- OOM/killed lines near the startup window strengthen `external_kill_or_oom_inferred`.
92+- No OOM evidence does not prove a clean exit; keep inference label explicit.
@@ -0,0 +1,92 @@
1+# Session Exit Classification Runbook
2+
3+This runbook classifies browser sessions using existing P0 marker files in the log directory:
4+
5+- `session_<id>.startup.marker`
6+- `session_<id>.shutdown.marker`
7+- `session_<id>.abrupt.marker`
8+
9+Marker directory (default):
10+
11+```bash
12+LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
13+```
14+
15+## Classification Model
16+
17+- `clean_exit` (`marker-confirmed`)
18+ - `shutdown.marker` is present.
19+- `main_process_crash_or_abrupt` (`marker-confirmed`)
20+ - `abrupt.marker` is present and no `shutdown.marker` exists.
21+- `external_kill_or_oom_inferred` (`best-effort`)
22+ - `startup.marker` exists, but `shutdown.marker` and `abrupt.marker` are both missing.
23+ - This is explicitly inferred, not confirmed.
24+
25+Implementation path in code:
26+
27+- `internal/bootstrap/session_exit_classification.go`
28+ - `ClassifySessionExitFromMarkers(lockDir, sessionID)`
29+ - `BuildSessionExitReport(lockDir)`
30+
31+## Quick Marker Report (Shell)
32+
33+```bash
34+LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
35+
36+for startup in "$LOG_DIR"/session_*.startup.marker; do
37+ [ -e "$startup" ] || continue
38+ base="$(basename "$startup")"
39+ sid="${base#session_}"
40+ sid="${sid%.startup.marker}"
41+ shutdown="$LOG_DIR/session_${sid}.shutdown.marker"
42+ abrupt="$LOG_DIR/session_${sid}.abrupt.marker"
43+
44+ if [ -f "$shutdown" ]; then
45+ class="clean_exit"
46+ inf="marker-confirmed"
47+ elif [ -f "$abrupt" ]; then
48+ class="main_process_crash_or_abrupt"
49+ inf="marker-confirmed"
50+ else
51+ class="external_kill_or_oom_inferred"
52+ inf="best-effort"
53+ fi
54+
55+ printf "%s\t%s\t%s\n" "$sid" "$class" "$inf"
56+done | sort
57+```
58+
59+## Optional System-Log Correlation
60+
61+Use this only when `external_kill_or_oom_inferred` needs more evidence.
62+
63+1. Get the startup timestamp for a session:
64+
65+```bash
66+SID="<session-id>"
67+LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dumber/logs"
68+cat "$LOG_DIR/session_${SID}.startup.marker"
69+```
70+
71+2. Inspect user journal around that window:
72+
73+```bash
74+journalctl --user --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "dumber|killed|oom|sigkill|signal"
75+```
76+
77+3. Inspect kernel/system OOM evidence:
78+
79+```bash
80+journalctl -k --since "2026-02-07 10:00:00" --until "2026-02-07 10:10:00" | rg -i "oom|out of memory|killed process"
81+```
82+
83+4. Optional coredump check:
84+
85+```bash
86+coredumpctl list | rg -i "dumber"
87+```
88+
89+Interpretation guidance:
90+
91+- OOM/killed lines near the startup window strengthen `external_kill_or_oom_inferred`.
92+- No OOM evidence does not prove a clean exit; keep inference label explicit.
A internal/bootstrap/session_exit_classification.go +201 -0
@@ -0,0 +1,201 @@
1+package bootstrap
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "sort"
8+ "strings"
9+ "time"
10+)
11+
12+type SessionExitClass string
13+
14+const (
15+ SessionExitCleanExit SessionExitClass = "clean_exit"
16+ SessionExitMainProcessCrashOrAbrupt SessionExitClass = "main_process_crash_or_abrupt"
17+ SessionExitExternalKillOrOOMInferred SessionExitClass = "external_kill_or_oom_inferred"
18+ SessionExitUnknown SessionExitClass = "unknown"
19+)
20+
21+type SessionExitClassification struct {
22+ SessionID string
23+ Class SessionExitClass
24+ Inference string
25+ Reason string
26+ StartupAt *time.Time
27+ ShutdownAt *time.Time
28+ AbruptDetectedAt *time.Time
29+ LastMarkerObserved time.Time
30+}
31+
32+func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) {
33+ classification := SessionExitClassification{
34+ SessionID: sessionID,
35+ Class: SessionExitUnknown,
36+ Inference: "marker-missing",
37+ Reason: "no known marker files found for session",
38+ }
39+ if lockDir == "" || sessionID == "" {
40+ return classification, fmt.Errorf("lockDir and sessionID are required")
41+ }
42+
43+ startupAt, startupExists, startupModTime, err := readMarkerTime(startupMarkerPath(lockDir, sessionID), "")
44+ if err != nil {
45+ return classification, err
46+ }
47+ shutdownAt, shutdownExists, shutdownModTime, err := readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "")
48+ if err != nil {
49+ return classification, err
50+ }
51+ abruptAt, abruptExists, abruptModTime, err := readMarkerTime(abruptMarkerPath(lockDir, sessionID), "detected_at=")
52+ if err != nil {
53+ return classification, err
54+ }
55+
56+ classification.StartupAt = startupAt
57+ classification.ShutdownAt = shutdownAt
58+ classification.AbruptDetectedAt = abruptAt
59+ classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime)
60+
61+ switch {
62+ case shutdownExists:
63+ classification.Class = SessionExitCleanExit
64+ classification.Inference = "marker-confirmed"
65+ classification.Reason = "shutdown marker present"
66+ case abruptExists:
67+ classification.Class = SessionExitMainProcessCrashOrAbrupt
68+ classification.Inference = "marker-confirmed"
69+ classification.Reason = "abrupt marker present and no shutdown marker"
70+ case startupExists:
71+ classification.Class = SessionExitExternalKillOrOOMInferred
72+ classification.Inference = "best-effort"
73+ classification.Reason = "startup marker present without shutdown/abrupt markers"
74+ }
75+
76+ return classification, nil
77+}
78+
79+func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) {
80+ if lockDir == "" {
81+ return nil, nil
82+ }
83+
84+ sessionIDs, err := discoverSessionIDsFromMarkers(lockDir)
85+ if err != nil {
86+ return nil, err
87+ }
88+ if len(sessionIDs) == 0 {
89+ return nil, nil
90+ }
91+
92+ report := make([]SessionExitClassification, 0, len(sessionIDs))
93+ for _, sessionID := range sessionIDs {
94+ classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
95+ if classErr != nil {
96+ return nil, classErr
97+ }
98+ report = append(report, classification)
99+ }
100+
101+ sort.Slice(report, func(i, j int) bool {
102+ return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved)
103+ })
104+ return report, nil
105+}
106+
107+func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) {
108+ pattern := filepath.Join(lockDir, "session_*.marker")
109+ paths, err := filepath.Glob(pattern)
110+ if err != nil {
111+ return nil, err
112+ }
113+
114+ ids := make(map[string]struct{}, len(paths))
115+ for _, path := range paths {
116+ base := filepath.Base(path)
117+ sessionID, ok := markerSessionID(base)
118+ if !ok {
119+ continue
120+ }
121+ ids[sessionID] = struct{}{}
122+ }
123+
124+ sessionIDs := make([]string, 0, len(ids))
125+ for id := range ids {
126+ sessionIDs = append(sessionIDs, id)
127+ }
128+ sort.Strings(sessionIDs)
129+ return sessionIDs, nil
130+}
131+
132+func markerSessionID(base string) (string, bool) {
133+ if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") {
134+ return "", false
135+ }
136+
137+ trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker")
138+ switch {
139+ case strings.HasSuffix(trimmed, ".startup"):
140+ return strings.TrimSuffix(trimmed, ".startup"), true
141+ case strings.HasSuffix(trimmed, ".shutdown"):
142+ return strings.TrimSuffix(trimmed, ".shutdown"), true
143+ case strings.HasSuffix(trimmed, ".abrupt"):
144+ return strings.TrimSuffix(trimmed, ".abrupt"), true
145+ default:
146+ return "", false
147+ }
148+}
149+
150+func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) {
151+ stat, err := os.Stat(path)
152+ if err != nil {
153+ if os.IsNotExist(err) {
154+ return nil, false, time.Time{}, nil
155+ }
156+ return nil, false, time.Time{}, err
157+ }
158+
159+ content, err := os.ReadFile(path)
160+ if err != nil {
161+ return nil, true, stat.ModTime(), err
162+ }
163+
164+ raw := strings.TrimSpace(string(content))
165+ if raw == "" {
166+ return nil, true, stat.ModTime(), nil
167+ }
168+
169+ if keyPrefix != "" {
170+ lines := strings.Split(raw, "\n")
171+ for _, line := range lines {
172+ if strings.HasPrefix(line, keyPrefix) {
173+ parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix))
174+ if parseErr != nil {
175+ return nil, true, stat.ModTime(), nil
176+ }
177+ return &parsed, true, stat.ModTime(), nil
178+ }
179+ }
180+ return nil, true, stat.ModTime(), nil
181+ }
182+
183+ parsed, parseErr := time.Parse(time.RFC3339Nano, raw)
184+ if parseErr != nil {
185+ return nil, true, stat.ModTime(), nil
186+ }
187+ return &parsed, true, stat.ModTime(), nil
188+}
189+
190+func latestNonZero(values ...time.Time) time.Time {
191+ var latest time.Time
192+ for _, value := range values {
193+ if value.IsZero() {
194+ continue
195+ }
196+ if latest.IsZero() || value.After(latest) {
197+ latest = value
198+ }
199+ }
200+ return latest
201+}
@@ -0,0 +1,201 @@
1+package bootstrap
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "sort"
8+ "strings"
9+ "time"
10+)
11+
12+type SessionExitClass string
13+
14+const (
15+ SessionExitCleanExit SessionExitClass = "clean_exit"
16+ SessionExitMainProcessCrashOrAbrupt SessionExitClass = "main_process_crash_or_abrupt"
17+ SessionExitExternalKillOrOOMInferred SessionExitClass = "external_kill_or_oom_inferred"
18+ SessionExitUnknown SessionExitClass = "unknown"
19+)
20+
21+type SessionExitClassification struct {
22+ SessionID string
23+ Class SessionExitClass
24+ Inference string
25+ Reason string
26+ StartupAt *time.Time
27+ ShutdownAt *time.Time
28+ AbruptDetectedAt *time.Time
29+ LastMarkerObserved time.Time
30+}
31+
32+func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) {
33+ classification := SessionExitClassification{
34+ SessionID: sessionID,
35+ Class: SessionExitUnknown,
36+ Inference: "marker-missing",
37+ Reason: "no known marker files found for session",
38+ }
39+ if lockDir == "" || sessionID == "" {
40+ return classification, fmt.Errorf("lockDir and sessionID are required")
41+ }
42+
43+ startupAt, startupExists, startupModTime, err := readMarkerTime(startupMarkerPath(lockDir, sessionID), "")
44+ if err != nil {
45+ return classification, err
46+ }
47+ shutdownAt, shutdownExists, shutdownModTime, err := readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "")
48+ if err != nil {
49+ return classification, err
50+ }
51+ abruptAt, abruptExists, abruptModTime, err := readMarkerTime(abruptMarkerPath(lockDir, sessionID), "detected_at=")
52+ if err != nil {
53+ return classification, err
54+ }
55+
56+ classification.StartupAt = startupAt
57+ classification.ShutdownAt = shutdownAt
58+ classification.AbruptDetectedAt = abruptAt
59+ classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime)
60+
61+ switch {
62+ case shutdownExists:
63+ classification.Class = SessionExitCleanExit
64+ classification.Inference = "marker-confirmed"
65+ classification.Reason = "shutdown marker present"
66+ case abruptExists:
67+ classification.Class = SessionExitMainProcessCrashOrAbrupt
68+ classification.Inference = "marker-confirmed"
69+ classification.Reason = "abrupt marker present and no shutdown marker"
70+ case startupExists:
71+ classification.Class = SessionExitExternalKillOrOOMInferred
72+ classification.Inference = "best-effort"
73+ classification.Reason = "startup marker present without shutdown/abrupt markers"
74+ }
75+
76+ return classification, nil
77+}
78+
79+func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) {
80+ if lockDir == "" {
81+ return nil, nil
82+ }
83+
84+ sessionIDs, err := discoverSessionIDsFromMarkers(lockDir)
85+ if err != nil {
86+ return nil, err
87+ }
88+ if len(sessionIDs) == 0 {
89+ return nil, nil
90+ }
91+
92+ report := make([]SessionExitClassification, 0, len(sessionIDs))
93+ for _, sessionID := range sessionIDs {
94+ classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
95+ if classErr != nil {
96+ return nil, classErr
97+ }
98+ report = append(report, classification)
99+ }
100+
101+ sort.Slice(report, func(i, j int) bool {
102+ return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved)
103+ })
104+ return report, nil
105+}
106+
107+func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) {
108+ pattern := filepath.Join(lockDir, "session_*.marker")
109+ paths, err := filepath.Glob(pattern)
110+ if err != nil {
111+ return nil, err
112+ }
113+
114+ ids := make(map[string]struct{}, len(paths))
115+ for _, path := range paths {
116+ base := filepath.Base(path)
117+ sessionID, ok := markerSessionID(base)
118+ if !ok {
119+ continue
120+ }
121+ ids[sessionID] = struct{}{}
122+ }
123+
124+ sessionIDs := make([]string, 0, len(ids))
125+ for id := range ids {
126+ sessionIDs = append(sessionIDs, id)
127+ }
128+ sort.Strings(sessionIDs)
129+ return sessionIDs, nil
130+}
131+
132+func markerSessionID(base string) (string, bool) {
133+ if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") {
134+ return "", false
135+ }
136+
137+ trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker")
138+ switch {
139+ case strings.HasSuffix(trimmed, ".startup"):
140+ return strings.TrimSuffix(trimmed, ".startup"), true
141+ case strings.HasSuffix(trimmed, ".shutdown"):
142+ return strings.TrimSuffix(trimmed, ".shutdown"), true
143+ case strings.HasSuffix(trimmed, ".abrupt"):
144+ return strings.TrimSuffix(trimmed, ".abrupt"), true
145+ default:
146+ return "", false
147+ }
148+}
149+
150+func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) {
151+ stat, err := os.Stat(path)
152+ if err != nil {
153+ if os.IsNotExist(err) {
154+ return nil, false, time.Time{}, nil
155+ }
156+ return nil, false, time.Time{}, err
157+ }
158+
159+ content, err := os.ReadFile(path)
160+ if err != nil {
161+ return nil, true, stat.ModTime(), err
162+ }
163+
164+ raw := strings.TrimSpace(string(content))
165+ if raw == "" {
166+ return nil, true, stat.ModTime(), nil
167+ }
168+
169+ if keyPrefix != "" {
170+ lines := strings.Split(raw, "\n")
171+ for _, line := range lines {
172+ if strings.HasPrefix(line, keyPrefix) {
173+ parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix))
174+ if parseErr != nil {
175+ return nil, true, stat.ModTime(), nil
176+ }
177+ return &parsed, true, stat.ModTime(), nil
178+ }
179+ }
180+ return nil, true, stat.ModTime(), nil
181+ }
182+
183+ parsed, parseErr := time.Parse(time.RFC3339Nano, raw)
184+ if parseErr != nil {
185+ return nil, true, stat.ModTime(), nil
186+ }
187+ return &parsed, true, stat.ModTime(), nil
188+}
189+
190+func latestNonZero(values ...time.Time) time.Time {
191+ var latest time.Time
192+ for _, value := range values {
193+ if value.IsZero() {
194+ continue
195+ }
196+ if latest.IsZero() || value.After(latest) {
197+ latest = value
198+ }
199+ }
200+ return latest
201+}
A internal/bootstrap/session_exit_classification_test.go +115 -0
@@ -0,0 +1,115 @@
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 TestClassifySessionExitFromMarkers_CleanExit(t *testing.T) {
14+ lockDir := t.TempDir()
15+ sessionID := "clean-1"
16+ startedAt := time.Date(2026, 2, 7, 9, 0, 0, 0, time.UTC)
17+ endedAt := startedAt.Add(2 * time.Minute)
18+
19+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
20+ require.NoError(t, writeShutdownMarker(lockDir, sessionID, endedAt))
21+
22+ classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
23+ require.NoError(t, err)
24+ assert.Equal(t, SessionExitCleanExit, classification.Class)
25+ assert.Equal(t, "marker-confirmed", classification.Inference)
26+ assert.Equal(t, "shutdown marker present", classification.Reason)
27+ require.NotNil(t, classification.StartupAt)
28+ assert.Equal(t, startedAt, *classification.StartupAt)
29+ require.NotNil(t, classification.ShutdownAt)
30+ assert.Equal(t, endedAt, *classification.ShutdownAt)
31+ assert.Nil(t, classification.AbruptDetectedAt)
32+}
33+
34+func TestClassifySessionExitFromMarkers_AbruptMarker(t *testing.T) {
35+ lockDir := t.TempDir()
36+ sessionID := "abrupt-1"
37+ startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
38+ detectedAt := startedAt.Add(5 * time.Minute)
39+
40+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
41+ require.NoError(t, os.WriteFile(
42+ abruptMarkerPath(lockDir, sessionID),
43+ []byte("detected_at="+detectedAt.Format(time.RFC3339Nano)+"\n"),
44+ markerFilePerm,
45+ ))
46+
47+ classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
48+ require.NoError(t, err)
49+ assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, classification.Class)
50+ assert.Equal(t, "marker-confirmed", classification.Inference)
51+ assert.Equal(t, "abrupt marker present and no shutdown marker", classification.Reason)
52+ require.NotNil(t, classification.StartupAt)
53+ assert.Equal(t, startedAt, *classification.StartupAt)
54+ assert.Nil(t, classification.ShutdownAt)
55+ require.NotNil(t, classification.AbruptDetectedAt)
56+ assert.Equal(t, detectedAt, *classification.AbruptDetectedAt)
57+}
58+
59+func TestClassifySessionExitFromMarkers_ExternalKillOrOOMInferred(t *testing.T) {
60+ lockDir := t.TempDir()
61+ sessionID := "inferred-1"
62+ startedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
63+
64+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
65+
66+ classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
67+ require.NoError(t, err)
68+ assert.Equal(t, SessionExitExternalKillOrOOMInferred, classification.Class)
69+ assert.Equal(t, "best-effort", classification.Inference)
70+ assert.Equal(t, "startup marker present without shutdown/abrupt markers", classification.Reason)
71+ require.NotNil(t, classification.StartupAt)
72+ assert.Equal(t, startedAt, *classification.StartupAt)
73+ assert.Nil(t, classification.ShutdownAt)
74+ assert.Nil(t, classification.AbruptDetectedAt)
75+}
76+
77+func TestClassifySessionExitFromMarkers_RequiresInputs(t *testing.T) {
78+ _, err := ClassifySessionExitFromMarkers("", "s1")
79+ require.Error(t, err)
80+
81+ _, err = ClassifySessionExitFromMarkers(t.TempDir(), "")
82+ require.Error(t, err)
83+}
84+
85+func TestBuildSessionExitReport(t *testing.T) {
86+ lockDir := t.TempDir()
87+
88+ require.NoError(t, writeStartupMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 0, 0, 0, time.UTC)))
89+ require.NoError(t, writeShutdownMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 1, 0, 0, time.UTC)))
90+
91+ require.NoError(t, writeStartupMarker(lockDir, "abrupt-r", time.Date(2026, 2, 7, 8, 5, 0, 0, time.UTC)))
92+ require.NoError(t, os.WriteFile(
93+ abruptMarkerPath(lockDir, "abrupt-r"),
94+ []byte("detected_at=2026-02-07T08:08:00Z\n"),
95+ markerFilePerm,
96+ ))
97+
98+ require.NoError(t, writeStartupMarker(lockDir, "inferred-r", time.Date(2026, 2, 7, 8, 10, 0, 0, time.UTC)))
99+
100+ // Unknown marker type should not be included.
101+ require.NoError(t, os.WriteFile(filepath.Join(lockDir, "session_unknown.custom.marker"), []byte("x"), markerFilePerm))
102+
103+ report, err := BuildSessionExitReport(lockDir)
104+ require.NoError(t, err)
105+ require.Len(t, report, 3)
106+
107+ got := map[string]SessionExitClass{}
108+ for _, item := range report {
109+ got[item.SessionID] = item.Class
110+ }
111+
112+ assert.Equal(t, SessionExitCleanExit, got["clean-r"])
113+ assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, got["abrupt-r"])
114+ assert.Equal(t, SessionExitExternalKillOrOOMInferred, got["inferred-r"])
115+}
@@ -0,0 +1,115 @@
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 TestClassifySessionExitFromMarkers_CleanExit(t *testing.T) {
14+ lockDir := t.TempDir()
15+ sessionID := "clean-1"
16+ startedAt := time.Date(2026, 2, 7, 9, 0, 0, 0, time.UTC)
17+ endedAt := startedAt.Add(2 * time.Minute)
18+
19+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
20+ require.NoError(t, writeShutdownMarker(lockDir, sessionID, endedAt))
21+
22+ classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
23+ require.NoError(t, err)
24+ assert.Equal(t, SessionExitCleanExit, classification.Class)
25+ assert.Equal(t, "marker-confirmed", classification.Inference)
26+ assert.Equal(t, "shutdown marker present", classification.Reason)
27+ require.NotNil(t, classification.StartupAt)
28+ assert.Equal(t, startedAt, *classification.StartupAt)
29+ require.NotNil(t, classification.ShutdownAt)
30+ assert.Equal(t, endedAt, *classification.ShutdownAt)
31+ assert.Nil(t, classification.AbruptDetectedAt)
32+}
33+
34+func TestClassifySessionExitFromMarkers_AbruptMarker(t *testing.T) {
35+ lockDir := t.TempDir()
36+ sessionID := "abrupt-1"
37+ startedAt := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
38+ detectedAt := startedAt.Add(5 * time.Minute)
39+
40+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
41+ require.NoError(t, os.WriteFile(
42+ abruptMarkerPath(lockDir, sessionID),
43+ []byte("detected_at="+detectedAt.Format(time.RFC3339Nano)+"\n"),
44+ markerFilePerm,
45+ ))
46+
47+ classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
48+ require.NoError(t, err)
49+ assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, classification.Class)
50+ assert.Equal(t, "marker-confirmed", classification.Inference)
51+ assert.Equal(t, "abrupt marker present and no shutdown marker", classification.Reason)
52+ require.NotNil(t, classification.StartupAt)
53+ assert.Equal(t, startedAt, *classification.StartupAt)
54+ assert.Nil(t, classification.ShutdownAt)
55+ require.NotNil(t, classification.AbruptDetectedAt)
56+ assert.Equal(t, detectedAt, *classification.AbruptDetectedAt)
57+}
58+
59+func TestClassifySessionExitFromMarkers_ExternalKillOrOOMInferred(t *testing.T) {
60+ lockDir := t.TempDir()
61+ sessionID := "inferred-1"
62+ startedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
63+
64+ require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
65+
66+ classification, err := ClassifySessionExitFromMarkers(lockDir, sessionID)
67+ require.NoError(t, err)
68+ assert.Equal(t, SessionExitExternalKillOrOOMInferred, classification.Class)
69+ assert.Equal(t, "best-effort", classification.Inference)
70+ assert.Equal(t, "startup marker present without shutdown/abrupt markers", classification.Reason)
71+ require.NotNil(t, classification.StartupAt)
72+ assert.Equal(t, startedAt, *classification.StartupAt)
73+ assert.Nil(t, classification.ShutdownAt)
74+ assert.Nil(t, classification.AbruptDetectedAt)
75+}
76+
77+func TestClassifySessionExitFromMarkers_RequiresInputs(t *testing.T) {
78+ _, err := ClassifySessionExitFromMarkers("", "s1")
79+ require.Error(t, err)
80+
81+ _, err = ClassifySessionExitFromMarkers(t.TempDir(), "")
82+ require.Error(t, err)
83+}
84+
85+func TestBuildSessionExitReport(t *testing.T) {
86+ lockDir := t.TempDir()
87+
88+ require.NoError(t, writeStartupMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 0, 0, 0, time.UTC)))
89+ require.NoError(t, writeShutdownMarker(lockDir, "clean-r", time.Date(2026, 2, 7, 8, 1, 0, 0, time.UTC)))
90+
91+ require.NoError(t, writeStartupMarker(lockDir, "abrupt-r", time.Date(2026, 2, 7, 8, 5, 0, 0, time.UTC)))
92+ require.NoError(t, os.WriteFile(
93+ abruptMarkerPath(lockDir, "abrupt-r"),
94+ []byte("detected_at=2026-02-07T08:08:00Z\n"),
95+ markerFilePerm,
96+ ))
97+
98+ require.NoError(t, writeStartupMarker(lockDir, "inferred-r", time.Date(2026, 2, 7, 8, 10, 0, 0, time.UTC)))
99+
100+ // Unknown marker type should not be included.
101+ require.NoError(t, os.WriteFile(filepath.Join(lockDir, "session_unknown.custom.marker"), []byte("x"), markerFilePerm))
102+
103+ report, err := BuildSessionExitReport(lockDir)
104+ require.NoError(t, err)
105+ require.Len(t, report, 3)
106+
107+ got := map[string]SessionExitClass{}
108+ for _, item := range report {
109+ got[item.SessionID] = item.Class
110+ }
111+
112+ assert.Equal(t, SessionExitCleanExit, got["clean-r"])
113+ assert.Equal(t, SessionExitMainProcessCrashOrAbrupt, got["abrupt-r"])
114+ assert.Equal(t, SessionExitExternalKillOrOOMInferred, got["inferred-r"])
115+}
M internal/infrastructure/filtering/downloader.go +38 -9
@@ -164,6 +164,10 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string,
164164 url := fmt.Sprintf("%s/%s", d.baseURL, filename)
165165 localPath := filepath.Join(d.cacheDir, filename)
166166
167+ if err := os.MkdirAll(d.cacheDir, cacheDirPerm); err != nil {
168+ return "", 0, fmt.Errorf("failed to create cache dir: %w", err)
169+ }
170+
167171 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
168172 if err != nil {
169173 return "", 0, fmt.Errorf("failed to create request: %w", err)
@@ -183,33 +187,58 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string,
183187 return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode)
184188 }
185189
186- // Write to temp file first, then atomic rename
187- tmpPath := localPath + ".tmp"
188- file, err := os.Create(tmpPath)
190+ // Write to temp file in cacheDir, then atomic rename into destination.
191+ tmpFile, err := os.CreateTemp(d.cacheDir, filepath.Base(filename)+".*.tmp")
189192 if err != nil {
190193 return "", 0, fmt.Errorf("failed to create temp file: %w", err)
191194 }
195+ tmpPath := tmpFile.Name()
192196
193- bytesWritten, err := io.Copy(file, resp.Body)
197+ bytesWritten, err := io.Copy(tmpFile, resp.Body)
194198 if err != nil {
195- _ = file.Close()
199+ _ = tmpFile.Close()
196200 _ = os.Remove(tmpPath)
197201 return "", 0, fmt.Errorf("failed to write %s: %w", filename, err)
198202 }
199- if err := file.Close(); err != nil {
203+ if err := tmpFile.Close(); err != nil {
200204 _ = os.Remove(tmpPath)
201205 return "", 0, fmt.Errorf("failed to close temp file: %w", err)
202206 }
203207
204- // Atomic rename
205- if err := os.Rename(tmpPath, localPath); err != nil {
208+ if err := d.renameTempFile(tmpPath, localPath); err != nil {
206209 _ = os.Remove(tmpPath)
207- return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
210+ return "", 0, err
208211 }
209212
210213 return localPath, bytesWritten, nil
211214 }
212215
216+func (*Downloader) renameTempFile(tmpPath, localPath string) error {
217+ parentDir := filepath.Dir(localPath)
218+
219+ if err := os.MkdirAll(parentDir, cacheDirPerm); err != nil {
220+ return fmt.Errorf("failed to create target dir: %w", err)
221+ }
222+
223+ parentInfo, err := os.Stat(parentDir)
224+ if err != nil {
225+ return fmt.Errorf("failed to stat target dir: %w", err)
226+ }
227+ if !parentInfo.IsDir() {
228+ return fmt.Errorf("target parent path is not a directory: %s", parentDir)
229+ }
230+
231+ if _, err := os.Stat(tmpPath); err != nil {
232+ return fmt.Errorf("temp file missing before rename: %w", err)
233+ }
234+
235+ if err := os.Rename(tmpPath, localPath); err != nil {
236+ return fmt.Errorf("failed to rename temp file: %w", err)
237+ }
238+
239+ return nil
240+}
241+
213242 // downloadAndCacheManifest downloads and caches the manifest.
214243 func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
215244 manifest, err := d.FetchManifest(ctx)
@@ -164,6 +164,10 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string,
164 url := fmt.Sprintf("%s/%s", d.baseURL, filename)164 url := fmt.Sprintf("%s/%s", d.baseURL, filename)
165 localPath := filepath.Join(d.cacheDir, filename)165 localPath := filepath.Join(d.cacheDir, filename)
166 166
167+ if err := os.MkdirAll(d.cacheDir, cacheDirPerm); err != nil {
168+ return "", 0, fmt.Errorf("failed to create cache dir: %w", err)
169+ }
170+
167 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)171 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
168 if err != nil {172 if err != nil {
169 return "", 0, fmt.Errorf("failed to create request: %w", err)173 return "", 0, fmt.Errorf("failed to create request: %w", err)
@@ -183,33 +187,58 @@ func (d *Downloader) downloadFile(ctx context.Context, filename string) (string,
183 return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode)187 return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode)
184 }188 }
185 189
186- // Write to temp file first, then atomic rename190+ // Write to temp file in cacheDir, then atomic rename into destination.
187- tmpPath := localPath + ".tmp"191+ tmpFile, err := os.CreateTemp(d.cacheDir, filepath.Base(filename)+".*.tmp")
188- file, err := os.Create(tmpPath)
189 if err != nil {192 if err != nil {
190 return "", 0, fmt.Errorf("failed to create temp file: %w", err)193 return "", 0, fmt.Errorf("failed to create temp file: %w", err)
191 }194 }
195+ tmpPath := tmpFile.Name()
192 196
193- bytesWritten, err := io.Copy(file, resp.Body)197+ bytesWritten, err := io.Copy(tmpFile, resp.Body)
194 if err != nil {198 if err != nil {
195- _ = file.Close()199+ _ = tmpFile.Close()
196 _ = os.Remove(tmpPath)200 _ = os.Remove(tmpPath)
197 return "", 0, fmt.Errorf("failed to write %s: %w", filename, err)201 return "", 0, fmt.Errorf("failed to write %s: %w", filename, err)
198 }202 }
199- if err := file.Close(); err != nil {203+ if err := tmpFile.Close(); err != nil {
200 _ = os.Remove(tmpPath)204 _ = os.Remove(tmpPath)
201 return "", 0, fmt.Errorf("failed to close temp file: %w", err)205 return "", 0, fmt.Errorf("failed to close temp file: %w", err)
202 }206 }
203 207
204- // Atomic rename208+ if err := d.renameTempFile(tmpPath, localPath); err != nil {
205- if err := os.Rename(tmpPath, localPath); err != nil {
206 _ = os.Remove(tmpPath)209 _ = os.Remove(tmpPath)
207- return "", 0, fmt.Errorf("failed to rename temp file: %w", err)210+ return "", 0, err
208 }211 }
209 212
210 return localPath, bytesWritten, nil213 return localPath, bytesWritten, nil
211 }214 }
212 215
216+func (*Downloader) renameTempFile(tmpPath, localPath string) error {
217+ parentDir := filepath.Dir(localPath)
218+
219+ if err := os.MkdirAll(parentDir, cacheDirPerm); err != nil {
220+ return fmt.Errorf("failed to create target dir: %w", err)
221+ }
222+
223+ parentInfo, err := os.Stat(parentDir)
224+ if err != nil {
225+ return fmt.Errorf("failed to stat target dir: %w", err)
226+ }
227+ if !parentInfo.IsDir() {
228+ return fmt.Errorf("target parent path is not a directory: %s", parentDir)
229+ }
230+
231+ if _, err := os.Stat(tmpPath); err != nil {
232+ return fmt.Errorf("temp file missing before rename: %w", err)
233+ }
234+
235+ if err := os.Rename(tmpPath, localPath); err != nil {
236+ return fmt.Errorf("failed to rename temp file: %w", err)
237+ }
238+
239+ return nil
240+}
241+
213 // downloadAndCacheManifest downloads and caches the manifest.242 // downloadAndCacheManifest downloads and caches the manifest.
214 func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {243 func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
215 manifest, err := d.FetchManifest(ctx)244 manifest, err := d.FetchManifest(ctx)
A internal/infrastructure/filtering/downloader_test.go +84 -0
@@ -0,0 +1,84 @@
1+package filtering
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestDownloader_DownloadFile_RecoversWhenTargetDirDeletedConcurrently(t *testing.T) {
16+ t.Parallel()
17+
18+ cacheDir := filepath.Join(t.TempDir(), "cache")
19+ filename := filepath.Join("nested", "combined-part1.json")
20+ localPath := filepath.Join(cacheDir, filename)
21+ targetDir := filepath.Dir(localPath)
22+
23+ require.NoError(t, os.MkdirAll(targetDir, cacheDirPerm))
24+
25+ downloadStarted := make(chan struct{})
26+ allowResponse := make(chan struct{})
27+
28+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+ if r.URL.Path == "/"+filename {
30+ close(downloadStarted)
31+ <-allowResponse
32+ _, _ = io.WriteString(w, `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`)
33+ return
34+ }
35+ http.NotFound(w, r)
36+ }))
37+ defer server.Close()
38+
39+ d := NewDownloader(cacheDir)
40+ d.baseURL = server.URL
41+
42+ type result struct {
43+ path string
44+ n int64
45+ err error
46+ }
47+ resultCh := make(chan result, 1)
48+ go func() {
49+ path, n, err := d.downloadFile(context.Background(), filename)
50+ resultCh <- result{path: path, n: n, err: err}
51+ }()
52+
53+ <-downloadStarted
54+ require.NoError(t, os.RemoveAll(targetDir))
55+ close(allowResponse)
56+
57+ res := <-resultCh
58+ require.NoError(t, res.err)
59+ require.Equal(t, localPath, res.path)
60+ require.Positive(t, res.n)
61+
62+ content, err := os.ReadFile(localPath)
63+ require.NoError(t, err)
64+ require.Contains(t, string(content), "url-filter")
65+}
66+
67+func TestDownloader_RenameTempFile_RequiresDirectoryParent(t *testing.T) {
68+ t.Parallel()
69+
70+ cacheDir := filepath.Join(t.TempDir(), "cache")
71+ require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm))
72+
73+ tmpFile := filepath.Join(cacheDir, "payload.tmp")
74+ require.NoError(t, os.WriteFile(tmpFile, []byte("payload"), manifestFilePerm))
75+
76+ parentAsFile := filepath.Join(cacheDir, "target")
77+ require.NoError(t, os.WriteFile(parentAsFile, []byte("not-a-directory"), manifestFilePerm))
78+
79+ d := NewDownloader(cacheDir)
80+
81+ err := d.renameTempFile(tmpFile, filepath.Join(parentAsFile, "filter.json"))
82+ require.Error(t, err)
83+ require.Contains(t, err.Error(), "not a directory")
84+}
@@ -0,0 +1,84 @@
1+package filtering
2+
3+import (
4+ "context"
5+ "io"
6+ "net/http"
7+ "net/http/httptest"
8+ "os"
9+ "path/filepath"
10+ "testing"
11+
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestDownloader_DownloadFile_RecoversWhenTargetDirDeletedConcurrently(t *testing.T) {
16+ t.Parallel()
17+
18+ cacheDir := filepath.Join(t.TempDir(), "cache")
19+ filename := filepath.Join("nested", "combined-part1.json")
20+ localPath := filepath.Join(cacheDir, filename)
21+ targetDir := filepath.Dir(localPath)
22+
23+ require.NoError(t, os.MkdirAll(targetDir, cacheDirPerm))
24+
25+ downloadStarted := make(chan struct{})
26+ allowResponse := make(chan struct{})
27+
28+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+ if r.URL.Path == "/"+filename {
30+ close(downloadStarted)
31+ <-allowResponse
32+ _, _ = io.WriteString(w, `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`)
33+ return
34+ }
35+ http.NotFound(w, r)
36+ }))
37+ defer server.Close()
38+
39+ d := NewDownloader(cacheDir)
40+ d.baseURL = server.URL
41+
42+ type result struct {
43+ path string
44+ n int64
45+ err error
46+ }
47+ resultCh := make(chan result, 1)
48+ go func() {
49+ path, n, err := d.downloadFile(context.Background(), filename)
50+ resultCh <- result{path: path, n: n, err: err}
51+ }()
52+
53+ <-downloadStarted
54+ require.NoError(t, os.RemoveAll(targetDir))
55+ close(allowResponse)
56+
57+ res := <-resultCh
58+ require.NoError(t, res.err)
59+ require.Equal(t, localPath, res.path)
60+ require.Positive(t, res.n)
61+
62+ content, err := os.ReadFile(localPath)
63+ require.NoError(t, err)
64+ require.Contains(t, string(content), "url-filter")
65+}
66+
67+func TestDownloader_RenameTempFile_RequiresDirectoryParent(t *testing.T) {
68+ t.Parallel()
69+
70+ cacheDir := filepath.Join(t.TempDir(), "cache")
71+ require.NoError(t, os.MkdirAll(cacheDir, cacheDirPerm))
72+
73+ tmpFile := filepath.Join(cacheDir, "payload.tmp")
74+ require.NoError(t, os.WriteFile(tmpFile, []byte("payload"), manifestFilePerm))
75+
76+ parentAsFile := filepath.Join(cacheDir, "target")
77+ require.NoError(t, os.WriteFile(parentAsFile, []byte("not-a-directory"), manifestFilePerm))
78+
79+ d := NewDownloader(cacheDir)
80+
81+ err := d.renameTempFile(tmpFile, filepath.Join(parentAsFile, "filter.json"))
82+ require.Error(t, err)
83+ require.Contains(t, err.Error(), "not a directory")
84+}
M internal/infrastructure/persistence/sqlite/session_state_repo.go +25 -3
@@ -5,6 +5,7 @@ import (
55 "database/sql"
66 "encoding/json"
77 "errors"
8+ "fmt"
89
910 "github.com/bnema/dumber/internal/domain/entity"
1011 "github.com/bnema/dumber/internal/domain/repository"
@@ -13,12 +14,16 @@ import (
1314 )
1415
1516 type sessionStateRepo struct {
17+ db *sql.DB
1618 queries *sqlc.Queries
1719 }
1820
1921 // NewSessionStateRepository creates a new session state repository.
2022 func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository {
21- return &sessionStateRepo{queries: sqlc.New(db)}
23+ return &sessionStateRepo{
24+ db: db,
25+ queries: sqlc.New(db),
26+ }
2227 }
2328
2429 // SaveSnapshot saves or updates a session state snapshot.
@@ -40,14 +45,31 @@ func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.Sessi
4045 Int("pane_count", state.CountPanes()).
4146 Msg("saving session state snapshot")
4247
43- return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{
48+ tx, err := r.db.BeginTx(ctx, nil)
49+ if err != nil {
50+ return fmt.Errorf("begin snapshot transaction: %w", err)
51+ }
52+
53+ txQueries := r.queries.WithTx(tx)
54+ if err := txQueries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{
4455 SessionID: string(state.SessionID),
4556 StateJson: string(stateJSON),
4657 Version: int64(state.Version),
4758 TabCount: int64(len(state.Tabs)),
4859 PaneCount: int64(state.CountPanes()),
4960 UpdatedAt: state.SavedAt,
50- })
61+ }); err != nil {
62+ if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
63+ return fmt.Errorf("save snapshot rollback after upsert failure: %w", errors.Join(err, rollbackErr))
64+ }
65+ return err
66+ }
67+
68+ if err := tx.Commit(); err != nil {
69+ return fmt.Errorf("commit snapshot transaction: %w", err)
70+ }
71+
72+ return nil
5173 }
5274
5375 // GetSnapshot returns the latest snapshot for a session.
@@ -5,6 +5,7 @@ import (
5 "database/sql"5 "database/sql"
6 "encoding/json"6 "encoding/json"
7 "errors"7 "errors"
8+ "fmt"
8 9
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/domain/repository"11 "github.com/bnema/dumber/internal/domain/repository"
@@ -13,12 +14,16 @@ import (
13 )14 )
14 15
15 type sessionStateRepo struct {16 type sessionStateRepo struct {
17+ db *sql.DB
16 queries *sqlc.Queries18 queries *sqlc.Queries
17 }19 }
18 20
19 // NewSessionStateRepository creates a new session state repository.21 // NewSessionStateRepository creates a new session state repository.
20 func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository {22 func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository {
21- return &sessionStateRepo{queries: sqlc.New(db)}23+ return &sessionStateRepo{
24+ db: db,
25+ queries: sqlc.New(db),
26+ }
22 }27 }
23 28
24 // SaveSnapshot saves or updates a session state snapshot.29 // SaveSnapshot saves or updates a session state snapshot.
@@ -40,14 +45,31 @@ func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.Sessi
40 Int("pane_count", state.CountPanes()).45 Int("pane_count", state.CountPanes()).
41 Msg("saving session state snapshot")46 Msg("saving session state snapshot")
42 47
43- return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{48+ tx, err := r.db.BeginTx(ctx, nil)
49+ if err != nil {
50+ return fmt.Errorf("begin snapshot transaction: %w", err)
51+ }
52+
53+ txQueries := r.queries.WithTx(tx)
54+ if err := txQueries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{
44 SessionID: string(state.SessionID),55 SessionID: string(state.SessionID),
45 StateJson: string(stateJSON),56 StateJson: string(stateJSON),
46 Version: int64(state.Version),57 Version: int64(state.Version),
47 TabCount: int64(len(state.Tabs)),58 TabCount: int64(len(state.Tabs)),
48 PaneCount: int64(state.CountPanes()),59 PaneCount: int64(state.CountPanes()),
49 UpdatedAt: state.SavedAt,60 UpdatedAt: state.SavedAt,
50- })61+ }); err != nil {
62+ if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
63+ return fmt.Errorf("save snapshot rollback after upsert failure: %w", errors.Join(err, rollbackErr))
64+ }
65+ return err
66+ }
67+
68+ if err := tx.Commit(); err != nil {
69+ return fmt.Errorf("commit snapshot transaction: %w", err)
70+ }
71+
72+ return nil
51 }73 }
52 74
53 // GetSnapshot returns the latest snapshot for a session.75 // GetSnapshot returns the latest snapshot for a session.
M internal/infrastructure/snapshot/service.go +105 -4
@@ -2,6 +2,9 @@ package snapshot
22
33 import (
44 "context"
5+ "errors"
6+ "fmt"
7+ "strings"
58 "sync"
69 "time"
710
@@ -10,11 +13,19 @@ import (
1013 "github.com/bnema/dumber/internal/logging"
1114 )
1215
16+const (
17+ defaultSnapshotIntervalMs = 5000
18+ maxFKRetries = 3
19+ fkRetryDelay = 100 * time.Millisecond
20+)
21+
1322 // Service handles debounced session state snapshots.
1423 type Service struct {
1524 snapshotUC *usecase.SnapshotSessionUseCase
1625 provider port.TabListProvider
1726 interval time.Duration
27+ retries int
28+ retryDelay time.Duration
1829
1930 mu sync.Mutex
2031 timer *time.Timer
@@ -31,12 +42,14 @@ func NewService(
3142 intervalMs int,
3243 ) *Service {
3344 if intervalMs <= 0 {
34- intervalMs = 5000 // Default 5 seconds
45+ intervalMs = defaultSnapshotIntervalMs
3546 }
3647 return &Service{
3748 snapshotUC: snapshotUC,
3849 provider: provider,
3950 interval: time.Duration(intervalMs) * time.Millisecond,
51+ retries: maxFKRetries,
52+ retryDelay: fkRetryDelay,
4053 }
4154 }
4255
@@ -54,8 +67,20 @@ func (s *Service) Start(ctx context.Context) {
5467 // to avoid FK constraint violations.
5568 func (s *Service) SetReady() {
5669 s.mu.Lock()
57- defer s.mu.Unlock()
5870 s.ready = true
71+ dirty := s.dirty
72+ ctx := s.ctx
73+ s.mu.Unlock()
74+
75+ if !dirty || ctx == nil {
76+ return
77+ }
78+
79+ go func() {
80+ if err := s.saveSnapshot(ctx); err != nil {
81+ logging.FromContext(ctx).Error().Err(err).Msg("failed to save pending session snapshot after ready")
82+ }
83+ }()
5984 }
6085
6186 // Stop stops the service and saves final state.
@@ -135,11 +160,87 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
135160 sessionID := s.provider.GetSessionID()
136161
137162 if sessionID == "" {
163+ s.markDirty()
138164 return nil
139165 }
140166
141- return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{
167+ if err := s.executeWithRetry(ctx, usecase.SnapshotInput{
142168 SessionID: sessionID,
143169 TabList: tabList,
144- })
170+ }); err != nil {
171+ s.markDirty()
172+ return err
173+ }
174+
175+ return nil
176+}
177+
178+func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {
179+ log := logging.FromContext(ctx)
180+ maxAttempts := s.retries + 1
181+
182+ var lastErr error
183+ for attempt := 1; attempt <= maxAttempts; attempt++ {
184+ if err := s.snapshotUC.Execute(ctx, input); err != nil {
185+ lastErr = err
186+ if !isTransientFKError(err) || attempt == maxAttempts {
187+ if attempt == maxAttempts && isTransientFKError(err) {
188+ log.Error().
189+ Err(err).
190+ Str("session_id", string(input.SessionID)).
191+ Int("attempts", maxAttempts).
192+ Msg("snapshot save retries exhausted after transient fk violation")
193+ }
194+ return err
195+ }
196+
197+ log.Warn().
198+ Err(err).
199+ Str("session_id", string(input.SessionID)).
200+ Int("attempt", attempt).
201+ Int("max_attempts", maxAttempts).
202+ Dur("retry_delay", s.retryDelay).
203+ Msg("transient fk violation while saving snapshot; retrying")
204+
205+ if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil {
206+ return fmt.Errorf("waiting to retry snapshot save: %w", waitErr)
207+ }
208+ continue
209+ }
210+
211+ return nil
212+ }
213+
214+ return lastErr
215+}
216+
217+func (s *Service) markDirty() {
218+ s.mu.Lock()
219+ s.dirty = true
220+ s.mu.Unlock()
221+}
222+
223+func waitForRetry(ctx context.Context, delay time.Duration) error {
224+ if delay <= 0 {
225+ return nil
226+ }
227+ timer := time.NewTimer(delay)
228+ defer timer.Stop()
229+
230+ select {
231+ case <-ctx.Done():
232+ return ctx.Err()
233+ case <-timer.C:
234+ return nil
235+ }
236+}
237+
238+func isTransientFKError(err error) bool {
239+ if err == nil {
240+ return false
241+ }
242+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
243+ return false
244+ }
245+ return strings.Contains(strings.ToLower(err.Error()), "foreign key")
145246 }
@@ -2,6 +2,9 @@ package snapshot
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
6+ "fmt"
7+ "strings"
5 "sync"8 "sync"
6 "time"9 "time"
7 10
@@ -10,11 +13,19 @@ import (
10 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
11 )14 )
12 15
16+const (
17+ defaultSnapshotIntervalMs = 5000
18+ maxFKRetries = 3
19+ fkRetryDelay = 100 * time.Millisecond
20+)
21+
13 // Service handles debounced session state snapshots.22 // Service handles debounced session state snapshots.
14 type Service struct {23 type Service struct {
15 snapshotUC *usecase.SnapshotSessionUseCase24 snapshotUC *usecase.SnapshotSessionUseCase
16 provider port.TabListProvider25 provider port.TabListProvider
17 interval time.Duration26 interval time.Duration
27+ retries int
28+ retryDelay time.Duration
18 29
19 mu sync.Mutex30 mu sync.Mutex
20 timer *time.Timer31 timer *time.Timer
@@ -31,12 +42,14 @@ func NewService(
31 intervalMs int,42 intervalMs int,
32 ) *Service {43 ) *Service {
33 if intervalMs <= 0 {44 if intervalMs <= 0 {
34- intervalMs = 5000 // Default 5 seconds45+ intervalMs = defaultSnapshotIntervalMs
35 }46 }
36 return &Service{47 return &Service{
37 snapshotUC: snapshotUC,48 snapshotUC: snapshotUC,
38 provider: provider,49 provider: provider,
39 interval: time.Duration(intervalMs) * time.Millisecond,50 interval: time.Duration(intervalMs) * time.Millisecond,
51+ retries: maxFKRetries,
52+ retryDelay: fkRetryDelay,
40 }53 }
41 }54 }
42 55
@@ -54,8 +67,20 @@ func (s *Service) Start(ctx context.Context) {
54 // to avoid FK constraint violations.67 // to avoid FK constraint violations.
55 func (s *Service) SetReady() {68 func (s *Service) SetReady() {
56 s.mu.Lock()69 s.mu.Lock()
57- defer s.mu.Unlock()
58 s.ready = true70 s.ready = true
71+ dirty := s.dirty
72+ ctx := s.ctx
73+ s.mu.Unlock()
74+
75+ if !dirty || ctx == nil {
76+ return
77+ }
78+
79+ go func() {
80+ if err := s.saveSnapshot(ctx); err != nil {
81+ logging.FromContext(ctx).Error().Err(err).Msg("failed to save pending session snapshot after ready")
82+ }
83+ }()
59 }84 }
60 85
61 // Stop stops the service and saves final state.86 // Stop stops the service and saves final state.
@@ -135,11 +160,87 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
135 sessionID := s.provider.GetSessionID()160 sessionID := s.provider.GetSessionID()
136 161
137 if sessionID == "" {162 if sessionID == "" {
163+ s.markDirty()
138 return nil164 return nil
139 }165 }
140 166
141- return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{167+ if err := s.executeWithRetry(ctx, usecase.SnapshotInput{
142 SessionID: sessionID,168 SessionID: sessionID,
143 TabList: tabList,169 TabList: tabList,
144- })170+ }); err != nil {
171+ s.markDirty()
172+ return err
173+ }
174+
175+ return nil
176+}
177+
178+func (s *Service) executeWithRetry(ctx context.Context, input usecase.SnapshotInput) error {
179+ log := logging.FromContext(ctx)
180+ maxAttempts := s.retries + 1
181+
182+ var lastErr error
183+ for attempt := 1; attempt <= maxAttempts; attempt++ {
184+ if err := s.snapshotUC.Execute(ctx, input); err != nil {
185+ lastErr = err
186+ if !isTransientFKError(err) || attempt == maxAttempts {
187+ if attempt == maxAttempts && isTransientFKError(err) {
188+ log.Error().
189+ Err(err).
190+ Str("session_id", string(input.SessionID)).
191+ Int("attempts", maxAttempts).
192+ Msg("snapshot save retries exhausted after transient fk violation")
193+ }
194+ return err
195+ }
196+
197+ log.Warn().
198+ Err(err).
199+ Str("session_id", string(input.SessionID)).
200+ Int("attempt", attempt).
201+ Int("max_attempts", maxAttempts).
202+ Dur("retry_delay", s.retryDelay).
203+ Msg("transient fk violation while saving snapshot; retrying")
204+
205+ if waitErr := waitForRetry(ctx, s.retryDelay); waitErr != nil {
206+ return fmt.Errorf("waiting to retry snapshot save: %w", waitErr)
207+ }
208+ continue
209+ }
210+
211+ return nil
212+ }
213+
214+ return lastErr
215+}
216+
217+func (s *Service) markDirty() {
218+ s.mu.Lock()
219+ s.dirty = true
220+ s.mu.Unlock()
221+}
222+
223+func waitForRetry(ctx context.Context, delay time.Duration) error {
224+ if delay <= 0 {
225+ return nil
226+ }
227+ timer := time.NewTimer(delay)
228+ defer timer.Stop()
229+
230+ select {
231+ case <-ctx.Done():
232+ return ctx.Err()
233+ case <-timer.C:
234+ return nil
235+ }
236+}
237+
238+func isTransientFKError(err error) bool {
239+ if err == nil {
240+ return false
241+ }
242+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
243+ return false
244+ }
245+ return strings.Contains(strings.ToLower(err.Error()), "foreign key")
145 }246 }
A internal/infrastructure/snapshot/service_test.go +138 -0
@@ -0,0 +1,138 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+type testProvider struct {
18+ sessionID entity.SessionID
19+ tabList *entity.TabList
20+}
21+
22+func (p *testProvider) GetTabList() *entity.TabList {
23+ return p.tabList
24+}
25+
26+func (p *testProvider) GetSessionID() entity.SessionID {
27+ return p.sessionID
28+}
29+
30+func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
31+ repo := repomocks.NewMockSessionStateRepository(t)
32+ calls := 0
33+ repo.EXPECT().
34+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
35+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
36+ calls++
37+ if calls == 1 {
38+ return errors.New("FOREIGN KEY constraint failed")
39+ }
40+ return nil
41+ })
42+
43+ uc := usecase.NewSnapshotSessionUseCase(repo)
44+ svc := NewService(uc, &testProvider{
45+ sessionID: "20260207_120000_fk_retry_ok",
46+ tabList: entity.NewTabList(),
47+ }, 1)
48+ svc.retryDelay = time.Millisecond
49+ svc.ready = true
50+ svc.dirty = true
51+
52+ err := svc.saveSnapshot(context.Background())
53+ require.NoError(t, err)
54+ assert.Equal(t, 2, calls)
55+ assert.False(t, svc.dirty)
56+}
57+
58+func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) {
59+ repo := repomocks.NewMockSessionStateRepository(t)
60+ calls := 0
61+ fkErr := errors.New("save session snapshot: FOREIGN KEY constraint failed")
62+ repo.EXPECT().
63+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
64+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
65+ calls++
66+ return fkErr
67+ })
68+
69+ uc := usecase.NewSnapshotSessionUseCase(repo)
70+ svc := NewService(uc, &testProvider{
71+ sessionID: "20260207_120000_fk_retry_fail",
72+ tabList: entity.NewTabList(),
73+ }, 1)
74+ svc.retryDelay = time.Millisecond
75+ svc.ready = true
76+ svc.dirty = true
77+
78+ err := svc.saveSnapshot(context.Background())
79+ require.Error(t, err)
80+ require.ErrorIs(t, err, fkErr)
81+ assert.Equal(t, svc.retries+1, calls)
82+ assert.True(t, svc.dirty)
83+}
84+
85+func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) {
86+ repo := repomocks.NewMockSessionStateRepository(t)
87+ calls := 0
88+ nonFKErr := errors.New("database is read-only")
89+ repo.EXPECT().
90+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
91+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
92+ calls++
93+ return nonFKErr
94+ })
95+
96+ uc := usecase.NewSnapshotSessionUseCase(repo)
97+ svc := NewService(uc, &testProvider{
98+ sessionID: "20260207_120000_non_fk",
99+ tabList: entity.NewTabList(),
100+ }, 1)
101+ svc.retryDelay = time.Millisecond
102+ svc.ready = true
103+ svc.dirty = true
104+
105+ err := svc.saveSnapshot(context.Background())
106+ require.Error(t, err)
107+ require.ErrorIs(t, err, nonFKErr)
108+ assert.Equal(t, 1, calls)
109+ assert.True(t, svc.dirty)
110+}
111+
112+func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
113+ repo := repomocks.NewMockSessionStateRepository(t)
114+ saved := make(chan struct{}, 1)
115+ repo.EXPECT().
116+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
117+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
118+ saved <- struct{}{}
119+ return nil
120+ })
121+
122+ uc := usecase.NewSnapshotSessionUseCase(repo)
123+ svc := NewService(uc, &testProvider{
124+ sessionID: "20260207_120000_ready_flush",
125+ tabList: entity.NewTabList(),
126+ }, 1)
127+ svc.Start(context.Background())
128+ svc.dirty = true
129+
130+ svc.SetReady()
131+
132+ select {
133+ case <-saved:
134+ case <-time.After(500 * time.Millisecond):
135+ t.Fatal("expected pending snapshot to be saved after SetReady")
136+ }
137+ assert.False(t, svc.dirty)
138+}
@@ -0,0 +1,138 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+type testProvider struct {
18+ sessionID entity.SessionID
19+ tabList *entity.TabList
20+}
21+
22+func (p *testProvider) GetTabList() *entity.TabList {
23+ return p.tabList
24+}
25+
26+func (p *testProvider) GetSessionID() entity.SessionID {
27+ return p.sessionID
28+}
29+
30+func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
31+ repo := repomocks.NewMockSessionStateRepository(t)
32+ calls := 0
33+ repo.EXPECT().
34+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
35+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
36+ calls++
37+ if calls == 1 {
38+ return errors.New("FOREIGN KEY constraint failed")
39+ }
40+ return nil
41+ })
42+
43+ uc := usecase.NewSnapshotSessionUseCase(repo)
44+ svc := NewService(uc, &testProvider{
45+ sessionID: "20260207_120000_fk_retry_ok",
46+ tabList: entity.NewTabList(),
47+ }, 1)
48+ svc.retryDelay = time.Millisecond
49+ svc.ready = true
50+ svc.dirty = true
51+
52+ err := svc.saveSnapshot(context.Background())
53+ require.NoError(t, err)
54+ assert.Equal(t, 2, calls)
55+ assert.False(t, svc.dirty)
56+}
57+
58+func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) {
59+ repo := repomocks.NewMockSessionStateRepository(t)
60+ calls := 0
61+ fkErr := errors.New("save session snapshot: FOREIGN KEY constraint failed")
62+ repo.EXPECT().
63+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
64+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
65+ calls++
66+ return fkErr
67+ })
68+
69+ uc := usecase.NewSnapshotSessionUseCase(repo)
70+ svc := NewService(uc, &testProvider{
71+ sessionID: "20260207_120000_fk_retry_fail",
72+ tabList: entity.NewTabList(),
73+ }, 1)
74+ svc.retryDelay = time.Millisecond
75+ svc.ready = true
76+ svc.dirty = true
77+
78+ err := svc.saveSnapshot(context.Background())
79+ require.Error(t, err)
80+ require.ErrorIs(t, err, fkErr)
81+ assert.Equal(t, svc.retries+1, calls)
82+ assert.True(t, svc.dirty)
83+}
84+
85+func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) {
86+ repo := repomocks.NewMockSessionStateRepository(t)
87+ calls := 0
88+ nonFKErr := errors.New("database is read-only")
89+ repo.EXPECT().
90+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
91+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
92+ calls++
93+ return nonFKErr
94+ })
95+
96+ uc := usecase.NewSnapshotSessionUseCase(repo)
97+ svc := NewService(uc, &testProvider{
98+ sessionID: "20260207_120000_non_fk",
99+ tabList: entity.NewTabList(),
100+ }, 1)
101+ svc.retryDelay = time.Millisecond
102+ svc.ready = true
103+ svc.dirty = true
104+
105+ err := svc.saveSnapshot(context.Background())
106+ require.Error(t, err)
107+ require.ErrorIs(t, err, nonFKErr)
108+ assert.Equal(t, 1, calls)
109+ assert.True(t, svc.dirty)
110+}
111+
112+func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
113+ repo := repomocks.NewMockSessionStateRepository(t)
114+ saved := make(chan struct{}, 1)
115+ repo.EXPECT().
116+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
117+ RunAndReturn(func(_ context.Context, _ *entity.SessionState) error {
118+ saved <- struct{}{}
119+ return nil
120+ })
121+
122+ uc := usecase.NewSnapshotSessionUseCase(repo)
123+ svc := NewService(uc, &testProvider{
124+ sessionID: "20260207_120000_ready_flush",
125+ tabList: entity.NewTabList(),
126+ }, 1)
127+ svc.Start(context.Background())
128+ svc.dirty = true
129+
130+ svc.SetReady()
131+
132+ select {
133+ case <-saved:
134+ case <-time.After(500 * time.Millisecond):
135+ t.Fatal("expected pending snapshot to be saved after SetReady")
136+ }
137+ assert.False(t, svc.dirty)
138+}

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