1package bootstrap
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10 "syscall"
11 "time"
12
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/usecase"
15 "github.com/bnema/dumber/internal/domain/entity"
16 "github.com/bnema/dumber/internal/domain/repository"
17 "github.com/bnema/dumber/internal/infrastructure/config"
18 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
19 corelogging "github.com/bnema/dumber/internal/logging"
20 "github.com/rs/zerolog"
21)
22
23const (
24 recentSessionsLimit = 200
25 lockDirPerm = 0o755
26 lockFilePerm = 0o600
27)
28
29type BrowserSession struct {
30 Session *entity.Session
31 Logger zerolog.Logger
32 LogCleanup func()
33
34 Persist func(context.Context) error
35 End func(context.Context) error
36}
37
38func StartBrowserSession(
39 ctx context.Context,
40 cfg *config.Config,
41 sessionRepo repository.SessionRepository,
42 deferPersist bool,
43) (*BrowserSession, context.Context, error) {
44 log := corelogging.FromContext(ctx)
45 if sessionRepo == nil {
46 return nil, ctx, errors.New("session repository is nil")
47 }
48
49 lockDir := cfg.Logging.LogDir
50 if lockDir == "" {
51 if dir, err := config.GetLogDir(); err == nil {
52 lockDir = dir
53 }
54 }
55
56 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
57 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
58 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
59
60 now := time.Now()
61 session := &entity.Session{
62 ID: entity.SessionID(corelogging.GenerateSessionID()),
63 Type: entity.SessionTypeBrowser,
64 StartedAt: now.UTC(),
65 }
66 if err := session.Validate(); err != nil {
67 return nil, ctx, err
68 }
69
70 logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
71 Level: cfg.Logging.Level,
72 Format: cfg.Logging.Format,
73 TimeFormat: corelogging.ConsoleTimeFormat,
74 LogDir: cfg.Logging.LogDir,
75 WriteToStderr: true,
76 EnableFileLog: cfg.Logging.EnableFileLog,
77 })
78 if err != nil {
79 log.Warn().Err(err).Msg("failed to create session logger")
80 }
81
82 sessionCtx := corelogging.WithContext(context.Background(), logger)
83
84 var (
85 lockMu sync.Mutex
86 lockFile *os.File
87 lockPath string
88 persistMu sync.Once
89 persistErr error
90 )
91
92 // persistFn ensures the session is saved exactly once. Using sync.Once is intentional:
93 // if the first persist fails, we cache the error and return it on subsequent calls
94 // rather than retrying. This prevents duplicate session records and ensures consistent
95 // error handling during shutdown sequences where persistFn may be called multiple times.
96 persistFn := func(persistCtx context.Context) error {
97 persistMu.Do(func() {
98 saveCtx := corelogging.WithContext(context.Background(), logger)
99 if err := sessionRepo.Save(saveCtx, session); err != nil {
100 persistErr = fmt.Errorf("save session: %w", err)
101 return
102 }
103
104 logger.Info().
105 Str("session_id", string(session.ID)).
106 Str("type", string(session.Type)).
107 Msg("session started")
108
109 lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
110 if lockErr != nil {
111 // Don’t fail startup if locking fails; it only affects stale-session cleanup.
112 logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
113 } else {
114 lockMu.Lock()
115 lockFile = lf
116 lockPath = lp
117 lockMu.Unlock()
118 }
119
120 // Defer stale session cleanup and old session pruning to background.
121 // This avoids blocking startup for non-critical housekeeping tasks.
122 runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
123 })
124 return persistErr
125 }
126
127 endFn := func(endCtx context.Context) error {
128 _ = persistFn(endCtx)
129 endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
130
131 lockMu.Lock()
132 lf := lockFile
133 lp := lockPath
134 lockMu.Unlock()
135
136 if lf != nil {
137 _ = unlockAndClose(lf)
138 _ = os.Remove(lp)
139 }
140 return endErr
141 }
142
143 browserSession := &BrowserSession{
144 Session: session,
145 Logger: logger,
146 LogCleanup: cleanup,
147 End: endFn,
148 }
149
150 if deferPersist {
151 browserSession.Persist = persistFn
152 } else {
153 if err := persistFn(ctx); err != nil {
154 if cleanup != nil {
155 cleanup()
156 }
157 return nil, ctx, err
158 }
159 }
160
161 return browserSession, sessionCtx, nil
162}
163
164func endStaleActiveBrowserSessions(
165 ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,
166) error {
167 // We only have GetRecentSessions; fetch enough to cover likely actives.
168 recent, err := sessionUC.GetRecentSessions(ctx, recentSessionsLimit)
169 if err != nil {
170 return err
171 }
172
173 now := time.Now()
174 for _, s := range recent {
175 if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
176 continue
177 }
178
179 lockPath := sessionLockPath(lockDir, s.ID)
180 if _, statErr := os.Stat(lockPath); statErr != nil {
181 // No lock file: conservative behavior, don’t auto-end.
182 continue
183 }
184
185 f, err := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
186 if err != nil {
187 continue
188 }
189
190 locked, lockErr := tryLockExclusiveNonBlocking(f)
191 if lockErr != nil {
192 _ = f.Close()
193 continue
194 }
195 if !locked {
196 // Another process is holding the lock => session still alive.
197 _ = f.Close()
198 continue
199 }
200
201 // We acquired the lock => no running process holds it => stale session.
202 if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
203 if log != nil {
204 log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
205 }
206 }
207 _ = unlockAndClose(f)
208 _ = os.Remove(lockPath)
209 }
210
211 return nil
212}
213
214func sessionLockPath(lockDir string, sessionID entity.SessionID) string {
215 return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
216}
217
218func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {
219 if lockDir == "" {
220 return nil, "", errors.New("lock dir is empty")
221 }
222 if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
223 return nil, "", err
224 }
225
226 lockPath := sessionLockPath(lockDir, sessionID)
227 f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, lockFilePerm)
228 if err != nil {
229 return nil, "", err
230 }
231
232 locked, lockErr := tryLockExclusiveNonBlocking(f)
233 if lockErr != nil {
234 _ = f.Close()
235 return nil, "", lockErr
236 }
237 if !locked {
238 _ = f.Close()
239 return nil, "", errors.New("session lock already held")
240 }
241 return f, lockPath, nil
242}
243
244func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
245 if f == nil {
246 return false, errors.New("nil file")
247 }
248 err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
249 if err == nil {
250 return true, nil
251 }
252 if errors.Is(err, syscall.EWOULDBLOCK) {
253 return false, nil
254 }
255 return false, err
256}
257
258func unlockAndClose(f *os.File) error {
259 if f == nil {
260 return nil
261 }
262 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
263 return f.Close()
264}
265
266// runSessionCleanupAsync performs stale session cleanup and old session pruning
267// in a background goroutine. This avoids blocking startup for non-critical tasks.
268// Uses context.Background() to ensure cleanup completes even if startup context is canceled.
269func runSessionCleanupAsync(
270 startupCtx context.Context,
271 sessionUC *usecase.ManageSessionUseCase,
272 cleanupUC *usecase.CleanupSessionsUseCase,
273 cfg *config.Config,
274 lockDir string,
275 log *zerolog.Logger,
276) {
277 // Silence unused parameter warning - startupCtx is intentionally unused.
278 _ = startupCtx
279
280 go func() {
281 // Use a detached background context instead of the startup context:
282 // session cleanup is critical and must run to completion even if the
283 // startup context is canceled or times out.
284 bgCtx := context.Background()
285
286 // End stale active sessions (orphaned from crashed processes)
287 if lockDir != "" {
288 if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
289 if log != nil {
290 log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
291 }
292 }
293 }
294
295 // Clean up old exited sessions based on config limits
296 cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
297 MaxExitedSessions: cfg.Session.MaxExitedSessions,
298 MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
299 })
300 if err != nil {
301 if log != nil {
302 log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
303 }
304 } else if cleanupOutput.TotalDeleted > 0 {
305 if log != nil {
306 log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
307 }
308 }
309 }()
310}