191 lines
4.5 KiB
Go
191 lines
4.5 KiB
Go
package browser
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"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 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
|
|
}
|
|
normalized, err := (domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: userDataDir, TargetURL: "https://example.invalid"}).Normalize()
|
|
if err != nil {
|
|
return ProfileUse{}, err
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
process := l.profiles[normalized.UserDataDir]
|
|
if process == nil {
|
|
return ProfileUse{}, nil
|
|
}
|
|
return ProfileUse{Occupied: true, PID: process.PID(), Source: "chub_registry"}, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
return &osProcess{command: command}, nil
|
|
}
|
|
|
|
type osProcess struct{ command *exec.Cmd }
|
|
|
|
func (p *osProcess) PID() int { return p.command.Process.Pid }
|
|
func (p *osProcess) Wait() (int, error) {
|
|
err := p.command.Wait()
|
|
if p.command.ProcessState == nil {
|
|
return 0, err
|
|
}
|
|
return p.command.ProcessState.ExitCode(), err
|
|
}
|