1package bootstrap
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "syscall"
12 "time"
13
14 "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/application/usecase"
16 "github.com/bnema/dumber/internal/domain/entity"
17 "github.com/bnema/dumber/internal/domain/repository"
18 "github.com/bnema/dumber/internal/infrastructure/config"
19 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
20 corelogging "github.com/bnema/dumber/internal/logging"
21 "github.com/rs/zerolog"
22)
23
24const (
25 recentSessionsLimit = 200
26 lockDirPerm = 0o755
27 lockFilePerm = 0o600
28 markerFilePerm = 0o644
29)
30
31type BrowserSession struct {
32 Session *entity.Session
33 Logger zerolog.Logger
34 LogCleanup func()
35
36 reportsMu sync.RWMutex
37 unexpectedCloseReportPath []string
38
39 Persist func(context.Context) error
40 End func(context.Context) error
41}
42
43func (s *BrowserSession) SetUnexpectedCloseReports(paths []string) {
44 if s == nil {
45 return
46 }
47 s.reportsMu.Lock()
48 defer s.reportsMu.Unlock()
49 s.unexpectedCloseReportPath = append([]string(nil), paths...)
50}
51
52func (s *BrowserSession) UnexpectedCloseReports() []string {
53 if s == nil {
54 return nil
55 }
56 s.reportsMu.RLock()
57 defer s.reportsMu.RUnlock()
58 return append([]string(nil), s.unexpectedCloseReportPath...)
59}
60
61func StartBrowserSession(
62 ctx context.Context,
63 cfg *config.Config,
64 sessionRepo repository.SessionRepository,
65 deferPersist bool,
66) (*BrowserSession, context.Context, error) {
67 log := corelogging.FromContext(ctx)
68 if sessionRepo == nil {
69 return nil, ctx, errors.New("session repository is nil")
70 }
71
72 lockDir := cfg.Logging.LogDir
73 if lockDir == "" {
74 if dir, err := config.GetLogDir(); err == nil {
75 lockDir = dir
76 }
77 }
78
79 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
80 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
81 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
82
83 now := time.Now()
84 session := &entity.Session{
85 ID: entity.SessionID(corelogging.GenerateSessionID()),
86 Type: entity.SessionTypeBrowser,
87 StartedAt: now.UTC(),
88 }
89 if err := session.Validate(); err != nil {
90 return nil, ctx, err
91 }
92
93 logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
94 Level: cfg.Logging.Level,
95 Format: cfg.Logging.Format,
96 TimeFormat: corelogging.ConsoleTimeFormat,
97 LogDir: cfg.Logging.LogDir,
98 WriteToStderr: true,
99 EnableFileLog: cfg.Logging.EnableFileLog,
100 })
101 if err != nil {
102 log.Warn().Err(err).Msg("failed to create session logger")
103 }
104
105 sessionCtx := corelogging.WithContext(context.Background(), logger)
106
107 var (
108 lockMu sync.Mutex
109 lockFile *os.File
110 lockPath string
111 persistMu sync.Once
112 persistErr error
113 )
114
115 browserSession := &BrowserSession{
116 Session: session,
117 Logger: logger,
118 LogCleanup: cleanup,
119 }
120
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.
125 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)
133 }
134 }
135
136 saveCtx := corelogging.WithContext(context.Background(), logger)
137 if err := sessionRepo.Save(saveCtx, session); err != nil {
138 persistErr = fmt.Errorf("save session: %w", err)
139 return
140 }
141
142 logger.Info().
143 Str("session_id", string(session.ID)).
144 Str("type", string(session.Type)).
145 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 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()
161 }
162
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)
166 })
167 return persistErr
168 }
169
170 endFn := func(endCtx context.Context) error {
171 _ = persistFn(endCtx)
172 endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
173
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)
189 }
190 return endErr
191 }
192
193 browserSession.End = endFn
194
195 if deferPersist {
196 browserSession.Persist = persistFn
197 } else {
198 if err := persistFn(ctx); err != nil {
199 if cleanup != nil {
200 cleanup()
201 }
202 return nil, ctx, err
203 }
204 }
205
206 return browserSession, sessionCtx, nil
207}
208
209func 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 }
243
244 return generatedReports, nil
245}
246
247func 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
254 }
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}
296
297func sessionLockPath(lockDir string, sessionID entity.SessionID) string {
298 return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
299}
300
301func 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)
311 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")
323 }
324 return f, lockPath, nil
325}
326
327func 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
341func unlockAndClose(f *os.File) error {
342 if f == nil {
343 return nil
344 }
345 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
346 return f.Close()
347}
348
349func startupMarkerPath(lockDir, sessionID string) string {
350 return filepath.Join(lockDir, fmt.Sprintf("session_%s.startup.marker", sessionID))
351}
352
353func shutdownMarkerPath(lockDir, sessionID string) string {
354 return filepath.Join(lockDir, fmt.Sprintf("session_%s.shutdown.marker", sessionID))
355}
356
357func abruptMarkerPath(lockDir, sessionID string) string {
358 return filepath.Join(lockDir, fmt.Sprintf("session_%s.abrupt.marker", sessionID))
359}
360
361func 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
382func 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
419func 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 {
438 if logger != nil {
439 logger.Warn().Err(checkErr).Str("session_id", sessionID).Msg("markAbruptExits: marker checks failed")
440 }
441 continue
442 }
443 if !shouldMark {
444 continue
445 }
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 }
464
465 return abruptSessions, nil
466}
467
468func 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
476func 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 }
484
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
490 }
491
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
497 }
498
499 return true, nil
500}
501
502func 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 {
510 logger.Warn().
511 Err(openErr).
512 Str("session_id", sessionID).
513 Str("path", lockPath).
514 Msg("markAbruptExits: open lock file failed")
515 }
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
530 }
531 if !locked {
532 _ = f.Close()
533 return true, nil
534 }
535 _ = unlockAndClose(f)
536 return false, nil
537}
538
539func markerExists(path string) (bool, error) {
540 _, err := os.Stat(path)
541 if err == nil {
542 return true, nil
543 }
544 if os.IsNotExist(err) {
545 return false, nil
546 }
547 return false, err
548}
549
550func firstNonEmptyLine(raw []byte) string {
551 trimmed := strings.TrimSpace(string(raw))
552 if trimmed == "" {
553 return ""
554 }
555 first, _, _ := strings.Cut(trimmed, "\n")
556 return strings.TrimSpace(first)
557}
558
559func 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}
571
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.Background() to ensure cleanup completes even if startup context is canceled.
575func runSessionCleanupAsync(
576 startupCtx context.Context,
577 sessionUC *usecase.ManageSessionUseCase,
578 cleanupUC *usecase.CleanupSessionsUseCase,
579 cfg *config.Config,
580 lockDir string,
581 log *zerolog.Logger,
582) {
583 // Silence unused parameter warning - startupCtx is intentionally unused.
584 _ = startupCtx
585
586 go func() {
587 // Use a detached background context instead of the startup context:
588 // session cleanup is critical and must run to completion even if the
589 // startup context is canceled or times out.
590 bgCtx := context.Background()
591
592 // End stale active sessions (orphaned from crashed processes)
593 if lockDir != "" {
594 if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
595 if log != nil {
596 log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
597 }
598 }
599 }
600
601 // Clean up old exited sessions based on config limits
602 cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
603 MaxExitedSessions: cfg.Session.MaxExitedSessions,
604 MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
605 })
606 if err != nil {
607 if log != nil {
608 log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
609 }
610 } else if cleanupOutput.TotalDeleted > 0 {
611 if log != nil {
612 log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
613 }
614 }
615
616 // Sweep paired startup+shutdown markers that are no longer needed.
617 if lockDir != "" {
618 sweepPairedMarkers(lockDir, cfg.Session.MaxExitedSessionAgeDays, log)
619 }
620 }()
621}
622
623// sweepPairedMarkers removes marker files for sessions that have both a
624// shutdown marker and a startup marker older than maxAgeDays. Both markers
625// must exist and be aged past the cutoff before any files are removed.
626// The associated abrupt marker (if present) is also cleaned up.
627// Removal errors are non-fatal (logged and skipped).
628func sweepPairedMarkers(lockDir string, maxAgeDays int, log *zerolog.Logger) {
629 if lockDir == "" {
630 return
631 }
632 if maxAgeDays <= 0 {
633 maxAgeDays = 7
634 }
635 cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
636
637 shutdownMarkers, err := filepath.Glob(filepath.Join(lockDir, "session_*.shutdown.marker"))
638 if err != nil {
639 if log != nil {
640 log.Warn().Err(err).Msg("sweepPairedMarkers: glob failed")
641 }
642 return
643 }
644
645 swept := 0
646 for _, shutdownPath := range shutdownMarkers {
647 base := filepath.Base(shutdownPath)
648 if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".shutdown.marker") {
649 continue
650 }
651 sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".shutdown.marker")
652 if sessionID == "" {
653 continue
654 }
655
656 shutdownInfo, err := os.Stat(shutdownPath)
657 if err != nil || shutdownInfo.ModTime().After(cutoff) {
658 continue
659 }
660
661 startupPath := startupMarkerPath(lockDir, sessionID)
662 startupInfo, err := os.Stat(startupPath)
663 if err != nil || startupInfo.ModTime().After(cutoff) {
664 // Startup marker missing or too recent — skip this session.
665 continue
666 }
667
668 abruptPath := abruptMarkerPath(lockDir, sessionID)
669
670 // Remove the trio: shutdown, startup, abrupt (if present).
671 for _, p := range []string{shutdownPath, startupPath, abruptPath} {
672 if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) {
673 if log != nil {
674 log.Warn().Err(rmErr).Str("path", p).Msg("sweepPairedMarkers: remove failed")
675 }
676 }
677 }
678 swept++
679 }
680
681 if swept > 0 && log != nil {
682 log.Info().Int("swept", swept).Msg("sweepPairedMarkers: cleaned up old marker files")
683 }
684}