fix(build): keep CLI help fallback asynchronous (#120454)

This commit is contained in:
Peter Steinberger
2026-08-07 21:28:52 -07:00
committed by GitHub
parent e9d3cf1a64
commit 263ad629c1
2 changed files with 46 additions and 48 deletions

View File

@@ -665,9 +665,9 @@ export async function renderBundledRootHelpText(
});
}
function renderSourceRootHelpText(renderContext?: RootHelpRenderContext): string {
async function renderSourceRootHelpText(renderContext?: RootHelpRenderContext): Promise<string> {
if (!renderContext) {
return withIsolatedRootHelpRenderContext(extensionsDir, renderSourceRootHelpText);
return await withIsolatedRootHelpRenderContext(extensionsDir, renderSourceRootHelpText);
}
const moduleUrl = pathToFileURL(path.join(rootDir, "src/cli/program/root-help.ts")).href;
const renderOptions = {
@@ -684,28 +684,12 @@ function renderSourceRootHelpText(renderContext?: RootHelpRenderContext): string
"process.stdout.write(output);",
"process.exit(0);",
].join("\n");
const result = spawnSync(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", inlineModule],
{
cwd: rootDir,
encoding: "utf8",
env: renderContext.env,
killSignal: "SIGKILL",
timeout: ROOT_HELP_RENDER_TIMEOUT_MS,
},
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = result.stderr?.trim();
throw new Error(
"Failed to render source root help" +
(stderr ? `: ${stderr}` : result.signal ? `: terminated by ${result.signal}` : ""),
);
}
return result.stdout ?? "";
return await spawnText(["--import", "tsx", "--input-type=module", "--eval", inlineModule], {
cwd: rootDir,
env: renderContext.env ?? process.env,
failureMessage: "Failed to render source root help",
timeoutMs: ROOT_HELP_RENDER_TIMEOUT_MS,
});
}
async function renderSourceBrowserHelpText(renderContext: RootHelpRenderContext): Promise<string> {
@@ -777,7 +761,7 @@ export async function writeCliStartupMetadata(options?: {
extensionsDir?: string;
sourceRootDir?: string;
renderBundledRootHelpText?: typeof renderBundledRootHelpText;
renderSourceRootHelpText?: typeof renderSourceRootHelpText;
renderSourceRootHelpText?: (renderContext: RootHelpRenderContext) => Awaitable<string>;
renderSourceBrowserHelpText?: (renderContext: RootHelpRenderContext) => Awaitable<string>;
renderSourceSecretsHelpText?: (renderContext: RootHelpRenderContext) => Awaitable<string>;
renderSourceNodesHelpText?: (renderContext: RootHelpRenderContext) => Awaitable<string>;
@@ -877,10 +861,11 @@ export async function writeCliStartupMetadata(options?: {
renderContext,
);
} catch {
// The spawnSync source fallback blocks the event loop; that is fine for
// this rare recovery path (missing/broken bundle) and only delays
// draining sibling render output, not its correctness.
return (options?.renderSourceRootHelpText ?? renderSourceRootHelpText)(renderContext);
// Keep the fallback asynchronous: sibling help renders share this
// event loop, so blocking here can turn completed children into false timeouts.
return await (options?.renderSourceRootHelpText ?? renderSourceRootHelpText)(
renderContext,
);
}
})();
const hasCustomCommandRenderer =

View File

@@ -1,5 +1,5 @@
// Write Cli Startup Metadata tests cover write cli startup metadata script behavior.
import { spawn, spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import fs, { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
@@ -12,7 +12,7 @@ import { createScriptTestHarness } from "./test-helpers.js";
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, spawnSync: vi.fn(actual.spawnSync) };
return { ...actual, spawn: vi.fn(actual.spawn) };
});
// These subprocess tests use explicit ready/close signals; timeout only catches broken fixtures.
@@ -119,25 +119,38 @@ async function waitForChildClose(
describe("write-cli-startup-metadata", () => {
const { createTempDir } = createScriptTestHarness();
it("hard-kills synchronous source root help after its timeout", () => {
const spawnSyncMock = vi.mocked(spawnSync);
const successfulRender = {
error: undefined,
output: [null, "Usage: openclaw\n", ""],
pid: 123,
signal: null,
status: 0,
stderr: "",
stdout: "Usage: openclaw\n",
};
spawnSyncMock.mockReturnValueOnce(successfulRender);
it("renders source root help without blocking sibling child events", async () => {
const child = createSpawnTextChild();
const spawnMock = vi.mocked(spawn);
spawnMock.mockImplementationOnce(() => child as unknown as ReturnType<typeof spawn>);
let siblingEventObserved = false;
const siblingEvent = new Promise<void>((resolve) => {
setImmediate(() => {
siblingEventObserved = true;
resolve();
});
});
expect(__testing.renderSourceRootHelpText()).toBe("Usage: openclaw\n");
const render = __testing.renderSourceRootHelpText();
child.stdout.write("Usage: openclaw\n");
setImmediate(() => {
child.emit("close", 0, null);
});
expect(spawnSyncMock).toHaveBeenCalledOnce();
expect(spawnSyncMock.mock.calls[0]?.[2]).toMatchObject({
killSignal: "SIGKILL",
timeout: 120_000,
await siblingEvent;
expect(siblingEventObserved).toBe(true);
await expect(render).resolves.toBe("Usage: openclaw\n");
expect(spawnMock).toHaveBeenCalledOnce();
expect(spawnMock.mock.calls[0]?.[1]).toEqual([
"--import",
"tsx",
"--input-type=module",
"--eval",
expect.any(String),
]);
expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
});
});