diff --git a/src/main/cli/appimage-copy-pre-extraction.test.ts b/src/main/cli/appimage-copy-pre-extraction.test.ts new file mode 100644 index 00000000000..daf284e9856 --- /dev/null +++ b/src/main/cli/appimage-copy-pre-extraction.test.ts @@ -0,0 +1,238 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveAppImageExtractedRoot } from './appimage-extracted-root' +import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' +import type * as PreExtractionModule from './appimage-copy-pre-extraction' + +type PreExtraction = typeof PreExtractionModule + +const created: string[] = [] +let preExtraction: PreExtraction + +beforeEach(async () => { + // Why: the module holds this launch's in-flight copy, so every test starts a fresh launch. + vi.resetModules() + preExtraction = await import('./appimage-copy-pre-extraction') +}) + +afterEach(async () => { + await Promise.all(created.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +async function makeFixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-appimage-pre-extract-')) + created.push(root) + const appImagePath = join(root, 'apps', 'Orca.AppImage') + await mkdir(join(root, 'apps'), { recursive: true }) + await writeFile(appImagePath, 'release 1.0.0', { mode: 0o755 }) + return { root, appImagePath, cacheRootPath: join(root, 'cache') } +} + +async function writePayload(cwd: string): Promise { + const launcherDir = join(cwd, 'squashfs-root', 'resources', 'bin') + await mkdir(launcherDir, { recursive: true }) + await writeFile(join(launcherDir, 'orca-ide'), '', { mode: 0o755 }) +} + +const acceptCache = async (): Promise => null + +describe('startAppImageCopyPreExtraction', () => { + it('unpacks a missing copy in the background without taking the CLI launcher', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const lines: string[] = [] + + const started = preExtraction.startAppImageCopyPreExtraction({ + appImagePath, + cacheRootPath, + probeCache: acceptCache, + runExtract: async (_path, cwd) => writePayload(cwd), + log: (line) => lines.push(line) + }) + + expect(await started).toBe(true) + expect(await preExtraction.waitForAppImageCopyPreExtraction(10)).toBe(true) + const copy = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + expect(existsSync(copy.payloadLauncherPath)).toBe(true) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed'))).toBe(false) + expect(lines.at(-1)).toContain('Unpacked this AppImage') + }) + + it('does nothing when the copy already exists, and then nothing is waited for', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const copy = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await mkdir(join(copy.rootPath, 'resources', 'bin'), { recursive: true }) + await writeFile(copy.payloadLauncherPath, '', { mode: 0o755 }) + const runExtract = vi.fn() + + expect( + preExtraction.startAppImageCopyPreExtraction({ appImagePath, cacheRootPath, runExtract }) + ).toBeNull() + expect(await preExtraction.waitForAppImageCopyPreExtraction(10)).toBe(false) + expect(runExtract).not.toHaveBeenCalled() + }) + + it('drops idle copies of earlier releases once this one has its copy', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const copy = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + const earlier = join(dirname(copy.rootPath), 'a'.repeat(24)) + await mkdir(join(earlier, 'resources', 'bin'), { recursive: true }) + await writeFile(join(earlier, 'resources', 'bin', 'orca-ide'), '', { mode: 0o755 }) + + const started = preExtraction.startAppImageCopyPreExtraction({ + appImagePath, + cacheRootPath, + probeCache: acceptCache, + runExtract: async (_path, cwd) => writePayload(cwd), + log: () => {} + }) + + expect(await started).toBe(true) + await vi.waitFor(() => expect(existsSync(earlier)).toBe(false)) + expect(existsSync(copy.payloadLauncherPath)).toBe(true) + }) + + it('drops idle earlier copies at a launch that already has its copy', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const copy = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + await mkdir(join(copy.rootPath, 'resources', 'bin'), { recursive: true }) + await writeFile(copy.payloadLauncherPath, '', { mode: 0o755 }) + const earlier = join(dirname(copy.rootPath), 'b'.repeat(24)) + await mkdir(join(earlier, 'resources', 'bin'), { recursive: true }) + + expect(preExtraction.startAppImageCopyPreExtraction({ appImagePath, cacheRootPath })).toBeNull() + await vi.waitFor(() => expect(existsSync(earlier)).toBe(false)) + expect(existsSync(copy.payloadLauncherPath)).toBe(true) + }) + + it('logs a cache it cannot use and never extracts into it', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const lines: string[] = [] + const runExtract = vi.fn() + + const started = preExtraction.startAppImageCopyPreExtraction({ + appImagePath, + cacheRootPath, + probeCache: async () => `${cacheRootPath} needs about 900 MB free`, + runExtract, + log: (line) => lines.push(line) + }) + + expect(await started).toBe(false) + expect(runExtract).not.toHaveBeenCalled() + expect(lines).toEqual([expect.stringContaining('needs about 900 MB free')]) + }) + + it('logs a failed extraction and leaves nothing behind', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + const lines: string[] = [] + + const started = preExtraction.startAppImageCopyPreExtraction({ + appImagePath, + cacheRootPath, + probeCache: acceptCache, + runExtract: async () => { + throw new Error('No space left on device') + }, + log: (line) => lines.push(line) + }) + + expect(await started).toBe(false) + expect(lines.at(-1)).toContain('Could not unpack this AppImage') + const namespaces = existsSync(cacheRootPath) ? await readdir(cacheRootPath) : [] + expect(namespaces.filter((name) => /^[0-9a-f]{24}$/u.test(name))).toEqual([]) + }) + + it('stops waiting at the bound while the copy is still unpacking', async () => { + const { appImagePath, cacheRootPath } = await makeFixture() + let finish: () => void = () => {} + const extracting = new Promise((resolve) => { + finish = resolve + }) + + const started = preExtraction.startAppImageCopyPreExtraction({ + appImagePath, + cacheRootPath, + probeCache: acceptCache, + runExtract: async (_path, cwd) => { + await extracting + await writePayload(cwd) + }, + log: () => {} + }) + + expect(await preExtraction.waitForAppImageCopyPreExtraction(20)).toBe(false) + finish() + expect(await started).toBe(true) + }) +}) + +describe('preExtractDownloadedAppImageUpdate', () => { + it('unpacks the download into the copy its install will run from', async () => { + const { root, appImagePath, cacheRootPath } = await makeFixture() + const downloadedFile = join(root, 'apps', '.pending-Orca.AppImage') + await writeFile(downloadedFile, 'release 2.0.0', { mode: 0o755 }) + + const staged = await preExtraction.preExtractDownloadedAppImageUpdate( + downloadedFile, + async () => true, + { + appImagePath, + cacheRootPath, + probeCache: acceptCache, + runExtract: async (path, cwd) => { + expect(path).toBe(downloadedFile) + await writePayload(cwd) + }, + log: () => {} + } + ) + expect(staged).toBe(true) + + // What the updater's install does on one filesystem: a rename, which keeps the file identity. + await rename(downloadedFile, appImagePath) + const installed = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })! + expect(existsSync(installed.payloadLauncherPath)).toBe(true) + expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'installed'))).toBe(false) + }) + + it('never runs a file that is not the updater download', async () => { + const { root, appImagePath, cacheRootPath } = await makeFixture() + const planted = join(root, 'Orca-2.0.0.AppImage') + await writeFile(planted, 'not an update', { mode: 0o755 }) + const runExtract = vi.fn() + + expect( + await preExtraction.preExtractDownloadedAppImageUpdate(planted, async () => false, { + appImagePath, + cacheRootPath, + probeCache: acceptCache, + runExtract + }) + ).toBe(false) + expect( + await preExtraction.preExtractDownloadedAppImageUpdate( + 'relative.AppImage', + async () => true, + { + appImagePath, + cacheRootPath, + runExtract + } + ) + ).toBe(false) + expect(runExtract).not.toHaveBeenCalled() + }) + + it('follows the updater on where a versioned AppImage name installs', () => { + const pending = '/home/u/.cache/orca-updater/pending/Orca-2.0.0.AppImage' + expect(preExtraction.predictInstalledAppImagePath('/apps/Orca.AppImage', pending)).toBe( + '/apps/Orca.AppImage' + ) + expect(preExtraction.predictInstalledAppImagePath('/apps/Orca-1.0.0.AppImage', pending)).toBe( + '/apps/Orca-2.0.0.AppImage' + ) + }) +}) diff --git a/src/main/cli/appimage-copy-pre-extraction.ts b/src/main/cli/appimage-copy-pre-extraction.ts new file mode 100644 index 00000000000..cd5b3ddec0d --- /dev/null +++ b/src/main/cli/appimage-copy-pre-extraction.ts @@ -0,0 +1,195 @@ +/** + * A validated AppImage desktop unpacks its extracted copy in the background, so the next terminal daemon + * runs from the copy instead of a FUSE mount that can vanish under it (appimage-extraction-cache.md): + * at a launch that finds no copy for this file, and when an update finishes downloading. Lowest + * priority, under the registration lock, never publishes the CLI launcher, and never blocks the + * window: a refusal or failure is logged and the daemon keeps today's mount fallback. + */ +import { existsSync } from 'node:fs' +import { lstat, stat } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join } from 'node:path' +import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity' +import { + isAppImageExtractionComplete, + resolveAppImageCacheRootPath, + resolveAppImageExtractedRoot, + runAppImageExtract, + stageAppImageExtractedRoot, + type AppImageExtractRunner +} from './appimage-extracted-root' +import { pruneAppImageExtractedRoots } from './appimage-extraction-pruning' +import { leaseAppImageGenerationForCurrentProcess } from './appimage-generation-lease' +import { withAppImageRegistrationLock } from './appimage-registration-lock' +import { probeAppImageCache } from './appimage-serve-preparation' + +/** How long a daemon launch waits for this launch's copy before falling back to the mount. */ +export const APPIMAGE_COPY_DAEMON_WAIT_MS = 8_000 +// Why `nice` alone: without an explicit I/O class, Linux derives a best-effort I/O priority from it. +const NICE_PROGRAM_PATHS = ['/usr/bin/nice', '/bin/nice'] as const +// electron-updater keeps a custom AppImage name unless it carries a version (AppImageUpdater.doInstall). +const VERSIONED_NAME = /\d+\.\d+\.\d+/ +const GENERATION_EXECUTABLE_NAME = 'orca-ide' + +export type AppImageCopyPreExtractionOptions = { + /** The running AppImage; defaults to this process's validated one. */ + appImagePath?: string + cacheRootPath?: string + runExtract?: AppImageExtractRunner + probeCache?: (cacheRootPath: string, appImagePath: string) => Promise + log?: (line: string) => void +} + +let currentCopy: Promise | null = null + +const runAppImageExtractInBackground: AppImageExtractRunner = (appImagePath, cwd, timeoutMs) => + runAppImageExtract(appImagePath, cwd, timeoutMs, NICE_PROGRAM_PATHS.find(existsSync)) + +/** At launch: unpack this AppImage's copy when there is none yet. Null when nothing needs doing. */ +export function startAppImageCopyPreExtraction( + options: AppImageCopyPreExtractionOptions = {} +): Promise | null { + const appImagePath = options.appImagePath ?? resolveAppImageRuntimeIdentity()?.appImagePath + if (!appImagePath || currentCopy) { + return currentCopy + } + const root = resolveAppImageExtractedRoot({ ...options, appImagePath }) + if (!root) { + return null + } + if (isAppImageExtractionComplete(root)) { + void pruneIdleGenerations(root.rootPath, { ...options, appImagePath }) + return null + } + currentCopy = preExtract({ ...options, appImagePath }, 'this AppImage') + // Why after, not inside: a daemon may be waiting on the copy, and must not wait on the prune too. + void currentCopy.then(async (ready) => { + if (ready) { + await pruneIdleGenerations(root.rootPath, { ...options, appImagePath }) + } + }) + return currentCopy +} + +/** True when this launch's copy finished within `timeoutMs`; false at once when none is running. */ +export async function waitForAppImageCopyPreExtraction( + timeoutMs = APPIMAGE_COPY_DAEMON_WAIT_MS +): Promise { + if (!currentCopy) { + return false + } + let timer: ReturnType | undefined + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs) + timer.unref?.() + }) + try { + return await Promise.race([currentCopy, timedOut]) + } finally { + clearTimeout(timer) + } +} + +/** At `update-downloaded`: unpack the downloaded AppImage into the copy its install will run from. */ +export async function preExtractDownloadedAppImageUpdate( + downloadedFile: unknown, + /** Whether a path is electron-updater's own verified download; injected to keep Electron out. */ + isUpdateDownload: (filePath: string) => Promise, + options: AppImageCopyPreExtractionOptions = {} +): Promise { + const appImagePath = options.appImagePath ?? resolveAppImageRuntimeIdentity()?.appImagePath + if (!appImagePath || typeof downloadedFile !== 'string' || !isAbsolute(downloadedFile)) { + return false + } + const log = options.log ?? ((line: string) => console.log(line)) + try { + // Why: this runs the downloaded file; accept only electron-updater's own verified download. + const download = await lstat(downloadedFile) + if (!download.isFile() || !(await isUpdateDownload(downloadedFile))) { + return false + } + const installedAppImagePath = predictInstalledAppImagePath(appImagePath, downloadedFile) + // Why: the updater's `mv` keeps the file identity that keys the copy only within one filesystem. + if (download.dev !== (await stat(dirname(installedAppImagePath))).dev) { + log('[appimage-copy] Not unpacking the update: it installs onto another filesystem') + return false + } + return await preExtract( + { ...options, appImagePath: downloadedFile, installedAppImagePath }, + `the update ${basename(downloadedFile)}` + ) + } catch (error) { + log(`[appimage-copy] Not unpacking the update: ${describe(error)}`) + return false + } +} + +export function predictInstalledAppImagePath(appImagePath: string, downloadedFile: string): string { + const currentName = basename(appImagePath) + return basename(downloadedFile) === currentName || !VERSIONED_NAME.test(currentName) + ? appImagePath + : join(dirname(appImagePath), basename(downloadedFile)) +} + +async function preExtract( + options: AppImageCopyPreExtractionOptions & { + appImagePath: string + installedAppImagePath?: string + }, + subject: string +): Promise { + const log = options.log ?? ((line: string) => console.log(line)) + const cacheRootPath = resolveAppImageCacheRootPath(options) + try { + const refusal = await (options.probeCache ?? probeAppImageCache)( + cacheRootPath, + options.appImagePath + ) + if (refusal) { + log(`[appimage-copy] Not unpacking ${subject}: ${refusal}`) + return false + } + const startedAt = Date.now() + log(`[appimage-copy] Unpacking ${subject} in the background into ${cacheRootPath}`) + const root = await withAppImageRegistrationLock(cacheRootPath, () => + stageAppImageExtractedRoot({ + ...options, + runExtract: options.runExtract ?? runAppImageExtractInBackground + }) + ) + if (!root) { + log(`[appimage-copy] Could not unpack ${subject}; terminal daemons keep the mount fallback`) + return false + } + if (options.installedAppImagePath) { + // Why: until the update installs, nothing runs from this copy, so this window's lease is + // what keeps the running release's pruners off it. + await leaseAppImageGenerationForCurrentProcess({ + execPath: join(root.rootPath, GENERATION_EXECUTABLE_NAME) + }) + } + log(`[appimage-copy] Unpacked ${subject} in ${Math.round((Date.now() - startedAt) / 1000)}s`) + return true + } catch (error) { + log(`[appimage-copy] Could not unpack ${subject}: ${describe(error)}`) + return false + } +} + +/** Without this, a desktop that never registers the CLI keeps every release's copy it unpacked. */ +async function pruneIdleGenerations( + keepRootPath: string, + options: AppImageCopyPreExtractionOptions & { appImagePath: string } +): Promise { + const log = options.log ?? ((line: string) => console.log(line)) + try { + await withAppImageRegistrationLock(resolveAppImageCacheRootPath(options), () => + pruneAppImageExtractedRoots(keepRootPath) + ) + } catch (error) { + log(`[appimage-copy] Could not prune older AppImage copies: ${describe(error)}`) + } +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/cli/appimage-extracted-root.ts b/src/main/cli/appimage-extracted-root.ts index 8ad4e659dd4..20f41b327e9 100644 --- a/src/main/cli/appimage-extracted-root.ts +++ b/src/main/cli/appimage-extracted-root.ts @@ -49,6 +49,8 @@ export type AppImageExtractRunner = ( export type AppImageExtractionOptions = { appImagePath: string + /** Where the AppImage will run from once installed, when that is not `appImagePath` yet. */ + installedAppImagePath?: string cacheRootPath?: string runExtract?: AppImageExtractRunner /** Absolute time (ms) by which every extraction attempt must have finished. */ @@ -156,7 +158,7 @@ export async function ensureAppImageExtractedRoot( ) if (launcherPath === root.stableLauncherPath) { await rm(getAppImageActiveExtractionPath(root.rootPath), { force: true }).catch(() => {}) - await recordAppImageSource(dirname(root.rootPath), options.appImagePath) + await recordAppImageSource(dirname(root.rootPath), installedPathOf(options)) return root } } @@ -165,6 +167,29 @@ export async function ensureAppImageExtractedRoot( return null } +/** + * Unpacks the generation without publishing it as the installed CLI launcher or pruning, so a + * desktop can prepare its daemon's copy, or an update's, without taking the `orca` command. + */ +export async function stageAppImageExtractedRoot( + options: AppImageExtractionOptions +): Promise { + const root = resolveAppImageExtractedRoot(options) + if (!root) { + return null + } + if (!isAppImageExtractionComplete(root)) { + await extractAppImageGeneration(options, root) + // Why: the marker fences a launcher publish, which a staged copy never makes. + await rm(getAppImageActiveExtractionPath(root.rootPath), { force: true }).catch(() => {}) + } + if (!isAppImageExtractionComplete(root)) { + return null + } + await recordAppImageSource(dirname(root.rootPath), installedPathOf(options)) + return root +} + async function cleanFailedEndpointPublication(root: AppImageExtractedRoot): Promise { await rm(getAppImageActiveExtractionPath(root.rootPath), { force: true }).catch(() => {}) await pruneAppImageExtractedRoots(root.rootPath) @@ -220,7 +245,11 @@ async function recordAppImageSource(namespacePath: string, appImagePath: string) } export function resolveAppImageNamespacePath(options: AppImageExtractionOptions): string { - return join(resolveAppImageCacheRootPath(options), appImageNamespaceKey(options.appImagePath)) + return join(resolveAppImageCacheRootPath(options), appImageNamespaceKey(installedPathOf(options))) +} + +function installedPathOf(options: AppImageExtractionOptions): string { + return options.installedAppImagePath ?? options.appImagePath } /** The namespace pre-lease builds used for this AppImage path. */ @@ -285,14 +314,16 @@ async function renameRoot(sourcePath: string, destinationPath: string): Promise< } } -async function runAppImageExtract( +/** `niceProgram` runs the extraction under `nice -n 19`. */ +export async function runAppImageExtract( appImagePath: string, cwd: string, - timeoutMs: number + timeoutMs: number, + niceProgram?: string ): Promise { const result = await runProcess({ - program: appImagePath, - args: ['--appimage-extract'], + program: niceProgram ?? appImagePath, + args: niceProgram ? ['-n', '19', appImagePath, '--appimage-extract'] : ['--appimage-extract'], cwd, timeoutMs, maxOutputBytes: 1024 * 1024, diff --git a/src/main/daemon/daemon-out-of-process-launcher-identity.test.ts b/src/main/daemon/daemon-out-of-process-launcher-identity.test.ts index cb86db09ce2..4b1529f2790 100644 --- a/src/main/daemon/daemon-out-of-process-launcher-identity.test.ts +++ b/src/main/daemon/daemon-out-of-process-launcher-identity.test.ts @@ -2,11 +2,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createOutOfProcessLauncher } from './daemon-out-of-process-launcher' import type * as AppImageLaunch from './daemon-appimage-launch' -const { prepare, resolveLaunch, resolveGeneration, launchChild } = vi.hoisted(() => ({ +const { prepare, resolveLaunch, resolveGeneration, launchChild, waitForCopy } = vi.hoisted(() => ({ prepare: vi.fn(), resolveLaunch: vi.fn(), resolveGeneration: vi.fn((): unknown => null), - launchChild: vi.fn() + launchChild: vi.fn(), + waitForCopy: vi.fn(async () => false) +})) +vi.mock('../cli/appimage-copy-pre-extraction', () => ({ + waitForAppImageCopyPreExtraction: waitForCopy })) vi.mock('./daemon-launched-child', async (importOriginal) => ({ ...(await importOriginal>()), @@ -53,6 +57,7 @@ const GENERATION = { afterEach(() => { vi.clearAllMocks() resolveGeneration.mockReturnValue(null) + waitForCopy.mockResolvedValue(false) }) async function expectedIdentity(): Promise { @@ -114,6 +119,23 @@ describe('daemon launch host on an AppImage', () => { } }) + it('runs on the copy this launch is still unpacking when it lands within the wait', async () => { + resolveLaunch.mockReturnValue(null) + resolveGeneration + .mockReturnValueOnce({ fallback: 'no-extracted-copy' }) + .mockReturnValue({ launch: GENERATION }) + waitForCopy.mockResolvedValue(true) + expect(await launchedWith()).toMatchObject({ extractedGeneration: GENERATION }) + expect(waitForCopy).toHaveBeenCalledTimes(1) + }) + + it('waits for no copy when the existing daemon is kept', async () => { + resolveLaunch.mockReturnValue(null) + resolveGeneration.mockReturnValue({ fallback: 'no-extracted-copy' }) + await expectedIdentity() + expect(waitForCopy).not.toHaveBeenCalled() + }) + it('says nothing when the existing daemon is kept', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { diff --git a/src/main/daemon/daemon-out-of-process-launcher.ts b/src/main/daemon/daemon-out-of-process-launcher.ts index 24b84be1e3d..750674bf6d2 100644 --- a/src/main/daemon/daemon-out-of-process-launcher.ts +++ b/src/main/daemon/daemon-out-of-process-launcher.ts @@ -19,6 +19,7 @@ import { getDaemonEntryPath, probeDaemonSocket as probeSocket } from './daemon-l import { materializeRelocatedDaemonHost, pruneDaemonHostStaging } from './daemon-host-relocation' import { appImageDaemonIdentityPath, resolveAppImageDaemonLaunch } from './daemon-appimage-launch' import { resolveAppImageGenerationDaemonLaunch } from './daemon-appimage-generation-launch' +import { waitForAppImageCopyPreExtraction } from '../cli/appimage-copy-pre-extraction' import { launchMacDaemonFromStableBundle } from './macos-daemon-launchd' import { DAEMON_RECOVERY_BUDGET_MS, daemonRecoveryProbeTimeoutMs } from './daemon-recovery-budget' import { cleanupDaemonForProtocol } from './daemon-protocol-cleanup' @@ -141,10 +142,9 @@ export function createOutOfProcessLauncher( appPath: environment.getAppPath(), appVersion: environment.getVersion() }) - const generation = resolveAppImageGenerationDaemonLaunch({ - entryPath, - appVersion: environment.getVersion() - }) + const resolveGeneration = (): ReturnType => + resolveAppImageGenerationDaemonLaunch({ entryPath, appVersion: environment.getVersion() }) + let generation = resolveGeneration() const extractedGeneration = generation && 'launch' in generation ? generation.launch : null const preservedHandle = await prepareDaemonReplacement({ runtimeDir, @@ -164,6 +164,17 @@ export function createOutOfProcessLauncher( return preservedHandle } + // Why only once launching: the first start of a release unpacks its copy in the background, + // and a short wait lets this new daemon run from it instead of a mount it would outlive. + if ( + generation && + 'fallback' in generation && + generation.fallback === 'no-extracted-copy' && + (await waitForAppImageCopyPreExtraction()) + ) { + generation = resolveGeneration() + } + const launchGeneration = generation && 'launch' in generation ? generation.launch : null const userDataPath = environment.getPath('userData') const macHandle = await launchMacDaemonFromStableBundle( { @@ -198,7 +209,7 @@ export function createOutOfProcessLauncher( forkEntryPath, relocatedExecPath: relocatedHost?.execPath, appImage: relocatedHost ? undefined : (appImage ?? undefined), - extractedGeneration: extractedGeneration ?? undefined, + extractedGeneration: launchGeneration ?? undefined, userDataPath, socketPath, tokenPath, diff --git a/src/main/linux-package-update-recovery.ts b/src/main/linux-package-update-recovery.ts index e0bf530bb65..4722a2e13ae 100644 --- a/src/main/linux-package-update-recovery.ts +++ b/src/main/linux-package-update-recovery.ts @@ -179,7 +179,7 @@ function getUpdaterCacheRoot(): string { * same-uid process can create its own `pending` directory, and per the design's trust model such a * process can already overwrite the real cached package anyway. */ -async function isContainedInCache(filePath: string): Promise { +export async function isInUpdaterPendingCache(filePath: string): Promise { const cacheRoot = path.resolve(getUpdaterCacheRoot()) if (!isInsideDirectory(cacheRoot, path.resolve(filePath))) { return false @@ -224,7 +224,7 @@ async function validateArtifact(artifact: LinuxPackageArtifact): Promise ({ + preExtractUpdateMock: vi.fn(async () => false), appMock: { isPackaged: true, getVersion: vi.fn(() => '1.0.51'), @@ -34,6 +36,9 @@ vi.mock('./linux-update-package-type', () => ({ isExternallyManagedLinuxInstall: isExternallyManagedLinuxInstallMock })) +vi.mock('./cli/appimage-copy-pre-extraction', () => ({ + preExtractDownloadedAppImageUpdate: preExtractUpdateMock +})) vi.mock('./updater-changelog', () => ({ fetchChangelog: vi.fn().mockResolvedValue(null) })) vi.mock('./updater-lifecycle-diagnostics', () => ({ recordUpdaterLifecycle: vi.fn() })) @@ -149,6 +154,18 @@ describe('registerAutoUpdaterHandlers linux package artifact tracking', () => { } } + it('starts unpacking a downloaded AppImage update without holding up the ready status', async () => { + getLinuxPackageTypeMock.mockReturnValue('non-root') + getLinuxRootPackageTypeMock.mockReturnValue(null) + preExtractUpdateMock.mockReturnValue(new Promise(() => {})) + const appImagePath = '/home/tester/.cache/orca-updater/pending/Orca-1.0.61.AppImage' + const { context, emit } = await register() + emit('update-downloaded', downloadedEvent({ downloadedFile: appImagePath })) + + expect(preExtractUpdateMock).toHaveBeenCalledWith(appImagePath, expect.any(Function)) + expect(context.sendStatus).toHaveBeenCalledWith(expect.objectContaining({ version: '1.0.61' })) + }) + it('hands the whole downloaded event to artifact capture', async () => { const { emit, getArtifact } = await register() diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 2553cacb594..742e9e4e1d5 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -15,6 +15,7 @@ import { } from './linux-package-downloaded-status' import { isExternallyManagedLinuxInstall } from './linux-update-package-type' import * as linuxPackageRecovery from './linux-package-update-recovery' +import { preExtractDownloadedAppImageUpdate } from './cli/appimage-copy-pre-extraction' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 @@ -249,6 +250,10 @@ export function registerAutoUpdaterHandlers({ } const macInstallerReady = process.platform === 'darwin' ? isMacInstallerReady() : true recordUpdaterLifecycle('update_downloaded', { version: info.version, macInstallerReady }) + // Why: the first daemon after this update should run from its unpacked copy, not the mount. + void preExtractDownloadedAppImageUpdate(info.downloadedFile, (filePath) => + linuxPackageRecovery.isInUpdaterPendingCache(filePath) + ) const linuxPackageStatus = resolveLinuxPackageDownloadedStatus(info) if (linuxPackageStatus) { sendStatus(linuxPackageStatus)