diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index 802ced311c3..a152f3fdb48 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -11,8 +11,8 @@ describe("restart-helper", () => { const originalPlatform = process.platform; const originalGetUid = process.getuid; - async function prepareAndReadScript(env: Record) { - const scriptPath = await prepareRestartScript(env); + async function prepareAndReadScript(env: Record, gatewayPort = 18789) { + const scriptPath = await prepareRestartScript(env, gatewayPort); expect(scriptPath).toBeTruthy(); const content = await fs.readFile(scriptPath!, "utf-8"); return { scriptPath: scriptPath!, content }; @@ -22,6 +22,39 @@ describe("restart-helper", () => { await fs.unlink(scriptPath); } + function expectWindowsRestartWaitOrdering(content: string, port = 18789) { + const endCommand = 'schtasks /End /TN "'; + const pollAttemptsInit = "set /a attempts=0"; + const pollLabel = ":wait_for_port_release"; + const pollAttemptIncrement = "set /a attempts+=1"; + const pollNetstatCheck = `netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul`; + const forceKillLabel = ":force_kill_listener"; + const forceKillCommand = "taskkill /F /PID %%P >nul 2>&1"; + const portReleasedLabel = ":port_released"; + const runCommand = 'schtasks /Run /TN "'; + const endIndex = content.indexOf(endCommand); + const attemptsInitIndex = content.indexOf(pollAttemptsInit, endIndex); + const pollLabelIndex = content.indexOf(pollLabel, attemptsInitIndex); + const pollAttemptIncrementIndex = content.indexOf(pollAttemptIncrement, pollLabelIndex); + const pollNetstatCheckIndex = content.indexOf(pollNetstatCheck, pollAttemptIncrementIndex); + const forceKillLabelIndex = content.indexOf(forceKillLabel, pollNetstatCheckIndex); + const forceKillCommandIndex = content.indexOf(forceKillCommand, forceKillLabelIndex); + const portReleasedLabelIndex = content.indexOf(portReleasedLabel, forceKillCommandIndex); + const runIndex = content.indexOf(runCommand, portReleasedLabelIndex); + + expect(endIndex).toBeGreaterThanOrEqual(0); + expect(attemptsInitIndex).toBeGreaterThan(endIndex); + expect(pollLabelIndex).toBeGreaterThan(attemptsInitIndex); + expect(pollAttemptIncrementIndex).toBeGreaterThan(pollLabelIndex); + expect(pollNetstatCheckIndex).toBeGreaterThan(pollAttemptIncrementIndex); + expect(forceKillLabelIndex).toBeGreaterThan(pollNetstatCheckIndex); + expect(forceKillCommandIndex).toBeGreaterThan(forceKillLabelIndex); + expect(portReleasedLabelIndex).toBeGreaterThan(forceKillCommandIndex); + expect(runIndex).toBeGreaterThan(portReleasedLabelIndex); + + expect(content).not.toContain("timeout /t 3 /nobreak >nul"); + } + beforeEach(() => { vi.resetAllMocks(); }); @@ -91,6 +124,7 @@ describe("restart-helper", () => { expect(content).toContain("@echo off"); expect(content).toContain('schtasks /End /TN "OpenClaw Gateway"'); expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway"'); + expectWindowsRestartWaitOrdering(content); // Batch self-cleanup expect(content).toContain('del "%~f0"'); await cleanupScript(scriptPath); @@ -105,6 +139,25 @@ describe("restart-helper", () => { }); expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (custom)"'); expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway (custom)"'); + expectWindowsRestartWaitOrdering(content); + await cleanupScript(scriptPath); + }); + + it("uses passed gateway port for port polling on Windows", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + const customPort = 9999; + + const { scriptPath, content } = await prepareAndReadScript( + { + OPENCLAW_PROFILE: "default", + }, + customPort, + ); + expect(content).toContain(`netstat -ano | findstr /R /C:":${customPort} .*LISTENING" >nul`); + expect(content).toContain( + `for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${customPort} .*LISTENING"') do (`, + ); + expectWindowsRestartWaitOrdering(content, customPort); await cleanupScript(scriptPath); }); @@ -135,6 +188,7 @@ describe("restart-helper", () => { OPENCLAW_PROFILE: "production", }); expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (production)"'); + expectWindowsRestartWaitOrdering(content); await cleanupScript(scriptPath); }); diff --git a/src/cli/update-cli/restart-helper.ts b/src/cli/update-cli/restart-helper.ts index d8f828af018..cef4e25418b 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { DEFAULT_GATEWAY_PORT } from "../../config/paths.js"; import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, @@ -55,6 +56,7 @@ function resolveWindowsTaskName(env: NodeJS.ProcessEnv): string { */ export async function prepareRestartScript( env: NodeJS.ProcessEnv = process.env, + gatewayPort: number = DEFAULT_GATEWAY_PORT, ): Promise { const tmpDir = os.tmpdir(); const timestamp = Date.now(); @@ -95,12 +97,29 @@ rm -f "$0" if (!isBatchSafe(taskName)) { return null; } + const port = + Number.isFinite(gatewayPort) && gatewayPort > 0 ? gatewayPort : DEFAULT_GATEWAY_PORT; filename = `openclaw-restart-${timestamp}.bat`; scriptContent = `@echo off REM Standalone restart script — survives parent process termination. REM Wait briefly to ensure file locks are released after update. timeout /t 2 /nobreak >nul schtasks /End /TN "${taskName}" +REM Poll for gateway port release before rerun; force-kill listener if stuck. +set /a attempts=0 +:wait_for_port_release +set /a attempts+=1 +netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul +if errorlevel 1 goto port_released +if %attempts% GEQ 10 goto force_kill_listener +timeout /t 1 /nobreak >nul +goto wait_for_port_release +:force_kill_listener +for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${port} .*LISTENING"') do ( + taskkill /F /PID %%P >nul 2>&1 + goto port_released +) +:port_released schtasks /Run /TN "${taskName}" REM Self-cleanup del "%~f0" diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 1cce6c66e8e..52f68732ca0 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -818,11 +818,15 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { let restartScriptPath: string | null = null; let refreshGatewayServiceEnv = false; + const gatewayPort = resolveGatewayPort( + configSnapshot.valid ? configSnapshot.config : undefined, + process.env, + ); if (shouldRestart) { try { const loaded = await resolveGatewayService().isLoaded({ env: process.env }); if (loaded) { - restartScriptPath = await prepareRestartScript(process.env); + restartScriptPath = await prepareRestartScript(process.env, gatewayPort); refreshGatewayServiceEnv = true; } } catch { @@ -903,7 +907,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { result, opts, refreshServiceEnv: refreshGatewayServiceEnv, - gatewayPort: resolveGatewayPort(configSnapshot.valid ? configSnapshot.config : undefined), + gatewayPort, restartScriptPath, });