Skip to content

Commit 4b6b1a3

Browse files
committed
fix(process): settle Windows supervisor waits from exit state
1 parent 5613913 commit 4b6b1a3

2 files changed

Lines changed: 112 additions & 2 deletions

File tree

src/process/supervisor/adapters/child.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,19 @@ function createStubChild(pid = 1234) {
2525
child.stderr = new PassThrough() as ChildProcess["stderr"];
2626
Object.defineProperty(child, "pid", { value: pid, configurable: true });
2727
Object.defineProperty(child, "killed", { value: false, configurable: true, writable: true });
28+
Object.defineProperty(child, "exitCode", { value: null, configurable: true, writable: true });
29+
Object.defineProperty(child, "signalCode", { value: null, configurable: true, writable: true });
2830
const killMock = vi.fn(() => true);
2931
child.kill = killMock as ChildProcess["kill"];
3032
const emitClose = (code: number | null, signal: NodeJS.Signals | null = null) => {
3133
child.emit("close", code, signal);
3234
};
33-
return { child, killMock, emitClose };
35+
const emitExit = (code: number | null, signal: NodeJS.Signals | null = null) => {
36+
child.exitCode = code;
37+
child.signalCode = signal;
38+
child.emit("exit", code, signal);
39+
};
40+
return { child, killMock, emitClose, emitExit };
3441
}
3542

3643
async function createAdapterHarness(params?: {
@@ -53,6 +60,14 @@ async function createAdapterHarness(params?: {
5360

5461
describe("createChildAdapter", () => {
5562
const originalServiceMarker = process.env.OPENCLAW_SERVICE_MARKER;
63+
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
64+
65+
const setPlatform = (platform: NodeJS.Platform) => {
66+
Object.defineProperty(process, "platform", {
67+
configurable: true,
68+
value: platform,
69+
});
70+
};
5671

5772
beforeAll(async () => {
5873
({ createChildAdapter } = await import("./child.js"));
@@ -74,6 +89,9 @@ describe("createChildAdapter", () => {
7489
});
7590

7691
afterEach(() => {
92+
if (originalPlatformDescriptor) {
93+
Object.defineProperty(process, "platform", originalPlatformDescriptor);
94+
}
7795
vi.useRealTimers();
7896
});
7997

@@ -155,6 +173,34 @@ describe("createChildAdapter", () => {
155173
expect(killMock).toHaveBeenCalledWith("SIGKILL");
156174
});
157175

176+
it("settles wait from exit state on Windows even when close never arrives", async () => {
177+
vi.useFakeTimers();
178+
setPlatform("win32");
179+
180+
const { adapter, emitExit } = await (async () => {
181+
const stub = createStubChild(8642);
182+
spawnWithFallbackMock.mockResolvedValue({
183+
child: stub.child,
184+
usedFallback: false,
185+
});
186+
const adapter = await createChildAdapter({
187+
argv: ["openclaw", "version"],
188+
stdinMode: "pipe-closed",
189+
});
190+
return { ...stub, adapter };
191+
})();
192+
193+
const settled = vi.fn();
194+
void adapter.wait().then((result) => {
195+
settled(result);
196+
});
197+
198+
emitExit(0, null);
199+
await vi.advanceTimersByTimeAsync(300);
200+
201+
expect(settled).toHaveBeenCalledWith({ code: 0, signal: null });
202+
});
203+
158204
it("disables detached mode in service-managed runtime", async () => {
159205
process.env.OPENCLAW_SERVICE_MARKER = "openclaw";
160206

src/process/supervisor/adapters/child.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type { ManagedRunStdin, SpawnProcessAdapter } from "../types.js";
66
import { toStringEnv } from "./env.js";
77

88
const FORCE_KILL_WAIT_FALLBACK_MS = 4000;
9+
const WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS = 250;
10+
const WINDOWS_CLOSE_STATE_POLL_MS = 10;
911

1012
function resolveCommand(command: string): string {
1113
return resolveWindowsCommandShim({
@@ -122,6 +124,8 @@ export async function createChildAdapter(params: {
122124
let rejectWait: ((reason?: unknown) => void) | null = null;
123125
let waitPromise: Promise<{ code: number | null; signal: NodeJS.Signals | null }> | null = null;
124126
let forceKillWaitFallbackTimer: NodeJS.Timeout | null = null;
127+
let childExitState: { code: number | null; signal: NodeJS.Signals | null } | null = null;
128+
let windowsExitSettleTimer: NodeJS.Timeout | null = null;
125129

126130
const clearForceKillWaitFallback = () => {
127131
if (!forceKillWaitFallbackTimer) {
@@ -131,11 +135,20 @@ export async function createChildAdapter(params: {
131135
forceKillWaitFallbackTimer = null;
132136
};
133137

138+
const clearWindowsExitSettleTimer = () => {
139+
if (!windowsExitSettleTimer) {
140+
return;
141+
}
142+
clearTimeout(windowsExitSettleTimer);
143+
windowsExitSettleTimer = null;
144+
};
145+
134146
const settleWait = (value: { code: number | null; signal: NodeJS.Signals | null }) => {
135147
if (waitResult || waitError !== undefined) {
136148
return;
137149
}
138150
clearForceKillWaitFallback();
151+
clearWindowsExitSettleTimer();
139152
waitResult = value;
140153
if (resolveWait) {
141154
const resolve = resolveWait;
@@ -150,6 +163,7 @@ export async function createChildAdapter(params: {
150163
return;
151164
}
152165
clearForceKillWaitFallback();
166+
clearWindowsExitSettleTimer();
153167
waitError = error;
154168
if (rejectWait) {
155169
const reject = rejectWait;
@@ -168,11 +182,60 @@ export async function createChildAdapter(params: {
168182
forceKillWaitFallbackTimer.unref?.();
169183
};
170184

185+
const resolveObservedExitState = (fallback: {
186+
code: number | null;
187+
signal: NodeJS.Signals | null;
188+
}) => ({
189+
code: childExitState?.code ?? child.exitCode ?? fallback.code,
190+
signal: childExitState?.signal ?? child.signalCode ?? fallback.signal,
191+
});
192+
193+
const hasObservedExitState = () =>
194+
childExitState != null || child.exitCode != null || child.signalCode != null;
195+
196+
const scheduleWindowsExitStateSettle = (fallback: {
197+
code: number | null;
198+
signal: NodeJS.Signals | null;
199+
}) => {
200+
if (process.platform !== "win32") {
201+
return;
202+
}
203+
clearWindowsExitSettleTimer();
204+
windowsExitSettleTimer = setTimeout(() => {
205+
settleWait(resolveObservedExitState(fallback));
206+
}, WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS);
207+
};
208+
171209
child.once("error", (error) => {
172210
rejectPendingWait(error);
173211
});
212+
child.once("exit", (code, signal) => {
213+
childExitState = { code, signal };
214+
scheduleWindowsExitStateSettle({ code, signal });
215+
});
174216
child.once("close", (code, signal) => {
175-
settleWait({ code, signal });
217+
if (process.platform !== "win32" || hasObservedExitState() || code != null || signal != null) {
218+
settleWait(resolveObservedExitState({ code, signal }));
219+
return;
220+
}
221+
222+
const startedAt = Date.now();
223+
const waitForExitState = () => {
224+
if (waitResult || waitError !== undefined) {
225+
return;
226+
}
227+
if (hasObservedExitState()) {
228+
settleWait(resolveObservedExitState({ code, signal }));
229+
return;
230+
}
231+
if (Date.now() - startedAt >= WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS) {
232+
settleWait({ code, signal });
233+
return;
234+
}
235+
setTimeout(waitForExitState, WINDOWS_CLOSE_STATE_POLL_MS);
236+
};
237+
238+
waitForExitState();
176239
});
177240

178241
const wait = async () => {
@@ -229,6 +292,7 @@ export async function createChildAdapter(params: {
229292

230293
const dispose = () => {
231294
clearForceKillWaitFallback();
295+
clearWindowsExitSettleTimer();
232296
child.removeAllListeners();
233297
};
234298

0 commit comments

Comments
 (0)