Spread the update downloads over 6 hours
This commit is contained in:
parent
dcac698631
commit
16e877ece4
4 changed files with 194 additions and 5 deletions
17
ts/scripts/prepare-no-delay-release.ts
Normal file
17
ts/scripts/prepare-no-delay-release.ts
Normal 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, ' '));
|
||||||
93
ts/test-node/updater/util_test.ts
Normal file
93
ts/test-node/updater/util_test.ts
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -53,7 +53,12 @@ import {
|
||||||
prepareDownload as prepareDifferentialDownload,
|
prepareDownload as prepareDifferentialDownload,
|
||||||
} from './differential';
|
} from './differential';
|
||||||
import { getGotOptions } from './got';
|
import { getGotOptions } from './got';
|
||||||
import { checkIntegrity, gracefulRename, gracefulRmRecursive } from './util';
|
import {
|
||||||
|
checkIntegrity,
|
||||||
|
gracefulRename,
|
||||||
|
gracefulRmRecursive,
|
||||||
|
isTimeToUpdate,
|
||||||
|
} from './util';
|
||||||
|
|
||||||
const POLL_INTERVAL = 30 * durations.MINUTE;
|
const POLL_INTERVAL = 30 * durations.MINUTE;
|
||||||
|
|
||||||
|
|
@ -61,6 +66,11 @@ type JSONVendorSchema = {
|
||||||
minOSVersion?: string;
|
minOSVersion?: string;
|
||||||
requireManualUpdate?: 'true' | 'false';
|
requireManualUpdate?: 'true' | 'false';
|
||||||
requireUserConfirmation?: '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 = {
|
type JSONUpdateSchema = {
|
||||||
|
|
@ -142,6 +152,10 @@ export abstract class Updater {
|
||||||
#autoRetryAttempts = 0;
|
#autoRetryAttempts = 0;
|
||||||
#autoRetryAfter: number | undefined;
|
#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({
|
constructor({
|
||||||
settingsChannel,
|
settingsChannel,
|
||||||
logger,
|
logger,
|
||||||
|
|
@ -580,6 +594,26 @@ export abstract class Updater {
|
||||||
return;
|
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(
|
this.logger.info(
|
||||||
`checkForUpdates: found newer version ${version} ` +
|
`checkForUpdates: found newer version ${version} ` +
|
||||||
`checkType=${checkType}`
|
`checkType=${checkType}`
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
@ -8,7 +8,7 @@ import { pipeline } from 'stream/promises';
|
||||||
|
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { LoggerType } from '../types/Logging';
|
||||||
import * as Errors from '../types/errors';
|
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 { sleep } from '../util/sleep';
|
||||||
import { isOlderThan } from '../util/timestamp';
|
import { isOlderThan } from '../util/timestamp';
|
||||||
|
|
||||||
|
|
@ -55,8 +55,8 @@ async function doGracefulFSOperation<Args extends ReadonlyArray<unknown>>({
|
||||||
logger,
|
logger,
|
||||||
startedAt,
|
startedAt,
|
||||||
retryCount,
|
retryCount,
|
||||||
retryAfter = 5 * durations.SECOND,
|
retryAfter = 5 * SECOND,
|
||||||
timeout = 5 * durations.MINUTE,
|
timeout = 5 * MINUTE,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
operation: (...args: Args) => Promise<void>;
|
operation: (...args: Args) => Promise<void>;
|
||||||
|
|
@ -138,3 +138,48 @@ export async function gracefulRmRecursive(
|
||||||
retryCount: 0,
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue