1package bootstrap
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
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/infrastructure/config"
17 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
18 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
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 End func(context.Context) error
35}
36
37func StartBrowserSession(ctx context.Context, cfg *config.Config, db *sql.DB) (*BrowserSession, context.Context, error) {
38 log := corelogging.FromContext(ctx)
39
40 lockDir := cfg.Logging.LogDir
41 if lockDir == "" {
42 if dir, err := config.GetLogDir(); err == nil {
43 lockDir = dir
44 }
45 }
46
47 sessionRepo := sqlite.NewSessionRepository(db)
48 sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
49 sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
50
51 // If a previous browser process died without cleanup, it may have left sessions "active".
52 // Use per-session lock files to distinguish truly running sessions from stale ones.
53 if lockDir != "" {
54 if err := endStaleActiveBrowserSessions(ctx, sessionUC, lockDir, log); err != nil {
55 log.Warn().Err(err).Msg("failed to mark stale sessions ended")
56 }
57 }
58
59 // Clean up old exited sessions based on config limits
60 cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
61 cleanupOutput, err := cleanupUC.Execute(ctx, usecase.CleanupSessionsInput{
62 MaxExitedSessions: cfg.Session.MaxExitedSessions,
63 MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
64 })
65 if err != nil {
66 log.Warn().Err(err).Msg("failed to cleanup old sessions")
67 } else if cleanupOutput.TotalDeleted > 0 {
68 log.Info().
69 Int64("deleted", cleanupOutput.TotalDeleted).
70 Msg("cleaned up old sessions on startup")
71 }
72
73 now := time.Now()
74 out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
75 Type: entity.SessionTypeBrowser,
76 Now: now,
77 LogConfig: port.SessionLogConfig{
78 Level: cfg.Logging.Level,
79 Format: cfg.Logging.Format,
80 TimeFormat: "15:04:05",
81 LogDir: cfg.Logging.LogDir,
82 WriteToStderr: true,
83 EnableFileLog: cfg.Logging.EnableFileLog,
84 },
85 })
86 if err != nil {
87 return nil, ctx, err
88 }
89
90 lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)
91 if lockErr != nil {
92 // Don’t fail startup if locking fails; it only affects stale-session cleanup.
93 out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
94 }
95
96 sessionCtx := corelogging.WithContext(context.Background(), out.Logger)
97
98 endFn := func(endCtx context.Context) error {
99 endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())
100 if lockFile != nil {
101 _ = unlockAndClose(lockFile)
102 _ = os.Remove(lockPath)
103 }
104 return endErr
105 }
106
107 return &BrowserSession{
108 Session: out.Session,
109 Logger: out.Logger,
110 LogCleanup: out.LogCleanup,
111 End: endFn,
112 }, sessionCtx, nil
113}
114
115func endStaleActiveBrowserSessions(
116 ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,
117) error {
118 // We only have GetRecentSessions; fetch enough to cover likely actives.
119 recent, err := sessionUC.GetRecentSessions(ctx, recentSessionsLimit)
120 if err != nil {
121 return err
122 }
123
124 now := time.Now()
125 for _, s := range recent {
126 if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
127 continue
128 }
129
130 lockPath := sessionLockPath(lockDir, s.ID)
131 if _, statErr := os.Stat(lockPath); statErr != nil {
132 // No lock file: conservative behavior, don’t auto-end.
133 continue
134 }
135
136 f, err := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
137 if err != nil {
138 continue
139 }
140
141 locked, lockErr := tryLockExclusiveNonBlocking(f)
142 if lockErr != nil {
143 _ = f.Close()
144 continue
145 }
146 if !locked {
147 // Another process is holding the lock => session still alive.
148 _ = f.Close()
149 continue
150 }
151
152 // We acquired the lock => no running process holds it => stale session.
153 if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
154 if log != nil {
155 log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
156 }
157 }
158 _ = unlockAndClose(f)
159 _ = os.Remove(lockPath)
160 }
161
162 return nil
163}
164
165func sessionLockPath(lockDir string, sessionID entity.SessionID) string {
166 return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
167}
168
169func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {
170 if lockDir == "" {
171 return nil, "", errors.New("lock dir is empty")
172 }
173 if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
174 return nil, "", err
175 }
176
177 lockPath := sessionLockPath(lockDir, sessionID)
178 f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, lockFilePerm)
179 if err != nil {
180 return nil, "", err
181 }
182
183 locked, lockErr := tryLockExclusiveNonBlocking(f)
184 if lockErr != nil {
185 _ = f.Close()
186 return nil, "", lockErr
187 }
188 if !locked {
189 _ = f.Close()
190 return nil, "", errors.New("session lock already held")
191 }
192 return f, lockPath, nil
193}
194
195func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
196 if f == nil {
197 return false, errors.New("nil file")
198 }
199 err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
200 if err == nil {
201 return true, nil
202 }
203 if errors.Is(err, syscall.EWOULDBLOCK) {
204 return false, nil
205 }
206 return false, err
207}
208
209func unlockAndClose(f *os.File) error {
210 if f == nil {
211 return nil
212 }
213 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
214 return f.Close()
215}