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:
@@ -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.
|
||||
+2
-2
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user