1package bootstrap
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "sync"
12 "syscall"
13 "time"
14
15 "github.com/bnema/dumber/internal/application/port"
16 "github.com/bnema/dumber/internal/application/usecase"
17 "github.com/bnema/dumber/internal/domain/entity"
18 "github.com/bnema/dumber/internal/domain/repository"
19 "github.com/bnema/dumber/internal/infrastructure/config"
20 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
21 "github.com/bnema/dumber/internal/infrastructure/process"
22 corelogging "github.com/bnema/dumber/internal/logging"
23 "github.com/rs/zerolog"
24)
25
26const (
27 recentSessionsLimit = 200
28 dirPerm = 0o755
29 filePerm = 0o644
30 pidFileName = "dumber.pid"
31)
32
33// BrowserSession holds the state for a running browser session.
34type BrowserSession struct {
35 Session *entity.Session
36 Logger zerolog.Logger
37 LogCleanup func()
38 LogPath string
39
40 reportsMu sync.RWMutex
41 crashReportPaths []string
42
43 Persist func(context.Context) error
44 End func(context.Context) error
45}
46
47// SetCrashReports stores crash report paths detected at startup.
48func (s *BrowserSession) SetCrashReports(paths []string) {
49 if s == nil {
50 return
51 }
52 s.reportsMu.Lock()
53 defer s.reportsMu.Unlock()
54 s.crashReportPaths = append([]string(nil), paths...)
55}
56
57// CrashReports returns crash report paths detected at startup.
58func (s *BrowserSession) CrashReports() []string {
59 if s == nil {
60 return nil
61 }
62 s.reportsMu.RLock()
63 defer s.reportsMu.RUnlock()
64 return append([]string(nil), s.crashReportPaths...)
65}
66
67// StartBrowserSession creates and persists a browser session.
68// It checks for previous crashes via PID file, creates the session log,
69// and sets up cleanup.
70func StartBrowserSession(
71 ctx context.Context,
72 cfg *config.Config,
73 sessionRepo repository.SessionRepository,
74 deferPersist bool,
75) (*BrowserSession, context.Context, error) {
76 log := corelogging.FromContext(ctx)
77 if sessionRepo == nil {
78 return nil, ctx, errors.New("session repository is nil")
79 }
80
81 logDir := cfg.Logging.LogDir
82 if logDir == "" {
83 if dir, err := config.GetLogDir(); err == nil {
84 logDir = dir
85 }
86 }
87
88 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
89 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
90 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo, process.NewLivenessProbe())
91
92 now := time.Now()
93 pid := os.Getpid()
94 session := &entity.Session{
95 ID: entity.SessionID(corelogging.GenerateSessionID()),
96 Type: entity.SessionTypeBrowser,
97 StartedAt: now.UTC(),
98 ProcessID: &pid,
99 }
100 if err := session.Validate(); err != nil {
101 return nil, ctx, err
102 }
103
104 logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
105 Level: cfg.Logging.Level,
106 Format: cfg.Logging.Format,
107 TimeFormat: corelogging.ConsoleTimeFormat,
108 LogDir: logDir,
109 WriteToStderr: true,
110 EnableFileLog: cfg.Logging.EnableFileLog,
111 })
112 if err != nil {
113 log.Warn().Err(err).Msg("failed to create session logger")
114 }
115
116 // Compute and log the session log file path early so the user can
117 // copy-paste it from the console output.
118 var logPath string
119 if logDir != "" {
120 logPath = filepath.Join(logDir, corelogging.SessionFilename(string(session.ID)))
121 logger.Info().Str("log_file", logPath).Msg("session log file")
122 }
123
124 sessionCtx := corelogging.WithSessionMetadata(
125 corelogging.WithContext(context.Background(), logger),
126 string(session.ID),
127 logPath,
128 )
129
130 bs := &BrowserSession{
131 Session: session,
132 Logger: logger,
133 LogCleanup: cleanup,
134 LogPath: logPath,
135 }
136
137 var persistOnce sync.Once
138 var persistErr error
139
140 persistFn := func(persistCtx context.Context) error {
141 persistOnce.Do(func() {
142 // Check for crash from previous run.
143 if logDir != "" {
144 reports := detectPreviousCrash(logDir, &logger)
145 if len(reports) > 0 {
146 bs.SetCrashReports(reports)
147 }
148 }
149
150 saveCtx := corelogging.WithContext(context.Background(), logger)
151 if err := sessionRepo.Save(saveCtx, session); err != nil {
152 persistErr = fmt.Errorf("save session: %w", err)
153 return
154 }
155
156 logger.Info().
157 Str("session_id", string(session.ID)).
158 Msg("session started")
159
160 // Write PID file so next startup can detect crashes.
161 if logDir != "" {
162 writePIDFile(logDir, &logger)
163 }
164
165 // Background cleanup of old sessions.
166 go runSessionCleanup(persistCtx, cleanupUC, cfg, logDir, session.ID, log)
167 })
168 return persistErr
169 }
170
171 endFn := func(endCtx context.Context) error {
172 _ = persistFn(endCtx)
173 endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
174
175 // Remove PID file on clean shutdown.
176 if logDir != "" {
177 removePIDFile(logDir)
178 }
179 return endErr
180 }
181
182 bs.End = endFn
183
184 if deferPersist {
185 bs.Persist = persistFn
186 } else {
187 if err := persistFn(ctx); err != nil {
188 if cleanup != nil {
189 cleanup()
190 }
191 return nil, ctx, err
192 }
193 }
194
195 return bs, sessionCtx, nil
196}
197
198// ---------------------------------------------------------------------------
199// PID file: single file {logDir}/dumber.pid
200// ---------------------------------------------------------------------------
201
202func pidFilePath(logDir string) string {
203 return filepath.Join(logDir, pidFileName)
204}
205
206func writePIDFile(logDir string, logger *zerolog.Logger) {
207 p := pidFilePath(logDir)
208 content := fmt.Sprintf("%d\n", os.Getpid())
209 if err := os.WriteFile(p, []byte(content), filePerm); err != nil && logger != nil {
210 logger.Warn().Err(err).Msg("failed to write PID file")
211 }
212}
213
214func removePIDFile(logDir string) {
215 _ = os.Remove(pidFilePath(logDir))
216}
217
218// detectPreviousCrash checks the PID file from a previous run.
219// If the file exists and the process is dead, the previous session crashed.
220// Returns generated crash report paths (if any).
221func detectPreviousCrash(logDir string, logger *zerolog.Logger) []string {
222 p := pidFilePath(logDir)
223 raw, err := os.ReadFile(p)
224 if err != nil {
225 return nil // No PID file → clean previous exit (or first run).
226 }
227
228 pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw)))
229 if parseErr != nil {
230 // Corrupt PID file, remove and move on.
231 _ = os.Remove(p)
232 return nil
233 }
234
235 // Check if the process is still running.
236 proc, procErr := os.FindProcess(pid)
237 if procErr == nil {
238 // On Unix, FindProcess always succeeds. Send signal 0 to probe existence.
239 if killErr := proc.Signal(syscall.Signal(0)); killErr == nil {
240 // Process is alive → another instance running, don't touch PID file.
241 if logger != nil {
242 logger.Warn().Int("pid", pid).Msg("previous instance still running (PID file exists)")
243 }
244 return nil
245 }
246 }
247
248 // Previous process is dead → crash. Generate report from last log.
249 _ = os.Remove(p)
250
251 lastLog := findLastSessionLog(logDir)
252 if lastLog == "" {
253 return nil
254 }
255
256 lastSessionID := sessionIDFromLogPath(lastLog)
257 if lastSessionID == "" {
258 return nil
259 }
260
261 if logger != nil {
262 logger.Warn().
263 Int("crashed_pid", pid).
264 Str("session_id", lastSessionID).
265 Msg("previous session crashed (stale PID file)")
266 }
267
268 reportPath, reportErr := writeCrashReport(logDir, lastSessionID, pid)
269 if reportErr != nil {
270 if logger != nil {
271 logger.Warn().Err(reportErr).Msg("failed to write crash report")
272 }
273 return nil
274 }
275 if reportPath == "" {
276 return nil
277 }
278 return []string{reportPath}
279}
280
281// findLastSessionLog returns the most recent session_*.log file in logDir.
282func findLastSessionLog(logDir string) string {
283 matches, err := filepath.Glob(filepath.Join(logDir, "session_*.log"))
284 if err != nil || len(matches) == 0 {
285 return ""
286 }
287 var newest string
288 var newestMod time.Time
289 for _, m := range matches {
290 info, statErr := os.Stat(m)
291 if statErr != nil {
292 continue
293 }
294 if info.ModTime().After(newestMod) {
295 newest = m
296 newestMod = info.ModTime()
297 }
298 }
299 return newest
300}
301
302func sessionIDFromLogPath(logPath string) string {
303 base := filepath.Base(logPath)
304 id, ok := corelogging.ParseSessionFilename(base)
305 if !ok {
306 return ""
307 }
308 return id
309}
310
311// ---------------------------------------------------------------------------
312// Background cleanup
313// ---------------------------------------------------------------------------
314
315func runSessionCleanup(
316 startupCtx context.Context,
317 cleanupUC *usecase.CleanupSessionsUseCase,
318 cfg *config.Config,
319 logDir string,
320 currentSessionID entity.SessionID,
321 log *zerolog.Logger,
322) {
323 bgCtx := context.WithoutCancel(startupCtx)
324
325 // End only active browser sessions whose recorded process is proven dead.
326 staleOutput, err := cleanupUC.ReconcileActiveBrowserSessions(bgCtx, usecase.ReconcileActiveBrowserSessionsInput{
327 CurrentSessionID: currentSessionID,
328 RecentLimit: recentSessionsLimit,
329 EndedAt: time.Now(),
330 })
331 if err != nil {
332 if log != nil {
333 log.Warn().Err(err).Msg("background: failed to reconcile active sessions")
334 }
335 } else if staleOutput.EndedDeadSessions > 0 && log != nil {
336 log.Info().Int64("ended", staleOutput.EndedDeadSessions).Msg("background: ended dead browser sessions")
337 }
338
339 // Clean up old exited sessions.
340 output, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
341 MaxExitedSessions: cfg.Session.MaxExitedSessions,
342 MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
343 })
344 if err != nil {
345 if log != nil {
346 log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
347 }
348 } else if output.TotalDeleted > 0 && log != nil {
349 log.Info().Int64("deleted", output.TotalDeleted).Msg("background: cleaned up old sessions")
350 }
351
352 // Prune old marker files left by previous versions.
353 sweepLegacyMarkers(logDir, log)
354
355 removedLogs, logCleanupErr := corelogging.CleanupSessionLogFiles(
356 logDir,
357 cfg.Logging.MaxFiles,
358 string(currentSessionID),
359 )
360 if logCleanupErr != nil {
361 if log != nil {
362 log.Warn().Err(logCleanupErr).Msg("background: failed to cleanup session log files")
363 }
364 } else if removedLogs > 0 && log != nil {
365 log.Info().
366 Int("deleted", removedLogs).
367 Int("max_files", cfg.Logging.MaxFiles).
368 Msg("background: cleaned up session log files")
369 }
370}
371
372// sweepLegacyMarkers removes .startup.marker, .shutdown.marker,
373// .abrupt.marker, and .lock files left by the old session system.
374func sweepLegacyMarkers(logDir string, log *zerolog.Logger) {
375 if logDir == "" {
376 return
377 }
378 patterns := []string{
379 "session_*.startup.marker",
380 "session_*.shutdown.marker",
381 "session_*.abrupt.marker",
382 "session_*.lock",
383 }
384 swept := 0
385 for _, pat := range patterns {
386 matches, _ := filepath.Glob(filepath.Join(logDir, pat))
387 for _, m := range matches {
388 if os.Remove(m) == nil {
389 swept++
390 }
391 }
392 }
393 if swept > 0 && log != nil {
394 log.Info().Int("swept", swept).Msg("cleaned up legacy session marker files")
395 }
396}