feat: toggle managed instance start and stop

This commit is contained in:
QiuSW
2026-07-25 17:10:20 +08:00
parent 3a3c92c875
commit b4e16fa708
7 changed files with 390 additions and 16 deletions
+71
View File
@@ -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
+54
View File
@@ -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