fix(cli): wait for process exit before restarting gateway on Windows (openclaw#27913) thanks @tda1017

Verified:
- pnpm vitest src/cli/update-cli/restart-helper.test.ts
- pnpm check
- pnpm build

Co-authored-by: tda1017 <95275462+tda1017@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
tda
2026-03-02 22:31:03 +08:00
committed by GitHub
parent cd18472405
commit d145518f94
3 changed files with 81 additions and 4 deletions

View File

@@ -11,8 +11,8 @@ describe("restart-helper", () => {
const originalPlatform = process.platform;
const originalGetUid = process.getuid;
async function prepareAndReadScript(env: Record<string, string>) {
const scriptPath = await prepareRestartScript(env);
async function prepareAndReadScript(env: Record<string, string>, 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);
});

View File

@@ -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<string | null> {
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"

View File

@@ -818,11 +818,15 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
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<void> {
result,
opts,
refreshServiceEnv: refreshGatewayServiceEnv,
gatewayPort: resolveGatewayPort(configSnapshot.valid ? configSnapshot.config : undefined),
gatewayPort,
restartScriptPath,
});