feat: add browser launcher registry
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
| ID | 任务 | 依赖 | 状态 |
|
| ID | 任务 | 依赖 | 状态 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| T-101 | 实现启动、registry、Wait 和 profile 占用检测 | T-003 | TODO |
|
| T-101 | 实现启动、registry、Wait 和 profile 占用检测 | T-003 | DONE |
|
||||||
| T-102 | 实现 Windows 进程身份查询和外部实例保守识别 | T-101 | TODO |
|
| T-102 | 实现 Windows 进程身份查询和外部实例保守识别 | T-101 | TODO |
|
||||||
| T-103 | 实现优雅关闭、强制关闭和 Job Object 进程树 | T-102 | TODO |
|
| T-103 | 实现优雅关闭、强制关闭和 Job Object 进程树 | T-102 | TODO |
|
||||||
| T-104 | Chrome/Edge 双实例、异常退出和权限失败 smoke | T-103 | TODO |
|
| T-104 | Chrome/Edge 双实例、异常退出和权限失败 smoke | T-103 | TODO |
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
- 日期:2026-07-22
|
- 日期:2026-07-22
|
||||||
- 阶段:文档与交互原型准备
|
- 阶段:文档与交互原型准备
|
||||||
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约和 Chrome/Edge 参数/发现模块,T-001 至 T-003 已完成
|
- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块和启动 registry,T-001 至 T-003、T-101 已完成
|
||||||
- UI:尚未实现;P0 页面需要先制作 HTML 原型
|
- UI:尚未实现;P0 页面需要先制作 HTML 原型
|
||||||
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
|
||||||
- blocker:无;下一个任务为 T-101
|
- blocker:无;下一个任务为 T-102
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
id: T-101
|
||||||
|
title: 实现启动、registry、Wait 和 profile 占用检测
|
||||||
|
phase: 1
|
||||||
|
deps: [T-003]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-07-22
|
||||||
|
owner: codex
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需求与背景
|
||||||
|
|
||||||
|
Chub 需要在启动真实 Windows 进程前建立本进程实例 registry,防止并发启动相同 user data dir,并在浏览器退出后及时释放占用。
|
||||||
|
|
||||||
|
## 方案与边界
|
||||||
|
|
||||||
|
- `internal/platform/browser/Launcher` 通过 `CommandRunner` 启动参数数组。
|
||||||
|
- profile registry 以规范化 user data dir 为 key,支持 pending reservation 和重复启动错误。
|
||||||
|
- `ProcessHandle.Wait` 支持 context 取消观察,但取消不会终止浏览器。
|
||||||
|
- 进程退出后后台 monitor 释放 registry;`InspectProfile` 只报告本进程 registry。
|
||||||
|
- 本任务不识别外部进程、不强制关闭、不管理 Job Object。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 两个不同 profile 可并行启动。
|
||||||
|
- 同 profile 第二次启动返回 `ErrProfileOccupied` 并包含 PID。
|
||||||
|
- 进程退出后 profile 可再次启动。
|
||||||
|
- Wait 取消不会终止进程。
|
||||||
|
- `go test ./...`、`go vet ./...` 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 变更:新增可注入 CommandRunner、OS runner、profile registry、tracked process 和 fake runner 回归测试。
|
||||||
|
- 验证:`gofmt`、`go test ./...`、`go vet ./...` 均通过。
|
||||||
|
- 阻塞:无。
|
||||||
|
- 残余风险:registry 只代表 Chub 当前进程;外部 profile 占用需 T-102 处理。
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"chub/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLauncherSeparatesProfilesBlocksDuplicatesAndReleasesAfterExit(t *testing.T) {
|
||||||
|
runner := &fakeRunner{}
|
||||||
|
launcher := NewLauncher(runner)
|
||||||
|
firstProfile := filepath.Join(t.TempDir(), "one")
|
||||||
|
secondProfile := filepath.Join(t.TempDir(), "two")
|
||||||
|
first, err := launcher.Start(context.Background(), launchSpec(firstProfile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Start() error = %v", err)
|
||||||
|
}
|
||||||
|
second, err := launcher.Start(context.Background(), launchSpec(secondProfile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Start() error = %v", err)
|
||||||
|
}
|
||||||
|
if first.PID() == second.PID() {
|
||||||
|
t.Fatalf("separate profiles shared PID %d", first.PID())
|
||||||
|
}
|
||||||
|
if _, err := launcher.Start(context.Background(), launchSpec(firstProfile)); !errors.Is(err, domain.ErrProfileOccupied) {
|
||||||
|
t.Fatalf("duplicate Start() error = %v", err)
|
||||||
|
}
|
||||||
|
use, err := launcher.InspectProfile(context.Background(), firstProfile)
|
||||||
|
if err != nil || !use.Occupied || use.PID != first.PID() || use.Source != "chub_registry" {
|
||||||
|
t.Fatalf("profile use = %+v, error = %v", use, err)
|
||||||
|
}
|
||||||
|
runner.processAt(0).finish(0, nil)
|
||||||
|
if code, err := first.Wait(context.Background()); err != nil || code != 0 {
|
||||||
|
t.Fatalf("Wait() = %d, %v", code, err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
use, err = launcher.InspectProfile(context.Background(), firstProfile)
|
||||||
|
if err == nil && !use.Occupied {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if use.Occupied {
|
||||||
|
t.Fatalf("profile remained occupied after exit: %+v", use)
|
||||||
|
}
|
||||||
|
runner.processAt(1).finish(0, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessWaitCancellationDoesNotTerminateBrowser(t *testing.T) {
|
||||||
|
runner := &fakeRunner{}
|
||||||
|
launcher := NewLauncher(runner)
|
||||||
|
handle, err := launcher.Start(context.Background(), launchSpec(filepath.Join(t.TempDir(), "profile")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Start() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := handle.Wait(ctx); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("cancelled Wait() error = %v", err)
|
||||||
|
}
|
||||||
|
if runner.processAt(0).wasFinished() {
|
||||||
|
t.Fatal("cancelled Wait terminated browser")
|
||||||
|
}
|
||||||
|
runner.processAt(0).finish(23, nil)
|
||||||
|
if code, err := handle.Wait(context.Background()); err != nil || code != 23 {
|
||||||
|
t.Fatalf("completed Wait() = %d, %v", code, 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"}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
processes []*fakeProcess
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRunner) Start(_ string, _ []string) (RunnerProcess, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
p := newFakeProcess(4100 + len(r.processes))
|
||||||
|
r.processes = append(r.processes, p)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
func (r *fakeRunner) processAt(index int) *fakeProcess {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.processes[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeProcess struct {
|
||||||
|
pid int
|
||||||
|
done chan processResult
|
||||||
|
mu sync.Mutex
|
||||||
|
finished bool
|
||||||
|
}
|
||||||
|
type processResult struct {
|
||||||
|
code int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeProcess(pid int) *fakeProcess {
|
||||||
|
return &fakeProcess{pid: pid, done: make(chan processResult, 1)}
|
||||||
|
}
|
||||||
|
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) finish(code int, err error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.finished {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.finished = true
|
||||||
|
p.done <- processResult{code: code, err: err}
|
||||||
|
}
|
||||||
|
func (p *fakeProcess) wasFinished() bool { p.mu.Lock(); defer p.mu.Unlock(); return p.finished }
|
||||||
Reference in New Issue
Block a user