Skip to content

Commit 83ebbcb

Browse files
authored
fix(crabbox): retry cold metadata probes so a slow run --help does not block validation (#102159)
The wrapper probes `crabbox --version` and `crabbox run --help` once each with a snappy 5s timeout to enumerate providers before delegating. A cold Crabbox — the first call right after a version bump, or one on a loaded machine — can exceed that timeout while rendering `run --help` (52KB, emitted on stderr in 0.36) or doing first-run init, and can emit nothing on that first call. When that happens both guards fire and hard-exit the wrapper ("selected binary failed basic --version/--help sanity checks" or "could not parse provider list from --help; refusing to run"), which blocks ALL remote validation (`pnpm check:changed`, remote `pnpm test` lanes) even though the binary is fine. Retry each metadata probe once with a generous 20s timeout when the first attempt is killed or returns empty. The warm path is unchanged (one ~instant probe); only a slow/empty first probe pays for the retry, after which the sanity/provider-list guards judge the recovered result. Add a regression test: a fake Crabbox whose `run --help` is slower than the default probe timeout (and, like 0.36, writes help to stderr) is now recovered by the retry instead of hard-failing.
1 parent bd4d4c0 commit 83ebbcb

2 files changed

Lines changed: 66 additions & 4 deletions

File tree

scripts/crabbox-wrapper.mjs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ import { resolvePathEnvKey, resolveWindowsCmdExePath } from "./windows-cmd-helpe
2828

2929
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
3030
const CRABBOX_METADATA_PROBE_TIMEOUT_MS = 5_000;
31+
// A cold Crabbox (first call after an upgrade, or one on a loaded machine) can
32+
// exceed the snappy default probe timeout while it renders `run --help` or does
33+
// first-run init. Retry the metadata probes once with this generous timeout so a
34+
// single slow probe does not hard-fail the wrapper and block all remote validation.
35+
const CRABBOX_METADATA_PROBE_RETRY_TIMEOUT_MS = 20_000;
3136
const ignoreRepoBinary = process.env.OPENCLAW_CRABBOX_WRAPPER_IGNORE_REPO_BINARY === "1";
3237
const repoLocal = ignoreRepoBinary ? null : resolveCrabboxBinary(process.env, process.platform);
3338
const pathLocal = resolvePathBinary("crabbox", process.env, process.platform);
@@ -360,14 +365,14 @@ function buildBatchCommandLine(command, commandArgs) {
360365
return `"${[escapedCommand, ...escapedArgs].join(" ")}"`;
361366
}
362367

363-
function checkedOutput(command, commandArgs) {
368+
function checkedOutput(command, commandArgs, timeoutMs = resolveMetadataProbeTimeoutMs(process.env)) {
364369
const invocation = spawnInvocation(command, commandArgs, process.env, process.platform);
365370
const result = spawnSync(invocation.command, invocation.args, {
366371
cwd: repoRoot,
367372
encoding: "utf8",
368373
stdio: ["ignore", "pipe", "pipe"],
369374
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
370-
timeout: resolveMetadataProbeTimeoutMs(process.env),
375+
timeout: timeoutMs,
371376
killSignal: "SIGKILL",
372377
});
373378
const timedOut = result.error?.name === "Error" && result.signal === "SIGKILL";
@@ -378,6 +383,19 @@ function checkedOutput(command, commandArgs) {
378383
};
379384
}
380385

386+
// Probe Crabbox metadata (`--version` / `run --help`) with one generous retry.
387+
// A cold Crabbox can be SIGKILLed by the snappy default timeout or emit nothing
388+
// on the first call, then be instant and clean on the next. Retrying keeps the
389+
// warm path fast (one ~instant probe) while stopping a single slow probe from
390+
// tripping the sanity/provider-list guards and blocking all remote validation.
391+
function probeCrabboxMetadata(command, commandArgs) {
392+
const first = checkedOutput(command, commandArgs);
393+
if (first.status === 0 && first.text.length > 0) {
394+
return first;
395+
}
396+
return checkedOutput(command, commandArgs, CRABBOX_METADATA_PROBE_RETRY_TIMEOUT_MS);
397+
}
398+
381399
function parseCrabboxVersion(value) {
382400
const match = `${value}`.match(/\bv?(\d+)\.(\d+)\.(\d+)(?:-([^\s+]+))?(?:\+[^\s]+)?\b/u);
383401
if (!match) {
@@ -3155,8 +3173,8 @@ function injectFullCheckoutLeaseReclaim(commandArgs) {
31553173
return normalizedArgs;
31563174
}
31573175

3158-
const version = checkedOutput(binary, ["--version"]);
3159-
const help = checkedOutput(binary, ["run", "--help"]);
3176+
const version = probeCrabboxMetadata(binary, ["--version"]);
3177+
const help = probeCrabboxMetadata(binary, ["run", "--help"]);
31603178
const providers = parseProvidersFromHelp(help.text);
31613179
const displayBinary = binary === "crabbox" ? "crabbox" : relative(repoRoot, binary);
31623180
const provider = selectedProvider(args, providers);

test/scripts/crabbox-wrapper.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,32 @@ function makeSlowVersionCrabbox(helpText: string): string {
227227
return binDir;
228228
}
229229

230+
// Fake Crabbox whose `run --help` is slow on every call and, like real Crabbox
231+
// 0.36, renders the provider help to stderr. Used to prove the wrapper retries a
232+
// cold/slow metadata probe instead of hard-failing.
233+
function makeSlowHelpCrabbox(helpText: string, delayMs: number): string {
234+
const binDir = mkdtempSync(path.join(tmpdir(), "openclaw-slow-help-crabbox-"));
235+
tempDirs.push(binDir);
236+
const crabboxPath = path.join(binDir, "crabbox");
237+
238+
const script = [
239+
"#!/usr/bin/env node",
240+
"const args = process.argv.slice(2);",
241+
"if (args[0] === '--version') {",
242+
" console.log(process.env.OPENCLAW_FAKE_CRABBOX_VERSION || 'crabbox 0.22.1');",
243+
" process.exit(0);",
244+
"} else if (args[0] === 'run' && args[1] === '--help') {",
245+
` setTimeout(() => { process.stderr.write(${JSON.stringify(helpText)}); process.exit(0); }, ${delayMs});`,
246+
"} else {",
247+
" process.exit(0);",
248+
"}",
249+
].join("\n");
250+
writeFileSync(crabboxPath, `${script}\n`, "utf8");
251+
writeFileSync(`${crabboxPath}.cmd`, windowsNodeCmdShim("crabbox"), "utf8");
252+
chmodSync(crabboxPath, 0o755);
253+
return binDir;
254+
}
255+
230256
function testTimingPreload(options: { clockScale?: number; spawnTimeoutMs?: number }): string {
231257
const key = JSON.stringify(options);
232258
let preloadPath = timingPreloads.get(key);
@@ -3128,6 +3154,24 @@ describe("scripts/crabbox-wrapper", () => {
31283154
expect(result.stderr).toContain("selected binary failed basic --version/--help sanity checks");
31293155
});
31303156

3157+
it("retries a cold Crabbox whose run --help is slower than the default probe timeout", () => {
3158+
const helpText = "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n";
3159+
// First probe is SIGKILLed at 150ms; the retry gets the full generous timeout
3160+
// and reads the (600ms) stderr help, so the wrapper must not hard-fail.
3161+
const result = runWrapper(helpText, ["--version"], {
3162+
env: { OPENCLAW_TEST_CRABBOX_METADATA_PROBE_TIMEOUT_MS: "150" },
3163+
extraPathEntries: [makeSlowHelpCrabbox(helpText, 600)],
3164+
});
3165+
3166+
expect(result.error).toBeUndefined();
3167+
expect(result.status).toBe(0);
3168+
expect(result.stderr).not.toContain("could not parse provider list");
3169+
expect(result.stderr).not.toContain("selected binary failed basic --version/--help sanity checks");
3170+
expect(result.stderr).toContain(
3171+
"providers=hetzner,aws,local-container,blacksmith-testbox,cloudflare",
3172+
);
3173+
});
3174+
31313175
it("parses provider choices from the --provider flag help format", () => {
31323176
const helpText =
31333177
"Usage: crabbox run [options]\n --provider hetzner|aws|local-container|blacksmith-testbox|cloudflare\n";

0 commit comments

Comments
 (0)