fix(core): close TOCTOU race in delay() abort handling

Move the initial signal.aborted check inside the Promise constructor
and add a re-check after addEventListener to close the race window
where an abort between the check and listener registration would be
silently lost. Also add clarifying comments on the intentional
attempt:1 usage in retry delay calculations.
This commit is contained in:
yiliang114 2026-05-17 23:35:13 +08:00
parent 0503c1d570
commit f3c8d0ca55

View file

@ -93,11 +93,10 @@ export function isUnattendedMode(): boolean {
* @returns A promise that resolves after the delay.
*/
function delay(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(new Error('Retry aborted by signal'));
}
return new Promise((resolve, reject) => {
if (signal?.aborted) {
return reject(new Error('Retry aborted by signal'));
}
const cleanup = () => {
signal?.removeEventListener('abort', onAbort);
};
@ -112,6 +111,12 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
// Re-check after listener registration to close the TOCTOU race window.
if (signal?.aborted) {
clearTimeout(timeout);
cleanup();
reject(new Error('Retry aborted by signal'));
}
});
}
@ -208,6 +213,8 @@ export async function retryWithBackoff<T>(
shouldRetryOnContent(result as GenerateContentResponse)
) {
const delayMs = getRetryDelayMs({
// attempt: 1 — currentDelay already tracks exponential growth;
// getRetryDelayMs is called here only for jitter calculation.
attempt: 1,
initialDelayMs: currentDelay,
maxDelayMs,
@ -316,6 +323,8 @@ export async function retryWithBackoff<T>(
} else {
logRetryAttempt(attempt, error, retryClassification, errorStatus);
const delayMs = getRetryDelayMs({
// attempt: 1 — currentDelay already tracks exponential growth;
// getRetryDelayMs is called here only for jitter calculation.
attempt: 1,
initialDelayMs: currentDelay,
maxDelayMs,