From eeaf4c78265756d38468b82218ec6e712fd764d3 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 25 Jul 2026 18:23:33 +0800 Subject: [PATCH] feat: notify when managed browsers exit --- cmd/chub/main.go | 21 +- cmd/chub/main_test.go | 108 +++++++++- cmd/chub/managed_exit_monitor.go | 114 ++++++++++ docs/06-tasks.md | 2 +- docs/current-state.md | 7 +- docs/tasks/T-307.md | 8 +- internal/domain/browser.go | 16 +- internal/ui/shell.go | 354 ++++++++++++++++++++++++++----- internal/ui/shell_test.go | 109 +++++++++- 9 files changed, 647 insertions(+), 92 deletions(-) create mode 100644 cmd/chub/managed_exit_monitor.go diff --git a/cmd/chub/main.go b/cmd/chub/main.go index 2008b7d..4f52444 100644 --- a/cmd/chub/main.go +++ b/cmd/chub/main.go @@ -112,6 +112,8 @@ func runWindow(logger *slog.Logger) { cdp := browser.NewCDPInspector() externalProfiles := browser.NewWindowsProfileInspector() proxies := newProxyDirectory() + exitMonitor := newManagedExitMonitor(shell.ReportManagedExit, window.Invalidate) + defer exitMonitor.Close() shell.OnStartInstance(instanceStarter{ launcher: launcher, resolver: discoverer, @@ -120,8 +122,9 @@ func runWindow(logger *slog.Logger) { remoteDebug: cdp, portAllocator: cdp, proxyResolver: proxies, + monitor: exitMonitor, }.Start, window.Invalidate) - shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher}.Stop, window.Invalidate) + shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher, monitor: exitMonitor}.Stop, window.Invalidate) shell.OnRefreshInstances(instanceStatusRefresher{ managedProfiles: launcher, externalProfiles: externalProfiles, @@ -274,11 +277,12 @@ type profileStopper interface { type instanceStopper struct { launcher profileStopper managedProfiles managedProfileInspector + monitor managedExitObserver waitTimeout time.Duration waitInterval time.Duration } -func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow) error { +func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow, launchGeneration uint64) error { if row.Status != "运行中" && row.Status != "运行中(调试不可用)" { return errors.New("实例当前不是可停止的 Chub 托管状态") } @@ -298,7 +302,14 @@ func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow) error { if use.Source != "chub_registry" || (row.PID > 0 && use.PID > 0 && row.PID != use.PID) { return domain.ErrIdentityMismatch } + expected := false + if s.monitor != nil { + expected = s.monitor.ExpectStop(row.ID, launchGeneration, row.PID) + } if err := s.launcher.StopProfile(ctx, row.UserDataDir, false); err != nil { + if expected { + s.monitor.ClearExpectedStop(row.ID, launchGeneration, row.PID) + } return err } timeout := s.waitTimeout @@ -342,6 +353,7 @@ type instanceStarter struct { remoteDebug browser.RemoteDebugEndpointInspector portAllocator browser.RemoteDebugPortAllocator proxyResolver proxyServerResolver + monitor managedExitObserver } type instanceStatusRefresher struct { @@ -428,7 +440,7 @@ func (s instanceStatusRefresher) refreshOne(ctx context.Context, row ui.Instance return result } -func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState) (ui.InstanceStartOutcome, error) { +func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState, launchGeneration uint64) (ui.InstanceStartOutcome, error) { kind, err := browserKind(row.Browser) if err != nil { return ui.InstanceStartOutcome{}, err @@ -496,6 +508,9 @@ func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings if err != nil { return ui.InstanceStartOutcome{}, err } + if s.monitor != nil { + s.monitor.Watch(row.ID, launchGeneration, handle) + } endpointCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() endpoint, endpointErr := s.remoteDebug.WaitForRemoteDebugPort(endpointCtx, kind, port) diff --git a/cmd/chub/main_test.go b/cmd/chub/main_test.go index b6c656f..d655722 100644 --- a/cmd/chub/main_test.go +++ b/cmd/chub/main_test.go @@ -26,7 +26,7 @@ func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) { resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote} - outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`, RemoteDebugStartPort: 9666}) + outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`, RemoteDebugStartPort: 9666}, 1) if err != nil || outcome.PID != 4242 || outcome.RemoteDebugPort != 9666 { t.Fatalf("Start() = %#v, %v", outcome, err) } @@ -45,33 +45,74 @@ func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) { proxies := newProxyDirectory() proxies.Set([]config.ProxyProfile{{ID: "proxy-sg", Name: "SG", Server: "http://127.0.0.1:8080"}}) starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, proxyResolver: proxies} - _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, ProxyID: "proxy-sg"}, ui.SettingsState{RemoteDebugStartPort: 9666}) + _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, ProxyID: "proxy-sg"}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1) if err != nil || launcher.spec.ProxyServer != "http://127.0.0.1:8080" { t.Fatalf("proxy launch spec = %#v, error = %v", launcher.spec, err) } - _, err = starter.Start(context.Background(), ui.InstanceRow{ID: "missing", Browser: "Chrome", UserDataDir: `C:\profiles\missing`, ProxyID: "gone"}, ui.SettingsState{RemoteDebugStartPort: 9666}) + _, err = starter.Start(context.Background(), ui.InstanceRow{ID: "missing", Browser: "Chrome", UserDataDir: `C:\profiles\missing`, ProxyID: "gone"}, ui.SettingsState{RemoteDebugStartPort: 9666}, 2) if err == nil || launcher.calls != 1 { t.Fatalf("missing proxy error = %v, launch calls = %d", err, launcher.calls) } } +func TestInstanceStarterRegistersOnlyLaunchedProcessWithExitMonitor(t *testing.T) { + launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}} + remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} + monitor := &fakeManagedExitObserver{} + starter := instanceStarter{launcher: launcher, resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, monitor: monitor} + _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 7) + if err != nil || monitor.watchID != "chrome-a" || monitor.watchGeneration != 7 || monitor.watchPID != 4242 { + t.Fatalf("monitor watch = %#v, error = %v", monitor, err) + } +} + 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`}) + err := stopper.Stop(context.Background(), ui.InstanceRow{ID: "managed", Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1) 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 TestInstanceStopperMarksExpectedManagedExit(t *testing.T) { + managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}, releaseOnStop: true} + monitor := &fakeManagedExitObserver{expectReturn: true} + stopper := instanceStopper{launcher: managed, managedProfiles: managed, monitor: monitor, 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`}, 9) + if err != nil || monitor.expectID != "managed" || monitor.expectGeneration != 9 || monitor.expectPID != 4242 || monitor.clearCalls != 0 { + t.Fatalf("expected stop monitor = %#v, error = %v", monitor, err) + } +} + +func TestManagedExitMonitorReportsExpectedExit(t *testing.T) { + results := make(chan ui.ManagedInstanceExit, 1) + monitor := newManagedExitMonitor(func(result ui.ManagedInstanceExit) { results <- result }, nil) + defer monitor.Close() + handle := &controlledProcessHandle{pid: 4242, done: make(chan processWaitResult, 1)} + monitor.Watch("managed", 3, handle) + if !monitor.ExpectStop("managed", 3, 4242) { + t.Fatal("expected monitored process to accept expected stop") + } + handle.done <- processWaitResult{code: 17} + select { + case result := <-results: + if result.InstanceID != "managed" || result.LaunchGeneration != 3 || result.PID != 4242 || result.ExitCode != 17 || !result.ExpectedStop { + t.Fatalf("exit result = %#v", result) + } + case <-time.After(time.Second): + t.Fatal("managed exit monitor did not report process exit") + } +} + 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`}) + err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1) 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`}) + err = stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4243, OccupancySource: "browser_message_window", UserDataDir: `C:\profiles\managed`}, 1) if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 { t.Fatalf("external Stop() = %v, calls=%d", err, managed.stopCalls) } @@ -80,7 +121,7 @@ func TestInstanceStopperRejectsMismatchedOrExternalProfiles(t *testing.T) { 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`}) + err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1) if err == nil || managed.stopCalls != 1 || managed.force { t.Fatalf("timeout Stop() = %v, calls=%d force=%v", err, managed.stopCalls, managed.force) } @@ -97,7 +138,7 @@ func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *tes remoteDebug: remote, portAllocator: remote, } - outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}) + outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1) if err != nil || !outcome.External || outcome.PID != 16108 || outcome.RemoteDebugPort != 9668 || outcome.Source != browser.ProfileSourceMessageWindow { t.Fatalf("Start() = %#v, %v", outcome, err) } @@ -117,7 +158,7 @@ func TestInstanceStarterRejectsUnverifiedExternalProfile(t *testing.T) { remoteDebug: remote, portAllocator: remote, } - _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}) + _, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1) if !errors.Is(err, domain.ErrProfileOccupied) { t.Fatalf("Start() error = %v", err) } @@ -213,6 +254,55 @@ func (p fakeProcessHandle) PID() int { return p.pid } func (fakeProcessHandle) Wait(context.Context) (int, error) { return 0, nil } +type processWaitResult struct { + code int + err error +} + +type controlledProcessHandle struct { + pid int + done chan processWaitResult +} + +func (p *controlledProcessHandle) PID() int { return p.pid } + +func (p *controlledProcessHandle) Wait(ctx context.Context) (int, error) { + select { + case result := <-p.done: + return result.code, result.err + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +type fakeManagedExitObserver struct { + watchID string + watchGeneration uint64 + watchPID int + expectID string + expectGeneration uint64 + expectPID int + expectReturn bool + clearCalls int +} + +func (m *fakeManagedExitObserver) Watch(id string, generation uint64, handle browser.ProcessHandle) { + m.watchID = id + m.watchGeneration = generation + if handle != nil { + m.watchPID = handle.PID() + } +} + +func (m *fakeManagedExitObserver) ExpectStop(id string, generation uint64, pid int) bool { + m.expectID = id + m.expectGeneration = generation + m.expectPID = pid + return m.expectReturn +} + +func (m *fakeManagedExitObserver) ClearExpectedStop(string, uint64, int) { m.clearCalls++ } + type fakeManagedProfileInspector struct{ use browser.ProfileUse } func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (browser.ProfileUse, error) { diff --git a/cmd/chub/managed_exit_monitor.go b/cmd/chub/managed_exit_monitor.go new file mode 100644 index 0000000..6fed2ba --- /dev/null +++ b/cmd/chub/managed_exit_monitor.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "sync" + + "chub/internal/platform/browser" + "chub/internal/ui" +) + +// managedExitObserver keeps lifecycle observation scoped to a browser process +// Chub started in the current application session. It never looks up a process +// by name or attaches to an external browser. +type managedExitObserver interface { + Watch(instanceID string, launchGeneration uint64, handle browser.ProcessHandle) + ExpectStop(instanceID string, launchGeneration uint64, pid int) bool + ClearExpectedStop(instanceID string, launchGeneration uint64, pid int) +} + +type managedExitProcessKey struct { + instanceID string + launchGeneration uint64 + pid int +} + +type managedExitMonitor struct { + ctx context.Context + cancel context.CancelFunc + report func(ui.ManagedInstanceExit) + invalidate func() + + mu sync.Mutex + active map[string]managedExitProcessKey + expected map[managedExitProcessKey]bool +} + +func newManagedExitMonitor(report func(ui.ManagedInstanceExit), invalidate func()) *managedExitMonitor { + ctx, cancel := context.WithCancel(context.Background()) + return &managedExitMonitor{ + ctx: ctx, + cancel: cancel, + report: report, + invalidate: invalidate, + active: make(map[string]managedExitProcessKey), + expected: make(map[managedExitProcessKey]bool), + } +} + +// Close stops notification delivery without stopping browsers. Process waits +// observe this context and return, while the launcher's own registry cleanup +// continues independently. +func (m *managedExitMonitor) Close() { + if m != nil && m.cancel != nil { + m.cancel() + } +} + +func (m *managedExitMonitor) Watch(instanceID string, launchGeneration uint64, handle browser.ProcessHandle) { + if m == nil || handle == nil || instanceID == "" || launchGeneration == 0 || handle.PID() <= 0 { + return + } + key := managedExitProcessKey{instanceID: instanceID, launchGeneration: launchGeneration, pid: handle.PID()} + m.mu.Lock() + if _, alreadyWatching := m.active[instanceID]; alreadyWatching { + m.mu.Unlock() + return + } + m.active[instanceID] = key + m.mu.Unlock() + + go func() { + exitCode, _ := handle.Wait(m.ctx) + if m.ctx.Err() != nil { + return + } + m.mu.Lock() + expected := m.expected[key] + delete(m.expected, key) + if current, ok := m.active[instanceID]; ok && current == key { + delete(m.active, instanceID) + } + m.mu.Unlock() + if m.report != nil { + m.report(ui.ManagedInstanceExit{InstanceID: instanceID, LaunchGeneration: launchGeneration, PID: key.pid, ExitCode: exitCode, ExpectedStop: expected}) + } + if m.invalidate != nil { + m.invalidate() + } + }() +} + +func (m *managedExitMonitor) ExpectStop(instanceID string, launchGeneration uint64, pid int) bool { + if m == nil || instanceID == "" || launchGeneration == 0 || pid <= 0 { + return false + } + key := managedExitProcessKey{instanceID: instanceID, launchGeneration: launchGeneration, pid: pid} + m.mu.Lock() + defer m.mu.Unlock() + if current, ok := m.active[instanceID]; !ok || current != key { + return false + } + m.expected[key] = true + return true +} + +func (m *managedExitMonitor) ClearExpectedStop(instanceID string, launchGeneration uint64, pid int) { + if m == nil { + return + } + key := managedExitProcessKey{instanceID: instanceID, launchGeneration: launchGeneration, pid: pid} + m.mu.Lock() + delete(m.expected, key) + m.mu.Unlock() +} diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 8bafc10..7f132ab 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -42,7 +42,7 @@ | T-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE | | T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE | | T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | DONE | -| T-307 | 受管浏览器意外退出监控与状态提示 | T-101,T-305,T-306 | DOING | +| T-307 | 受管浏览器意外退出监控与状态提示 | T-101,T-305,T-306 | DONE | ## Backlog diff --git a/docs/current-state.md b/docs/current-state.md index edaa809..770aef4 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -3,11 +3,12 @@ ## 快照 - 日期:2026-07-25 -- 阶段:Phase 3 真实实例操作(T-307 进行中;T-301 至 T-306、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 已完成 +- 阶段:Phase 3 真实实例操作(T-301 至 T-307、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-307 已完成 - UI:Gio 双页 Shell 使用左侧“实例/设置”导航;实例页以等宽“新建实例 / 刷新实例状态”命令区开始,窄内容区自动堆叠。列表固定显示实例名称、浏览器类型、用户数据目录、调试端口、状态和带边框的操作列;操作顺序固定为启动/停止(或重新检测)、编辑、删除,删除与相邻操作保持更大间距以降低误点。主操作在已退出时启动,在 Chub 托管运行或调试不可用时显示停止,在启动/停止中禁用,在外部关联、外部占用或未知占用时仅重新检测;优雅停止通过后台 adapter 验证 registry 的 PID/profile 身份并等待其释放,绝不按进程名关闭或接管外部 Chrome/Edge。刷新通过后台回调只检查已保存实例,以配置快照丢弃编辑或删除后的过期结果;它结合 Chub registry、指定 profile 的外部占用证据和 loopback CDP 端点更新状态,但不扫描、接管或关闭其他 Chrome/Edge。双击、Enter 或编辑图标打开实例编辑弹层,支持名称、浏览器类型、User Data Dir、启动 URL、完整地址代理选择和只读实际端口;保存保留未公开启动选项,Escape 对脏表单先请求确认,活跃/外部关联实例锁定身份约束字段。设置页使用完整代理地址选择器、地址输入及保存/删除操作;选择会回填地址,保存按稳定 `proxyId` 新增或更新,删除未引用代理前必须确认,仍被实例引用的代理会被拒绝。启动通过异步回调接到 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 +- T-307:本应用会话启动的受管 Chrome/Edge 根进程由单实例后台 `Wait` 监控;意外退出按 ID、启动代次和 PID 验证后立即变为“已退出”,清空运行时 PID/端口,并以可关闭、可合并的提示告知用户。提示关闭后焦点回到启动操作;Chub 请求停止、外部实例、过期事件和应用关闭取消监控均不提示。 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 -- blocker:无;当前执行 T-307 的受管浏览器意外退出监控与提示。 +- blocker:无;T-307 已完成,下一项从 Backlog 或后续需求确定。 ## 当前目录 diff --git a/docs/tasks/T-307.md b/docs/tasks/T-307.md index 12887d1..8af146e 100644 --- a/docs/tasks/T-307.md +++ b/docs/tasks/T-307.md @@ -3,7 +3,7 @@ id: T-307 title: 受管浏览器意外退出监控与状态提示 phase: 3 deps: [T-101, T-305, T-306] -status: DOING +status: DONE created: 2026-07-25 owner: codex --- @@ -31,7 +31,7 @@ owner: codex ## 执行记录 -- 状态:DOING -- 变更:待实现。 -- 验证:待执行。 +- 状态:DONE +- 变更:新增当前会话受管进程的单实例 `Wait` 监控;退出事件按实例 ID、启动代次与 PID 回到 Shell 的线程安全结果队列。意外退出会立即恢复“已退出/启动”状态并合并显示“知道了”提示;Chub 请求停止、外部实例和过期事件不会提示。提示关闭后焦点回到该实例的启动操作。 +- 验证:新增命令入口监控与预期停止测试、Shell 的意外退出/启动期快速退出/过期事件/多个退出聚合测试;`go test -race ./cmd/chub ./internal/ui`、`go test ./...`、`go vet ./...`、`scripts/smoke-windows.ps1` 与 `scripts/smoke-browser.ps1` 均通过。 - 阻塞:无。 diff --git a/internal/domain/browser.go b/internal/domain/browser.go index f1d9694..029ace0 100644 --- a/internal/domain/browser.go +++ b/internal/domain/browser.go @@ -133,13 +133,15 @@ const ( ) type BrowserEvent struct { - Kind EventKind - InstanceID string - Status InstanceStatus - PID int - ExitCode *int - ErrorCode ErrorCode - At time.Time + Kind EventKind + InstanceID string + LaunchGeneration uint64 + Status InstanceStatus + PID int + ExitCode *int + ExpectedStop bool + ErrorCode ErrorCode + At time.Time } type ErrorCode string diff --git a/internal/ui/shell.go b/internal/ui/shell.go index 02cd678..8ea1f66 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "chub/internal/domain" "gioui.org/io/key" @@ -75,11 +76,22 @@ type InstanceStartOutcome struct { Warning string } -type InstanceStarter func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error) +// ManagedInstanceExit is delivered by the application adapter after the root +// process of a browser started by this Chub session exits. It contains only +// DTO data; UI code never receives a platform process handle. +type ManagedInstanceExit struct { + InstanceID string + LaunchGeneration uint64 + PID int + ExitCode int + ExpectedStop bool +} + +type InstanceStarter func(context.Context, InstanceRow, SettingsState, uint64) (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 +type InstanceStopper func(context.Context, InstanceRow, uint64) 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 @@ -134,6 +146,18 @@ type instanceRefreshResult struct { err error } +type managedExitKey struct { + instanceID string + launchGeneration uint64 + pid int +} + +type unexpectedExitNotice struct { + instanceID string + name string + browser string +} + type pathSearchState struct { request uint64 cancel context.CancelFunc @@ -277,43 +301,53 @@ type Shell struct { pendingProxyDelete string proxyDeleteFocus bool - list widget.List - rows []InstanceRow - selectedInstanceID string - rowClicks map[string]*widget.Clickable - startClicks map[string]*widget.Clickable - editClicks map[string]*widget.Clickable - deleteClicks map[string]*widget.Clickable - deleteConfirm widget.Clickable - deleteCancel widget.Clickable - deleteBlocker widget.Clickable - startStates map[string]*instanceStartState - startResults chan instanceStartResult - stopStates map[string]*instanceStopState - stopResults chan instanceStopResult - refreshClickState instanceRefreshState - refreshResults chan instanceRefreshResult - nextInstance uint64 - instanceFeedback string - pendingDeleteID string - editingID string - editOriginal InstanceRow - pendingEditDiscard bool - editFocusPending bool - focusRestoreID string - onSave func(SettingsState) - onInstancesChanged func([]InstanceRow) - onProxiesChanged func([]ProxyOption) - instanceStarter InstanceStarter - instanceStopper InstanceStopper - instanceRefresher InstanceRefresher - pathSearcher PathSearcher - invalidate func() - searches map[PathField]*pathSearchState - searchResults chan pathSearchResult - directoryChooser DirectoryChooser - directoryPick directoryPickState - directoryResults chan directoryPickResult + list widget.List + rows []InstanceRow + selectedInstanceID string + rowClicks map[string]*widget.Clickable + startClicks map[string]*widget.Clickable + editClicks map[string]*widget.Clickable + deleteClicks map[string]*widget.Clickable + deleteConfirm widget.Clickable + deleteCancel widget.Clickable + deleteBlocker widget.Clickable + startStates map[string]*instanceStartState + startResults chan instanceStartResult + stopStates map[string]*instanceStopState + stopResults chan instanceStopResult + refreshClickState instanceRefreshState + refreshResults chan instanceRefreshResult + managedExitMu sync.Mutex + managedExitResults []ManagedInstanceExit + pendingManagedExit map[managedExitKey]ManagedInstanceExit + unexpectedExits []unexpectedExitNotice + unexpectedExitOpen bool + unexpectedExitAck widget.Clickable + unexpectedExitBlocker widget.Clickable + unexpectedExitFocus bool + unexpectedExitFocusID string + nextInstance uint64 + instanceFeedback string + pendingDeleteID string + editingID string + editOriginal InstanceRow + pendingEditDiscard bool + editFocusPending bool + focusRestoreID string + focusStartID string + onSave func(SettingsState) + onInstancesChanged func([]InstanceRow) + onProxiesChanged func([]ProxyOption) + instanceStarter InstanceStarter + instanceStopper InstanceStopper + instanceRefresher InstanceRefresher + pathSearcher PathSearcher + invalidate func() + searches map[PathField]*pathSearchState + searchResults chan pathSearchResult + directoryChooser DirectoryChooser + directoryPick directoryPickState + directoryResults chan directoryPickResult } func NewShell(theme *material.Theme) *Shell { @@ -327,7 +361,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), 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)} + }, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), pendingManagedExit: make(map[managedExitKey]ManagedInstanceExit), 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`) @@ -407,6 +441,19 @@ func (s *Shell) OnStopInstance(stopper InstanceStopper, invalidate func()) { s.invalidate = invalidate } +// ReportManagedExit is safe for the application's single per-instance monitor +// goroutine. The event is consumed during the next UI frame and therefore does +// not perform layout or mutate widgets from a background thread. The monitor +// that reports this result is responsible for invalidating its window. +func (s *Shell) ReportManagedExit(result ManagedInstanceExit) { + if result.InstanceID == "" || result.LaunchGeneration == 0 || result.PID <= 0 { + return + } + s.managedExitMu.Lock() + s.managedExitResults = append(s.managedExitResults, result) + s.managedExitMu.Unlock() +} + func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) { s.instanceRefresher = refresher s.invalidate = invalidate @@ -428,8 +475,12 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { s.consumeStartResults() s.consumeStopResults() s.consumeRefreshResults() + s.consumeManagedExitResults() + s.presentUnexpectedExitIfReady() s.consumeKeyboard(gtx) - if s.pendingDeleteID != "" { + if s.unexpectedExitOpen { + s.consumeUnexpectedExitDialog(gtx) + } else if s.pendingDeleteID != "" { s.consumeDeleteConfirmation(gtx) } else if s.pendingProxyDelete != "" { s.consumeProxyDeleteConfirmation(gtx) @@ -438,6 +489,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { } else { s.consumeControls(gtx) } + s.presentUnexpectedExitIfReady() mainLayout := func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, layout.Rigid(s.sidebar), @@ -446,9 +498,15 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions { }), ) } - if s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open { + if !s.unexpectedExitOpen && s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open { return mainLayout(gtx) } + if s.unexpectedExitOpen { + return layout.Stack{Alignment: layout.Center}.Layout(gtx, + layout.Expanded(mainLayout), + layout.Stacked(s.unexpectedExitDialog), + ) + } if s.pendingDeleteID != "" { return layout.Stack{Alignment: layout.Center}.Layout(gtx, layout.Expanded(mainLayout), @@ -546,7 +604,9 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) { } switch keyEvent.Name { case key.NameEscape: - if s.pendingProxyDelete != "" { + if s.unexpectedExitOpen { + s.dismissUnexpectedExitNotice() + } else if s.pendingProxyDelete != "" { s.cancelProxyDelete() } else if s.proxyPicker.open { s.proxyPicker.open = false @@ -919,18 +979,24 @@ 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 { mode := instanceActionFor(row) + startClick := s.startClickFor(row.ID) 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.startClickFor(row.ID), actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)), + dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(s.instanceIconButton(startClick, actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)), layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout), layout.Rigid(s.instanceIconButton(s.deleteClickFor(row.ID), instanceDeleteIcon, "删除 "+row.Name, color.NRGBA{R: 188, G: 51, B: 51, A: 255})), ) + if s.focusStartID == row.ID { + gtx.Execute(key.FocusCmd{Tag: startClick}) + s.focusStartID = "" + } + return dims }) } } @@ -1270,6 +1336,79 @@ func (s *Shell) proxyDeleteConfirmButton(gtx layout.Context) layout.Dimensions { return style.Layout(gtx) } +func (s *Shell) unexpectedExitDialog(gtx layout.Context) layout.Dimensions { + if !s.unexpectedExitOpen || len(s.unexpectedExits) == 0 { + return layout.Dimensions{} + } + gtx.Constraints.Min = gtx.Constraints.Max + return s.unexpectedExitBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop() + paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 92}) + return layout.Center.Layout(gtx, s.unexpectedExitCard) + }) +} + +func (s *Shell) unexpectedExitCard(gtx layout.Context) layout.Dimensions { + if maxWidth := gtx.Dp(480); gtx.Constraints.Max.X > maxWidth { + gtx.Constraints.Max.X = maxWidth + } + title := "浏览器已退出" + message := "检测到以下由 Chub 启动的浏览器已退出。" + if len(s.unexpectedExits) == 1 { + notice := s.unexpectedExits[0] + message = fmt.Sprintf("检测到“%s”的 %s 已退出。", notice.name, notice.browser) + } else { + title = fmt.Sprintf("%d 个浏览器已退出", len(s.unexpectedExits)) + } + return layout.Background{}.Layout(gtx, + func(gtx layout.Context) layout.Dimensions { + rect := image.Rectangle{Max: gtx.Constraints.Min} + defer clip.UniformRRect(rect, gtx.Dp(8)).Push(gtx.Ops).Pop() + paint.Fill(gtx.Ops, s.theme.Palette.Bg) + return layout.Dimensions{Size: gtx.Constraints.Min} + }, + func(gtx layout.Context) layout.Dimensions { + return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + children := []layout.FlexChild{ + layout.Rigid(material.H6(s.theme, title).Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(material.Body1(s.theme, message).Layout), + } + if len(s.unexpectedExits) > 1 { + for index, notice := range s.unexpectedExits { + if index == 5 { + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(material.Caption(s.theme, fmt.Sprintf("另有 %d 个实例已退出。", len(s.unexpectedExits)-index)).Layout), + ) + break + } + notice := notice + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(material.Body2(s.theme, fmt.Sprintf("• %s · %s", notice.name, notice.browser)).Layout), + ) + } + } + children = append(children, + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(material.Caption(s.theme, "实例配置和 User Data Dir 未被删除。关闭提示后可重新启动。").Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(18)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.E.Layout(gtx, material.Button(s.theme, &s.unexpectedExitAck, "知道了").Layout) + }), + ) + dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + if s.unexpectedExitFocus { + gtx.Execute(key.FocusCmd{Tag: &s.unexpectedExitAck}) + s.unexpectedExitFocus = false + } + return dims + }) + }, + ) +} + func statusLabel(theme *material.Theme, status string) layout.Widget { style := material.Label(theme, unit.Sp(13), status) style.Color = theme.Palette.Fg @@ -1957,7 +2096,8 @@ func (s *Shell) requestStop(id string) { s.instanceFeedback = fmt.Sprintf("%s 正在停止,请稍候。", row.Name) return } - if s.instanceStopper == nil { + stopper := s.instanceStopper + if stopper == nil { s.instanceFeedback = "浏览器停止服务尚未准备好。" return } @@ -1968,14 +2108,19 @@ func (s *Shell) requestStop(id string) { state.request++ request := state.request state.running = true + launchGeneration := uint64(0) + if started := s.startStates[id]; started != nil { + launchGeneration = started.request + } state.previousStatus = row.Status s.setInstanceStatus(id, "停止中") s.instanceFeedback = fmt.Sprintf("正在请求 %s 正常退出…", row.Name) + invalidate := s.invalidate go func() { - err := s.instanceStopper(context.Background(), row) + err := stopper(context.Background(), row, launchGeneration) s.stopResults <- instanceStopResult{id: id, request: request, err: err} - if s.invalidate != nil { - s.invalidate() + if invalidate != nil { + invalidate() } }() } @@ -1995,7 +2140,8 @@ func (s *Shell) requestStart(id string) { s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name) return } - if s.instanceStarter == nil { + starter := s.instanceStarter + if starter == nil { s.instanceFeedback = "浏览器启动服务尚未准备好。" return } @@ -2013,11 +2159,12 @@ func (s *Shell) requestStart(id string) { state.running = true s.setInstanceStatus(id, "启动中") s.instanceFeedback = fmt.Sprintf("正在启动 %s…", row.Name) + invalidate := s.invalidate go func() { - outcome, err := s.instanceStarter(context.Background(), row, settings) + outcome, err := starter(context.Background(), row, settings, request) s.startResults <- instanceStartResult{id: id, request: request, outcome: outcome, err: err} - if s.invalidate != nil { - s.invalidate() + if invalidate != nil { + invalidate() } }() } @@ -2089,6 +2236,9 @@ func (s *Shell) consumeStartResults() { status = "运行中(调试不可用)" } s.setInstanceRuntime(result.id, status, result.outcome.PID, result.outcome.RemoteDebugPort, "chub_registry") + if s.applyPendingManagedExit(result.id, result.request, result.outcome.PID) { + continue + } if result.outcome.Warning != "" { s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning) } else { @@ -2105,7 +2255,8 @@ func (s *Shell) requestRefresh() { s.instanceFeedback = "实例状态正在刷新,请稍候。" return } - if s.instanceRefresher == nil { + refresher := s.instanceRefresher + if refresher == nil { s.instanceFeedback = "实例状态刷新服务尚未准备好。" return } @@ -2118,11 +2269,12 @@ func (s *Shell) requestRefresh() { request := s.refreshClickState.request s.refreshClickState.running = true s.instanceFeedback = "正在刷新已保存实例的状态…" + invalidate := s.invalidate go func() { - results, err := s.instanceRefresher(context.Background(), rows) + results, err := refresher(context.Background(), rows) s.refreshResults <- instanceRefreshResult{request: request, snapshots: snapshots, results: results, err: err} - if s.invalidate != nil { - s.invalidate() + if invalidate != nil { + invalidate() } }() } @@ -2172,6 +2324,66 @@ func (s *Shell) consumeRefreshResults() { } } +func (s *Shell) consumeManagedExitResults() { + s.managedExitMu.Lock() + results := s.managedExitResults + s.managedExitResults = nil + s.managedExitMu.Unlock() + for _, result := range results { + key := managedExitKey{instanceID: result.InstanceID, launchGeneration: result.LaunchGeneration, pid: result.PID} + row, exists := s.instanceRow(result.InstanceID) + if !exists { + continue + } + state := s.startStates[result.InstanceID] + if state != nil && state.running && state.request == result.LaunchGeneration && row.Status == "启动中" { + s.pendingManagedExit[key] = result + continue + } + s.applyManagedExit(result) + } +} + +func (s *Shell) applyPendingManagedExit(id string, launchGeneration uint64, pid int) bool { + key := managedExitKey{instanceID: id, launchGeneration: launchGeneration, pid: pid} + result, exists := s.pendingManagedExit[key] + if !exists { + return false + } + delete(s.pendingManagedExit, key) + return s.applyManagedExit(result) +} + +func (s *Shell) applyManagedExit(result ManagedInstanceExit) bool { + row, exists := s.instanceRow(result.InstanceID) + if !exists || row.PID != result.PID { + return false + } + state := s.startStates[result.InstanceID] + if state == nil || state.request != result.LaunchGeneration { + return false + } + if row.Status != "运行中" && row.Status != "运行中(调试不可用)" && row.Status != "停止中" { + return false + } + s.setInstanceRuntime(result.InstanceID, "已退出", 0, 0, "") + if result.ExpectedStop || row.Status == "停止中" { + s.instanceFeedback = fmt.Sprintf("%s 已正常退出。", row.Name) + return true + } + for _, notice := range s.unexpectedExits { + if notice.instanceID == row.ID { + return true + } + } + s.unexpectedExits = append(s.unexpectedExits, unexpectedExitNotice{instanceID: row.ID, name: row.Name, browser: row.Browser}) + if s.unexpectedExitFocusID == "" { + s.unexpectedExitFocusID = row.ID + } + s.instanceFeedback = fmt.Sprintf("检测到“%s”的 %s 已退出,可重新启动。", row.Name, row.Browser) + return true +} + func instanceRefreshFingerprint(row InstanceRow) string { return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID}, "\x00") } @@ -2215,6 +2427,34 @@ func (s *Shell) consumeProxyDeleteConfirmation(gtx layout.Context) { } } +func (s *Shell) consumeUnexpectedExitDialog(gtx layout.Context) { + for s.unexpectedExitBlocker.Clicked(gtx) { + } + for s.unexpectedExitAck.Clicked(gtx) { + s.dismissUnexpectedExitNotice() + } +} + +func (s *Shell) hasOtherModal() bool { + return s.pendingDeleteID != "" || s.pendingProxyDelete != "" || s.editingID != "" || s.pendingEditDiscard || s.proxyPicker.open +} + +func (s *Shell) presentUnexpectedExitIfReady() { + if s.unexpectedExitOpen || len(s.unexpectedExits) == 0 || s.hasOtherModal() { + return + } + s.unexpectedExitOpen = true + s.unexpectedExitFocus = true +} + +func (s *Shell) dismissUnexpectedExitNotice() { + s.unexpectedExitOpen = false + s.unexpectedExitFocus = false + s.unexpectedExits = nil + s.focusStartID = s.unexpectedExitFocusID + s.unexpectedExitFocusID = "" +} + func (s *Shell) cancelDelete() { if row, ok := s.instanceRow(s.pendingDeleteID); ok { s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name) diff --git a/internal/ui/shell_test.go b/internal/ui/shell_test.go index c4cf14b..116033c 100644 --- a/internal/ui/shell_test.go +++ b/internal/ui/shell_test.go @@ -129,7 +129,7 @@ func TestShellStartsInstanceAsynchronouslyAndAppliesResult(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[0] started := make(chan InstanceRow, 1) - shell.OnStartInstance(func(_ context.Context, row InstanceRow, _ SettingsState) (InstanceStartOutcome, error) { + shell.OnStartInstance(func(_ context.Context, row InstanceRow, _ SettingsState, _ uint64) (InstanceStartOutcome, error) { started <- row return InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9666}, nil }, nil) @@ -165,7 +165,7 @@ func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) { entered := make(chan struct{}, 1) release := make(chan struct{}) var calls atomic.Int32 - shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState) (InstanceStartOutcome, error) { + shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState, _ uint64) (InstanceStartOutcome, error) { calls.Add(1) entered <- struct{}{} <-release @@ -196,7 +196,7 @@ func TestShellStopsManagedInstanceAsynchronouslyAndRestoresStartAction(t *testin shell := NewShell(material.NewTheme()) target := shell.rows[0] stopped := make(chan InstanceRow, 1) - shell.OnStopInstance(func(_ context.Context, row InstanceRow) error { + shell.OnStopInstance(func(_ context.Context, row InstanceRow, _ uint64) error { stopped <- row return nil }, nil) @@ -233,7 +233,7 @@ func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) { entered := make(chan struct{}, 1) release := make(chan struct{}) var calls atomic.Int32 - shell.OnStopInstance(func(context.Context, InstanceRow) error { + shell.OnStopInstance(func(context.Context, InstanceRow, uint64) error { calls.Add(1) entered <- struct{}{} <-release @@ -260,8 +260,8 @@ func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) { 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) { + shell.OnStopInstance(func(context.Context, InstanceRow, uint64) error { externalStops.Add(1); return nil }, nil) + shell.OnStartInstance(func(context.Context, InstanceRow, SettingsState, uint64) (InstanceStartOutcome, error) { return InstanceStartOutcome{External: true, Source: "browser_message_window"}, nil }, nil) shell.requestInstanceAction(external.ID) @@ -273,7 +273,7 @@ func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) { 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.OnStopInstance(func(context.Context, InstanceRow, uint64) error { return errors.New("permission denied") }, nil) shell.requestInstanceAction(target.ID) var result instanceStopResult select { @@ -358,7 +358,7 @@ func TestShellNormalizesRemoteDebugStartPort(t *testing.T) { func TestShellMarksExternalAssociationWithoutManagingLifecycle(t *testing.T) { shell := NewShell(material.NewTheme()) target := shell.rows[2] - shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState) (InstanceStartOutcome, error) { + shell.OnStartInstance(func(_ context.Context, _ InstanceRow, _ SettingsState, _ uint64) (InstanceStartOutcome, error) { return InstanceStartOutcome{PID: 16108, RemoteDebugPort: 9668, External: true, Source: "browser_message_window"}, nil }, nil) @@ -624,3 +624,96 @@ func TestShellSavesProxyFromAddressPicker(t *testing.T) { t.Fatalf("created proxy = %#v, selected %q, changes %d", shell.proxies, shell.settingsProxyID, changes) } } + +func TestShellMarksUnexpectedManagedExitAndRestoresStartFocus(t *testing.T) { + shell := NewShell(material.NewTheme()) + target := shell.rows[0] + shell.startStates[target.ID] = &instanceStartState{request: 7} + + shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 7, PID: target.PID, ExitCode: 0}) + shell.consumeManagedExitResults() + shell.presentUnexpectedExitIfReady() + + row, ok := shell.instanceRow(target.ID) + if !ok || row.Status != "已退出" || row.PID != 0 || row.RemoteDebugPort != 0 || row.OccupancySource != "" || !shell.unexpectedExitOpen || len(shell.unexpectedExits) != 1 { + t.Fatalf("unexpected exit state = row %#v, dialog %v, notices %#v", row, shell.unexpectedExitOpen, shell.unexpectedExits) + } + if mode := instanceActionFor(row); !mode.enabled || mode.stop || mode.label != "启动" { + t.Fatalf("action after unexpected exit = %#v", mode) + } + + shell.dismissUnexpectedExitNotice() + if shell.unexpectedExitOpen || len(shell.unexpectedExits) != 0 || shell.focusStartID != target.ID { + t.Fatalf("dismissed unexpected exit state = open %v, notices %#v, focus %q", shell.unexpectedExitOpen, shell.unexpectedExits, shell.focusStartID) + } +} + +func TestShellDoesNotNotifyExpectedOrStaleManagedExit(t *testing.T) { + shell := NewShell(material.NewTheme()) + target := shell.rows[0] + shell.startStates[target.ID] = &instanceStartState{request: 3} + + shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 2, PID: target.PID, ExitCode: 0}) + shell.consumeManagedExitResults() + if row, _ := shell.instanceRow(target.ID); row.Status != "运行中" || len(shell.unexpectedExits) != 0 { + t.Fatalf("stale exit changed row = %#v, notices %#v", row, shell.unexpectedExits) + } + + shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 3, PID: target.PID, ExitCode: 0, ExpectedStop: true}) + shell.consumeManagedExitResults() + shell.presentUnexpectedExitIfReady() + if row, _ := shell.instanceRow(target.ID); row.Status != "已退出" || len(shell.unexpectedExits) != 0 || shell.unexpectedExitOpen { + t.Fatalf("expected exit state = row %#v, notices %#v, dialog %v", row, shell.unexpectedExits, shell.unexpectedExitOpen) + } +} + +func TestShellAppliesManagedExitThatArrivesDuringStart(t *testing.T) { + shell := NewShell(material.NewTheme()) + target := shell.rows[0] + target.Status = "启动中" + target.PID = 0 + target.RemoteDebugPort = 0 + shell.rows[0] = target + shell.startStates[target.ID] = &instanceStartState{request: 5, running: true} + + shell.ReportManagedExit(ManagedInstanceExit{InstanceID: target.ID, LaunchGeneration: 5, PID: 5123, ExitCode: 1}) + shell.consumeManagedExitResults() + if len(shell.pendingManagedExit) != 1 { + t.Fatalf("pending managed exits = %#v", shell.pendingManagedExit) + } + + shell.startStates[target.ID].running = false + shell.setInstanceRuntime(target.ID, "运行中", 5123, 9666, "chub_registry") + if !shell.applyPendingManagedExit(target.ID, 5, 5123) { + t.Fatal("pending managed exit was not applied") + } + shell.presentUnexpectedExitIfReady() + if row, _ := shell.instanceRow(target.ID); row.Status != "已退出" || !shell.unexpectedExitOpen { + t.Fatalf("quick exit after start = row %#v, dialog %v", row, shell.unexpectedExitOpen) + } +} + +func TestShellAggregatesManagedExitNoticesUntilOtherModalCloses(t *testing.T) { + shell := NewShell(material.NewTheme()) + shell.SetInstances([]InstanceRow{ + {ID: "chrome", Name: "运营 Chrome", Browser: "Chrome", UserDataDir: t.TempDir(), PID: 4001, RemoteDebugPort: 9666, OccupancySource: "chub_registry", Status: "运行中"}, + {ID: "edge", Name: "审核 Edge", Browser: "Edge", UserDataDir: t.TempDir(), PID: 4002, RemoteDebugPort: 9667, OccupancySource: "chub_registry", Status: "运行中"}, + }) + shell.startStates["chrome"] = &instanceStartState{request: 1} + shell.startStates["edge"] = &instanceStartState{request: 2} + shell.editingID = "editing" + + shell.ReportManagedExit(ManagedInstanceExit{InstanceID: "chrome", LaunchGeneration: 1, PID: 4001}) + shell.ReportManagedExit(ManagedInstanceExit{InstanceID: "edge", LaunchGeneration: 2, PID: 4002}) + shell.consumeManagedExitResults() + shell.presentUnexpectedExitIfReady() + if shell.unexpectedExitOpen || len(shell.unexpectedExits) != 2 { + t.Fatalf("queued notices = open %v, notices %#v", shell.unexpectedExitOpen, shell.unexpectedExits) + } + + shell.editingID = "" + shell.presentUnexpectedExitIfReady() + if !shell.unexpectedExitOpen || len(shell.unexpectedExits) != 2 { + t.Fatalf("aggregated notices = open %v, notices %#v", shell.unexpectedExitOpen, shell.unexpectedExits) + } +}