feat: add scoped browser stop and job control
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| T-101 | 实现启动、registry、Wait 和 profile 占用检测 | T-003 | DONE |
|
| T-101 | 实现启动、registry、Wait 和 profile 占用检测 | T-003 | DONE |
|
||||||
| T-102 | 实现 Windows 进程身份查询和外部实例保守识别 | T-101 | DONE |
|
| T-102 | 实现 Windows 进程身份查询和外部实例保守识别 | T-101 | DONE |
|
||||||
| T-103 | 实现优雅关闭、强制关闭和 Job Object 进程树 | T-102 | TODO |
|
| T-103 | 实现优雅关闭、强制关闭和 Job Object 进程树 | T-102 | DONE |
|
||||||
| T-104 | Chrome/Edge 双实例、异常退出和权限失败 smoke | T-103 | TODO |
|
| T-104 | Chrome/Edge 双实例、异常退出和权限失败 smoke | T-103 | TODO |
|
||||||
|
|
||||||
## Phase 2:产品外壳
|
## Phase 2:产品外壳
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type BrowserManager interface {
|
|||||||
|
|
||||||
`Stop(force=false)` 只执行优雅关闭;强制关闭必须由明确用户动作或 CLI `--force` 触发。
|
`Stop(force=false)` 只执行优雅关闭;强制关闭必须由明确用户动作或 CLI `--force` 触发。
|
||||||
|
|
||||||
|
平台实现通过实例级 `StopProfile(userDataDir, force)` 执行关闭;force 只对已注册并经过身份绑定的实例生效。
|
||||||
|
|
||||||
参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。
|
参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。
|
||||||
|
|
||||||
## CLI 目标形状
|
## CLI 目标形状
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
- 日期:2026-07-22
|
- 日期:2026-07-22
|
||||||
- 阶段:文档与交互原型准备
|
- 阶段:文档与交互原型准备
|
||||||
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry 和 Windows 身份/占用检查,T-001 至 T-003、T-101、T-102 已完成
|
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭和 Job Object,T-001 至 T-003、T-101 至 T-103 已完成
|
||||||
- UI:尚未实现;P0 页面需要先制作 HTML 原型
|
- UI:尚未实现;P0 页面需要先制作 HTML 原型
|
||||||
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
||||||
- blocker:无;下一个任务为 T-103
|
- blocker:无;下一个任务为 T-104
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
id: T-103
|
||||||
|
title: 实现优雅关闭、强制关闭和 Job Object 进程树
|
||||||
|
phase: 1
|
||||||
|
deps: [T-102]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-22
|
||||||
|
owner: codex
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求与背景
|
||||||
|
|
||||||
|
浏览器是多进程架构,Chub 必须提供实例级关闭能力,同时避免通过进程名称影响用户的其他 Chrome/Edge。
|
||||||
|
|
||||||
|
## 方案与边界
|
||||||
|
|
||||||
|
- `Launcher.StopProfile` 只定位本 registry 中的规范化 user data dir。
|
||||||
|
- Windows 优雅关闭向对应 PID 的窗口发送 `WM_CLOSE`。
|
||||||
|
- Windows 启动进程加入 Job Object;强制关闭调用 `TerminateJobObject`,覆盖该实例的子进程树。
|
||||||
|
- 非 Windows 保留显式降级:仅 force 使用进程 Kill,graceful 返回 unsupported。
|
||||||
|
- 外部未注册进程不提供 Stop 接口。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 优雅关闭与强制关闭传递正确模式。
|
||||||
|
- 未注册 profile 返回 `ErrInstanceNotFound`。
|
||||||
|
- 取消 Stop 不执行关闭动作。
|
||||||
|
- Windows Job Object 和窗口消息代码可编译,现有测试通过。
|
||||||
|
- `go test ./...`、`go vet ./...` 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 变更:新增实例 StopProfile、RunnerProcessController、Windows Job Object、WM_CLOSE 和非 Windows 降级实现。
|
||||||
|
- 验证:`gofmt`、`go test ./...`、`go vet ./...` 均通过。
|
||||||
|
- 阻塞:无。
|
||||||
|
- 残余风险:真实 Chrome/Edge 窗口优雅关闭、子进程回收和强制关闭需 T-104 Windows smoke 验证。
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"chub/internal/domain"
|
"chub/internal/domain"
|
||||||
@@ -21,6 +23,10 @@ type RunnerProcess interface {
|
|||||||
Wait() (int, error)
|
Wait() (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RunnerProcessController interface {
|
||||||
|
Stop(context.Context, bool) error
|
||||||
|
}
|
||||||
|
|
||||||
type ProfileUse struct {
|
type ProfileUse struct {
|
||||||
Occupied bool
|
Occupied bool
|
||||||
PID int
|
PID int
|
||||||
@@ -96,19 +102,33 @@ func (l *Launcher) InspectProfile(ctx context.Context, userDataDir string) (Prof
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return ProfileUse{}, err
|
return ProfileUse{}, err
|
||||||
}
|
}
|
||||||
normalized, err := (domain.LaunchSpec{Kind: domain.BrowserChrome, UserDataDir: userDataDir, TargetURL: "https://example.invalid"}).Normalize()
|
profile := strings.TrimSpace(userDataDir)
|
||||||
if err != nil {
|
if profile == "" || !filepath.IsAbs(profile) { return ProfileUse{}, fmt.Errorf("%w: user data dir must be absolute", domain.ErrInvalidLaunchSpec) }
|
||||||
return ProfileUse{}, err
|
profile = filepath.Clean(profile)
|
||||||
}
|
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
process := l.profiles[normalized.UserDataDir]
|
process := l.profiles[profile]
|
||||||
if process == nil {
|
if process == nil {
|
||||||
return ProfileUse{}, nil
|
return ProfileUse{}, nil
|
||||||
}
|
}
|
||||||
return ProfileUse{Occupied: true, PID: process.PID(), Source: "chub_registry"}, 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) {
|
func (l *Launcher) release(profile string, process *trackedProcess) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -175,13 +195,23 @@ func (osRunner) Start(executable string, args []string) (RunnerProcess, error) {
|
|||||||
if err := command.Start(); err != nil {
|
if err := command.Start(); err != nil {
|
||||||
return nil, err
|
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) PID() int { return p.command.Process.Pid }
|
||||||
func (p *osProcess) Wait() (int, error) {
|
func (p *osProcess) Wait() (int, error) {
|
||||||
|
defer p.job.close()
|
||||||
err := p.command.Wait()
|
err := p.command.Wait()
|
||||||
if p.command.ProcessState == nil {
|
if p.command.ProcessState == nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"chub/internal/domain"
|
"chub/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -42,6 +43,7 @@ func TestLauncherSeparatesProfilesBlocksDuplicatesAndReleasesAfterExit(t *testin
|
|||||||
if err == nil && !use.Occupied {
|
if err == nil && !use.Occupied {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
}
|
}
|
||||||
if use.Occupied {
|
if use.Occupied {
|
||||||
t.Fatalf("profile remained occupied after exit: %+v", use)
|
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 {
|
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"}
|
return domain.LaunchSpec{Kind: domain.BrowserChrome, Executable: `C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe`, UserDataDir: profile, TargetURL: "https://example.com"}
|
||||||
}
|
}
|
||||||
@@ -97,6 +117,8 @@ type fakeProcess struct {
|
|||||||
done chan processResult
|
done chan processResult
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
finished bool
|
finished bool
|
||||||
|
stopCount int
|
||||||
|
stopForce bool
|
||||||
}
|
}
|
||||||
type processResult struct {
|
type processResult struct {
|
||||||
code int
|
code int
|
||||||
@@ -108,6 +130,13 @@ func newFakeProcess(pid int) *fakeProcess {
|
|||||||
}
|
}
|
||||||
func (p *fakeProcess) PID() int { return p.pid }
|
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) 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) {
|
func (p *fakeProcess) finish(code int, err error) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|||||||
Reference in New Issue
Block a user