package browser import ( "context" "errors" "fmt" "os/exec" "path/filepath" "strings" "sync" "chub/internal/domain" ) var ErrInvalidProcess = errors.New("browser process handle is invalid") type CommandRunner interface { Start(executable string, args []string) (RunnerProcess, error) } type RunnerProcess interface { PID() int Wait() (int, error) } type RunnerProcessController interface { Stop(context.Context, bool) error } type ProfileUse struct { Occupied bool PID int Source string } type Launcher struct { runner CommandRunner mu sync.Mutex profiles map[string]*trackedProcess } func NewLauncher(runner CommandRunner) *Launcher { return &Launcher{runner: runner, profiles: make(map[string]*trackedProcess)} } func NewOSLauncher() *Launcher { return NewLauncher(osRunner{}) } func (l *Launcher) Start(ctx context.Context, spec domain.LaunchSpec) (ProcessHandle, error) { if err := ctx.Err(); err != nil { return nil, err } if l == nil || l.runner == nil { return nil, fmt.Errorf("%w: command runner is required", domain.ErrInvalidLaunchSpec) } normalized, err := spec.Normalize() if err != nil { return nil, err } if normalized.Executable == "" { return nil, fmt.Errorf("%w: executable is required", domain.ErrInvalidLaunchSpec) } args, err := BuildArgs(normalized) if err != nil { return nil, err } l.mu.Lock() profile := normalized.UserDataDir if existing := l.profiles[profile]; existing != nil { l.mu.Unlock() return nil, &ProfileOccupiedError{UserDataDir: profile, PID: existing.PID()} } pending := newPendingProcess() l.profiles[profile] = pending l.mu.Unlock() raw, err := l.runner.Start(normalized.Executable, args) if err != nil { l.release(profile, pending) return nil, err } if raw == nil || raw.PID() <= 0 { l.release(profile, pending) return nil, ErrInvalidProcess } process := newTrackedProcess(raw) l.mu.Lock() if l.profiles[profile] != pending { l.mu.Unlock() return nil, fmt.Errorf("%w: profile reservation changed", ErrInvalidProcess) } l.profiles[profile] = process l.mu.Unlock() go func() { _, _ = process.Wait(context.Background()) l.release(profile, process) }() return process, nil } func (l *Launcher) InspectProfile(ctx context.Context, userDataDir string) (ProfileUse, error) { if err := ctx.Err(); err != nil { return ProfileUse{}, err } profile := strings.TrimSpace(userDataDir) if profile == "" || !filepath.IsAbs(profile) { return ProfileUse{}, fmt.Errorf("%w: user data dir must be absolute", domain.ErrInvalidLaunchSpec) } profile = filepath.Clean(profile) l.mu.Lock() defer l.mu.Unlock() process := l.profiles[profile] if process == nil { return ProfileUse{}, nil } return ProfileUse{Occupied: true, PID: process.PID(), Source: "chub_registry"}, nil } func (l *Launcher) StopProfile(ctx context.Context, userDataDir string, force bool) error { if err := ctx.Err(); err != nil { return err } profile := strings.TrimSpace(userDataDir) if profile == "" || !filepath.IsAbs(profile) { return fmt.Errorf("%w: user data dir must be absolute", domain.ErrInvalidLaunchSpec) } profile = filepath.Clean(profile) l.mu.Lock() process := l.profiles[profile] l.mu.Unlock() if process == nil { return domain.ErrInstanceNotFound } if process.raw == nil { return errors.New("browser process is still starting") } controller, ok := process.raw.(RunnerProcessController) if !ok { return errors.New("browser process does not support stop") } return controller.Stop(ctx, force) } func (l *Launcher) release(profile string, process *trackedProcess) { l.mu.Lock() defer l.mu.Unlock() if l.profiles[profile] == process { delete(l.profiles, profile) } } type ProfileOccupiedError struct { UserDataDir string PID int } func (e *ProfileOccupiedError) Error() string { if e.PID <= 0 { return fmt.Sprintf("%s: %s", domain.ErrProfileOccupied, e.UserDataDir) } return fmt.Sprintf("%s: %s (pid %d)", domain.ErrProfileOccupied, e.UserDataDir, e.PID) } func (e *ProfileOccupiedError) Unwrap() error { return domain.ErrProfileOccupied } type ProcessHandle interface { PID() int Wait(context.Context) (int, error) } type trackedProcess struct { pid int raw RunnerProcess once sync.Once done chan struct{} code int err error } func newPendingProcess() *trackedProcess { return &trackedProcess{done: make(chan struct{})} } func newTrackedProcess(raw RunnerProcess) *trackedProcess { return &trackedProcess{pid: raw.PID(), raw: raw, done: make(chan struct{})} } func (p *trackedProcess) PID() int { return p.pid } func (p *trackedProcess) Wait(ctx context.Context) (int, error) { if p.raw == nil { return 0, errors.New("browser process is still starting") } p.once.Do(func() { go func() { p.code, p.err = p.raw.Wait() close(p.done) }() }) select { case <-p.done: return p.code, p.err case <-ctx.Done(): return 0, ctx.Err() } } type osRunner struct{} func (osRunner) Start(executable string, args []string) (RunnerProcess, error) { command := exec.Command(executable, args...) if err := command.Start(); err != nil { return nil, err } process := &osProcess{command: command} if err := process.job.assign(command.Process); err != nil { _ = command.Process.Kill() _, _ = command.Process.Wait() return nil, err } return process, nil } type osProcess struct { command *exec.Cmd job jobHandle } func (p *osProcess) PID() int { return p.command.Process.Pid } func (p *osProcess) Wait() (int, error) { defer p.job.close() err := p.command.Wait() if p.command.ProcessState == nil { return 0, err } return p.command.ProcessState.ExitCode(), err }