diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 479c5e5..3c4a722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,8 +63,27 @@ jobs: - name: Install Linux dependencies if: runner.os == 'Linux' run: | - sudo apt-get update - sudo apt-get install -y \ + # Hosted Ubuntu images can expose an unreachable Azure mirror in + # their apt sources while the public Ubuntu archive is reachable. + # Keep release jobs bounded and use the same source normalization as + # the native CI path so a runner-local mirror cannot hang packaging. + sudo find /etc/apt -type f \ + -exec sed -i \ + -e 's#http://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \ + -e 's#https://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \ + {} + + sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + -o DPkg::Lock::Timeout=60 \ + update + sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \ + -o Acquire::Retries=3 \ + -o Acquire::http::Timeout=30 \ + -o Acquire::https::Timeout=30 \ + -o DPkg::Lock::Timeout=60 \ + install -y \ libwebkit2gtk-4.1-dev \ libappindicator3-dev \ librsvg2-dev \ diff --git a/scripts/engine-download.js b/scripts/engine-download.js index 975953c..accdfe5 100644 --- a/scripts/engine-download.js +++ b/scripts/engine-download.js @@ -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(); } } diff --git a/scripts/engine-download.node-test.js b/scripts/engine-download.node-test.js index da02092..6bc77f4 100644 --- a/scripts/engine-download.node-test.js +++ b/scripts/engine-download.node-test.js @@ -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 }); + } +}); diff --git a/scripts/engine-payload-promotion.js b/scripts/engine-payload-promotion.js index ce87263..60892a8 100644 --- a/scripts/engine-payload-promotion.js +++ b/scripts/engine-payload-promotion.js @@ -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 diff --git a/scripts/engine-payload-promotion.node-test.js b/scripts/engine-payload-promotion.node-test.js index 013bc27..a6c3e96 100644 --- a/scripts/engine-payload-promotion.node-test.js +++ b/scripts/engine-payload-promotion.node-test.js @@ -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 }); + } +}); diff --git a/scripts/provision-engines.js b/scripts/provision-engines.js index b89d644..fe40272 100644 --- a/scripts/provision-engines.js +++ b/scripts/provision-engines.js @@ -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); + } } diff --git a/scripts/release-workflow.node-test.js b/scripts/release-workflow.node-test.js new file mode 100644 index 0000000..ed02ef2 --- /dev/null +++ b/scripts/release-workflow.node-test.js @@ -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); +});