diff --git a/.gitignore b/.gitignore index d644d18..e26afe6 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ target/ src-tauri/target/ src-tauri/gen/ src-tauri/engine-dist/ +src-tauri/engine-dist.lock +src-tauri/engine-dist.lock.* src-tauri/provisioned-engines/ # Locally provisioned native engines diff --git a/engine-sources.lock.json b/engine-sources.lock.json index a8ebee4..e78d792 100644 --- a/engine-sources.lock.json +++ b/engine-sources.lock.json @@ -8,14 +8,14 @@ "sha256": "30b4c14aafab6082becff7881e41b76df46dc43ea7633479410a91e29da492bf" }, "deno": { - "version": "2.9.5", - "url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-pc-windows-msvc.zip", - "sha256": "171efab55ac6b9881fd53ee4c20f8bf3bb1340ffc618483746909014db12216a" + "version": "2.9.6", + "url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-pc-windows-msvc.zip", + "sha256": "15e5300b0ba3c3695a7621d90160a746ec9e710228cee639afa9d580f6e3cd11" }, "ffmpeg": { - "version": "8.1.2-46-g139afe709a", - "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-26-13-06/ffmpeg-n8.1.2-46-g139afe709a-win64-gpl-8.1.zip", - "sha256": "f966bc2e843bcd680dedd6d1a2c0c895bab859a402c6dd107cbe72a796dfebcf" + "version": "8.1.2-50-g1a748fe2cd", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-29-13-12/ffmpeg-n8.1.2-50-g1a748fe2cd-win64-gpl-8.1.zip", + "sha256": "3b56a6e46944cbe87692a4a72b29d95e527cbbd9925fc7beb07bb63d578cd5ae" }, "aria2c": { "version": "1.37.0", @@ -30,14 +30,14 @@ "sha256": "32e72032766bef9199d99d15beb69fd52e46df8f8b06f0d8745db59e04d339e9" }, "deno": { - "version": "2.9.5", - "url": "https://github.com/denoland/deno/releases/download/v2.9.5/deno-x86_64-unknown-linux-gnu.zip", - "sha256": "8b010a3b1a4a0188a67cdb8a7a27348b2a501af78aec7fc74f2ace167368d530" + "version": "2.9.6", + "url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-unknown-linux-gnu.zip", + "sha256": "394f07f4da2bebe6ce6f1e7ce0fa16429b29b08c35e3fac3fe25972676dff4b2" }, "ffmpeg": { - "version": "8.1.2-46-g139afe709a", - "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-26-13-06/ffmpeg-n8.1.2-46-g139afe709a-linux64-gpl-8.1.tar.xz", - "sha256": "0814f4491c2673ea505be8fb65a76c2bfabaa5aad8f33d49b1c5b87a2262e8c5" + "version": "8.1.2-50-g1a748fe2cd", + "url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-29-13-12/ffmpeg-n8.1.2-50-g1a748fe2cd-linux64-gpl-8.1.tar.xz", + "sha256": "be243f2520d6e7e92c82b015ed72a3f5b57c7970801c0d51f031457122653c76" }, "aria2c": { "version": "1.37.0", diff --git a/engines.lock.json b/engines.lock.json index 71c751d..6fa194b 100644 --- a/engines.lock.json +++ b/engines.lock.json @@ -23,10 +23,10 @@ "sha256": "734e6b72a0c2d0d5e089b5a0094be74fa058c15158f6b0689207a01fedafd8f5" }, "deno": { - "version": "2.9.5", + "version": "2.9.6", "source": "https://github.com/denoland/deno", "build": "official aarch64-apple-darwin executable", - "sha256": "b5bd08edab254d42d7b05aa5b6cb4c9b8d4dede4975aff76951ce2cce18866fa" + "sha256": "b3ac3bd206e48c26026cadd80c1367e96c149f9c66130952382a642b09fa8a71" } }, "runtimeTrees": { diff --git a/package-lock.json b/package-lock.json index 51dbead..a3b1087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "i18next": "^26.4.0", - "lucide-react": "^1.34.0", + "lucide-react": "^1.37.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.12", @@ -35,7 +35,7 @@ "@tauri-apps/cli": "^2.11.4", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", - "@vitejs/plugin-react": "^6.1.0", + "@vitejs/plugin-react": "^6.1.1", "autoprefixer": "^10.5.4", "postcss": "^8.5.26", "tailwindcss": "^4.3.3", @@ -1416,9 +1416,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", - "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2122,9 +2122,9 @@ } }, "node_modules/lucide-react": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz", - "integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==", + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.37.0.tgz", + "integrity": "sha512-LPsB4rD1TD6wZu1djKOf9vUnS1jTNaHbolXebXDgiTdb6jeA1agIJhJsIybCmjKmQClcOaal1o1OaiYahEftyQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 546f095..8802201 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "test:torrent:rpc": "cd src-tauri && cargo test --test torrent_rpc -- --nocapture", "verify:macos-signing": "node scripts/verify-macos-signing.js", "preview": "vite preview", - "tauri": "tauri", + "tauri": "node scripts/tauri-command.js", "test": "vitest" }, "dependencies": { @@ -60,7 +60,7 @@ "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "i18next": "^26.4.0", - "lucide-react": "^1.34.0", + "lucide-react": "^1.37.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.12", @@ -70,7 +70,7 @@ "@tauri-apps/cli": "^2.11.4", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", - "@vitejs/plugin-react": "^6.1.0", + "@vitejs/plugin-react": "^6.1.1", "autoprefixer": "^10.5.4", "postcss": "^8.5.26", "tailwindcss": "^4.3.3", diff --git a/scripts/engine-staging-lock.js b/scripts/engine-staging-lock.js new file mode 100644 index 0000000..7f5ecfb --- /dev/null +++ b/scripts/engine-staging-lock.js @@ -0,0 +1,187 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_TIMEOUT_MS = 60_000; +const RETRY_DELAYS_MS = [25, 50, 100, 250, 500]; +const RETRYABLE_RENAME_ERRORS = new Set(['EACCES', 'EBUSY', 'EPERM']); + +function sleep(milliseconds) { + return new Promise(resolve => setTimeout(resolve, milliseconds)); +} + +function isProcessAlive(pid) { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +function readLock(lockPath) { + let contents; + try { + contents = fs.readFileSync(lockPath, 'utf8'); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } + + try { + const owner = JSON.parse(contents); + if ( + !Number.isSafeInteger(owner?.pid) + || owner.pid <= 0 + || typeof owner.token !== 'string' + || owner.token.length === 0 + ) { + return { malformed: true }; + } + return { owner }; + } catch { + return { malformed: true }; + } +} + +async function removeStaleLock(lockPath) { + const quarantinePath = `${lockPath}.stale-${process.pid}-${process.hrtime.bigint()}`; + for (let attempt = 0; ; attempt += 1) { + try { + // Rename is the compare-and-remove operation: another waiter cannot + // delete a newly acquired lock after this path has changed owners. + fs.renameSync(lockPath, quarantinePath); + break; + } catch (error) { + if (error?.code === 'ENOENT' || error?.code === 'EEXIST') return false; + if (!RETRYABLE_RENAME_ERRORS.has(error?.code) || attempt >= RETRY_DELAYS_MS.length) { + return false; + } + await sleep(RETRY_DELAYS_MS[attempt]); + } + } + + try { + fs.unlinkSync(quarantinePath); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + return true; +} + +function releaseLock(lockPath, token) { + const currentLock = readLock(lockPath); + if (!currentLock) return; + if (currentLock.malformed) { + throw new Error(`Cannot release staging lock with an invalid owner record: ${lockPath}`); + } + const owner = currentLock.owner; + if (owner.token !== token || owner.pid !== process.pid) { + throw new Error(`Staging lock ownership changed before release: ${lockPath}`); + } + try { + fs.unlinkSync(lockPath); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +} + +function createLock(lockPath, owner) { + const temporaryPath = `${lockPath}.owner-${owner.token}`; + let temporaryCreated = false; + try { + // Write the complete record away from the contested path, then publish it + // with a hard link. Unlike rename, link never replaces an existing lock. + fs.writeFileSync(temporaryPath, `${JSON.stringify(owner)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + temporaryCreated = true; + fs.linkSync(temporaryPath, lockPath); + } finally { + if (temporaryCreated) { + try { + fs.unlinkSync(temporaryPath); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + } +} + +export function readExclusiveFileLockOwner(lockPath) { + const currentLock = readLock(path.resolve(lockPath)); + if (!currentLock?.owner) { + throw new Error(`Staging lock owner record is unavailable: ${path.resolve(lockPath)}`); + } + return currentLock.owner; +} + +export function assertExclusiveFileLockHeld(lockPath, expectedOwner) { + if ( + !Number.isSafeInteger(expectedOwner?.pid) + || expectedOwner.pid <= 0 + || typeof expectedOwner.token !== 'string' + || expectedOwner.token.length === 0 + ) { + throw new Error(`Invalid inherited staging lock owner: ${path.resolve(lockPath)}`); + } + + const owner = readExclusiveFileLockOwner(lockPath); + if ( + owner.pid !== expectedOwner.pid + || owner.token !== expectedOwner.token + || !isProcessAlive(owner.pid) + ) { + throw new Error(`Inherited staging lock is no longer held: ${path.resolve(lockPath)}`); + } +} + +/** + * Serializes operations that replace the shared engine-dist directory. + * Returns an idempotent release function after the caller owns the lock. + */ +export async function acquireExclusiveFileLock(lockPath, options = {}) { + const resolvedPath = path.resolve(lockPath); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const startedAt = Date.now(); + const token = `${process.pid}-${process.hrtime.bigint()}`; + const owner = { + pid: process.pid, + token, + startedAt: new Date().toISOString(), + }; + + for (let attempt = 0; ; attempt += 1) { + try { + createLock(resolvedPath, owner); + + let released = false; + return () => { + if (released) return; + releaseLock(resolvedPath, token); + released = true; + }; + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + + const currentLock = readLock(resolvedPath); + if (currentLock?.malformed) { + if (await removeStaleLock(resolvedPath)) continue; + } else if (currentLock?.owner && !isProcessAlive(currentLock.owner.pid)) { + if (await removeStaleLock(resolvedPath)) continue; + } + + if (Date.now() - startedAt >= timeoutMs) { + const ownerDescription = currentLock?.owner?.pid + ? ` owned by PID ${currentLock.owner.pid}` + : ' with an unreadable owner record'; + throw new Error(`Timed out waiting for staging lock ${resolvedPath}${ownerDescription}`); + } + + const delay = RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)]; + await sleep(delay); + } + } +} diff --git a/scripts/engine-staging-lock.node-test.js b/scripts/engine-staging-lock.node-test.js new file mode 100644 index 0000000..1cdd41b --- /dev/null +++ b/scripts/engine-staging-lock.node-test.js @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { + acquireExclusiveFileLock, + assertExclusiveFileLockHeld, + readExclusiveFileLockOwner, +} from './engine-staging-lock.js'; + +const childOutputStates = new WeakMap(); + +function temporaryLockPath() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-stage-lock-')); + return path.join(directory, 'engine-dist.lock'); +} + +function waitForOutput(child, expected) { + const state = childOutputStates.get(child); + assert.ok(state, 'child output tracking must be installed before waiting'); + if (state.output.includes(expected)) return Promise.resolve(state.output); + if (state.error) return Promise.reject(state.error); + if (state.exit) { + return Promise.reject(new Error( + `lock child exited before '${expected}' (code=${state.exit.code}, signal=${state.exit.signal})`, + )); + } + + return new Promise((resolve, reject) => { + state.waiters.push({ expected, resolve, reject }); + }); +} + +function trackChildOutput(child) { + const state = { error: null, exit: null, output: '', waiters: [] }; + childOutputStates.set(child, state); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', chunk => { + state.output += chunk; + for (const waiter of state.waiters.splice(0)) { + if (state.output.includes(waiter.expected)) { + waiter.resolve(state.output); + } else { + state.waiters.push(waiter); + } + } + }); + child.once('error', error => { + state.error = error; + for (const waiter of state.waiters.splice(0)) waiter.reject(error); + }); + child.once('exit', (code, signal) => { + state.exit = { code, signal }; + const error = new Error(`lock child exited before a requested output (code=${code}, signal=${signal})`); + for (const waiter of state.waiters.splice(0)) waiter.reject(error); + }); +} + +function spawnLockChild(lockPath) { + const moduleUrl = pathToFileURL(path.resolve('scripts/engine-staging-lock.js')).href; + const source = ` +import { acquireExclusiveFileLock } from ${JSON.stringify(moduleUrl)}; +process.stdout.write('started\\n'); +const release = await acquireExclusiveFileLock(process.argv[1], { timeoutMs: 5_000 }); +process.stdout.write('acquired\\n'); +process.stdin.once('data', () => { + release(); + process.exit(0); +}); +process.stdin.resume(); +`; + const child = spawn(process.execPath, ['--input-type=module', '-e', source, lockPath], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + trackChildOutput(child); + return child; +} + +test('exclusive staging lock waits for a live owner and releases idempotently', async () => { + const lockPath = temporaryLockPath(); + const releaseFirst = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); + const secondAcquisition = acquireExclusiveFileLock(lockPath, { + timeoutMs: 1_000, + }); + + assert.equal(fs.existsSync(lockPath), true); + releaseFirst(); + releaseFirst(); + + const releaseSecond = await secondAcquisition; + assert.equal(fs.existsSync(lockPath), true); + releaseSecond(); + assert.equal(fs.existsSync(lockPath), false); +}); + +test('exclusive staging lock recovers empty and malformed legacy records', async () => { + for (const contents of ['', '{not-json']) { + const lockPath = temporaryLockPath(); + fs.writeFileSync(lockPath, contents); + + const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); + const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + assert.equal(owner.pid, process.pid); + release(); + assert.equal(fs.existsSync(lockPath), false); + } +}); + +test('exclusive staging lock serializes separate processes', async () => { + const lockPath = temporaryLockPath(); + const first = spawnLockChild(lockPath); + let second; + try { + await waitForOutput(first, 'acquired\n'); + second = spawnLockChild(lockPath); + await waitForOutput(second, 'started\n'); + + const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + assert.equal(owner.pid, first.pid); + first.stdin.write('\n'); + await once(first, 'exit'); + await waitForOutput(second, 'acquired\n'); + second.stdin.write('\n'); + await once(second, 'exit'); + } finally { + for (const child of [first, second]) { + if (child && child.exitCode === null && child.signalCode === null) child.kill(); + } + } +}); + +test('exclusive staging lock recovers after an owner is force-killed', async () => { + const lockPath = temporaryLockPath(); + const owner = spawnLockChild(lockPath); + try { + await waitForOutput(owner, 'acquired\n'); + assert.equal(owner.kill('SIGKILL'), true); + await once(owner, 'exit'); + + const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); + const replacement = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + assert.equal(replacement.pid, process.pid); + release(); + assert.equal(fs.existsSync(lockPath), false); + } finally { + if (owner.exitCode === null && owner.signalCode === null) owner.kill(); + } +}); + +test('exclusive staging lock recovers a lock owned by a dead process', async () => { + const lockPath = temporaryLockPath(); + fs.writeFileSync(lockPath, JSON.stringify({ + pid: 999_999_999, + token: 'dead-owner', + })); + + const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); + const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + assert.equal(owner.pid, process.pid); + assert.notEqual(owner.token, 'dead-owner'); + release(); + assert.equal(fs.existsSync(lockPath), false); +}); + +test('staging lock release fails closed when ownership changes', async () => { + const lockPath = temporaryLockPath(); + const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); + fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, token: 'other-owner' })); + + assert.throws(() => release(), /ownership changed/); + fs.unlinkSync(lockPath); +}); + +test('inherited staging lock validation rejects a released lease', async () => { + const lockPath = temporaryLockPath(); + const release = await acquireExclusiveFileLock(lockPath, { timeoutMs: 1_000 }); + const owner = readExclusiveFileLockOwner(lockPath); + assert.doesNotThrow(() => assertExclusiveFileLockHeld(lockPath, owner)); + release(); + + assert.throws(() => assertExclusiveFileLockHeld(lockPath, owner), /owner record is unavailable/); +}); diff --git a/scripts/stage-engines.js b/scripts/stage-engines.js index c6e7ddd..bbb73dd 100644 --- a/scripts/stage-engines.js +++ b/scripts/stage-engines.js @@ -4,6 +4,10 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js'; +import { + acquireExclusiveFileLock, + assertExclusiveFileLockHeld, +} from './engine-staging-lock.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); @@ -104,29 +108,48 @@ if (targetLock) { } const destination = path.join(outputRoot, target); -fs.rmSync(outputRoot, { recursive: true, force: true }); -fs.mkdirSync(destination, { recursive: true }); - -for (const name of expectedNames) { - fs.copyFileSync(path.join(source, name), path.join(destination, name)); - if (!isWindowsTarget) { - fs.chmodSync(path.join(destination, name), 0o755); - } +const stagingLockPath = `${outputRoot}.lock`; +const inheritedLockPid = process.env.FIRELINK_ENGINE_STAGING_LOCK_PID; +const inheritedLockToken = process.env.FIRELINK_ENGINE_STAGING_LOCK_TOKEN; +const inheritedStagingLock = inheritedLockPid !== undefined || inheritedLockToken !== undefined; +if (inheritedStagingLock) { + assertExclusiveFileLockHeld(stagingLockPath, { + pid: Number(inheritedLockPid), + token: inheritedLockToken, + }); } +const releaseStageLock = inheritedStagingLock + ? null + : await acquireExclusiveFileLock(stagingLockPath); +try { + // Tauri packages the shared engine-dist root. Keep exactly one target in it; + // the lock serializes this replacement when local stage commands overlap. + fs.rmSync(outputRoot, { recursive: true, force: true }); + fs.mkdirSync(destination, { recursive: true }); -for (const runtimeDir of ['_internal', 'aria2-libs']) { - const sourceDir = path.join(source, runtimeDir); - if (fs.existsSync(sourceDir)) { - fs.cpSync(sourceDir, path.join(destination, runtimeDir), { - recursive: true, - dereference: false, - preserveTimestamps: true, - }); + for (const name of expectedNames) { + fs.copyFileSync(path.join(source, name), path.join(destination, name)); + if (!isWindowsTarget) { + fs.chmodSync(path.join(destination, name), 0o755); + } } -} -const payloadManifest = path.join(source, 'payload-manifest.json'); -if (fs.existsSync(payloadManifest)) { - fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json')); + + for (const runtimeDir of ['_internal', 'aria2-libs']) { + const sourceDir = path.join(source, runtimeDir); + if (fs.existsSync(sourceDir)) { + fs.cpSync(sourceDir, path.join(destination, runtimeDir), { + recursive: true, + dereference: false, + preserveTimestamps: true, + }); + } + } + const payloadManifest = path.join(source, 'payload-manifest.json'); + if (fs.existsSync(payloadManifest)) { + fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json')); + } +} finally { + releaseStageLock?.(); } console.log(`Staged Firelink engines for ${target} from ${source}`); diff --git a/scripts/tauri-command.js b/scripts/tauri-command.js new file mode 100644 index 0000000..fef2529 --- /dev/null +++ b/scripts/tauri-command.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { + acquireExclusiveFileLock, + readExclusiveFileLockOwner, +} from './engine-staging-lock.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); +const tauriCli = path.join(repoRoot, 'node_modules', '@tauri-apps', 'cli', 'tauri.js'); +const stagingLockPath = path.join(repoRoot, 'src-tauri', 'engine-dist.lock'); +const ENGINE_TREE_COMMANDS = new Set(['dev', 'build', 'bundle']); + +export function commandUsesEngineTree(args) { + return args.some(argument => ENGINE_TREE_COMMANDS.has(argument)); +} + +function signalExitCode(signal) { + return { + SIGHUP: 129, + SIGINT: 130, + SIGTERM: 143, + }[signal] ?? 1; +} + +const args = process.argv.slice(2); +const holdsEngineLock = commandUsesEngineTree(args); +let releaseEngineLock; +let child; + +function handleSignal(signal) { + if (child && child.exitCode === null && child.signalCode === null) { + try { + child.kill(signal); + } catch (error) { + if (error?.code !== 'ESRCH') { + console.error(`[WARN] Could not terminate Tauri: ${error.message}`); + } + } + return; + } + + // No child has started yet, so there is no packaging work to preserve. If + // lock acquisition was interrupted after ownership was granted, release it + // before exiting; otherwise exit promptly instead of waiting out the lock. + try { + releaseEngineLock?.(); + } catch (error) { + console.error(`[WARN] Could not release the engine staging lock: ${error.message}`); + } + process.exit(signalExitCode(signal)); +} + +async function run() { + const handlers = new Map(['SIGHUP', 'SIGINT', 'SIGTERM'].map(signal => [ + signal, + () => handleSignal(signal), + ])); + for (const [signal, handler] of handlers) process.once(signal, handler); + + try { + const env = { ...process.env }; + if (holdsEngineLock) { + releaseEngineLock = await acquireExclusiveFileLock(stagingLockPath); + const owner = readExclusiveFileLockOwner(stagingLockPath); + // beforeBuildCommand and beforeDevCommand run as child processes. The + // wrapper keeps the lock until Tauri has finished consuming resources; + // nested staging/verifying must therefore use this held lease instead + // of trying to acquire the same path again. + env.FIRELINK_ENGINE_STAGING_LOCK_PID = String(owner.pid); + env.FIRELINK_ENGINE_STAGING_LOCK_TOKEN = owner.token; + } + + child = spawn(process.execPath, [tauriCli, ...args], { + cwd: repoRoot, + env, + stdio: 'inherit', + windowsHide: true, + }); + + const result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + + if (result.signal) { + process.exitCode = signalExitCode(result.signal); + } else { + process.exitCode = result.code ?? 1; + } + } finally { + for (const [signal, handler] of handlers) process.removeListener(signal, handler); + releaseEngineLock?.(); + } +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + run().catch(error => { + console.error(`[FAIL] Tauri command failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/tauri-command.node-test.js b/scripts/tauri-command.node-test.js new file mode 100644 index 0000000..8566e4a --- /dev/null +++ b/scripts/tauri-command.node-test.js @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { commandUsesEngineTree } from './tauri-command.js'; + +test('Tauri engine-consuming commands hold the shared staging lease', () => { + assert.equal(commandUsesEngineTree(['dev']), true); + assert.equal(commandUsesEngineTree(['build', '--target', 'x86_64-unknown-linux-gnu']), true); + assert.equal(commandUsesEngineTree(['bundle', '--bundles', 'appimage']), true); + assert.equal(commandUsesEngineTree(['info']), false); + assert.equal(commandUsesEngineTree(['--help']), false); +}); diff --git a/scripts/verify-binaries.js b/scripts/verify-binaries.js index 2f26ac6..0a8fe70 100644 --- a/scripts/verify-binaries.js +++ b/scripts/verify-binaries.js @@ -5,6 +5,10 @@ import os from 'node:os'; import net from 'node:net'; import { execFileSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { + acquireExclusiveFileLock, + assertExclusiveFileLockHeld, +} from './engine-staging-lock.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -89,6 +93,20 @@ const binariesDir = configuredRoot ? path.resolve(configuredRoot) : path.join(scriptsDir, '..', 'src-tauri', 'binaries'); const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno']; +const stagedVerification = process.argv.includes('--staged'); +const stagingLockPath = path.join(scriptsDir, '..', 'src-tauri', 'engine-dist.lock'); +const inheritedLockPid = process.env.FIRELINK_ENGINE_STAGING_LOCK_PID; +const inheritedLockToken = process.env.FIRELINK_ENGINE_STAGING_LOCK_TOKEN; +const inheritedStagingLock = inheritedLockPid !== undefined || inheritedLockToken !== undefined; +if (stagedVerification && inheritedStagingLock) { + assertExclusiveFileLockHeld(stagingLockPath, { + pid: Number(inheritedLockPid), + token: inheritedLockToken, + }); +} +const releaseStagingLock = stagedVerification && !inheritedStagingLock + ? await acquireExclusiveFileLock(stagingLockPath) + : null; const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar']; const FORBIDDEN_STDERR = [ @@ -171,6 +189,7 @@ for (const eng of requiredEngines) { if (exitCode !== 0) { console.error('\nAborting: missing required sidecars.'); + releaseStagingLock?.(); process.exit(1); } @@ -583,6 +602,7 @@ if (canExecuteTarget) { // ───── Result ───── console.log(''); +releaseStagingLock?.(); if (exitCode !== 0) { console.error(`[FAIL] ${exitCode} engine verification check(s) failed.`); process.exit(1); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0e3f4ca..67ca9e8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -10,13 +10,13 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpubits", + "cpufeatures 0.3.1", ] [[package]] @@ -396,11 +396,11 @@ dependencies = [ [[package]] name = "block-padding" -version = "0.3.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -557,9 +557,9 @@ dependencies = [ [[package]] name = "cbc" -version = "0.1.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ "cipher", ] @@ -609,12 +609,12 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -634,11 +634,11 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.1.7", + "crypto-common 0.2.2", "inout", ] @@ -752,6 +752,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -763,9 +769,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -1053,7 +1059,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -1403,7 +1408,7 @@ dependencies = [ "base64 0.22.1", "chrono", "futures-util", - "hmac 0.13.0", + "hmac", "keepawake", "keyring-core", "log 0.4.34", @@ -1453,12 +1458,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -2001,20 +2007,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.12.1", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", + "hmac", ] [[package]] @@ -2092,9 +2089,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -2320,9 +2317,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2341,12 +2338,12 @@ dependencies = [ [[package]] name = "inout" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ "block-padding", - "generic-array", + "hybrid-array", ] [[package]] @@ -2677,9 +2674,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" dependencies = [ "libc", ] @@ -2811,6 +2808,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -3403,7 +3410,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap 2.14.1", ] [[package]] @@ -3489,7 +3496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", - "indexmap 2.14.0", + "indexmap 2.14.1", "quick-xml", "serde", "time", @@ -3505,7 +3512,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -3518,7 +3525,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4135,20 +4142,20 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secret-service" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ "aes", "cbc", "futures-util", - "generic-array", - "getrandom 0.2.17", + "getrandom 0.4.3", "hkdf", + "hybrid-array", "num", "once_cell", "serde", - "sha2 0.10.9", + "sha2 0.11.0", "zbus", ] @@ -4333,7 +4340,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.1", "jiff", "schemars 0.9.0", "schemars 1.2.2", @@ -4404,7 +4411,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -4426,7 +4433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -5459,7 +5466,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -5474,7 +5481,7 @@ version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -5516,7 +5523,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -5527,7 +5534,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -5540,7 +5547,7 @@ version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.4", @@ -5894,9 +5901,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.25.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7132,6 +7139,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" diff --git a/src-tauri/binaries/deno-aarch64-apple-darwin b/src-tauri/binaries/deno-aarch64-apple-darwin index e3e08b9..54984fa 100755 Binary files a/src-tauri/binaries/deno-aarch64-apple-darwin and b/src-tauri/binaries/deno-aarch64-apple-darwin differ