fix: schedule the first image-update check on the cron cadence after a restart (#1491)

In cron mode the service armed the first check for two minutes after boot
regardless of the configured schedule, so a restart triggered an out-of-cadence
check (a weekly cron would run on every boot, then follow cron). Arm the first
check at the next cron fire time instead; interval mode keeps its 2-minute
post-boot delay.
This commit is contained in:
Anso
2026-06-27 22:38:31 -04:00
committed by GitHub
parent bee0dfd094
commit 3ad807be45
2 changed files with 93 additions and 2 deletions
@@ -1156,6 +1156,12 @@ describe('ImageUpdateService cron scheduling', () => {
mockGetGlobalSettings.mockReturnValue({ developer_mode: '0' });
});
afterEach(() => {
// Tests below switch to fake timers and a pinned clock; reset so the state
// does not leak into later tests in this block (or the file).
vi.useRealTimers();
});
it('getStatus returns mode and cronExpression fields', () => {
const service = ImageUpdateService.getInstance();
// Before start/configureFromSettings, defaults apply.
@@ -1238,6 +1244,88 @@ describe('ImageUpdateService cron scheduling', () => {
expect(delay).toBeGreaterThan(0);
});
it('start() in cron mode arms at the next cron fire, not the 2-minute startup delay', () => {
vi.useFakeTimers();
// Pin "now" so the next cron fire is deterministic and far beyond the
// startup delay (next Monday 03:00 from this Thursday is days away).
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockGetGlobalSettings.mockReturnValue({
developer_mode: '0',
image_update_check_mode: 'cron',
image_update_check_cron: '0 3 * * 1',
image_update_check_interval_minutes: '120',
});
const service = ImageUpdateService.getInstance();
const checkSpy = vi.spyOn(service as any, 'check').mockResolvedValue(undefined);
service.start();
// The first check is scheduled at the cron fire time, not 2 minutes out, so
// a restart never triggers an out-of-cadence check.
const startupDelay = (ImageUpdateService as any).STARTUP_DELAY_MS as number;
const nextAt = service.getStatus().nextCheckAt!;
expect(nextAt - Date.now()).toBeGreaterThan(startupDelay);
// Advancing well past the old startup delay (but before the cron fire) must
// not fire a check.
vi.advanceTimersByTime(10 * 60 * 1000);
expect(checkSpy).not.toHaveBeenCalled();
// The check fires exactly when the cron schedule says, not merely "later":
// advancing to the computed fire time triggers it once.
vi.advanceTimersByTime(nextAt - Date.now() + 1000);
expect(checkSpy).toHaveBeenCalledTimes(1);
service.stop();
checkSpy.mockRestore();
});
it('start() in cron mode fires sooner than the startup delay when the next fire is near', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
mockGetGlobalSettings.mockReturnValue({
developer_mode: '0',
image_update_check_mode: 'cron',
image_update_check_cron: '* * * * *', // every minute
image_update_check_interval_minutes: '120',
});
const service = ImageUpdateService.getInstance();
const checkSpy = vi.spyOn(service as any, 'check').mockResolvedValue(undefined);
service.start();
// A frequent cron fires before the old 2-minute startup delay would have:
// cron mode honors the schedule in both directions, not just "no sooner".
const startupDelay = (ImageUpdateService as any).STARTUP_DELAY_MS as number;
const nextAt = service.getStatus().nextCheckAt!;
expect(nextAt - Date.now()).toBeLessThan(startupDelay);
vi.advanceTimersByTime(nextAt - Date.now() + 100);
expect(checkSpy).toHaveBeenCalledTimes(1);
service.stop();
checkSpy.mockRestore();
});
it('start() in interval mode keeps the 2-minute startup delay', () => {
vi.useFakeTimers();
mockGetGlobalSettings.mockReturnValue({ developer_mode: '0' });
const service = ImageUpdateService.getInstance();
const checkSpy = vi.spyOn(service as any, 'check').mockResolvedValue(undefined);
service.start();
// Just before the 2-minute delay: no check yet.
vi.advanceTimersByTime(2 * 60 * 1000 - 1000);
expect(checkSpy).not.toHaveBeenCalled();
// Crossing the 2-minute mark fires the first check.
vi.advanceTimersByTime(1000);
expect(checkSpy).toHaveBeenCalledTimes(1);
service.stop();
checkSpy.mockRestore();
});
it('nextDelayMs falls back to interval on runtime parse failure', () => {
// Set up cron mode, then corrupt the expression at runtime before nextDelayMs.
mockGetGlobalSettings.mockReturnValue({
+5 -2
View File
@@ -220,8 +220,11 @@ export class ImageUpdateService {
if (this.timer) return;
this.polling = true;
this.configureFromSettings();
// Preserve the existing 2-minute post-boot delay before the first check.
this.armNext(ImageUpdateService.STARTUP_DELAY_MS);
// Interval mode keeps the 2-minute post-boot delay before the first check.
// Cron mode honors its schedule: arm at the next cron fire time so a restart
// never triggers an out-of-cadence check (e.g. a weekly cron must not run on
// every boot).
this.armNext(this.mode === 'cron' ? this.nextDelayMs() : ImageUpdateService.STARTUP_DELAY_MS);
}
public stop() {