feat: add scoped browser stop and job control

This commit is contained in:
QiuSW
2026-07-22 15:03:21 +08:00
parent d6f6bb0f2c
commit 1ce02bcf3b
8 changed files with 262 additions and 14 deletions
+24
View File
@@ -0,0 +1,24 @@
//go:build !windows
package browser
import (
"context"
"errors"
"os"
)
type jobHandle struct{}
func (jobHandle) assign(*os.Process) error { return nil }
func (jobHandle) close() {}
func (p *osProcess) Stop(ctx context.Context, force bool) error {
if err := ctx.Err(); err != nil {
return err
}
if !force {
return errors.New("graceful browser close is only supported on Windows")
}
return p.command.Process.Kill()
}
+126
View File
@@ -0,0 +1,126 @@
//go:build windows
package browser
import (
"context"
"errors"
"fmt"
"os"
"syscall"
"unsafe"
)
const (
jobObjectExtendedLimitClass = 9
jobObjectLimitKillOnJobClose = 0x2000
wmClose = 0x0010
)
var (
kernel32Job = syscall.NewLazyDLL("kernel32.dll")
createJobObjectW = kernel32Job.NewProc("CreateJobObjectW")
setInformationJobObject = kernel32Job.NewProc("SetInformationJobObject")
assignProcessToJobObject = kernel32Job.NewProc("AssignProcessToJobObject")
terminateJobObject = kernel32Job.NewProc("TerminateJobObject")
enumWindows = user32.NewProc("EnumWindows")
getWindowProcessID = user32.NewProc("GetWindowThreadProcessId")
postMessageW = user32.NewProc("PostMessageW")
)
type jobHandle struct{ handle syscall.Handle }
type jobObjectBasicLimitInformation struct {
PerProcessUserTimeLimit int64
PerJobUserTimeLimit int64
LimitFlags uint32
MinimumWorkingSetSize uintptr
MaximumWorkingSetSize uintptr
ActiveProcessLimit uint32
Affinity uintptr
PriorityClass uint32
SchedulingClass uint32
}
type ioCounters struct{ ReadOperationCount, WriteOperationCount, OtherOperationCount, ReadTransferCount, WriteTransferCount, OtherTransferCount uint64 }
type jobObjectExtendedLimitInformation struct {
BasicLimitInformation jobObjectBasicLimitInformation
IoInfo ioCounters
ProcessMemoryLimit uintptr
JobMemoryLimit uintptr
PeakProcessMemoryUsed uintptr
PeakJobMemoryUsed uintptr
}
func (j *jobHandle) assign(process *os.Process) error {
job, _, err := createJobObjectW.Call(0, 0)
if job == 0 {
return fmt.Errorf("create browser job object: %w", err)
}
j.handle = syscall.Handle(job)
info := jobObjectExtendedLimitInformation{BasicLimitInformation: jobObjectBasicLimitInformation{LimitFlags: jobObjectLimitKillOnJobClose}}
if result, _, callErr := setInformationJobObject.Call(job, jobObjectExtendedLimitClass, uintptr(unsafe.Pointer(&info)), unsafe.Sizeof(info)); result == 0 {
j.close()
return fmt.Errorf("configure browser job object: %w", callErr)
}
processHandle, err := syscall.OpenProcess(0x0100|0x0001, false, uint32(process.Pid))
if err != nil {
j.close()
return fmt.Errorf("open browser process for job object: %w", err)
}
defer syscall.CloseHandle(processHandle)
if result, _, callErr := assignProcessToJobObject.Call(job, uintptr(processHandle)); result == 0 {
j.close()
return fmt.Errorf("assign browser process to job object: %w", callErr)
}
return nil
}
func (j *jobHandle) close() {
if j == nil || j.handle == 0 {
return
}
_ = syscall.CloseHandle(j.handle)
j.handle = 0
}
func (j *jobHandle) terminate() error {
if j == nil || j.handle == 0 {
return errors.New("browser job object is unavailable")
}
result, _, err := terminateJobObject.Call(uintptr(j.handle), 1)
if result == 0 {
return err
}
return nil
}
func (p *osProcess) Stop(ctx context.Context, force bool) error {
if err := ctx.Err(); err != nil {
return err
}
if force {
return p.job.terminate()
}
return postCloseToProcess(p.command.Process.Pid)
}
func postCloseToProcess(pid int) error {
if pid <= 0 {
return errors.New("invalid browser PID")
}
callback := syscall.NewCallback(func(hwnd uintptr, lParam uintptr) uintptr {
var windowPID uint32
getWindowProcessID.Call(hwnd, uintptr(unsafe.Pointer(&windowPID)))
if int(windowPID) == pid {
postMessageW.Call(hwnd, wmClose, 0, 0)
}
return 1
})
result, _, err := enumWindows.Call(callback, uintptr(pid))
if result == 0 && err != syscall.Errno(0) {
return err
}
return nil
}
+37 -7
View File
@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"sync"
"chub/internal/domain"
@@ -21,6 +23,10 @@ type RunnerProcess interface {
Wait() (int, error)
}
type RunnerProcessController interface {
Stop(context.Context, bool) error
}
type ProfileUse struct {
Occupied bool
PID int
@@ -96,19 +102,33 @@ func (l *Launcher) InspectProfile(ctx context.Context, userDataDir string) (Prof
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
}
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[normalized.UserDataDir]
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()
@@ -175,13 +195,23 @@ func (osRunner) Start(executable string, args []string) (RunnerProcess, error) {
if err := command.Start(); err != nil {
return nil, err
}
return &osProcess{command: command}, nil
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 }
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
+33 -4
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"sync"
"testing"
"time"
"chub/internal/domain"
)
@@ -42,6 +43,7 @@ func TestLauncherSeparatesProfilesBlocksDuplicatesAndReleasesAfterExit(t *testin
if err == nil && !use.Occupied {
break
}
time.Sleep(time.Millisecond)
}
if use.Occupied {
t.Fatalf("profile remained occupied after exit: %+v", use)
@@ -70,6 +72,24 @@ func TestProcessWaitCancellationDoesNotTerminateBrowser(t *testing.T) {
}
}
func TestLauncherStopProfileTargetsOnlyRegisteredProfile(t *testing.T) {
runner := &fakeRunner{}
launcher := NewLauncher(runner)
profile := filepath.Join(t.TempDir(), "profile")
if _, err := launcher.Start(context.Background(), launchSpec(profile)); err != nil {
t.Fatalf("Start() error = %v", err)
}
if err := launcher.StopProfile(context.Background(), profile, false); err != nil {
t.Fatalf("StopProfile() error = %v", err)
}
if runner.processAt(0).stopCount != 1 || runner.processAt(0).stopForce {
t.Fatalf("stop state = count %d force %v", runner.processAt(0).stopCount, runner.processAt(0).stopForce)
}
if err := launcher.StopProfile(context.Background(), filepath.Join(t.TempDir(), "other"), true); !errors.Is(err, domain.ErrInstanceNotFound) {
t.Fatalf("unknown profile error = %v", err)
}
}
func launchSpec(profile string) domain.LaunchSpec {
return domain.LaunchSpec{Kind: domain.BrowserChrome, Executable: `C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe`, UserDataDir: profile, TargetURL: "https://example.com"}
}
@@ -93,10 +113,12 @@ func (r *fakeRunner) processAt(index int) *fakeProcess {
}
type fakeProcess struct {
pid int
done chan processResult
mu sync.Mutex
finished bool
pid int
done chan processResult
mu sync.Mutex
finished bool
stopCount int
stopForce bool
}
type processResult struct {
code int
@@ -108,6 +130,13 @@ func newFakeProcess(pid int) *fakeProcess {
}
func (p *fakeProcess) PID() int { return p.pid }
func (p *fakeProcess) Wait() (int, error) { result := <-p.done; return result.code, result.err }
func (p *fakeProcess) Stop(_ context.Context, force bool) error {
p.mu.Lock()
defer p.mu.Unlock()
p.stopCount++
p.stopForce = force
return nil
}
func (p *fakeProcess) finish(code int, err error) {
p.mu.Lock()
defer p.mu.Unlock()