WitNote / internal /bridge /profile_lock.go
AUXteam's picture
Upload folder using huggingface_hub
6a7089a verified
// profile_lock.go handles stale Chrome profile lock recovery.
//
// When a container restarts (or Chrome crashes), Chrome's SingletonLock,
// SingletonSocket, and SingletonCookie files may be left behind in the profile
// directory. On next startup Chrome sees these and refuses to launch with
// "The profile appears to be in use by another Chromium process".
//
// This code detects that error, checks whether the owning process is actually
// still running (via PID probe and process listing), and removes the stale
// lock files if it's safe to do so. It retries Chrome startup once after
// clearing the locks.
package bridge
import (
"bytes"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
var chromeProfileProcessLister = findChromeProfileProcesses
var chromePIDIsRunning = isChromePIDRunning
var killChromeProfileProcesses = killProcesses
var isProfileOwnedByRunningPinchtabMock = isProfileOwnedByRunningPinchtab
var chromeSingletonFiles = []string{
"SingletonLock",
"SingletonSocket",
"SingletonCookie",
}
var chromeProfileLockPIDPattern = regexp.MustCompile(`(?:Chromium|Chrome) process \((\d+)\)`)
type chromeProfileProcess struct {
PID string
Command string
}
func isChromeProfileLockError(msg string) bool {
if msg == "" {
return false
}
return strings.Contains(msg, "The profile appears to be in use by another Chromium process") ||
strings.Contains(msg, "The profile appears to be in use by another Chrome process") ||
strings.Contains(msg, "process_singleton")
}
func clearStaleChromeProfileLock(profileDir, errMsg string) (bool, error) {
if strings.TrimSpace(profileDir) == "" {
return false, nil
}
if pid, ok := extractChromeProfileLockPID(errMsg); ok {
running, err := chromePIDIsRunning(pid)
if err != nil {
slog.Warn("failed to probe chrome lock pid; falling back to process listing", "profile", profileDir, "pid", pid, "err", err)
} else if running {
// If we find the PID from the error message is still running,
// check if it's actually managed by another active PinchTab.
if owned, ptPid := isProfileOwnedByRunningPinchtabMock(profileDir); owned {
slog.Warn("chrome profile lock appears active and owned by another pinchtab; leaving singleton files in place", "profile", profileDir, "pid", pid, "pinchtab_pid", ptPid)
return false, nil
}
slog.Warn("chrome profile lock appears active but pinchtab owner is dead; proceeding with stale cleanup", "profile", profileDir, "pid", pid)
}
}
processes, err := chromeProfileProcessLister(profileDir)
if err != nil {
if _, ok := extractChromeProfileLockPID(errMsg); ok {
slog.Warn("profile process listing unavailable; proceeding with stale lock cleanup based on lock pid", "profile", profileDir, "err", err)
} else {
return false, err
}
}
if len(processes) > 0 {
if owned, ptPid := isProfileOwnedByRunningPinchtabMock(profileDir); owned {
pids := make([]string, 0, len(processes))
for _, proc := range processes {
pids = append(pids, proc.PID)
}
slog.Warn("chrome profile lock appears active and owned by another pinchtab; leaving singleton files in place", "profile", profileDir, "pids", strings.Join(pids, ","), "pinchtab_pid", ptPid)
return false, nil
}
// If no other PinchTab owns this profile, we can safely kill the stale Chrome processes.
slog.Warn("chrome profile lock appears active but no pinchtab owner found; killing stale processes", "profile", profileDir)
if err := killChromeProfileProcesses(processes); err != nil {
slog.Error("failed to kill stale chrome processes", "profile", profileDir, "err", err)
return false, nil
}
}
removed := false
for _, name := range chromeSingletonFiles {
path := filepath.Join(profileDir, name)
if _, err := os.Lstat(path); err != nil {
if os.IsNotExist(err) {
continue
}
return false, fmt.Errorf("inspect %s: %w", path, err)
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return removed, fmt.Errorf("remove %s: %w", path, err)
}
removed = true
}
return removed, nil
}
func isProfileOwnedByRunningPinchtab(profileDir string) (bool, int) {
pidFile := filepath.Join(profileDir, "pinchtab.pid")
data, err := os.ReadFile(pidFile)
if err != nil {
if !os.IsNotExist(err) {
slog.Debug("failed to read pinchtab pid file", "path", pidFile, "err", err)
}
return false, 0
}
var pid int
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
slog.Debug("failed to parse pinchtab pid file", "path", pidFile, "err", err)
return false, 0
}
if pid == os.Getpid() {
return false, pid // It's us
}
running, err := chromePIDIsRunning(pid)
if err == nil && running {
// Even if the PID is running, check if it's actually a pinchtab process
// to handle PID reuse.
if isPinchTabProcess(pid) {
slog.Debug("profile is owned by another active pinchtab", "profile", profileDir, "pid", pid)
return true, pid
}
slog.Debug("PID in lockfile is running but not a pinchtab process (PID reuse)", "profile", profileDir, "pid", pid)
} else {
slog.Debug("PID in lockfile is not running", "profile", profileDir, "pid", pid, "err", err)
}
return false, 0
}
func AcquireProfileLock(profileDir string) error {
if profileDir == "" {
return nil
}
if err := os.MkdirAll(profileDir, 0755); err != nil {
return fmt.Errorf("mkdir profile dir: %w", err)
}
if owned, pid := isProfileOwnedByRunningPinchtab(profileDir); owned {
return fmt.Errorf("profile %s is already in use by pinchtab process %d", profileDir, pid)
}
pidFile := filepath.Join(profileDir, "pinchtab.pid")
slog.Debug("acquiring profile lock", "profile", profileDir, "pid", os.Getpid())
return os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", os.Getpid())), 0644)
}
func extractChromeProfileLockPID(msg string) (int, bool) {
if msg == "" {
return 0, false
}
match := chromeProfileLockPIDPattern.FindStringSubmatch(msg)
if len(match) != 2 {
return 0, false
}
pid := 0
for _, ch := range match[1] {
pid = pid*10 + int(ch-'0')
}
if pid <= 0 {
return 0, false
}
return pid, true
}
func findChromeProfileProcesses(profileDir string) ([]chromeProfileProcess, error) {
if strings.TrimSpace(profileDir) == "" {
return nil, nil
}
cmd := exec.Command("ps", "-axo", "pid=,args=")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("list chrome processes: %w", err)
}
return parseChromeProfileProcesses(out, profileDir), nil
}
func parseChromeProfileProcesses(out []byte, profileDir string) []chromeProfileProcess {
if len(out) == 0 || strings.TrimSpace(profileDir) == "" {
return nil
}
needleEquals := "--user-data-dir=" + profileDir
needleSpace := "--user-data-dir " + profileDir
lines := bytes.Split(out, []byte{'\n'})
processes := make([]chromeProfileProcess, 0)
for _, rawLine := range lines {
line := strings.TrimSpace(string(rawLine))
if line == "" || (!strings.Contains(line, needleEquals) && !strings.Contains(line, needleSpace)) {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
processes = append(processes, chromeProfileProcess{
PID: fields[0],
Command: strings.TrimSpace(strings.TrimPrefix(line, fields[0])),
})
}
return processes
}