feat: notify when managed browsers exit
This commit is contained in:
+18
-3
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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` 均通过。
|
||||
- 阻塞:无。
|
||||
|
||||
@@ -135,9 +135,11 @@ const (
|
||||
type BrowserEvent struct {
|
||||
Kind EventKind
|
||||
InstanceID string
|
||||
LaunchGeneration uint64
|
||||
Status InstanceStatus
|
||||
PID int
|
||||
ExitCode *int
|
||||
ExpectedStop bool
|
||||
ErrorCode ErrorCode
|
||||
At time.Time
|
||||
}
|
||||
|
||||
+260
-20
@@ -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
|
||||
@@ -293,6 +317,15 @@ type Shell struct {
|
||||
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
|
||||
@@ -301,6 +334,7 @@ type Shell struct {
|
||||
pendingEditDiscard bool
|
||||
editFocusPending bool
|
||||
focusRestoreID string
|
||||
focusStartID string
|
||||
onSave func(SettingsState)
|
||||
onInstancesChanged func([]InstanceRow)
|
||||
onProxiesChanged func([]ProxyOption)
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user