From 16e877ece4c6bf9ade10b30e98a30aa6f71f8a61 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Thu, 24 Apr 2025 15:05:25 -0700 Subject: [PATCH] Spread the update downloads over 6 hours --- ts/scripts/prepare-no-delay-release.ts | 17 +++++ ts/test-node/updater/util_test.ts | 93 ++++++++++++++++++++++++++ ts/updater/common.ts | 36 +++++++++- ts/updater/util.ts | 53 +++++++++++++-- 4 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 ts/scripts/prepare-no-delay-release.ts create mode 100644 ts/test-node/updater/util_test.ts diff --git a/ts/scripts/prepare-no-delay-release.ts b/ts/scripts/prepare-no-delay-release.ts new file mode 100644 index 0000000000..54486e9e2f --- /dev/null +++ b/ts/scripts/prepare-no-delay-release.ts @@ -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, ' ')); diff --git a/ts/test-node/updater/util_test.ts b/ts/test-node/updater/util_test.ts new file mode 100644 index 0000000000..99b441fbc5 --- /dev/null +++ b/ts/test-node/updater/util_test.ts @@ -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, + }) + ); + }); + }); +}); diff --git a/ts/updater/common.ts b/ts/updater/common.ts index e85c7976a8..6739e878ed 100644 --- a/ts/updater/common.ts +++ b/ts/updater/common.ts @@ -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}` diff --git a/ts/updater/util.ts b/ts/updater/util.ts index d8f76130f2..326bdda8a4 100644 --- a/ts/updater/util.ts +++ b/ts/updater/util.ts @@ -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>({ logger, startedAt, retryCount, - retryAfter = 5 * durations.SECOND, - timeout = 5 * durations.MINUTE, + retryAfter = 5 * SECOND, + timeout = 5 * MINUTE, }: { name: string; operation: (...args: Args) => Promise; @@ -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; +}