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