feat: notify when managed browsers exit

This commit is contained in:
QiuSW
2026-07-25 18:23:33 +08:00
parent f5b5e95223
commit eeaf4c7826
9 changed files with 647 additions and 92 deletions
+18 -3
View File
@@ -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)
+99 -9
View File
@@ -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) {
+114
View File
@@ -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()
}
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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 或后续需求确定。
## 当前目录
+4 -4
View File
@@ -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` 均通过。
- 阻塞:无。
+9 -7
View File
@@ -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
+297 -57
View File
@@ -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)
+101 -8
View File
@@ -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)
}
}