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()
|
||||
}
|
||||
Reference in New Issue
Block a user