• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

valksor / go-mehrhof / 21375098520

26 Jan 2026 09:44PM UTC coverage: 40.173% (+0.1%) from 40.04%
21375098520

push

github

k0d3r1s
Enhances signing process for releases

Streamlines the signing configuration for releases by utilizing password-less signing.

This simplifies the release process by removing the need to explicitly pass the password during signing.

21721 of 54069 relevant lines covered (40.17%)

20.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.42
/internal/browser/impl_session.go
1
package browser
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "errors"
7
        "fmt"
8
        "log/slog"
9
        "math/rand"
10
        "net"
11
        "net/http"
12
        "os"
13
        "os/exec"
14
        "path/filepath"
15
        "strconv"
16
        "sync"
17
        "syscall"
18
        "time"
19
)
20

21
const (
22
        // Session file stores the active browser session info.
23
        sessionFile = ".mehrhof/browser.json"
24
        // Default port range for random port allocation.
25
        minPort = 9200
26
        maxPort = 9300
27
        // Maximum session age before considering it stale (24 hours).
28
        maxSessionAge = 24 * time.Hour
29
)
30

31
// Session represents an isolated browser instance.
32
type Session struct {
33
        PID         int       `json:"pid"`
34
        Port        int       `json:"port"`
35
        Host        string    `json:"host"`
36
        UserDataDir string    `json:"user_data_dir"`
37
        StartedAt   time.Time `json:"started_at"`
38
}
39

40
// SessionManager handles lifecycle of isolated browser sessions.
41
type SessionManager struct {
42
        workspaceDir string
43
        session      *Session
44
        config       Config
45
        mu           sync.RWMutex
46
}
47

48
// NewSessionManager creates a new session manager.
49
func NewSessionManager(workspaceDir string, config Config) *SessionManager {
45✔
50
        return &SessionManager{
45✔
51
                workspaceDir: workspaceDir,
45✔
52
                config:       config,
45✔
53
        }
45✔
54
}
45✔
55

56
// ConnectOrCreate tries to connect to existing session, or launches a new isolated browser.
57
func (sm *SessionManager) ConnectOrCreate(ctx context.Context) (*Session, error) {
34✔
58
        sm.mu.Lock()
34✔
59
        defer sm.mu.Unlock()
34✔
60

34✔
61
        // First, try to load existing session
34✔
62
        session, err := sm.loadSession()
34✔
63
        if err == nil && session != nil {
35✔
64
                // Validate the session metadata
1✔
65
                if err := sm.validateSession(session); err != nil {
1✔
66
                        slog.Warn("invalid session, will create new", "error", err)
×
67
                        sm.cleanupStaleSession()
×
68

×
69
                        return sm.launchBrowserUnlocked(ctx)
×
70
                }
×
71

72
                // Fast path: check if anything is listening on the port
73
                // This is much faster than isProcessAlive (which can be fooled by zombies/PID reuse)
74
                // and faster than isEndpointResponsive (which waits for HTTP timeout)
75
                if !sm.isPortInUse(ctx, session.Host, session.Port) {
2✔
76
                        // Nothing listening - definitely stale
1✔
77
                        slog.Debug("port not in use, session is stale", "port", session.Port, "pid", session.PID)
1✔
78
                        // Try to kill the process if it's still running (cleanup zombie or orphan)
1✔
79
                        if sm.isProcessAlive(session.PID) {
2✔
80
                                slog.Warn("killing stale browser process", "pid", session.PID)
1✔
81
                                sm.killProcess(session.PID)
1✔
82
                        }
1✔
83
                        sm.cleanupStaleSession()
1✔
84

1✔
85
                        return sm.launchBrowserUnlocked(ctx)
1✔
86
                }
87

88
                // Port is in use - verify it's actually Chrome responding
89
                if sm.isEndpointResponsive(ctx, session.Host, session.Port) {
×
90
                        slog.Info("reusing existing browser session", "pid", session.PID, "port", session.Port)
×
91
                        sm.session = session
×
92

×
93
                        return session, nil
×
94
                }
×
95

96
                // Something else is using the port, or Chrome is hung
97
                // Try to kill Chrome if it's still running
98
                if sm.isProcessAlive(session.PID) {
×
99
                        slog.Warn("browser process unresponsive, cleaning up", "pid", session.PID, "port", session.Port)
×
100
                        sm.killProcess(session.PID)
×
101
                }
×
102

103
                sm.cleanupStaleSession()
×
104
        }
105

106
        // No existing session, create a new one
107
        return sm.launchBrowserUnlocked(ctx)
33✔
108
}
109

110
// validateSession checks if a session is valid and not stale.
111
func (sm *SessionManager) validateSession(session *Session) error {
7✔
112
        // Check required fields
7✔
113
        if session.PID == 0 {
8✔
114
                return errors.New("invalid PID")
1✔
115
        }
1✔
116
        if session.Port < minPort || session.Port > maxPort {
7✔
117
                return fmt.Errorf("invalid port: %d", session.Port)
1✔
118
        }
1✔
119
        if session.Host == "" {
6✔
120
                return errors.New("empty host")
1✔
121
        }
1✔
122

123
        // Check session age
124
        if time.Since(session.StartedAt) > maxSessionAge {
5✔
125
                return fmt.Errorf("session too old: %v", time.Since(session.StartedAt))
1✔
126
        }
1✔
127

128
        // Verify user data directory exists
129
        if session.UserDataDir != "" {
6✔
130
                if _, err := os.Stat(session.UserDataDir); os.IsNotExist(err) {
3✔
131
                        return fmt.Errorf("user data directory missing: %s", session.UserDataDir)
×
132
                }
×
133
        }
134

135
        return nil
3✔
136
}
137

138
// cleanupStaleSession removes stale session files and directories.
139
func (sm *SessionManager) cleanupStaleSession() {
2✔
140
        sessionPath := sm.sessionPath()
2✔
141

2✔
142
        // Load session to get user data dir
2✔
143
        if data, err := os.ReadFile(sessionPath); err == nil {
4✔
144
                var session Session
2✔
145
                if json.Unmarshal(data, &session) == nil {
4✔
146
                        // Remove user data directory
2✔
147
                        if session.UserDataDir != "" {
4✔
148
                                _ = os.RemoveAll(session.UserDataDir)
2✔
149
                        }
2✔
150
                }
151
        }
152

153
        // Remove session file
154
        _ = os.Remove(sessionPath)
2✔
155
}
156

157
// Cleanup terminates the isolated browser if we launched it.
158
func (sm *SessionManager) Cleanup() error {
32✔
159
        sm.mu.Lock()
32✔
160
        defer sm.mu.Unlock()
32✔
161

32✔
162
        if sm.session == nil {
32✔
163
                return nil
×
164
        }
×
165

166
        slog.Info("cleaning up browser session", "pid", sm.session.PID)
32✔
167

32✔
168
        // Kill the browser process group
32✔
169
        if sm.isProcessAlive(sm.session.PID) {
64✔
170
                // Find the process for proper cleanup
32✔
171
                process, err := os.FindProcess(sm.session.PID)
32✔
172
                if err != nil {
32✔
173
                        slog.Warn("failed to find browser process", "error", err)
×
174
                } else {
32✔
175
                        // Try graceful shutdown first
32✔
176
                        if err := syscall.Kill(-sm.session.PID, syscall.SIGTERM); err != nil {
32✔
177
                                slog.Warn("SIGTERM failed", "error", err)
×
178
                        }
×
179

180
                        // Wait for graceful shutdown with timeout
181
                        done := make(chan struct{}, 1)
32✔
182
                        go func() {
64✔
183
                                _, _ = process.Wait() // Reap zombie, ignore result
32✔
184
                                close(done)
32✔
185
                        }()
32✔
186

187
                        select {
32✔
188
                        case <-done:
32✔
189
                                // Clean exit
32✔
190
                                slog.Debug("browser process terminated gracefully")
32✔
191
                        case <-time.After(2 * time.Second):
×
192
                                // Force kill after timeout
×
193
                                if err := syscall.Kill(-sm.session.PID, syscall.SIGKILL); err != nil {
×
194
                                        slog.Debug("browser process already terminated", "error", err)
×
195
                                }
×
196

197
                                // Wait for process to exit, but with another timeout
198
                                select {
×
199
                                case <-done:
×
200
                                        slog.Debug("browser process terminated after SIGKILL")
×
201
                                case <-time.After(1 * time.Second):
×
202
                                        // Process might be an unkillable zombie
×
203
                                        slog.Warn("browser process may be zombie (unable to reap)")
×
204
                                        // Don't block - state is consistent anyway
205
                                }
206
                        }
207
                }
208
        }
209

210
        // Clean up user data directory
211
        if sm.session.UserDataDir != "" {
64✔
212
                if err := os.RemoveAll(sm.session.UserDataDir); err != nil {
32✔
213
                        slog.Warn("failed to remove user data directory", "error", err)
×
214
                }
×
215
        }
216

217
        // Remove session file
218
        if err := os.Remove(sm.sessionPath()); err != nil && !os.IsNotExist(err) {
32✔
219
                slog.Warn("failed to remove session file", "error", err)
×
220
        }
×
221

222
        sm.session = nil
32✔
223

32✔
224
        return nil
32✔
225
}
226

227
// GetSession returns the current session.
228
func (sm *SessionManager) GetSession() *Session {
2✔
229
        sm.mu.RLock()
2✔
230
        defer sm.mu.RUnlock()
2✔
231

2✔
232
        return sm.session
2✔
233
}
2✔
234

235
// launchBrowserUnlocked creates a new isolated browser instance.
236
// Caller must hold sm.mu.
237
func (sm *SessionManager) launchBrowserUnlocked(ctx context.Context) (*Session, error) {
34✔
238
        // Try multiple times if port conflicts occur
34✔
239
        maxRetries := 5
34✔
240
        var lastErr error
34✔
241

34✔
242
        for attempt := range maxRetries {
68✔
243
                port := sm.config.Port
34✔
244
                if port == 0 {
68✔
245
                        // Find an available port instead of random allocation
34✔
246
                        port = sm.findAvailablePort(ctx)
34✔
247
                }
34✔
248

249
                session, err := sm.tryLaunchBrowser(ctx, port)
34✔
250
                if err == nil {
67✔
251
                        return session, nil
33✔
252
                }
33✔
253

254
                lastErr = err
1✔
255
                slog.Debug("browser launch attempt failed", "attempt", attempt+1, "error", err)
1✔
256

1✔
257
                // If port is fixed by config, don't retry
1✔
258
                if sm.config.Port != 0 {
1✔
259
                        break
×
260
                }
261

262
                // Wait before retry
263
                select {
1✔
264
                case <-ctx.Done():
1✔
265
                        return nil, ctx.Err()
1✔
266
                case <-time.After(500 * time.Millisecond):
×
267
                }
268
        }
269

270
        return nil, lastErr
×
271
}
272

273
// findAvailablePort finds an available port in the configured range.
274
// Note: This is a best-effort check. The actual port availability is confirmed
275
// when Chrome tries to bind to it. Port conflicts are handled by retry logic.
276
func (sm *SessionManager) findAvailablePort(ctx context.Context) int {
34✔
277
        //nolint:gosec // G404 - Port allocation doesn't need cryptographic randomness
34✔
278
        for range 50 {
68✔
279
                port := minPort + rand.Intn(maxPort-minPort)
34✔
280

34✔
281
                listener := net.ListenConfig{}
34✔
282
                ln, err := listener.Listen(ctx, "tcp", fmt.Sprintf(":%d", port))
34✔
283
                if err == nil {
68✔
284
                        // Port is available (TOCTOU: race possible between Close() and Chrome binding)
34✔
285
                        _ = ln.Close()
34✔
286

34✔
287
                        return port
34✔
288
                }
34✔
289
        }
290

291
        // Fallback to random port if all checks fail
292
        // Retry logic in launchBrowserUnlocked will handle conflicts
293
        //nolint:gosec // G404 - Port allocation doesn't need cryptographic randomness
294
        return minPort + rand.Intn(maxPort-minPort)
×
295
}
296

297
// tryLaunchBrowser attempts to launch Chrome on a specific port.
298
func (sm *SessionManager) tryLaunchBrowser(ctx context.Context, port int) (*Session, error) {
34✔
299
        // Create temporary user data directory
34✔
300
        userDataDir, err := os.MkdirTemp("", "mehr-browser-*")
34✔
301
        if err != nil {
34✔
302
                return nil, errLaunch(fmt.Errorf("create user data dir: %w", err))
×
303
        }
×
304

305
        // Build Chrome command
306
        args := []string{
34✔
307
                "--remote-debugging-port=" + strconv.Itoa(port),
34✔
308
                "--no-first-run",
34✔
309
                "--no-default-browser-check",
34✔
310
                "--user-data-dir=" + userDataDir,
34✔
311
        }
34✔
312

34✔
313
        // Add certificate handling flags (default: ignore for local dev)
34✔
314
        // --ignore-certificate-errors: Bypass SSL certificate validation
34✔
315
        // --test-type: Suppress Chrome's unsupported flag warning
34✔
316
        if sm.config.IgnoreCertErrors {
34✔
317
                args = append(args, "--ignore-certificate-errors")
×
318
                args = append(args, "--test-type")
×
319
        }
×
320

321
        if sm.config.Headless {
68✔
322
                args = append(args,
34✔
323
                        "--headless",
34✔
324
                        "--disable-gpu",
34✔
325
                        "--no-sandbox",
34✔
326
                )
34✔
327
        }
34✔
328

329
        // Try to find Chrome executable
330
        chromePath, err := findChrome()
34✔
331
        if err != nil {
34✔
332
                _ = os.RemoveAll(userDataDir)
×
333

×
334
                return nil, errLaunch(err)
×
335
        }
×
336

337
        slog.Info("launching isolated browser", "path", chromePath, "port", port, "headless", sm.config.Headless)
34✔
338

34✔
339
        cmd := exec.CommandContext(ctx, chromePath, args...)
34✔
340
        cmd.SysProcAttr = &syscall.SysProcAttr{
34✔
341
                Setpgid: true,
34✔
342
        }
34✔
343
        // Disconnect Chrome from parent's stdio to prevent interference with MCP protocol.
34✔
344
        // When running via MCP server, stdin/stdout are used for JSON-RPC communication,
34✔
345
        // and Chrome processes must not inherit these file descriptors.
34✔
346
        cmd.Stdin = nil
34✔
347
        cmd.Stdout = nil
34✔
348
        cmd.Stderr = nil
34✔
349

34✔
350
        if err := cmd.Start(); err != nil {
35✔
351
                _ = os.RemoveAll(userDataDir)
1✔
352

1✔
353
                return nil, errLaunch(fmt.Errorf("start chrome: %w", err))
1✔
354
        }
1✔
355

356
        // Wait a moment for Chrome to start
357
        select {
33✔
358
        case <-ctx.Done():
×
359
                // Kill entire process group, not just parent process
×
360
                if cmd.Process != nil && cmd.Process.Pid > 0 {
×
361
                        // Negative PID kills entire process group
×
362
                        _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
×
363
                        time.Sleep(100 * time.Millisecond)
×
364
                        _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
×
365
                }
×
366
                _ = os.RemoveAll(userDataDir)
×
367

×
368
                return nil, ctx.Err()
×
369
        case <-time.After(2 * time.Second):
33✔
370
        }
371

372
        // Verify Chrome is running (ProcessState is nil for running processes)
373
        if cmd.ProcessState == nil {
66✔
374
                session := &Session{
33✔
375
                        PID:         cmd.Process.Pid,
33✔
376
                        Port:        port,
33✔
377
                        Host:        sm.config.Host,
33✔
378
                        UserDataDir: userDataDir,
33✔
379
                        StartedAt:   time.Now(),
33✔
380
                }
33✔
381

33✔
382
                // Save session
33✔
383
                if err := sm.saveSession(session); err != nil {
33✔
384
                        slog.Warn("failed to save session", "error", err)
×
385
                }
×
386

387
                sm.session = session
33✔
388

33✔
389
                return session, nil
33✔
390
        }
391

392
        _ = os.RemoveAll(userDataDir)
×
393

×
394
        return nil, errLaunch(errors.New("chrome exited immediately"))
×
395
}
396

397
// loadSession loads existing session from disk.
398
func (sm *SessionManager) loadSession() (*Session, error) {
38✔
399
        path := sm.sessionPath()
38✔
400
        data, err := os.ReadFile(path)
38✔
401
        if err != nil {
71✔
402
                return nil, err
33✔
403
        }
33✔
404

405
        var session Session
5✔
406
        if err := json.Unmarshal(data, &session); err != nil {
6✔
407
                // JSON is corrupted, back up the file and remove it
1✔
408
                slog.Warn("corrupted session file, backing up and removing", "error", err)
1✔
409
                backupPath := path + ".corrupted." + strconv.FormatInt(time.Now().Unix(), 10)
1✔
410
                _ = os.Rename(path, backupPath)
1✔
411

1✔
412
                return nil, fmt.Errorf("unmarshal session: %w", err)
1✔
413
        }
1✔
414

415
        return &session, nil
4✔
416
}
417

418
// saveSession saves session to disk.
419
func (sm *SessionManager) saveSession(session *Session) error {
37✔
420
        // Ensure directory exists
37✔
421
        sessionDir := filepath.Dir(sm.sessionPath())
37✔
422
        if err := os.MkdirAll(sessionDir, 0o755); err != nil {
37✔
423
                return fmt.Errorf("create session dir: %w", err)
×
424
        }
×
425

426
        data, err := json.MarshalIndent(session, "", "  ")
37✔
427
        if err != nil {
37✔
428
                return fmt.Errorf("marshal session: %w", err)
×
429
        }
×
430

431
        // Use restricted permissions (0600) - session contains PID/port info
432
        if err := os.WriteFile(sm.sessionPath(), data, 0o600); err != nil {
37✔
433
                return fmt.Errorf("write session: %w", err)
×
434
        }
×
435

436
        return nil
37✔
437
}
438

439
// sessionPath returns the path to the session file.
440
func (sm *SessionManager) sessionPath() string {
146✔
441
        return filepath.Join(sm.workspaceDir, sessionFile)
146✔
442
}
146✔
443

444
// isProcessAlive checks if a process with the given PID is running.
445
// Note: On Linux, this can return true for zombie processes or reused PIDs.
446
// Use isPortInUse for more reliable session validation.
447
func (sm *SessionManager) isProcessAlive(pid int) bool {
46✔
448
        process, err := os.FindProcess(pid)
46✔
449
        if err != nil {
46✔
450
                return false
×
451
        }
×
452

453
        err = process.Signal(syscall.Signal(0))
46✔
454

46✔
455
        return err == nil
46✔
456
}
457

458
// isPortInUse checks if something is listening on the given host:port.
459
// This is faster and more reliable than isProcessAlive for detecting stale sessions.
460
func (sm *SessionManager) isPortInUse(ctx context.Context, host string, port int) bool {
1✔
461
        // Use a short timeout for quick port check (200ms is enough for local connections)
1✔
462
        ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
1✔
463
        defer cancel()
1✔
464

1✔
465
        dialer := &net.Dialer{}
1✔
466
        conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
1✔
467
        if err != nil {
2✔
468
                return false
1✔
469
        }
1✔
470
        _ = conn.Close()
×
471

×
472
        return true
×
473
}
474

475
// isEndpointResponsive checks if Chrome's HTTP endpoint is responding.
476
// This catches cases where the process exists but Chrome is hung/zombie.
477
func (sm *SessionManager) isEndpointResponsive(ctx context.Context, host string, port int) bool {
8✔
478
        url := fmt.Sprintf("http://%s:%d/json/version", host, port)
8✔
479

8✔
480
        ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
8✔
481
        defer cancel()
8✔
482

8✔
483
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
8✔
484
        if err != nil {
8✔
485
                return false
×
486
        }
×
487

488
        client := &http.Client{}
8✔
489
        resp, err := client.Do(req)
8✔
490
        if err != nil {
12✔
491
                return false
4✔
492
        }
4✔
493
        defer func() { _ = resp.Body.Close() }()
8✔
494

495
        return resp.StatusCode == http.StatusOK
4✔
496
}
497

498
// killProcess terminates a browser process that's become unresponsive.
499
func (sm *SessionManager) killProcess(pid int) {
4✔
500
        process, err := os.FindProcess(pid)
4✔
501
        if err != nil {
4✔
502
                return
×
503
        }
×
504

505
        // Try SIGTERM first
506
        _ = syscall.Kill(-pid, syscall.SIGTERM)
4✔
507

4✔
508
        // Give it a moment, then force kill
4✔
509
        time.Sleep(500 * time.Millisecond)
4✔
510
        if sm.isProcessAlive(pid) {
6✔
511
                _ = syscall.Kill(-pid, syscall.SIGKILL)
2✔
512
        }
2✔
513

514
        // Reap zombie
515
        _, _ = process.Wait()
4✔
516
}
517

518
// findChrome locates the Chrome executable on the system.
519
func findChrome() (string, error) {
34✔
520
        // Try PATH first (covers most Linux distributions)
34✔
521
        executables := []string{
34✔
522
                "google-chrome",
34✔
523
                "google-chrome-stable",
34✔
524
                "google-chrome-beta",
34✔
525
                "chromium",
34✔
526
                "chromium-browser",
34✔
527
        }
34✔
528
        for _, exe := range executables {
68✔
529
                if path, err := exec.LookPath(exe); err == nil {
68✔
530
                        return path, nil
34✔
531
                }
34✔
532
        }
533

534
        // Fallback to paths NOT in PATH
535
        paths := []string{
×
536
                "/opt/hostedtoolcache/setup-chrome/chrome/stable/x64/chrome", // GitHub Actions setup-chrome
×
537
                "/snap/bin/chromium", // Snap installs (often not in PATH)
×
538
                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",                      // macOS .app bundle
×
539
                "/Applications/Chromium.app/Contents/MacOS/Chromium",                                // macOS .app bundle
×
540
                "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",                        // Windows
×
541
                "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",                  // Windows x86
×
542
                filepath.Join(os.Getenv("LOCALAPPDATA"), "Google\\Chrome\\Application\\chrome.exe"), // Windows user install
×
543
        }
×
544

×
545
        for _, path := range paths {
×
546
                if _, err := os.Stat(path); err == nil {
×
547
                        return path, nil
×
548
                }
×
549
        }
550

551
        return "", fmt.Errorf("chrome not found (tried PATH: %v, plus known locations)", executables)
×
552
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc