Skip to content

Commit d7ba263

Browse files
committed
fix(supervisor): treat incomplete marker/kind signals as fallthrough to schtasks probe
Refine marker/kind detection logic in detectRespawnSupervisor: only return null (skipping probe) when BOTH OPENCLAW_SERVICE_MARKER and OPENCLAW_SERVICE_KIND are present and kind is not 'gateway'. Previously, setting only marker without kind would short-circuit the schtasks probe, producing false negatives on Windows scheduled tasks. test coverage: - probe via spawnSync with status=null (killed by signal) - probe via spawnSync timeout exception (ETIMEDOUT) - unrelated env vars (no known markers) still triggers probe - whitespace-only marker after trim treated as absent - marker set but service kind missing (incomplete signal) - safe-integer boundary (Number.MAX_SAFE_INTEGER) for content-length
1 parent e16cfc6 commit d7ba263

3 files changed

Lines changed: 94 additions & 4 deletions

File tree

src/infra/supervisor-markers.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,66 @@ describe("win32 schtasks probe fallback", () => {
183183
).toBeNull();
184184
expect(mockSpawnSync).not.toHaveBeenCalled();
185185
});
186+
187+
it("returns null when probe status is null (process killed by signal)", () => {
188+
mockSpawnSync.mockReturnValue({
189+
status: null,
190+
pid: 0,
191+
output: ["", "", ""],
192+
stdout: "",
193+
stderr: "",
194+
signal: "SIGTERM",
195+
});
196+
expect(detectRespawnSupervisor({}, "win32")).toBeNull();
197+
});
198+
199+
it("returns null when probe throws on timeout", () => {
200+
mockSpawnSync.mockImplementation(() => {
201+
throw new Error("ETIMEDOUT");
202+
});
203+
expect(detectRespawnSupervisor({}, "win32")).toBeNull();
204+
});
205+
206+
it("probes when env has unrelated keys but no known markers", () => {
207+
mockSpawnSync.mockReturnValue({
208+
status: 0,
209+
pid: 0,
210+
output: ["", "", ""],
211+
stdout: "",
212+
stderr: "",
213+
signal: null,
214+
});
215+
expect(detectRespawnSupervisor({ PATH: "/usr/bin", HOME: "/home/user" }, "win32")).toBe(
216+
"schtasks",
217+
);
218+
expect(mockSpawnSync).toHaveBeenCalled();
219+
});
220+
221+
it("probes when marker is whitespace-only after trim", () => {
222+
mockSpawnSync.mockReturnValue({
223+
status: 0,
224+
pid: 0,
225+
output: ["", "", ""],
226+
stdout: "",
227+
stderr: "",
228+
signal: null,
229+
});
230+
expect(detectRespawnSupervisor({ OPENCLAW_SERVICE_MARKER: " " }, "win32")).toBe("schtasks");
231+
expect(mockSpawnSync).toHaveBeenCalled();
232+
});
233+
234+
it("probes when marker is set but service kind is missing (incomplete signal)", () => {
235+
mockSpawnSync.mockReturnValue({
236+
status: 0,
237+
pid: 0,
238+
output: ["", "", ""],
239+
stdout: "",
240+
stderr: "",
241+
signal: null,
242+
});
243+
expect(detectRespawnSupervisor({ OPENCLAW_SERVICE_MARKER: "openclaw" }, "win32")).toBe(
244+
"schtasks",
245+
);
246+
expect(mockSpawnSync).toHaveBeenCalled();
247+
});
186248
});

src/infra/supervisor-markers.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Defines process supervisor marker labels for gateway diagnostics.
22
import { spawnSync } from "node:child_process";
3-
import { GATEWAY_LAUNCH_AGENT_LABEL, GATEWAY_WINDOWS_TASK_NAME, resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
3+
import {
4+
GATEWAY_LAUNCH_AGENT_LABEL,
5+
GATEWAY_WINDOWS_TASK_NAME,
6+
resolveGatewayLaunchAgentLabel,
7+
} from "../daemon/constants.js";
48

59
const SUPERVISOR_HINTS = {
610
launchd: ["OPENCLAW_LAUNCHD_LABEL"],
@@ -100,9 +104,11 @@ export function detectRespawnSupervisor(
100104
if (marker && serviceKind === "gateway") {
101105
return "schtasks";
102106
}
103-
// If service markers are present but don't match, respect the explicit
104-
// signal (e.g. marker=worker means "not a gateway").
105-
if (marker || serviceKind) {
107+
// If both service markers are explicitly set and don't match gateway,
108+
// respect the explicit signal (e.g. marker=worker means "not a gateway").
109+
// If only one is set (incomplete signal), we can't be certain — fall
110+
// through to the schtasks probe below.
111+
if (marker && serviceKind && serviceKind !== "gateway") {
106112
return null;
107113
}
108114
// Fallback: probe schtasks directly when no env vars are set at all (e.g.

test/scripts/bounded-response.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,28 @@ describe("scripts bounded response reader", () => {
9999
},
100100
);
101101

102+
it.each(helpers)("accepts safe integer %s content-length values", async (_name, read) => {
103+
const response = {
104+
headers: new Headers({
105+
"content-length": String(Number.MAX_SAFE_INTEGER),
106+
}),
107+
body: {
108+
async cancel() {},
109+
getReader() {
110+
return {
111+
async read() {
112+
return { done: true, value: undefined };
113+
},
114+
async cancel() {},
115+
releaseLock() {},
116+
};
117+
},
118+
},
119+
} as unknown as Response;
120+
121+
await expect(read(response, "probe", Number.MAX_SAFE_INTEGER)).resolves.toBe("");
122+
});
123+
102124
it.each(helpers)(
103125
"rejects unsafe decimal %s content-length values before reading",
104126
async (_name, read) => {

0 commit comments

Comments
 (0)