Skip to content

Commit e891e93

Browse files
committed
fix(desktop): clamp generation timeout to Node setTimeout int32 cap
Node's setTimeout max delay is 2^31-1 ms (~24.8 days). Larger values overflow and the timer fires immediately, aborting generation instantly when the user picks a long timeout. Clamp the millisecond value to TIMEOUT_MAX_MS before scheduling, and reject NaN/Infinity/negative values up front with a CodesignError ('PREFERENCES_INVALID_TIMEOUT') instead of silently disabling the timeout. A value of 0 still means 'disabled'.
1 parent 4f8ba2a commit e891e93

2 files changed

Lines changed: 58 additions & 8 deletions

File tree

apps/desktop/src/main/generation-ipc.test.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,17 +132,53 @@ describe('armGenerationTimeout', () => {
132132
);
133133
});
134134

135-
it('returns a no-op for non-positive or non-finite timeout values', async () => {
135+
it('treats 0 as disabled and does not arm a timeout', async () => {
136136
const controller = new AbortController();
137137
const logger = { warn: vi.fn() };
138138

139-
for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
140-
const clear = await armGenerationTimeout('gen-1', controller, async () => value, logger);
141-
vi.advanceTimersByTime(60_000);
142-
clear();
143-
}
139+
const clear = await armGenerationTimeout('gen-1', controller, async () => 0, logger);
140+
vi.advanceTimersByTime(60_000);
141+
clear();
144142

145143
expect(controller.signal.aborted).toBe(false);
146144
expect(logger.warn).not.toHaveBeenCalled();
147145
});
146+
147+
it('clamps very large timeout values to Node setTimeout int32 cap so the abort does not fire immediately', async () => {
148+
const controller = new AbortController();
149+
const logger = { warn: vi.fn() };
150+
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
151+
152+
const clear = await armGenerationTimeout('gen-1', controller, async () => 99_999_999, logger);
153+
154+
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
155+
const delay = setTimeoutSpy.mock.calls[0]?.[1];
156+
expect(delay).toBe(2_147_483_647);
157+
158+
vi.advanceTimersByTime(1);
159+
expect(controller.signal.aborted).toBe(false);
160+
161+
setTimeoutSpy.mockRestore();
162+
clear();
163+
});
164+
165+
it('throws PREFERENCES_INVALID_TIMEOUT when the timeout value is NaN', async () => {
166+
const controller = new AbortController();
167+
const logger = { warn: vi.fn() };
168+
169+
await expect(
170+
armGenerationTimeout('gen-1', controller, async () => Number.NaN, logger),
171+
).rejects.toMatchObject({ name: 'CodesignError', code: 'PREFERENCES_INVALID_TIMEOUT' });
172+
expect(controller.signal.aborted).toBe(false);
173+
});
174+
175+
it('throws PREFERENCES_INVALID_TIMEOUT when the timeout value is negative', async () => {
176+
const controller = new AbortController();
177+
const logger = { warn: vi.fn() };
178+
179+
await expect(
180+
armGenerationTimeout('gen-1', controller, async () => -1, logger),
181+
).rejects.toMatchObject({ name: 'CodesignError', code: 'PREFERENCES_INVALID_TIMEOUT' });
182+
expect(controller.signal.aborted).toBe(false);
183+
});
148184
});

apps/desktop/src/main/generation-ipc.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface GenerationTimeoutLogger {
3434
* If the prefs read throws, the failure is surfaced (rethrown as
3535
* `PREFERENCES_READ_FAIL`) rather than silently dropping the timeout — an
3636
* unbounded LLM call is worse than a visible error the user can act on.
37+
* NaN / Infinity / negative values likewise throw `PREFERENCES_INVALID_TIMEOUT`
38+
* instead of silently disabling the timeout. A value of `0` means "disabled".
3739
*/
3840
export async function armGenerationTimeout(
3941
id: string,
@@ -52,7 +54,19 @@ export async function armGenerationTimeout(
5254
'PREFERENCES_READ_FAIL',
5355
);
5456
}
55-
if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) return () => {};
57+
if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
58+
logger.warn('generate.timeout.invalid_value', { id, timeoutSec });
59+
throw new CodesignError(
60+
`Invalid generation timeout: ${String(timeoutSec)} (must be a non-negative finite number).`,
61+
'PREFERENCES_INVALID_TIMEOUT',
62+
);
63+
}
64+
if (timeoutSec === 0) return () => {};
65+
66+
// Node's setTimeout caps delay at int32 (~24.8 days). Larger values overflow
67+
// and fire immediately, which would abort generation instantly.
68+
const TIMEOUT_MAX_MS = 2_147_483_647;
69+
const ms = Math.min(timeoutSec * 1000, TIMEOUT_MAX_MS);
5670

5771
const handle = setTimeout(() => {
5872
logger.warn('generate.timeout.fired', { id, timeoutSec });
@@ -62,6 +76,6 @@ export async function armGenerationTimeout(
6276
'GENERATION_TIMEOUT',
6377
),
6478
);
65-
}, timeoutSec * 1000);
79+
}, ms);
6680
return () => clearTimeout(handle);
6781
}

0 commit comments

Comments
 (0)