Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down
47 changes: 47 additions & 0 deletions src/execution.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
13 changes: 11 additions & 2 deletions src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseEnvTimeout('OPENCLI_BROWSER_
*/
export async function runWithTimeout<T>(
promise: Promise<T>,
opts: { timeout: number; label?: string },
opts: { timeout: number; label?: string; hint?: string },
): Promise<T> {
const label = opts.label ?? 'Operation';
return withTimeoutMs(promise, opts.timeout * 1000,
() => new TimeoutError(label, opts.timeout));
() => new TimeoutError(label, opts.timeout, opts.hint));
}

/**
Expand Down
Loading