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() cdp := browser.NewCDPInspector()
externalProfiles := browser.NewWindowsProfileInspector() externalProfiles := browser.NewWindowsProfileInspector()
proxies := newProxyDirectory() proxies := newProxyDirectory()
exitMonitor := newManagedExitMonitor(shell.ReportManagedExit, window.Invalidate)
defer exitMonitor.Close()
shell.OnStartInstance(instanceStarter{ shell.OnStartInstance(instanceStarter{
launcher: launcher, launcher: launcher,
resolver: discoverer, resolver: discoverer,
@@ -120,8 +122,9 @@ func runWindow(logger *slog.Logger) {
remoteDebug: cdp, remoteDebug: cdp,
portAllocator: cdp, portAllocator: cdp,
proxyResolver: proxies, proxyResolver: proxies,
monitor: exitMonitor,
}.Start, window.Invalidate) }.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{ shell.OnRefreshInstances(instanceStatusRefresher{
managedProfiles: launcher, managedProfiles: launcher,
externalProfiles: externalProfiles, externalProfiles: externalProfiles,
@@ -274,11 +277,12 @@ type profileStopper interface {
type instanceStopper struct { type instanceStopper struct {
launcher profileStopper launcher profileStopper
managedProfiles managedProfileInspector managedProfiles managedProfileInspector
monitor managedExitObserver
waitTimeout time.Duration waitTimeout time.Duration
waitInterval 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 != "运行中(调试不可用)" { if row.Status != "运行中" && row.Status != "运行中(调试不可用)" {
return errors.New("实例当前不是可停止的 Chub 托管状态") 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) { if use.Source != "chub_registry" || (row.PID > 0 && use.PID > 0 && row.PID != use.PID) {
return domain.ErrIdentityMismatch 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 err := s.launcher.StopProfile(ctx, row.UserDataDir, false); err != nil {
if expected {
s.monitor.ClearExpectedStop(row.ID, launchGeneration, row.PID)
}
return err return err
} }
timeout := s.waitTimeout timeout := s.waitTimeout
@@ -342,6 +353,7 @@ type instanceStarter struct {
remoteDebug browser.RemoteDebugEndpointInspector remoteDebug browser.RemoteDebugEndpointInspector
portAllocator browser.RemoteDebugPortAllocator portAllocator browser.RemoteDebugPortAllocator
proxyResolver proxyServerResolver proxyResolver proxyServerResolver
monitor managedExitObserver
} }
type instanceStatusRefresher struct { type instanceStatusRefresher struct {
@@ -428,7 +440,7 @@ func (s instanceStatusRefresher) refreshOne(ctx context.Context, row ui.Instance
return result 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) kind, err := browserKind(row.Browser)
if err != nil { if err != nil {
return ui.InstanceStartOutcome{}, err return ui.InstanceStartOutcome{}, err
@@ -496,6 +508,9 @@ func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings
if err != nil { if err != nil {
return ui.InstanceStartOutcome{}, err return ui.InstanceStartOutcome{}, err
} }
if s.monitor != nil {
s.monitor.Watch(row.ID, launchGeneration, handle)
}
endpointCtx, cancel := context.WithTimeout(ctx, 5*time.Second) endpointCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
endpoint, endpointErr := s.remoteDebug.WaitForRemoteDebugPort(endpointCtx, kind, port) 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`} resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`}
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable} remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote} 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 { if err != nil || outcome.PID != 4242 || outcome.RemoteDebugPort != 9666 {
t.Fatalf("Start() = %#v, %v", outcome, err) t.Fatalf("Start() = %#v, %v", outcome, err)
} }
@@ -45,33 +45,74 @@ func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) {
proxies := newProxyDirectory() proxies := newProxyDirectory()
proxies.Set([]config.ProxyProfile{{ID: "proxy-sg", Name: "SG", Server: "http://127.0.0.1:8080"}}) 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} 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" { if err != nil || launcher.spec.ProxyServer != "http://127.0.0.1:8080" {
t.Fatalf("proxy launch spec = %#v, error = %v", launcher.spec, err) 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 { if err == nil || launcher.calls != 1 {
t.Fatalf("missing proxy error = %v, launch calls = %d", err, launcher.calls) 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) { func TestInstanceStopperGracefullyStopsOnlyVerifiedManagedProfile(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}, releaseOnStop: true} 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} 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` { 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) 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) { func TestInstanceStopperRejectsMismatchedOrExternalProfiles(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4243, Source: "chub_registry"}} 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} 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 { if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 {
t.Fatalf("mismatch Stop() = %v, calls=%d", err, managed.stopCalls) 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 { if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 {
t.Fatalf("external Stop() = %v, calls=%d", err, managed.stopCalls) t.Fatalf("external Stop() = %v, calls=%d", err, managed.stopCalls)
} }
@@ -80,7 +121,7 @@ func TestInstanceStopperRejectsMismatchedOrExternalProfiles(t *testing.T) {
func TestInstanceStopperDoesNotEscalateWhenGracefulCloseTimesOut(t *testing.T) { func TestInstanceStopperDoesNotEscalateWhenGracefulCloseTimesOut(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}} 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} 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 { if err == nil || managed.stopCalls != 1 || managed.force {
t.Fatalf("timeout Stop() = %v, calls=%d force=%v", err, managed.stopCalls, 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, remoteDebug: remote,
portAllocator: 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 { if err != nil || !outcome.External || outcome.PID != 16108 || outcome.RemoteDebugPort != 9668 || outcome.Source != browser.ProfileSourceMessageWindow {
t.Fatalf("Start() = %#v, %v", outcome, err) t.Fatalf("Start() = %#v, %v", outcome, err)
} }
@@ -117,7 +158,7 @@ func TestInstanceStarterRejectsUnverifiedExternalProfile(t *testing.T) {
remoteDebug: remote, remoteDebug: remote,
portAllocator: 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) { if !errors.Is(err, domain.ErrProfileOccupied) {
t.Fatalf("Start() error = %v", err) 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 } 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 } type fakeManagedProfileInspector struct{ use browser.ProfileUse }
func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (browser.ProfileUse, error) { 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-304 | 代理配置库、实例选择与安全启动参数传递 | T-303 | DONE |
| T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE | | T-305 | GUI 受管实例优雅停止与启停状态机 | T-304 | DONE |
| T-306 | 精简代理下拉编辑与实例行安全操作布局 | T-304,T-305 | 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 ## Backlog
+4 -3
View File
@@ -3,11 +3,12 @@
## 快照 ## 快照
- 日期:2026-07-25 - 日期:2026-07-25
- 阶段:Phase 3 真实实例操作(T-307 进行中;T-301 至 T-306、T-201 至 T-208 已完成) - 阶段: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-303 已完成 - 代码:已建立 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 - 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`,尚未复制或接入本项目 - 浏览器核心:设计参考来自 `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: 受管浏览器意外退出监控与状态提示 title: 受管浏览器意外退出监控与状态提示
phase: 3 phase: 3
deps: [T-101, T-305, T-306] deps: [T-101, T-305, T-306]
status: DOING status: DONE
created: 2026-07-25 created: 2026-07-25
owner: codex 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 { type BrowserEvent struct {
Kind EventKind Kind EventKind
InstanceID string InstanceID string
Status InstanceStatus LaunchGeneration uint64
PID int Status InstanceStatus
ExitCode *int PID int
ErrorCode ErrorCode ExitCode *int
At time.Time ExpectedStop bool
ErrorCode ErrorCode
At time.Time
} }
type ErrorCode string type ErrorCode string
+297 -57
View File
@@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"chub/internal/domain" "chub/internal/domain"
"gioui.org/io/key" "gioui.org/io/key"
@@ -75,11 +76,22 @@ type InstanceStartOutcome struct {
Warning string 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 // InstanceStopper requests graceful shutdown only for the supplied configured
// instance. Implementations must reject external or unverified processes. // 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 // InstanceRefreshResult carries a read-only status check for one configured
// instance. The UI applies it only while the row's editable fields still match // instance. The UI applies it only while the row's editable fields still match
@@ -134,6 +146,18 @@ type instanceRefreshResult struct {
err error err error
} }
type managedExitKey struct {
instanceID string
launchGeneration uint64
pid int
}
type unexpectedExitNotice struct {
instanceID string
name string
browser string
}
type pathSearchState struct { type pathSearchState struct {
request uint64 request uint64
cancel context.CancelFunc cancel context.CancelFunc
@@ -277,43 +301,53 @@ type Shell struct {
pendingProxyDelete string pendingProxyDelete string
proxyDeleteFocus bool proxyDeleteFocus bool
list widget.List list widget.List
rows []InstanceRow rows []InstanceRow
selectedInstanceID string selectedInstanceID string
rowClicks map[string]*widget.Clickable rowClicks map[string]*widget.Clickable
startClicks map[string]*widget.Clickable startClicks map[string]*widget.Clickable
editClicks map[string]*widget.Clickable editClicks map[string]*widget.Clickable
deleteClicks map[string]*widget.Clickable deleteClicks map[string]*widget.Clickable
deleteConfirm widget.Clickable deleteConfirm widget.Clickable
deleteCancel widget.Clickable deleteCancel widget.Clickable
deleteBlocker widget.Clickable deleteBlocker widget.Clickable
startStates map[string]*instanceStartState startStates map[string]*instanceStartState
startResults chan instanceStartResult startResults chan instanceStartResult
stopStates map[string]*instanceStopState stopStates map[string]*instanceStopState
stopResults chan instanceStopResult stopResults chan instanceStopResult
refreshClickState instanceRefreshState refreshClickState instanceRefreshState
refreshResults chan instanceRefreshResult refreshResults chan instanceRefreshResult
nextInstance uint64 managedExitMu sync.Mutex
instanceFeedback string managedExitResults []ManagedInstanceExit
pendingDeleteID string pendingManagedExit map[managedExitKey]ManagedInstanceExit
editingID string unexpectedExits []unexpectedExitNotice
editOriginal InstanceRow unexpectedExitOpen bool
pendingEditDiscard bool unexpectedExitAck widget.Clickable
editFocusPending bool unexpectedExitBlocker widget.Clickable
focusRestoreID string unexpectedExitFocus bool
onSave func(SettingsState) unexpectedExitFocusID string
onInstancesChanged func([]InstanceRow) nextInstance uint64
onProxiesChanged func([]ProxyOption) instanceFeedback string
instanceStarter InstanceStarter pendingDeleteID string
instanceStopper InstanceStopper editingID string
instanceRefresher InstanceRefresher editOriginal InstanceRow
pathSearcher PathSearcher pendingEditDiscard bool
invalidate func() editFocusPending bool
searches map[PathField]*pathSearchState focusRestoreID string
searchResults chan pathSearchResult focusStartID string
directoryChooser DirectoryChooser onSave func(SettingsState)
directoryPick directoryPickState onInstancesChanged func([]InstanceRow)
directoryResults chan directoryPickResult 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 { func NewShell(theme *material.Theme) *Shell {
@@ -327,7 +361,7 @@ func NewShell(theme *material.Theme) *Shell {
PathEdgeExecutable: {}, PathEdgeExecutable: {},
PathDefaultUserData: {}, PathDefaultUserData: {},
PathLogDirectory: {}, 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.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`) s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
s.dataDir.SetText(`C:\Users\Public\chub\profiles`) s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
@@ -407,6 +441,19 @@ func (s *Shell) OnStopInstance(stopper InstanceStopper, invalidate func()) {
s.invalidate = invalidate 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()) { func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func()) {
s.instanceRefresher = refresher s.instanceRefresher = refresher
s.invalidate = invalidate s.invalidate = invalidate
@@ -428,8 +475,12 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
s.consumeStartResults() s.consumeStartResults()
s.consumeStopResults() s.consumeStopResults()
s.consumeRefreshResults() s.consumeRefreshResults()
s.consumeManagedExitResults()
s.presentUnexpectedExitIfReady()
s.consumeKeyboard(gtx) s.consumeKeyboard(gtx)
if s.pendingDeleteID != "" { if s.unexpectedExitOpen {
s.consumeUnexpectedExitDialog(gtx)
} else if s.pendingDeleteID != "" {
s.consumeDeleteConfirmation(gtx) s.consumeDeleteConfirmation(gtx)
} else if s.pendingProxyDelete != "" { } else if s.pendingProxyDelete != "" {
s.consumeProxyDeleteConfirmation(gtx) s.consumeProxyDeleteConfirmation(gtx)
@@ -438,6 +489,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
} else { } else {
s.consumeControls(gtx) s.consumeControls(gtx)
} }
s.presentUnexpectedExitIfReady()
mainLayout := func(gtx layout.Context) layout.Dimensions { mainLayout := func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx, return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
layout.Rigid(s.sidebar), 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) return mainLayout(gtx)
} }
if s.unexpectedExitOpen {
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout),
layout.Stacked(s.unexpectedExitDialog),
)
}
if s.pendingDeleteID != "" { if s.pendingDeleteID != "" {
return layout.Stack{Alignment: layout.Center}.Layout(gtx, return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(mainLayout), layout.Expanded(mainLayout),
@@ -546,7 +604,9 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) {
} }
switch keyEvent.Name { switch keyEvent.Name {
case key.NameEscape: case key.NameEscape:
if s.pendingProxyDelete != "" { if s.unexpectedExitOpen {
s.dismissUnexpectedExitNotice()
} else if s.pendingProxyDelete != "" {
s.cancelProxyDelete() s.cancelProxyDelete()
} else if s.proxyPicker.open { } else if s.proxyPicker.open {
s.proxyPicker.open = false s.proxyPicker.open = false
@@ -919,18 +979,24 @@ func (s *Shell) instanceActionButtons(row InstanceRow) layout.Widget {
return func(gtx layout.Context) layout.Dimensions { return func(gtx layout.Context) layout.Dimensions {
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
mode := instanceActionFor(row) mode := instanceActionFor(row)
startClick := s.startClickFor(row.ID)
actionLabel := mode.label + " " + row.Name actionLabel := mode.label + " " + row.Name
actionIcon := instanceStartIcon actionIcon := instanceStartIcon
if mode.stop { if mode.stop {
actionIcon = instanceStopIcon actionIcon = instanceStopIcon
} }
return layout.Flex{Alignment: layout.Middle}.Layout(gtx, dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(s.instanceIconButton(s.startClickFor(row.ID), actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)), layout.Rigid(s.instanceIconButton(startClick, actionIcon, actionLabel, s.theme.Palette.ContrastBg, mode.enabled)),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), 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(s.instanceIconButton(s.editClickFor(row.ID), instanceEditIcon, "编辑 "+row.Name, s.theme.Palette.ContrastBg)),
layout.Rigid(layout.Spacer{Width: unit.Dp(10)}.Layout), 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})), 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) 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 { func statusLabel(theme *material.Theme, status string) layout.Widget {
style := material.Label(theme, unit.Sp(13), status) style := material.Label(theme, unit.Sp(13), status)
style.Color = theme.Palette.Fg style.Color = theme.Palette.Fg
@@ -1957,7 +2096,8 @@ func (s *Shell) requestStop(id string) {
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请稍候。", row.Name) s.instanceFeedback = fmt.Sprintf("%s 正在停止,请稍候。", row.Name)
return return
} }
if s.instanceStopper == nil { stopper := s.instanceStopper
if stopper == nil {
s.instanceFeedback = "浏览器停止服务尚未准备好。" s.instanceFeedback = "浏览器停止服务尚未准备好。"
return return
} }
@@ -1968,14 +2108,19 @@ func (s *Shell) requestStop(id string) {
state.request++ state.request++
request := state.request request := state.request
state.running = true state.running = true
launchGeneration := uint64(0)
if started := s.startStates[id]; started != nil {
launchGeneration = started.request
}
state.previousStatus = row.Status state.previousStatus = row.Status
s.setInstanceStatus(id, "停止中") s.setInstanceStatus(id, "停止中")
s.instanceFeedback = fmt.Sprintf("正在请求 %s 正常退出…", row.Name) s.instanceFeedback = fmt.Sprintf("正在请求 %s 正常退出…", row.Name)
invalidate := s.invalidate
go func() { go func() {
err := s.instanceStopper(context.Background(), row) err := stopper(context.Background(), row, launchGeneration)
s.stopResults <- instanceStopResult{id: id, request: request, err: err} s.stopResults <- instanceStopResult{id: id, request: request, err: err}
if s.invalidate != nil { if invalidate != nil {
s.invalidate() invalidate()
} }
}() }()
} }
@@ -1995,7 +2140,8 @@ func (s *Shell) requestStart(id string) {
s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name) s.instanceFeedback = fmt.Sprintf("%s 正在停止,请等待退出后再启动。", row.Name)
return return
} }
if s.instanceStarter == nil { starter := s.instanceStarter
if starter == nil {
s.instanceFeedback = "浏览器启动服务尚未准备好。" s.instanceFeedback = "浏览器启动服务尚未准备好。"
return return
} }
@@ -2013,11 +2159,12 @@ func (s *Shell) requestStart(id string) {
state.running = true state.running = true
s.setInstanceStatus(id, "启动中") s.setInstanceStatus(id, "启动中")
s.instanceFeedback = fmt.Sprintf("正在启动 %s…", row.Name) s.instanceFeedback = fmt.Sprintf("正在启动 %s…", row.Name)
invalidate := s.invalidate
go func() { 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} s.startResults <- instanceStartResult{id: id, request: request, outcome: outcome, err: err}
if s.invalidate != nil { if invalidate != nil {
s.invalidate() invalidate()
} }
}() }()
} }
@@ -2089,6 +2236,9 @@ func (s *Shell) consumeStartResults() {
status = "运行中(调试不可用)" status = "运行中(调试不可用)"
} }
s.setInstanceRuntime(result.id, status, result.outcome.PID, result.outcome.RemoteDebugPort, "chub_registry") 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 != "" { if result.outcome.Warning != "" {
s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning) s.instanceFeedback = fmt.Sprintf("%s 已启动(PID %d),但%s。", row.Name, result.outcome.PID, result.outcome.Warning)
} else { } else {
@@ -2105,7 +2255,8 @@ func (s *Shell) requestRefresh() {
s.instanceFeedback = "实例状态正在刷新,请稍候。" s.instanceFeedback = "实例状态正在刷新,请稍候。"
return return
} }
if s.instanceRefresher == nil { refresher := s.instanceRefresher
if refresher == nil {
s.instanceFeedback = "实例状态刷新服务尚未准备好。" s.instanceFeedback = "实例状态刷新服务尚未准备好。"
return return
} }
@@ -2118,11 +2269,12 @@ func (s *Shell) requestRefresh() {
request := s.refreshClickState.request request := s.refreshClickState.request
s.refreshClickState.running = true s.refreshClickState.running = true
s.instanceFeedback = "正在刷新已保存实例的状态…" s.instanceFeedback = "正在刷新已保存实例的状态…"
invalidate := s.invalidate
go func() { 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} s.refreshResults <- instanceRefreshResult{request: request, snapshots: snapshots, results: results, err: err}
if s.invalidate != nil { if invalidate != nil {
s.invalidate() 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 { func instanceRefreshFingerprint(row InstanceRow) string {
return strings.Join([]string{row.ID, row.Name, row.Browser, row.UserDataDir, row.TargetURL, row.ProxyID}, "\x00") 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() { func (s *Shell) cancelDelete() {
if row, ok := s.instanceRow(s.pendingDeleteID); ok { if row, ok := s.instanceRow(s.pendingDeleteID); ok {
s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name) s.instanceFeedback = fmt.Sprintf("已取消删除实例“%s”。", row.Name)
+101 -8
View File
@@ -129,7 +129,7 @@ func TestShellStartsInstanceAsynchronouslyAndAppliesResult(t *testing.T) {
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
target := shell.rows[0] target := shell.rows[0]
started := make(chan InstanceRow, 1) 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 started <- row
return InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9666}, nil return InstanceStartOutcome{PID: 4242, RemoteDebugPort: 9666}, nil
}, nil) }, nil)
@@ -165,7 +165,7 @@ func TestShellDoesNotStartTheSameInstanceTwiceWhilePending(t *testing.T) {
entered := make(chan struct{}, 1) entered := make(chan struct{}, 1)
release := make(chan struct{}) release := make(chan struct{})
var calls atomic.Int32 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) calls.Add(1)
entered <- struct{}{} entered <- struct{}{}
<-release <-release
@@ -196,7 +196,7 @@ func TestShellStopsManagedInstanceAsynchronouslyAndRestoresStartAction(t *testin
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
target := shell.rows[0] target := shell.rows[0]
stopped := make(chan InstanceRow, 1) stopped := make(chan InstanceRow, 1)
shell.OnStopInstance(func(_ context.Context, row InstanceRow) error { shell.OnStopInstance(func(_ context.Context, row InstanceRow, _ uint64) error {
stopped <- row stopped <- row
return nil return nil
}, nil) }, nil)
@@ -233,7 +233,7 @@ func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) {
entered := make(chan struct{}, 1) entered := make(chan struct{}, 1)
release := make(chan struct{}) release := make(chan struct{})
var calls atomic.Int32 var calls atomic.Int32
shell.OnStopInstance(func(context.Context, InstanceRow) error { shell.OnStopInstance(func(context.Context, InstanceRow, uint64) error {
calls.Add(1) calls.Add(1)
entered <- struct{}{} entered <- struct{}{}
<-release <-release
@@ -260,8 +260,8 @@ func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) {
external := shell.rows[2] external := shell.rows[2]
var externalStops atomic.Int32 var externalStops atomic.Int32
shell.OnStopInstance(func(context.Context, InstanceRow) error { externalStops.Add(1); return nil }, nil) shell.OnStopInstance(func(context.Context, InstanceRow, uint64) error { externalStops.Add(1); return nil }, nil)
shell.OnStartInstance(func(context.Context, InstanceRow, SettingsState) (InstanceStartOutcome, error) { shell.OnStartInstance(func(context.Context, InstanceRow, SettingsState, uint64) (InstanceStartOutcome, error) {
return InstanceStartOutcome{External: true, Source: "browser_message_window"}, nil return InstanceStartOutcome{External: true, Source: "browser_message_window"}, nil
}, nil) }, nil)
shell.requestInstanceAction(external.ID) shell.requestInstanceAction(external.ID)
@@ -273,7 +273,7 @@ func TestShellDoesNotStopTheSameInstanceTwiceOrExternalInstance(t *testing.T) {
func TestShellRestoresManagedStatusAfterStopFailure(t *testing.T) { func TestShellRestoresManagedStatusAfterStopFailure(t *testing.T) {
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
target := shell.rows[0] 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) shell.requestInstanceAction(target.ID)
var result instanceStopResult var result instanceStopResult
select { select {
@@ -358,7 +358,7 @@ func TestShellNormalizesRemoteDebugStartPort(t *testing.T) {
func TestShellMarksExternalAssociationWithoutManagingLifecycle(t *testing.T) { func TestShellMarksExternalAssociationWithoutManagingLifecycle(t *testing.T) {
shell := NewShell(material.NewTheme()) shell := NewShell(material.NewTheme())
target := shell.rows[2] 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 return InstanceStartOutcome{PID: 16108, RemoteDebugPort: 9668, External: true, Source: "browser_message_window"}, nil
}, 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) 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)
}
}