Files
cdp_hub/internal/platform/browser/launcher.go
T

221 lines
5.5 KiB
Go

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
}