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
6 changes: 5 additions & 1 deletion src/lib/spinner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import pc from 'picocolors';
import type { GlobalOpts } from './client';
import { errorMessage, outputError } from './output';
import { isInteractive, isUnicodeSupported } from './tty';
import { REQUEST_TIMEOUT_MS, withTimeout } from './with-timeout';

// Status symbols generated via String.fromCodePoint() — never literal Unicode in
// source — to prevent UTF-8 → Latin-1 corruption when the npm package is bundled.
Expand Down Expand Up @@ -67,7 +68,10 @@ export async function withSpinner<T>(
const spinner = createSpinner(loading, globalOpts.quiet);
try {
for (let attempt = 0; ; attempt++) {
const { data, error, headers } = await call();
const { data, error, headers } = await withTimeout(
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Timeout handling does not cancel the underlying SDK request, so timed-out commands may still complete remotely.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/spinner.ts, line 71:

<comment>Timeout handling does not cancel the underlying SDK request, so timed-out commands may still complete remotely.</comment>

<file context>
@@ -67,7 +68,10 @@ export async function withSpinner<T>(
   try {
     for (let attempt = 0; ; attempt++) {
-      const { data, error, headers } = await call();
+      const { data, error, headers } = await withTimeout(
+        call(),
+        REQUEST_TIMEOUT_MS,
</file context>
Fix with Cubic

call(),
REQUEST_TIMEOUT_MS,
);
if (error) {
if (attempt < MAX_RETRIES && error.name === 'rate_limit_exceeded') {
const delay =
Expand Down
21 changes: 21 additions & 0 deletions src/lib/with-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const REQUEST_TIMEOUT_MS = 30_000;

const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Request timed out after ${ms / 1000}s`));
}, ms);
timer.unref();
promise.then(
(val) => {
clearTimeout(timer);
resolve(val);
},
(err) => {
clearTimeout(timer);
reject(err);
},
);
});

export { REQUEST_TIMEOUT_MS, withTimeout };
24 changes: 24 additions & 0 deletions tests/lib/spinner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
vi,
} from 'vitest';
import { withSpinner } from '../../src/lib/spinner';
import * as timeoutModule from '../../src/lib/with-timeout';
import {
captureTestEnv,
ExitError,
Expand Down Expand Up @@ -187,6 +188,29 @@ describe('withSpinner retry on rate_limit_exceeded', () => {
// Default first retry delay is 1s
expect(Date.now() - start).toBeGreaterThanOrEqual(900);
});

it('exits with error when request times out', async () => {
vi.spyOn(timeoutModule, 'withTimeout').mockRejectedValue(
new Error('Request timed out after 30s'),
);

let threw = false;
try {
await withSpinner(
msgs,
async () => ({ data: null, error: null, headers: null }),
'test_error',
globalOpts,
);
} catch (err) {
threw = true;
expect(err).toBeInstanceOf(ExitError);
expect((err as ExitError).code).toBe(1);
}
expect(threw).toBe(true);
const errOutput = errorSpy.mock.calls.flat().join(' ');
expect(errOutput).toContain('timed out');
});
});

describe('createSpinner', () => {
Expand Down
23 changes: 23 additions & 0 deletions tests/lib/with-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { withTimeout } from '../../src/lib/with-timeout';

describe('withTimeout', () => {
it('resolves when the promise completes before the deadline', async () => {
const result = await withTimeout(Promise.resolve(42), 1000);
expect(result).toBe(42);
});

it('rejects when the promise exceeds the deadline', async () => {
const slow = new Promise<string>((resolve) =>
setTimeout(() => resolve('late'), 5000),
);
await expect(withTimeout(slow, 50)).rejects.toThrow(
'Request timed out after 0.05s',
);
});

it('forwards the original rejection when the promise fails before the deadline', async () => {
const failing = Promise.reject(new Error('boom'));
await expect(withTimeout(failing, 1000)).rejects.toThrow('boom');
});
});