feat: notify when managed browsers exit

This commit is contained in:
QiuSW
2026-07-25 18:23:33 +08:00
parent f5b5e95223
commit eeaf4c7826
9 changed files with 647 additions and 92 deletions
+18 -3
View File
@@ -112,6 +112,8 @@ func runWindow(logger *slog.Logger) {
cdp := browser.NewCDPInspector()
externalProfiles := browser.NewWindowsProfileInspector()
proxies := newProxyDirectory()
exitMonitor := newManagedExitMonitor(shell.ReportManagedExit, window.Invalidate)
defer exitMonitor.Close()
shell.OnStartInstance(instanceStarter{
launcher: launcher,
resolver: discoverer,
@@ -120,8 +122,9 @@ func runWindow(logger *slog.Logger) {
remoteDebug: cdp,
portAllocator: cdp,
proxyResolver: proxies,
monitor: exitMonitor,
}.Start, window.Invalidate)
shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher}.Stop, window.Invalidate)
shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher, monitor: exitMonitor}.Stop, window.Invalidate)
shell.OnRefreshInstances(instanceStatusRefresher{
managedProfiles: launcher,
externalProfiles: externalProfiles,
@@ -274,11 +277,12 @@ type profileStopper interface {
type instanceStopper struct {
launcher profileStopper
managedProfiles managedProfileInspector
monitor managedExitObserver
waitTimeout time.Duration
waitInterval time.Duration
}
func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow) error {
func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow, launchGeneration uint64) error {
if row.Status != "运行中" && row.Status != "运行中(调试不可用)" {
return errors.New("实例当前不是可停止的 Chub 托管状态")
}
@@ -298,7 +302,14 @@ func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow) error {
if use.Source != "chub_registry" || (row.PID > 0 && use.PID > 0 && row.PID != use.PID) {
return domain.ErrIdentityMismatch
}
expected := false
if s.monitor != nil {
expected = s.monitor.ExpectStop(row.ID, launchGeneration, row.PID)
}
if err := s.launcher.StopProfile(ctx, row.UserDataDir, false); err != nil {
if expected {
s.monitor.ClearExpectedStop(row.ID, launchGeneration, row.PID)
}
return err
}
timeout := s.waitTimeout
@@ -342,6 +353,7 @@ type instanceStarter struct {
remoteDebug browser.RemoteDebugEndpointInspector
portAllocator browser.RemoteDebugPortAllocator
proxyResolver proxyServerResolver
monitor managedExitObserver
}
type instanceStatusRefresher struct {
@@ -428,7 +440,7 @@ func (s instanceStatusRefresher) refreshOne(ctx context.Context, row ui.Instance
return result
}
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState) (ui.InstanceStartOutcome, error) {
func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings ui.SettingsState, launchGeneration uint64) (ui.InstanceStartOutcome, error) {
kind, err := browserKind(row.Browser)
if err != nil {
return ui.InstanceStartOutcome{}, err
@@ -496,6 +508,9 @@ func (s instanceStarter) Start(ctx context.Context, row ui.InstanceRow, settings
if err != nil {
return ui.InstanceStartOutcome{}, err
}
if s.monitor != nil {
s.monitor.Watch(row.ID, launchGeneration, handle)
}
endpointCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
endpoint, endpointErr := s.remoteDebug.WaitForRemoteDebugPort(endpointCtx, kind, port)
+99 -9
View File
@@ -26,7 +26,7 @@ func TestInstanceStarterBuildsLaunchSpecFromInstanceAndSettings(t *testing.T) {
resolver := &fakeExecutableResolver{path: `C:\Browser\msedge.exe`}
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote}
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`, RemoteDebugStartPort: 9666})
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "edge-a", Browser: "Edge", UserDataDir: `C:\profiles\edge-a`}, ui.SettingsState{EdgePath: `C:\Configured\msedge.exe`, RemoteDebugStartPort: 9666}, 1)
if err != nil || outcome.PID != 4242 || outcome.RemoteDebugPort != 9666 {
t.Fatalf("Start() = %#v, %v", outcome, err)
}
@@ -45,33 +45,74 @@ func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) {
proxies := newProxyDirectory()
proxies.Set([]config.ProxyProfile{{ID: "proxy-sg", Name: "SG", Server: "http://127.0.0.1:8080"}})
starter := instanceStarter{launcher: launcher, resolver: resolver, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, proxyResolver: proxies}
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, ProxyID: "proxy-sg"}, ui.SettingsState{RemoteDebugStartPort: 9666})
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`, ProxyID: "proxy-sg"}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1)
if err != nil || launcher.spec.ProxyServer != "http://127.0.0.1:8080" {
t.Fatalf("proxy launch spec = %#v, error = %v", launcher.spec, err)
}
_, err = starter.Start(context.Background(), ui.InstanceRow{ID: "missing", Browser: "Chrome", UserDataDir: `C:\profiles\missing`, ProxyID: "gone"}, ui.SettingsState{RemoteDebugStartPort: 9666})
_, err = starter.Start(context.Background(), ui.InstanceRow{ID: "missing", Browser: "Chrome", UserDataDir: `C:\profiles\missing`, ProxyID: "gone"}, ui.SettingsState{RemoteDebugStartPort: 9666}, 2)
if err == nil || launcher.calls != 1 {
t.Fatalf("missing proxy error = %v, launch calls = %d", err, launcher.calls)
}
}
func TestInstanceStarterRegistersOnlyLaunchedProcessWithExitMonitor(t *testing.T) {
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9666}, inspectErr: browser.ErrRemoteDebugEndpointUnavailable}
monitor := &fakeManagedExitObserver{}
starter := instanceStarter{launcher: launcher, resolver: &fakeExecutableResolver{path: `C:\Browser\chrome.exe`}, managedProfiles: fakeManagedProfileInspector{}, externalProfiles: fakeExternalProfileInspector{}, remoteDebug: remote, portAllocator: remote, monitor: monitor}
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 7)
if err != nil || monitor.watchID != "chrome-a" || monitor.watchGeneration != 7 || monitor.watchPID != 4242 {
t.Fatalf("monitor watch = %#v, error = %v", monitor, err)
}
}
func TestInstanceStopperGracefullyStopsOnlyVerifiedManagedProfile(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}, releaseOnStop: true}
stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: time.Second, waitInterval: time.Millisecond}
err := stopper.Stop(context.Background(), ui.InstanceRow{ID: "managed", Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`})
err := stopper.Stop(context.Background(), ui.InstanceRow{ID: "managed", Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1)
if err != nil || managed.stopCalls != 1 || managed.force || managed.userDataDir != `C:\profiles\managed` {
t.Fatalf("Stop() = %v, calls=%d force=%v dir=%q", err, managed.stopCalls, managed.force, managed.userDataDir)
}
}
func TestInstanceStopperMarksExpectedManagedExit(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}, releaseOnStop: true}
monitor := &fakeManagedExitObserver{expectReturn: true}
stopper := instanceStopper{launcher: managed, managedProfiles: managed, monitor: monitor, waitTimeout: time.Second, waitInterval: time.Millisecond}
err := stopper.Stop(context.Background(), ui.InstanceRow{ID: "managed", Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 9)
if err != nil || monitor.expectID != "managed" || monitor.expectGeneration != 9 || monitor.expectPID != 4242 || monitor.clearCalls != 0 {
t.Fatalf("expected stop monitor = %#v, error = %v", monitor, err)
}
}
func TestManagedExitMonitorReportsExpectedExit(t *testing.T) {
results := make(chan ui.ManagedInstanceExit, 1)
monitor := newManagedExitMonitor(func(result ui.ManagedInstanceExit) { results <- result }, nil)
defer monitor.Close()
handle := &controlledProcessHandle{pid: 4242, done: make(chan processWaitResult, 1)}
monitor.Watch("managed", 3, handle)
if !monitor.ExpectStop("managed", 3, 4242) {
t.Fatal("expected monitored process to accept expected stop")
}
handle.done <- processWaitResult{code: 17}
select {
case result := <-results:
if result.InstanceID != "managed" || result.LaunchGeneration != 3 || result.PID != 4242 || result.ExitCode != 17 || !result.ExpectedStop {
t.Fatalf("exit result = %#v", result)
}
case <-time.After(time.Second):
t.Fatal("managed exit monitor did not report process exit")
}
}
func TestInstanceStopperRejectsMismatchedOrExternalProfiles(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4243, Source: "chub_registry"}}
stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: 10 * time.Millisecond, waitInterval: time.Millisecond}
err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`})
err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1)
if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 {
t.Fatalf("mismatch Stop() = %v, calls=%d", err, managed.stopCalls)
}
err = stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4243, OccupancySource: "browser_message_window", UserDataDir: `C:\profiles\managed`})
err = stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4243, OccupancySource: "browser_message_window", UserDataDir: `C:\profiles\managed`}, 1)
if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 {
t.Fatalf("external Stop() = %v, calls=%d", err, managed.stopCalls)
}
@@ -80,7 +121,7 @@ func TestInstanceStopperRejectsMismatchedOrExternalProfiles(t *testing.T) {
func TestInstanceStopperDoesNotEscalateWhenGracefulCloseTimesOut(t *testing.T) {
managed := &fakeManagedStopper{use: browser.ProfileUse{Occupied: true, PID: 4242, Source: "chub_registry"}}
stopper := instanceStopper{launcher: managed, managedProfiles: managed, waitTimeout: 10 * time.Millisecond, waitInterval: time.Millisecond}
err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`})
err := stopper.Stop(context.Background(), ui.InstanceRow{Status: "运行中", PID: 4242, OccupancySource: "chub_registry", UserDataDir: `C:\profiles\managed`}, 1)
if err == nil || managed.stopCalls != 1 || managed.force {
t.Fatalf("timeout Stop() = %v, calls=%d force=%v", err, managed.stopCalls, managed.force)
}
@@ -97,7 +138,7 @@ func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *tes
remoteDebug: remote,
portAllocator: remote,
}
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666})
outcome, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1)
if err != nil || !outcome.External || outcome.PID != 16108 || outcome.RemoteDebugPort != 9668 || outcome.Source != browser.ProfileSourceMessageWindow {
t.Fatalf("Start() = %#v, %v", outcome, err)
}
@@ -117,7 +158,7 @@ func TestInstanceStarterRejectsUnverifiedExternalProfile(t *testing.T) {
remoteDebug: remote,
portAllocator: remote,
}
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666})
_, err := starter.Start(context.Background(), ui.InstanceRow{ID: "chrome-a", Browser: "Chrome", UserDataDir: `C:\profiles\chrome-a`}, ui.SettingsState{RemoteDebugStartPort: 9666}, 1)
if !errors.Is(err, domain.ErrProfileOccupied) {
t.Fatalf("Start() error = %v", err)
}
@@ -213,6 +254,55 @@ func (p fakeProcessHandle) PID() int { return p.pid }
func (fakeProcessHandle) Wait(context.Context) (int, error) { return 0, nil }
type processWaitResult struct {
code int
err error
}
type controlledProcessHandle struct {
pid int
done chan processWaitResult
}
func (p *controlledProcessHandle) PID() int { return p.pid }
func (p *controlledProcessHandle) Wait(ctx context.Context) (int, error) {
select {
case result := <-p.done:
return result.code, result.err
case <-ctx.Done():
return 0, ctx.Err()
}
}
type fakeManagedExitObserver struct {
watchID string
watchGeneration uint64
watchPID int
expectID string
expectGeneration uint64
expectPID int
expectReturn bool
clearCalls int
}
func (m *fakeManagedExitObserver) Watch(id string, generation uint64, handle browser.ProcessHandle) {
m.watchID = id
m.watchGeneration = generation
if handle != nil {
m.watchPID = handle.PID()
}
}
func (m *fakeManagedExitObserver) ExpectStop(id string, generation uint64, pid int) bool {
m.expectID = id
m.expectGeneration = generation
m.expectPID = pid
return m.expectReturn
}
func (m *fakeManagedExitObserver) ClearExpectedStop(string, uint64, int) { m.clearCalls++ }
type fakeManagedProfileInspector struct{ use browser.ProfileUse }
func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (browser.ProfileUse, error) {
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"context"
"sync"
"chub/internal/platform/browser"
"chub/internal/ui"
)
// managedExitObserver keeps lifecycle observation scoped to a browser process
// Chub started in the current application session. It never looks up a process
// by name or attaches to an external browser.
type managedExitObserver interface {
Watch(instanceID string, launchGeneration uint64, handle browser.ProcessHandle)
ExpectStop(instanceID string, launchGeneration uint64, pid int) bool
ClearExpectedStop(instanceID string, launchGeneration uint64, pid int)
}
type managedExitProcessKey struct {
instanceID string
launchGeneration uint64
pid int
}
type managedExitMonitor struct {
ctx context.Context
cancel context.CancelFunc
report func(ui.ManagedInstanceExit)
invalidate func()
mu sync.Mutex
active map[string]managedExitProcessKey
expected map[managedExitProcessKey]bool
}
func newManagedExitMonitor(report func(ui.ManagedInstanceExit), invalidate func()) *managedExitMonitor {
ctx, cancel := context.WithCancel(context.Background())
return &managedExitMonitor{
ctx: ctx,
cancel: cancel,
report: report,
invalidate: invalidate,
active: make(map[string]managedExitProcessKey),
expected: make(map[managedExitProcessKey]bool),
}
}
// Close stops notification delivery without stopping browsers. Process waits
// observe this context and return, while the launcher's own registry cleanup
// continues independently.
func (m *managedExitMonitor) Close() {
if m != nil && m.cancel != nil {
m.cancel()
}
}
func (m *managedExitMonitor) Watch(instanceID string, launchGeneration uint64, handle browser.ProcessHandle) {
if m == nil || handle == nil || instanceID == "" || launchGeneration == 0 || handle.PID() <= 0 {
return
}
key := managedExitProcessKey{instanceID: instanceID, launchGeneration: launchGeneration, pid: handle.PID()}
m.mu.Lock()
if _, alreadyWatching := m.active[instanceID]; alreadyWatching {
m.mu.Unlock()
return
}
m.active[instanceID] = key
m.mu.Unlock()
go func() {
exitCode, _ := handle.Wait(m.ctx)
if m.ctx.Err() != nil {
return
}
m.mu.Lock()
expected := m.expected[key]
delete(m.expected, key)
if current, ok := m.active[instanceID]; ok && current == key {
delete(m.active, instanceID)
}
m.mu.Unlock()
if m.report != nil {
m.report(ui.ManagedInstanceExit{InstanceID: instanceID, LaunchGeneration: launchGeneration, PID: key.pid, ExitCode: exitCode, ExpectedStop: expected})
}
if m.invalidate != nil {
m.invalidate()
}
}()
}
func (m *managedExitMonitor) ExpectStop(instanceID string, launchGeneration uint64, pid int) bool {
if m == nil || instanceID == "" || launchGeneration == 0 || pid <= 0 {
return false
}
key := managedExitProcessKey{instanceID: instanceID, launchGeneration: launchGeneration, pid: pid}
m.mu.Lock()
defer m.mu.Unlock()
if current, ok := m.active[instanceID]; !ok || current != key {
return false
}
m.expected[key] = true
return true
}
func (m *managedExitMonitor) ClearExpectedStop(instanceID string, launchGeneration uint64, pid int) {
if m == nil {
return
}
key := managedExitProcessKey{instanceID: instanceID, launchGeneration: launchGeneration, pid: pid}
m.mu.Lock()
delete(m.expected, key)
m.mu.Unlock()
}