v0.20.0 c741a39

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	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}

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