vibe-proxy/backend/sdk/cliproxy/service_lifecycle.go
2026-08-27 15:02:32 +02:00

349 lines
9.5 KiB
Go

package cliproxy
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/api"
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
)
// Run starts the service and blocks until the context is cancelled or the server stops.
// It initializes all components including authentication, file watching, HTTP server,
// and starts processing requests. The method blocks until the context is cancelled.
//
// Parameters:
// - ctx: The context for controlling the service lifecycle
//
// Returns:
// - error: An error if the service fails to start or run
func (s *Service) Run(ctx context.Context) error {
if s == nil {
return fmt.Errorf("cliproxy: service is nil")
}
if ctx == nil {
ctx = context.Background()
}
ctx, runCancel := context.WithCancel(ctx)
s.homeMu.Lock()
s.runCancel = runCancel
s.homeMu.Unlock()
defer func() {
runCancel()
s.homeMu.Lock()
if s.runCancel != nil {
s.runCancel = nil
}
s.homeMu.Unlock()
}()
usage.StartDefault(ctx)
homeEnabled := s.cfg != nil && s.cfg.Home.Enabled
if homeEnabled {
forceHomeRuntimeConfig(s.cfg)
redisqueue.SetUsageStatisticsEnabled(true)
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
defer func() {
if err := s.Shutdown(shutdownCtx); err != nil {
log.Errorf("service shutdown returned error: %v", err)
}
}()
if !homeEnabled {
if errEnsureAuthDir := s.ensureAuthDir(); errEnsureAuthDir != nil {
return errEnsureAuthDir
}
}
s.applyRetryConfig(s.cfg)
s.configureCooldownStateStore(s.cfg)
s.registerPluginAuthParser()
if s.coreManager != nil && !homeEnabled {
if errLoad := s.coreManager.Load(ctx); errLoad != nil {
log.Warnf("failed to load auth store: %v", errLoad)
}
s.registerConfigAPIKeyAuths(coreauth.WithSkipPersist(ctx), s.cfg)
if s.cfg.SaveCooldownStatus {
if errRestoreCooldown := s.coreManager.RestoreCooldownStates(ctx); errRestoreCooldown != nil {
log.Warnf("failed to restore cooldown state: %v", errRestoreCooldown)
}
}
}
if !homeEnabled {
tokenResult, err := s.tokenProvider.Load(ctx, s.cfg)
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
if tokenResult == nil {
tokenResult = &TokenClientResult{}
}
apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg)
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
if apiKeyResult == nil {
apiKeyResult = &APIKeyClientResult{}
}
}
// legacy clients removed; no caches to refresh
s.ensureWebsocketGateway()
if homeEnabled {
s.registerAvailableExecutors(ctx, executorRegistrationOptions{
includeBaseline: true,
})
// Home mode does not expose in-process Redis RESP usage output; usage is forwarded to home instead.
redisqueue.SetEnabled(true)
}
// handlers no longer depend on legacy clients; pass nil slice initially
s.server = api.NewServer(s.cfg, s.coreManager, s.accessManager, s.configPath, s.serverOptions...)
s.syncPluginRuntimeConfig(ctx)
if homeEnabled {
s.syncPluginModelRuntime(ctx)
}
if s.authManager == nil {
s.authManager = newDefaultAuthManager()
}
if homeEnabled {
s.startHomeSubscriber(ctx)
}
if s.hooks.OnBeforeStart != nil {
s.hooks.OnBeforeStart(s.cfg)
}
s.serverErr = make(chan error, 1)
go func() {
if errStart := s.server.Start(); errStart != nil {
s.serverErr <- errStart
} else {
s.serverErr <- nil
}
}()
time.Sleep(100 * time.Millisecond)
fmt.Printf("API server started successfully on: %s:%d\n", s.cfg.Host, s.cfg.Port)
s.applyPprofConfig(s.cfg)
if s.hooks.OnAfterStart != nil {
s.hooks.OnAfterStart(s)
}
if !homeEnabled {
var watcherWrapper *WatcherWrapper
reloadCallback := func(newCfg *config.Config) { s.applyWatcherConfigUpdate(newCfg) }
watcherWrapper, errCreate := s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback)
if errCreate != nil {
return fmt.Errorf("cliproxy: failed to create watcher: %w", errCreate)
}
s.watcher = watcherWrapper
s.ensureAuthUpdateQueue(ctx)
if s.authUpdates != nil {
watcherWrapper.SetAuthUpdateQueue(s.authUpdates)
}
watcherWrapper.SetConfig(s.cfg)
s.registerPluginAuthParser()
watcherCtx, watcherCancel := context.WithCancel(context.Background())
s.watcherCancel = watcherCancel
if errStart := watcherWrapper.Start(watcherCtx); errStart != nil {
return fmt.Errorf("cliproxy: failed to start watcher: %w", errStart)
}
log.Info("file watcher started for config and auth directory changes")
s.syncPluginModelRuntime(ctx)
}
s.registerModelRefreshCallback()
// Prefer core auth manager auto refresh if available.
if s.coreManager != nil && !homeEnabled {
interval := 15 * time.Minute
s.coreManager.StartAutoRefresh(context.Background(), interval)
log.Infof("core auth auto-refresh started (interval=%s)", interval)
}
select {
case <-ctx.Done():
log.Debug("service context cancelled, shutting down...")
return ctx.Err()
case errServer := <-s.serverErr:
return errServer
}
}
// Shutdown gracefully stops background workers and the HTTP server.
// It ensures all resources are properly cleaned up and connections are closed.
// The shutdown is idempotent and can be called multiple times safely.
//
// Parameters:
// - ctx: The context for controlling the shutdown timeout
//
// Returns:
// - error: An error if shutdown fails
func (s *Service) Shutdown(ctx context.Context) error {
if s == nil {
return nil
}
var shutdownErr error
s.shutdownOnce.Do(func() {
if ctx == nil {
ctx = context.Background()
}
s.homeLifecycleMu.Lock()
if supervisor := s.homeSupervisor; supervisor != nil {
s.homeConfigCommitMu.Lock()
supervisor.cancel()
s.homeConfigCommitMu.Unlock()
<-supervisor.done
}
s.homeMu.Lock()
homeCancel := s.homeCancel
homeClient := s.homeClient
homeRegistry := s.homeRegistry
homeDispatchBundle := s.homeDispatchBundle
homeForwarder := s.homeLogForwarder
homeForwarderClient := s.homeLogForwarderClient
s.homeGeneration++
s.homeCancel = nil
s.homeClient = nil
s.homeRegistry = nil
s.homeDispatchBundle = nil
s.homeDrainBound = 0
s.homeLogForwarder = nil
s.homeLogForwarderClient = nil
s.homeMu.Unlock()
if s.coreManager != nil {
s.coreManager.ClearHomeDispatchBundle(homeDispatchBundle)
}
home.ClearCurrentIf(homeClient)
if homeCancel != nil {
homeCancel()
}
if homeRegistry != nil {
if errClose := homeRegistry.Close(); errClose != nil {
log.WithError(errClose).Warn("failed to close Home execution registry during shutdown")
}
}
if homeClient != nil {
homeClient.Close()
}
if homeForwarder != nil {
if homeForwarderClient == homeClient {
homeForwarder.Deactivate(homeClient)
}
homeForwarder.Stop()
}
s.homeLifecycleMu.Unlock()
// legacy refresh loop removed; only stopping core auth manager below
if s.watcherCancel != nil {
s.watcherCancel()
}
if s.coreManager != nil {
s.coreManager.StopAutoRefresh()
}
if s.watcher != nil {
if err := s.watcher.Stop(); err != nil {
log.Errorf("failed to stop file watcher: %v", err)
shutdownErr = err
}
}
if s.wsGateway != nil {
if err := s.wsGateway.Stop(ctx); err != nil {
log.Errorf("failed to stop websocket gateway: %v", err)
if shutdownErr == nil {
shutdownErr = err
}
}
}
if s.authQueueStop != nil {
s.authQueueStop()
s.authQueueStop = nil
}
if errShutdownPprof := s.shutdownPprof(ctx); errShutdownPprof != nil {
log.Errorf("failed to stop pprof server: %v", errShutdownPprof)
if shutdownErr == nil {
shutdownErr = errShutdownPprof
}
}
// no legacy clients to persist
if s.server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := s.server.Stop(shutdownCtx); err != nil {
log.Errorf("error stopping API server: %v", err)
if shutdownErr == nil {
shutdownErr = err
}
}
}
if s.pluginHost != nil {
sdktranslator.SetPluginHooks(nil)
sdkAuth.RegisterPluginAuthParser(nil)
if s.watcher != nil {
s.watcher.SetPluginAuthParser(nil)
}
s.pluginHost.ApplyConfig(ctx, &config.Config{})
s.pluginHost.RegisterModels(ctx, registry.GetGlobalRegistry())
s.registerAvailableExecutors(ctx, executorRegistrationOptions{
includePlugins: true,
})
s.pluginHost.RegisterFrontendAuthProviders()
s.pluginHost.ShutdownAllContext(ctx)
if s.accessManager != nil {
s.accessManager.SetProviders(sdkaccess.RegisteredProviders())
}
}
usage.StopDefault()
})
return shutdownErr
}
func (s *Service) ensureAuthDir() error {
info, err := os.Stat(s.cfg.AuthDir)
if err != nil {
if os.IsNotExist(err) {
if mkErr := os.MkdirAll(s.cfg.AuthDir, 0o755); mkErr != nil {
return fmt.Errorf("cliproxy: failed to create auth directory %s: %w", s.cfg.AuthDir, mkErr)
}
log.Infof("created missing auth directory: %s", s.cfg.AuthDir)
return nil
}
return fmt.Errorf("cliproxy: error checking auth directory %s: %w", s.cfg.AuthDir, err)
}
if !info.IsDir() {
return fmt.Errorf("cliproxy: auth path exists but is not a directory: %s", s.cfg.AuthDir)
}
return nil
}