main 5041353

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

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