From 23be05210b1dbb3c15c232414ef8b108cb6e1001 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 22 Jul 2026 14:57:27 +0800 Subject: [PATCH] feat: add browser launcher registry --- docs/06-tasks.md | 2 +- docs/current-state.md | 4 +- docs/tasks/T-101.md | 37 ++++ internal/platform/browser/launcher.go | 190 +++++++++++++++++++++ internal/platform/browser/launcher_test.go | 120 +++++++++++++ 5 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 docs/tasks/T-101.md create mode 100644 internal/platform/browser/launcher.go create mode 100644 internal/platform/browser/launcher_test.go diff --git a/docs/06-tasks.md b/docs/06-tasks.md index a79a034..c00e01f 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -14,7 +14,7 @@ | ID | 任务 | 依赖 | 状态 | | --- | --- | --- | --- | -| T-101 | 实现启动、registry、Wait 和 profile 占用检测 | T-003 | TODO | +| T-101 | 实现启动、registry、Wait 和 profile 占用检测 | T-003 | DONE | | T-102 | 实现 Windows 进程身份查询和外部实例保守识别 | T-101 | TODO | | T-103 | 实现优雅关闭、强制关闭和 Job Object 进程树 | T-102 | TODO | | T-104 | Chrome/Edge 双实例、异常退出和权限失败 smoke | T-103 | TODO | diff --git a/docs/current-state.md b/docs/current-state.md index 4dfbfe4..d261ba7 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 参数/发现模块,T-001 至 T-003 已完成 +- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块和启动 registry,T-001 至 T-003、T-101 已完成 - UI:尚未实现;P0 页面需要先制作 HTML 原型 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;下一个任务为 T-101 +- blocker:无;下一个任务为 T-102 ## 当前目录 diff --git a/docs/tasks/T-101.md b/docs/tasks/T-101.md new file mode 100644 index 0000000..f7cad98 --- /dev/null +++ b/docs/tasks/T-101.md @@ -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 处理。 diff --git a/internal/platform/browser/launcher.go b/internal/platform/browser/launcher.go new file mode 100644 index 0000000..3a4d814 --- /dev/null +++ b/internal/platform/browser/launcher.go @@ -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 +} diff --git a/internal/platform/browser/launcher_test.go b/internal/platform/browser/launcher_test.go new file mode 100644 index 0000000..e175f46 --- /dev/null +++ b/internal/platform/browser/launcher_test.go @@ -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 }