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 now := time.Now()
60 out, err := sessionUC.StartSession(ctx, usecase.StartSessionInput{
61 Type: entity.SessionTypeBrowser,
62 Now: now,
63 LogConfig: port.SessionLogConfig{
64 Level: cfg.Logging.Level,
65 Format: cfg.Logging.Format,
66 TimeFormat: "15:04:05",
67 LogDir: cfg.Logging.LogDir,
68 WriteToStderr: true,
69 EnableFileLog: cfg.Logging.EnableFileLog,
70 },
71 })
72 if err != nil {
73 return nil, ctx, err
74 }
75
76 lockFile, lockPath, lockErr := lockSessionFile(lockDir, out.Session.ID)
77 if lockErr != nil {
78 // Don’t fail startup if locking fails; it only affects stale-session cleanup.
79 out.Logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
80 }
81
82 sessionCtx := corelogging.WithContext(context.Background(), out.Logger)
83
84 endFn := func(endCtx context.Context) error {
85 endErr := sessionUC.EndSession(endCtx, out.Session.ID, time.Now())
86 if lockFile != nil {
87 _ = unlockAndClose(lockFile)
88 _ = os.Remove(lockPath)
89 }
90 return endErr
91 }
92
93 return &BrowserSession{
94 Session: out.Session,
95 Logger: out.Logger,
96 LogCleanup: out.LogCleanup,
97 End: endFn,
98 }, sessionCtx, nil
99}
100
101func endStaleActiveBrowserSessions(
102 ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,
103) error {
104 // We only have GetRecentSessions; fetch enough to cover likely actives.
105 recent, err := sessionUC.GetRecentSessions(ctx, recentSessionsLimit)
106 if err != nil {
107 return err
108 }
109
110 now := time.Now()
111 for _, s := range recent {
112 if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
113 continue
114 }
115
116 lockPath := sessionLockPath(lockDir, s.ID)
117 if _, statErr := os.Stat(lockPath); statErr != nil {
118 // No lock file: conservative behavior, don’t auto-end.
119 continue
120 }
121
122 f, err := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
123 if err != nil {
124 continue
125 }
126
127 locked, lockErr := tryLockExclusiveNonBlocking(f)
128 if lockErr != nil {
129 _ = f.Close()
130 continue
131 }
132 if !locked {
133 // Another process is holding the lock => session still alive.
134 _ = f.Close()
135 continue
136 }
137
138 // We acquired the lock => no running process holds it => stale session.
139 if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
140 if log != nil {
141 log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
142 }
143 }
144 _ = unlockAndClose(f)
145 _ = os.Remove(lockPath)
146 }
147
148 return nil
149}
150
151func sessionLockPath(lockDir string, sessionID entity.SessionID) string {
152 return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
153}
154
155func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {
156 if lockDir == "" {
157 return nil, "", errors.New("lock dir is empty")
158 }
159 if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
160 return nil, "", err
161 }
162
163 lockPath := sessionLockPath(lockDir, sessionID)
164 f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, lockFilePerm)
165 if err != nil {
166 return nil, "", err
167 }
168
169 locked, lockErr := tryLockExclusiveNonBlocking(f)
170 if lockErr != nil {
171 _ = f.Close()
172 return nil, "", lockErr
173 }
174 if !locked {
175 _ = f.Close()
176 return nil, "", errors.New("session lock already held")
177 }
178 return f, lockPath, nil
179}
180
181func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
182 if f == nil {
183 return false, errors.New("nil file")
184 }
185 err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
186 if err == nil {
187 return true, nil
188 }
189 if errors.Is(err, syscall.EWOULDBLOCK) {
190 return false, nil
191 }
192 return false, err
193}
194
195func unlockAndClose(f *os.File) error {
196 if f == nil {
197 return nil
198 }
199 _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
200 return f.Close()
201}