fix(scheduler): handle setTimeout overflow for long sleep durations

Node.js setTimeout uses a 32-bit signed integer for the delay, so
values larger than 2^31-1 ms (~24.8 days) cause the callback to fire
immediately. Fix by chunking the wait into segments of at most 2^30 ms.

Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
This commit is contained in:
Fredrik Adelöw
2026-04-18 14:40:22 +02:00
parent c3ca20c864
commit 89d324840c
4 changed files with 56 additions and 8 deletions
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Fixed scheduler `sleep` firing immediately for durations longer than ~24.8 days, caused by Node.js `setTimeout` overflowing its 32-bit millisecond limit.
@@ -150,13 +150,13 @@ describe('PluginTaskManagerImpl', () => {
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ seconds: 60 }),
fn,
scope: 'global',
});
await manager.triggerTask('task1');
jest.advanceTimersByTime(5000);
await jest.advanceTimersByTimeAsync(65_000);
await promise;
expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal));
@@ -56,6 +56,29 @@ describe('util', () => {
await promise;
expect(true).toBe(true);
}, 1_000);
it('handles durations longer than 2^31 ms by chunking setTimeout calls', async () => {
jest.useFakeTimers();
try {
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
let resolved = false;
const promise = sleep(Duration.fromMillis(thirtyDaysMs)).then(() => {
resolved = true;
});
expect(jest.getTimerCount()).toBe(1);
await jest.advanceTimersByTimeAsync(2 ** 30);
expect(resolved).toBe(false);
expect(jest.getTimerCount()).toBe(1);
await jest.advanceTimersByTimeAsync(thirtyDaysMs - 2 ** 30);
await promise;
expect(resolved).toBe(true);
} finally {
jest.useRealTimers();
}
});
});
describe('delegateAbortController', () => {
@@ -54,6 +54,11 @@ export function nowPlus(duration: Duration | undefined, knex: Knex) {
return knex.raw(`now() + interval '${seconds} seconds'`);
}
// Node.js setTimeout uses a 32-bit signed integer internally, so timeouts
// longer than 2^31-1 ms (~24.8 days) fire immediately. We cap each individual
// wait at 2^30 ms (~12.4 days) and loop until the full duration has elapsed.
const MAX_TIMEOUT_MS = 2 ** 30;
/**
* Sleep for the given duration, but return sooner if the abort signal
* triggers.
@@ -69,19 +74,34 @@ export async function sleep(
return;
}
await new Promise<void>(resolve => {
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
let remaining = duration.as('milliseconds');
if (!Number.isFinite(remaining) || remaining <= 0) {
return;
}
const done = () => {
await new Promise<void>(resolve => {
let timeoutHandle: NodeJS.Timeout | undefined;
const finish = () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
abortSignal?.removeEventListener('abort', done);
abortSignal?.removeEventListener('abort', finish);
resolve();
};
timeoutHandle = setTimeout(done, duration.as('milliseconds'));
abortSignal?.addEventListener('abort', done);
const tick = () => {
if (remaining <= 0) {
finish();
return;
}
const chunk = Math.min(remaining, MAX_TIMEOUT_MS);
remaining -= chunk;
timeoutHandle = setTimeout(tick, chunk);
};
abortSignal?.addEventListener('abort', finish);
tick();
});
}