v0.21.0 73bf644

session.go RawHistoryFolder

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}

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