detached 2f401f2

refactor(session): replace marker-based crash detection with PID file

The session system used 3 marker files + 1 lock file per session
(startup.marker, shutdown.marker, abrupt.marker, .lock), accumulating
hundreds of files over time and adding I/O overhead at every startup.

Replace with a single dumber.pid file:
- Written at startup, removed on clean shutdown
- If present at next startup with a dead process: previous crash detected
- Crash report generated from the last session log

Removed:
- session_exit_classification.go (marker-based exit analysis)
- session_lock.go (dead code, never called)
- All marker read/write/sweep functions
- lockDir parameter from ListSessions, DeleteSession, GetLastRestorable
  use cases

Added:
- Log file path printed at startup for easy copy-paste
- sweepLegacyMarkers() auto-cleans old marker files on first run

Session log + crash reports (JSON/MD) + DB sessions are preserved.
commit
2f401f2561d44a712abfda8e42c3a444967347ff
author
brice · · 2026-03-20 18:59 UTC
committer
brice · · 2026-03-28 04:37 UTC
parent
037fd9b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +311 -1172
M internal/application/usecase/delete_session.go +2 -5
@@ -19,19 +19,16 @@ var (
1919 type DeleteSessionUseCase struct {
2020 stateRepo repository.SessionStateRepository
2121 sessionRepo repository.SessionRepository
22- lockDir string
2322 }
2423
2524 // NewDeleteSessionUseCase creates a new DeleteSessionUseCase.
2625 func NewDeleteSessionUseCase(
2726 stateRepo repository.SessionStateRepository,
2827 sessionRepo repository.SessionRepository,
29- lockDir string,
3028 ) *DeleteSessionUseCase {
3129 return &DeleteSessionUseCase{
3230 stateRepo: stateRepo,
3331 sessionRepo: sessionRepo,
34- lockDir: lockDir,
3532 }
3633 }
3734
@@ -60,8 +57,8 @@ func (uc *DeleteSessionUseCase) Execute(ctx context.Context, input DeleteSession
6057 return ErrSessionNotFound
6158 }
6259
63- // Check not active (EndedAt is nil OR has lock file)
64- if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) {
60+ // Don't delete active sessions.
61+ if session.IsActive() {
6562 return ErrCannotDeleteActiveSession
6663 }
6764
@@ -19,19 +19,16 @@ var (
19 type DeleteSessionUseCase struct {19 type DeleteSessionUseCase struct {
20 stateRepo repository.SessionStateRepository20 stateRepo repository.SessionStateRepository
21 sessionRepo repository.SessionRepository21 sessionRepo repository.SessionRepository
22- lockDir string
23 }22 }
24 23
25 // NewDeleteSessionUseCase creates a new DeleteSessionUseCase.24 // NewDeleteSessionUseCase creates a new DeleteSessionUseCase.
26 func NewDeleteSessionUseCase(25 func NewDeleteSessionUseCase(
27 stateRepo repository.SessionStateRepository,26 stateRepo repository.SessionStateRepository,
28 sessionRepo repository.SessionRepository,27 sessionRepo repository.SessionRepository,
29- lockDir string,
30 ) *DeleteSessionUseCase {28 ) *DeleteSessionUseCase {
31 return &DeleteSessionUseCase{29 return &DeleteSessionUseCase{
32 stateRepo: stateRepo,30 stateRepo: stateRepo,
33 sessionRepo: sessionRepo,31 sessionRepo: sessionRepo,
34- lockDir: lockDir,
35 }32 }
36 }33 }
37 34
@@ -60,8 +57,8 @@ func (uc *DeleteSessionUseCase) Execute(ctx context.Context, input DeleteSession
60 return ErrSessionNotFound57 return ErrSessionNotFound
61 }58 }
62 59
63- // Check not active (EndedAt is nil OR has lock file)60+ // Don't delete active sessions.
64- if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) {61+ if session.IsActive() {
65 return ErrCannotDeleteActiveSession62 return ErrCannotDeleteActiveSession
66 }63 }
67 64
M internal/application/usecase/delete_session_test.go +8 -44
@@ -2,8 +2,6 @@ package usecase_test
22
33 import (
44 "errors"
5- "os"
6- "path/filepath"
75 "testing"
86 "time"
97
@@ -34,7 +32,7 @@ func TestDeleteSessionUseCase_Execute_Success(t *testing.T) {
3432 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
3533 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
3634
37- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
35+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
3836
3937 err := uc.Execute(ctx, usecase.DeleteSessionInput{
4038 SessionID: sessionID,
@@ -51,7 +49,7 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteCurrentSession(t *testing.T) {
5149
5250 sessionID := entity.SessionID("20251224_120000_current")
5351
54- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
52+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
5553
5654 err := uc.Execute(ctx, usecase.DeleteSessionInput{
5755 SessionID: sessionID,
@@ -71,7 +69,7 @@ func TestDeleteSessionUseCase_Execute_SessionNotFound(t *testing.T) {
7169
7270 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, nil)
7371
74- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
72+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
7573
7674 err := uc.Execute(ctx, usecase.DeleteSessionInput{
7775 SessionID: sessionID,
@@ -99,7 +97,7 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) {
9997
10098 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
10199
102- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
100+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
103101
104102 err := uc.Execute(ctx, usecase.DeleteSessionInput{
105103 SessionID: sessionID,
@@ -109,40 +107,6 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) {
109107 assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
110108 }
111109
112-func TestDeleteSessionUseCase_Execute_CannotDeleteLockedSession(t *testing.T) {
113- ctx := testContext()
114-
115- stateRepo := repomocks.NewMockSessionStateRepository(t)
116- sessionRepo := repomocks.NewMockSessionRepository(t)
117-
118- // Create a temporary lock directory and lock file
119- lockDir := t.TempDir()
120- sessionID := entity.SessionID("20251224_120000_locked")
121- lockPath := filepath.Join(lockDir, "session_"+string(sessionID)+".lock")
122- f, err := os.Create(lockPath)
123- require.NoError(t, err)
124- f.Close()
125-
126- endedAt := time.Now().Add(-time.Hour)
127- session := &entity.Session{
128- ID: sessionID,
129- Type: entity.SessionTypeBrowser,
130- StartedAt: time.Now().Add(-2 * time.Hour),
131- EndedAt: &endedAt, // Ended, but has lock file
132- }
133-
134- sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
135-
136- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, lockDir)
137-
138- err = uc.Execute(ctx, usecase.DeleteSessionInput{
139- SessionID: sessionID,
140- CurrentSessionID: "other-session",
141- })
142- require.Error(t, err)
143- assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
144-}
145-
146110 func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {
147111 ctx := testContext()
148112
@@ -153,7 +117,7 @@ func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {
153117
154118 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, errors.New("db error"))
155119
156- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
120+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
157121
158122 err := uc.Execute(ctx, usecase.DeleteSessionInput{
159123 SessionID: sessionID,
@@ -184,7 +148,7 @@ func TestDeleteSessionUseCase_Execute_DeleteSnapshotErrorIgnored(t *testing.T) {
184148 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows"))
185149 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
186150
187- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
151+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
188152
189153 err := uc.Execute(ctx, usecase.DeleteSessionInput{
190154 SessionID: sessionID,
@@ -214,7 +178,7 @@ func TestDeleteSessionUseCase_Execute_DeleteSessionError(t *testing.T) {
214178 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
215179 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(errors.New("delete failed"))
216180
217- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
181+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
218182
219183 err := uc.Execute(ctx, usecase.DeleteSessionInput{
220184 SessionID: sessionID,
@@ -244,7 +208,7 @@ func TestDeleteSessionUseCase_Execute_EmptyCurrentSessionAllowed(t *testing.T) {
244208 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
245209 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
246210
247- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
211+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
248212
249213 // CurrentSessionID is empty (e.g., CLI context)
250214 err := uc.Execute(ctx, usecase.DeleteSessionInput{
@@ -2,8 +2,6 @@ package usecase_test
2 2
3 import (3 import (
4 "errors"4 "errors"
5- "os"
6- "path/filepath"
7 "testing"5 "testing"
8 "time"6 "time"
9 7
@@ -34,7 +32,7 @@ func TestDeleteSessionUseCase_Execute_Success(t *testing.T) {
34 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)32 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
35 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)33 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
36 34
37- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")35+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
38 36
39 err := uc.Execute(ctx, usecase.DeleteSessionInput{37 err := uc.Execute(ctx, usecase.DeleteSessionInput{
40 SessionID: sessionID,38 SessionID: sessionID,
@@ -51,7 +49,7 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteCurrentSession(t *testing.T) {
51 49
52 sessionID := entity.SessionID("20251224_120000_current")50 sessionID := entity.SessionID("20251224_120000_current")
53 51
54- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")52+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
55 53
56 err := uc.Execute(ctx, usecase.DeleteSessionInput{54 err := uc.Execute(ctx, usecase.DeleteSessionInput{
57 SessionID: sessionID,55 SessionID: sessionID,
@@ -71,7 +69,7 @@ func TestDeleteSessionUseCase_Execute_SessionNotFound(t *testing.T) {
71 69
72 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, nil)70 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, nil)
73 71
74- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")72+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
75 73
76 err := uc.Execute(ctx, usecase.DeleteSessionInput{74 err := uc.Execute(ctx, usecase.DeleteSessionInput{
77 SessionID: sessionID,75 SessionID: sessionID,
@@ -99,7 +97,7 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) {
99 97
100 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)98 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
101 99
102- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")100+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
103 101
104 err := uc.Execute(ctx, usecase.DeleteSessionInput{102 err := uc.Execute(ctx, usecase.DeleteSessionInput{
105 SessionID: sessionID,103 SessionID: sessionID,
@@ -109,40 +107,6 @@ func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) {
109 assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)107 assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
110 }108 }
111 109
112-func TestDeleteSessionUseCase_Execute_CannotDeleteLockedSession(t *testing.T) {
113- ctx := testContext()
114-
115- stateRepo := repomocks.NewMockSessionStateRepository(t)
116- sessionRepo := repomocks.NewMockSessionRepository(t)
117-
118- // Create a temporary lock directory and lock file
119- lockDir := t.TempDir()
120- sessionID := entity.SessionID("20251224_120000_locked")
121- lockPath := filepath.Join(lockDir, "session_"+string(sessionID)+".lock")
122- f, err := os.Create(lockPath)
123- require.NoError(t, err)
124- f.Close()
125-
126- endedAt := time.Now().Add(-time.Hour)
127- session := &entity.Session{
128- ID: sessionID,
129- Type: entity.SessionTypeBrowser,
130- StartedAt: time.Now().Add(-2 * time.Hour),
131- EndedAt: &endedAt, // Ended, but has lock file
132- }
133-
134- sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
135-
136- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, lockDir)
137-
138- err = uc.Execute(ctx, usecase.DeleteSessionInput{
139- SessionID: sessionID,
140- CurrentSessionID: "other-session",
141- })
142- require.Error(t, err)
143- assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
144-}
145-
146 func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {110 func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {
147 ctx := testContext()111 ctx := testContext()
148 112
@@ -153,7 +117,7 @@ func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {
153 117
154 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, errors.New("db error"))118 sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, errors.New("db error"))
155 119
156- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")120+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
157 121
158 err := uc.Execute(ctx, usecase.DeleteSessionInput{122 err := uc.Execute(ctx, usecase.DeleteSessionInput{
159 SessionID: sessionID,123 SessionID: sessionID,
@@ -184,7 +148,7 @@ func TestDeleteSessionUseCase_Execute_DeleteSnapshotErrorIgnored(t *testing.T) {
184 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows"))148 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows"))
185 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)149 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
186 150
187- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")151+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
188 152
189 err := uc.Execute(ctx, usecase.DeleteSessionInput{153 err := uc.Execute(ctx, usecase.DeleteSessionInput{
190 SessionID: sessionID,154 SessionID: sessionID,
@@ -214,7 +178,7 @@ func TestDeleteSessionUseCase_Execute_DeleteSessionError(t *testing.T) {
214 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)178 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
215 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(errors.New("delete failed"))179 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(errors.New("delete failed"))
216 180
217- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")181+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
218 182
219 err := uc.Execute(ctx, usecase.DeleteSessionInput{183 err := uc.Execute(ctx, usecase.DeleteSessionInput{
220 SessionID: sessionID,184 SessionID: sessionID,
@@ -244,7 +208,7 @@ func TestDeleteSessionUseCase_Execute_EmptyCurrentSessionAllowed(t *testing.T) {
244 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)208 stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
245 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)209 sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
246 210
247- uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")211+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo)
248 212
249 // CurrentSessionID is empty (e.g., CLI context)213 // CurrentSessionID is empty (e.g., CLI context)
250 err := uc.Execute(ctx, usecase.DeleteSessionInput{214 err := uc.Execute(ctx, usecase.DeleteSessionInput{
M internal/application/usecase/get_last_restorable_session.go +2 -6
@@ -21,19 +21,16 @@ const (
2121 type GetLastRestorableSessionUseCase struct {
2222 sessionRepo repository.SessionRepository
2323 stateRepo repository.SessionStateRepository
24- lockDir string
2524 }
2625
2726 // NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase.
2827 func NewGetLastRestorableSessionUseCase(
2928 sessionRepo repository.SessionRepository,
3029 stateRepo repository.SessionStateRepository,
31- lockDir string,
3230 ) *GetLastRestorableSessionUseCase {
3331 return &GetLastRestorableSessionUseCase{
3432 sessionRepo: sessionRepo,
3533 stateRepo: stateRepo,
36- lockDir: lockDir,
3734 }
3835 }
3936
@@ -75,9 +72,8 @@ func (uc *GetLastRestorableSessionUseCase) Execute(
7572 continue
7673 }
7774
78- // Check if session is actively running
79- // A session is "active" if ended_at IS NULL AND it has a lock file
80- if session.IsActive() && isSessionLocked(uc.lockDir, session.ID) {
75+ // Skip sessions that haven't been ended yet (still running or crashed).
76+ if session.IsActive() {
8177 log.Debug().
8278 Str("session_id", string(session.ID)).
8379 Msg("auto-restore: skipping active session")
@@ -21,19 +21,16 @@ const (
21 type GetLastRestorableSessionUseCase struct {21 type GetLastRestorableSessionUseCase struct {
22 sessionRepo repository.SessionRepository22 sessionRepo repository.SessionRepository
23 stateRepo repository.SessionStateRepository23 stateRepo repository.SessionStateRepository
24- lockDir string
25 }24 }
26 25
27 // NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase.26 // NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase.
28 func NewGetLastRestorableSessionUseCase(27 func NewGetLastRestorableSessionUseCase(
29 sessionRepo repository.SessionRepository,28 sessionRepo repository.SessionRepository,
30 stateRepo repository.SessionStateRepository,29 stateRepo repository.SessionStateRepository,
31- lockDir string,
32 ) *GetLastRestorableSessionUseCase {30 ) *GetLastRestorableSessionUseCase {
33 return &GetLastRestorableSessionUseCase{31 return &GetLastRestorableSessionUseCase{
34 sessionRepo: sessionRepo,32 sessionRepo: sessionRepo,
35 stateRepo: stateRepo,33 stateRepo: stateRepo,
36- lockDir: lockDir,
37 }34 }
38 }35 }
39 36
@@ -75,9 +72,8 @@ func (uc *GetLastRestorableSessionUseCase) Execute(
75 continue72 continue
76 }73 }
77 74
78- // Check if session is actively running75+ // Skip sessions that haven't been ended yet (still running or crashed).
79- // A session is "active" if ended_at IS NULL AND it has a lock file76+ if session.IsActive() {
80- if session.IsActive() && isSessionLocked(uc.lockDir, session.ID) {
81 log.Debug().77 log.Debug().
82 Str("session_id", string(session.ID)).78 Str("session_id", string(session.ID)).
83 Msg("auto-restore: skipping active session")79 Msg("auto-restore: skipping active session")
M internal/application/usecase/get_last_restorable_session_test.go +14 -27
@@ -1,8 +1,6 @@
11 package usecase_test
22
33 import (
4- "os"
5- "path/filepath"
64 "testing"
75 "time"
86
@@ -21,7 +19,7 @@ func TestGetLastRestorableSessionUseCase_NoSessions(t *testing.T) {
2119
2220 sessionRepo.EXPECT().GetRecent(ctx, 10).Return([]*entity.Session{}, nil)
2321
24- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
22+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
2523
2624 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
2725 ExcludeSessionID: "current",
@@ -53,7 +51,7 @@ func TestGetLastRestorableSessionUseCase_OnlyCurrentSession(t *testing.T) {
5351
5452 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
5553
56- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
54+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
5755
5856 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
5957 ExcludeSessionID: currentID,
@@ -82,7 +80,7 @@ func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) {
8280
8381 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
8482
85- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
83+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
8684
8785 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
8886 ExcludeSessionID: "current",
@@ -92,23 +90,14 @@ func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) {
9290 assert.Empty(t, output.SessionID)
9391 }
9492
95-func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) {
93+func TestGetLastRestorableSessionUseCase_SkipsActiveSessions(t *testing.T) {
9694 ctx := testContext()
9795
98- // Create temp dir for lock files
99- lockDir := t.TempDir()
100-
10196 sessionRepo := repomocks.NewMockSessionRepository(t)
10297 stateRepo := repomocks.NewMockSessionStateRepository(t)
10398
10499 activeID := entity.SessionID("20251225_120000_actv")
105100
106- // Create lock file to simulate running session
107- lockPath := filepath.Join(lockDir, "session_"+string(activeID)+".lock")
108- f, err := os.Create(lockPath)
109- require.NoError(t, err)
110- f.Close()
111-
112101 sessions := []*entity.Session{
113102 {
114103 ID: activeID,
@@ -120,7 +109,7 @@ func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) {
120109
121110 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
122111
123- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)
112+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
124113
125114 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
126115 ExcludeSessionID: "current",
@@ -151,7 +140,7 @@ func TestGetLastRestorableSessionUseCase_SkipsNoSnapshot(t *testing.T) {
151140 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
152141 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) // No snapshot
153142
154- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
143+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
155144
156145 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
157146 ExcludeSessionID: "current",
@@ -189,7 +178,7 @@ func TestGetLastRestorableSessionUseCase_SkipsEmptyTabs(t *testing.T) {
189178 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
190179 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(emptyState, nil)
191180
192- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
181+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
193182
194183 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
195184 ExcludeSessionID: "current",
@@ -241,7 +230,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) {
241230 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
242231 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
243232
244- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
233+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
245234
246235 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
247236 ExcludeSessionID: "current",
@@ -256,21 +245,19 @@ func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) {
256245 func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {
257246 ctx := testContext()
258247
259- // Empty lock dir (no lock files)
260- lockDir := t.TempDir()
261-
262248 sessionRepo := repomocks.NewMockSessionRepository(t)
263249 stateRepo := repomocks.NewMockSessionStateRepository(t)
264250
265251 sessionID := entity.SessionID("20251225_120000_crsh")
266252
267- // Session with no ended_at (crashed) but no lock file
253+ // Crashed session that was cleaned up by background cleanup (EndedAt set).
254+ endedAt := time.Now().Add(-30 * time.Minute)
268255 sessions := []*entity.Session{
269256 {
270257 ID: sessionID,
271258 Type: entity.SessionTypeBrowser,
272259 StartedAt: time.Now().Add(-1 * time.Hour),
273- EndedAt: nil, // Not ended - simulates crash
260+ EndedAt: &endedAt,
274261 },
275262 }
276263
@@ -298,7 +285,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {
298285 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
299286 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
300287
301- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)
288+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
302289
303290 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
304291 ExcludeSessionID: "current",
@@ -349,7 +336,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsMostRecent(t *testing.T) {
349336 stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil)
350337 // Note: GetSnapshot for olderID should NOT be called since we return first match
351338
352- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
339+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
353340
354341 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
355342 ExcludeSessionID: "current",
@@ -397,7 +384,7 @@ func TestGetLastRestorableSessionUseCase_SkipsFirstIfNoSnapshot(t *testing.T) {
397384 stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot
398385 stateRepo.EXPECT().GetSnapshot(ctx, goodID).Return(goodState, nil)
399386
400- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
387+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
401388
402389 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
403390 ExcludeSessionID: "current",
@@ -1,8 +1,6 @@
1 package usecase_test1 package usecase_test
2 2
3 import (3 import (
4- "os"
5- "path/filepath"
6 "testing"4 "testing"
7 "time"5 "time"
8 6
@@ -21,7 +19,7 @@ func TestGetLastRestorableSessionUseCase_NoSessions(t *testing.T) {
21 19
22 sessionRepo.EXPECT().GetRecent(ctx, 10).Return([]*entity.Session{}, nil)20 sessionRepo.EXPECT().GetRecent(ctx, 10).Return([]*entity.Session{}, nil)
23 21
24- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")22+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
25 23
26 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{24 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
27 ExcludeSessionID: "current",25 ExcludeSessionID: "current",
@@ -53,7 +51,7 @@ func TestGetLastRestorableSessionUseCase_OnlyCurrentSession(t *testing.T) {
53 51
54 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)52 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
55 53
56- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")54+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
57 55
58 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{56 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
59 ExcludeSessionID: currentID,57 ExcludeSessionID: currentID,
@@ -82,7 +80,7 @@ func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) {
82 80
83 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)81 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
84 82
85- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")83+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
86 84
87 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{85 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
88 ExcludeSessionID: "current",86 ExcludeSessionID: "current",
@@ -92,23 +90,14 @@ func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) {
92 assert.Empty(t, output.SessionID)90 assert.Empty(t, output.SessionID)
93 }91 }
94 92
95-func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) {93+func TestGetLastRestorableSessionUseCase_SkipsActiveSessions(t *testing.T) {
96 ctx := testContext()94 ctx := testContext()
97 95
98- // Create temp dir for lock files
99- lockDir := t.TempDir()
100-
101 sessionRepo := repomocks.NewMockSessionRepository(t)96 sessionRepo := repomocks.NewMockSessionRepository(t)
102 stateRepo := repomocks.NewMockSessionStateRepository(t)97 stateRepo := repomocks.NewMockSessionStateRepository(t)
103 98
104 activeID := entity.SessionID("20251225_120000_actv")99 activeID := entity.SessionID("20251225_120000_actv")
105 100
106- // Create lock file to simulate running session
107- lockPath := filepath.Join(lockDir, "session_"+string(activeID)+".lock")
108- f, err := os.Create(lockPath)
109- require.NoError(t, err)
110- f.Close()
111-
112 sessions := []*entity.Session{101 sessions := []*entity.Session{
113 {102 {
114 ID: activeID,103 ID: activeID,
@@ -120,7 +109,7 @@ func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) {
120 109
121 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)110 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
122 111
123- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)112+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
124 113
125 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{114 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
126 ExcludeSessionID: "current",115 ExcludeSessionID: "current",
@@ -151,7 +140,7 @@ func TestGetLastRestorableSessionUseCase_SkipsNoSnapshot(t *testing.T) {
151 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)140 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
152 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) // No snapshot141 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) // No snapshot
153 142
154- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")143+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
155 144
156 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{145 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
157 ExcludeSessionID: "current",146 ExcludeSessionID: "current",
@@ -189,7 +178,7 @@ func TestGetLastRestorableSessionUseCase_SkipsEmptyTabs(t *testing.T) {
189 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)178 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
190 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(emptyState, nil)179 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(emptyState, nil)
191 180
192- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")181+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
193 182
194 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{183 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
195 ExcludeSessionID: "current",184 ExcludeSessionID: "current",
@@ -241,7 +230,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) {
241 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)230 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
242 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)231 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
243 232
244- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")233+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
245 234
246 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{235 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
247 ExcludeSessionID: "current",236 ExcludeSessionID: "current",
@@ -256,21 +245,19 @@ func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) {
256 func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {245 func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {
257 ctx := testContext()246 ctx := testContext()
258 247
259- // Empty lock dir (no lock files)
260- lockDir := t.TempDir()
261-
262 sessionRepo := repomocks.NewMockSessionRepository(t)248 sessionRepo := repomocks.NewMockSessionRepository(t)
263 stateRepo := repomocks.NewMockSessionStateRepository(t)249 stateRepo := repomocks.NewMockSessionStateRepository(t)
264 250
265 sessionID := entity.SessionID("20251225_120000_crsh")251 sessionID := entity.SessionID("20251225_120000_crsh")
266 252
267- // Session with no ended_at (crashed) but no lock file253+ // Crashed session that was cleaned up by background cleanup (EndedAt set).
254+ endedAt := time.Now().Add(-30 * time.Minute)
268 sessions := []*entity.Session{255 sessions := []*entity.Session{
269 {256 {
270 ID: sessionID,257 ID: sessionID,
271 Type: entity.SessionTypeBrowser,258 Type: entity.SessionTypeBrowser,
272 StartedAt: time.Now().Add(-1 * time.Hour),259 StartedAt: time.Now().Add(-1 * time.Hour),
273- EndedAt: nil, // Not ended - simulates crash260+ EndedAt: &endedAt,
274 },261 },
275 }262 }
276 263
@@ -298,7 +285,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {
298 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)285 sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
299 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)286 stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
300 287
301- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)288+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
302 289
303 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{290 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
304 ExcludeSessionID: "current",291 ExcludeSessionID: "current",
@@ -349,7 +336,7 @@ func TestGetLastRestorableSessionUseCase_ReturnsMostRecent(t *testing.T) {
349 stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil)336 stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil)
350 // Note: GetSnapshot for olderID should NOT be called since we return first match337 // Note: GetSnapshot for olderID should NOT be called since we return first match
351 338
352- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")339+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
353 340
354 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{341 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
355 ExcludeSessionID: "current",342 ExcludeSessionID: "current",
@@ -397,7 +384,7 @@ func TestGetLastRestorableSessionUseCase_SkipsFirstIfNoSnapshot(t *testing.T) {
397 stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot384 stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot
398 stateRepo.EXPECT().GetSnapshot(ctx, goodID).Return(goodState, nil)385 stateRepo.EXPECT().GetSnapshot(ctx, goodID).Return(goodState, nil)
399 386
400- uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")387+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo)
401 388
402 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{389 output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
403 ExcludeSessionID: "current",390 ExcludeSessionID: "current",
M internal/application/usecase/list_sessions.go +2 -10
@@ -13,19 +13,16 @@ import (
1313 type ListSessionsUseCase struct {
1414 sessionRepo repository.SessionRepository
1515 stateRepo repository.SessionStateRepository
16- lockDir string // Directory where lock files are stored
1716 }
1817
1918 // NewListSessionsUseCase creates a new ListSessionsUseCase.
2019 func NewListSessionsUseCase(
2120 sessionRepo repository.SessionRepository,
2221 stateRepo repository.SessionStateRepository,
23- lockDir string,
2422 ) *ListSessionsUseCase {
2523 return &ListSessionsUseCase{
2624 sessionRepo: sessionRepo,
2725 stateRepo: stateRepo,
28- lockDir: lockDir,
2926 }
3027 }
3128
@@ -73,7 +70,7 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent
7370 info := entity.SessionInfo{
7471 Session: session,
7572 IsCurrent: session.ID == currentSessionID,
76- IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
73+ IsActive: session.IsActive(),
7774 }
7875
7976 if state, ok := snapshotMap[session.ID]; ok {
@@ -94,11 +91,6 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent
9491 return &ListSessionsOutput{Sessions: result}, nil
9592 }
9693
97-// hasActiveLock checks if a session has an active lock file.
98-func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
99- return isSessionLocked(uc.lockDir, sessionID)
100-}
101-
10294 // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
10395 func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {
10496 // Simple bubble sort for small lists
@@ -149,7 +141,7 @@ func (uc *ListSessionsUseCase) GetSessionInfo(
149141 info := &entity.SessionInfo{
150142 Session: session,
151143 IsCurrent: session.ID == currentSessionID,
152- IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
144+ IsActive: session.IsActive(),
153145 UpdatedAt: session.StartedAt,
154146 }
155147
@@ -13,19 +13,16 @@ import (
13 type ListSessionsUseCase struct {13 type ListSessionsUseCase struct {
14 sessionRepo repository.SessionRepository14 sessionRepo repository.SessionRepository
15 stateRepo repository.SessionStateRepository15 stateRepo repository.SessionStateRepository
16- lockDir string // Directory where lock files are stored
17 }16 }
18 17
19 // NewListSessionsUseCase creates a new ListSessionsUseCase.18 // NewListSessionsUseCase creates a new ListSessionsUseCase.
20 func NewListSessionsUseCase(19 func NewListSessionsUseCase(
21 sessionRepo repository.SessionRepository,20 sessionRepo repository.SessionRepository,
22 stateRepo repository.SessionStateRepository,21 stateRepo repository.SessionStateRepository,
23- lockDir string,
24 ) *ListSessionsUseCase {22 ) *ListSessionsUseCase {
25 return &ListSessionsUseCase{23 return &ListSessionsUseCase{
26 sessionRepo: sessionRepo,24 sessionRepo: sessionRepo,
27 stateRepo: stateRepo,25 stateRepo: stateRepo,
28- lockDir: lockDir,
29 }26 }
30 }27 }
31 28
@@ -73,7 +70,7 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent
73 info := entity.SessionInfo{70 info := entity.SessionInfo{
74 Session: session,71 Session: session,
75 IsCurrent: session.ID == currentSessionID,72 IsCurrent: session.ID == currentSessionID,
76- IsActive: session.IsActive() || uc.hasActiveLock(session.ID),73+ IsActive: session.IsActive(),
77 }74 }
78 75
79 if state, ok := snapshotMap[session.ID]; ok {76 if state, ok := snapshotMap[session.ID]; ok {
@@ -94,11 +91,6 @@ func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID ent
94 return &ListSessionsOutput{Sessions: result}, nil91 return &ListSessionsOutput{Sessions: result}, nil
95 }92 }
96 93
97-// hasActiveLock checks if a session has an active lock file.
98-func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
99- return isSessionLocked(uc.lockDir, sessionID)
100-}
101-
102 // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.94 // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
103 func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {95 func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {
104 // Simple bubble sort for small lists96 // Simple bubble sort for small lists
@@ -149,7 +141,7 @@ func (uc *ListSessionsUseCase) GetSessionInfo(
149 info := &entity.SessionInfo{141 info := &entity.SessionInfo{
150 Session: session,142 Session: session,
151 IsCurrent: session.ID == currentSessionID,143 IsCurrent: session.ID == currentSessionID,
152- IsActive: session.IsActive() || uc.hasActiveLock(session.ID),144+ IsActive: session.IsActive(),
153 UpdatedAt: session.StartedAt,145 UpdatedAt: session.StartedAt,
154 }146 }
155 147
M internal/application/usecase/list_sessions_test.go +4 -4
@@ -47,7 +47,7 @@ func TestListSessionsUseCase_Execute_ReturnsSessionsWithState(t *testing.T) {
4747 sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil)
4848 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil)
4949
50- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
50+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
5151
5252 output, err := uc.Execute(ctx, "", 50)
5353 require.NoError(t, err)
@@ -103,7 +103,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing
103103 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
104104 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil)
105105
106- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
106+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
107107
108108 output, err := uc.GetPurgeableSessions(ctx)
109109 require.NoError(t, err)
@@ -139,7 +139,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing.
139139 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
140140 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil)
141141
142- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
142+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
143143
144144 output, err := uc.GetPurgeableSessions(ctx)
145145 require.NoError(t, err)
@@ -167,7 +167,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing
167167 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
168168 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError)
169169
170- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
170+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
171171
172172 output, err := uc.GetPurgeableSessions(ctx)
173173 // Should succeed even if size query fails
@@ -47,7 +47,7 @@ func TestListSessionsUseCase_Execute_ReturnsSessionsWithState(t *testing.T) {
47 sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil)47 sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil)
48 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil)48 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil)
49 49
50- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")50+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
51 51
52 output, err := uc.Execute(ctx, "", 50)52 output, err := uc.Execute(ctx, "", 50)
53 require.NoError(t, err)53 require.NoError(t, err)
@@ -103,7 +103,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing
103 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)103 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
104 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil)104 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil)
105 105
106- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")106+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
107 107
108 output, err := uc.GetPurgeableSessions(ctx)108 output, err := uc.GetPurgeableSessions(ctx)
109 require.NoError(t, err)109 require.NoError(t, err)
@@ -139,7 +139,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing.
139 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)139 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
140 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil)140 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil)
141 141
142- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")142+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
143 143
144 output, err := uc.GetPurgeableSessions(ctx)144 output, err := uc.GetPurgeableSessions(ctx)
145 require.NoError(t, err)145 require.NoError(t, err)
@@ -167,7 +167,7 @@ func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing
167 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)167 stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
168 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError)168 stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError)
169 169
170- uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")170+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo)
171 171
172 output, err := uc.GetPurgeableSessions(ctx)172 output, err := uc.GetPurgeableSessions(ctx)
173 // Should succeed even if size query fails173 // Should succeed even if size query fails
D internal/application/usecase/session_lock.go +0 -21
@@ -1,21 +0,0 @@
1-package usecase
2-
3-import (
4- "fmt"
5- "os"
6- "path/filepath"
7-
8- "github.com/bnema/dumber/internal/domain/entity"
9-)
10-
11-// isSessionLocked checks if a session has an active lock file.
12-// Lock files are created in lockDir with format: session_<sessionID>.lock
13-// This matches the format used by bootstrap.sessionLockPath().
14-func isSessionLocked(lockDir string, sessionID entity.SessionID) bool {
15- if lockDir == "" {
16- return false
17- }
18- lockPath := filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
19- _, err := os.Stat(lockPath)
20- return err == nil
21-}
@@ -1,21 +0,0 @@
1-package usecase
2-
3-import (
4- "fmt"
5- "os"
6- "path/filepath"
7-
8- "github.com/bnema/dumber/internal/domain/entity"
9-)
10-
11-// isSessionLocked checks if a session has an active lock file.
12-// Lock files are created in lockDir with format: session_<sessionID>.lock
13-// This matches the format used by bootstrap.sessionLockPath().
14-func isSessionLocked(lockDir string, sessionID entity.SessionID) bool {
15- if lockDir == "" {
16- return false
17- }
18- lockPath := filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
19- _, err := os.Stat(lockPath)
20- return err == nil
21-}
M internal/bootstrap/crash_report.go +91 -153
@@ -11,7 +11,6 @@ import (
1111 "regexp"
1212 "runtime"
1313 "sort"
14- "strconv"
1514 "strings"
1615 "time"
1716
@@ -25,23 +24,20 @@ const (
2524 scannerMaxTokenSize = 256 * 1024
2625 )
2726
28-type unexpectedCloseReport struct {
29- ReportVersion int `json:"report_version"`
30- GeneratedAt string `json:"generated_at"`
31- SessionID string `json:"session_id"`
32- Classification SessionExitClassification `json:"classification"`
33- StartupPID int `json:"startup_pid,omitempty"`
34- StartupPPID int `json:"startup_ppid,omitempty"`
35- MarkerFiles map[string]string `json:"marker_files"`
36- SessionLogFile string `json:"session_log_file,omitempty"`
37- SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
38- ReporterProcess unexpectedCloseReporter `json:"reporter_process"`
39- CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"`
40- IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"`
41- GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
27+type crashReport struct {
28+ ReportVersion int `json:"report_version"`
29+ GeneratedAt string `json:"generated_at"`
30+ SessionID string `json:"session_id"`
31+ CrashedPID int `json:"crashed_pid,omitempty"`
32+ SessionLogFile string `json:"session_log_file,omitempty"`
33+ SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
34+ ReporterProcess crashReporter `json:"reporter_process"`
35+ CoreDumpDiagnostics crashCoreDump `json:"core_dump_diagnostics"`
36+ IssueTemplate crashIssueTemplate `json:"issue_template"`
37+ GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
4238 }
4339
44-type unexpectedCloseReporter struct {
40+type crashReporter struct {
4541 GeneratedBy string `json:"generated_by"`
4642 GoVersion string `json:"go_version"`
4743 GOOS string `json:"goos"`
@@ -50,33 +46,26 @@ type unexpectedCloseReporter struct {
5046 PPID int `json:"ppid"`
5147 }
5248
53-type unexpectedCloseCoreDump struct {
49+type crashCoreDump struct {
5450 RLimitCoreSoft string `json:"rlimit_core_soft"`
5551 RLimitCoreHard string `json:"rlimit_core_hard"`
5652 Hint string `json:"hint"`
5753 }
5854
59-type unexpectedCloseIssueTemplate struct {
55+type crashIssueTemplate struct {
6056 Title string `json:"title"`
6157 Summary string `json:"summary"`
6258 }
6359
64-func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
65- if lockDir == "" || sessionID == "" {
66- return "", errors.New("lockDir and sessionID are required")
60+// writeCrashReport generates JSON + Markdown crash reports for a crashed session.
61+// Returns the JSON report path, or empty string if the report already exists.
62+func writeCrashReport(logDir, sessionID string, crashedPID int) (string, error) {
63+ if logDir == "" || sessionID == "" {
64+ return "", errors.New("logDir and sessionID are required")
6765 }
6866
69- classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
70- if classifyErr != nil {
71- return "", classifyErr
72- }
73-
74- if classification.Class == SessionExitCleanExit {
75- return "", nil
76- }
77-
78- reportsDir := filepath.Join(lockDir, crashReportsDirName)
79- if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil {
67+ reportsDir := filepath.Join(logDir, crashReportsDirName)
68+ if err := os.MkdirAll(reportsDir, dirPerm); err != nil {
8069 return "", err
8170 }
8271
@@ -84,47 +73,34 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
8473
8574 jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
8675 markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
87- if _, statErr := os.Stat(jsonPath); statErr == nil {
88- if _, mdErr := os.Stat(markdownPath); mdErr == nil {
89- return jsonPath, nil
90- }
91- raw, readErr := os.ReadFile(jsonPath)
92- if readErr != nil {
93- return "", readErr
94- }
95- var existing unexpectedCloseReport
96- if err := json.Unmarshal(raw, &existing); err != nil {
97- return "", err
98- }
99- markdown := buildUnexpectedCloseMarkdown(existing)
100- if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
101- return "", err
102- }
76+
77+ // Skip if report already exists.
78+ if _, err := os.Stat(jsonPath); err == nil {
10379 return jsonPath, nil
104- } else if !os.IsNotExist(statErr) {
105- return "", statErr
10680 }
10781
108- report := unexpectedCloseReport{
109- ReportVersion: 1,
110- GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
111- SessionID: sessionID,
112- Classification: classification,
113- MarkerFiles: reportMarkerFiles(lockDir, sessionID),
114- ReporterProcess: currentReporter(),
82+ report := crashReport{
83+ ReportVersion: 2,
84+ GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
85+ SessionID: sessionID,
86+ CrashedPID: crashedPID,
87+ ReporterProcess: crashReporter{
88+ GeneratedBy: "dumber",
89+ GoVersion: runtime.Version(),
90+ GOOS: runtime.GOOS,
91+ GOARCH: runtime.GOARCH,
92+ PID: os.Getpid(),
93+ PPID: os.Getppid(),
94+ },
11595 CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
116- IssueTemplate: unexpectedCloseIssueTemplate{
96+ IssueTemplate: crashIssueTemplate{
11797 Title: fmt.Sprintf("Unexpected close in session %s", sessionID),
11898 Summary: "Dumber closed unexpectedly and generated this report automatically.",
11999 },
120100 }
121101
122- startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID)
123- report.StartupPID = startupPID
124- report.StartupPPID = startupPPID
125-
126- logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
127- if _, statErr := os.Stat(logPath); statErr == nil {
102+ logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID))
103+ if _, err := os.Stat(logPath); err == nil {
128104 report.SessionLogFile = logPath
129105 report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
130106 }
@@ -136,113 +112,35 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
136112 return "", err
137113 }
138114 payload = append(payload, '\n')
139- if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil {
115+ if err := os.WriteFile(jsonPath, payload, filePerm); err != nil {
140116 return "", err
141117 }
142118
143- markdown := buildUnexpectedCloseMarkdown(report)
144- if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
119+ markdown := buildCrashMarkdown(report)
120+ if err := os.WriteFile(markdownPath, []byte(markdown), filePerm); err != nil {
145121 return "", err
146122 }
147123
148124 return jsonPath, nil
149125 }
150126
151-func reportMarkerFiles(lockDir, sessionID string) map[string]string {
152- return map[string]string{
153- "startup": startupMarkerPath(lockDir, sessionID),
154- "shutdown": shutdownMarkerPath(lockDir, sessionID),
155- "abrupt": abruptMarkerPath(lockDir, sessionID),
156- }
157-}
158-
159-func currentReporter() unexpectedCloseReporter {
160- return unexpectedCloseReporter{
161- GeneratedBy: "dumber",
162- GoVersion: runtime.Version(),
163- GOOS: runtime.GOOS,
164- GOARCH: runtime.GOARCH,
165- PID: os.Getpid(),
166- PPID: os.Getppid(),
167- }
168-}
169-
170-func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
171- raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
172- if err != nil {
173- raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID))
174- if err != nil {
175- return 0, 0
176- }
177- }
178-
179- pid, _ := strconv.Atoi(markerValue(raw, "pid="))
180- ppid, _ := strconv.Atoi(markerValue(raw, "ppid="))
181- return pid, ppid
182-}
183-
184-func readRedactedLogTail(path string, lines int) []string {
185- if lines <= 0 {
186- return nil
187- }
188-
189- file, err := os.Open(path)
190- if err != nil {
191- return nil
192- }
193- defer func() { _ = file.Close() }()
194-
195- ring := make([]string, lines)
196- count := 0
197- scanner := bufio.NewScanner(file)
198- scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
199- for scanner.Scan() {
200- ring[count%lines] = redactSensitiveContent(scanner.Text())
201- count++
202- }
203-
204- var result []string
205- if count <= lines {
206- result = make([]string, count)
207- copy(result, ring[:count])
208- } else {
209- result = make([]string, lines)
210- start := count % lines
211- copy(result, ring[start:])
212- copy(result[lines-start:], ring[:start])
213- }
214-
215- if scanner.Err() != nil {
216- result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
217- }
218- return result
219-}
220-
221-func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
127+func buildCrashMarkdown(report crashReport) string {
222128 lines := []string{
223129 "# Unexpected Close Report",
224130 "",
225131 fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
226132 fmt.Sprintf("Session: `%s`", report.SessionID),
227- fmt.Sprintf("Class: `%s`", report.Classification.Class),
228- fmt.Sprintf("Inference: `%s`", report.Classification.Inference),
229- fmt.Sprintf("Reason: `%s`", report.Classification.Reason),
133+ fmt.Sprintf("Crashed PID: `%d`", report.CrashedPID),
230134 "",
231- "## Process Context",
232- fmt.Sprintf("- startup pid: `%d`", report.StartupPID),
233- fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID),
234- fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID),
235- fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID),
135+ "## Reporter",
136+ fmt.Sprintf("- pid: `%d`", report.ReporterProcess.PID),
137+ fmt.Sprintf("- ppid: `%d`", report.ReporterProcess.PPID),
138+ fmt.Sprintf("- go: `%s`", report.ReporterProcess.GoVersion),
236139 "",
237140 "## Core Dump Diagnostics",
238141 fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
239142 fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
240143 fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
241- "",
242- "## Marker Files",
243- fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]),
244- fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]),
245- fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]),
246144 }
247145
248146 if len(report.SessionLogTail) > 0 {
@@ -266,8 +164,7 @@ func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
266164 "",
267165 "### Crash report",
268166 fmt.Sprintf("- session id: `%s`", report.SessionID),
269- fmt.Sprintf("- class: `%s`", report.Classification.Class),
270- fmt.Sprintf("- reason: `%s`", report.Classification.Reason),
167+ fmt.Sprintf("- crashed pid: `%d`", report.CrashedPID),
271168 "",
272169 "### Additional context",
273170 "- distro:",
@@ -279,6 +176,47 @@ func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
279176 return strings.Join(lines, "\n") + "\n"
280177 }
281178
179+// ---------------------------------------------------------------------------
180+// Redaction helpers (kept from previous implementation)
181+// ---------------------------------------------------------------------------
182+
183+func readRedactedLogTail(path string, lines int) []string {
184+ if lines <= 0 {
185+ return nil
186+ }
187+
188+ file, err := os.Open(path)
189+ if err != nil {
190+ return nil
191+ }
192+ defer func() { _ = file.Close() }()
193+
194+ ring := make([]string, lines)
195+ count := 0
196+ scanner := bufio.NewScanner(file)
197+ scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
198+ for scanner.Scan() {
199+ ring[count%lines] = redactSensitiveContent(scanner.Text())
200+ count++
201+ }
202+
203+ var result []string
204+ if count <= lines {
205+ result = make([]string, count)
206+ copy(result, ring[:count])
207+ } else {
208+ result = make([]string, lines)
209+ start := count % lines
210+ copy(result, ring[start:])
211+ copy(result[lines-start:], ring[:start])
212+ }
213+
214+ if scanner.Err() != nil {
215+ result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
216+ }
217+ return result
218+}
219+
282220 var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
283221
284222 var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
@@ -11,7 +11,6 @@ import (
11 "regexp"11 "regexp"
12 "runtime"12 "runtime"
13 "sort"13 "sort"
14- "strconv"
15 "strings"14 "strings"
16 "time"15 "time"
17 16
@@ -25,23 +24,20 @@ const (
25 scannerMaxTokenSize = 256 * 102424 scannerMaxTokenSize = 256 * 1024
26 )25 )
27 26
28-type unexpectedCloseReport struct {27+type crashReport struct {
29- ReportVersion int `json:"report_version"`28+ ReportVersion int `json:"report_version"`
30- GeneratedAt string `json:"generated_at"`29+ GeneratedAt string `json:"generated_at"`
31- SessionID string `json:"session_id"`30+ SessionID string `json:"session_id"`
32- Classification SessionExitClassification `json:"classification"`31+ CrashedPID int `json:"crashed_pid,omitempty"`
33- StartupPID int `json:"startup_pid,omitempty"`32+ SessionLogFile string `json:"session_log_file,omitempty"`
34- StartupPPID int `json:"startup_ppid,omitempty"`33+ SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`
35- MarkerFiles map[string]string `json:"marker_files"`34+ ReporterProcess crashReporter `json:"reporter_process"`
36- SessionLogFile string `json:"session_log_file,omitempty"`35+ CoreDumpDiagnostics crashCoreDump `json:"core_dump_diagnostics"`
37- SessionLogTail []string `json:"session_log_tail_redacted,omitempty"`36+ IssueTemplate crashIssueTemplate `json:"issue_template"`
38- ReporterProcess unexpectedCloseReporter `json:"reporter_process"`37+ GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
39- CoreDumpDiagnostics unexpectedCloseCoreDump `json:"core_dump_diagnostics"`
40- IssueTemplate unexpectedCloseIssueTemplate `json:"issue_template"`
41- GeneratedMarkdownRef string `json:"generated_markdown_ref,omitempty"`
42 }38 }
43 39
44-type unexpectedCloseReporter struct {40+type crashReporter struct {
45 GeneratedBy string `json:"generated_by"`41 GeneratedBy string `json:"generated_by"`
46 GoVersion string `json:"go_version"`42 GoVersion string `json:"go_version"`
47 GOOS string `json:"goos"`43 GOOS string `json:"goos"`
@@ -50,33 +46,26 @@ type unexpectedCloseReporter struct {
50 PPID int `json:"ppid"`46 PPID int `json:"ppid"`
51 }47 }
52 48
53-type unexpectedCloseCoreDump struct {49+type crashCoreDump struct {
54 RLimitCoreSoft string `json:"rlimit_core_soft"`50 RLimitCoreSoft string `json:"rlimit_core_soft"`
55 RLimitCoreHard string `json:"rlimit_core_hard"`51 RLimitCoreHard string `json:"rlimit_core_hard"`
56 Hint string `json:"hint"`52 Hint string `json:"hint"`
57 }53 }
58 54
59-type unexpectedCloseIssueTemplate struct {55+type crashIssueTemplate struct {
60 Title string `json:"title"`56 Title string `json:"title"`
61 Summary string `json:"summary"`57 Summary string `json:"summary"`
62 }58 }
63 59
64-func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {60+// writeCrashReport generates JSON + Markdown crash reports for a crashed session.
65- if lockDir == "" || sessionID == "" {61+// Returns the JSON report path, or empty string if the report already exists.
66- return "", errors.New("lockDir and sessionID are required")62+func writeCrashReport(logDir, sessionID string, crashedPID int) (string, error) {
63+ if logDir == "" || sessionID == "" {
64+ return "", errors.New("logDir and sessionID are required")
67 }65 }
68 66
69- classification, classifyErr := ClassifySessionExitFromMarkers(lockDir, sessionID)67+ reportsDir := filepath.Join(logDir, crashReportsDirName)
70- if classifyErr != nil {68+ if err := os.MkdirAll(reportsDir, dirPerm); err != nil {
71- return "", classifyErr
72- }
73-
74- if classification.Class == SessionExitCleanExit {
75- return "", nil
76- }
77-
78- reportsDir := filepath.Join(lockDir, crashReportsDirName)
79- if err := os.MkdirAll(reportsDir, lockDirPerm); err != nil {
80 return "", err69 return "", err
81 }70 }
82 71
@@ -84,47 +73,34 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
84 73
85 jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))74 jsonPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.json", sessionID))
86 markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))75 markdownPath := filepath.Join(reportsDir, fmt.Sprintf("session_%s.crash.md", sessionID))
87- if _, statErr := os.Stat(jsonPath); statErr == nil {76+
88- if _, mdErr := os.Stat(markdownPath); mdErr == nil {77+ // Skip if report already exists.
89- return jsonPath, nil78+ if _, err := os.Stat(jsonPath); err == nil {
90- }
91- raw, readErr := os.ReadFile(jsonPath)
92- if readErr != nil {
93- return "", readErr
94- }
95- var existing unexpectedCloseReport
96- if err := json.Unmarshal(raw, &existing); err != nil {
97- return "", err
98- }
99- markdown := buildUnexpectedCloseMarkdown(existing)
100- if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {
101- return "", err
102- }
103 return jsonPath, nil79 return jsonPath, nil
104- } else if !os.IsNotExist(statErr) {
105- return "", statErr
106 }80 }
107 81
108- report := unexpectedCloseReport{82+ report := crashReport{
109- ReportVersion: 1,83+ ReportVersion: 2,
110- GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),84+ GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
111- SessionID: sessionID,85+ SessionID: sessionID,
112- Classification: classification,86+ CrashedPID: crashedPID,
113- MarkerFiles: reportMarkerFiles(lockDir, sessionID),87+ ReporterProcess: crashReporter{
114- ReporterProcess: currentReporter(),88+ GeneratedBy: "dumber",
89+ GoVersion: runtime.Version(),
90+ GOOS: runtime.GOOS,
91+ GOARCH: runtime.GOARCH,
92+ PID: os.Getpid(),
93+ PPID: os.Getppid(),
94+ },
115 CoreDumpDiagnostics: collectCoreDumpDiagnostics(),95 CoreDumpDiagnostics: collectCoreDumpDiagnostics(),
116- IssueTemplate: unexpectedCloseIssueTemplate{96+ IssueTemplate: crashIssueTemplate{
117 Title: fmt.Sprintf("Unexpected close in session %s", sessionID),97 Title: fmt.Sprintf("Unexpected close in session %s", sessionID),
118 Summary: "Dumber closed unexpectedly and generated this report automatically.",98 Summary: "Dumber closed unexpectedly and generated this report automatically.",
119 },99 },
120 }100 }
121 101
122- startupPID, startupPPID := readStartupProcessIDs(lockDir, sessionID)102+ logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID))
123- report.StartupPID = startupPID103+ if _, err := os.Stat(logPath); err == nil {
124- report.StartupPPID = startupPPID
125-
126- logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
127- if _, statErr := os.Stat(logPath); statErr == nil {
128 report.SessionLogFile = logPath104 report.SessionLogFile = logPath
129 report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)105 report.SessionLogTail = readRedactedLogTail(logPath, logTailLineCount)
130 }106 }
@@ -136,113 +112,35 @@ func writeUnexpectedCloseReport(lockDir, sessionID string) (string, error) {
136 return "", err112 return "", err
137 }113 }
138 payload = append(payload, '\n')114 payload = append(payload, '\n')
139- if err := os.WriteFile(jsonPath, payload, markerFilePerm); err != nil {115+ if err := os.WriteFile(jsonPath, payload, filePerm); err != nil {
140 return "", err116 return "", err
141 }117 }
142 118
143- markdown := buildUnexpectedCloseMarkdown(report)119+ markdown := buildCrashMarkdown(report)
144- if err := os.WriteFile(markdownPath, []byte(markdown), markerFilePerm); err != nil {120+ if err := os.WriteFile(markdownPath, []byte(markdown), filePerm); err != nil {
145 return "", err121 return "", err
146 }122 }
147 123
148 return jsonPath, nil124 return jsonPath, nil
149 }125 }
150 126
151-func reportMarkerFiles(lockDir, sessionID string) map[string]string {127+func buildCrashMarkdown(report crashReport) string {
152- return map[string]string{
153- "startup": startupMarkerPath(lockDir, sessionID),
154- "shutdown": shutdownMarkerPath(lockDir, sessionID),
155- "abrupt": abruptMarkerPath(lockDir, sessionID),
156- }
157-}
158-
159-func currentReporter() unexpectedCloseReporter {
160- return unexpectedCloseReporter{
161- GeneratedBy: "dumber",
162- GoVersion: runtime.Version(),
163- GOOS: runtime.GOOS,
164- GOARCH: runtime.GOARCH,
165- PID: os.Getpid(),
166- PPID: os.Getppid(),
167- }
168-}
169-
170-func readStartupProcessIDs(lockDir, sessionID string) (int, int) {
171- raw, err := os.ReadFile(startupMarkerPath(lockDir, sessionID))
172- if err != nil {
173- raw, err = os.ReadFile(abruptMarkerPath(lockDir, sessionID))
174- if err != nil {
175- return 0, 0
176- }
177- }
178-
179- pid, _ := strconv.Atoi(markerValue(raw, "pid="))
180- ppid, _ := strconv.Atoi(markerValue(raw, "ppid="))
181- return pid, ppid
182-}
183-
184-func readRedactedLogTail(path string, lines int) []string {
185- if lines <= 0 {
186- return nil
187- }
188-
189- file, err := os.Open(path)
190- if err != nil {
191- return nil
192- }
193- defer func() { _ = file.Close() }()
194-
195- ring := make([]string, lines)
196- count := 0
197- scanner := bufio.NewScanner(file)
198- scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
199- for scanner.Scan() {
200- ring[count%lines] = redactSensitiveContent(scanner.Text())
201- count++
202- }
203-
204- var result []string
205- if count <= lines {
206- result = make([]string, count)
207- copy(result, ring[:count])
208- } else {
209- result = make([]string, lines)
210- start := count % lines
211- copy(result, ring[start:])
212- copy(result[lines-start:], ring[:start])
213- }
214-
215- if scanner.Err() != nil {
216- result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
217- }
218- return result
219-}
220-
221-func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
222 lines := []string{128 lines := []string{
223 "# Unexpected Close Report",129 "# Unexpected Close Report",
224 "",130 "",
225 fmt.Sprintf("Generated: `%s`", report.GeneratedAt),131 fmt.Sprintf("Generated: `%s`", report.GeneratedAt),
226 fmt.Sprintf("Session: `%s`", report.SessionID),132 fmt.Sprintf("Session: `%s`", report.SessionID),
227- fmt.Sprintf("Class: `%s`", report.Classification.Class),133+ fmt.Sprintf("Crashed PID: `%d`", report.CrashedPID),
228- fmt.Sprintf("Inference: `%s`", report.Classification.Inference),
229- fmt.Sprintf("Reason: `%s`", report.Classification.Reason),
230 "",134 "",
231- "## Process Context",135+ "## Reporter",
232- fmt.Sprintf("- startup pid: `%d`", report.StartupPID),136+ fmt.Sprintf("- pid: `%d`", report.ReporterProcess.PID),
233- fmt.Sprintf("- startup ppid: `%d`", report.StartupPPID),137+ fmt.Sprintf("- ppid: `%d`", report.ReporterProcess.PPID),
234- fmt.Sprintf("- reporter pid: `%d`", report.ReporterProcess.PID),138+ fmt.Sprintf("- go: `%s`", report.ReporterProcess.GoVersion),
235- fmt.Sprintf("- reporter ppid: `%d`", report.ReporterProcess.PPID),
236 "",139 "",
237 "## Core Dump Diagnostics",140 "## Core Dump Diagnostics",
238 fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),141 fmt.Sprintf("- RLIMIT_CORE soft: `%s`", report.CoreDumpDiagnostics.RLimitCoreSoft),
239 fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),142 fmt.Sprintf("- RLIMIT_CORE hard: `%s`", report.CoreDumpDiagnostics.RLimitCoreHard),
240 fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),143 fmt.Sprintf("- hint: %s", report.CoreDumpDiagnostics.Hint),
241- "",
242- "## Marker Files",
243- fmt.Sprintf("- startup: `%s`", report.MarkerFiles["startup"]),
244- fmt.Sprintf("- shutdown: `%s`", report.MarkerFiles["shutdown"]),
245- fmt.Sprintf("- abrupt: `%s`", report.MarkerFiles["abrupt"]),
246 }144 }
247 145
248 if len(report.SessionLogTail) > 0 {146 if len(report.SessionLogTail) > 0 {
@@ -266,8 +164,7 @@ func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
266 "",164 "",
267 "### Crash report",165 "### Crash report",
268 fmt.Sprintf("- session id: `%s`", report.SessionID),166 fmt.Sprintf("- session id: `%s`", report.SessionID),
269- fmt.Sprintf("- class: `%s`", report.Classification.Class),167+ fmt.Sprintf("- crashed pid: `%d`", report.CrashedPID),
270- fmt.Sprintf("- reason: `%s`", report.Classification.Reason),
271 "",168 "",
272 "### Additional context",169 "### Additional context",
273 "- distro:",170 "- distro:",
@@ -279,6 +176,47 @@ func buildUnexpectedCloseMarkdown(report unexpectedCloseReport) string {
279 return strings.Join(lines, "\n") + "\n"176 return strings.Join(lines, "\n") + "\n"
280 }177 }
281 178
179+// ---------------------------------------------------------------------------
180+// Redaction helpers (kept from previous implementation)
181+// ---------------------------------------------------------------------------
182+
183+func readRedactedLogTail(path string, lines int) []string {
184+ if lines <= 0 {
185+ return nil
186+ }
187+
188+ file, err := os.Open(path)
189+ if err != nil {
190+ return nil
191+ }
192+ defer func() { _ = file.Close() }()
193+
194+ ring := make([]string, lines)
195+ count := 0
196+ scanner := bufio.NewScanner(file)
197+ scanner.Buffer(make([]byte, 0, scannerMaxTokenSize), scannerMaxTokenSize)
198+ for scanner.Scan() {
199+ ring[count%lines] = redactSensitiveContent(scanner.Text())
200+ count++
201+ }
202+
203+ var result []string
204+ if count <= lines {
205+ result = make([]string, count)
206+ copy(result, ring[:count])
207+ } else {
208+ result = make([]string, lines)
209+ start := count % lines
210+ copy(result, ring[start:])
211+ copy(result[lines-start:], ring[:start])
212+ }
213+
214+ if scanner.Err() != nil {
215+ result = append(result, fmt.Sprintf("[log tail truncated: %v]", scanner.Err()))
216+ }
217+ return result
218+}
219+
282 var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)220 var urlRegex = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s\]\)\"'<>]+`)
283 221
284 var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)222 var secretKeyRegex = regexp.MustCompile(`(?i)(token|access_token|id_token|code|password|passwd|secret|authorization)=([^&\s]+)`)
M internal/bootstrap/crash_report_other.go +2 -2
@@ -2,8 +2,8 @@
22
33 package bootstrap
44
5-func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
6- return unexpectedCloseCoreDump{
5+func collectCoreDumpDiagnostics() crashCoreDump {
6+ return crashCoreDump{
77 RLimitCoreSoft: "unsupported",
88 RLimitCoreHard: "unsupported",
99 Hint: "Core dump diagnostics are only available on Linux and macOS.",
@@ -2,8 +2,8 @@
2 2
3 package bootstrap3 package bootstrap
4 4
5-func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {5+func collectCoreDumpDiagnostics() crashCoreDump {
6- return unexpectedCloseCoreDump{6+ return crashCoreDump{
7 RLimitCoreSoft: "unsupported",7 RLimitCoreSoft: "unsupported",
8 RLimitCoreHard: "unsupported",8 RLimitCoreHard: "unsupported",
9 Hint: "Core dump diagnostics are only available on Linux and macOS.",9 Hint: "Core dump diagnostics are only available on Linux and macOS.",
M internal/bootstrap/crash_report_test.go +9 -20
@@ -7,7 +7,6 @@ import (
77 "path/filepath"
88 "strings"
99 "testing"
10- "time"
1110
1211 corelogging "github.com/bnema/dumber/internal/logging"
1312 "github.com/stretchr/testify/assert"
@@ -65,7 +64,7 @@ func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
6564 for i := 0; i < 10; i++ {
6665 lines = append(lines, fmt.Sprintf("line %d", i))
6766 }
68- require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), markerFilePerm))
67+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), filePerm))
6968
7069 t.Run("fewer_lines_than_requested", func(t *testing.T) {
7170 result := readRedactedLogTail(path, 20)
@@ -83,23 +82,18 @@ func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
8382 })
8483 }
8584
86-func TestWriteUnexpectedCloseReport(t *testing.T) {
87- lockDir := t.TempDir()
85+func TestWriteCrashReport(t *testing.T) {
86+ logDir := t.TempDir()
8887 sessionID := "crash-test-1"
89- startedAt := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
9088
91- require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))
92- _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil)
93- require.NoError(t, err)
94-
95- logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
89+ logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID))
9690 logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` +
9791 "\n" +
9892 `{"level":"warn","message":"callback code=abc token=def"}` +
9993 "\n"
100- require.NoError(t, os.WriteFile(logPath, []byte(logBody), markerFilePerm))
94+ require.NoError(t, os.WriteFile(logPath, []byte(logBody), filePerm))
10195
102- jsonPath, err := writeUnexpectedCloseReport(lockDir, sessionID)
96+ jsonPath, err := writeCrashReport(logDir, sessionID, 12345)
10397 require.NoError(t, err)
10498 require.NotEmpty(t, jsonPath)
10599 assert.FileExists(t, jsonPath)
@@ -113,20 +107,15 @@ func TestWriteUnexpectedCloseReport(t *testing.T) {
113107 var payload map[string]any
114108 require.NoError(t, json.Unmarshal(raw, &payload))
115109 assert.Equal(t, sessionID, payload["session_id"])
116-
117- classificationRaw, ok := payload["classification"].(map[string]any)
118- require.True(t, ok, "classification should be a map[string]any")
119- classValue, ok := classificationRaw["class"].(string)
120- require.True(t, ok, "classification.class should be a string")
121- assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
110+ assert.InDelta(t, float64(12345), payload["crashed_pid"], 0)
122111
123112 tail, ok := payload["session_log_tail_redacted"].([]any)
124113 require.True(t, ok, "session_log_tail_redacted should be a []any")
125114 require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries")
126115 first, ok := tail[0].(string)
127- require.True(t, ok, "first tail entry should be a string")
116+ require.True(t, ok)
128117 last, ok := tail[len(tail)-1].(string)
129- require.True(t, ok, "last tail entry should be a string")
118+ require.True(t, ok)
130119 text := first + last
131120 assert.NotContains(t, text, "?a=1&b=2")
132121 assert.NotContains(t, text, "code=abc")
@@ -7,7 +7,6 @@ import (
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9 "testing"9 "testing"
10- "time"
11 10
12 corelogging "github.com/bnema/dumber/internal/logging"11 corelogging "github.com/bnema/dumber/internal/logging"
13 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
@@ -65,7 +64,7 @@ func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
65 for i := 0; i < 10; i++ {64 for i := 0; i < 10; i++ {
66 lines = append(lines, fmt.Sprintf("line %d", i))65 lines = append(lines, fmt.Sprintf("line %d", i))
67 }66 }
68- require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), markerFilePerm))67+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), filePerm))
69 68
70 t.Run("fewer_lines_than_requested", func(t *testing.T) {69 t.Run("fewer_lines_than_requested", func(t *testing.T) {
71 result := readRedactedLogTail(path, 20)70 result := readRedactedLogTail(path, 20)
@@ -83,23 +82,18 @@ func TestReadRedactedLogTail_RingBuffer(t *testing.T) {
83 })82 })
84 }83 }
85 84
86-func TestWriteUnexpectedCloseReport(t *testing.T) {85+func TestWriteCrashReport(t *testing.T) {
87- lockDir := t.TempDir()86+ logDir := t.TempDir()
88 sessionID := "crash-test-1"87 sessionID := "crash-test-1"
89- startedAt := time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
90 88
91- require.NoError(t, writeStartupMarker(lockDir, sessionID, startedAt))89+ logPath := filepath.Join(logDir, corelogging.SessionFilename(sessionID))
92- _, err := markAbruptExits(lockDir, startedAt.Add(3*time.Minute), nil)
93- require.NoError(t, err)
94-
95- logPath := filepath.Join(lockDir, corelogging.SessionFilename(sessionID))
96 logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` +90 logBody := `{"level":"info","message":"opening https://example.com/path?a=1&b=2"}` +
97 "\n" +91 "\n" +
98 `{"level":"warn","message":"callback code=abc token=def"}` +92 `{"level":"warn","message":"callback code=abc token=def"}` +
99 "\n"93 "\n"
100- require.NoError(t, os.WriteFile(logPath, []byte(logBody), markerFilePerm))94+ require.NoError(t, os.WriteFile(logPath, []byte(logBody), filePerm))
101 95
102- jsonPath, err := writeUnexpectedCloseReport(lockDir, sessionID)96+ jsonPath, err := writeCrashReport(logDir, sessionID, 12345)
103 require.NoError(t, err)97 require.NoError(t, err)
104 require.NotEmpty(t, jsonPath)98 require.NotEmpty(t, jsonPath)
105 assert.FileExists(t, jsonPath)99 assert.FileExists(t, jsonPath)
@@ -113,20 +107,15 @@ func TestWriteUnexpectedCloseReport(t *testing.T) {
113 var payload map[string]any107 var payload map[string]any
114 require.NoError(t, json.Unmarshal(raw, &payload))108 require.NoError(t, json.Unmarshal(raw, &payload))
115 assert.Equal(t, sessionID, payload["session_id"])109 assert.Equal(t, sessionID, payload["session_id"])
116-110+ assert.InDelta(t, float64(12345), payload["crashed_pid"], 0)
117- classificationRaw, ok := payload["classification"].(map[string]any)
118- require.True(t, ok, "classification should be a map[string]any")
119- classValue, ok := classificationRaw["class"].(string)
120- require.True(t, ok, "classification.class should be a string")
121- assert.Equal(t, string(SessionExitMainProcessCrashOrAbrupt), classValue)
122 111
123 tail, ok := payload["session_log_tail_redacted"].([]any)112 tail, ok := payload["session_log_tail_redacted"].([]any)
124 require.True(t, ok, "session_log_tail_redacted should be a []any")113 require.True(t, ok, "session_log_tail_redacted should be a []any")
125 require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries")114 require.GreaterOrEqual(t, len(tail), 2, "expected at least 2 log tail entries")
126 first, ok := tail[0].(string)115 first, ok := tail[0].(string)
127- require.True(t, ok, "first tail entry should be a string")116+ require.True(t, ok)
128 last, ok := tail[len(tail)-1].(string)117 last, ok := tail[len(tail)-1].(string)
129- require.True(t, ok, "last tail entry should be a string")118+ require.True(t, ok)
130 text := first + last119 text := first + last
131 assert.NotContains(t, text, "?a=1&b=2")120 assert.NotContains(t, text, "?a=1&b=2")
132 assert.NotContains(t, text, "code=abc")121 assert.NotContains(t, text, "code=abc")
M internal/bootstrap/crash_report_unix.go +6 -4
@@ -8,14 +8,16 @@ import (
88 "golang.org/x/sys/unix"
99 )
1010
11-func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
12- d := unexpectedCloseCoreDump{
11+const unknownDiag = "unknown"
12+
13+func collectCoreDumpDiagnostics() crashCoreDump {
14+ d := crashCoreDump{
1315 Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
1416 }
1517 var limit unix.Rlimit
1618 if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
17- d.RLimitCoreSoft = string(SessionExitUnknown)
18- d.RLimitCoreHard = string(SessionExitUnknown)
19+ d.RLimitCoreSoft = unknownDiag
20+ d.RLimitCoreHard = unknownDiag
1921 return d
2022 }
2123 d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
@@ -8,14 +8,16 @@ import (
8 "golang.org/x/sys/unix"8 "golang.org/x/sys/unix"
9 )9 )
10 10
11-func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {11+const unknownDiag = "unknown"
12- d := unexpectedCloseCoreDump{12+
13+func collectCoreDumpDiagnostics() crashCoreDump {
14+ d := crashCoreDump{
13 Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",15 Hint: "Run `coredumpctl list | rg -i \"dumber\"` and include matching entries in the issue.",
14 }16 }
15 var limit unix.Rlimit17 var limit unix.Rlimit
16 if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {18 if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
17- d.RLimitCoreSoft = string(SessionExitUnknown)19+ d.RLimitCoreSoft = unknownDiag
18- d.RLimitCoreHard = string(SessionExitUnknown)20+ d.RLimitCoreHard = unknownDiag
19 return d21 return d
20 }22 }
21 d.RLimitCoreSoft = formatRlimitCore(limit.Cur)23 d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
M internal/bootstrap/session.go +169 -477
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "strconv"
910 "strings"
1011 "sync"
1112 "syscall"
@@ -23,41 +24,48 @@ import (
2324
2425 const (
2526 recentSessionsLimit = 200
26- lockDirPerm = 0o755
27- lockFilePerm = 0o600
28- markerFilePerm = 0o644
27+ dirPerm = 0o755
28+ filePerm = 0o644
29+ pidFileName = "dumber.pid"
2930 )
3031
32+// BrowserSession holds the state for a running browser session.
3133 type BrowserSession struct {
3234 Session *entity.Session
3335 Logger zerolog.Logger
3436 LogCleanup func()
37+ LogPath string
3538
36- reportsMu sync.RWMutex
37- unexpectedCloseReportPath []string
39+ reportsMu sync.RWMutex
40+ crashReportPaths []string
3841
3942 Persist func(context.Context) error
4043 End func(context.Context) error
4144 }
4245
43-func (s *BrowserSession) SetUnexpectedCloseReports(paths []string) {
46+// SetCrashReports stores crash report paths detected at startup.
47+func (s *BrowserSession) SetCrashReports(paths []string) {
4448 if s == nil {
4549 return
4650 }
4751 s.reportsMu.Lock()
4852 defer s.reportsMu.Unlock()
49- s.unexpectedCloseReportPath = append([]string(nil), paths...)
53+ s.crashReportPaths = append([]string(nil), paths...)
5054 }
5155
52-func (s *BrowserSession) UnexpectedCloseReports() []string {
56+// CrashReports returns crash report paths detected at startup.
57+func (s *BrowserSession) CrashReports() []string {
5358 if s == nil {
5459 return nil
5560 }
5661 s.reportsMu.RLock()
5762 defer s.reportsMu.RUnlock()
58- return append([]string(nil), s.unexpectedCloseReportPath...)
63+ return append([]string(nil), s.crashReportPaths...)
5964 }
6065
66+// StartBrowserSession creates and persists a browser session.
67+// It checks for previous crashes via PID file, creates the session log,
68+// and sets up cleanup.
6169 func StartBrowserSession(
6270 ctx context.Context,
6371 cfg *config.Config,
@@ -69,10 +77,10 @@ func StartBrowserSession(
6977 return nil, ctx, errors.New("session repository is nil")
7078 }
7179
72- lockDir := cfg.Logging.LogDir
73- if lockDir == "" {
80+ logDir := cfg.Logging.LogDir
81+ if logDir == "" {
7482 if dir, err := config.GetLogDir(); err == nil {
75- lockDir = dir
83+ logDir = dir
7684 }
7785 }
7886
@@ -102,34 +110,33 @@ func StartBrowserSession(
102110 log.Warn().Err(err).Msg("failed to create session logger")
103111 }
104112
105- sessionCtx := corelogging.WithContext(context.Background(), logger)
113+ // Compute and log the session log file path early so the user can
114+ // copy-paste it from the console output.
115+ var logPath string
116+ if logDir != "" {
117+ logPath = filepath.Join(logDir, corelogging.SessionFilename(string(session.ID)))
118+ logger.Info().Str("log_file", logPath).Msg("session log file")
119+ }
106120
107- var (
108- lockMu sync.Mutex
109- lockFile *os.File
110- lockPath string
111- persistMu sync.Once
112- persistErr error
113- )
121+ sessionCtx := corelogging.WithContext(context.Background(), logger)
114122
115- browserSession := &BrowserSession{
123+ bs := &BrowserSession{
116124 Session: session,
117125 Logger: logger,
118126 LogCleanup: cleanup,
127+ LogPath: logPath,
119128 }
120129
121- // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:
122- // if the first persist fails, we cache the error and return it on subsequent calls
123- // rather than retrying. This prevents duplicate session records and ensures consistent
124- // error handling during shutdown sequences where persistFn may be called multiple times.
130+ var persistOnce sync.Once
131+ var persistErr error
132+
125133 persistFn := func(persistCtx context.Context) error {
126- persistMu.Do(func() {
127- if lockDir != "" {
128- generatedReports, abruptErr := detectAbruptSessionsAndBuildReports(lockDir, &logger)
129- if abruptErr != nil {
130- logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")
131- } else if len(generatedReports) > 0 {
132- browserSession.SetUnexpectedCloseReports(generatedReports)
134+ persistOnce.Do(func() {
135+ // Check for crash from previous run.
136+ if logDir != "" {
137+ reports := detectPreviousCrash(logDir, &logger)
138+ if len(reports) > 0 {
139+ bs.SetCrashReports(reports)
133140 }
134141 }
135142
@@ -141,28 +148,15 @@ func StartBrowserSession(
141148
142149 logger.Info().
143150 Str("session_id", string(session.ID)).
144- Str("type", string(session.Type)).
145151 Msg("session started")
146- if lockDir != "" {
147- if err := writeStartupMarker(lockDir, string(session.ID), now.UTC()); err != nil {
148- logger.Warn().Err(err).Str("session_id", string(session.ID)).Msg("failed to write startup marker")
149- }
150- }
151152
152- lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
153- if lockErr != nil {
154- // Don’t fail startup if locking fails; it only affects stale-session cleanup.
155- logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
156- } else {
157- lockMu.Lock()
158- lockFile = lf
159- lockPath = lp
160- lockMu.Unlock()
153+ // Write PID file so next startup can detect crashes.
154+ if logDir != "" {
155+ writePIDFile(logDir, &logger)
161156 }
162157
163- // Defer stale session cleanup and old session pruning to background.
164- // This avoids blocking startup for non-critical housekeeping tasks.
165- runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
158+ // Background cleanup of old sessions.
159+ go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, log)
166160 })
167161 return persistErr
168162 }
@@ -171,29 +165,17 @@ func StartBrowserSession(
171165 _ = persistFn(endCtx)
172166 endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
173167
174- lockMu.Lock()
175- lf := lockFile
176- lp := lockPath
177- lockMu.Unlock()
178-
179- if lockDir != "" {
180- shutdownAt := time.Now().UTC()
181- if markerErr := writeShutdownMarker(lockDir, string(session.ID), shutdownAt); markerErr != nil {
182- logger.Warn().Err(markerErr).Str("session_id", string(session.ID)).Msg("failed to write clean shutdown marker")
183- }
184- }
185-
186- if lf != nil {
187- _ = unlockAndClose(lf)
188- _ = os.Remove(lp)
168+ // Remove PID file on clean shutdown.
169+ if logDir != "" {
170+ removePIDFile(logDir)
189171 }
190172 return endErr
191173 }
192174
193- browserSession.End = endFn
175+ bs.End = endFn
194176
195177 if deferPersist {
196- browserSession.Persist = persistFn
178+ bs.Persist = persistFn
197179 } else {
198180 if err := persistFn(ctx); err != nil {
199181 if cleanup != nil {
@@ -203,479 +185,189 @@ func StartBrowserSession(
203185 }
204186 }
205187
206- return browserSession, sessionCtx, nil
188+ return bs, sessionCtx, nil
207189 }
208190
209-func detectAbruptSessionsAndBuildReports(lockDir string, logger *zerolog.Logger) ([]string, error) {
210- abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), logger)
211- if abruptErr != nil {
212- return nil, abruptErr
213- }
214-
215- generatedReports := make([]string, 0, len(abruptSessions))
216- for _, abruptSessionID := range abruptSessions {
217- if logger != nil {
218- logger.Warn().
219- Str("session_id", abruptSessionID).
220- Msg("abrupt-exit marker detected (startup without shutdown)")
221- }
222- reportPath, reportErr := writeUnexpectedCloseReport(lockDir, abruptSessionID)
223- if reportErr != nil {
224- if logger != nil {
225- logger.Warn().
226- Err(reportErr).
227- Str("session_id", abruptSessionID).
228- Msg("failed to write unexpected-close report")
229- }
230- continue
231- }
232- if reportPath == "" {
233- continue
234- }
235- generatedReports = append(generatedReports, reportPath)
236- if logger != nil {
237- logger.Warn().
238- Str("session_id", abruptSessionID).
239- Str("report_path", reportPath).
240- Msg("unexpected-close report generated")
241- }
242- }
191+// ---------------------------------------------------------------------------
192+// PID file: single file {logDir}/dumber.pid
193+// ---------------------------------------------------------------------------
243194
244- return generatedReports, nil
195+func pidFilePath(logDir string) string {
196+ return filepath.Join(logDir, pidFileName)
245197 }
246198
247-func endStaleActiveBrowserSessions(
248- ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,
249-) error {
250- // We only have GetRecentSessions; fetch enough to cover likely actives.
251- recent, err := sessionUC.GetRecentSessions(ctx, recentSessionsLimit)
252- if err != nil {
253- return err
199+func writePIDFile(logDir string, logger *zerolog.Logger) {
200+ p := pidFilePath(logDir)
201+ content := fmt.Sprintf("%d\n", os.Getpid())
202+ if err := os.WriteFile(p, []byte(content), filePerm); err != nil && logger != nil {
203+ logger.Warn().Err(err).Msg("failed to write PID file")
254204 }
255-
256- now := time.Now()
257- for _, s := range recent {
258- if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
259- continue
260- }
261-
262- lockPath := sessionLockPath(lockDir, s.ID)
263- if _, statErr := os.Stat(lockPath); statErr != nil {
264- // No lock file: conservative behavior, don’t auto-end.
265- continue
266- }
267-
268- f, err := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
269- if err != nil {
270- continue
271- }
272-
273- locked, lockErr := tryLockExclusiveNonBlocking(f)
274- if lockErr != nil {
275- _ = f.Close()
276- continue
277- }
278- if !locked {
279- // Another process is holding the lock => session still alive.
280- _ = f.Close()
281- continue
282- }
283-
284- // We acquired the lock => no running process holds it => stale session.
285- if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
286- if log != nil {
287- log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
288- }
289- }
290- _ = unlockAndClose(f)
291- _ = os.Remove(lockPath)
292- }
293-
294- return nil
295205 }
296206
297-func sessionLockPath(lockDir string, sessionID entity.SessionID) string {
298- return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
207+func removePIDFile(logDir string) {
208+ _ = os.Remove(pidFilePath(logDir))
299209 }
300210
301-func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {
302- if lockDir == "" {
303- return nil, "", errors.New("lock dir is empty")
304- }
305- if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
306- return nil, "", err
307- }
308-
309- lockPath := sessionLockPath(lockDir, sessionID)
310- f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, lockFilePerm)
211+// detectPreviousCrash checks the PID file from a previous run.
212+// If the file exists and the process is dead, the previous session crashed.
213+// Returns generated crash report paths (if any).
214+func detectPreviousCrash(logDir string, logger *zerolog.Logger) []string {
215+ p := pidFilePath(logDir)
216+ raw, err := os.ReadFile(p)
311217 if err != nil {
312- return nil, "", err
313- }
314-
315- locked, lockErr := tryLockExclusiveNonBlocking(f)
316- if lockErr != nil {
317- _ = f.Close()
318- return nil, "", lockErr
319- }
320- if !locked {
321- _ = f.Close()
322- return nil, "", errors.New("session lock already held")
218+ return nil // No PID file → clean previous exit (or first run).
323219 }
324- return f, lockPath, nil
325-}
326-
327-func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
328- if f == nil {
329- return false, errors.New("nil file")
330- }
331- err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
332- if err == nil {
333- return true, nil
334- }
335- if errors.Is(err, syscall.EWOULDBLOCK) {
336- return false, nil
337- }
338- return false, err
339-}
340220
341-func unlockAndClose(f *os.File) error {
342- if f == nil {
221+ pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw)))
222+ if parseErr != nil {
223+ // Corrupt PID file, remove and move on.
224+ _ = os.Remove(p)
343225 return nil
344226 }
345- _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
346- return f.Close()
347-}
348-
349-func startupMarkerPath(lockDir, sessionID string) string {
350- return filepath.Join(lockDir, fmt.Sprintf("session_%s.startup.marker", sessionID))
351-}
352-
353-func shutdownMarkerPath(lockDir, sessionID string) string {
354- return filepath.Join(lockDir, fmt.Sprintf("session_%s.shutdown.marker", sessionID))
355-}
356-
357-func abruptMarkerPath(lockDir, sessionID string) string {
358- return filepath.Join(lockDir, fmt.Sprintf("session_%s.abrupt.marker", sessionID))
359-}
360-
361-func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error {
362- if lockDir == "" || sessionID == "" {
363- return errors.New("startup marker requires lockDir and sessionID")
364- }
365- if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
366- return err
367- }
368- content := []byte(fmt.Sprintf(
369- "%s\npid=%d\nppid=%d\n",
370- startedAt.Format(time.RFC3339Nano),
371- os.Getpid(),
372- os.Getppid(),
373- ))
374- if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
375- return err
376- }
377- _ = os.Remove(shutdownMarkerPath(lockDir, sessionID))
378- _ = os.Remove(abruptMarkerPath(lockDir, sessionID))
379- return nil
380-}
381-
382-func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
383- if lockDir == "" || sessionID == "" {
384- return errors.New("shutdown marker requires lockDir and sessionID")
385- }
386- if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
387- return err
388- }
389-
390- // Preserve the startup time inside the shutdown marker so
391- // ClassifySessionExitFromMarkers can still read it after the
392- // startup marker file is removed.
393- var startupLine string
394- var pidLine string
395- var ppidLine string
396- startupPath := startupMarkerPath(lockDir, sessionID)
397- if raw, readErr := os.ReadFile(startupPath); readErr == nil {
398- if t := firstNonEmptyLine(raw); t != "" {
399- startupLine = "started_at=" + t + "\n"
400- }
401- if pid := markerValue(raw, "pid="); pid != "" {
402- pidLine = "pid=" + pid + "\n"
403- }
404- if ppid := markerValue(raw, "ppid="); ppid != "" {
405- ppidLine = "ppid=" + ppid + "\n"
406- }
407- }
408-
409- content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine + pidLine + ppidLine)
410- if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
411- return err
412- }
413- // Clean up the matching startup marker now that shutdown is recorded.
414- // Errors are non-fatal: the marker will be swept later if removal fails.
415- _ = os.Remove(startupPath)
416- return nil
417-}
418227
419-func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logger) ([]string, error) {
420- if lockDir == "" {
421- return nil, nil
422- }
423- pattern := filepath.Join(lockDir, "session_*.startup.marker")
424- startupMarkers, err := filepath.Glob(pattern)
425- if err != nil {
426- return nil, err
427- }
428-
429- abruptSessions := make([]string, 0, len(startupMarkers))
430- for _, startupPath := range startupMarkers {
431- sessionID := sessionIDFromStartupMarker(startupPath)
432- if sessionID == "" {
433- continue
434- }
435-
436- shouldMark, checkErr := shouldMarkAbruptExit(lockDir, sessionID, logger)
437- if checkErr != nil {
228+ // Check if the process is still running.
229+ proc, procErr := os.FindProcess(pid)
230+ if procErr == nil {
231+ // On Unix, FindProcess always succeeds. Send signal 0 to probe existence.
232+ if killErr := proc.Signal(syscall.Signal(0)); killErr == nil {
233+ // Process is alive → another instance running, don't touch PID file.
438234 if logger != nil {
439- logger.Warn().Err(checkErr).Str("session_id", sessionID).Msg("markAbruptExits: marker checks failed")
235+ logger.Warn().Int("pid", pid).Msg("previous instance still running (PID file exists)")
440236 }
441- continue
442- }
443- if !shouldMark {
444- continue
237+ return nil
445238 }
446-
447- startupRaw, _ := os.ReadFile(startupPath)
448- payload := fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath)
449- if startupAt := firstNonEmptyLine(startupRaw); startupAt != "" {
450- payload += "started_at=" + startupAt + "\n"
451- }
452- if pid := markerValue(startupRaw, "pid="); pid != "" {
453- payload += "pid=" + pid + "\n"
454- }
455- if ppid := markerValue(startupRaw, "ppid="); ppid != "" {
456- payload += "ppid=" + ppid + "\n"
457- }
458-
459- if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil {
460- return abruptSessions, err
461- }
462- abruptSessions = append(abruptSessions, sessionID)
463239 }
464240
465- return abruptSessions, nil
466-}
241+ // Previous process is dead → crash. Generate report from last log.
242+ _ = os.Remove(p)
467243
468-func sessionIDFromStartupMarker(startupPath string) string {
469- base := filepath.Base(startupPath)
470- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {
471- return ""
472- }
473- return strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
474-}
475-
476-func shouldMarkAbruptExit(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
477- active, probeErr := sessionLockHeldByLiveProcess(lockDir, sessionID, logger)
478- if probeErr != nil {
479- return false, probeErr
480- }
481- if active {
482- return false, nil
244+ lastLog := findLastSessionLog(logDir)
245+ if lastLog == "" {
246+ return nil
483247 }
484248
485- shutdownPath := shutdownMarkerPath(lockDir, sessionID)
486- if exists, statErr := markerExists(shutdownPath); statErr != nil {
487- return false, statErr
488- } else if exists {
489- return false, nil
249+ lastSessionID := sessionIDFromLogPath(lastLog)
250+ if lastSessionID == "" {
251+ return nil
490252 }
491253
492- abruptPath := abruptMarkerPath(lockDir, sessionID)
493- if exists, statErr := markerExists(abruptPath); statErr != nil {
494- return false, statErr
495- } else if exists {
496- return false, nil
254+ if logger != nil {
255+ logger.Warn().
256+ Int("crashed_pid", pid).
257+ Str("session_id", lastSessionID).
258+ Msg("previous session crashed (stale PID file)")
497259 }
498260
499- return true, nil
500-}
501-
502-func sessionLockHeldByLiveProcess(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
503- lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID))
504- f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
505- if openErr != nil {
506- if os.IsNotExist(openErr) {
507- return false, nil
508- }
261+ reportPath, reportErr := writeCrashReport(logDir, lastSessionID, pid)
262+ if reportErr != nil {
509263 if logger != nil {
510- logger.Warn().
511- Err(openErr).
512- Str("session_id", sessionID).
513- Str("path", lockPath).
514- Msg("markAbruptExits: open lock file failed")
264+ logger.Warn().Err(reportErr).Msg("failed to write crash report")
515265 }
516- return false, openErr
517- }
518-
519- locked, lockErr := tryLockExclusiveNonBlocking(f)
520- if lockErr != nil {
521- _ = f.Close()
522- if logger != nil {
523- logger.Warn().
524- Err(lockErr).
525- Str("session_id", sessionID).
526- Str("path", lockPath).
527- Msg("markAbruptExits: lock probe failed")
528- }
529- return false, lockErr
266+ return nil
530267 }
531- if !locked {
532- _ = f.Close()
533- return true, nil
268+ if reportPath == "" {
269+ return nil
534270 }
535- _ = unlockAndClose(f)
536- return false, nil
271+ return []string{reportPath}
537272 }
538273
539-func markerExists(path string) (bool, error) {
540- _, err := os.Stat(path)
541- if err == nil {
542- return true, nil
274+// findLastSessionLog returns the most recent session_*.log file in logDir.
275+func findLastSessionLog(logDir string) string {
276+ matches, err := filepath.Glob(filepath.Join(logDir, "session_*.log"))
277+ if err != nil || len(matches) == 0 {
278+ return ""
543279 }
544- if os.IsNotExist(err) {
545- return false, nil
280+ var newest string
281+ var newestMod time.Time
282+ for _, m := range matches {
283+ info, statErr := os.Stat(m)
284+ if statErr != nil {
285+ continue
286+ }
287+ if info.ModTime().After(newestMod) {
288+ newest = m
289+ newestMod = info.ModTime()
290+ }
546291 }
547- return false, err
292+ return newest
548293 }
549294
550-func firstNonEmptyLine(raw []byte) string {
551- trimmed := strings.TrimSpace(string(raw))
552- if trimmed == "" {
295+func sessionIDFromLogPath(logPath string) string {
296+ base := filepath.Base(logPath)
297+ id, ok := corelogging.ParseSessionFilename(base)
298+ if !ok {
553299 return ""
554300 }
555- first, _, _ := strings.Cut(trimmed, "\n")
556- return strings.TrimSpace(first)
301+ return id
557302 }
558303
559-func markerValue(raw []byte, key string) string {
560- if key == "" || len(raw) == 0 {
561- return ""
562- }
563- for _, line := range strings.Split(string(raw), "\n") {
564- line = strings.TrimSpace(line)
565- if strings.HasPrefix(line, key) {
566- return strings.TrimSpace(strings.TrimPrefix(line, key))
567- }
568- }
569- return ""
570-}
304+// ---------------------------------------------------------------------------
305+// Background cleanup
306+// ---------------------------------------------------------------------------
571307
572-// runSessionCleanupAsync performs stale session cleanup and old session pruning
573-// in a background goroutine. This avoids blocking startup for non-critical tasks.
574-// Uses context.WithoutCancel(startupCtx) so the background work inherits request
575-// values and the logger from the startup context while detaching from its cancellation.
576-func runSessionCleanupAsync(
308+func runSessionCleanup(
577309 startupCtx context.Context,
578310 sessionUC *usecase.ManageSessionUseCase,
579311 cleanupUC *usecase.CleanupSessionsUseCase,
580312 cfg *config.Config,
581- lockDir string,
582313 log *zerolog.Logger,
583314 ) {
584- go func() {
585- // Detach cancellation from the startup context so cleanup runs to completion
586- // even if the startup context is canceled, while still carrying its values/logger.
587- bgCtx := context.WithoutCancel(startupCtx)
588-
589- // End stale active sessions (orphaned from crashed processes)
590- if lockDir != "" {
591- if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
592- if log != nil {
593- log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
594- }
595- }
596- }
315+ bgCtx := context.WithoutCancel(startupCtx)
597316
598- // Clean up old exited sessions based on config limits
599- cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
600- MaxExitedSessions: cfg.Session.MaxExitedSessions,
601- MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
602- })
603- if err != nil {
604- if log != nil {
605- log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
606- }
607- } else if cleanupOutput.TotalDeleted > 0 {
608- if log != nil {
609- log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
610- }
317+ // End stale active sessions that have no running process.
318+ recent, err := sessionUC.GetRecentSessions(bgCtx, recentSessionsLimit)
319+ if err != nil {
320+ if log != nil {
321+ log.Warn().Err(err).Msg("background: failed to list recent sessions")
611322 }
612-
613- // Sweep paired startup+shutdown markers that are no longer needed.
614- if lockDir != "" {
615- sweepPairedMarkers(lockDir, cfg.Session.MaxExitedSessionAgeDays, log)
323+ } else {
324+ now := time.Now()
325+ for _, s := range recent {
326+ if s != nil && s.Type == entity.SessionTypeBrowser && s.IsActive() {
327+ _ = sessionUC.EndSession(bgCtx, s.ID, now)
328+ }
616329 }
617- }()
618-}
619-
620-// sweepPairedMarkers removes marker files for sessions that have both a
621-// shutdown marker and a startup marker older than maxAgeDays. Both markers
622-// must exist and be aged past the cutoff before any files are removed.
623-// The associated abrupt marker (if present) is also cleaned up.
624-// Removal errors are non-fatal (logged and skipped).
625-func sweepPairedMarkers(lockDir string, maxAgeDays int, log *zerolog.Logger) {
626- if lockDir == "" {
627- return
628- }
629- if maxAgeDays <= 0 {
630- maxAgeDays = 7
631330 }
632- cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
633331
634- shutdownMarkers, err := filepath.Glob(filepath.Join(lockDir, "session_*.shutdown.marker"))
332+ // Clean up old exited sessions.
333+ output, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
334+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
335+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
336+ })
635337 if err != nil {
636338 if log != nil {
637- log.Warn().Err(err).Msg("sweepPairedMarkers: glob failed")
339+ log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
638340 }
639- return
341+ } else if output.TotalDeleted > 0 && log != nil {
342+ log.Info().Int64("deleted", output.TotalDeleted).Msg("background: cleaned up old sessions")
640343 }
641344
642- swept := 0
643- for _, shutdownPath := range shutdownMarkers {
644- base := filepath.Base(shutdownPath)
645- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".shutdown.marker") {
646- continue
647- }
648- sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".shutdown.marker")
649- if sessionID == "" {
650- continue
651- }
652-
653- shutdownInfo, err := os.Stat(shutdownPath)
654- if err != nil || shutdownInfo.ModTime().After(cutoff) {
655- continue
656- }
657-
658- startupPath := startupMarkerPath(lockDir, sessionID)
659- startupInfo, err := os.Stat(startupPath)
660- if err != nil || startupInfo.ModTime().After(cutoff) {
661- // Startup marker missing or too recent — skip this session.
662- continue
663- }
664-
665- abruptPath := abruptMarkerPath(lockDir, sessionID)
345+ // Prune old marker files left by previous versions.
346+ sweepLegacyMarkers(cfg.Logging.LogDir, log)
347+}
666348
667- // Remove the trio: shutdown, startup, abrupt (if present).
668- for _, p := range []string{shutdownPath, startupPath, abruptPath} {
669- if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) {
670- if log != nil {
671- log.Warn().Err(rmErr).Str("path", p).Msg("sweepPairedMarkers: remove failed")
672- }
349+// sweepLegacyMarkers removes .startup.marker, .shutdown.marker,
350+// .abrupt.marker, and .lock files left by the old session system.
351+func sweepLegacyMarkers(logDir string, log *zerolog.Logger) {
352+ if logDir == "" {
353+ return
354+ }
355+ patterns := []string{
356+ "session_*.startup.marker",
357+ "session_*.shutdown.marker",
358+ "session_*.abrupt.marker",
359+ "session_*.lock",
360+ }
361+ swept := 0
362+ for _, pat := range patterns {
363+ matches, _ := filepath.Glob(filepath.Join(logDir, pat))
364+ for _, m := range matches {
365+ if os.Remove(m) == nil {
366+ swept++
673367 }
674368 }
675- swept++
676369 }
677-
678370 if swept > 0 && log != nil {
679- log.Info().Int("swept", swept).Msg("sweepPairedMarkers: cleaned up old marker files")
371+ log.Info().Int("swept", swept).Msg("cleaned up legacy session marker files")
680372 }
681373 }
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "strconv"
9 "strings"10 "strings"
10 "sync"11 "sync"
11 "syscall"12 "syscall"
@@ -23,41 +24,48 @@ import (
23 24
24 const (25 const (
25 recentSessionsLimit = 20026 recentSessionsLimit = 200
26- lockDirPerm = 0o75527+ dirPerm = 0o755
27- lockFilePerm = 0o60028+ filePerm = 0o644
28- markerFilePerm = 0o64429+ pidFileName = "dumber.pid"
29 )30 )
30 31
32+// BrowserSession holds the state for a running browser session.
31 type BrowserSession struct {33 type BrowserSession struct {
32 Session *entity.Session34 Session *entity.Session
33 Logger zerolog.Logger35 Logger zerolog.Logger
34 LogCleanup func()36 LogCleanup func()
37+ LogPath string
35 38
36- reportsMu sync.RWMutex39+ reportsMu sync.RWMutex
37- unexpectedCloseReportPath []string40+ crashReportPaths []string
38 41
39 Persist func(context.Context) error42 Persist func(context.Context) error
40 End func(context.Context) error43 End func(context.Context) error
41 }44 }
42 45
43-func (s *BrowserSession) SetUnexpectedCloseReports(paths []string) {46+// SetCrashReports stores crash report paths detected at startup.
47+func (s *BrowserSession) SetCrashReports(paths []string) {
44 if s == nil {48 if s == nil {
45 return49 return
46 }50 }
47 s.reportsMu.Lock()51 s.reportsMu.Lock()
48 defer s.reportsMu.Unlock()52 defer s.reportsMu.Unlock()
49- s.unexpectedCloseReportPath = append([]string(nil), paths...)53+ s.crashReportPaths = append([]string(nil), paths...)
50 }54 }
51 55
52-func (s *BrowserSession) UnexpectedCloseReports() []string {56+// CrashReports returns crash report paths detected at startup.
57+func (s *BrowserSession) CrashReports() []string {
53 if s == nil {58 if s == nil {
54 return nil59 return nil
55 }60 }
56 s.reportsMu.RLock()61 s.reportsMu.RLock()
57 defer s.reportsMu.RUnlock()62 defer s.reportsMu.RUnlock()
58- return append([]string(nil), s.unexpectedCloseReportPath...)63+ return append([]string(nil), s.crashReportPaths...)
59 }64 }
60 65
66+// StartBrowserSession creates and persists a browser session.
67+// It checks for previous crashes via PID file, creates the session log,
68+// and sets up cleanup.
61 func StartBrowserSession(69 func StartBrowserSession(
62 ctx context.Context,70 ctx context.Context,
63 cfg *config.Config,71 cfg *config.Config,
@@ -69,10 +77,10 @@ func StartBrowserSession(
69 return nil, ctx, errors.New("session repository is nil")77 return nil, ctx, errors.New("session repository is nil")
70 }78 }
71 79
72- lockDir := cfg.Logging.LogDir80+ logDir := cfg.Logging.LogDir
73- if lockDir == "" {81+ if logDir == "" {
74 if dir, err := config.GetLogDir(); err == nil {82 if dir, err := config.GetLogDir(); err == nil {
75- lockDir = dir83+ logDir = dir
76 }84 }
77 }85 }
78 86
@@ -102,34 +110,33 @@ func StartBrowserSession(
102 log.Warn().Err(err).Msg("failed to create session logger")110 log.Warn().Err(err).Msg("failed to create session logger")
103 }111 }
104 112
105- sessionCtx := corelogging.WithContext(context.Background(), logger)113+ // Compute and log the session log file path early so the user can
114+ // copy-paste it from the console output.
115+ var logPath string
116+ if logDir != "" {
117+ logPath = filepath.Join(logDir, corelogging.SessionFilename(string(session.ID)))
118+ logger.Info().Str("log_file", logPath).Msg("session log file")
119+ }
106 120
107- var (121+ sessionCtx := corelogging.WithContext(context.Background(), logger)
108- lockMu sync.Mutex
109- lockFile *os.File
110- lockPath string
111- persistMu sync.Once
112- persistErr error
113- )
114 122
115- browserSession := &BrowserSession{123+ bs := &BrowserSession{
116 Session: session,124 Session: session,
117 Logger: logger,125 Logger: logger,
118 LogCleanup: cleanup,126 LogCleanup: cleanup,
127+ LogPath: logPath,
119 }128 }
120 129
121- // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:130+ var persistOnce sync.Once
122- // if the first persist fails, we cache the error and return it on subsequent calls131+ var persistErr error
123- // rather than retrying. This prevents duplicate session records and ensures consistent132+
124- // error handling during shutdown sequences where persistFn may be called multiple times.
125 persistFn := func(persistCtx context.Context) error {133 persistFn := func(persistCtx context.Context) error {
126- persistMu.Do(func() {134+ persistOnce.Do(func() {
127- if lockDir != "" {135+ // Check for crash from previous run.
128- generatedReports, abruptErr := detectAbruptSessionsAndBuildReports(lockDir, &logger)136+ if logDir != "" {
129- if abruptErr != nil {137+ reports := detectPreviousCrash(logDir, &logger)
130- logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")138+ if len(reports) > 0 {
131- } else if len(generatedReports) > 0 {139+ bs.SetCrashReports(reports)
132- browserSession.SetUnexpectedCloseReports(generatedReports)
133 }140 }
134 }141 }
135 142
@@ -141,28 +148,15 @@ func StartBrowserSession(
141 148
142 logger.Info().149 logger.Info().
143 Str("session_id", string(session.ID)).150 Str("session_id", string(session.ID)).
144- Str("type", string(session.Type)).
145 Msg("session started")151 Msg("session started")
146- if lockDir != "" {
147- if err := writeStartupMarker(lockDir, string(session.ID), now.UTC()); err != nil {
148- logger.Warn().Err(err).Str("session_id", string(session.ID)).Msg("failed to write startup marker")
149- }
150- }
151 152
152- lf, lp, lockErr := lockSessionFile(lockDir, session.ID)153+ // Write PID file so next startup can detect crashes.
153- if lockErr != nil {154+ if logDir != "" {
154- // Don’t fail startup if locking fails; it only affects stale-session cleanup.155+ writePIDFile(logDir, &logger)
155- logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
156- } else {
157- lockMu.Lock()
158- lockFile = lf
159- lockPath = lp
160- lockMu.Unlock()
161 }156 }
162 157
163- // Defer stale session cleanup and old session pruning to background.158+ // Background cleanup of old sessions.
164- // This avoids blocking startup for non-critical housekeeping tasks.159+ go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, log)
165- runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
166 })160 })
167 return persistErr161 return persistErr
168 }162 }
@@ -171,29 +165,17 @@ func StartBrowserSession(
171 _ = persistFn(endCtx)165 _ = persistFn(endCtx)
172 endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())166 endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
173 167
174- lockMu.Lock()168+ // Remove PID file on clean shutdown.
175- lf := lockFile169+ if logDir != "" {
176- lp := lockPath170+ removePIDFile(logDir)
177- lockMu.Unlock()
178-
179- if lockDir != "" {
180- shutdownAt := time.Now().UTC()
181- if markerErr := writeShutdownMarker(lockDir, string(session.ID), shutdownAt); markerErr != nil {
182- logger.Warn().Err(markerErr).Str("session_id", string(session.ID)).Msg("failed to write clean shutdown marker")
183- }
184- }
185-
186- if lf != nil {
187- _ = unlockAndClose(lf)
188- _ = os.Remove(lp)
189 }171 }
190 return endErr172 return endErr
191 }173 }
192 174
193- browserSession.End = endFn175+ bs.End = endFn
194 176
195 if deferPersist {177 if deferPersist {
196- browserSession.Persist = persistFn178+ bs.Persist = persistFn
197 } else {179 } else {
198 if err := persistFn(ctx); err != nil {180 if err := persistFn(ctx); err != nil {
199 if cleanup != nil {181 if cleanup != nil {
@@ -203,479 +185,189 @@ func StartBrowserSession(
203 }185 }
204 }186 }
205 187
206- return browserSession, sessionCtx, nil188+ return bs, sessionCtx, nil
207 }189 }
208 190
209-func detectAbruptSessionsAndBuildReports(lockDir string, logger *zerolog.Logger) ([]string, error) {191+// ---------------------------------------------------------------------------
210- abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), logger)192+// PID file: single file {logDir}/dumber.pid
211- if abruptErr != nil {193+// ---------------------------------------------------------------------------
212- return nil, abruptErr
213- }
214-
215- generatedReports := make([]string, 0, len(abruptSessions))
216- for _, abruptSessionID := range abruptSessions {
217- if logger != nil {
218- logger.Warn().
219- Str("session_id", abruptSessionID).
220- Msg("abrupt-exit marker detected (startup without shutdown)")
221- }
222- reportPath, reportErr := writeUnexpectedCloseReport(lockDir, abruptSessionID)
223- if reportErr != nil {
224- if logger != nil {
225- logger.Warn().
226- Err(reportErr).
227- Str("session_id", abruptSessionID).
228- Msg("failed to write unexpected-close report")
229- }
230- continue
231- }
232- if reportPath == "" {
233- continue
234- }
235- generatedReports = append(generatedReports, reportPath)
236- if logger != nil {
237- logger.Warn().
238- Str("session_id", abruptSessionID).
239- Str("report_path", reportPath).
240- Msg("unexpected-close report generated")
241- }
242- }
243 194
244- return generatedReports, nil195+func pidFilePath(logDir string) string {
196+ return filepath.Join(logDir, pidFileName)
245 }197 }
246 198
247-func endStaleActiveBrowserSessions(199+func writePIDFile(logDir string, logger *zerolog.Logger) {
248- ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,200+ p := pidFilePath(logDir)
249-) error {201+ content := fmt.Sprintf("%d\n", os.Getpid())
250- // We only have GetRecentSessions; fetch enough to cover likely actives.202+ if err := os.WriteFile(p, []byte(content), filePerm); err != nil && logger != nil {
251- recent, err := sessionUC.GetRecentSessions(ctx, recentSessionsLimit)203+ logger.Warn().Err(err).Msg("failed to write PID file")
252- if err != nil {
253- return err
254 }204 }
255-
256- now := time.Now()
257- for _, s := range recent {
258- if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
259- continue
260- }
261-
262- lockPath := sessionLockPath(lockDir, s.ID)
263- if _, statErr := os.Stat(lockPath); statErr != nil {
264- // No lock file: conservative behavior, don’t auto-end.
265- continue
266- }
267-
268- f, err := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
269- if err != nil {
270- continue
271- }
272-
273- locked, lockErr := tryLockExclusiveNonBlocking(f)
274- if lockErr != nil {
275- _ = f.Close()
276- continue
277- }
278- if !locked {
279- // Another process is holding the lock => session still alive.
280- _ = f.Close()
281- continue
282- }
283-
284- // We acquired the lock => no running process holds it => stale session.
285- if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
286- if log != nil {
287- log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
288- }
289- }
290- _ = unlockAndClose(f)
291- _ = os.Remove(lockPath)
292- }
293-
294- return nil
295 }205 }
296 206
297-func sessionLockPath(lockDir string, sessionID entity.SessionID) string {207+func removePIDFile(logDir string) {
298- return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))208+ _ = os.Remove(pidFilePath(logDir))
299 }209 }
300 210
301-func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {211+// detectPreviousCrash checks the PID file from a previous run.
302- if lockDir == "" {212+// If the file exists and the process is dead, the previous session crashed.
303- return nil, "", errors.New("lock dir is empty")213+// Returns generated crash report paths (if any).
304- }214+func detectPreviousCrash(logDir string, logger *zerolog.Logger) []string {
305- if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {215+ p := pidFilePath(logDir)
306- return nil, "", err216+ raw, err := os.ReadFile(p)
307- }
308-
309- lockPath := sessionLockPath(lockDir, sessionID)
310- f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, lockFilePerm)
311 if err != nil {217 if err != nil {
312- return nil, "", err218+ return nil // No PID file → clean previous exit (or first run).
313- }
314-
315- locked, lockErr := tryLockExclusiveNonBlocking(f)
316- if lockErr != nil {
317- _ = f.Close()
318- return nil, "", lockErr
319- }
320- if !locked {
321- _ = f.Close()
322- return nil, "", errors.New("session lock already held")
323 }219 }
324- return f, lockPath, nil
325-}
326-
327-func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
328- if f == nil {
329- return false, errors.New("nil file")
330- }
331- err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
332- if err == nil {
333- return true, nil
334- }
335- if errors.Is(err, syscall.EWOULDBLOCK) {
336- return false, nil
337- }
338- return false, err
339-}
340 220
341-func unlockAndClose(f *os.File) error {221+ pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw)))
342- if f == nil {222+ if parseErr != nil {
223+ // Corrupt PID file, remove and move on.
224+ _ = os.Remove(p)
343 return nil225 return nil
344 }226 }
345- _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
346- return f.Close()
347-}
348-
349-func startupMarkerPath(lockDir, sessionID string) string {
350- return filepath.Join(lockDir, fmt.Sprintf("session_%s.startup.marker", sessionID))
351-}
352-
353-func shutdownMarkerPath(lockDir, sessionID string) string {
354- return filepath.Join(lockDir, fmt.Sprintf("session_%s.shutdown.marker", sessionID))
355-}
356-
357-func abruptMarkerPath(lockDir, sessionID string) string {
358- return filepath.Join(lockDir, fmt.Sprintf("session_%s.abrupt.marker", sessionID))
359-}
360-
361-func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error {
362- if lockDir == "" || sessionID == "" {
363- return errors.New("startup marker requires lockDir and sessionID")
364- }
365- if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
366- return err
367- }
368- content := []byte(fmt.Sprintf(
369- "%s\npid=%d\nppid=%d\n",
370- startedAt.Format(time.RFC3339Nano),
371- os.Getpid(),
372- os.Getppid(),
373- ))
374- if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
375- return err
376- }
377- _ = os.Remove(shutdownMarkerPath(lockDir, sessionID))
378- _ = os.Remove(abruptMarkerPath(lockDir, sessionID))
379- return nil
380-}
381-
382-func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
383- if lockDir == "" || sessionID == "" {
384- return errors.New("shutdown marker requires lockDir and sessionID")
385- }
386- if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
387- return err
388- }
389-
390- // Preserve the startup time inside the shutdown marker so
391- // ClassifySessionExitFromMarkers can still read it after the
392- // startup marker file is removed.
393- var startupLine string
394- var pidLine string
395- var ppidLine string
396- startupPath := startupMarkerPath(lockDir, sessionID)
397- if raw, readErr := os.ReadFile(startupPath); readErr == nil {
398- if t := firstNonEmptyLine(raw); t != "" {
399- startupLine = "started_at=" + t + "\n"
400- }
401- if pid := markerValue(raw, "pid="); pid != "" {
402- pidLine = "pid=" + pid + "\n"
403- }
404- if ppid := markerValue(raw, "ppid="); ppid != "" {
405- ppidLine = "ppid=" + ppid + "\n"
406- }
407- }
408-
409- content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine + pidLine + ppidLine)
410- if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
411- return err
412- }
413- // Clean up the matching startup marker now that shutdown is recorded.
414- // Errors are non-fatal: the marker will be swept later if removal fails.
415- _ = os.Remove(startupPath)
416- return nil
417-}
418 227
419-func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logger) ([]string, error) {228+ // Check if the process is still running.
420- if lockDir == "" {229+ proc, procErr := os.FindProcess(pid)
421- return nil, nil230+ if procErr == nil {
422- }231+ // On Unix, FindProcess always succeeds. Send signal 0 to probe existence.
423- pattern := filepath.Join(lockDir, "session_*.startup.marker")232+ if killErr := proc.Signal(syscall.Signal(0)); killErr == nil {
424- startupMarkers, err := filepath.Glob(pattern)233+ // Process is alive → another instance running, don't touch PID file.
425- if err != nil {
426- return nil, err
427- }
428-
429- abruptSessions := make([]string, 0, len(startupMarkers))
430- for _, startupPath := range startupMarkers {
431- sessionID := sessionIDFromStartupMarker(startupPath)
432- if sessionID == "" {
433- continue
434- }
435-
436- shouldMark, checkErr := shouldMarkAbruptExit(lockDir, sessionID, logger)
437- if checkErr != nil {
438 if logger != nil {234 if logger != nil {
439- logger.Warn().Err(checkErr).Str("session_id", sessionID).Msg("markAbruptExits: marker checks failed")235+ logger.Warn().Int("pid", pid).Msg("previous instance still running (PID file exists)")
440 }236 }
441- continue237+ return nil
442- }
443- if !shouldMark {
444- continue
445 }238 }
446-
447- startupRaw, _ := os.ReadFile(startupPath)
448- payload := fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath)
449- if startupAt := firstNonEmptyLine(startupRaw); startupAt != "" {
450- payload += "started_at=" + startupAt + "\n"
451- }
452- if pid := markerValue(startupRaw, "pid="); pid != "" {
453- payload += "pid=" + pid + "\n"
454- }
455- if ppid := markerValue(startupRaw, "ppid="); ppid != "" {
456- payload += "ppid=" + ppid + "\n"
457- }
458-
459- if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil {
460- return abruptSessions, err
461- }
462- abruptSessions = append(abruptSessions, sessionID)
463 }239 }
464 240
465- return abruptSessions, nil241+ // Previous process is dead → crash. Generate report from last log.
466-}242+ _ = os.Remove(p)
467 243
468-func sessionIDFromStartupMarker(startupPath string) string {244+ lastLog := findLastSessionLog(logDir)
469- base := filepath.Base(startupPath)245+ if lastLog == "" {
470- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {246+ return nil
471- return ""
472- }
473- return strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
474-}
475-
476-func shouldMarkAbruptExit(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
477- active, probeErr := sessionLockHeldByLiveProcess(lockDir, sessionID, logger)
478- if probeErr != nil {
479- return false, probeErr
480- }
481- if active {
482- return false, nil
483 }247 }
484 248
485- shutdownPath := shutdownMarkerPath(lockDir, sessionID)249+ lastSessionID := sessionIDFromLogPath(lastLog)
486- if exists, statErr := markerExists(shutdownPath); statErr != nil {250+ if lastSessionID == "" {
487- return false, statErr251+ return nil
488- } else if exists {
489- return false, nil
490 }252 }
491 253
492- abruptPath := abruptMarkerPath(lockDir, sessionID)254+ if logger != nil {
493- if exists, statErr := markerExists(abruptPath); statErr != nil {255+ logger.Warn().
494- return false, statErr256+ Int("crashed_pid", pid).
495- } else if exists {257+ Str("session_id", lastSessionID).
496- return false, nil258+ Msg("previous session crashed (stale PID file)")
497 }259 }
498 260
499- return true, nil261+ reportPath, reportErr := writeCrashReport(logDir, lastSessionID, pid)
500-}262+ if reportErr != nil {
501-
502-func sessionLockHeldByLiveProcess(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
503- lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID))
504- f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
505- if openErr != nil {
506- if os.IsNotExist(openErr) {
507- return false, nil
508- }
509 if logger != nil {263 if logger != nil {
510- logger.Warn().264+ logger.Warn().Err(reportErr).Msg("failed to write crash report")
511- Err(openErr).
512- Str("session_id", sessionID).
513- Str("path", lockPath).
514- Msg("markAbruptExits: open lock file failed")
515 }265 }
516- return false, openErr266+ return nil
517- }
518-
519- locked, lockErr := tryLockExclusiveNonBlocking(f)
520- if lockErr != nil {
521- _ = f.Close()
522- if logger != nil {
523- logger.Warn().
524- Err(lockErr).
525- Str("session_id", sessionID).
526- Str("path", lockPath).
527- Msg("markAbruptExits: lock probe failed")
528- }
529- return false, lockErr
530 }267 }
531- if !locked {268+ if reportPath == "" {
532- _ = f.Close()269+ return nil
533- return true, nil
534 }270 }
535- _ = unlockAndClose(f)271+ return []string{reportPath}
536- return false, nil
537 }272 }
538 273
539-func markerExists(path string) (bool, error) {274+// findLastSessionLog returns the most recent session_*.log file in logDir.
540- _, err := os.Stat(path)275+func findLastSessionLog(logDir string) string {
541- if err == nil {276+ matches, err := filepath.Glob(filepath.Join(logDir, "session_*.log"))
542- return true, nil277+ if err != nil || len(matches) == 0 {
278+ return ""
543 }279 }
544- if os.IsNotExist(err) {280+ var newest string
545- return false, nil281+ var newestMod time.Time
282+ for _, m := range matches {
283+ info, statErr := os.Stat(m)
284+ if statErr != nil {
285+ continue
286+ }
287+ if info.ModTime().After(newestMod) {
288+ newest = m
289+ newestMod = info.ModTime()
290+ }
546 }291 }
547- return false, err292+ return newest
548 }293 }
549 294
550-func firstNonEmptyLine(raw []byte) string {295+func sessionIDFromLogPath(logPath string) string {
551- trimmed := strings.TrimSpace(string(raw))296+ base := filepath.Base(logPath)
552- if trimmed == "" {297+ id, ok := corelogging.ParseSessionFilename(base)
298+ if !ok {
553 return ""299 return ""
554 }300 }
555- first, _, _ := strings.Cut(trimmed, "\n")301+ return id
556- return strings.TrimSpace(first)
557 }302 }
558 303
559-func markerValue(raw []byte, key string) string {304+// ---------------------------------------------------------------------------
560- if key == "" || len(raw) == 0 {305+// Background cleanup
561- return ""306+// ---------------------------------------------------------------------------
562- }
563- for _, line := range strings.Split(string(raw), "\n") {
564- line = strings.TrimSpace(line)
565- if strings.HasPrefix(line, key) {
566- return strings.TrimSpace(strings.TrimPrefix(line, key))
567- }
568- }
569- return ""
570-}
571 307
572-// runSessionCleanupAsync performs stale session cleanup and old session pruning308+func runSessionCleanup(
573-// in a background goroutine. This avoids blocking startup for non-critical tasks.
574-// Uses context.WithoutCancel(startupCtx) so the background work inherits request
575-// values and the logger from the startup context while detaching from its cancellation.
576-func runSessionCleanupAsync(
577 startupCtx context.Context,309 startupCtx context.Context,
578 sessionUC *usecase.ManageSessionUseCase,310 sessionUC *usecase.ManageSessionUseCase,
579 cleanupUC *usecase.CleanupSessionsUseCase,311 cleanupUC *usecase.CleanupSessionsUseCase,
580 cfg *config.Config,312 cfg *config.Config,
581- lockDir string,
582 log *zerolog.Logger,313 log *zerolog.Logger,
583 ) {314 ) {
584- go func() {315+ bgCtx := context.WithoutCancel(startupCtx)
585- // Detach cancellation from the startup context so cleanup runs to completion
586- // even if the startup context is canceled, while still carrying its values/logger.
587- bgCtx := context.WithoutCancel(startupCtx)
588-
589- // End stale active sessions (orphaned from crashed processes)
590- if lockDir != "" {
591- if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
592- if log != nil {
593- log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
594- }
595- }
596- }
597 316
598- // Clean up old exited sessions based on config limits317+ // End stale active sessions that have no running process.
599- cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{318+ recent, err := sessionUC.GetRecentSessions(bgCtx, recentSessionsLimit)
600- MaxExitedSessions: cfg.Session.MaxExitedSessions,319+ if err != nil {
601- MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,320+ if log != nil {
602- })321+ log.Warn().Err(err).Msg("background: failed to list recent sessions")
603- if err != nil {
604- if log != nil {
605- log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
606- }
607- } else if cleanupOutput.TotalDeleted > 0 {
608- if log != nil {
609- log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
610- }
611 }322 }
612-323+ } else {
613- // Sweep paired startup+shutdown markers that are no longer needed.324+ now := time.Now()
614- if lockDir != "" {325+ for _, s := range recent {
615- sweepPairedMarkers(lockDir, cfg.Session.MaxExitedSessionAgeDays, log)326+ if s != nil && s.Type == entity.SessionTypeBrowser && s.IsActive() {
327+ _ = sessionUC.EndSession(bgCtx, s.ID, now)
328+ }
616 }329 }
617- }()
618-}
619-
620-// sweepPairedMarkers removes marker files for sessions that have both a
621-// shutdown marker and a startup marker older than maxAgeDays. Both markers
622-// must exist and be aged past the cutoff before any files are removed.
623-// The associated abrupt marker (if present) is also cleaned up.
624-// Removal errors are non-fatal (logged and skipped).
625-func sweepPairedMarkers(lockDir string, maxAgeDays int, log *zerolog.Logger) {
626- if lockDir == "" {
627- return
628- }
629- if maxAgeDays <= 0 {
630- maxAgeDays = 7
631 }330 }
632- cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
633 331
634- shutdownMarkers, err := filepath.Glob(filepath.Join(lockDir, "session_*.shutdown.marker"))332+ // Clean up old exited sessions.
333+ output, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
334+ MaxExitedSessions: cfg.Session.MaxExitedSessions,
335+ MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
336+ })
635 if err != nil {337 if err != nil {
636 if log != nil {338 if log != nil {
637- log.Warn().Err(err).Msg("sweepPairedMarkers: glob failed")339+ log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
638 }340 }
639- return341+ } else if output.TotalDeleted > 0 && log != nil {
342+ log.Info().Int64("deleted", output.TotalDeleted).Msg("background: cleaned up old sessions")
640 }343 }
641 344
642- swept := 0345+ // Prune old marker files left by previous versions.
643- for _, shutdownPath := range shutdownMarkers {346+ sweepLegacyMarkers(cfg.Logging.LogDir, log)
644- base := filepath.Base(shutdownPath)347+}
645- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".shutdown.marker") {
646- continue
647- }
648- sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".shutdown.marker")
649- if sessionID == "" {
650- continue
651- }
652-
653- shutdownInfo, err := os.Stat(shutdownPath)
654- if err != nil || shutdownInfo.ModTime().After(cutoff) {
655- continue
656- }
657-
658- startupPath := startupMarkerPath(lockDir, sessionID)
659- startupInfo, err := os.Stat(startupPath)
660- if err != nil || startupInfo.ModTime().After(cutoff) {
661- // Startup marker missing or too recent — skip this session.
662- continue
663- }
664-
665- abruptPath := abruptMarkerPath(lockDir, sessionID)
666 348
667- // Remove the trio: shutdown, startup, abrupt (if present).349+// sweepLegacyMarkers removes .startup.marker, .shutdown.marker,
668- for _, p := range []string{shutdownPath, startupPath, abruptPath} {350+// .abrupt.marker, and .lock files left by the old session system.
669- if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) {351+func sweepLegacyMarkers(logDir string, log *zerolog.Logger) {
670- if log != nil {352+ if logDir == "" {
671- log.Warn().Err(rmErr).Str("path", p).Msg("sweepPairedMarkers: remove failed")353+ return
672- }354+ }
355+ patterns := []string{
356+ "session_*.startup.marker",
357+ "session_*.shutdown.marker",
358+ "session_*.abrupt.marker",
359+ "session_*.lock",
360+ }
361+ swept := 0
362+ for _, pat := range patterns {
363+ matches, _ := filepath.Glob(filepath.Join(logDir, pat))
364+ for _, m := range matches {
365+ if os.Remove(m) == nil {
366+ swept++
673 }367 }
674 }368 }
675- swept++
676 }369 }
677-
678 if swept > 0 && log != nil {370 if swept > 0 && log != nil {
679- log.Info().Int("swept", swept).Msg("sweepPairedMarkers: cleaned up old marker files")371+ log.Info().Int("swept", swept).Msg("cleaned up legacy session marker files")
680 }372 }
681 }373 }
D internal/bootstrap/session_exit_classification.go +0 -209
@@ -1,209 +0,0 @@
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 `json:"session_id"`
23- Class SessionExitClass `json:"class"`
24- Inference string `json:"inference"`
25- Reason string `json:"reason"`
26- StartupAt *time.Time `json:"startup_at,omitempty"`
27- ShutdownAt *time.Time `json:"shutdown_at,omitempty"`
28- AbruptDetectedAt *time.Time `json:"abrupt_detected_at,omitempty"`
29- LastMarkerObserved time.Time `json:"last_marker_observed"`
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- // If the startup marker was already cleaned up (writeShutdownMarker removes
57- // it), fall back to the started_at line embedded in the shutdown marker.
58- if startupAt == nil && shutdownExists {
59- startupAt, _, _, _ = readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "started_at=")
60- }
61- classification.StartupAt = startupAt
62- classification.ShutdownAt = shutdownAt
63- classification.AbruptDetectedAt = abruptAt
64- classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime)
65-
66- switch {
67- case shutdownExists:
68- classification.Class = SessionExitCleanExit
69- classification.Inference = "marker-confirmed"
70- classification.Reason = "shutdown marker present"
71- case abruptExists:
72- classification.Class = SessionExitMainProcessCrashOrAbrupt
73- classification.Inference = "marker-confirmed"
74- classification.Reason = "abrupt marker present and no shutdown marker"
75- case startupExists:
76- classification.Class = SessionExitExternalKillOrOOMInferred
77- classification.Inference = "best-effort"
78- classification.Reason = "startup marker present without shutdown/abrupt markers"
79- }
80-
81- return classification, nil
82-}
83-
84-func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) {
85- if lockDir == "" {
86- return nil, nil
87- }
88-
89- sessionIDs, err := discoverSessionIDsFromMarkers(lockDir)
90- if err != nil {
91- return nil, err
92- }
93- if len(sessionIDs) == 0 {
94- return nil, nil
95- }
96-
97- report := make([]SessionExitClassification, 0, len(sessionIDs))
98- for _, sessionID := range sessionIDs {
99- classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
100- if classErr != nil {
101- return nil, classErr
102- }
103- report = append(report, classification)
104- }
105-
106- sort.Slice(report, func(i, j int) bool {
107- return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved)
108- })
109- return report, nil
110-}
111-
112-func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) {
113- pattern := filepath.Join(lockDir, "session_*.marker")
114- paths, err := filepath.Glob(pattern)
115- if err != nil {
116- return nil, err
117- }
118-
119- ids := make(map[string]struct{}, len(paths))
120- for _, path := range paths {
121- base := filepath.Base(path)
122- sessionID, ok := markerSessionID(base)
123- if !ok {
124- continue
125- }
126- ids[sessionID] = struct{}{}
127- }
128-
129- sessionIDs := make([]string, 0, len(ids))
130- for id := range ids {
131- sessionIDs = append(sessionIDs, id)
132- }
133- sort.Strings(sessionIDs)
134- return sessionIDs, nil
135-}
136-
137-func markerSessionID(base string) (string, bool) {
138- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") {
139- return "", false
140- }
141-
142- trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker")
143- switch {
144- case strings.HasSuffix(trimmed, ".startup"):
145- return strings.TrimSuffix(trimmed, ".startup"), true
146- case strings.HasSuffix(trimmed, ".shutdown"):
147- return strings.TrimSuffix(trimmed, ".shutdown"), true
148- case strings.HasSuffix(trimmed, ".abrupt"):
149- return strings.TrimSuffix(trimmed, ".abrupt"), true
150- default:
151- return "", false
152- }
153-}
154-
155-func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) {
156- stat, err := os.Stat(path)
157- if err != nil {
158- if os.IsNotExist(err) {
159- return nil, false, time.Time{}, nil
160- }
161- return nil, false, time.Time{}, err
162- }
163-
164- content, err := os.ReadFile(path)
165- if err != nil {
166- return nil, true, stat.ModTime(), err
167- }
168-
169- raw := strings.TrimSpace(string(content))
170- if raw == "" {
171- return nil, true, stat.ModTime(), nil
172- }
173-
174- if keyPrefix != "" {
175- lines := strings.Split(raw, "\n")
176- for _, line := range lines {
177- if strings.HasPrefix(line, keyPrefix) {
178- parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix))
179- if parseErr != nil {
180- return nil, true, stat.ModTime(), nil
181- }
182- return &parsed, true, stat.ModTime(), nil
183- }
184- }
185- return nil, true, stat.ModTime(), nil
186- }
187-
188- // When no key prefix is given, parse the first line as the timestamp.
189- // Multi-line markers may contain additional metadata (e.g. started_at=).
190- firstLine, _, _ := strings.Cut(raw, "\n")
191- parsed, parseErr := time.Parse(time.RFC3339Nano, firstLine)
192- if parseErr != nil {
193- return nil, true, stat.ModTime(), nil
194- }
195- return &parsed, true, stat.ModTime(), nil
196-}
197-
198-func latestNonZero(values ...time.Time) time.Time {
199- var latest time.Time
200- for _, value := range values {
201- if value.IsZero() {
202- continue
203- }
204- if latest.IsZero() || value.After(latest) {
205- latest = value
206- }
207- }
208- return latest
209-}
@@ -1,209 +0,0 @@
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 `json:"session_id"`
23- Class SessionExitClass `json:"class"`
24- Inference string `json:"inference"`
25- Reason string `json:"reason"`
26- StartupAt *time.Time `json:"startup_at,omitempty"`
27- ShutdownAt *time.Time `json:"shutdown_at,omitempty"`
28- AbruptDetectedAt *time.Time `json:"abrupt_detected_at,omitempty"`
29- LastMarkerObserved time.Time `json:"last_marker_observed"`
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- // If the startup marker was already cleaned up (writeShutdownMarker removes
57- // it), fall back to the started_at line embedded in the shutdown marker.
58- if startupAt == nil && shutdownExists {
59- startupAt, _, _, _ = readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "started_at=")
60- }
61- classification.StartupAt = startupAt
62- classification.ShutdownAt = shutdownAt
63- classification.AbruptDetectedAt = abruptAt
64- classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime)
65-
66- switch {
67- case shutdownExists:
68- classification.Class = SessionExitCleanExit
69- classification.Inference = "marker-confirmed"
70- classification.Reason = "shutdown marker present"
71- case abruptExists:
72- classification.Class = SessionExitMainProcessCrashOrAbrupt
73- classification.Inference = "marker-confirmed"
74- classification.Reason = "abrupt marker present and no shutdown marker"
75- case startupExists:
76- classification.Class = SessionExitExternalKillOrOOMInferred
77- classification.Inference = "best-effort"
78- classification.Reason = "startup marker present without shutdown/abrupt markers"
79- }
80-
81- return classification, nil
82-}
83-
84-func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) {
85- if lockDir == "" {
86- return nil, nil
87- }
88-
89- sessionIDs, err := discoverSessionIDsFromMarkers(lockDir)
90- if err != nil {
91- return nil, err
92- }
93- if len(sessionIDs) == 0 {
94- return nil, nil
95- }
96-
97- report := make([]SessionExitClassification, 0, len(sessionIDs))
98- for _, sessionID := range sessionIDs {
99- classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
100- if classErr != nil {
101- return nil, classErr
102- }
103- report = append(report, classification)
104- }
105-
106- sort.Slice(report, func(i, j int) bool {
107- return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved)
108- })
109- return report, nil
110-}
111-
112-func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) {
113- pattern := filepath.Join(lockDir, "session_*.marker")
114- paths, err := filepath.Glob(pattern)
115- if err != nil {
116- return nil, err
117- }
118-
119- ids := make(map[string]struct{}, len(paths))
120- for _, path := range paths {
121- base := filepath.Base(path)
122- sessionID, ok := markerSessionID(base)
123- if !ok {
124- continue
125- }
126- ids[sessionID] = struct{}{}
127- }
128-
129- sessionIDs := make([]string, 0, len(ids))
130- for id := range ids {
131- sessionIDs = append(sessionIDs, id)
132- }
133- sort.Strings(sessionIDs)
134- return sessionIDs, nil
135-}
136-
137-func markerSessionID(base string) (string, bool) {
138- if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") {
139- return "", false
140- }
141-
142- trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker")
143- switch {
144- case strings.HasSuffix(trimmed, ".startup"):
145- return strings.TrimSuffix(trimmed, ".startup"), true
146- case strings.HasSuffix(trimmed, ".shutdown"):
147- return strings.TrimSuffix(trimmed, ".shutdown"), true
148- case strings.HasSuffix(trimmed, ".abrupt"):
149- return strings.TrimSuffix(trimmed, ".abrupt"), true
150- default:
151- return "", false
152- }
153-}
154-
155-func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) {
156- stat, err := os.Stat(path)
157- if err != nil {
158- if os.IsNotExist(err) {
159- return nil, false, time.Time{}, nil
160- }
161- return nil, false, time.Time{}, err
162- }
163-
164- content, err := os.ReadFile(path)
165- if err != nil {
166- return nil, true, stat.ModTime(), err
167- }
168-
169- raw := strings.TrimSpace(string(content))
170- if raw == "" {
171- return nil, true, stat.ModTime(), nil
172- }
173-
174- if keyPrefix != "" {
175- lines := strings.Split(raw, "\n")
176- for _, line := range lines {
177- if strings.HasPrefix(line, keyPrefix) {
178- parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix))
179- if parseErr != nil {
180- return nil, true, stat.ModTime(), nil
181- }
182- return &parsed, true, stat.ModTime(), nil
183- }
184- }
185- return nil, true, stat.ModTime(), nil
186- }
187-
188- // When no key prefix is given, parse the first line as the timestamp.
189- // Multi-line markers may contain additional metadata (e.g. started_at=).
190- firstLine, _, _ := strings.Cut(raw, "\n")
191- parsed, parseErr := time.Parse(time.RFC3339Nano, firstLine)
192- if parseErr != nil {
193- return nil, true, stat.ModTime(), nil
194- }
195- return &parsed, true, stat.ModTime(), nil
196-}
197-
198-func latestNonZero(values ...time.Time) time.Time {
199- var latest time.Time
200- for _, value := range values {
201- if value.IsZero() {
202- continue
203- }
204- if latest.IsZero() || value.After(latest) {
205- latest = value
206- }
207- }
208- return latest
209-}
D internal/bootstrap/session_exit_classification_test.go +0 -115
@@ -1,115 +0,0 @@
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-}
@@ -1,115 +0,0 @@
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-}
D internal/bootstrap/session_markers_test.go +0 -73
@@ -1,73 +0,0 @@
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- assert.Contains(t, string(startupContent), "pid=")
26- assert.Contains(t, string(startupContent), "ppid=")
27-
28- err = writeShutdownMarker(lockDir, sessionID, endedAt)
29- require.NoError(t, err)
30-
31- shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID))
32- require.NoError(t, err)
33- assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano))
34- assert.Contains(t, string(shutdownContent), "started_at="+startedAt.Format(time.RFC3339Nano))
35- assert.Contains(t, string(shutdownContent), "pid=")
36- assert.Contains(t, string(shutdownContent), "ppid=")
37-}
38-
39-func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
40- lockDir := t.TempDir()
41- detectedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
42-
43- require.NoError(t, writeStartupMarker(lockDir, "abrupt-a", detectedAt.Add(-5*time.Minute)))
44- require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute)))
45- require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute)))
46-
47- abruptSessions, err := markAbruptExits(lockDir, detectedAt, nil)
48- require.NoError(t, err)
49- assert.Equal(t, []string{"abrupt-a"}, abruptSessions)
50-
51- abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a"))
52- require.NoError(t, err)
53- assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano))
54- assert.Contains(t, string(abruptMarkerData), "started_at=")
55- assert.Contains(t, string(abruptMarkerData), "pid=")
56- assert.Contains(t, string(abruptMarkerData), "ppid=")
57- assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker"))
58-}
59-
60-func TestSessionMarkers_MarkAbruptExitsIdempotent(t *testing.T) {
61- lockDir := t.TempDir()
62- detectedAt := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
63-
64- require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute)))
65-
66- first, err := markAbruptExits(lockDir, detectedAt, nil)
67- require.NoError(t, err)
68- assert.Equal(t, []string{"abrupt-c"}, first)
69-
70- second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute), nil)
71- require.NoError(t, err)
72- assert.Empty(t, second)
73-}
@@ -1,73 +0,0 @@
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- assert.Contains(t, string(startupContent), "pid=")
26- assert.Contains(t, string(startupContent), "ppid=")
27-
28- err = writeShutdownMarker(lockDir, sessionID, endedAt)
29- require.NoError(t, err)
30-
31- shutdownContent, err := os.ReadFile(shutdownMarkerPath(lockDir, sessionID))
32- require.NoError(t, err)
33- assert.Contains(t, string(shutdownContent), endedAt.Format(time.RFC3339Nano))
34- assert.Contains(t, string(shutdownContent), "started_at="+startedAt.Format(time.RFC3339Nano))
35- assert.Contains(t, string(shutdownContent), "pid=")
36- assert.Contains(t, string(shutdownContent), "ppid=")
37-}
38-
39-func TestSessionMarkers_MarkAbruptExits(t *testing.T) {
40- lockDir := t.TempDir()
41- detectedAt := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
42-
43- require.NoError(t, writeStartupMarker(lockDir, "abrupt-a", detectedAt.Add(-5*time.Minute)))
44- require.NoError(t, writeStartupMarker(lockDir, "clean-b", detectedAt.Add(-4*time.Minute)))
45- require.NoError(t, writeShutdownMarker(lockDir, "clean-b", detectedAt.Add(-1*time.Minute)))
46-
47- abruptSessions, err := markAbruptExits(lockDir, detectedAt, nil)
48- require.NoError(t, err)
49- assert.Equal(t, []string{"abrupt-a"}, abruptSessions)
50-
51- abruptMarkerData, err := os.ReadFile(abruptMarkerPath(lockDir, "abrupt-a"))
52- require.NoError(t, err)
53- assert.Contains(t, string(abruptMarkerData), "detected_at="+detectedAt.Format(time.RFC3339Nano))
54- assert.Contains(t, string(abruptMarkerData), "started_at=")
55- assert.Contains(t, string(abruptMarkerData), "pid=")
56- assert.Contains(t, string(abruptMarkerData), "ppid=")
57- assert.FileExists(t, filepath.Join(lockDir, "session_clean-b.shutdown.marker"))
58-}
59-
60-func TestSessionMarkers_MarkAbruptExitsIdempotent(t *testing.T) {
61- lockDir := t.TempDir()
62- detectedAt := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
63-
64- require.NoError(t, writeStartupMarker(lockDir, "abrupt-c", detectedAt.Add(-10*time.Minute)))
65-
66- first, err := markAbruptExits(lockDir, detectedAt, nil)
67- require.NoError(t, err)
68- assert.Equal(t, []string{"abrupt-c"}, first)
69-
70- second, err := markAbruptExits(lockDir, detectedAt.Add(1*time.Minute), nil)
71- require.NoError(t, err)
72- assert.Empty(t, second)
73-}
M internal/cli/app.go +2 -2
@@ -112,9 +112,9 @@ func NewApp() (*App, error) {
112112
113113 // Create use cases
114114 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
115- listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir)
115+ listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo)
116116 restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
117- deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo, cfg.Logging.LogDir)
117+ deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo)
118118
119119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
120120 faviconCacheDir, _ := config.GetFaviconCacheDir()
@@ -112,9 +112,9 @@ func NewApp() (*App, error) {
112 112
113 // Create use cases113 // Create use cases
114 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)114 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
115- listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir)115+ listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo)
116 restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)116 restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
117- deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo, cfg.Logging.LogDir)117+ deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo)
118 118
119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
120 faviconCacheDir, _ := config.GetFaviconCacheDir()120 faviconCacheDir, _ := config.GetFaviconCacheDir()

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