惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
H
Help Net Security
Jina AI
Jina AI
V
V2EX
G
Google Developers Blog
B
Blog
GbyAI
GbyAI
U
Unit 42
爱范儿
爱范儿
腾讯CDC
Engineering at Meta
Engineering at Meta
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 三生石上(FineUI控件)
宝玉的分享
宝玉的分享
小众软件
小众软件
D
DataBreaches.Net
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - Franky
博客园 - 聂微东
The Cloudflare Blog
I
InfoQ
Microsoft Azure Blog
Microsoft Azure Blog
Hugging Face - Blog
Hugging Face - Blog
大猫的无限游戏
大猫的无限游戏

Recent Commits to openclaw:main

test: merge chat side-result checks · openclaw/openclaw@ddd2c2a test: merge cron history checks · openclaw/openclaw@f7eb746 test: merge responsive navigation shell checks · openclaw/openclaw@c2e4b47 docs(changelog): add codex oauth fixes · openclaw/openclaw@628e6cd test: merge navigation routing cases · openclaw/openclaw@5d8cecb Tests: mock channel registry bundled fallback · openclaw/openclaw@2b08233 Secrets: avoid broad web search discovery for single plugin config · openclaw/openclaw@a464f59 test: merge config view browser checks · openclaw/openclaw@20cf511 fix(status): align oauth health with runtime · openclaw/openclaw@eed7116 feat: add macOS screen snapshots for monitor preview (#67954) thanks … · openclaw/openclaw@f377db1 fix: report shared auth scopes in hello-ok (#67810) thanks @BunsDev · openclaw/openclaw@0b6c39b Auto-reply: avoid eager bundled route fallback · openclaw/openclaw@3ea1bf4 Tests: narrow session binding contract setup · openclaw/openclaw@54e4e16 fix(macOS): enable undo/redo in webchat composer text input (#34962) · openclaw/openclaw@00951dc Tests: speed up channel setup promotion · openclaw/openclaw@82b529a Docs: refresh agent instructions · openclaw/openclaw@5775fe2 fix(auth): serialize OAuth refresh across agents to fix #26322 (#67876) · openclaw/openclaw@8e79080 test: allow ollama public surface boundary test · openclaw/openclaw@7d4f1a6 Docs: add test performance guardrails · openclaw/openclaw@89706d3 Tests: restore context-engine usage proof · openclaw/openclaw@e4c4f95 Tests: slim context engine runtime coverage · openclaw/openclaw@74c198f ci: retry failed custom checkouts · openclaw/openclaw@0ee5baf test: trim duplicate provider auth onboarding cases · openclaw/openclaw@1ffc02e matrix: fix sessions_spawn --thread subagent session spawning (#67643) · openclaw/openclaw@1ce2596 test: reduce auth choice fixture churn · openclaw/openclaw@857b9cd test: mock health status config boundaries · openclaw/openclaw@9d5ab4a test: mock onboard config io boundary · openclaw/openclaw@299694d test: mock legacy state plugin boundaries · openclaw/openclaw@2713089 test: mock channel install boundaries · openclaw/openclaw@b945248 test: mock doctor preview channel boundaries · openclaw/openclaw@b1a3ad4
fix(e2e): harden Parallels host timeouts · openclaw/openc...
vincentkoc · 2026-06-01 · via Recent Commits to openclaw:main
1-

import { spawn, spawnSync, type SpawnOptions } from "node:child_process";

1+

import { spawn, spawnSync, type SpawnOptions, type SpawnSyncReturns } from "node:child_process";

22

import { writeFile } from "node:fs/promises";

33

import path from "node:path";

44

import { fileURLToPath } from "node:url";

@@ -9,6 +9,13 @@ import type { CommandResult, RunOptions } from "./types.ts";

991010

export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");

111112+

const HOST_COMMAND_MAX_BUFFER_BYTES = 50 * 1024 * 1024;

13+

const HOST_COMMAND_WRAPPER_EXTRA_BUFFER_BYTES = 1024 * 1024;

14+

const HOST_COMMAND_WRAPPER_BACKSTOP_MS = 5_000;

15+

const HOST_COMMAND_CHILD_PID_PREFIX = "__OPENCLAW_HOST_COMMAND_CHILD_PID__";

16+

const HOST_COMMAND_SPAWN_ERROR_PREFIX = "__OPENCLAW_HOST_COMMAND_SPAWN_ERROR__";

17+

const HOST_COMMAND_TIMEOUT_PREFIX = "__OPENCLAW_HOST_COMMAND_TIMEOUT__";

18+1219

type HostCommandInvocation = {

1320

args: string[];

1421

command: string;

@@ -47,6 +54,161 @@ export function die(message: string): never {

4754

process.exit(1);

4855

}

495657+

function signalHostCommandProcess(pid: number | undefined, signal: NodeJS.Signals): void {

58+

if (!pid) {

59+

return;

60+

}

61+

try {

62+

if (process.platform === "win32") {

63+

process.kill(pid, signal);

64+

} else {

65+

process.kill(-pid, signal);

66+

}

67+

} catch (error) {

68+

const code = (error as NodeJS.ErrnoException).code;

69+

if (code !== "ESRCH") {

70+

warn(

71+

`failed to send ${signal} to timed host command process ${pid}: ${

72+

code ?? String(error)

73+

}`,

74+

);

75+

}

76+

}

77+

}

78+79+

const POSIX_TIMEOUT_WRAPPER = String.raw`

80+

const { spawn } = require("node:child_process");

81+

const { readFileSync, writeSync } = require("node:fs");

82+83+

const payload = JSON.parse(readFileSync(0, "utf8"));

84+

const child = spawn(payload.command, payload.args, {

85+

cwd: payload.cwd,

86+

detached: true,

87+

env: payload.env,

88+

shell: payload.shell,

89+

stdio: ["pipe", "pipe", "pipe"],

90+

});

91+

writeSync(

92+

3,

93+

${JSON.stringify(HOST_COMMAND_CHILD_PID_PREFIX)} + JSON.stringify({

94+

pid: child.pid || null,

95+

}) + "\n",

96+

);

97+98+

let timedOut = false;

99+

let killTimer;

100+

let outputExceeded = false;

101+

let stderrBytes = 0;

102+

let stdoutBytes = 0;

103+104+

function writeAllSync(fd, chunk) {

105+

let offset = 0;

106+

while (offset < chunk.byteLength) {

107+

offset += writeSync(fd, chunk, offset, chunk.byteLength - offset);

108+

}

109+

}

110+111+

function signalGroup(signal) {

112+

if (!child.pid) {

113+

return;

114+

}

115+

try {

116+

process.kill(-child.pid, signal);

117+

} catch (error) {

118+

if (error && error.code !== "ESRCH") {

119+

process.stderr.write("failed to send " + signal + " to timed host command process " + child.pid + ": " + (error.code || String(error)) + "\n");

120+

}

121+

}

122+

}

123+124+

function forwardBounded(stream, chunk) {

125+

const currentBytes = stream === "stdout" ? stdoutBytes : stderrBytes;

126+

const nextBytes = currentBytes + chunk.byteLength;

127+

const limit = payload.maxBufferBytes;

128+

if (stream === "stdout") {

129+

stdoutBytes = nextBytes;

130+

} else {

131+

stderrBytes = nextBytes;

132+

}

133+

if (outputExceeded) {

134+

return;

135+

}

136+

if (nextBytes <= limit) {

137+

writeAllSync(stream === "stdout" ? 1 : 2, chunk);

138+

return;

139+

}

140+

outputExceeded = true;

141+

const allowedBytes = Math.max(0, limit - currentBytes);

142+

if (allowedBytes > 0) {

143+

writeAllSync(stream === "stdout" ? 1 : 2, chunk.subarray(0, allowedBytes));

144+

}

145+

writeAllSync(

146+

2,

147+

Buffer.from("host command output exceeded " + limit + " bytes; terminating process group\n"),

148+

);

149+

signalGroup("SIGKILL");

150+

}

151+152+

for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {

153+

process.once(signal, () => {

154+

signalGroup(signal);

155+

process.kill(process.pid, signal);

156+

});

157+

}

158+159+

const timeout = setTimeout(() => {

160+

timedOut = true;

161+

signalGroup("SIGTERM");

162+

killTimer = setTimeout(() => signalGroup("SIGKILL"), 100);

163+

killTimer.unref();

164+

}, payload.timeoutMs);

165+

timeout.unref();

166+167+

child.stdout.on("data", (chunk) => forwardBounded("stdout", chunk));

168+

child.stderr.on("data", (chunk) => forwardBounded("stderr", chunk));

169+

child.stdin.on("error", (error) => {

170+

if (error && error.code !== "EPIPE" && error.code !== "ECONNRESET") {

171+

writeAllSync(2, Buffer.from("host command stdin write failed: " + (error.code || String(error)) + "\n"));

172+

}

173+

});

174+

child.on("error", (error) => {

175+

clearTimeout(timeout);

176+

if (killTimer) {

177+

clearTimeout(killTimer);

178+

}

179+

writeSync(

180+

3,

181+

${JSON.stringify(HOST_COMMAND_SPAWN_ERROR_PREFIX)} + JSON.stringify({

182+

code: error.code || null,

183+

message: error.message,

184+

}) + "\n",

185+

);

186+

process.stderr.write(error.message + "\n");

187+

process.exit(127);

188+

});

189+

child.on("close", (code, signal) => {

190+

clearTimeout(timeout);

191+

if (killTimer) {

192+

clearTimeout(killTimer);

193+

}

194+

if (timedOut) {

195+

signalGroup("SIGKILL");

196+

writeSync(3, ${JSON.stringify(HOST_COMMAND_TIMEOUT_PREFIX)} + "{}\n");

197+

process.exit(124);

198+

}

199+

if (outputExceeded) {

200+

process.exit(1);

201+

}

202+

process.exit(code ?? (signal ? 128 : 1));

203+

});

204+205+

if (payload.input != null) {

206+

child.stdin.end(payload.input);

207+

} else {

208+

child.stdin.end();

209+

}

210+

`;

211+50212

export function shellQuote(value: string): string {

51213

return `'${value.replaceAll("'", `'"'"'`)}'`;

52214

}

@@ -116,20 +278,46 @@ export function resolveHostCommandInvocation(

116278

export function run(command: string, args: string[], options: RunOptions = {}): CommandResult {

117279

const env = { ...process.env, ...options.env };

118280

const invocation = resolveHostCommandInvocation(command, args, { env });

119-

const result = spawnSync(invocation.command, invocation.args, {

120-

cwd: options.cwd ?? repoRoot,

121-

encoding: "utf8",

122-

env: invocation.env ?? env,

123-

input: options.input,

124-

killSignal: "SIGKILL",

125-

maxBuffer: 50 * 1024 * 1024,

126-

stdio: options.quiet ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],

127-

shell: invocation.shell,

128-

timeout: options.timeoutMs,

129-

windowsVerbatimArguments: invocation.windowsVerbatimArguments,

130-

});

281+

const usesPosixTimedWrapper = process.platform !== "win32" && options.timeoutMs !== undefined;

282+

const result =

283+

usesPosixTimedWrapper

284+

? runPosixTimedCommandSync(invocation, env, options)

285+

: spawnSync(invocation.command, invocation.args, {

286+

cwd: options.cwd ?? repoRoot,

287+

encoding: "utf8",

288+

env: invocation.env ?? env,

289+

input: options.input,

290+

killSignal: "SIGKILL",

291+

maxBuffer: HOST_COMMAND_MAX_BUFFER_BYTES,

292+

stdio: options.quiet ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],

293+

shell: invocation.shell,

294+

timeout: options.timeoutMs,

295+

windowsVerbatimArguments: invocation.windowsVerbatimArguments,

296+

});

131297132-

const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";

298+

let wrapperTimedOut = false;

299+

if (usesPosixTimedWrapper) {

300+

const wrapperControl = typeof result.output[3] === "string" ? result.output[3] : "";

301+

const outerWrapperTimedOut =

302+

(result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";

303+

if (outerWrapperTimedOut) {

304+

signalHostCommandProcess(parsePosixTimedWrapperChildPid(wrapperControl), "SIGKILL");

305+

}

306+

wrapperTimedOut = outerWrapperTimedOut || hasPosixTimedWrapperTimeout(wrapperControl);

307+

const spawnError = parsePosixTimedWrapperSpawnError(wrapperControl);

308+

if (spawnError) {

309+

throw spawnError;

310+

}

311+

}

312+

const timedOut =

313+

wrapperTimedOut || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";

314+

if (wrapperTimedOut && options.check !== false) {

315+

const error = new Error(

316+

`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`,

317+

) as NodeJS.ErrnoException;

318+

error.code = "ETIMEDOUT";

319+

throw error;

320+

}

133321

if (result.error && !(timedOut && options.check === false)) {

134322

throw result.error;

135323

}

@@ -152,6 +340,76 @@ export function run(command: string, args: string[], options: RunOptions = {}):

152340

return commandResult;

153341

}

154342343+

function hasPosixTimedWrapperTimeout(controlOutput: string): boolean {

344+

return controlOutput.split("\n").some((entry) => entry.startsWith(HOST_COMMAND_TIMEOUT_PREFIX));

345+

}

346+347+

function parsePosixTimedWrapperChildPid(controlOutput: string): number | undefined {

348+

const line = controlOutput

349+

.split("\n")

350+

.find((entry) => entry.startsWith(HOST_COMMAND_CHILD_PID_PREFIX));

351+

if (!line) {

352+

return undefined;

353+

}

354+

try {

355+

const parsed = JSON.parse(line.slice(HOST_COMMAND_CHILD_PID_PREFIX.length)) as {

356+

pid?: unknown;

357+

};

358+

return typeof parsed.pid === "number" ? parsed.pid : undefined;

359+

} catch {

360+

return undefined;

361+

}

362+

}

363+364+

function parsePosixTimedWrapperSpawnError(stderr: string): NodeJS.ErrnoException | null {

365+

const line = stderr

366+

.split("\n")

367+

.find((entry) => entry.startsWith(HOST_COMMAND_SPAWN_ERROR_PREFIX));

368+

if (!line) {

369+

return null;

370+

}

371+

const raw = line.slice(HOST_COMMAND_SPAWN_ERROR_PREFIX.length);

372+

try {

373+

const parsed = JSON.parse(raw) as { code?: unknown; message?: unknown };

374+

const error = new Error(

375+

typeof parsed.message === "string" ? parsed.message : "host command spawn failed",

376+

) as NodeJS.ErrnoException;

377+

if (typeof parsed.code === "string") {

378+

error.code = parsed.code;

379+

}

380+

return error;

381+

} catch {

382+

return new Error("host command spawn failed") as NodeJS.ErrnoException;

383+

}

384+

}

385+386+

function runPosixTimedCommandSync(

387+

invocation: HostCommandInvocation,

388+

env: NodeJS.ProcessEnv,

389+

options: RunOptions,

390+

): SpawnSyncReturns<string> {

391+

const payload = JSON.stringify({

392+

args: invocation.args,

393+

command: invocation.command,

394+

cwd: options.cwd ?? repoRoot,

395+

env: invocation.env ?? env,

396+

input: options.input,

397+

maxBufferBytes: HOST_COMMAND_MAX_BUFFER_BYTES,

398+

shell: invocation.shell,

399+

timeoutMs: options.timeoutMs,

400+

});

401+

return spawnSync(process.execPath, ["-e", POSIX_TIMEOUT_WRAPPER], {

402+

cwd: options.cwd ?? repoRoot,

403+

encoding: "utf8",

404+

env,

405+

input: payload,

406+

killSignal: "SIGKILL",

407+

maxBuffer: HOST_COMMAND_MAX_BUFFER_BYTES * 2 + HOST_COMMAND_WRAPPER_EXTRA_BUFFER_BYTES,

408+

stdio: ["pipe", "pipe", "pipe", "pipe"],

409+

timeout: (options.timeoutMs ?? 0) + HOST_COMMAND_WRAPPER_BACKSTOP_MS,

410+

});

411+

}

412+155413

export function sh(script: string, options: RunOptions = {}): CommandResult {

156414

return run("bash", ["-lc", script], options);

157415

}

@@ -166,11 +424,31 @@ export async function runStreaming(

166424

const invocation = resolveHostCommandInvocation(command, args, { env });

167425

const child = spawn(invocation.command, invocation.args, {

168426

cwd: options.cwd ?? repoRoot,

427+

detached: process.platform !== "win32" && options.timeoutMs != null,

169428

env: invocation.env ?? env,

170429

shell: invocation.shell,

171430

stdio: ["pipe", "pipe", "pipe"],

172431

windowsVerbatimArguments: invocation.windowsVerbatimArguments,

173432

} satisfies SpawnOptions);

433+

const childPid = child.pid;

434+

const parentSignalHandlers = new Map<NodeJS.Signals, () => void>();

435+

const removeParentSignalHandlers = (): void => {

436+

for (const [signal, handler] of parentSignalHandlers) {

437+

process.off(signal, handler);

438+

}

439+

parentSignalHandlers.clear();

440+

};

441+

if (process.platform !== "win32" && options.timeoutMs != null) {

442+

for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) {

443+

const handler = (): void => {

444+

signalHostCommandProcess(childPid, signal);

445+

removeParentSignalHandlers();

446+

process.kill(process.pid, signal);

447+

};

448+

parentSignalHandlers.set(signal, handler);

449+

process.once(signal, handler);

450+

}

451+

}

174452175453

let log = "";

176454

const append = (chunk: Buffer): void => {

@@ -195,21 +473,36 @@ export async function runStreaming(

195473

}

196474197475

let timedOut = false;

476+

let killTimer: NodeJS.Timeout | undefined;

198477

const timer =

199478

options.timeoutMs == null

200479

? undefined

201480

: setTimeout(() => {

202481

timedOut = true;

203-

child.kill("SIGTERM");

204-

setTimeout(() => child.kill("SIGKILL"), 2_000).unref();

482+

signalHostCommandProcess(childPid, "SIGTERM");

483+

killTimer = setTimeout(() => signalHostCommandProcess(childPid, "SIGKILL"), 2_000);

484+

killTimer.unref();

205485

}, options.timeoutMs);

206486207-

child.on("error", reject);

487+

child.on("error", (error) => {

488+

if (killTimer) {

489+

clearTimeout(killTimer);

490+

}

491+

removeParentSignalHandlers();

492+

reject(error);

493+

});

208494

child.on("close", (code, signal) => {

209495

void (async () => {

210496

if (timer) {

211497

clearTimeout(timer);

212498

}

499+

if (killTimer) {

500+

clearTimeout(killTimer);

501+

}

502+

removeParentSignalHandlers();

503+

if (timedOut) {

504+

signalHostCommandProcess(childPid, "SIGKILL");

505+

}

213506

if (options.logPath) {

214507

await writeFile(options.logPath, log, "utf8");

215508

}