From 52512dab13752288696f9b6de6a44365b76f8402 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 25 Mar 2026 01:03:45 +0800 Subject: [PATCH] fix(execution): apply timeout to non-browser commands Non-browser commands (`browser: false`) ran without any timeout protection, even when `timeoutSeconds` was explicitly set. This wraps the non-browser execution path with `runWithTimeout()` when the adapter defines a positive `timeoutSeconds`. Also adds an optional `hint` parameter to `TimeoutError` so the non-browser path shows a relevant suggestion instead of the browser-specific `OPENCLI_BROWSER_COMMAND_TIMEOUT` env var hint. --- src/errors.ts | 4 ++-- src/execution.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++++ src/execution.ts | 13 ++++++++++-- src/runtime.ts | 4 ++-- 4 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 src/execution.test.ts diff --git a/src/errors.ts b/src/errors.ts index 88e9f755..3b92e4f5 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -75,11 +75,11 @@ export class AuthRequiredError extends CliError { // ── Timeout ───────────────────────────────────────────────────────────────── export class TimeoutError extends CliError { - constructor(label: string, seconds: number) { + constructor(label: string, seconds: number, hint?: string) { super( 'TIMEOUT', `${label} timed out after ${seconds}s`, - 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var', + hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var', ); this.name = 'TimeoutError'; } diff --git a/src/execution.test.ts b/src/execution.test.ts new file mode 100644 index 00000000..5385a117 --- /dev/null +++ b/src/execution.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { executeCommand } from './execution.js'; +import { TimeoutError } from './errors.js'; +import { cli, Strategy } from './registry.js'; +import { withTimeoutMs } from './runtime.js'; + +describe('executeCommand — non-browser timeout', () => { + it('applies timeoutSeconds to non-browser commands', async () => { + const cmd = cli({ + site: 'test-execution', + name: 'non-browser-timeout', + description: 'test non-browser timeout', + browser: false, + strategy: Strategy.PUBLIC, + timeoutSeconds: 0.01, + func: () => new Promise(() => {}), + }); + + // Sentinel timeout at 200ms — if the inner 10ms timeout fires first, + // the error will be a TimeoutError with the command label, not 'sentinel'. + const error = await withTimeoutMs(executeCommand(cmd, {}), 200, 'sentinel timeout') + .catch((err) => err); + + expect(error).toBeInstanceOf(TimeoutError); + expect(error).toMatchObject({ + code: 'TIMEOUT', + message: 'test-execution/non-browser-timeout timed out after 0.01s', + }); + }); + + it('skips timeout when timeoutSeconds is 0', async () => { + const cmd = cli({ + site: 'test-execution', + name: 'non-browser-zero-timeout', + description: 'test zero timeout bypasses wrapping', + browser: false, + strategy: Strategy.PUBLIC, + timeoutSeconds: 0, + func: () => new Promise(() => {}), + }); + + // With timeout guard skipped, the sentinel fires instead. + await expect( + withTimeoutMs(executeCommand(cmd, {}), 50, 'sentinel timeout'), + ).rejects.toThrow('sentinel timeout'); + }); +}); diff --git a/src/execution.ts b/src/execution.ts index 2d9109ce..ec7e2c91 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -184,8 +184,17 @@ export async function executeCommand( }); }, { workspace: `site:${cmd.site}` }); } else { - // Non-browser commands run directly - result = await runCommand(cmd, null, kwargs, debug); + // Non-browser commands: apply timeout only when explicitly configured. + const timeout = cmd.timeoutSeconds; + if (timeout !== undefined && timeout > 0) { + result = await runWithTimeout(runCommand(cmd, null, kwargs, debug), { + timeout, + label: fullName(cmd), + hint: `Increase the adapter's timeoutSeconds setting (currently ${timeout}s)`, + }); + } else { + result = await runCommand(cmd, null, kwargs, debug); + } } } catch (err) { hookCtx.error = err; diff --git a/src/runtime.ts b/src/runtime.ts index f3b63961..55ea56ec 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -30,11 +30,11 @@ export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_ */ export async function runWithTimeout( promise: Promise, - opts: { timeout: number; label?: string }, + opts: { timeout: number; label?: string; hint?: string }, ): Promise { const label = opts.label ?? 'Operation'; return withTimeoutMs(promise, opts.timeout * 1000, - () => new TimeoutError(label, opts.timeout)); + () => new TimeoutError(label, opts.timeout, opts.hint)); } /**