fix(linux): unpack the AppImage copy in the background so the first daemon runs from it (#28311)

* fix(linux): unpack the AppImage copy in the background so the first daemon runs from it

A validated AppImage now extracts its generation in the background, at the
lowest CPU and I/O priority and under the registration lock, at a launch that
finds no copy for its file and when an update finishes downloading (into the
namespace and generation key the installed file will have). The copy is staged
without publishing the CLI launcher. A daemon about to be launched while this
launch is unpacking waits up to 8s for it, then runs from the copy instead of a
FUSE mount it would outlive. A full cache, noexec cache, untrusted download or
failure is logged and the launch keeps today's fallback.

* fix(linux): drop idle copies of earlier releases at launch

Staging never prunes, so a desktop that never registers the CLI would keep
every release's unpacked copy. At launch, once this release has its copy, idle
sibling generations in its namespace are pruned under the registration lock;
leases, residency and the installed CLI target still protect anything in use.

* fix(linux): leave serve's own first-start unpack and its progress in place

Serve already unpacks the copy itself with one deadline and progress on its
output (D3-D2), which the packaged shutdown oracle checks. Launch-time
pre-extraction now runs for the desktop only; serve still gets the
update-downloaded staging.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-13 06:23:25 -07:00
committed by GitHub
co-authored by m4air
parent b978bc4dc2
commit c90f4fb435
9 changed files with 541 additions and 16 deletions
@@ -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<void> {
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<string | null> => 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<void>((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'
)
})
})
@@ -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<string | null>
log?: (line: string) => void
}
let currentCopy: Promise<boolean> | 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<boolean> | 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<boolean> {
if (!currentCopy) {
return false
}
let timer: ReturnType<typeof setTimeout> | undefined
const timedOut = new Promise<false>((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<boolean>,
options: AppImageCopyPreExtractionOptions = {}
): Promise<boolean> {
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<boolean> {
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<void> {
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)
}
+37 -6
View File
@@ -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<AppImageExtractedRoot | null> {
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<void> {
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<void> {
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,
@@ -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<Record<string, unknown>>()),
@@ -53,6 +57,7 @@ const GENERATION = {
afterEach(() => {
vi.clearAllMocks()
resolveGeneration.mockReturnValue(null)
waitForCopy.mockResolvedValue(false)
})
async function expectedIdentity(): Promise<unknown> {
@@ -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 {
@@ -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<typeof resolveAppImageGenerationDaemonLaunch> =>
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,
+2 -2
View File
@@ -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<boolean> {
export async function isInUpdaterPendingCache(filePath: string): Promise<boolean> {
const cacheRoot = path.resolve(getUpdaterCacheRoot())
if (!isInsideDirectory(cacheRoot, path.resolve(filePath))) {
return false
@@ -224,7 +224,7 @@ async function validateArtifact(artifact: LinuxPackageArtifact): Promise<Validat
return { ok: false, reason: 'hash-mismatch' }
}
try {
if (!(await isContainedInCache(artifact.path))) {
if (!(await isInUpdaterPendingCache(artifact.path))) {
return { ok: false, reason: 'not-regular' }
}
const stats = await fsp.lstat(artifact.path)
@@ -34,6 +34,7 @@ import {
showRuntimeRpcStartupFailureDialog
} from '../runtime/runtime-rpc-startup-failure'
import { registerCliForHeadlessServe } from './serve-cli-registration'
import { startAppImageCopyPreExtraction } from '../cli/appimage-copy-pre-extraction'
import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion'
import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration'
import { startDesktopPushService } from './main-process-push-startup'
@@ -287,6 +288,11 @@ export async function initializeMainProcessRuntimeLaunch(
})
desktopWindow = desktopStartup.window
} else {
// Why before the daemon starts: its host choice waits briefly for a copy unpacking here.
// Serve unpacks for itself, with a deadline and progress on its own output.
if (!serveOptions) {
void startAppImageCopyPreExtraction()
}
await shellPathReady
bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))
}
+18 -1
View File
@@ -7,8 +7,10 @@ const {
nativeUpdaterMock,
getLinuxPackageTypeMock,
getLinuxRootPackageTypeMock,
isExternallyManagedLinuxInstallMock
isExternallyManagedLinuxInstallMock,
preExtractUpdateMock
} = vi.hoisted(() => ({
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()
+5
View File
@@ -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)