Spread the update downloads over 6 hours

This commit is contained in:
Fedor Indutny 2025-04-24 15:05:25 -07:00 committed by GitHub
commit 16e877ece4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 194 additions and 5 deletions

View file

@ -0,0 +1,17 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import fs from 'node:fs';
import { join } from 'node:path';
const PACKAGE_FILE = join(__dirname, '..', '..', 'package.json');
const json = JSON.parse(fs.readFileSync(PACKAGE_FILE, { encoding: 'utf8' }));
json.build.mac.releaseInfo.vendor.noDelay = true;
json.build.win.releaseInfo.vendor.noDelay = true;
fs.writeFileSync(PACKAGE_FILE, JSON.stringify(json, null, ' '));

View file

@ -0,0 +1,93 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { isTimeToUpdate } from '../../updater/util';
import { HOUR } from '../../util/durations';
import * as logger from '../../logging/log';
describe('updater/util', () => {
const now = 1745522337601;
describe('isTimeToUpdate', () => {
it('should update immediately if update is too far in the past', () => {
assert.isTrue(
isTimeToUpdate({
logger,
pollId: 'abc',
releasedAt: new Date(0).getTime(),
now,
})
);
});
it('should update immediately if release date invalid', () => {
assert.isTrue(
isTimeToUpdate({
logger,
pollId: 'abc',
releasedAt: NaN,
now,
})
);
assert.isTrue(
isTimeToUpdate({
logger,
pollId: 'abc',
releasedAt: Infinity,
now,
})
);
});
it('should delay the update', () => {
assert.isFalse(
isTimeToUpdate({
logger,
pollId: 'abcd',
releasedAt: now,
now,
})
);
assert.isFalse(
isTimeToUpdate({
logger,
pollId: 'abcd',
releasedAt: now,
now: now + HOUR,
})
);
assert.isTrue(
isTimeToUpdate({
logger,
pollId: 'abcd',
releasedAt: now,
now: now + 2 * HOUR,
})
);
});
it('should compute the delay based on pollId', () => {
assert.isFalse(
isTimeToUpdate({
logger,
pollId: 'abc',
releasedAt: now,
now,
})
);
assert.isTrue(
isTimeToUpdate({
logger,
pollId: 'abc',
releasedAt: now,
now: now + HOUR,
})
);
});
});
});

View file

@ -53,7 +53,12 @@ import {
prepareDownload as prepareDifferentialDownload,
} from './differential';
import { getGotOptions } from './got';
import { checkIntegrity, gracefulRename, gracefulRmRecursive } from './util';
import {
checkIntegrity,
gracefulRename,
gracefulRmRecursive,
isTimeToUpdate,
} from './util';
const POLL_INTERVAL = 30 * durations.MINUTE;
@ -61,6 +66,11 @@ type JSONVendorSchema = {
minOSVersion?: string;
requireManualUpdate?: 'true' | 'false';
requireUserConfirmation?: 'true' | 'false';
// If 'true' - the update will be autodownloaded as soon as it becomes
// available. Otherwise a delay up to 6h might be applied. See
// `isTimeToUpdate`.
noDelay?: 'true' | 'false';
};
type JSONUpdateSchema = {
@ -142,6 +152,10 @@ export abstract class Updater {
#autoRetryAttempts = 0;
#autoRetryAfter: number | undefined;
// Just a stable randomness that is used for determining the update time. The
// value does not have to be consistent across restarts.
#pollId = getGuid();
constructor({
settingsChannel,
logger,
@ -580,6 +594,26 @@ export abstract class Updater {
return;
}
if (checkType === CheckType.Normal && vendor?.noDelay !== 'true') {
try {
const releasedAt = new Date(parsedYaml.releaseDate).getTime();
if (
!isTimeToUpdate({
logger: this.logger,
pollId: this.#pollId,
releasedAt,
})
) {
return;
}
} catch (error) {
this.logger.warn(
`checkForUpdates: failed to compute delay for ${parsedYaml.releaseDate}`
);
}
}
this.logger.info(
`checkForUpdates: found newer version ${version} ` +
`checkType=${checkType}`

View file

@ -1,4 +1,4 @@
// Copyright 2022 Signal Messenger, LLC
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createHash } from 'crypto';
@ -8,7 +8,7 @@ import { pipeline } from 'stream/promises';
import type { LoggerType } from '../types/Logging';
import * as Errors from '../types/errors';
import * as durations from '../util/durations';
import { SECOND, MINUTE, HOUR } from '../util/durations';
import { sleep } from '../util/sleep';
import { isOlderThan } from '../util/timestamp';
@ -55,8 +55,8 @@ async function doGracefulFSOperation<Args extends ReadonlyArray<unknown>>({
logger,
startedAt,
retryCount,
retryAfter = 5 * durations.SECOND,
timeout = 5 * durations.MINUTE,
retryAfter = 5 * SECOND,
timeout = 5 * MINUTE,
}: {
name: string;
operation: (...args: Args) => Promise<void>;
@ -138,3 +138,48 @@ export async function gracefulRmRecursive(
retryCount: 0,
});
}
const MAX_UPDATE_DELAY = 6 * HOUR;
export function isTimeToUpdate({
logger,
pollId,
releasedAt,
now = Date.now(),
maxDelay = MAX_UPDATE_DELAY,
}: {
logger: LoggerType;
pollId: string;
releasedAt: number;
now?: number;
maxDelay?: number;
}): boolean {
// Check that the release date is a proper number
if (!Number.isFinite(releasedAt) || Number.isNaN(releasedAt)) {
logger.warn('updater/isTimeToUpdate: invalid releasedAt');
return true;
}
// Check that the release date is not too far in the future
if (releasedAt - HOUR > now) {
logger.warn('updater/isTimeToUpdate: releasedAt too far in the future');
return true;
}
const digest = createHash('sha512')
.update(pollId)
.update(Buffer.alloc(1))
.update(new Date(releasedAt).toJSON())
.digest();
const delay = maxDelay * (digest.readUInt32LE(0) / 0xffffffff);
const updateAt = releasedAt + delay;
if (now >= updateAt) {
return true;
}
const remaining = Math.round((updateAt - now) / MINUTE);
logger.info(`updater/isTimeToUpdate: updating in ${remaining} minutes`);
return false;
}