Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 78 additions & 41 deletions cmd/maxx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"syscall"
"sync/atomic"
"time"

"github.com/awsl-project/maxx/internal/adapter/client"
Expand Down Expand Up @@ -399,6 +401,81 @@ func main() {
codexOAuthServer := core.NewCodexOAuthServer(codexHandler)
codexHandler.SetOAuthServer(codexOAuthServer)

var restartInProgress int32

shutdownServer := func(reason string) {
log.Printf("Initiating graceful shutdown (%s)...", reason)

// Step 1: Wait for active proxy requests to complete
activeCount := requestTracker.ActiveCount()
if activeCount > 0 {
log.Printf("Waiting for %d active proxy requests to complete...", activeCount)
completed := requestTracker.GracefulShutdown(core.GracefulShutdownTimeout)
if !completed {
log.Printf("Graceful shutdown timeout, some requests may be interrupted")
} else {
log.Printf("All proxy requests completed successfully")
}
} else {
// Mark as shutting down to reject new requests
requestTracker.GracefulShutdown(0)
log.Printf("No active proxy requests")
}

// Step 2: Stop pprof manager
shutdownCtx, cancel := context.WithTimeout(context.Background(), core.HTTPShutdownTimeout)
defer cancel()

// Stop background cleanup task
cleanupCancel()

// Stop pprof manager
if err := pprofMgr.Stop(shutdownCtx); err != nil {
log.Printf("Warning: Failed to stop pprof manager: %v", err)
}

// Stop Codex OAuth server
if err := codexOAuthServer.Stop(shutdownCtx); err != nil {
log.Printf("Warning: Failed to stop Codex OAuth server: %v", err)
}

// Step 3: Shutdown HTTP server
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP server graceful shutdown failed: %v, forcing close", err)
if closeErr := server.Close(); closeErr != nil {
log.Printf("Force close error: %v", closeErr)
}
}
}

restartServer := func() error {
if !atomic.CompareAndSwapInt32(&restartInProgress, 0, 1) {
return fmt.Errorf("restart already in progress")
}

shutdownServer("restart")

executable, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to locate executable: %w", err)
}

cmd := exec.Command(executable, os.Args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()

if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start new process: %w", err)
}

log.Printf("[Admin] Started new process (pid=%d). Exiting current process.", cmd.Process.Pid)
os.Exit(0)
return nil
}
Comment on lines +451 to +475
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

重启失败时服务器无法恢复的严重问题

restartServer 函数在调用 shutdownServer 之后(Line 456)才尝试启动新进程。如果 cmd.Start() 失败(Line 468),旧进程已经关闭,服务器将处于不可用状态且无法自动恢复。

建议在关闭服务器之前先验证可执行文件是否可访问,或者调整顺序以降低风险。

🛠️ 建议的修复方案
 restartServer := func() error {
   if !atomic.CompareAndSwapInt32(&restartInProgress, 0, 1) {
     return fmt.Errorf("restart already in progress")
   }

-  shutdownServer("restart")
-
   executable, err := os.Executable()
   if err != nil {
+    atomic.StoreInt32(&restartInProgress, 0)
     return fmt.Errorf("failed to locate executable: %w", err)
   }

+  // Verify executable is accessible before shutting down
+  if _, err := os.Stat(executable); err != nil {
+    atomic.StoreInt32(&restartInProgress, 0)
+    return fmt.Errorf("executable not accessible: %w", err)
+  }
+
+  shutdownServer("restart")
+
   cmd := exec.Command(executable, os.Args[1:]...)
   cmd.Stdout = os.Stdout
   cmd.Stderr = os.Stderr
   cmd.Env = os.Environ()

   if err := cmd.Start(); err != nil {
+    // At this point shutdown already happened, log critical error
+    log.Printf("[Admin] CRITICAL: Failed to start new process after shutdown: %v", err)
     return fmt.Errorf("failed to start new process: %w", err)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/maxx/main.go` around lines 451 - 475, The restartServer function
currently calls shutdownServer before attempting to start the replacement
process, so if exec.Command(...).Start() fails the server is left down; change
the flow in restartServer (and related usage of restartInProgress) to first
validate and start the replacement process and only call
shutdownServer("restart") after cmd.Start() succeeds: locate restartServer,
ensure os.Executable() is checked early, create exec.Command with
Stdout/Stderr/Env, call cmd.Start() and on success log the new pid then call
shutdownServer and exit; if cmd.Start() returns an error, reset the atomic
restartInProgress flag and return the error so the existing process stays up.
Make sure to reference restartInProgress, restartServer, shutdownServer, and
cmd.Start in your change.


adminHandler.SetRestartFunc(restartServer)

// Start server in goroutine
log.Printf("Starting Maxx server %s on %s", version.Info(), *addr)
log.Printf("Data directory: %s", dataDirPath)
Expand All @@ -425,47 +502,7 @@ func main() {
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("Received signal %v, initiating graceful shutdown...", sig)

// Step 1: Wait for active proxy requests to complete
activeCount := requestTracker.ActiveCount()
if activeCount > 0 {
log.Printf("Waiting for %d active proxy requests to complete...", activeCount)
completed := requestTracker.GracefulShutdown(core.GracefulShutdownTimeout)
if !completed {
log.Printf("Graceful shutdown timeout, some requests may be interrupted")
} else {
log.Printf("All proxy requests completed successfully")
}
} else {
// Mark as shutting down to reject new requests
requestTracker.GracefulShutdown(0)
log.Printf("No active proxy requests")
}

// Step 2: Stop pprof manager
shutdownCtx, cancel := context.WithTimeout(context.Background(), core.HTTPShutdownTimeout)
defer cancel()

// Stop background cleanup task
cleanupCancel()

// Stop pprof manager
if err := pprofMgr.Stop(shutdownCtx); err != nil {
log.Printf("Warning: Failed to stop pprof manager: %v", err)
}

// Stop Codex OAuth server
if err := codexOAuthServer.Stop(shutdownCtx); err != nil {
log.Printf("Warning: Failed to stop Codex OAuth server: %v", err)
}

// Step 3: Shutdown HTTP server
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP server graceful shutdown failed: %v, forcing close", err)
if closeErr := server.Close(); closeErr != nil {
log.Printf("Force close error: %v", closeErr)
}
}
shutdownServer(fmt.Sprintf("signal %v", sig))

log.Printf("Server stopped")
}
5 changes: 5 additions & 0 deletions internal/desktop/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ func (a *LauncherApp) startServerAsync() {
}
a.components = components

// Allow Web admin endpoint to trigger desktop restart
if components.AdminHandler != nil {
components.AdminHandler.SetRestartFunc(a.RestartServer)
}

// 设置 Wails context 用于事件广播
if components.WailsBroadcaster != nil {
components.WailsBroadcaster.SetContext(a.ctx)
Expand Down
29 changes: 29 additions & 0 deletions internal/handler/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type AdminHandler struct {
svc *service.AdminService
backupSvc *service.BackupService
logPath string
restartFn func() error
}

// NewAdminHandler creates a new admin handler
Expand All @@ -32,6 +33,11 @@ func NewAdminHandler(svc *service.AdminService, backupSvc *service.BackupService
}
}

// SetRestartFunc sets the restart callback for admin restart endpoint.
func (h *AdminHandler) SetRestartFunc(fn func() error) {
h.restartFn = fn
}

// ServeHTTP routes admin requests
func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/admin")
Expand All @@ -50,6 +56,8 @@ func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}

switch resource {
case "restart":
h.handleRestart(w, r)
case "providers":
h.handleProviders(w, r, id)
case "routes":
Expand Down Expand Up @@ -99,6 +107,27 @@ func (h *AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}

func (h *AdminHandler) handleRestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}

if h.restartFn == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "restart not supported"})
return
}

go func() {
if err := h.restartFn(); err != nil {
log.Printf("[Admin] Restart failed: %v", err)
}
}()

writeJSON(w, http.StatusAccepted, map[string]string{"status": "restarting"})
}

// Provider handlers
func (h *AdminHandler) handleProviders(w http.ResponseWriter, r *http.Request, id uint64) {
// Check for special endpoints
Expand Down
43 changes: 40 additions & 3 deletions web/src/components/layout/app-sidebar/nav-user.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use client';

import { Moon, Sun, Laptop, Sparkles, Gem, Github, ChevronsUp } from 'lucide-react';
import { Moon, Sun, Laptop, Sparkles, Gem, Github, ChevronsUp, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/components/theme-provider';
import { useTransport } from '@/lib/transport/context';
import type { Theme } from '@/lib/theme';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
Expand All @@ -13,6 +14,7 @@ import {
DropdownMenuTrigger,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
Expand All @@ -29,18 +31,45 @@ import {
export function NavUser() {
const { isMobile, state } = useSidebar();
const { t, i18n } = useTranslation();
const { transport } = useTransport();
const { theme, setTheme } = useTheme();
const isCollapsed = !isMobile && state === 'collapsed';
const currentLanguage = (i18n.resolvedLanguage || i18n.language || 'en').toLowerCase().startsWith('zh')
? 'zh'
: 'en';
const currentLanguageLabel =
currentLanguage === 'zh' ? t('settings.languages.zh') : t('settings.languages.en');
const desktopRestartAvailable =
typeof window !== 'undefined' &&
!!(window as unknown as { go?: { desktop?: { LauncherApp?: { RestartServer?: () => unknown } } } })
.go?.desktop?.LauncherApp?.RestartServer;

const handleToggleLanguage = () => {
i18n.changeLanguage(currentLanguage === 'zh' ? 'en' : 'zh');
};

const handleRestartServer = async () => {
if (!window.confirm(t('nav.restartServerConfirm'))) return;
try {
if (desktopRestartAvailable) {
const launcher = (window as unknown as {
go?: { desktop?: { LauncherApp?: { RestartServer?: () => Promise<void> } } };
}).go?.desktop?.LauncherApp;
if (!launcher?.RestartServer) {
throw new Error('Desktop restart is unavailable.');
}
await launcher.RestartServer();
return;
}
await transport.restartServer();
} catch (error) {
console.error('Restart server failed:', error);
if (typeof window !== 'undefined') {
window.alert(t('nav.restartServerFailed'));
}
}
};

const user = {
name: 'Maxx',
avatar: '/logo.png',
Expand Down Expand Up @@ -123,8 +152,8 @@ export function NavUser() {
)}
/>
<DropdownMenuContent
className="!w-40 rounded-lg max-w-xs !min-w-0"
style={{ width: '10rem' }}
className="!w-32 rounded-lg max-w-xs !min-w-0"
style={{ width: '8rem' }}
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
Expand Down Expand Up @@ -194,6 +223,13 @@ export function NavUser() {
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleRestartServer}>
<RefreshCw />
<span>{t('nav.restartServer')}</span>
</DropdownMenuItem>
</>
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand All @@ -202,3 +238,4 @@ export function NavUser() {
);
}


6 changes: 6 additions & 0 deletions web/src/lib/transport/http-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,12 @@ export class HttpTransport implements Transport {
return data;
}

// ===== System API =====

async restartServer(): Promise<void> {
await this.client.post('/restart');
}

// ===== Provider Stats API =====

async getProviderStats(
Expand Down
3 changes: 3 additions & 0 deletions web/src/lib/transport/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ export interface Transport {
// ===== Proxy Status API =====
getProxyStatus(): Promise<ProxyStatus>;

// ===== System API =====
restartServer(): Promise<void>;

// ===== Provider Stats API =====
getProviderStats(clientType?: string, projectId?: number): Promise<Record<number, ProviderStats>>;

Expand Down
3 changes: 3 additions & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"account": "Account",
"notifications": "Notifications",
"theme": "Theme",
"restartServer": "Restart Server",
"restartServerConfirm": "Restart the server? Active connections may be briefly interrupted.",
"restartServerFailed": "Restart failed. Please check the server status.",
"language": "Language",
"logout": "Log out"
},
Expand Down
3 changes: 3 additions & 0 deletions web/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
"account": "账户",
"notifications": "通知",
"theme": "主题",
"restartServer": "重启服务器",
"restartServerConfirm": "确定重启服务器?当前连接可能会短暂中断。",
"restartServerFailed": "重启失败,请检查服务状态。",
"language": "语言",
"logout": "退出登录"
},
Expand Down