From 1ce02bcf3b852ab2fe71854d9a8ed8c07c02a5de Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 22 Jul 2026 15:03:21 +0800 Subject: [PATCH] feat: add scoped browser stop and job control --- docs/06-tasks.md | 2 +- docs/api.md | 2 + docs/current-state.md | 4 +- docs/tasks/T-103.md | 37 ++++++ internal/platform/browser/job_other.go | 24 ++++ internal/platform/browser/job_windows.go | 126 +++++++++++++++++++++ internal/platform/browser/launcher.go | 44 +++++-- internal/platform/browser/launcher_test.go | 37 +++++- 8 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 docs/tasks/T-103.md create mode 100644 internal/platform/browser/job_other.go create mode 100644 internal/platform/browser/job_windows.go diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 0358ee3..5940133 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -16,7 +16,7 @@ | --- | --- | --- | --- | | T-101 | 实现启动、registry、Wait 和 profile 占用检测 | T-003 | 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 | ## Phase 2:产品外壳 diff --git a/docs/api.md b/docs/api.md index 9e811e2..c35a967 100644 --- a/docs/api.md +++ b/docs/api.md @@ -20,6 +20,8 @@ type BrowserManager interface { `Stop(force=false)` 只执行优雅关闭;强制关闭必须由明确用户动作或 CLI `--force` 触发。 +平台实现通过实例级 `StopProfile(userDataDir, force)` 执行关闭;force 只对已注册并经过身份绑定的实例生效。 + 参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。 ## CLI 目标形状 diff --git a/docs/current-state.md b/docs/current-state.md index cceb0f9..49c0931 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,10 +4,10 @@ - 日期: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 原型 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;下一个任务为 T-103 +- blocker:无;下一个任务为 T-104 ## 当前目录 diff --git a/docs/tasks/T-103.md b/docs/tasks/T-103.md new file mode 100644 index 0000000..b057664 --- /dev/null +++ b/docs/tasks/T-103.md @@ -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 验证。 diff --git a/internal/platform/browser/job_other.go b/internal/platform/browser/job_other.go new file mode 100644 index 0000000..5719087 --- /dev/null +++ b/internal/platform/browser/job_other.go @@ -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() +} diff --git a/internal/platform/browser/job_windows.go b/internal/platform/browser/job_windows.go new file mode 100644 index 0000000..580adca --- /dev/null +++ b/internal/platform/browser/job_windows.go @@ -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 +} diff --git a/internal/platform/browser/launcher.go b/internal/platform/browser/launcher.go index 3a4d814..5e83f8b 100644 --- a/internal/platform/browser/launcher.go +++ b/internal/platform/browser/launcher.go @@ -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 diff --git a/internal/platform/browser/launcher_test.go b/internal/platform/browser/launcher_test.go index e175f46..0ebd75c 100644 --- a/internal/platform/browser/launcher_test.go +++ b/internal/platform/browser/launcher_test.go @@ -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()