From 2031447638d9be820c380d097d881a9cd486b390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84=E5=A7=93=E5=90=8D?= Date: Sun, 26 Jul 2026 17:46:17 +0800 Subject: [PATCH] fix(update): auto-restart after Windows binary swap Schedule platform restart immediately after successful update/rollback so Windows does not stay on the old in-memory process. Prefer native-control restart-app and force-kill stuck PIDs before relaunch. --- .../internal/handler/admin/system_handler.go | 20 +++++++++++++++++-- .../handler/admin/system_handler_test.go | 2 +- .../internal/pkg/sysutil/restart_windows.go | 20 +++++++++++++------ .../pkg/sysutil/restart_windows_test.go | 2 ++ 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/backend/internal/handler/admin/system_handler.go b/backend/internal/handler/admin/system_handler.go index 6afa29f05e6..c67f480903c 100644 --- a/backend/internal/handler/admin/system_handler.go +++ b/backend/internal/handler/admin/system_handler.go @@ -120,8 +120,18 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) { } succeeded = true + // Schedule platform restart after the response is sent. + // On Windows the process that just swapped the binary still runs the + // old image; waiting for a second /restart click can leave the old + // process alive forever (pre-fix builds no-op, or the user never + // clicks). Linux relies on systemd Restart=always after exit. + go func() { + time.Sleep(500 * time.Millisecond) + sysutil.RestartServiceAsync() + }() + return gin.H{ - "message": "Update completed. Please restart the service.", + "message": "Update completed. Service restart initiated.", "need_restart": true, "operation_id": lock.OperationID(), }, nil @@ -189,8 +199,14 @@ func (h *SystemHandler) Rollback(c *gin.Context) { } succeeded = true + // Same as update: binary is on disk; load it without a manual stop/start. + go func() { + time.Sleep(500 * time.Millisecond) + sysutil.RestartServiceAsync() + }() + return gin.H{ - "message": "Rollback completed. Please restart the service.", + "message": "Rollback completed. Service restart initiated.", "need_restart": true, "version": targetVersion, "operation_id": lock.OperationID(), diff --git a/backend/internal/handler/admin/system_handler_test.go b/backend/internal/handler/admin/system_handler_test.go index fabee82d810..b01a8e31503 100644 --- a/backend/internal/handler/admin/system_handler_test.go +++ b/backend/internal/handler/admin/system_handler_test.go @@ -259,7 +259,7 @@ func TestSystemHandlerRollbackWithVersionCallsRollbackToVersion(t *testing.T) { var body systemUpdateResponseEnvelope require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) require.Equal(t, 0, body.Code) - require.Equal(t, "Rollback completed. Please restart the service.", body.Data.Message) + require.Equal(t, "Rollback completed. Service restart initiated.", body.Data.Message) } func TestSystemHandlerRollbackWithDisallowedVersionReturnsBadRequest(t *testing.T) { diff --git a/backend/internal/pkg/sysutil/restart_windows.go b/backend/internal/pkg/sysutil/restart_windows.go index 8af917294bf..e2d2dad0894 100644 --- a/backend/internal/pkg/sysutil/restart_windows.go +++ b/backend/internal/pkg/sysutil/restart_windows.go @@ -68,16 +68,20 @@ func platformRestart() error { } // buildWindowsRestartScript waits for the current PID to exit, then starts the -// service again. Prefer native-control.ps1 (preserves env + log redirection + -// pid file); otherwise re-launch the binary in the same working directory. +// service again. Prefer native-control.ps1 restart-app (app-only, keeps DB/Redis); +// fall back to start; otherwise re-launch the binary in the same working directory. func buildWindowsRestartScript(pid int, exe, workDir string) string { exeDir := filepath.Dir(exe) nativeControl := filepath.Join(exeDir, "native-control.ps1") var relaunch string if _, err := os.Stat(nativeControl); err == nil { + // Prefer restart-app (only cycles sub2api). Older scripts without that + // action fall back to start (safe when the old process has already exited). relaunch = fmt.Sprintf( - `& %s start; if ($LASTEXITCODE -ne 0) { throw "native-control start failed: $LASTEXITCODE" }`, + `& %s restart-app; if ($LASTEXITCODE -ne 0) { & %s start }; `+ + `if ($LASTEXITCODE -ne 0) { throw "native-control restart/start failed: $LASTEXITCODE" }`, + psQuote(nativeControl), psQuote(nativeControl), ) } else { @@ -94,16 +98,20 @@ func buildWindowsRestartScript(pid int, exe, workDir string) string { ) } - // Wait until the old process is gone, then give handles a moment to release - // (important when stdout/stderr log files are still locked on Windows). + // Wait until the old process is gone; force-kill if it ignores graceful exit + // (stuck handlers). Then give handles a moment to release log file locks. return fmt.Sprintf( `$ErrorActionPreference = 'Stop'; `+ `$targetPid = %d; `+ - `for ($i = 0; $i -lt 150; $i++) { `+ + `for ($i = 0; $i -lt 100; $i++) { `+ `if (-not (Get-Process -Id $targetPid -ErrorAction SilentlyContinue)) { break }; `+ `Start-Sleep -Milliseconds 200 `+ `}; `+ `if (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) { `+ + `Stop-Process -Id $targetPid -Force -ErrorAction SilentlyContinue; `+ + `Start-Sleep -Milliseconds 500 `+ + `}; `+ + `if (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) { `+ `throw "timed out waiting for process $targetPid to exit" `+ `}; `+ `Start-Sleep -Milliseconds 500; `+ diff --git a/backend/internal/pkg/sysutil/restart_windows_test.go b/backend/internal/pkg/sysutil/restart_windows_test.go index 853e6349317..a0f81cbfebd 100644 --- a/backend/internal/pkg/sysutil/restart_windows_test.go +++ b/backend/internal/pkg/sysutil/restart_windows_test.go @@ -26,7 +26,9 @@ func TestBuildWindowsRestartScriptPrefersNativeControl(t *testing.T) { require.Contains(t, script, "$targetPid = 4242") require.Contains(t, script, "native-control.ps1") + require.Contains(t, script, "restart-app") require.Contains(t, script, "start") + require.Contains(t, script, "Stop-Process -Id $targetPid -Force") require.NotContains(t, script, "Start-Process -FilePath") }