mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-02 05:57:59 +00:00
fix(release): bound engine provisioning and Linux package setup
- abort locked engine downloads and extraction on process interruption - recover only dead-PID staging trees while preserving active and legacy trees - normalize Ubuntu mirrors and bound apt in release jobs - cover cancellation, orphan cleanup, and workflow contracts with focused tests
This commit is contained in:
@@ -45,6 +45,40 @@ function createDownloadTimeout(idleTimeoutMs) {
|
||||
return { signal: controller.signal, refresh, dispose };
|
||||
}
|
||||
|
||||
function abortReason(signal) {
|
||||
if (signal?.reason instanceof Error) return signal.reason;
|
||||
return new Error('Engine archive download aborted');
|
||||
}
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
}
|
||||
|
||||
function combineAbortSignals(signals) {
|
||||
const activeSignals = signals.filter(Boolean);
|
||||
const controller = new AbortController();
|
||||
const listeners = [];
|
||||
const abort = signal => {
|
||||
if (!controller.signal.aborted) controller.abort(abortReason(signal));
|
||||
};
|
||||
|
||||
for (const signal of activeSignals) {
|
||||
const listener = () => abort(signal);
|
||||
listeners.push([signal, listener]);
|
||||
if (signal.aborted) abort(signal);
|
||||
else signal.addEventListener('abort', listener, { once: true });
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
dispose() {
|
||||
for (const [signal, listener] of listeners) {
|
||||
signal.removeEventListener('abort', listener);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function archiveSize(archive) {
|
||||
try {
|
||||
return fs.statSync(archive).size;
|
||||
@@ -60,8 +94,26 @@ function checksumMismatchError(name, expected, actual) {
|
||||
return error;
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
function sleep(milliseconds, signal) {
|
||||
if (!signal) return new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer;
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const finish = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(abortReason(signal));
|
||||
};
|
||||
timer = setTimeout(finish, milliseconds);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
async function resetArchive(archive) {
|
||||
@@ -92,20 +144,23 @@ export async function downloadEngineArchive({
|
||||
attempts = DEFAULT_ATTEMPTS,
|
||||
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
||||
retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
|
||||
signal,
|
||||
}) {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
throwIfAborted(signal);
|
||||
const partialSize = archiveSize(archive);
|
||||
if (partialSize > 0 && sha256(archive) === expectedSha256) return archive;
|
||||
const downloadTimeout = createDownloadTimeout(idleTimeoutMs);
|
||||
const requestSignal = combineAbortSignals([signal, downloadTimeout.signal]);
|
||||
let resetForRetry = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined,
|
||||
redirect: 'follow',
|
||||
signal: downloadTimeout.signal,
|
||||
signal: requestSignal.signal,
|
||||
});
|
||||
|
||||
if (response.status === 416 && partialSize > 0) {
|
||||
@@ -143,9 +198,10 @@ export async function downloadEngineArchive({
|
||||
},
|
||||
}),
|
||||
fs.createWriteStream(archive, { flags: append ? 'a' : 'w' }),
|
||||
{ signal: downloadTimeout.signal },
|
||||
{ signal: requestSignal.signal },
|
||||
);
|
||||
|
||||
throwIfAborted(signal);
|
||||
const finalSize = archiveSize(archive);
|
||||
const expectedFinalSize = contentRange?.total
|
||||
?? (expectedResponseLength === undefined
|
||||
@@ -163,6 +219,7 @@ export async function downloadEngineArchive({
|
||||
resetForRetry = true;
|
||||
throw checksumMismatchError(name, expectedSha256, actual);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw abortReason(signal);
|
||||
lastError = error;
|
||||
if (resetForRetry || error?.code === 'ARCHIVE_CHECKSUM_MISMATCH') {
|
||||
await resetArchive(archive);
|
||||
@@ -175,8 +232,9 @@ export async function downloadEngineArchive({
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelaysMs[attempt - 1] ?? 0));
|
||||
await sleep(retryDelaysMs[attempt - 1] ?? 0, signal);
|
||||
} finally {
|
||||
requestSignal.dispose();
|
||||
downloadTimeout.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,3 +180,48 @@ test('restarts from zero after an unsatisfiable retained range', async () => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('propagates external cancellation without retrying an in-flight archive', async () => {
|
||||
const { directory, archive } = makeArchivePath();
|
||||
const abortController = new AbortController();
|
||||
let calls = 0;
|
||||
|
||||
try {
|
||||
await withMockFetch(async (_url, options) => {
|
||||
calls += 1;
|
||||
assert.equal(options.signal.aborted, false);
|
||||
setTimeout(() => {
|
||||
abortController.abort(new Error('provisioning interrupted'));
|
||||
}, 10);
|
||||
return new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(Buffer.from('partial archive bytes'));
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '100' },
|
||||
});
|
||||
}, async () => {
|
||||
await assert.rejects(
|
||||
downloadEngineArchive({
|
||||
name: 'test',
|
||||
url: 'https://example.test/engine.zip',
|
||||
archive,
|
||||
expectedSha256: digest(Buffer.from('never completed')),
|
||||
attempts: 3,
|
||||
retryDelaysMs: [500, 500],
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
error => {
|
||||
assert.match(error.message, /provisioning interrupted/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(calls, 1);
|
||||
assert.ok(fs.statSync(archive).size > 0);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,6 +50,33 @@ async function removeOrphanedPreviousPayloads(destination) {
|
||||
}
|
||||
}
|
||||
|
||||
function provisioningTemporaryPrefix(target) {
|
||||
return `.firelink-engines-${target}-`;
|
||||
}
|
||||
|
||||
function provisioningTemporaryOwner(target, candidate) {
|
||||
const suffix = path.basename(candidate).slice(provisioningTemporaryPrefix(target).length);
|
||||
const separator = suffix.indexOf('-');
|
||||
const pid = separator >= 0 ? suffix.slice(0, separator) : suffix;
|
||||
return /^\d+$/.test(pid) ? Number(pid) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only staging directories created by a provisioner whose PID is no
|
||||
* longer alive. Legacy directories without an owner PID remain untouched so
|
||||
* a concurrent or ambiguous provisioner can never lose its staging tree.
|
||||
*/
|
||||
export async function removeOrphanedProvisioningDirectories(destinationParent, target) {
|
||||
const prefix = provisioningTemporaryPrefix(target);
|
||||
for (const entry of fs.readdirSync(destinationParent, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
|
||||
const candidate = path.join(destinationParent, entry.name);
|
||||
const owner = provisioningTemporaryOwner(target, candidate);
|
||||
if (owner === null || owner === process.pid || isProcessAlive(owner)) continue;
|
||||
await removePathWithRetry(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the only previous payload left by a process that died after moving
|
||||
* the destination aside but before publishing its replacement. Multiple
|
||||
|
||||
@@ -6,6 +6,7 @@ import test from 'node:test';
|
||||
import {
|
||||
promoteDirectory,
|
||||
recoverInterruptedPromotion,
|
||||
removeOrphanedProvisioningDirectories,
|
||||
removePathWithRetry,
|
||||
} from './engine-payload-promotion.js';
|
||||
|
||||
@@ -126,3 +127,24 @@ test('removes orphaned backups from dead provisioners after a successful publish
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('removes only provisioning staging owned by a dead PID', async () => {
|
||||
const root = temporaryDirectory();
|
||||
try {
|
||||
const target = 'x86_64-unknown-linux-gnu';
|
||||
const orphaned = path.join(root, `.firelink-engines-${target}-999999999-dead`);
|
||||
const live = path.join(root, `.firelink-engines-${target}-${process.pid}-live`);
|
||||
const legacy = path.join(root, `.firelink-engines-${target}-legacy`);
|
||||
fs.mkdirSync(orphaned, { recursive: true });
|
||||
fs.mkdirSync(live, { recursive: true });
|
||||
fs.mkdirSync(legacy, { recursive: true });
|
||||
|
||||
await removeOrphanedProvisioningDirectories(root, target);
|
||||
|
||||
assert.equal(fs.existsSync(orphaned), false);
|
||||
assert.equal(fs.existsSync(live), true);
|
||||
assert.equal(fs.existsSync(legacy), true);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
|
||||
import { downloadEngineArchive } from './engine-download.js';
|
||||
import {
|
||||
promoteDirectory,
|
||||
recoverInterruptedPromotion,
|
||||
removeOrphanedProvisioningDirectories,
|
||||
removePathWithRetry,
|
||||
} from './engine-payload-promotion.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const sourceLock = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
|
||||
);
|
||||
@@ -37,17 +40,34 @@ if (!targetSources) {
|
||||
}
|
||||
|
||||
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
|
||||
const destinationParent = path.dirname(destination);
|
||||
fs.mkdirSync(destinationParent, { recursive: true });
|
||||
recoverInterruptedPromotion(destination);
|
||||
// Keep staging on the destination filesystem so the final rename is atomic.
|
||||
const temporary = fs.mkdtempSync(path.join(destinationParent, `.firelink-engines-${target}-`));
|
||||
const payloadDestination = path.join(temporary, 'payload');
|
||||
fs.mkdirSync(payloadDestination, { recursive: true });
|
||||
const isWindows = target.includes('windows');
|
||||
const executableSuffix = isWindows ? '.exe' : '';
|
||||
const provisioningAbortController = new AbortController();
|
||||
const signalNames = process.platform === 'win32'
|
||||
? ['SIGINT', 'SIGTERM']
|
||||
: ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
||||
const signalHandlers = new Map();
|
||||
for (const signalName of signalNames) {
|
||||
const handler = () => {
|
||||
if (!provisioningAbortController.signal.aborted) {
|
||||
provisioningAbortController.abort(new Error(`Engine provisioning interrupted by ${signalName}`));
|
||||
}
|
||||
};
|
||||
signalHandlers.set(signalName, handler);
|
||||
process.on(signalName, handler);
|
||||
}
|
||||
|
||||
let temporary;
|
||||
let payloadDestination;
|
||||
|
||||
function throwIfProvisioningAborted() {
|
||||
if (provisioningAbortController.signal.aborted) {
|
||||
throw provisioningAbortController.signal.reason;
|
||||
}
|
||||
}
|
||||
|
||||
async function download(name, source) {
|
||||
throwIfProvisioningAborted();
|
||||
const sourcePath = new URL(source.url).pathname;
|
||||
const archive = path.join(
|
||||
temporary,
|
||||
@@ -58,14 +78,23 @@ async function download(name, source) {
|
||||
url: source.url,
|
||||
archive,
|
||||
expectedSha256: source.sha256,
|
||||
signal: provisioningAbortController.signal,
|
||||
});
|
||||
throwIfProvisioningAborted();
|
||||
const extracted = path.join(temporary, `${name}-extracted`);
|
||||
fs.mkdirSync(extracted);
|
||||
if (archive.endsWith('.zip') && process.platform !== 'win32') {
|
||||
execFileSync('unzip', ['-q', archive, '-d', extracted], { stdio: 'inherit' });
|
||||
await execFileAsync('unzip', ['-q', archive, '-d', extracted], {
|
||||
stdio: 'inherit',
|
||||
signal: provisioningAbortController.signal,
|
||||
});
|
||||
} else {
|
||||
execFileSync('tar', ['-xf', archive, '-C', extracted], { stdio: 'inherit' });
|
||||
await execFileAsync('tar', ['-xf', archive, '-C', extracted], {
|
||||
stdio: 'inherit',
|
||||
signal: provisioningAbortController.signal,
|
||||
});
|
||||
}
|
||||
throwIfProvisioningAborted();
|
||||
return extracted;
|
||||
}
|
||||
|
||||
@@ -123,6 +152,18 @@ function writePayloadManifest() {
|
||||
}
|
||||
|
||||
try {
|
||||
const destinationParent = path.dirname(destination);
|
||||
fs.mkdirSync(destinationParent, { recursive: true });
|
||||
recoverInterruptedPromotion(destination);
|
||||
await removeOrphanedProvisioningDirectories(destinationParent, target);
|
||||
throwIfProvisioningAborted();
|
||||
// Keep staging on the destination filesystem so the final rename is atomic.
|
||||
temporary = fs.mkdtempSync(
|
||||
path.join(destinationParent, `.firelink-engines-${target}-${process.pid}-`)
|
||||
);
|
||||
payloadDestination = path.join(temporary, 'payload');
|
||||
fs.mkdirSync(payloadDestination, { recursive: true });
|
||||
|
||||
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
|
||||
copyExecutable(
|
||||
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
|
||||
@@ -143,8 +184,12 @@ try {
|
||||
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
|
||||
|
||||
writePayloadManifest();
|
||||
throwIfProvisioningAborted();
|
||||
await promoteDirectory(payloadDestination, destination);
|
||||
console.log(`Provisioned locked engine payload at ${destination}`);
|
||||
} finally {
|
||||
await removePathWithRetry(temporary);
|
||||
if (temporary) await removePathWithRetry(temporary);
|
||||
for (const [signalName, handler] of signalHandlers) {
|
||||
process.removeListener(signalName, handler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, '..');
|
||||
const releaseWorkflow = fs.readFileSync(
|
||||
path.join(repositoryRoot, '.github', 'workflows', 'release.yml'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('release Linux dependency installation is mirror-normalized and bounded', () => {
|
||||
assert.match(releaseWorkflow, /azure\\\.archive\\\.ubuntu\\\.com/);
|
||||
assert.equal((releaseWorkflow.match(/Acquire::Retries=3/g) || []).length, 2);
|
||||
assert.equal((releaseWorkflow.match(/timeout --foreground --signal=TERM --kill-after=30s 10m apt-get/g) || []).length, 2);
|
||||
assert.doesNotMatch(releaseWorkflow, /^\s*sudo apt-get (update|install)/m);
|
||||
});
|
||||
Reference in New Issue
Block a user