feat: toggle managed instance start and stop
This commit is contained in:
@@ -121,6 +121,7 @@ func runWindow(logger *slog.Logger) {
|
||||
portAllocator: cdp,
|
||||
proxyResolver: proxies,
|
||||
}.Start, window.Invalidate)
|
||||
shell.OnStopInstance(instanceStopper{launcher: launcher, managedProfiles: launcher}.Stop, window.Invalidate)
|
||||
shell.OnRefreshInstances(instanceStatusRefresher{
|
||||
managedProfiles: launcher,
|
||||
externalProfiles: externalProfiles,
|
||||
@@ -263,6 +264,76 @@ type managedProfileInspector interface {
|
||||
InspectProfile(context.Context, string) (browser.ProfileUse, error)
|
||||
}
|
||||
|
||||
type profileStopper interface {
|
||||
StopProfile(context.Context, string, bool) error
|
||||
}
|
||||
|
||||
// instanceStopper is deliberately scoped to Chub's in-memory registry. It
|
||||
// requests graceful exit only, then waits for that registry entry to disappear;
|
||||
// it never enumerates or terminates browsers by process name.
|
||||
type instanceStopper struct {
|
||||
launcher profileStopper
|
||||
managedProfiles managedProfileInspector
|
||||
waitTimeout time.Duration
|
||||
waitInterval time.Duration
|
||||
}
|
||||
|
||||
func (s instanceStopper) Stop(ctx context.Context, row ui.InstanceRow) error {
|
||||
if row.Status != "运行中" && row.Status != "运行中(调试不可用)" {
|
||||
return errors.New("实例当前不是可停止的 Chub 托管状态")
|
||||
}
|
||||
if row.OccupancySource != "" && row.OccupancySource != "chub_registry" {
|
||||
return domain.ErrIdentityMismatch
|
||||
}
|
||||
if s.launcher == nil || s.managedProfiles == nil {
|
||||
return errors.New("浏览器停止服务尚未准备好")
|
||||
}
|
||||
use, err := s.managedProfiles.InspectProfile(ctx, row.UserDataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查 Chub 实例身份失败:%w", err)
|
||||
}
|
||||
if !use.Occupied {
|
||||
return domain.ErrInstanceNotFound
|
||||
}
|
||||
if use.Source != "chub_registry" || (row.PID > 0 && use.PID > 0 && row.PID != use.PID) {
|
||||
return domain.ErrIdentityMismatch
|
||||
}
|
||||
if err := s.launcher.StopProfile(ctx, row.UserDataDir, false); err != nil {
|
||||
return err
|
||||
}
|
||||
timeout := s.waitTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
interval := s.waitInterval
|
||||
if interval <= 0 {
|
||||
interval = 100 * time.Millisecond
|
||||
}
|
||||
waitCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
for {
|
||||
use, err = s.managedProfiles.InspectProfile(waitCtx, row.UserDataDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return errors.New("浏览器未在等待时间内退出;可稍后刷新状态")
|
||||
}
|
||||
return fmt.Errorf("检查浏览器退出状态失败:%w", err)
|
||||
}
|
||||
if !use.Occupied {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
return errors.New("浏览器未在等待时间内退出;可稍后刷新状态")
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type instanceStarter struct {
|
||||
launcher processLauncher
|
||||
resolver executableResolver
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chub/internal/domain"
|
||||
"chub/internal/platform/browser"
|
||||
@@ -54,6 +55,37 @@ func TestInstanceStarterResolvesSelectedProxyAtLaunch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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`})
|
||||
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 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`})
|
||||
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`})
|
||||
if !errors.Is(err, domain.ErrIdentityMismatch) || managed.stopCalls != 0 {
|
||||
t.Fatalf("external Stop() = %v, calls=%d", err, managed.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
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`})
|
||||
if err == nil || managed.stopCalls != 1 || managed.force {
|
||||
t.Fatalf("timeout Stop() = %v, calls=%d force=%v", err, managed.stopCalls, managed.force)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceStarterAssociatesVerifiedExternalProfileWithoutLaunching(t *testing.T) {
|
||||
launcher := &fakeProcessLauncher{handle: fakeProcessHandle{pid: 4242}}
|
||||
remote := &fakeRemoteDebugInspector{endpoint: browser.RemoteDebugEndpoint{Port: 9668}}
|
||||
@@ -187,6 +219,28 @@ func (i fakeManagedProfileInspector) InspectProfile(context.Context, string) (br
|
||||
return i.use, nil
|
||||
}
|
||||
|
||||
type fakeManagedStopper struct {
|
||||
use browser.ProfileUse
|
||||
stopCalls int
|
||||
force bool
|
||||
userDataDir string
|
||||
releaseOnStop bool
|
||||
}
|
||||
|
||||
func (s *fakeManagedStopper) InspectProfile(context.Context, string) (browser.ProfileUse, error) {
|
||||
return s.use, nil
|
||||
}
|
||||
|
||||
func (s *fakeManagedStopper) StopProfile(_ context.Context, userDataDir string, force bool) error {
|
||||
s.stopCalls++
|
||||
s.force = force
|
||||
s.userDataDir = userDataDir
|
||||
if s.releaseOnStop {
|
||||
s.use.Occupied = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeExternalProfileInspector struct {
|
||||
use browser.ProfileUse
|
||||
err error
|
||||
|
||||
Reference in New Issue
Block a user