Skip to content

Commit d145518

Browse files
tda1017Takhoffman
andauthored
fix(cli): wait for process exit before restarting gateway on Windows (#27913) thanks @tda1017
Verified: - pnpm vitest src/cli/update-cli/restart-helper.test.ts - pnpm check - pnpm build Co-authored-by: tda1017 <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent cd18472 commit d145518

3 files changed

Lines changed: 81 additions & 4 deletions

File tree

src/cli/update-cli/restart-helper.test.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ describe("restart-helper", () => {
1111
const originalPlatform = process.platform;
1212
const originalGetUid = process.getuid;
1313

14-
async function prepareAndReadScript(env: Record<string, string>) {
15-
const scriptPath = await prepareRestartScript(env);
14+
async function prepareAndReadScript(env: Record<string, string>, gatewayPort = 18789) {
15+
const scriptPath = await prepareRestartScript(env, gatewayPort);
1616
expect(scriptPath).toBeTruthy();
1717
const content = await fs.readFile(scriptPath!, "utf-8");
1818
return { scriptPath: scriptPath!, content };
@@ -22,6 +22,39 @@ describe("restart-helper", () => {
2222
await fs.unlink(scriptPath);
2323
}
2424

25+
function expectWindowsRestartWaitOrdering(content: string, port = 18789) {
26+
const endCommand = 'schtasks /End /TN "';
27+
const pollAttemptsInit = "set /a attempts=0";
28+
const pollLabel = ":wait_for_port_release";
29+
const pollAttemptIncrement = "set /a attempts+=1";
30+
const pollNetstatCheck = `netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul`;
31+
const forceKillLabel = ":force_kill_listener";
32+
const forceKillCommand = "taskkill /F /PID %%P >nul 2>&1";
33+
const portReleasedLabel = ":port_released";
34+
const runCommand = 'schtasks /Run /TN "';
35+
const endIndex = content.indexOf(endCommand);
36+
const attemptsInitIndex = content.indexOf(pollAttemptsInit, endIndex);
37+
const pollLabelIndex = content.indexOf(pollLabel, attemptsInitIndex);
38+
const pollAttemptIncrementIndex = content.indexOf(pollAttemptIncrement, pollLabelIndex);
39+
const pollNetstatCheckIndex = content.indexOf(pollNetstatCheck, pollAttemptIncrementIndex);
40+
const forceKillLabelIndex = content.indexOf(forceKillLabel, pollNetstatCheckIndex);
41+
const forceKillCommandIndex = content.indexOf(forceKillCommand, forceKillLabelIndex);
42+
const portReleasedLabelIndex = content.indexOf(portReleasedLabel, forceKillCommandIndex);
43+
const runIndex = content.indexOf(runCommand, portReleasedLabelIndex);
44+
45+
expect(endIndex).toBeGreaterThanOrEqual(0);
46+
expect(attemptsInitIndex).toBeGreaterThan(endIndex);
47+
expect(pollLabelIndex).toBeGreaterThan(attemptsInitIndex);
48+
expect(pollAttemptIncrementIndex).toBeGreaterThan(pollLabelIndex);
49+
expect(pollNetstatCheckIndex).toBeGreaterThan(pollAttemptIncrementIndex);
50+
expect(forceKillLabelIndex).toBeGreaterThan(pollNetstatCheckIndex);
51+
expect(forceKillCommandIndex).toBeGreaterThan(forceKillLabelIndex);
52+
expect(portReleasedLabelIndex).toBeGreaterThan(forceKillCommandIndex);
53+
expect(runIndex).toBeGreaterThan(portReleasedLabelIndex);
54+
55+
expect(content).not.toContain("timeout /t 3 /nobreak >nul");
56+
}
57+
2558
beforeEach(() => {
2659
vi.resetAllMocks();
2760
});
@@ -91,6 +124,7 @@ describe("restart-helper", () => {
91124
expect(content).toContain("@echo off");
92125
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway"');
93126
expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway"');
127+
expectWindowsRestartWaitOrdering(content);
94128
// Batch self-cleanup
95129
expect(content).toContain('del "%~f0"');
96130
await cleanupScript(scriptPath);
@@ -105,6 +139,25 @@ describe("restart-helper", () => {
105139
});
106140
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (custom)"');
107141
expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway (custom)"');
142+
expectWindowsRestartWaitOrdering(content);
143+
await cleanupScript(scriptPath);
144+
});
145+
146+
it("uses passed gateway port for port polling on Windows", async () => {
147+
Object.defineProperty(process, "platform", { value: "win32" });
148+
const customPort = 9999;
149+
150+
const { scriptPath, content } = await prepareAndReadScript(
151+
{
152+
OPENCLAW_PROFILE: "default",
153+
},
154+
customPort,
155+
);
156+
expect(content).toContain(`netstat -ano | findstr /R /C:":${customPort} .*LISTENING" >nul`);
157+
expect(content).toContain(
158+
`for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${customPort} .*LISTENING"') do (`,
159+
);
160+
expectWindowsRestartWaitOrdering(content, customPort);
108161
await cleanupScript(scriptPath);
109162
});
110163

@@ -135,6 +188,7 @@ describe("restart-helper", () => {
135188
OPENCLAW_PROFILE: "production",
136189
});
137190
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (production)"');
191+
expectWindowsRestartWaitOrdering(content);
138192
await cleanupScript(scriptPath);
139193
});
140194

src/cli/update-cli/restart-helper.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
22
import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
5+
import { DEFAULT_GATEWAY_PORT } from "../../config/paths.js";
56
import {
67
resolveGatewayLaunchAgentLabel,
78
resolveGatewaySystemdServiceName,
@@ -55,6 +56,7 @@ function resolveWindowsTaskName(env: NodeJS.ProcessEnv): string {
5556
*/
5657
export async function prepareRestartScript(
5758
env: NodeJS.ProcessEnv = process.env,
59+
gatewayPort: number = DEFAULT_GATEWAY_PORT,
5860
): Promise<string | null> {
5961
const tmpDir = os.tmpdir();
6062
const timestamp = Date.now();
@@ -95,12 +97,29 @@ rm -f "$0"
9597
if (!isBatchSafe(taskName)) {
9698
return null;
9799
}
100+
const port =
101+
Number.isFinite(gatewayPort) && gatewayPort > 0 ? gatewayPort : DEFAULT_GATEWAY_PORT;
98102
filename = `openclaw-restart-${timestamp}.bat`;
99103
scriptContent = `@echo off
100104
REM Standalone restart script — survives parent process termination.
101105
REM Wait briefly to ensure file locks are released after update.
102106
timeout /t 2 /nobreak >nul
103107
schtasks /End /TN "${taskName}"
108+
REM Poll for gateway port release before rerun; force-kill listener if stuck.
109+
set /a attempts=0
110+
:wait_for_port_release
111+
set /a attempts+=1
112+
netstat -ano | findstr /R /C:":${port} .*LISTENING" >nul
113+
if errorlevel 1 goto port_released
114+
if %attempts% GEQ 10 goto force_kill_listener
115+
timeout /t 1 /nobreak >nul
116+
goto wait_for_port_release
117+
:force_kill_listener
118+
for /f "tokens=5" %%P in ('netstat -ano ^| findstr /R /C:":${port} .*LISTENING"') do (
119+
taskkill /F /PID %%P >nul 2>&1
120+
goto port_released
121+
)
122+
:port_released
104123
schtasks /Run /TN "${taskName}"
105124
REM Self-cleanup
106125
del "%~f0"

src/cli/update-cli/update-command.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -818,11 +818,15 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
818818

819819
let restartScriptPath: string | null = null;
820820
let refreshGatewayServiceEnv = false;
821+
const gatewayPort = resolveGatewayPort(
822+
configSnapshot.valid ? configSnapshot.config : undefined,
823+
process.env,
824+
);
821825
if (shouldRestart) {
822826
try {
823827
const loaded = await resolveGatewayService().isLoaded({ env: process.env });
824828
if (loaded) {
825-
restartScriptPath = await prepareRestartScript(process.env);
829+
restartScriptPath = await prepareRestartScript(process.env, gatewayPort);
826830
refreshGatewayServiceEnv = true;
827831
}
828832
} catch {
@@ -903,7 +907,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
903907
result,
904908
opts,
905909
refreshServiceEnv: refreshGatewayServiceEnv,
906-
gatewayPort: resolveGatewayPort(configSnapshot.valid ? configSnapshot.config : undefined),
910+
gatewayPort,
907911
restartScriptPath,
908912
});
909913

0 commit comments

Comments
 (0)