From b4e16fa708f6e06f8c3532235d9c62bcd74ed9a0 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 25 Jul 2026 17:10:20 +0800 Subject: [PATCH] feat: toggle managed instance start and stop --- cmd/chub/main.go | 71 ++++++++++++++++ cmd/chub/main_test.go | 54 ++++++++++++ docs/06-tasks.md | 2 +- docs/current-state.md | 6 +- docs/tasks/T-305.md | 8 +- internal/ui/shell.go | 167 ++++++++++++++++++++++++++++++++++++-- internal/ui/shell_test.go | 98 ++++++++++++++++++++++ 7 files changed, 390 insertions(+), 16 deletions(-) diff --git a/cmd/chub/main.go b/cmd/chub/main.go index 16697a4..2008b7d 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -121,6 +121,7 @@ func runWindow(logger *slog.Logger) { portAllocator: cdp, proxyResolver: proxies, }.Start, window.Invalidate) + shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher}.Stop, window.Invalidate) shell.OnRefreshInstances(instanceStatusRefresher{ managedProfiles: launcher, externalProfiles: externalProfiles, @@ -263,6 +264,76 @@ type managedProfileInspector interface { InspectProfile(context.Context, string) (browser.ProfileUse, error) } +type profileStopper interface { + StopProfile(context.Context, string, bool) error +} + +// instanceStopper is deliberately scoped to Chub's in-memory registry. It +// requests graceful exit only, then waits for that registry entry to disappear; +// it never enumerates or terminates browsers by process name. +type instanceStopper struct { + launcher profileStopper + managedProfiles managedProfileInspector + waitTimeout time.Duration + waitInterval time.Duration +} + +func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow) error { + if row.Status != "运行中" && row.Status != "运行中(调试不可用)" { + return errors.New("实例当前不是可停止的 Chub 托管状态") + } + if row.OccupancySource != "" && row.OccupancySource != "chub_registry" { + return domain.ErrIdentityMismatch + } + if s.launcher == nil || s.managedProfiles == nil { + return errors.New("浏览器停止服务尚未准备好") + } + use, err := s.managedProfiles.InspectProfile(ctx, row.UserDataDir) + if err != nil { + return fmt.Errorf("检查 Chub 实例身份失败:%w", err) + } + if !use.Occupied { + return domain.ErrInstanceNotFound + } + if use.Source != "chub_registry" || (row.PID > 0 && use.PID > 0 && row.PID != use.PID) { + return domain.ErrIdentityMismatch + } + if err := s.launcher.StopProfile(ctx, row.UserDataDir, false); err != nil { + return err + } + timeout := s.waitTimeout + if timeout <= 0 { + timeout = 8 * time.Second + } + interval := s.waitInterval + if interval <= 0 { + interval = 100 * time.Millisecond + } + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + for { + use, err = s.managedProfiles.InspectProfile(waitCtx, row.UserDataDir) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return errors.New("浏览器未在等待时间内退出;可稍后刷新状态") + } + return fmt.Errorf("检查浏览器退出状态失败:%w", err) + } + if !use.Occupied { + return nil + } + timer := time.NewTimer(interval) + select { + case <-waitCtx.Done(): + if !timer.Stop() { + <-timer.C + } + return errors.New("浏览器未在等待时间内退出;可稍后刷新状态") + case <-timer.C: + } + } +} + type instanceStarter struct { launcher processLauncher resolver executableResolver diff --git a/cmd/chub/main_test.go b/cmd/chub/main_test.go index 3762221..b6c656f 100644 --- a/cmd/chub/main_test.go +++ b/cmd/chub/main_test.go @@ -6,6 +6,7 @@ import ( "errors" "strings" "testing" + "time" "chub/internal/domain" "chub/internal/platform/browser" @@ -54,6 +55,37 @@ func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) { } } +func TestInstanceStopperGracefullyStopsOnlyVerifiedManagedProfile(t *testing.T) { + managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}, releaseOnStop: true} + stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: time.Second, waitInterval: time.Millisecond} + err := stopper.Stop(context.Background(), ui.InstanceRow{ID: "managed", Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}) + if err != nil || managed.stopCalls != 1 || managed.force || managed.userDataDir != `C:\profiles\managed` { + t.Fatalf("Stop() = %v, calls=%d force=%v dir=%q", err, managed.stopCalls, managed.force, managed.userDataDir) + } +} + +func TestInstanceStopperRejectsMismatchedOrExternalProfiles(t *testing.T) { + managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4243, Source: "chub_registry"}} + stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: 10 * time.Millisecond, waitInterval: time.Millisecond} + err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}) + if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 { + t.Fatalf("mismatch Stop() = %v, calls=%d", err, managed.stopCalls) + } + err = stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4243, OccupancySource: "browser_message_window", UserDataDir: `C:\profiles\managed`}) + if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 { + t.Fatalf("external Stop() = %v, calls=%d", err, managed.stopCalls) + } +} + +func TestInstanceStopperDoesNotEscalateWhenGracefulCloseTimesOut(t *testing.T) { + managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}} + stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: 10 * time.Millisecond, waitInterval: time.Millisecond} + err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}) + if err == nil || managed.stopCalls != 1 || managed.force { + t.Fatalf("timeout Stop() = %v, calls=%d force=%v", err, managed.stopCalls, managed.force) + } +} + func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *testing.T) { launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}} @@ -187,6 +219,28 @@ func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (br return i.use, nil } +type fakeManagedStopper struct { + use browser.ProfileUse + stopCalls int + force bool + userDataDir string + releaseOnStop bool +} + +func (s *fakeManagedStopper) InspectProfile(context.Context, string) (browser.ProfileUse, error) { + return s.use, nil +} + +func (s *fakeManagedStopper) StopProfile(_ context.Context, userDataDir string, force bool) error { + s.stopCalls++ + s.force = force + s.userDataDir = userDataDir + if s.releaseOnStop { + s.use.Occupied = false + } + return nil +} + type fakeExternalProfileInspector struct { use browser.ProfileUse err error diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 5591a65..3fe59b3 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -40,7 +40,7 @@ | T-302 | 本地 CDP 启动、外部关联与实例端口展示 | T-102,T-301 | DONE | | T-303 | 实例状态刷新与编辑对话框 | T-302 | DONE | | T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE | -| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DOING | +| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE | ## Backlog diff --git a/docs/current-state.md b/docs/current-state.md index dc2e6cb..52009ba 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,11 +3,11 @@ ## 快照 - 日期:2026-07-25 -- 阶段:Phase 3 真实实例操作(T-305 进行中;T-304、T-303、T-302、T-301 已完成;T-201 至 T-208 已完成) +- 阶段:Phase 3 真实实例操作(T-305、T-304、T-303、T-302、T-301 已完成;T-201 至 T-208 已完成) - 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、loopback CDP 端口分配与端点校验、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约、应用内事件总线、UI 状态测试和 Windows smoke 脚本,T-001 至 T-003、T-101 至 T-104、T-201 至 T-208、T-301 至 T-303 已完成 -- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑编辑/启动/删除图标,并以外层边框和表头分隔组织。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页可维护名称加无认证端点的代理库,删除仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter +- UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和紧凑编辑/启动/停止/删除图标,并以外层边框和表头分隔组织。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页可维护名称加无认证端点的代理库,删除仍被实例引用的代理会被拒绝。启动通过异步回调接到 Windows 浏览器启动器:未占用目录从已保存的起始端口(空值默认 9666)选择 loopback CDP 端口,并在启动时按已选代理 ID 解析最新的 `--proxy-server` 参数;同目录外部浏览器只有在 `DevToolsActivePort` 与 CDP 端点可验证时才显示“外部已关联”,且不会接管其生命周期。删除使用确认弹层,只删除 Chub 实例配置而不删除 User Data Dir。新建、编辑和删除实例会保存到本地配置;四个设置路径有独立异步可取消搜索,切换页面后输入和任务状态保留;新建实例表单使用 Label、Windows 原生目录选择器和 Chrome/Edge RadioButton;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;下一步完成已注册实例的 GUI 优雅停止、停止结果等待和启停图标状态回传(T-305)。 +- blocker:无;Phase 3 的当前 GUI 启动、代理、刷新、编辑、删除和受管实例优雅停止已闭环。下一步可按 Backlog 决策 CLI BrowserManager adapter、重启/强制关闭确认或 CDP tab/window 管理。 ## 当前目录 diff --git a/docs/tasks/T-305.md b/docs/tasks/T-305.md index 04dc802..cbc471f 100644 --- a/docs/tasks/T-305.md +++ b/docs/tasks/T-305.md @@ -3,7 +3,7 @@ id: T-305 title: GUI 受管实例优雅停止与启停状态机 phase: 3 deps: [T-304] -status: DOING +status: DONE created: 2026-07-25 owner: codex --- @@ -29,7 +29,7 @@ owner: codex ## 执行记录 -- 状态:DOING -- 变更:待实现。 -- 验证:待执行。 +- 状态:DONE +- 变更:实例主操作现在按状态显示启动、停止、重新检测或禁用图标。受管运行/调试不可用实例点击停止后进入“停止中”,后台 adapter 先检查 Chub registry 来源和 PID,再调用 `StopProfile(userDataDir, false)`,等待 registry 释放后清空 PID/端口并恢复启动图标。超时、身份不匹配或权限失败保持原运行状态并给出反馈;外部关联、外部占用和未知占用只允许重新检测。停止期间拒绝重复启停和删除。 +- 验证:`gofmt -w cmd/chub/main.go internal/ui/shell.go cmd/chub/main_test.go internal/ui/shell_test.go`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub`、`scripts/smoke-browser.ps1`(真实 Chrome/Edge)和 `scripts/smoke-windows.ps1` 均通过。 - 阻塞:无。 diff --git a/internal/ui/shell.go b/internal/ui/shell.go index c181cbb..e09af60 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -41,6 +41,7 @@ const ( var ( instanceStartIcon = mustIcon(icons.AVPlayArrow) + instanceStopIcon = mustIcon(icons.AVStop) instanceEditIcon = mustIcon(icons.EditorModeEdit) instanceDeleteIcon = mustIcon(icons.ActionDelete) ) @@ -76,6 +77,10 @@ type InstanceStartOutcome struct { type InstanceStarter func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error) +// InstanceStopper requests graceful shutdown only for the supplied configured +// instance. Implementations must reject external or unverified processes. +type InstanceStopper func(context.Context, InstanceRow) error + // InstanceRefreshResult carries a read-only status check for one configured // instance. The UI applies it only while the row's editable fields still match // the snapshot passed to the refresher. @@ -105,6 +110,18 @@ type instanceStartResult struct { err error } +type instanceStopState struct { + request uint64 + running bool + previousStatus string +} + +type instanceStopResult struct { + id string + request uint64 + err error +} + type instanceRefreshState struct { request uint64 running bool @@ -268,6 +285,8 @@ type Shell struct { deleteBlocker widget.Clickable startStates map[string]*instanceStartState startResults chan instanceStartResult + stopStates map[string]*instanceStopState + stopResults chan instanceStopResult refreshClickState instanceRefreshState refreshResults chan instanceRefreshResult nextInstance uint64 @@ -282,6 +301,7 @@ type Shell struct { onInstancesChanged func([]InstanceRow) onProxiesChanged func([]ProxyOption) instanceStarter InstanceStarter + instanceStopper InstanceStopper instanceRefresher InstanceRefresher pathSearcher PathSearcher invalidate func() @@ -303,7 +323,7 @@ func NewShell(theme *material.Theme) *Shell { PathEdgeExecutable: {}, PathDefaultUserData: {}, PathLogDirectory: {}, - }, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyEditClicks: make(map[string]*widget.Clickable), proxyDeleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} + }, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyEditClicks: make(map[string]*widget.Clickable), proxyDeleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)} s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`) s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) s.dataDir.SetText(`C:\Users\Public\chub\profiles`) @@ -374,6 +394,11 @@ func (s *Shell) OnStartInstance(starter InstanceStarter, invalidate func()) { s.invalidate = invalidate } +func (s *Shell) OnStopInstance(stopper InstanceStopper, invalidate func()) { + s.instanceStopper = stopper + s.invalidate = invalidate +} + func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) { s.instanceRefresher = refresher s.invalidate = invalidate @@ -393,6 +418,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { s.consumeSearchResults() s.consumeDirectoryResults() s.consumeStartResults() + s.consumeStopResults() s.consumeRefreshResults() s.consumeKeyboard(gtx) if s.pendingDeleteID != "" { @@ -476,7 +502,7 @@ func (s *Shell) consumeControls(gtx layout.Context) { } } for s.startClickFor(row.ID).Clicked(gtx) { - s.requestStart(row.ID) + s.requestInstanceAction(row.ID) } for s.editClickFor(row.ID).Clicked(gtx) { s.beginEdit(row.ID) @@ -592,7 +618,7 @@ func (s *Shell) beginEdit(id string) { func (s *Shell) editLocked(row InstanceRow) bool { switch row.Status { - case "启动中", "运行中", "运行中(调试不可用)", "外部已关联": + case "启动中", "停止中", "运行中", "运行中(调试不可用)", "外部已关联": return true default: return false @@ -874,14 +900,16 @@ func remoteDebugPortText(port int) string { func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - actionLabel := "启动 " + row.Name - if row.Status == "外部已关联" { - actionLabel = "重新检测 " + row.Name + mode := instanceActionFor(row) + actionLabel := mode.label + " " + row.Name + actionIcon := instanceStartIcon + if mode.stop { + actionIcon = instanceStopIcon } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)), layout.Rigid(layout.Spacer{Width: unit.Dp(2)}.Layout), - layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), instanceStartIcon, actionLabel, s.theme.Palette.ContrastBg)), + layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)), layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})), ) @@ -889,8 +917,32 @@ func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget { } } -func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA) layout.Widget { +type instanceActionMode struct { + label string + stop bool + enabled bool +} + +func instanceActionFor(row InstanceRow) instanceActionMode { + switch row.Status { + case "运行中", "运行中(调试不可用)": + return instanceActionMode{label: "停止", stop: true, enabled: true} + case "启动中": + return instanceActionMode{label: "启动中", enabled: false} + case "停止中": + return instanceActionMode{label: "停止中", stop: true, enabled: false} + case "外部已关联", "外部占用", "未知占用": + return instanceActionMode{label: "重新检测", enabled: true} + default: + return instanceActionMode{label: "启动", enabled: true} + } +} + +func (s *Shell) instanceIconButton(click *widget.Clickable, icon *widget.Icon, description string, iconColor color.NRGBA, enabled ...bool) layout.Widget { return func(gtx layout.Context) layout.Dimensions { + if len(enabled) > 0 && !enabled[0] { + gtx = gtx.Disabled() + } style := material.IconButton(s.theme, click, icon, description) style.Size = unit.Dp(16) style.Inset = layout.UniformInset(unit.Dp(5)) @@ -1136,6 +1188,7 @@ func statusLabel(theme *material.Theme, status string) layout.Widget { if color, ok := map[string]color.NRGBA{ "运行中": {R: 24, G: 125, B: 78, A: 255}, "启动中": {R: 175, G: 105, B: 0, A: 255}, + "停止中": {R: 175, G: 105, B: 0, A: 255}, "启动失败": {R: 188, G: 51, B: 51, A: 255}, "运行中(调试不可用)": {R: 175, G: 105, B: 0, A: 255}, "外部已关联": {R: 146, G: 93, B: 0, A: 255}, @@ -1791,6 +1844,66 @@ func (s *Shell) notifyProxiesChanged() { } } +func (s *Shell) requestInstanceAction(id string) { + row, ok := s.instanceRow(id) + if !ok { + s.instanceFeedback = "找不到要操作的实例。" + return + } + mode := instanceActionFor(row) + if !mode.enabled { + s.instanceFeedback = fmt.Sprintf("%s%s,请稍候。", row.Name, mode.label) + return + } + if mode.stop { + s.requestStop(id) + return + } + s.requestStart(id) +} + +func (s *Shell) requestStop(id string) { + row, ok := s.instanceRow(id) + if !ok { + s.instanceFeedback = "找不到要停止的实例。" + return + } + if row.Status != "运行中" && row.Status != "运行中(调试不可用)" { + s.instanceFeedback = fmt.Sprintf("%s 当前不能由 Chub 停止。", row.Name) + return + } + if row.OccupancySource != "" && row.OccupancySource != "chub_registry" { + s.instanceFeedback = fmt.Sprintf("%s 不是 Chub 托管实例,不能停止。", row.Name) + return + } + state := s.stopStates[id] + if state != nil && state.running { + s.instanceFeedback = fmt.Sprintf("%s 正在停止,请稍候。", row.Name) + return + } + if s.instanceStopper == nil { + s.instanceFeedback = "浏览器停止服务尚未准备好。" + return + } + if state == nil { + state = &instanceStopState{} + s.stopStates[id] = state + } + state.request++ + request := state.request + state.running = true + state.previousStatus = row.Status + s.setInstanceStatus(id, "停止中") + s.instanceFeedback = fmt.Sprintf("正在请求 %s 正常退出…", row.Name) + go func() { + err := s.instanceStopper(context.Background(), row) + s.stopResults <- instanceStopResult{id: id, request: request, err: err} + if s.invalidate != nil { + s.invalidate() + } + }() +} + func (s *Shell) requestStart(id string) { row, ok := s.instanceRow(id) if !ok { @@ -1802,6 +1915,10 @@ func (s *Shell) requestStart(id string) { s.instanceFeedback = fmt.Sprintf("%s 正在启动,请稍候。", row.Name) return } + if stop := s.stopStates[id]; stop != nil && stop.running { + s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name) + return + } if s.instanceStarter == nil { s.instanceFeedback = "浏览器启动服务尚未准备好。" return @@ -1829,6 +1946,35 @@ func (s *Shell) requestStart(id string) { }() } +func (s *Shell) consumeStopResults() { + for { + select { + case result := <-s.stopResults: + state := s.stopStates[result.id] + if state == nil || !state.running || result.request != state.request { + continue + } + state.running = false + row, exists := s.instanceRow(result.id) + if !exists { + continue + } + if result.err != nil { + if row.Status == "停止中" { + s.setInstanceRuntime(result.id, state.previousStatus, row.PID, row.RemoteDebugPort, row.OccupancySource) + } + s.instanceFeedback = fmt.Sprintf("停止 %s 失败:%v", row.Name, result.err) + continue + } + s.setInstanceRuntime(result.id, "已退出", 0, 0, "") + s.focusRestoreID = result.id + s.instanceFeedback = fmt.Sprintf("%s 已正常退出。", row.Name) + default: + return + } + } +} + func (s *Shell) consumeStartResults() { for { select { @@ -1964,6 +2110,10 @@ func (s *Shell) requestDelete(id string) { s.instanceFeedback = fmt.Sprintf("%s 正在启动,暂时不能删除。", row.Name) return } + if state := s.stopStates[id]; state != nil && state.running { + s.instanceFeedback = fmt.Sprintf("%s 正在停止,暂时不能删除。", row.Name) + return + } s.pendingDeleteID = id } @@ -2002,6 +2152,7 @@ func (s *Shell) deleteInstance(id string) { delete(s.editClicks, id) delete(s.deleteClicks, id) delete(s.startStates, id) + delete(s.stopStates, id) s.instanceFeedback = fmt.Sprintf("已删除实例“%s”;其 User Data Dir 未被删除。", row.Name) s.notifyInstancesChanged() return diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index 6ed69ef..a19dd43 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -2,6 +2,7 @@ package ui import ( "context" + "errors" "path/filepath" "strings" "sync/atomic" @@ -191,6 +192,103 @@ func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) { } } +func TestShellStopsManagedInstanceAsynchronouslyAndRestoresStartAction(t *testing.T) { + shell := NewShell(material.NewTheme()) + target := shell.rows[0] + stopped := make(chan InstanceRow, 1) + shell.OnStopInstance(func(_ context.Context, row InstanceRow) error { + stopped <- row + return nil + }, nil) + + shell.requestInstanceAction(target.ID) + if row, ok := shell.instanceRow(target.ID); !ok || row.Status != "停止中" { + t.Fatalf("stop status = %#v, want 停止中", row) + } + select { + case got := <-stopped: + if got.ID != target.ID || got.Status != "运行中" { + t.Fatalf("stopped row = %#v", got) + } + case <-time.After(time.Second): + t.Fatal("stop callback was not invoked") + } + var result instanceStopResult + select { + case result = <-shell.stopResults: + case <-time.After(time.Second): + t.Fatal("stop result was not produced") + } + shell.stopResults <- result + shell.consumeStopResults() + row, ok := shell.instanceRow(target.ID) + if !ok || row.Status != "已退出" || row.PID != 0 || row.RemoteDebugPort != 0 || instanceActionFor(row).stop { + t.Fatalf("completed stop row = %#v", row) + } +} + +func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) { + shell := NewShell(material.NewTheme()) + target := shell.rows[0] + entered := make(chan struct{}, 1) + release := make(chan struct{}) + var calls atomic.Int32 + shell.OnStopInstance(func(context.Context, InstanceRow) error { + calls.Add(1) + entered <- struct{}{} + <-release + return nil + }, nil) + shell.requestInstanceAction(target.ID) + shell.requestInstanceAction(target.ID) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("stop callback was not invoked") + } + if calls.Load() != 1 { + t.Fatalf("stop calls = %d, want 1", calls.Load()) + } + close(release) + select { + case result := <-shell.stopResults: + shell.stopResults <- result + shell.consumeStopResults() + case <-time.After(time.Second): + t.Fatal("stop result was not produced") + } + + external := shell.rows[2] + var externalStops atomic.Int32 + shell.OnStopInstance(func(context.Context, InstanceRow) error { externalStops.Add(1); return nil }, nil) + shell.OnStartInstance(func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error) { + return InstanceStartOutcome{External: true, Source: "browser_message_window"}, nil + }, nil) + shell.requestInstanceAction(external.ID) + if externalStops.Load() != 0 { + t.Fatal("external association used stop callback") + } +} + +func TestShellRestoresManagedStatusAfterStopFailure(t *testing.T) { + shell := NewShell(material.NewTheme()) + target := shell.rows[0] + shell.OnStopInstance(func(context.Context, InstanceRow) error { return errors.New("permission denied") }, nil) + shell.requestInstanceAction(target.ID) + var result instanceStopResult + select { + case result = <-shell.stopResults: + case <-time.After(time.Second): + t.Fatal("stop result was not produced") + } + shell.stopResults <- result + shell.consumeStopResults() + row, ok := shell.instanceRow(target.ID) + if !ok || row.Status != "运行中" || !strings.Contains(shell.instanceFeedback, "失败") { + t.Fatalf("failed stop row = %#v, feedback=%q", row, shell.instanceFeedback) + } +} + func TestShellCancelsOnlyTheActivePathSearch(t *testing.T) { shell := NewShell(material.NewTheme()) started := make(chan struct{}, 1)