v0.29.0 21b3150

session.go RawHistoryFolder

1package bootstrap
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strconv"
10	"strings"
11	"sync"
12	"syscall"
13	"time"
14
15	"github.com/bnema/dumber/internal/application/port"
16	"github.com/bnema/dumber/internal/application/usecase"
17	"github.com/bnema/dumber/internal/domain/entity"
18	"github.com/bnema/dumber/internal/domain/repository"
19	"github.com/bnema/dumber/internal/infrastructure/config"
20	infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
21	corelogging "github.com/bnema/dumber/internal/logging"
22	"github.com/rs/zerolog"
23)
24
25const (
26	recentSessionsLimit = 200
27	dirPerm             = 0o755
28	filePerm            = 0o644
29	pidFileName         = "dumber.pid"
30)
31
32// BrowserSession holds the state for a running browser session.
33type BrowserSession struct {
34	Session    *entity.Session
35	Logger     zerolog.Logger
36	LogCleanup func()
37	LogPath    string
38
39	reportsMu        sync.RWMutex
40	crashReportPaths []string
41
42	Persist func(context.Context) error
43	End     func(context.Context) error
44}
45
46// SetCrashReports stores crash report paths detected at startup.
47func (s *BrowserSession) SetCrashReports(paths []string) {
48	if s == nil {
49		return
50	}
51	s.reportsMu.Lock()
52	defer s.reportsMu.Unlock()
53	s.crashReportPaths = append([]string(nil), paths...)
54}
55
56// CrashReports returns crash report paths detected at startup.
57func (s *BrowserSession) CrashReports() []string {
58	if s == nil {
59		return nil
60	}
61	s.reportsMu.RLock()
62	defer s.reportsMu.RUnlock()
63	return append([]string(nil), s.crashReportPaths...)
64}
65
66// StartBrowserSession creates and persists a browser session.
67// It checks for previous crashes via PID file, creates the session log,
68// and sets up cleanup.
69func StartBrowserSession(
70	ctx context.Context,
71	cfg *config.Config,
72	sessionRepo repository.SessionRepository,
73	deferPersist bool,
74) (*BrowserSession, context.Context, error) {
75	log := corelogging.FromContext(ctx)
76	if sessionRepo == nil {
77		return nil, ctx, errors.New("session repository is nil")
78	}
79
80	logDir := cfg.Logging.LogDir
81	if logDir == "" {
82		if dir, err := config.GetLogDir(); err == nil {
83			logDir = dir
84		}
85	}
86
87	sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
88	sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
89	cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
90
91	now := time.Now()
92	session := &entity.Session{
93		ID:        entity.SessionID(corelogging.GenerateSessionID()),
94		Type:      entity.SessionTypeBrowser,
95		StartedAt: now.UTC(),
96	}
97	if err := session.Validate(); err != nil {
98		return nil, ctx, err
99	}
100
101	logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
102		Level:         cfg.Logging.Level,
103		Format:        cfg.Logging.Format,
104		TimeFormat:    corelogging.ConsoleTimeFormat,
105		LogDir:        logDir,
106		WriteToStderr: true,
107		EnableFileLog: cfg.Logging.EnableFileLog,
108	})
109	if err != nil {
110		log.Warn().Err(err).Msg("failed to create session logger")
111	}
112
113	// Compute and log the session log file path early so the user can
114	// copy-paste it from the console output.
115	var logPath string
116	if logDir != "" {
117		logPath = filepath.Join(logDir, corelogging.SessionFilename(string(session.ID)))
118		logger.Info().Str("log_file", logPath).Msg("session log file")
119	}
120
121	sessionCtx := corelogging.WithSessionMetadata(
122		corelogging.WithContext(context.Background(), logger),
123		string(session.ID),
124		logPath,
125	)
126
127	bs := &BrowserSession{
128		Session:    session,
129		Logger:     logger,
130		LogCleanup: cleanup,
131		LogPath:    logPath,
132	}
133
134	var persistOnce sync.Once
135	var persistErr error
136
137	persistFn := func(persistCtx context.Context) error {
138		persistOnce.Do(func() {
139			// Check for crash from previous run.
140			if logDir != "" {
141				reports := detectPreviousCrash(logDir, &logger)
142				if len(reports) > 0 {
143					bs.SetCrashReports(reports)
144				}
145			}
146
147			saveCtx := corelogging.WithContext(context.Background(), logger)
148			if err := sessionRepo.Save(saveCtx, session); err != nil {
149				persistErr = fmt.Errorf("save session: %w", err)
150				return
151			}
152
153			logger.Info().
154				Str("session_id", string(session.ID)).
155				Msg("session started")
156
157			// Write PID file so next startup can detect crashes.
158			if logDir != "" {
159				writePIDFile(logDir, &logger)
160			}
161
162			// Background cleanup of old sessions.
163			go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, logDir, session.ID, log)
164		})
165		return persistErr
166	}
167
168	endFn := func(endCtx context.Context) error {
169		_ = persistFn(endCtx)
170		endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
171
172		// Remove PID file on clean shutdown.
173		if logDir != "" {
174			removePIDFile(logDir)
175		}
176		return endErr
177	}
178
179	bs.End = endFn
180
181	if deferPersist {
182		bs.Persist = persistFn
183	} else {
184		if err := persistFn(ctx); err != nil {
185			if cleanup != nil {
186				cleanup()
187			}
188			return nil, ctx, err
189		}
190	}
191
192	return bs, sessionCtx, nil
193}
194
195// ---------------------------------------------------------------------------
196// PID file: single file {logDir}/dumber.pid
197// ---------------------------------------------------------------------------
198
199func pidFilePath(logDir string) string {
200	return filepath.Join(logDir, pidFileName)
201}
202
203func writePIDFile(logDir string, logger *zerolog.Logger) {
204	p := pidFilePath(logDir)
205	content := fmt.Sprintf("%d\n", os.Getpid())
206	if err := os.WriteFile(p, []byte(content), filePerm); err != nil && logger != nil {
207		logger.Warn().Err(err).Msg("failed to write PID file")
208	}
209}
210
211func removePIDFile(logDir string) {
212	_ = os.Remove(pidFilePath(logDir))
213}
214
215// detectPreviousCrash checks the PID file from a previous run.
216// If the file exists and the process is dead, the previous session crashed.
217// Returns generated crash report paths (if any).
218func detectPreviousCrash(logDir string, logger *zerolog.Logger) []string {
219	p := pidFilePath(logDir)
220	raw, err := os.ReadFile(p)
221	if err != nil {
222		return nil // No PID file → clean previous exit (or first run).
223	}
224
225	pid, parseErr := strconv.Atoi(strings.TrimSpace(string(raw)))
226	if parseErr != nil {
227		// Corrupt PID file, remove and move on.
228		_ = os.Remove(p)
229		return nil
230	}
231
232	// Check if the process is still running.
233	proc, procErr := os.FindProcess(pid)
234	if procErr == nil {
235		// On Unix, FindProcess always succeeds. Send signal 0 to probe existence.
236		if killErr := proc.Signal(syscall.Signal(0)); killErr == nil {
237			// Process is alive → another instance running, don't touch PID file.
238			if logger != nil {
239				logger.Warn().Int("pid", pid).Msg("previous instance still running (PID file exists)")
240			}
241			return nil
242		}
243	}
244
245	// Previous process is dead → crash. Generate report from last log.
246	_ = os.Remove(p)
247
248	lastLog := findLastSessionLog(logDir)
249	if lastLog == "" {
250		return nil
251	}
252
253	lastSessionID := sessionIDFromLogPath(lastLog)
254	if lastSessionID == "" {
255		return nil
256	}
257
258	if logger != nil {
259		logger.Warn().
260			Int("crashed_pid", pid).
261			Str("session_id", lastSessionID).
262			Msg("previous session crashed (stale PID file)")
263	}
264
265	reportPath, reportErr := writeCrashReport(logDir, lastSessionID, pid)
266	if reportErr != nil {
267		if logger != nil {
268			logger.Warn().Err(reportErr).Msg("failed to write crash report")
269		}
270		return nil
271	}
272	if reportPath == "" {
273		return nil
274	}
275	return []string{reportPath}
276}
277
278// findLastSessionLog returns the most recent session_*.log file in logDir.
279func findLastSessionLog(logDir string) string {
280	matches, err := filepath.Glob(filepath.Join(logDir, "session_*.log"))
281	if err != nil || len(matches) == 0 {
282		return ""
283	}
284	var newest string
285	var newestMod time.Time
286	for _, m := range matches {
287		info, statErr := os.Stat(m)
288		if statErr != nil {
289			continue
290		}
291		if info.ModTime().After(newestMod) {
292			newest = m
293			newestMod = info.ModTime()
294		}
295	}
296	return newest
297}
298
299func sessionIDFromLogPath(logPath string) string {
300	base := filepath.Base(logPath)
301	id, ok := corelogging.ParseSessionFilename(base)
302	if !ok {
303		return ""
304	}
305	return id
306}
307
308// ---------------------------------------------------------------------------
309// Background cleanup
310// ---------------------------------------------------------------------------
311
312func runSessionCleanup(
313	startupCtx context.Context,
314	sessionUC *usecase.ManageSessionUseCase,
315	cleanupUC *usecase.CleanupSessionsUseCase,
316	cfg *config.Config,
317	logDir string,
318	currentSessionID entity.SessionID,
319	log *zerolog.Logger,
320) {
321	bgCtx := context.WithoutCancel(startupCtx)
322
323	// End stale active sessions that have no running process.
324	recent, err := sessionUC.GetRecentSessions(bgCtx, recentSessionsLimit)
325	if err != nil {
326		if log != nil {
327			log.Warn().Err(err).Msg("background: failed to list recent sessions")
328		}
329	} else {
330		now := time.Now()
331		for _, s := range recent {
332			if s != nil &&
333				s.ID != currentSessionID &&
334				s.Type == entity.SessionTypeBrowser &&
335				s.IsActive() {
336				_ = sessionUC.EndSession(bgCtx, s.ID, now)
337			}
338		}
339	}
340
341	// Clean up old exited sessions.
342	output, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
343		MaxExitedSessions:       cfg.Session.MaxExitedSessions,
344		MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
345	})
346	if err != nil {
347		if log != nil {
348			log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
349		}
350	} else if output.TotalDeleted > 0 && log != nil {
351		log.Info().Int64("deleted", output.TotalDeleted).Msg("background: cleaned up old sessions")
352	}
353
354	// Prune old marker files left by previous versions.
355	sweepLegacyMarkers(logDir, log)
356
357	removedLogs, logCleanupErr := corelogging.CleanupSessionLogFiles(
358		logDir,
359		cfg.Logging.MaxFiles,
360		string(currentSessionID),
361	)
362	if logCleanupErr != nil {
363		if log != nil {
364			log.Warn().Err(logCleanupErr).Msg("background: failed to cleanup session log files")
365		}
366	} else if removedLogs > 0 && log != nil {
367		log.Info().
368			Int("deleted", removedLogs).
369			Int("max_files", cfg.Logging.MaxFiles).
370			Msg("background: cleaned up session log files")
371	}
372}
373
374// sweepLegacyMarkers removes .startup.marker, .shutdown.marker,
375// .abrupt.marker, and .lock files left by the old session system.
376func sweepLegacyMarkers(logDir string, log *zerolog.Logger) {
377	if logDir == "" {
378		return
379	}
380	patterns := []string{
381		"session_*.startup.marker",
382		"session_*.shutdown.marker",
383		"session_*.abrupt.marker",
384		"session_*.lock",
385	}
386	swept := 0
387	for _, pat := range patterns {
388		matches, _ := filepath.Glob(filepath.Join(logDir, pat))
389		for _, m := range matches {
390			if os.Remove(m) == nil {
391				swept++
392			}
393		}
394	}
395	if swept > 0 && log != nil {
396		log.Info().Int("swept", swept).Msg("cleaned up legacy session marker files")
397	}
398}

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