v0.26.2 3cd9d2e

session.go RawHistoryFolder

1package bootstrap
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"sync"
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/domain/repository"
17	"github.com/bnema/dumber/internal/infrastructure/config"
18	infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
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	Persist func(context.Context) error
35	End     func(context.Context) error
36}
37
38func StartBrowserSession(
39	ctx context.Context,
40	cfg *config.Config,
41	sessionRepo repository.SessionRepository,
42	deferPersist bool,
43) (*BrowserSession, context.Context, error) {
44	log := corelogging.FromContext(ctx)
45	if sessionRepo == nil {
46		return nil, ctx, errors.New("session repository is nil")
47	}
48
49	lockDir := cfg.Logging.LogDir
50	if lockDir == "" {
51		if dir, err := config.GetLogDir(); err == nil {
52			lockDir = dir
53		}
54	}
55
56	sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
57	sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
58	cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
59
60	now := time.Now()
61	session := &entity.Session{
62		ID:        entity.SessionID(corelogging.GenerateSessionID()),
63		Type:      entity.SessionTypeBrowser,
64		StartedAt: now.UTC(),
65	}
66	if err := session.Validate(); err != nil {
67		return nil, ctx, err
68	}
69
70	logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
71		Level:         cfg.Logging.Level,
72		Format:        cfg.Logging.Format,
73		TimeFormat:    corelogging.ConsoleTimeFormat,
74		LogDir:        cfg.Logging.LogDir,
75		WriteToStderr: true,
76		EnableFileLog: cfg.Logging.EnableFileLog,
77	})
78	if err != nil {
79		log.Warn().Err(err).Msg("failed to create session logger")
80	}
81
82	sessionCtx := corelogging.WithContext(context.Background(), logger)
83
84	var (
85		lockMu     sync.Mutex
86		lockFile   *os.File
87		lockPath   string
88		persistMu  sync.Once
89		persistErr error
90	)
91
92	// persistFn ensures the session is saved exactly once. Using sync.Once is intentional:
93	// if the first persist fails, we cache the error and return it on subsequent calls
94	// rather than retrying. This prevents duplicate session records and ensures consistent
95	// error handling during shutdown sequences where persistFn may be called multiple times.
96	persistFn := func(persistCtx context.Context) error {
97		persistMu.Do(func() {
98			saveCtx := corelogging.WithContext(context.Background(), logger)
99			if err := sessionRepo.Save(saveCtx, session); err != nil {
100				persistErr = fmt.Errorf("save session: %w", err)
101				return
102			}
103
104			logger.Info().
105				Str("session_id", string(session.ID)).
106				Str("type", string(session.Type)).
107				Msg("session started")
108
109			lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
110			if lockErr != nil {
111				// Don’t fail startup if locking fails; it only affects stale-session cleanup.
112				logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
113			} else {
114				lockMu.Lock()
115				lockFile = lf
116				lockPath = lp
117				lockMu.Unlock()
118			}
119
120			// Defer stale session cleanup and old session pruning to background.
121			// This avoids blocking startup for non-critical housekeeping tasks.
122			runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
123		})
124		return persistErr
125	}
126
127	endFn := func(endCtx context.Context) error {
128		_ = persistFn(endCtx)
129		endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
130
131		lockMu.Lock()
132		lf := lockFile
133		lp := lockPath
134		lockMu.Unlock()
135
136		if lf != nil {
137			_ = unlockAndClose(lf)
138			_ = os.Remove(lp)
139		}
140		return endErr
141	}
142
143	browserSession := &BrowserSession{
144		Session:    session,
145		Logger:     logger,
146		LogCleanup: cleanup,
147		End:        endFn,
148	}
149
150	if deferPersist {
151		browserSession.Persist = persistFn
152	} else {
153		if err := persistFn(ctx); err != nil {
154			if cleanup != nil {
155				cleanup()
156			}
157			return nil, ctx, err
158		}
159	}
160
161	return browserSession, sessionCtx, nil
162}
163
164func endStaleActiveBrowserSessions(
165	ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,
166) error {
167	// We only have GetRecentSessions; fetch enough to cover likely actives.
168	recent, err := sessionUC.GetRecentSessions(ctx, recentSessionsLimit)
169	if err != nil {
170		return err
171	}
172
173	now := time.Now()
174	for _, s := range recent {
175		if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
176			continue
177		}
178
179		lockPath := sessionLockPath(lockDir, s.ID)
180		if _, statErr := os.Stat(lockPath); statErr != nil {
181			// No lock file: conservative behavior, don’t auto-end.
182			continue
183		}
184
185		f, err := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
186		if err != nil {
187			continue
188		}
189
190		locked, lockErr := tryLockExclusiveNonBlocking(f)
191		if lockErr != nil {
192			_ = f.Close()
193			continue
194		}
195		if !locked {
196			// Another process is holding the lock => session still alive.
197			_ = f.Close()
198			continue
199		}
200
201		// We acquired the lock => no running process holds it => stale session.
202		if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
203			if log != nil {
204				log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
205			}
206		}
207		_ = unlockAndClose(f)
208		_ = os.Remove(lockPath)
209	}
210
211	return nil
212}
213
214func sessionLockPath(lockDir string, sessionID entity.SessionID) string {
215	return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
216}
217
218func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {
219	if lockDir == "" {
220		return nil, "", errors.New("lock dir is empty")
221	}
222	if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
223		return nil, "", err
224	}
225
226	lockPath := sessionLockPath(lockDir, sessionID)
227	f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, lockFilePerm)
228	if err != nil {
229		return nil, "", err
230	}
231
232	locked, lockErr := tryLockExclusiveNonBlocking(f)
233	if lockErr != nil {
234		_ = f.Close()
235		return nil, "", lockErr
236	}
237	if !locked {
238		_ = f.Close()
239		return nil, "", errors.New("session lock already held")
240	}
241	return f, lockPath, nil
242}
243
244func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
245	if f == nil {
246		return false, errors.New("nil file")
247	}
248	err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
249	if err == nil {
250		return true, nil
251	}
252	if errors.Is(err, syscall.EWOULDBLOCK) {
253		return false, nil
254	}
255	return false, err
256}
257
258func unlockAndClose(f *os.File) error {
259	if f == nil {
260		return nil
261	}
262	_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
263	return f.Close()
264}
265
266// runSessionCleanupAsync performs stale session cleanup and old session pruning
267// in a background goroutine. This avoids blocking startup for non-critical tasks.
268// Uses context.Background() to ensure cleanup completes even if startup context is canceled.
269func runSessionCleanupAsync(
270	startupCtx context.Context,
271	sessionUC *usecase.ManageSessionUseCase,
272	cleanupUC *usecase.CleanupSessionsUseCase,
273	cfg *config.Config,
274	lockDir string,
275	log *zerolog.Logger,
276) {
277	// Silence unused parameter warning - startupCtx is intentionally unused.
278	_ = startupCtx
279
280	go func() {
281		// Use a detached background context instead of the startup context:
282		// session cleanup is critical and must run to completion even if the
283		// startup context is canceled or times out.
284		bgCtx := context.Background()
285
286		// End stale active sessions (orphaned from crashed processes)
287		if lockDir != "" {
288			if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
289				if log != nil {
290					log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
291				}
292			}
293		}
294
295		// Clean up old exited sessions based on config limits
296		cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
297			MaxExitedSessions:       cfg.Session.MaxExitedSessions,
298			MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
299		})
300		if err != nil {
301			if log != nil {
302				log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
303			}
304		} else if cleanupOutput.TotalDeleted > 0 {
305			if log != nil {
306				log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
307			}
308		}
309	}()
310}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close