diff --git a/src/errors.ts b/src/errors.ts index 0c5c4553..d34c8434 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -51,8 +51,12 @@ export class AuthRequiredError extends CliError { } export class TimeoutError extends CliError { - constructor(label: string, seconds: number) { - super('TIMEOUT', `${label} timed out after ${seconds}s`, 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var'); + constructor(label: string, seconds: number, hint?: string) { + super( + 'TIMEOUT', + `${label} timed out after ${seconds}s`, + hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var', + ); } } 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 fe72a365..e6f4e404 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -194,7 +194,17 @@ export async function executeCommand( }); }, { workspace: `site:${cmd.site}` }); } else { - 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 1ae3f923..3a99e912 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)); } /**