mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 03:57:35 +00:00
fix(serve): give the first AppImage serve one unpacking deadline and progress (#28015)
Direct AppImage serve now unpacks its stable copy once, under one overall deadline that also bounds the registration lock wait and both extraction attempts, prints progress to stderr, and refuses fast when the private cache is not writable, not executable, or too full. Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -104,6 +104,12 @@ if [[ "$entrypoint_kind" == appimage ]]; then
|
||||
echo "FAIL: installed CLI payload ${installed_target:-missing} is in the legacy namespace or missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
# A fresh cache means this serve unpacked the AppImage first, with progress on stderr.
|
||||
if ! grep -q '\[serve\] AppImage copy ready after' "$stderr_log"; then
|
||||
cat "$stderr_log" >&2
|
||||
echo "FAIL: first AppImage serve did not report unpacking progress" >&2
|
||||
exit 1
|
||||
fi
|
||||
registered_cli_verified=true
|
||||
fi
|
||||
|
||||
|
||||
@@ -31,10 +31,18 @@ export type AppImageExtractedRoot = {
|
||||
stableLauncherPath: string
|
||||
}
|
||||
|
||||
export type AppImageExtractRunner = (
|
||||
appImagePath: string,
|
||||
cwd: string,
|
||||
timeoutMs: number
|
||||
) => Promise<void>
|
||||
|
||||
export type AppImageExtractionOptions = {
|
||||
appImagePath: string
|
||||
cacheRootPath?: string
|
||||
runExtract?: (appImagePath: string, cwd: string) => Promise<void>
|
||||
runExtract?: AppImageExtractRunner
|
||||
/** Absolute time (ms) by which every extraction attempt must have finished. */
|
||||
deadlineAt?: number
|
||||
}
|
||||
|
||||
export function getAppImageCacheRootPath(homePath = homedir()): string {
|
||||
@@ -139,7 +147,7 @@ export async function ensureAppImageExtractedRoot(
|
||||
): Promise<AppImageExtractedRoot | null> {
|
||||
for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) {
|
||||
const root = resolveAppImageExtractedRoot(options)
|
||||
if (!root) {
|
||||
if (!root || remainingExtractionMs(options) <= 0) {
|
||||
return null
|
||||
}
|
||||
const complete =
|
||||
@@ -178,7 +186,11 @@ async function extractAppImageGeneration(
|
||||
const stagingPath = await mkdtemp(join(namespacePath, EXTRACTION_STAGING_PREFIX))
|
||||
const stopTracking = trackAppImageExtraction(stagingPath)
|
||||
try {
|
||||
await (options.runExtract ?? runAppImageExtract)(options.appImagePath, stagingPath)
|
||||
const timeoutMs = remainingExtractionMs(options)
|
||||
if (timeoutMs <= 0) {
|
||||
return false
|
||||
}
|
||||
await (options.runExtract ?? runAppImageExtract)(options.appImagePath, stagingPath, timeoutMs)
|
||||
const extractedPath = join(stagingPath, EXTRACT_OUTPUT_DIR)
|
||||
if (!hasPayloadLauncher(extractedPath) || !isCurrentGeneration(options, root)) {
|
||||
return false
|
||||
@@ -195,6 +207,12 @@ async function extractAppImageGeneration(
|
||||
}
|
||||
}
|
||||
|
||||
function remainingExtractionMs(options: AppImageExtractionOptions): number {
|
||||
return options.deadlineAt === undefined
|
||||
? APPIMAGE_EXTRACTION_TIMEOUT_MS
|
||||
: Math.min(APPIMAGE_EXTRACTION_TIMEOUT_MS, options.deadlineAt - Date.now())
|
||||
}
|
||||
|
||||
function digest(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex').slice(0, 24)
|
||||
}
|
||||
@@ -273,12 +291,16 @@ async function renameRoot(sourcePath: string, destinationPath: string): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppImageExtract(appImagePath: string, cwd: string): Promise<void> {
|
||||
async function runAppImageExtract(
|
||||
appImagePath: string,
|
||||
cwd: string,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
const result = await runProcess({
|
||||
program: appImagePath,
|
||||
args: ['--appimage-extract'],
|
||||
cwd,
|
||||
timeoutMs: APPIMAGE_EXTRACTION_TIMEOUT_MS,
|
||||
timeoutMs,
|
||||
maxOutputBytes: 1024 * 1024,
|
||||
terminationBarrier: true
|
||||
})
|
||||
|
||||
@@ -21,21 +21,29 @@ const LOCK_RETRIES = {
|
||||
|
||||
export async function withAppImageRegistrationLock<T>(
|
||||
cacheRootPath: string,
|
||||
operation: () => Promise<T>
|
||||
operation: () => Promise<T>,
|
||||
options: { deadlineAt?: number } = {}
|
||||
): Promise<T> {
|
||||
await mkdir(cacheRootPath, { recursive: true, mode: 0o700 })
|
||||
const waitMs = Math.min(
|
||||
LOCK_ACQUIRE_DEADLINE_MS,
|
||||
options.deadlineAt === undefined ? Infinity : options.deadlineAt - Date.now()
|
||||
)
|
||||
let release: () => Promise<void>
|
||||
try {
|
||||
if (waitMs <= 0) {
|
||||
throw new Error('deadline already passed')
|
||||
}
|
||||
release = await lock(join(cacheRootPath, LOCK_TARGET_NAME), {
|
||||
realpath: false,
|
||||
retries: LOCK_RETRIES,
|
||||
retries: { ...LOCK_RETRIES, maxRetryTime: waitMs },
|
||||
stale: LOCK_STALE_MS,
|
||||
update: APPIMAGE_EXTRACTION_TIMEOUT_MS / 10
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Timed out waiting for another Orca process to finish CLI registration ` +
|
||||
`(waited ${Math.round(LOCK_ACQUIRE_DEADLINE_MS / 1000)}s). ` +
|
||||
`(waited ${Math.round(Math.max(waitMs, 0) / 1000)}s). ` +
|
||||
`If no other Orca is running, remove ${join(cacheRootPath, LOCK_TARGET_NAME)}.lock and retry.`,
|
||||
{ cause: error }
|
||||
)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveAppImageExtractedRoot } from './appimage-extracted-root'
|
||||
import { withAppImageRegistrationLock } from './appimage-registration-lock'
|
||||
import { prepareAppImageCopyForServe, probeAppImageCache } from './appimage-serve-preparation'
|
||||
|
||||
const created: string[] = []
|
||||
|
||||
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-serve-'))
|
||||
created.push(root)
|
||||
const appImagePath = join(root, 'Orca.AppImage')
|
||||
await writeFile(appImagePath, '#!/usr/bin/env bash\n', { 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('prepareAppImageCopyForServe', () => {
|
||||
it('returns at once, silently, when the copy already exists', async () => {
|
||||
const { appImagePath, cacheRootPath } = await makeFixture()
|
||||
const root = resolveAppImageExtractedRoot({ appImagePath, cacheRootPath })!
|
||||
await mkdir(join(root.rootPath, 'resources', 'bin'), { recursive: true })
|
||||
await writeFile(join(root.rootPath, 'resources', 'bin', 'orca-ide'), '', { mode: 0o755 })
|
||||
const lines: string[] = []
|
||||
|
||||
const result = await prepareAppImageCopyForServe({
|
||||
appImagePath,
|
||||
cacheRootPath,
|
||||
log: (line) => lines.push(line),
|
||||
runExtract: async () => {
|
||||
throw new Error('must not extract')
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe('ready')
|
||||
expect(lines).toEqual([])
|
||||
})
|
||||
|
||||
it('reports progress while unpacking and when it is done', async () => {
|
||||
const { appImagePath, cacheRootPath } = await makeFixture()
|
||||
const lines: string[] = []
|
||||
|
||||
const result = await prepareAppImageCopyForServe({
|
||||
appImagePath,
|
||||
cacheRootPath,
|
||||
probeCache: acceptCache,
|
||||
progressIntervalMs: 5,
|
||||
log: (line) => lines.push(line),
|
||||
runExtract: async (_path, cwd) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
await writePayload(cwd)
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe('ready')
|
||||
expect(lines[0]).toContain('unpacking a stable copy')
|
||||
expect(lines.some((line) => line.includes('Still unpacking'))).toBe(true)
|
||||
expect(lines.at(-1)).toContain('AppImage copy ready')
|
||||
})
|
||||
|
||||
it('bounds every extraction attempt by one overall deadline', async () => {
|
||||
const { appImagePath, cacheRootPath } = await makeFixture()
|
||||
const timeouts: number[] = []
|
||||
const startedAt = Date.now()
|
||||
|
||||
const result = await prepareAppImageCopyForServe({
|
||||
appImagePath,
|
||||
cacheRootPath,
|
||||
budgetMs: 200,
|
||||
probeCache: acceptCache,
|
||||
log: () => {},
|
||||
runExtract: async (_path, _cwd, timeoutMs) => {
|
||||
timeouts.push(timeoutMs)
|
||||
await new Promise((resolve) => setTimeout(resolve, timeoutMs + 5))
|
||||
throw new Error('AppImage extraction timed out.')
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ status: 'unavailable' })
|
||||
expect(timeouts.length).toBeGreaterThanOrEqual(1)
|
||||
expect(timeouts.every((timeout) => timeout <= 200)).toBe(true)
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000)
|
||||
})
|
||||
|
||||
it('refuses before extracting when the cache cannot hold a runnable copy', async () => {
|
||||
const { appImagePath, cacheRootPath } = await makeFixture()
|
||||
let extracted = false
|
||||
|
||||
const result = await prepareAppImageCopyForServe({
|
||||
appImagePath,
|
||||
cacheRootPath,
|
||||
probeCache: async () => 'cache is mounted noexec',
|
||||
log: () => {},
|
||||
runExtract: async () => {
|
||||
extracted = true
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ status: 'unavailable', reason: 'cache is mounted noexec' })
|
||||
expect(extracted).toBe(false)
|
||||
})
|
||||
|
||||
it('gives up waiting for the lock at the deadline', async () => {
|
||||
const { appImagePath, cacheRootPath } = await makeFixture()
|
||||
let release!: () => void
|
||||
const held = withAppImageRegistrationLock(
|
||||
cacheRootPath,
|
||||
() => new Promise<void>((resolve) => (release = resolve))
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const result = await prepareAppImageCopyForServe({
|
||||
appImagePath,
|
||||
cacheRootPath,
|
||||
budgetMs: 300,
|
||||
probeCache: acceptCache,
|
||||
log: () => {},
|
||||
runExtract: async (_path, cwd) => writePayload(cwd)
|
||||
})
|
||||
release()
|
||||
await held
|
||||
|
||||
expect(result).toMatchObject({ status: 'unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
// POSIX permissions and shebang execution are what the probe measures.
|
||||
describe.skipIf(process.platform === 'win32')('probeAppImageCache', () => {
|
||||
it('accepts a private, writable cache that can run programs', async () => {
|
||||
const { appImagePath, cacheRootPath } = await makeFixture()
|
||||
await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('accepts an AppImage in a read-only directory', async () => {
|
||||
const { root, cacheRootPath } = await makeFixture()
|
||||
const readOnlyDir = join(root, 'opt')
|
||||
await mkdir(readOnlyDir)
|
||||
const appImagePath = join(readOnlyDir, 'Orca.AppImage')
|
||||
await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 })
|
||||
await chmod(readOnlyDir, 0o555)
|
||||
try {
|
||||
await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toBeNull()
|
||||
} finally {
|
||||
await chmod(readOnlyDir, 0o755)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a cache other users can write to', async () => {
|
||||
const { appImagePath, cacheRootPath } = await makeFixture()
|
||||
await mkdir(cacheRootPath, { recursive: true })
|
||||
await chmod(cacheRootPath, 0o777)
|
||||
await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toContain(
|
||||
'writable by other users'
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(process.getuid?.() === 0)('refuses a cache it cannot write to', async () => {
|
||||
const { appImagePath, cacheRootPath } = await makeFixture()
|
||||
await mkdir(cacheRootPath, { recursive: true })
|
||||
await chmod(cacheRootPath, 0o500)
|
||||
try {
|
||||
await expect(probeAppImageCache(cacheRootPath, appImagePath)).resolves.toContain(
|
||||
'not writable'
|
||||
)
|
||||
} finally {
|
||||
await chmod(cacheRootPath, 0o700)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* The first `AppImage serve` after an update extracts the AppImage before it registers the CLI.
|
||||
* That wait gets one deadline across the lock and every extraction attempt, progress on stderr,
|
||||
* and a fast refusal when the private cache cannot hold a runnable copy.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, stat, statfs, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import {
|
||||
ensureAppImageExtractedRoot,
|
||||
isAppImageExtractionComplete,
|
||||
resolveAppImageCacheRootPath,
|
||||
resolveAppImageExtractedRoot,
|
||||
type AppImageExtractionOptions
|
||||
} from './appimage-extracted-root'
|
||||
import { APPIMAGE_EXTRACTION_TIMEOUT_MS } from './appimage-extraction-pruning'
|
||||
import { withAppImageRegistrationLock } from './appimage-registration-lock'
|
||||
|
||||
export const APPIMAGE_SERVE_PREPARATION_BUDGET_MS = APPIMAGE_EXTRACTION_TIMEOUT_MS
|
||||
const PROGRESS_INTERVAL_MS = 15_000
|
||||
// An extracted payload is about 2.7x the AppImage file; leave headroom for the staging copy.
|
||||
const FREE_SPACE_FACTOR = 3
|
||||
|
||||
export type AppImageServePreparation =
|
||||
| { status: 'ready'; deadlineAt: number }
|
||||
| { status: 'unavailable'; reason: string; deadlineAt: number }
|
||||
|
||||
export type AppImageServePreparationOptions = Omit<AppImageExtractionOptions, 'deadlineAt'> & {
|
||||
budgetMs?: number
|
||||
progressIntervalMs?: number
|
||||
log?: (line: string) => void
|
||||
probeCache?: (cacheRootPath: string, appImagePath: string) => Promise<string | null>
|
||||
}
|
||||
|
||||
export async function prepareAppImageCopyForServe(
|
||||
options: AppImageServePreparationOptions
|
||||
): Promise<AppImageServePreparation> {
|
||||
const budgetMs = options.budgetMs ?? APPIMAGE_SERVE_PREPARATION_BUDGET_MS
|
||||
const deadlineAt = Date.now() + budgetMs
|
||||
const log = options.log ?? ((line: string) => console.log(line))
|
||||
const root = resolveAppImageExtractedRoot(options)
|
||||
if (!root) {
|
||||
return { status: 'unavailable', reason: 'the AppImage file is not readable', deadlineAt }
|
||||
}
|
||||
if (isAppImageExtractionComplete(root)) {
|
||||
return { status: 'ready', deadlineAt }
|
||||
}
|
||||
const cacheRootPath = resolveAppImageCacheRootPath(options)
|
||||
const startedAt = Date.now()
|
||||
log(
|
||||
`[serve] First start of this AppImage: unpacking a stable copy into ${cacheRootPath} ` +
|
||||
`(up to ${formatSeconds(budgetMs)})`
|
||||
)
|
||||
const refusal = await (options.probeCache ?? probeAppImageCache)(
|
||||
cacheRootPath,
|
||||
options.appImagePath
|
||||
)
|
||||
if (refusal) {
|
||||
log(`[serve] Cannot unpack the AppImage copy: ${refusal}`)
|
||||
return { status: 'unavailable', reason: refusal, deadlineAt }
|
||||
}
|
||||
const progress = setInterval(
|
||||
() =>
|
||||
log(
|
||||
`[serve] Still unpacking the AppImage copy (${formatSeconds(Date.now() - startedAt)} elapsed)`
|
||||
),
|
||||
options.progressIntervalMs ?? PROGRESS_INTERVAL_MS
|
||||
)
|
||||
progress.unref?.()
|
||||
try {
|
||||
const extracted = await withAppImageRegistrationLock(
|
||||
cacheRootPath,
|
||||
() => ensureAppImageExtractedRoot({ ...options, deadlineAt }),
|
||||
{ deadlineAt }
|
||||
)
|
||||
if (!extracted) {
|
||||
const reason =
|
||||
Date.now() >= deadlineAt
|
||||
? `it did not finish within ${formatSeconds(budgetMs)}`
|
||||
: 'the AppImage could not be unpacked'
|
||||
log(`[serve] Cannot unpack the AppImage copy: ${reason}`)
|
||||
return { status: 'unavailable', reason, deadlineAt }
|
||||
}
|
||||
log(`[serve] AppImage copy ready after ${formatSeconds(Date.now() - startedAt)}`)
|
||||
return { status: 'ready', deadlineAt }
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
log(`[serve] Cannot unpack the AppImage copy: ${reason}`)
|
||||
return { status: 'unavailable', reason, deadlineAt }
|
||||
} finally {
|
||||
clearInterval(progress)
|
||||
}
|
||||
}
|
||||
|
||||
/** Null when the cache is a private, writable directory that can run programs and has room. */
|
||||
export async function probeAppImageCache(
|
||||
cacheRootPath: string,
|
||||
appImagePath: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
await mkdir(cacheRootPath, { recursive: true, mode: 0o700 })
|
||||
const stats = await stat(cacheRootPath)
|
||||
const uid = process.getuid?.()
|
||||
if (uid !== undefined && stats.uid !== uid) {
|
||||
return `${cacheRootPath} belongs to another user`
|
||||
}
|
||||
if ((stats.mode & 0o022) !== 0) {
|
||||
return `${cacheRootPath} is writable by other users`
|
||||
}
|
||||
const needed = (await stat(appImagePath)).size * FREE_SPACE_FACTOR
|
||||
const fsStats = await statfs(cacheRootPath)
|
||||
if (fsStats.bavail * fsStats.bsize < needed) {
|
||||
return `${cacheRootPath} needs about ${Math.ceil(needed / 1024 / 1024)} MB free`
|
||||
}
|
||||
} catch (error) {
|
||||
return `${cacheRootPath} is not usable (${describeError(error)})`
|
||||
}
|
||||
const probePath = join(cacheRootPath, `.exec-probe-${process.pid}-${randomUUID()}`)
|
||||
try {
|
||||
try {
|
||||
await writeFile(probePath, '#!/bin/sh\nexit 0\n', { mode: 0o700 })
|
||||
} catch (error) {
|
||||
return `${cacheRootPath} is not writable (${describeError(error)})`
|
||||
}
|
||||
try {
|
||||
const result = await runProcess({ program: probePath, args: [], timeoutMs: 5_000 })
|
||||
return result.code === 0 ? null : `${cacheRootPath} cannot run programs`
|
||||
} catch (error) {
|
||||
return `${cacheRootPath} cannot run programs (${describeError(error)}; is it mounted noexec?)`
|
||||
}
|
||||
} finally {
|
||||
await unlink(probePath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
if (error instanceof Error && 'code' in error && typeof error.code === 'string') {
|
||||
return error.code
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function formatSeconds(ms: number): string {
|
||||
return `${Math.max(0, Math.round(ms / 1000))}s`
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export abstract class CliInstallLocation {
|
||||
protected readonly hasUnverifiedAppImageRuntime: boolean
|
||||
protected readonly appImageCacheRootPath: string
|
||||
protected readonly appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise<void>
|
||||
protected readonly appImageDeadlineAt?: number
|
||||
|
||||
protected get commandName(): string {
|
||||
if (!this.isPackaged && !this.commandPathOverride) {
|
||||
@@ -117,6 +118,7 @@ export abstract class CliInstallLocation {
|
||||
this.appImageCacheRootPath =
|
||||
options.appImageCacheRootPath ?? getAppImageCacheRootPath(this.homePath)
|
||||
this.appImageExtractRunner = options.appImageExtractRunner
|
||||
this.appImageDeadlineAt = options.appImageDeadlineAt
|
||||
}
|
||||
|
||||
protected resolveInstallSpec(): InstallSpec | null {
|
||||
@@ -274,7 +276,8 @@ export abstract class CliInstallLocation {
|
||||
? {
|
||||
appImagePath: this.appImagePath,
|
||||
cacheRootPath: this.appImageCacheRootPath,
|
||||
runExtract: this.appImageExtractRunner
|
||||
runExtract: this.appImageExtractRunner,
|
||||
deadlineAt: this.appImageDeadlineAt
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export type CliInstallerOptions = {
|
||||
appImagePath?: string | null
|
||||
appImageCacheRootPath?: string
|
||||
appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise<void>
|
||||
/** Absolute time (ms) by which AppImage extraction and its lock wait must finish. */
|
||||
appImageDeadlineAt?: number
|
||||
}
|
||||
|
||||
export type InstallSpec = {
|
||||
|
||||
@@ -222,7 +222,9 @@ export class CliInstaller extends CliPathRegistration {
|
||||
|
||||
private runAppImageRegistrationOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
return this.isLinuxAppImage()
|
||||
? withAppImageRegistrationLock(this.appImageCacheRootPath, operation)
|
||||
? withAppImageRegistrationLock(this.appImageCacheRootPath, operation, {
|
||||
deadlineAt: this.appImageDeadlineAt
|
||||
})
|
||||
: operation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export type LinuxBareOrcaDispatcherOptions = {
|
||||
appImageCacheRootPath?: string
|
||||
/** Test seam — defaults to running the AppImage's own `--appimage-extract`. */
|
||||
appImageExtractRunner?: (appImagePath: string, cwd: string) => Promise<void>
|
||||
/** Absolute time (ms) by which AppImage extraction and its lock wait must finish. */
|
||||
appImageDeadlineAt?: number
|
||||
}
|
||||
|
||||
export type LinuxBareOrcaDispatcherState =
|
||||
@@ -100,7 +102,8 @@ async function resolveStableLauncherPath(
|
||||
const extractionOptions = {
|
||||
appImagePath,
|
||||
cacheRootPath: options.appImageCacheRootPath,
|
||||
runExtract: options.appImageExtractRunner
|
||||
runExtract: options.appImageExtractRunner,
|
||||
deadlineAt: options.appImageDeadlineAt
|
||||
}
|
||||
return withAppImageRegistrationLock(
|
||||
resolveAppImageCacheRootPath(extractionOptions),
|
||||
@@ -113,7 +116,8 @@ async function resolveStableLauncherPath(
|
||||
)
|
||||
}
|
||||
return extractedRoot?.stableLauncherPath ?? null
|
||||
}
|
||||
},
|
||||
{ deadlineAt: options.appImageDeadlineAt }
|
||||
)
|
||||
}
|
||||
const launcher = getBundledLauncherPath('linux', options.resourcesPath)
|
||||
|
||||
@@ -33,8 +33,7 @@ import {
|
||||
recordRuntimeRpcStartFailure,
|
||||
showRuntimeRpcStartupFailureDialog
|
||||
} from '../runtime/runtime-rpc-startup-failure'
|
||||
import { CliInstaller } from '../cli/cli-installer'
|
||||
import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher'
|
||||
import { registerCliForHeadlessServe } from './serve-cli-registration'
|
||||
import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion'
|
||||
import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration'
|
||||
import { startDesktopPushService } from './main-process-push-startup'
|
||||
@@ -175,42 +174,7 @@ async function launchServeMode(
|
||||
startDesktopPushService(runtimeRpc)
|
||||
settleDesktopActivation()
|
||||
installServeQuitHandling()
|
||||
// Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals).
|
||||
if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
try {
|
||||
// Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none.
|
||||
const cliStatus = await new CliInstaller({
|
||||
privilegedRunner: async () => {
|
||||
throw new Error('serve CLI auto-install must not request administrator privileges')
|
||||
}
|
||||
}).install()
|
||||
console.log(
|
||||
`[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[serve] orca CLI install skipped:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort.
|
||||
if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) {
|
||||
try {
|
||||
const dispatcher = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath: process.resourcesPath
|
||||
})
|
||||
console.log(
|
||||
`[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` +
|
||||
`${dispatcher.target ? ` -> ${dispatcher.target}` : ''}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[serve] bare orca dispatcher install skipped:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
await registerCliForHeadlessServe()
|
||||
// Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
|
||||
state.automations?.start()
|
||||
// Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { app } from 'electron'
|
||||
import { resolveAppImageRuntimeIdentity } from '../appimage-runtime-identity'
|
||||
import { prepareAppImageCopyForServe } from '../cli/appimage-serve-preparation'
|
||||
import { CliInstaller } from '../cli/cli-installer'
|
||||
import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher'
|
||||
|
||||
/** Headless serve has no renderer to run the normal cli:install flow, so it registers here. */
|
||||
export async function registerCliForHeadlessServe(): Promise<void> {
|
||||
// Windows-excluded: install() only mutates registry PATH, not child terminals.
|
||||
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
||||
return
|
||||
}
|
||||
const appImagePath =
|
||||
process.platform === 'linux' ? (resolveAppImageRuntimeIdentity()?.appImagePath ?? null) : null
|
||||
let appImageDeadlineAt: number | undefined
|
||||
if (appImagePath) {
|
||||
// Why first: CLI install and the bare dispatcher would otherwise each wait out their own
|
||||
// extraction timeout; this gives the whole AppImage wait one deadline and visible progress.
|
||||
const preparation = await prepareAppImageCopyForServe({ appImagePath })
|
||||
if (preparation.status === 'unavailable') {
|
||||
console.warn(`[serve] orca CLI install skipped: ${preparation.reason}`)
|
||||
return
|
||||
}
|
||||
appImageDeadlineAt = preparation.deadlineAt
|
||||
}
|
||||
try {
|
||||
// Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none.
|
||||
const cliStatus = await new CliInstaller({
|
||||
privilegedRunner: async () => {
|
||||
throw new Error('serve CLI auto-install must not request administrator privileges')
|
||||
},
|
||||
...(appImageDeadlineAt === undefined ? {} : { appImageDeadlineAt })
|
||||
}).install()
|
||||
console.log(
|
||||
`[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[serve] orca CLI install skipped:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
// Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort.
|
||||
if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) {
|
||||
try {
|
||||
const dispatcher = await installLinuxBareOrcaDispatcher({
|
||||
resourcesPath: process.resourcesPath,
|
||||
...(appImageDeadlineAt === undefined ? {} : { appImageDeadlineAt })
|
||||
})
|
||||
console.log(
|
||||
`[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` +
|
||||
`${dispatcher.target ? ` -> ${dispatcher.target}` : ''}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[serve] bare orca dispatcher install skipped:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user