Relaunch macOS orca serve safely after updates (#9634)
This commit is contained in:
parent
b25c298a2b
commit
1fef1e1ddd
|
|
@ -171,7 +171,8 @@
|
|||
"headless orca serve",
|
||||
"single-instance desktop activation",
|
||||
"CLI open",
|
||||
"persistent terminal reattach"
|
||||
"persistent terminal reattach",
|
||||
"update install handoff"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
|
|
@ -191,18 +192,23 @@
|
|||
"daemon",
|
||||
"ssh"
|
||||
],
|
||||
"coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, and SSH identity transfer. A macOS Electron journey starts one headless owner in an isolated profile, creates and writes to a daemon PTY, activates the GUI through a second process, and verifies the original owner/runtime/daemon/PTY identities plus pre- and post-promotion I/O. Live packaged, Linux, and Windows journeys remain uncollected.",
|
||||
"coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, SSH identity transfer, and the macOS serve update handoff from staged installer through atomic bundle replacement and target-version readiness. A macOS Electron journey covers headless promotion and persistent PTY identity. A disposable locally signed Electron canary exercised real ShipIt and a temporary LaunchAgent with the compiled production supervisor; full packaged Orca and Linux/Windows serve updates remain uncollected.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8457"
|
||||
"https://github.com/stablyai/orca/issues/8457",
|
||||
"https://github.com/stablyai/orca/issues/9563"
|
||||
],
|
||||
"invariant": "A safely promotable headless serve process is the single app owner. Desktop activation opens a window in that same process only after the persistent PTY provider and runtime RPC are ready; every live persisted local or SSH terminal remains bound to the same PTY/session, and a committed Cmd+Q still exits the promoted app.",
|
||||
"oracle": "Unit tests coalesce early activation, fail closed on a fallback local provider, preserve the production single-instance path for serve, expose explicit desktop window state to local and remote clients, persist headless tab/leaf bindings, transfer local and SSH reattach metadata, and retain quit intent. The Electron journey asserts one main-process PID, runtime id, daemon PID, and PTY id across activation; confirms output written before promotion is visible afterward; confirms new terminal input still works; and requires the activating second process to exit.",
|
||||
"invariant": "A safely promotable headless serve process is the single app owner. Desktop activation preserves its daemon-backed sessions. On macOS, a CLI-supervised serve update keeps the node-mode parent alive across ShipIt's atomic bundle swap, restarts with the original serve arguments only after the target bundle is present, and clears handoff state only after that target version reports runtime readiness. Unsupported or failed handoffs leave the current serving owner intact or recover it once without an install retry loop.",
|
||||
"oracle": "Unit tests coalesce early activation, preserve daemon and SSH identity, and reproduce the update race with a staged target, old serving child, persistent CLI parent, atomic .app replacement, and replacement readiness message. They assert the parent does not exit for launchd to respawn the old app, the native updater does not launch an interactive GUI, the replacement version is verified before handoff completion, mismatches become durable failures without retries, and unsupported/preflight-failed installs do not invoke native quit or PTY cleanup. The Electron journey independently verifies headless promotion retains owner/runtime/daemon/PTY identity and terminal I/O.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/launch.test.ts src/main/serve-update-handoff.test.ts src/main/updater.headless-serve-install.test.ts src/main/updater.test.ts src/main/updater.mac-install.test.ts src/main/window/attach-main-window-services.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/updater.headless-serve-install.test.ts",
|
||||
"src/main/serve-update-handoff.test.ts",
|
||||
"src/cli/runtime/launch.test.ts",
|
||||
"src/main/startup/serve-desktop-activation.test.ts",
|
||||
"src/main/startup/serve-desktop-activation-wiring.test.ts",
|
||||
"src/main/startup/single-instance-lock.test.ts",
|
||||
|
|
@ -213,6 +219,35 @@
|
|||
"tests/e2e/headless-serve-desktop-activation.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/updater.headless-serve-install.test.ts",
|
||||
"assertions": [
|
||||
"a ready update in headless serve is deferred before native install, paired-client disconnect, or active-session cleanup",
|
||||
"a supervised serve persists handoff after checkpoints but before native quit and uses no native GUI relaunch",
|
||||
"unsupported serve refuses updater staging and install-on-quit while preserving availability checks",
|
||||
"a failed handoff preflight preserves the serving owner before native quit or PTY cleanup",
|
||||
"macOS installer-readiness timeout cannot quit a headless serving owner",
|
||||
"ordinary macOS app quit is not reinterpreted as an install request in headless serve mode",
|
||||
"repeated requests emit one deterministic status and lifecycle diagnostic while interactive installs remain unchanged"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/cli/runtime/launch.test.ts",
|
||||
"assertions": [
|
||||
"the CLI parent remains alive after the old serving child exits instead of letting launchd respawn it",
|
||||
"an atomic app-bundle replacement starts one target-version serve child with the original arguments",
|
||||
"handoff completes only after the replacement reports target-version runtime readiness",
|
||||
"a replacement version mismatch or readiness timeout is persisted and exits without an in-process retry loop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/serve-update-handoff.test.ts",
|
||||
"assertions": [
|
||||
"install intent and failure state are written atomically under canonical user data",
|
||||
"an injected handoff path outside canonical user data cannot authorize an update",
|
||||
"a target-version startup clears stale failure state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/startup/serve-desktop-activation.test.ts",
|
||||
"assertions": [
|
||||
|
|
@ -272,6 +307,15 @@
|
|||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/runtime/launch.test.ts src/main/serve-update-handoff.test.ts src/main/updater.headless-serve-install.test.ts src/main/updater.test.ts src/main/updater.mac-install.test.ts src/main/window/attach-main-window-services.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5,
|
||||
"summary": "Seven focused files passed with 133 tests. The lifecycle harness keeps the CLI parent alive across an atomic .app replacement, starts one target-version serve replacement, and requires its bounded readiness message. Unsupported and failed-preflight paths make zero native install and PTY-cleanup calls; supervised native install leaves the modeled daemon session intact and suppresses native GUI relaunch."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-13",
|
||||
"runner": "local",
|
||||
|
|
@ -301,11 +345,11 @@
|
|||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The first Electron red run proved that bypassing the dev single-instance lock created a second owner. After enforcing one owner, the next red run retained owner/runtime/daemon identity but exposed a new PTY because the headless tab/leaf binding was not persisted. The final implementation persists that binding before renderer hydration and both subsequent Electron runs kept the original PTY and transcript. Focused unit tests were also observed red before the activation gate, promotion metadata transfer, and headless spawn-persistence changes were added."
|
||||
"evidence": "The original updater regression was observed red with one native install call, one paired-client disconnect, one cleanup start, no replacement owner, and a stranded staged installer. The root-cause harness was then observed red because the Electron child received no handoff path and the CLI parent exited, allowing launchd to spawn the old version while ShipIt still required zero running target apps. A live canary then exposed MacUpdater ignoring quitAndInstall relaunch arguments and starting a second desktop owner; disabling its independent relaunch for supervised mode produced one stable LaunchAgent parent, one verified replacement, and a surviving session across the real ShipIt swap. The final deterministic harness keeps that parent, observes the atomic bundle swap, and verifies the new serving version before clearing state. Earlier activation evidence also fixed second-owner and replacement-PTY failures."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "Activation adds no polling in the app owner and performs one bounded pass over live PTY records plus persisted terminal bindings only when a headless owner opens its first window. Desktop-only bell, command, PR-link, and mode scanners are rebuilt only while a real renderer graph is ready, preserving the prior pure-headless output path. CLI open keeps its existing 250ms bounded startup poll."
|
||||
"required": true,
|
||||
"evidence": "Normal serve and desktop paths add only constant-time mode checks plus one IPC listener on the macOS CLI child. During an actual install handoff only, the CLI watches the stable app parent directory and performs a bounded 250ms version-file poll for at most 120 seconds; there are no subprocesses, network calls, provider scans, or startup waits. Activation performance is unchanged."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.",
|
||||
|
|
@ -314,6 +358,7 @@
|
|||
],
|
||||
"knownGaps": [
|
||||
"The Electron journey uses an isolated development bundle rather than the installed application so it cannot disturb a real user session.",
|
||||
"The live ShipIt/LaunchAgent canary used a disposable minimal Electron bundle plus the compiled production supervisor; a full packaged Orca update has not yet been run.",
|
||||
"Linux and Windows single-instance activation have unit coverage but no live Electron evidence yet.",
|
||||
"SSH identity transfer is deterministic unit coverage only; the live Electron journey currently exercises the local daemon provider."
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import { EventEmitter } from 'node:events'
|
||||
import { resolve } from 'node:path'
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
import {
|
||||
getServeUpdateHandoffPath,
|
||||
parseServeUpdateHandoffState
|
||||
} from '../../shared/serve-update-handoff'
|
||||
import {
|
||||
readServeUpdateHandoff,
|
||||
SERVE_REPLACEMENT_READY_TIMEOUT_MS
|
||||
} from './serve-update-supervisor'
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn()
|
||||
|
|
@ -17,6 +27,7 @@ class FakeChildProcess extends EventEmitter {
|
|||
stdout = new EventEmitter()
|
||||
kill = vi.fn()
|
||||
unref = vi.fn()
|
||||
pid = 4101
|
||||
}
|
||||
|
||||
const RECIPE_JSON = JSON.stringify({
|
||||
|
|
@ -71,6 +82,8 @@ function startRecipeJsonServer() {
|
|||
}
|
||||
|
||||
describe('serveOrcaApp', () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset()
|
||||
process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca'
|
||||
|
|
@ -80,8 +93,238 @@ describe('serveOrcaApp', () => {
|
|||
vi.restoreAllMocks()
|
||||
delete process.env.ORCA_APP_EXECUTABLE
|
||||
delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
return Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'keeps the serve supervisor alive until the installed target version can take ownership',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-serve-update-'))
|
||||
temporaryDirectories.push(root)
|
||||
const appPath = join(root, 'Orca.app')
|
||||
const executable = join(appPath, 'Contents', 'MacOS', 'Orca')
|
||||
const infoPlistPath = join(appPath, 'Contents', 'Info.plist')
|
||||
const userDataPath = join(root, 'user-data')
|
||||
await mkdir(join(appPath, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(userDataPath, { recursive: true })
|
||||
await writeFile(
|
||||
infoPlistPath,
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.51</string></dict></plist>'
|
||||
)
|
||||
process.env.ORCA_APP_EXECUTABLE = executable
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
|
||||
const oldOwner = new FakeChildProcess()
|
||||
const replacementOwner = new FakeChildProcess()
|
||||
replacementOwner.pid = 4102
|
||||
spawnMock.mockReturnValueOnce(oldOwner).mockReturnValueOnce(replacementOwner)
|
||||
let supervisorExited = false
|
||||
const supervisor = serveOrcaApp({ json: true }).then((code) => {
|
||||
supervisorExited = true
|
||||
return code
|
||||
})
|
||||
const childEnv = spawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv | undefined
|
||||
const handoffPath = childEnv?.ORCA_SERVE_UPDATE_HANDOFF_PATH
|
||||
expect(handoffPath).toBeTruthy()
|
||||
await writeFile(
|
||||
handoffPath!,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: oldOwner.pid
|
||||
})
|
||||
)
|
||||
|
||||
oldOwner.emit('exit', 0, null)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(supervisorExited).toBe(false)
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const updateAppPath = join(root, 'Update.app')
|
||||
await mkdir(join(updateAppPath, 'Contents'), { recursive: true })
|
||||
await writeFile(
|
||||
join(updateAppPath, 'Contents', 'Info.plist'),
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.61</string></dict></plist>'
|
||||
)
|
||||
await rename(appPath, join(root, 'Previous.app'))
|
||||
await rename(updateAppPath, appPath)
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
|
||||
replacementOwner.emit('message', {
|
||||
type: 'orca:serve-ready',
|
||||
version: '1.0.61',
|
||||
runtimeId: 'runtime-new'
|
||||
})
|
||||
|
||||
expect(supervisorExited).toBe(false)
|
||||
await vi.waitFor(async () => expect(await readServeUpdateHandoff(handoffPath!)).toBeNull())
|
||||
replacementOwner.emit('exit', 0, null)
|
||||
await expect(supervisor).resolves.toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'records a replacement version mismatch without starting a retry loop',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-serve-update-mismatch-'))
|
||||
temporaryDirectories.push(root)
|
||||
const appPath = join(root, 'Orca.app')
|
||||
const executable = join(appPath, 'Contents', 'MacOS', 'Orca')
|
||||
const userDataPath = join(root, 'user-data')
|
||||
await mkdir(join(appPath, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(userDataPath, { recursive: true })
|
||||
await writeFile(
|
||||
join(appPath, 'Contents', 'Info.plist'),
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.61</string></dict></plist>'
|
||||
)
|
||||
const handoffPath = getServeUpdateHandoffPath(userDataPath)
|
||||
await writeFile(
|
||||
handoffPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: 4101
|
||||
})
|
||||
)
|
||||
process.env.ORCA_APP_EXECUTABLE = executable
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
const replacementOwner = new FakeChildProcess()
|
||||
replacementOwner.pid = 4102
|
||||
spawnMock.mockReturnValue(replacementOwner)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const supervisor = serveOrcaApp({ json: true })
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
replacementOwner.emit('message', {
|
||||
type: 'orca:serve-ready',
|
||||
version: '1.0.51',
|
||||
runtimeId: 'runtime-old'
|
||||
})
|
||||
await vi.waitFor(() => expect(replacementOwner.kill).toHaveBeenCalledWith('SIGTERM'))
|
||||
replacementOwner.emit('exit', 0, null)
|
||||
|
||||
await expect(supervisor).resolves.toBe(1)
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
expect(parseServeUpdateHandoffState(JSON.parse(await readFile(handoffPath, 'utf8')))).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'failed',
|
||||
targetVersion: '1.0.61',
|
||||
reason: expect.stringContaining('reported version 1.0.51')
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'records replacement spawn failure before rejecting without a retry loop',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-serve-update-spawn-failure-'))
|
||||
temporaryDirectories.push(root)
|
||||
const appPath = join(root, 'Orca.app')
|
||||
const executable = join(appPath, 'Contents', 'MacOS', 'Orca')
|
||||
const userDataPath = join(root, 'user-data')
|
||||
await mkdir(join(appPath, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(userDataPath, { recursive: true })
|
||||
await writeFile(
|
||||
join(appPath, 'Contents', 'Info.plist'),
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.61</string></dict></plist>'
|
||||
)
|
||||
const handoffPath = getServeUpdateHandoffPath(userDataPath)
|
||||
await writeFile(
|
||||
handoffPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: 4101
|
||||
})
|
||||
)
|
||||
process.env.ORCA_APP_EXECUTABLE = executable
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
const replacementOwner = new FakeChildProcess()
|
||||
spawnMock.mockReturnValue(replacementOwner)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const supervisor = serveOrcaApp({ json: true })
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
replacementOwner.emit('error', new Error('spawn ENOENT'))
|
||||
|
||||
await expect(supervisor).rejects.toThrow('spawn ENOENT')
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
expect(parseServeUpdateHandoffState(JSON.parse(await readFile(handoffPath, 'utf8')))).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'failed',
|
||||
targetVersion: '1.0.61',
|
||||
reason: expect.stringContaining('spawn ENOENT')
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'fails a replacement that never reports runtime readiness without retrying it',
|
||||
async () => {
|
||||
vi.useFakeTimers()
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-serve-update-no-readiness-'))
|
||||
temporaryDirectories.push(root)
|
||||
const appPath = join(root, 'Orca.app')
|
||||
const executable = join(appPath, 'Contents', 'MacOS', 'Orca')
|
||||
const userDataPath = join(root, 'user-data')
|
||||
await mkdir(join(appPath, 'Contents', 'MacOS'), { recursive: true })
|
||||
await mkdir(userDataPath, { recursive: true })
|
||||
await writeFile(
|
||||
join(appPath, 'Contents', 'Info.plist'),
|
||||
'<plist><dict><key>CFBundleShortVersionString</key><string>1.0.61</string></dict></plist>'
|
||||
)
|
||||
const handoffPath = getServeUpdateHandoffPath(userDataPath)
|
||||
await writeFile(
|
||||
handoffPath,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: 4101
|
||||
})
|
||||
)
|
||||
process.env.ORCA_APP_EXECUTABLE = executable
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
const replacementOwner = new FakeChildProcess()
|
||||
spawnMock.mockReturnValue(replacementOwner)
|
||||
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
try {
|
||||
const supervisor = serveOrcaApp({ json: true })
|
||||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SERVE_REPLACEMENT_READY_TIMEOUT_MS)
|
||||
expect(replacementOwner.kill).toHaveBeenCalledWith('SIGTERM')
|
||||
replacementOwner.emit('exit', 0, null)
|
||||
|
||||
await expect(supervisor).resolves.toBe(1)
|
||||
expect(spawnMock).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
parseServeUpdateHandoffState(JSON.parse(await readFile(handoffPath, 'utf8')))
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'failed',
|
||||
reason: expect.stringContaining('did not report serving version')
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('pins the Electron child cwd to the app root instead of the caller cwd', async () => {
|
||||
const child = {
|
||||
kill: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { resolve } from 'node:path'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import {
|
||||
SERVE_UPDATE_HANDOFF_PATH_ENV,
|
||||
getServeUpdateHandoffPath
|
||||
} from '../../shared/serve-update-handoff'
|
||||
import {
|
||||
getEphemeralVmRecipeResultConnection,
|
||||
parseEphemeralVmRecipeResult
|
||||
} from '../../shared/ephemeral-vm-recipes'
|
||||
import { getDefaultUserDataPath } from './metadata'
|
||||
import { getMacAppBundlePath } from './mac-app-update-bundle'
|
||||
import {
|
||||
readServeUpdateHandoffSync,
|
||||
resumeInterruptedServeUpdate,
|
||||
superviseForegroundServe
|
||||
} from './serve-update-supervisor'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
|
||||
|
|
@ -101,48 +112,51 @@ export function serveOrcaApp(
|
|||
childArgs.push('--serve-recipe-json', '--serve-project-root', args.projectRoot)
|
||||
}
|
||||
|
||||
const child = spawnProcess(executable, childArgs, {
|
||||
const handoffPath =
|
||||
args.recipeJson !== true && getMacAppBundlePath(executable)
|
||||
? getServeUpdateHandoffPath(getDefaultUserDataPath())
|
||||
: null
|
||||
const childEnv = stripElectronRunAsNode(process.env)
|
||||
if (handoffPath) {
|
||||
childEnv[SERVE_UPDATE_HANDOFF_PATH_ENV] = handoffPath
|
||||
}
|
||||
const spawnOptions: SpawnOptions = {
|
||||
detached: args.recipeJson === true,
|
||||
cwd: resolveAppRoot(),
|
||||
stdio: args.recipeJson === true ? ['ignore', 'pipe', 'inherit'] : 'inherit',
|
||||
stdio:
|
||||
args.recipeJson === true
|
||||
? ['ignore', 'pipe', 'inherit']
|
||||
: handoffPath
|
||||
? ['inherit', 'inherit', 'inherit', 'ipc']
|
||||
: 'inherit',
|
||||
...getExecutableSpawnOptions(executable),
|
||||
env: stripElectronRunAsNode(process.env)
|
||||
})
|
||||
env: childEnv
|
||||
}
|
||||
const interruptedHandoff = handoffPath ? readServeUpdateHandoffSync(handoffPath) : null
|
||||
if (interruptedHandoff?.phase === 'install-requested') {
|
||||
// Why: the node-mode CLI is not an NSRunningApplication, so it can retain launchd ownership while ShipIt swaps the app.
|
||||
return resumeInterruptedServeUpdate({
|
||||
executable,
|
||||
childArgs,
|
||||
spawnOptions,
|
||||
spawnChild: spawnProcess,
|
||||
handoffPath: handoffPath!,
|
||||
handoff: interruptedHandoff
|
||||
})
|
||||
}
|
||||
const child = spawnProcess(executable, childArgs, spawnOptions)
|
||||
|
||||
if (args.recipeJson) {
|
||||
return waitForRecipeJson(child)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const forwardSignal = (signal: NodeJS.Signals): void => {
|
||||
child.kill(signal)
|
||||
forceKillTimer ??= setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
}, 5000)
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
process.off('SIGINT', forwardSignal)
|
||||
process.off('SIGTERM', forwardSignal)
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer)
|
||||
forceKillTimer = null
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', forwardSignal)
|
||||
process.on('SIGTERM', forwardSignal)
|
||||
child.once('error', (error) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
cleanup()
|
||||
if (typeof code === 'number') {
|
||||
resolve(code)
|
||||
return
|
||||
}
|
||||
reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`))
|
||||
})
|
||||
return superviseForegroundServe({
|
||||
executable,
|
||||
childArgs,
|
||||
spawnOptions,
|
||||
spawnChild: spawnProcess,
|
||||
child,
|
||||
handoffPath,
|
||||
expectedHandoff: null
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -271,13 +285,3 @@ export function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEn
|
|||
delete next.ELECTRON_RUN_AS_NODE
|
||||
return next
|
||||
}
|
||||
|
||||
function getMacAppBundlePath(execPath: string): string | null {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
const macOsDir = dirname(execPath)
|
||||
const contentsDir = dirname(macOsDir)
|
||||
const appBundlePath = dirname(contentsDir)
|
||||
return appBundlePath.endsWith('.app') ? appBundlePath : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { watch, type FSWatcher } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const MAC_BUNDLE_UPDATE_TIMEOUT_MS = 120_000
|
||||
|
||||
export function getMacAppBundlePath(executable: string): string | null {
|
||||
if (process.platform !== 'darwin') {
|
||||
return null
|
||||
}
|
||||
const macOsDir = dirname(executable)
|
||||
const contentsDir = dirname(macOsDir)
|
||||
const appBundlePath = dirname(contentsDir)
|
||||
return appBundlePath.endsWith('.app') ? appBundlePath : null
|
||||
}
|
||||
|
||||
export async function waitForMacBundleVersion(
|
||||
executable: string,
|
||||
targetVersion: string,
|
||||
timeoutMs = MAC_BUNDLE_UPDATE_TIMEOUT_MS
|
||||
): Promise<boolean> {
|
||||
const appBundlePath = getMacAppBundlePath(executable)
|
||||
if (!appBundlePath) {
|
||||
return false
|
||||
}
|
||||
const infoPlistPath = resolve(appBundlePath, 'Contents', 'Info.plist')
|
||||
if ((await readMacBundleVersion(infoPlistPath)) === targetVersion) {
|
||||
return true
|
||||
}
|
||||
|
||||
return new Promise((resolveWait) => {
|
||||
let settled = false
|
||||
let checking = false
|
||||
let watcher: FSWatcher | null = null
|
||||
let poll: ReturnType<typeof setInterval> | null = null
|
||||
const finish = (ready: boolean): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (poll) {
|
||||
clearInterval(poll)
|
||||
}
|
||||
watcher?.close()
|
||||
resolveWait(ready)
|
||||
}
|
||||
const check = (): void => {
|
||||
if (checking || settled) {
|
||||
return
|
||||
}
|
||||
checking = true
|
||||
void readMacBundleVersion(infoPlistPath)
|
||||
.then((version) => {
|
||||
if (version === targetVersion) {
|
||||
finish(true)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checking = false
|
||||
})
|
||||
}
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs)
|
||||
poll = setInterval(check, 250)
|
||||
try {
|
||||
// Why: ShipIt replaces the whole .app, so watch its stable parent rather than an inode inside the old bundle.
|
||||
watcher = watch(dirname(appBundlePath), check)
|
||||
watcher.on('error', () => {
|
||||
watcher?.close()
|
||||
watcher = null
|
||||
})
|
||||
} catch {
|
||||
watcher = null
|
||||
}
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
async function readMacBundleVersion(infoPlistPath: string): Promise<string | null> {
|
||||
try {
|
||||
const plist = await readFile(infoPlistPath, 'utf8')
|
||||
const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(plist)
|
||||
return match?.[1]?.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
import type { ChildProcess, SpawnOptions, spawn } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import {
|
||||
parseServeSupervisorMessage,
|
||||
parseServeUpdateHandoffState,
|
||||
type ServeUpdateHandoffState
|
||||
} from '../../shared/serve-update-handoff'
|
||||
import { RuntimeClientError } from './types'
|
||||
import { waitForMacBundleVersion } from './mac-app-update-bundle'
|
||||
|
||||
export const SERVE_REPLACEMENT_READY_TIMEOUT_MS = 60_000
|
||||
|
||||
type InstallRequestedHandoff = Extract<ServeUpdateHandoffState, { phase: 'install-requested' }>
|
||||
type ServeReadiness = 'not-expected' | 'pending' | 'verified' | 'failed'
|
||||
|
||||
type ServeSupervisorArgs = {
|
||||
executable: string
|
||||
childArgs: string[]
|
||||
spawnOptions: SpawnOptions
|
||||
spawnChild: typeof spawn
|
||||
handoffPath: string | null
|
||||
}
|
||||
|
||||
export async function resumeInterruptedServeUpdate(
|
||||
args: ServeSupervisorArgs & { handoffPath: string; handoff: InstallRequestedHandoff }
|
||||
): Promise<number> {
|
||||
const installed = await waitForMacBundleVersion(args.executable, args.handoff.targetVersion)
|
||||
if (!installed) {
|
||||
await recordServeUpdateHandoffFailure(
|
||||
args.handoffPath,
|
||||
args.handoff,
|
||||
`Timed out waiting for Orca ${args.handoff.targetVersion} to be installed.`
|
||||
)
|
||||
}
|
||||
const child = args.spawnChild(args.executable, args.childArgs, args.spawnOptions)
|
||||
return superviseForegroundServe({
|
||||
...args,
|
||||
child,
|
||||
expectedHandoff: installed ? args.handoff : null
|
||||
})
|
||||
}
|
||||
|
||||
export async function superviseForegroundServe(
|
||||
args: ServeSupervisorArgs & {
|
||||
child: ChildProcess
|
||||
expectedHandoff: InstallRequestedHandoff | null
|
||||
}
|
||||
): Promise<number> {
|
||||
let child = args.child
|
||||
let expectedHandoff = args.expectedHandoff
|
||||
|
||||
while (true) {
|
||||
const result = await waitForForegroundChild(
|
||||
child,
|
||||
args.handoffPath && expectedHandoff
|
||||
? { handoffPath: args.handoffPath, handoff: expectedHandoff }
|
||||
: null
|
||||
)
|
||||
|
||||
if (result.readiness === 'failed') {
|
||||
return 1
|
||||
}
|
||||
if (expectedHandoff && result.readiness !== 'verified') {
|
||||
if (args.handoffPath) {
|
||||
await recordServeUpdateHandoffFailure(
|
||||
args.handoffPath,
|
||||
expectedHandoff,
|
||||
`Replacement exited before serving version ${expectedHandoff.targetVersion}.`
|
||||
)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const handoff = args.handoffPath ? await readServeUpdateHandoff(args.handoffPath) : null
|
||||
if (
|
||||
handoff?.phase !== 'install-requested' ||
|
||||
(child.pid !== undefined && handoff.servingPid !== child.pid)
|
||||
) {
|
||||
if (typeof result.code === 'number') {
|
||||
return result.code
|
||||
}
|
||||
throw new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${result.signal}`)
|
||||
}
|
||||
|
||||
const installed = await waitForMacBundleVersion(args.executable, handoff.targetVersion)
|
||||
if (!installed) {
|
||||
await recordServeUpdateHandoffFailure(
|
||||
args.handoffPath!,
|
||||
handoff,
|
||||
`Timed out waiting for Orca ${handoff.targetVersion} to be installed.`
|
||||
)
|
||||
expectedHandoff = null
|
||||
} else {
|
||||
expectedHandoff = handoff
|
||||
}
|
||||
child = args.spawnChild(args.executable, args.childArgs, args.spawnOptions)
|
||||
}
|
||||
}
|
||||
|
||||
function waitForForegroundChild(
|
||||
child: ChildProcess,
|
||||
expected: { handoffPath: string; handoff: InstallRequestedHandoff } | null
|
||||
): Promise<{
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
readiness: ServeReadiness
|
||||
}> {
|
||||
return new Promise((resolveWait, reject) => {
|
||||
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let readyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let readiness: ServeReadiness = expected ? 'pending' : 'not-expected'
|
||||
let stateWrite = Promise.resolve()
|
||||
const terminateChild = (): void => {
|
||||
child.kill('SIGTERM')
|
||||
forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), 5000)
|
||||
}
|
||||
const recordReplacementFailure = (reason: string): boolean => {
|
||||
if (!expected || readiness !== 'pending') {
|
||||
return false
|
||||
}
|
||||
readiness = 'failed'
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
readyTimer = null
|
||||
}
|
||||
stateWrite = recordServeUpdateHandoffFailure(
|
||||
expected.handoffPath,
|
||||
expected.handoff,
|
||||
reason
|
||||
).catch((error) => {
|
||||
process.stderr.write(`[serve] could not record update handoff failure: ${String(error)}\n`)
|
||||
})
|
||||
return true
|
||||
}
|
||||
const rejectReplacement = (reason: string): void => {
|
||||
if (!recordReplacementFailure(reason)) {
|
||||
return
|
||||
}
|
||||
terminateChild()
|
||||
}
|
||||
const forwardSignal = (signal: NodeJS.Signals): void => {
|
||||
child.kill(signal)
|
||||
forceKillTimer ??= setTimeout(() => child.kill('SIGKILL'), 5000)
|
||||
}
|
||||
const handleMessage = (value: unknown): void => {
|
||||
const message = parseServeSupervisorMessage(value)
|
||||
if (!message || !expected || readiness !== 'pending') {
|
||||
return
|
||||
}
|
||||
if (message.version !== expected.handoff.targetVersion) {
|
||||
rejectReplacement(
|
||||
`Replacement reported version ${message.version}; expected ${expected.handoff.targetVersion}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
readiness = 'verified'
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
readyTimer = null
|
||||
}
|
||||
stateWrite = completeServeUpdateHandoff(
|
||||
expected.handoffPath,
|
||||
expected.handoff,
|
||||
message.runtimeId
|
||||
).catch((error) => {
|
||||
readiness = 'failed'
|
||||
process.stderr.write(`[serve] could not complete update handoff: ${String(error)}\n`)
|
||||
terminateChild()
|
||||
})
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
process.off('SIGINT', forwardSignal)
|
||||
process.off('SIGTERM', forwardSignal)
|
||||
if (typeof child.off === 'function') {
|
||||
child.off('message', handleMessage)
|
||||
}
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer)
|
||||
}
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer)
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', forwardSignal)
|
||||
process.on('SIGTERM', forwardSignal)
|
||||
if (typeof child.on === 'function') {
|
||||
child.on('message', handleMessage)
|
||||
}
|
||||
if (expected) {
|
||||
readyTimer = setTimeout(() => {
|
||||
rejectReplacement(
|
||||
`Replacement did not report serving version ${expected.handoff.targetVersion} within ${SERVE_REPLACEMENT_READY_TIMEOUT_MS}ms.`
|
||||
)
|
||||
}, SERVE_REPLACEMENT_READY_TIMEOUT_MS)
|
||||
}
|
||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null): void => {
|
||||
cleanup()
|
||||
void stateWrite.then(() => resolveWait({ code, signal, readiness }))
|
||||
}
|
||||
child.once('error', (error) => {
|
||||
recordReplacementFailure(`Could not start the replacement process: ${String(error)}`)
|
||||
cleanup()
|
||||
child.off('exit', handleExit)
|
||||
// Why: the LaunchAgent may restart this parent immediately, so durable failure must precede process rejection.
|
||||
void stateWrite.then(() => reject(error))
|
||||
})
|
||||
child.once('exit', handleExit)
|
||||
})
|
||||
}
|
||||
|
||||
export async function readServeUpdateHandoff(
|
||||
handoffPath: string
|
||||
): Promise<ServeUpdateHandoffState | null> {
|
||||
try {
|
||||
return parseServeUpdateHandoffState(JSON.parse(await readFile(handoffPath, 'utf8')))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readServeUpdateHandoffSync(handoffPath: string): ServeUpdateHandoffState | null {
|
||||
try {
|
||||
return parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8')))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearServeUpdateHandoff(handoffPath: string): Promise<void> {
|
||||
await unlink(handoffPath).catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function completeServeUpdateHandoff(
|
||||
handoffPath: string,
|
||||
state: InstallRequestedHandoff,
|
||||
runtimeId: string
|
||||
): Promise<void> {
|
||||
await writeServeUpdateHandoffState(handoffPath, {
|
||||
...state,
|
||||
phase: 'completed',
|
||||
runtimeId
|
||||
})
|
||||
await clearServeUpdateHandoff(handoffPath)
|
||||
}
|
||||
|
||||
export async function recordServeUpdateHandoffFailure(
|
||||
handoffPath: string,
|
||||
state: Extract<ServeUpdateHandoffState, { phase: 'install-requested' }>,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
const failedState: ServeUpdateHandoffState = { ...state, phase: 'failed', reason }
|
||||
await writeServeUpdateHandoffState(handoffPath, failedState)
|
||||
process.stderr.write(`[serve] update handoff failed: ${reason}\n`)
|
||||
}
|
||||
|
||||
async function writeServeUpdateHandoffState(
|
||||
handoffPath: string,
|
||||
state: ServeUpdateHandoffState
|
||||
): Promise<void> {
|
||||
const temporaryPath = `${handoffPath}.${process.pid}.tmp`
|
||||
await writeFile(temporaryPath, JSON.stringify(state), { mode: 0o600 })
|
||||
await rename(temporaryPath, handoffPath)
|
||||
}
|
||||
|
|
@ -52,9 +52,13 @@ import {
|
|||
registerAppMenu,
|
||||
rebuildAppMenu
|
||||
} from './menu/register-app-menu'
|
||||
import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater'
|
||||
import { checkForUpdatesFromMenu, isQuittingForUpdate, resolveUpdateInstallMode } from './updater'
|
||||
import type { TuiAgent, UpdateCheckOptions } from '../shared/types'
|
||||
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
|
||||
import {
|
||||
installServeSupervisorDisconnectQuit,
|
||||
notifyServeSupervisorReady
|
||||
} from './serve-update-handoff'
|
||||
import {
|
||||
configureElectronNetworkCompatibility,
|
||||
configureDevUserDataPath,
|
||||
|
|
@ -452,6 +456,7 @@ if (app.isPackaged && process.platform !== 'win32') {
|
|||
}
|
||||
configureDevUserDataPath(is.dev)
|
||||
configureOrcaUserDataPathEnv()
|
||||
installServeSupervisorDisconnectQuit(isServeMode)
|
||||
|
||||
// Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon.
|
||||
const TRAY_CREATE_FALLBACK_MS = 12_000
|
||||
|
|
@ -1145,7 +1150,8 @@ function openMainWindow(): BrowserWindow {
|
|||
// Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787).
|
||||
isRecoveryReloadInFlight,
|
||||
onBeforeUpdateQuit: () =>
|
||||
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
|
||||
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }),
|
||||
updateInstallMode: resolveUpdateInstallMode(isServeMode)
|
||||
}
|
||||
)
|
||||
rateLimits.attach(window)
|
||||
|
|
@ -1521,6 +1527,7 @@ async function printServeReady(options: ServeOptions): Promise<void> {
|
|||
}
|
||||
}
|
||||
const endpoint = runtimeRpc.getWebSocketEndpoint()
|
||||
notifyServeSupervisorReady(runtime.getRuntimeId())
|
||||
const pairing = options.noPairing
|
||||
? ({ available: false } as const)
|
||||
: runtimeRpc.createPairingOffer({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
SERVE_UPDATE_HANDOFF_PATH_ENV,
|
||||
getServeUpdateHandoffPath,
|
||||
parseServeUpdateHandoffState
|
||||
} from '../shared/serve-update-handoff'
|
||||
|
||||
const { appMock, getCanonicalUserDataPathMock } = vi.hoisted(() => ({
|
||||
appMock: { getVersion: vi.fn(() => '1.0.51'), quit: vi.fn() },
|
||||
getCanonicalUserDataPathMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ app: appMock }))
|
||||
vi.mock('./persistence', () => ({ getCanonicalUserDataPath: getCanonicalUserDataPathMock }))
|
||||
|
||||
describe('serve update handoff', () => {
|
||||
let root: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
appMock.getVersion.mockReturnValue('1.0.51')
|
||||
appMock.quit.mockReset()
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-serve-handoff-'))
|
||||
getCanonicalUserDataPathMock.mockReturnValue(root)
|
||||
process.env[SERVE_UPDATE_HANDOFF_PATH_ENV] = getServeUpdateHandoffPath(root)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env[SERVE_UPDATE_HANDOFF_PATH_ENV]
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'persists install intent and a later deterministic failure for the serving pid',
|
||||
async () => {
|
||||
const {
|
||||
failServeUpdateHandoff,
|
||||
getServeUpdateHandoffFailure,
|
||||
hasServeUpdateSupervisor,
|
||||
requestServeUpdateHandoff
|
||||
} = await import('./serve-update-handoff')
|
||||
|
||||
expect(hasServeUpdateSupervisor()).toBe(true)
|
||||
expect(requestServeUpdateHandoff('1.0.61')).toBe(true)
|
||||
expect(readState(root)).toEqual({
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: process.pid
|
||||
})
|
||||
|
||||
failServeUpdateHandoff('native updater rejected the request')
|
||||
|
||||
expect(readState(root)).toEqual({
|
||||
schemaVersion: 1,
|
||||
phase: 'failed',
|
||||
fromVersion: '1.0.51',
|
||||
targetVersion: '1.0.61',
|
||||
servingPid: process.pid,
|
||||
reason: 'native updater rejected the request'
|
||||
})
|
||||
expect(getServeUpdateHandoffFailure()).toBe('native updater rejected the request')
|
||||
|
||||
appMock.getVersion.mockReturnValue('1.0.61')
|
||||
expect(getServeUpdateHandoffFailure()).toBeNull()
|
||||
expect(existsSync(getServeUpdateHandoffPath(root))).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects a handoff path outside the canonical user-data directory', async () => {
|
||||
process.env[SERVE_UPDATE_HANDOFF_PATH_ENV] = join(root, '..', 'untrusted.json')
|
||||
const { hasServeUpdateSupervisor, requestServeUpdateHandoff } =
|
||||
await import('./serve-update-handoff')
|
||||
|
||||
expect(hasServeUpdateSupervisor()).toBe(false)
|
||||
expect(requestServeUpdateHandoff('1.0.61')).toBe(false)
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'quits a supervised serve child when its CLI parent is lost',
|
||||
async () => {
|
||||
const parent = new EventEmitter()
|
||||
const { installServeSupervisorDisconnectQuit } = await import('./serve-update-handoff')
|
||||
|
||||
const removeListener = installServeSupervisorDisconnectQuit(true, parent)
|
||||
parent.emit('disconnect')
|
||||
|
||||
expect(appMock.quit).toHaveBeenCalledOnce()
|
||||
removeListener()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function readState(root: string) {
|
||||
return parseServeUpdateHandoffState(
|
||||
JSON.parse(readFileSync(getServeUpdateHandoffPath(root), 'utf8'))
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
SERVE_UPDATE_HANDOFF_PATH_ENV,
|
||||
getServeUpdateHandoffPath,
|
||||
parseServeUpdateHandoffState,
|
||||
type ServeSupervisorMessage,
|
||||
type ServeUpdateHandoffState
|
||||
} from '../shared/serve-update-handoff'
|
||||
import { getCanonicalUserDataPath } from './persistence'
|
||||
|
||||
function getConfiguredHandoffPath(): string | null {
|
||||
const configuredPath = process.env[SERVE_UPDATE_HANDOFF_PATH_ENV]
|
||||
if (!configuredPath) {
|
||||
return null
|
||||
}
|
||||
const expectedPath = getServeUpdateHandoffPath(getCanonicalUserDataPath())
|
||||
return resolve(configuredPath) === resolve(expectedPath) ? expectedPath : null
|
||||
}
|
||||
|
||||
export function hasServeUpdateSupervisor(): boolean {
|
||||
return process.platform === 'darwin' && getConfiguredHandoffPath() !== null
|
||||
}
|
||||
|
||||
export function requestServeUpdateHandoff(targetVersion: string): boolean {
|
||||
const handoffPath = getConfiguredHandoffPath()
|
||||
if (!handoffPath || !targetVersion) {
|
||||
return false
|
||||
}
|
||||
return writeHandoffState(handoffPath, {
|
||||
schemaVersion: 1,
|
||||
phase: 'install-requested',
|
||||
fromVersion: app.getVersion(),
|
||||
targetVersion,
|
||||
servingPid: process.pid
|
||||
})
|
||||
}
|
||||
|
||||
export function failServeUpdateHandoff(reason: string): void {
|
||||
const handoffPath = getConfiguredHandoffPath()
|
||||
if (!handoffPath) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const state = parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8')))
|
||||
if (state?.phase !== 'install-requested' || state.servingPid !== process.pid) {
|
||||
return
|
||||
}
|
||||
writeHandoffState(handoffPath, { ...state, phase: 'failed', reason })
|
||||
} catch {
|
||||
// The updater already reports the primary failure; a missing handoff file needs no second error.
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyServeSupervisorReady(runtimeId: string): void {
|
||||
if (!process.send || process.connected === false) {
|
||||
return
|
||||
}
|
||||
const message: ServeSupervisorMessage = {
|
||||
type: 'orca:serve-ready',
|
||||
version: app.getVersion(),
|
||||
runtimeId
|
||||
}
|
||||
try {
|
||||
process.send(message)
|
||||
} catch {
|
||||
// The disconnect listener owns parent-loss recovery; readiness reporting must not throw through startup.
|
||||
}
|
||||
}
|
||||
|
||||
export function installServeSupervisorDisconnectQuit(
|
||||
isServeMode: boolean,
|
||||
parent: {
|
||||
once(event: 'disconnect', listener: () => void): unknown
|
||||
off(event: 'disconnect', listener: () => void): unknown
|
||||
} = process
|
||||
): () => void {
|
||||
if (!isServeMode || !hasServeUpdateSupervisor()) {
|
||||
return () => undefined
|
||||
}
|
||||
const quit = (): void => app.quit()
|
||||
parent.once('disconnect', quit)
|
||||
return () => parent.off('disconnect', quit)
|
||||
}
|
||||
|
||||
export function getServeUpdateHandoffFailure(): string | null {
|
||||
const handoffPath = getConfiguredHandoffPath()
|
||||
if (!handoffPath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const state = parseServeUpdateHandoffState(JSON.parse(readFileSync(handoffPath, 'utf8')))
|
||||
if (state?.phase !== 'failed') {
|
||||
return null
|
||||
}
|
||||
if (state.targetVersion === app.getVersion()) {
|
||||
unlinkSync(handoffPath)
|
||||
return null
|
||||
}
|
||||
return state.reason
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeHandoffState(path: string, state: ServeUpdateHandoffState): boolean {
|
||||
const temporaryPath = `${path}.${process.pid}.tmp`
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(temporaryPath, JSON.stringify(state), { mode: 0o600 })
|
||||
renameSync(temporaryPath, path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -46,4 +46,8 @@ describe('serve desktop activation wiring', () => {
|
|||
expect(settleIndex).toBeGreaterThan(rpcIndex)
|
||||
expect(source).not.toContain('runtime.syncWindowGraph(0,')
|
||||
})
|
||||
|
||||
it('keeps the headless install policy after desktop promotion', () => {
|
||||
expect(source).toContain('updateInstallMode: resolveUpdateInstallMode(isServeMode)')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ type UpdaterHandlerContext = {
|
|||
markUpdateAvailableEventPending: (attemptId: number | null) => void
|
||||
markMissingManifestPrereleaseFallbackChecking: () => void
|
||||
performQuitAndInstall: () => void | Promise<void>
|
||||
shouldDeferMacQuitForInstall: () => boolean
|
||||
recordCompletedUpdateCheck: () => void
|
||||
sendCheckFailureStatus: (
|
||||
message: string,
|
||||
|
|
@ -77,6 +78,7 @@ export function registerAutoUpdaterHandlers({
|
|||
markUpdateAvailableEventPending,
|
||||
markMissingManifestPrereleaseFallbackChecking,
|
||||
performQuitAndInstall,
|
||||
shouldDeferMacQuitForInstall,
|
||||
recordCompletedUpdateCheck,
|
||||
sendCheckFailureStatus,
|
||||
sendErrorStatus,
|
||||
|
|
@ -104,6 +106,9 @@ export function registerAutoUpdaterHandlers({
|
|||
}
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
if (!shouldDeferMacQuitForInstall()) {
|
||||
return
|
||||
}
|
||||
if (consumeMacInstallGuardBypass()) {
|
||||
recordUpdaterLifecycle('macos_before_quit_guard_bypassed')
|
||||
return
|
||||
|
|
|
|||
|
|
@ -0,0 +1,497 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
appMock,
|
||||
autoUpdaterMock,
|
||||
nativeUpdaterMock,
|
||||
killAllPtyMock,
|
||||
recordUpdaterLifecycleMock,
|
||||
requestServeUpdateHandoffMock,
|
||||
failServeUpdateHandoffMock,
|
||||
resetHandlers
|
||||
} = vi.hoisted(() => {
|
||||
const appHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
const updaterHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
|
||||
|
||||
const emit = (
|
||||
handlers: Map<string, ((...args: unknown[]) => void)[]>,
|
||||
event: string,
|
||||
...args: unknown[]
|
||||
): void => {
|
||||
for (const handler of handlers.get(event) ?? []) {
|
||||
handler(...args)
|
||||
}
|
||||
}
|
||||
|
||||
const appMock = {
|
||||
isPackaged: true,
|
||||
getVersion: vi.fn(() => '1.0.51'),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
appHandlers.set(event, [...(appHandlers.get(event) ?? []), handler])
|
||||
return appMock
|
||||
}),
|
||||
emit: (event: string, ...args: unknown[]) => emit(appHandlers, event, ...args),
|
||||
quit: vi.fn()
|
||||
}
|
||||
|
||||
const autoUpdaterMock = {
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
autoRunAppAfterInstall: true,
|
||||
allowPrerelease: false,
|
||||
checkForUpdates: vi.fn().mockResolvedValue(null),
|
||||
downloadUpdate: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
setFeedURL: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
updaterHandlers.set(event, [...(updaterHandlers.get(event) ?? []), handler])
|
||||
return autoUpdaterMock
|
||||
}),
|
||||
emit: (event: string, ...args: unknown[]) => emit(updaterHandlers, event, ...args)
|
||||
}
|
||||
|
||||
return {
|
||||
appMock,
|
||||
autoUpdaterMock,
|
||||
nativeUpdaterMock: { on: vi.fn() },
|
||||
killAllPtyMock: vi.fn(),
|
||||
recordUpdaterLifecycleMock: vi.fn(),
|
||||
requestServeUpdateHandoffMock: vi.fn(() => true),
|
||||
failServeUpdateHandoffMock: vi.fn(),
|
||||
resetHandlers: () => {
|
||||
appHandlers.clear()
|
||||
updaterHandlers.clear()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: appMock,
|
||||
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
||||
autoUpdater: nativeUpdaterMock,
|
||||
powerMonitor: { on: vi.fn() },
|
||||
shell: { openExternal: vi.fn() },
|
||||
net: { fetch: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock }))
|
||||
vi.mock('./electron-updater-loader', () => ({ loadElectronAutoUpdater: () => autoUpdaterMock }))
|
||||
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
|
||||
vi.mock('./ipc/pty', () => ({ killAllPty: killAllPtyMock }))
|
||||
vi.mock('./updater-changelog', () => ({ fetchChangelog: vi.fn().mockResolvedValue(null) }))
|
||||
vi.mock('./updater-nudge', () => ({
|
||||
fetchNudge: vi.fn().mockResolvedValue(null),
|
||||
shouldApplyNudge: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
vi.mock('./updater-prerelease-feed', () => ({
|
||||
fetchNewerReleaseTagsWithReadiness: vi.fn().mockResolvedValue({
|
||||
tags: ['v1.0.61'],
|
||||
state: 'ready'
|
||||
}),
|
||||
getReleaseDownloadUrl: vi.fn()
|
||||
}))
|
||||
vi.mock('./update-install-exit-watchdog', () => ({
|
||||
armUpdateInstallExitWatchdog: vi.fn(),
|
||||
disarmUpdateInstallExitWatchdog: vi.fn()
|
||||
}))
|
||||
vi.mock('./updater-lifecycle-diagnostics', () => ({
|
||||
recordUpdaterLifecycle: recordUpdaterLifecycleMock
|
||||
}))
|
||||
vi.mock('./serve-update-handoff', () => ({
|
||||
failServeUpdateHandoff: failServeUpdateHandoffMock,
|
||||
getServeUpdateHandoffFailure: vi.fn(() => null),
|
||||
hasServeUpdateSupervisor: vi.fn(() => true),
|
||||
requestServeUpdateHandoff: requestServeUpdateHandoffMock
|
||||
}))
|
||||
|
||||
describe('headless serve update install handoff', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null)
|
||||
autoUpdaterMock.downloadUpdate.mockReset().mockResolvedValue([])
|
||||
autoUpdaterMock.quitAndInstall.mockReset()
|
||||
autoUpdaterMock.setFeedURL.mockReset()
|
||||
autoUpdaterMock.on.mockClear()
|
||||
autoUpdaterMock.autoInstallOnAppQuit = false
|
||||
autoUpdaterMock.autoRunAppAfterInstall = true
|
||||
nativeUpdaterMock.on.mockReset()
|
||||
appMock.on.mockClear()
|
||||
appMock.quit.mockReset()
|
||||
killAllPtyMock.mockReset()
|
||||
recordUpdaterLifecycleMock.mockReset()
|
||||
requestServeUpdateHandoffMock.mockReset().mockReturnValue(true)
|
||||
failServeUpdateHandoffMock.mockReset()
|
||||
resetHandlers()
|
||||
})
|
||||
|
||||
it('defers install before disconnecting the serving owner or starting session cleanup', async () => {
|
||||
const lifecycle: string[] = []
|
||||
const pendingInstaller = { version: '1.0.61', staged: true }
|
||||
const servingOwner = { version: '1.0.51', connectedClients: 2, verified: true }
|
||||
const replacementOwner: { version: string; verified: boolean } | null = null
|
||||
const send = vi.fn()
|
||||
const beginSessionCleanup = vi.fn(() => lifecycle.push('session-cleanup'))
|
||||
const disconnectPairedClients = vi.fn(() => {
|
||||
lifecycle.push('paired-clients-disconnected')
|
||||
servingOwner.connectedClients = 0
|
||||
servingOwner.verified = false
|
||||
})
|
||||
|
||||
appMock.on('will-quit', disconnectPairedClients)
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => {
|
||||
autoUpdaterMock.emit('update-available', { version: pendingInstaller.version })
|
||||
})
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
lifecycle.push('native-quit-and-install')
|
||||
appMock.emit('will-quit', { preventDefault: vi.fn() })
|
||||
})
|
||||
killAllPtyMock.mockImplementation(beginSessionCleanup)
|
||||
|
||||
const { checkForUpdatesFromMenu, quitAndInstall, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater(
|
||||
{ webContents: { send } } as never,
|
||||
{
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
} as never
|
||||
)
|
||||
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: pendingInstaller.version })
|
||||
const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1] as (() => void) | undefined
|
||||
nativeReadyHandler?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'downloaded', version: pendingInstaller.version })
|
||||
)
|
||||
|
||||
quitAndInstall()
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
const statuses = send.mock.calls
|
||||
.filter(([channel]) => channel === 'updater:status')
|
||||
.map(([, status]) => status)
|
||||
expect({
|
||||
nativeInstallCalls: autoUpdaterMock.quitAndInstall.mock.calls.length,
|
||||
pairedClientDisconnects: disconnectPairedClients.mock.calls.length,
|
||||
sessionCleanupStarts: beginSessionCleanup.mock.calls.length,
|
||||
servingOwner,
|
||||
replacementOwner,
|
||||
pendingInstaller,
|
||||
deferredStatusVisible: statuses.some(
|
||||
(status) =>
|
||||
status &&
|
||||
typeof status === 'object' &&
|
||||
'state' in status &&
|
||||
status.state === 'error' &&
|
||||
'message' in status &&
|
||||
typeof status.message === 'string' &&
|
||||
status.message.includes('orca serve')
|
||||
),
|
||||
deferralDiagnostics: recordUpdaterLifecycleMock.mock.calls.filter(
|
||||
([event]) => event === 'headless_serve_install_deferred'
|
||||
).length,
|
||||
lifecycle
|
||||
}).toEqual({
|
||||
nativeInstallCalls: 0,
|
||||
pairedClientDisconnects: 0,
|
||||
sessionCleanupStarts: 0,
|
||||
servingOwner: { version: '1.0.51', connectedClients: 2, verified: true },
|
||||
replacementOwner: null,
|
||||
pendingInstaller: { version: '1.0.61', staged: true },
|
||||
deferredStatusVisible: true,
|
||||
deferralDiagnostics: 1,
|
||||
lifecycle: []
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks staging and install-on-quit while still reporting an available update', async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, downloadUpdate, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'available', version: '1.0.61' })
|
||||
)
|
||||
|
||||
downloadUpdate()
|
||||
downloadUpdate()
|
||||
|
||||
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false)
|
||||
expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled()
|
||||
expect(
|
||||
recordUpdaterLifecycleMock.mock.calls.filter(
|
||||
([event, data]) =>
|
||||
event === 'headless_serve_install_deferred' &&
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'phase' in data &&
|
||||
data.phase === 'download'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
send.mock.calls.filter(
|
||||
([channel, status]) => channel === 'updater:status' && status?.state === 'error'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hands a supervised install to the serve parent before native quit and cleanup', async () => {
|
||||
const lifecycle: string[] = []
|
||||
const daemonSession = { alive: true }
|
||||
const send = vi.fn()
|
||||
const disconnectPairedClients = vi.fn(() => lifecycle.push('paired-clients-disconnected'))
|
||||
appMock.on('will-quit', disconnectPairedClients)
|
||||
requestServeUpdateHandoffMock.mockImplementation(() => {
|
||||
lifecycle.push('handoff-persisted')
|
||||
return true
|
||||
})
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
autoUpdaterMock.quitAndInstall.mockImplementation(() => {
|
||||
lifecycle.push('native-quit-and-install')
|
||||
appMock.emit('will-quit', { preventDefault: vi.fn() })
|
||||
})
|
||||
killAllPtyMock.mockImplementation(() => lifecycle.push('in-process-pty-cleanup'))
|
||||
|
||||
const { checkForUpdatesFromMenu, downloadUpdate, quitAndInstall, setupAutoUpdater } =
|
||||
await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'supervised-headless-serve',
|
||||
onBeforeQuit: () => {
|
||||
lifecycle.push('pre-quit-checkpoint')
|
||||
}
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
downloadUpdate()
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1] as (() => void) | undefined
|
||||
nativeReadyHandler?.()
|
||||
|
||||
quitAndInstall()
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
quitAndInstall()
|
||||
|
||||
expect(requestServeUpdateHandoffMock).toHaveBeenCalledWith('1.0.61')
|
||||
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(false)
|
||||
expect(autoUpdaterMock.autoRunAppAfterInstall).toBe(false)
|
||||
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledOnce()
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(true, false)
|
||||
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledOnce()
|
||||
expect(daemonSession).toEqual({ alive: true })
|
||||
expect(lifecycle).toEqual([
|
||||
'pre-quit-checkpoint',
|
||||
'handoff-persisted',
|
||||
'native-quit-and-install',
|
||||
'paired-clients-disconnected',
|
||||
'in-process-pty-cleanup'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the serving owner intact when the supervisor handoff cannot be persisted', async () => {
|
||||
const send = vi.fn()
|
||||
requestServeUpdateHandoffMock.mockReturnValue(false)
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, quitAndInstall, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'supervised-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1] as (() => void) | undefined
|
||||
nativeReadyHandler?.()
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(requestServeUpdateHandoffMock).toHaveBeenCalledOnce()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(killAllPtyMock).not.toHaveBeenCalled()
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({
|
||||
state: 'error',
|
||||
message: expect.stringContaining('supervised server restart')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'defers a pre-staged macOS update resumed from the native-ready continuation',
|
||||
async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
|
||||
const { deferMacQuitUntilInstallerReady } = await import('./updater-mac-install')
|
||||
expect(
|
||||
deferMacQuitUntilInstallerReady(
|
||||
{ state: 'downloading', percent: 100, version: '1.0.61' },
|
||||
true,
|
||||
() => '1.0.61',
|
||||
send
|
||||
)
|
||||
).toBe(true)
|
||||
const nativeReadyHandler = nativeUpdaterMock.on.mock.calls.find(
|
||||
([event]) => event === 'update-downloaded'
|
||||
)?.[1] as (() => void) | undefined
|
||||
nativeReadyHandler?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(killAllPtyMock).not.toHaveBeenCalled()
|
||||
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
|
||||
'headless_serve_install_deferred',
|
||||
{ phase: 'install', version: '1.0.61' },
|
||||
expect.objectContaining({ level: 'warn' })
|
||||
)
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'error', message: expect.stringContaining('orca serve') })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'does not reinterpret an ordinary headless app quit as an update install request',
|
||||
async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
|
||||
const preventDefault = vi.fn()
|
||||
appMock.emit('before-quit', { preventDefault })
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(appMock.quit).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform === 'darwin')(
|
||||
'defers before the macOS installer-readiness timeout can quit the serving owner',
|
||||
async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, quitAndInstall, setupAutoUpdater } =
|
||||
await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'unsupported-headless-serve'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
|
||||
|
||||
quitAndInstall()
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
expect(appMock.quit).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
|
||||
expect(killAllPtyMock).not.toHaveBeenCalled()
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'updater:status',
|
||||
expect.objectContaining({ state: 'error', message: expect.stringContaining('orca serve') })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves interactive download and install-on-quit behavior', async () => {
|
||||
const send = vi.fn()
|
||||
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
|
||||
autoUpdaterMock.emit('checking-for-update')
|
||||
queueMicrotask(() => autoUpdaterMock.emit('update-available', { version: '1.0.61' }))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { checkForUpdatesFromMenu, downloadUpdate, setupAutoUpdater } = await import('./updater')
|
||||
setupAutoUpdater({ webContents: { send } } as never, {
|
||||
getLastUpdateCheckAt: () => Date.now(),
|
||||
installMode: 'interactive'
|
||||
})
|
||||
checkForUpdatesFromMenu()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
downloadUpdate()
|
||||
|
||||
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true)
|
||||
expect(autoUpdaterMock.autoRunAppAfterInstall).toBe(true)
|
||||
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith(
|
||||
'headless_serve_install_deferred',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -50,12 +50,14 @@ const {
|
|||
autoUpdaterMock.setFeedURL.mockClear()
|
||||
autoUpdaterMock.updateConfigPath = undefined
|
||||
autoUpdaterMock.allowPrerelease = false
|
||||
autoUpdaterMock.autoRunAppAfterInstall = true
|
||||
delete (autoUpdaterMock as Record<string, unknown>).verifyUpdateCodeSignature
|
||||
}
|
||||
|
||||
const autoUpdaterMock = {
|
||||
autoDownload: false,
|
||||
autoInstallOnAppQuit: false,
|
||||
autoRunAppAfterInstall: true,
|
||||
allowPrerelease: false,
|
||||
on,
|
||||
checkForUpdates: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -33,12 +33,22 @@ import {
|
|||
getReleaseDownloadUrl
|
||||
} from './updater-prerelease-feed'
|
||||
import { fetchNudge, shouldApplyNudge } from './updater-nudge'
|
||||
import {
|
||||
failServeUpdateHandoff,
|
||||
getServeUpdateHandoffFailure,
|
||||
hasServeUpdateSupervisor,
|
||||
requestServeUpdateHandoff
|
||||
} from './serve-update-handoff'
|
||||
|
||||
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
|
||||
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
|
||||
type PrimaryEventSuppression = { failureKey: string; error: unknown }
|
||||
type UpdateCheckVariant = 'default' | 'prerelease' | 'perf'
|
||||
type ReleaseFeedPreflightResult = 'ready' | 'not-available'
|
||||
export type UpdateInstallMode =
|
||||
| 'interactive'
|
||||
| 'supervised-headless-serve'
|
||||
| 'unsupported-headless-serve'
|
||||
|
||||
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
|
||||
|
|
@ -66,6 +76,8 @@ let autoUpdateCheckTimer: ReturnType<typeof setTimeout> | null = null
|
|||
let nudgeCheckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingQuitAndInstallTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let quitAndInstallInProgress = false
|
||||
let updateInstallMode: UpdateInstallMode = 'interactive'
|
||||
let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null }
|
||||
// Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer.
|
||||
let updateInstallCommitted = false
|
||||
// Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery.
|
||||
|
|
@ -520,6 +532,36 @@ function getPendingInstallVersion(): string {
|
|||
return ''
|
||||
}
|
||||
|
||||
function deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean {
|
||||
if (updateInstallMode !== 'unsupported-headless-serve') {
|
||||
return false
|
||||
}
|
||||
const diagnosticVersion = version || 'unknown'
|
||||
if (lastInstallDeferralVersion[phase] !== diagnosticVersion) {
|
||||
lastInstallDeferralVersion[phase] = diagnosticVersion
|
||||
recordUpdaterLifecycle(
|
||||
'headless_serve_install_deferred',
|
||||
{ phase, version: version || null },
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'Update install deferred while hosting orca serve'
|
||||
}
|
||||
)
|
||||
}
|
||||
sendErrorStatus(
|
||||
'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.',
|
||||
true
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode {
|
||||
if (!isServeMode) {
|
||||
return 'interactive'
|
||||
}
|
||||
return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve'
|
||||
}
|
||||
|
||||
function getCheckFailureKey(message: string, userInitiated?: boolean): string {
|
||||
return `${userInitiated ? 'user' : 'auto'}:${message}`
|
||||
}
|
||||
|
|
@ -541,19 +583,23 @@ async function performQuitAndInstall(): Promise<void> {
|
|||
recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' })
|
||||
return
|
||||
}
|
||||
quitAndInstallInProgress = true
|
||||
|
||||
if (pendingQuitAndInstallTimer) {
|
||||
clearTimeout(pendingQuitAndInstallTimer)
|
||||
pendingQuitAndInstallTimer = null
|
||||
}
|
||||
|
||||
const pendingVersion = getPendingInstallVersion()
|
||||
if (deferHeadlessServeInstall('install', pendingVersion)) {
|
||||
return
|
||||
}
|
||||
quitAndInstallInProgress = true
|
||||
|
||||
markMacQuitAndInstallInFlight()
|
||||
|
||||
// Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle.
|
||||
quittingForUpdate = true
|
||||
|
||||
const pendingVersion = getPendingInstallVersion()
|
||||
try {
|
||||
await withUpdaterSpan({ stage: 'install' }, async (span) => {
|
||||
span.setAttribute('updater.version', pendingVersion || 'unknown')
|
||||
|
|
@ -570,6 +616,26 @@ async function performQuitAndInstall(): Promise<void> {
|
|||
await runBeforeUpdateQuitCleanup()
|
||||
span.addEvent('pre_quit_cleanup_done')
|
||||
|
||||
if (
|
||||
updateInstallMode === 'supervised-headless-serve' &&
|
||||
!requestServeUpdateHandoff(pendingVersion)
|
||||
) {
|
||||
recordUpdaterLifecycle(
|
||||
'headless_serve_handoff_failed',
|
||||
{ version: pendingVersion || null },
|
||||
{
|
||||
level: 'warn',
|
||||
message: 'Could not persist supervised serve update handoff'
|
||||
}
|
||||
)
|
||||
sendErrorStatus(
|
||||
'Could not prepare the supervised server restart. Orca remains running.',
|
||||
true
|
||||
)
|
||||
resetQuitForUpdateState()
|
||||
return
|
||||
}
|
||||
|
||||
recordUpdaterLifecycle('quit_and_install_invoking_native', {
|
||||
version: pendingVersion || null
|
||||
})
|
||||
|
|
@ -580,7 +646,8 @@ async function performQuitAndInstall(): Promise<void> {
|
|||
// Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure.
|
||||
quitAndInstallNativeInvoked = true
|
||||
// Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact.
|
||||
getAutoUpdater().quitAndInstall(false, true)
|
||||
const supervisorOwnsRelaunch = updateInstallMode === 'supervised-headless-serve'
|
||||
getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch)
|
||||
span.addEvent('native_quit_and_install_invoked')
|
||||
|
||||
// Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran.
|
||||
|
|
@ -606,6 +673,7 @@ async function performQuitAndInstall(): Promise<void> {
|
|||
}
|
||||
})
|
||||
} catch (error) {
|
||||
failServeUpdateHandoff('Could not invoke the native updater.')
|
||||
resetQuitForUpdateState()
|
||||
recordUpdaterLifecycle(
|
||||
'quit_and_install_failed',
|
||||
|
|
@ -635,6 +703,7 @@ function handleQuitAndInstallFailure(): boolean {
|
|||
if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) {
|
||||
return false
|
||||
}
|
||||
failServeUpdateHandoff('The native updater rejected the install request.')
|
||||
resetQuitForUpdateState()
|
||||
recordUpdaterLifecycle('quit_and_install_failed_via_event', undefined, {
|
||||
level: 'warn',
|
||||
|
|
@ -1164,6 +1233,10 @@ export function quitAndInstall(): void {
|
|||
return
|
||||
}
|
||||
|
||||
if (deferHeadlessServeInstall('install', getPendingInstallVersion())) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
deferMacQuitUntilInstallerReady(
|
||||
currentStatus,
|
||||
|
|
@ -1256,6 +1329,7 @@ export function setupAutoUpdater(
|
|||
getDismissedUpdateNudgeId?: () => string | null
|
||||
setPendingUpdateNudgeId?: (id: string | null) => void
|
||||
setDismissedUpdateNudgeId?: (id: string | null) => void
|
||||
installMode?: UpdateInstallMode
|
||||
}
|
||||
): void {
|
||||
mainWindowRef = mainWindow
|
||||
|
|
@ -1266,6 +1340,18 @@ export function setupAutoUpdater(
|
|||
_getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null
|
||||
_setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null
|
||||
_setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null
|
||||
updateInstallMode = opts?.installMode ?? 'interactive'
|
||||
lastInstallDeferralVersion = { download: null, install: null }
|
||||
|
||||
const serveHandoffFailure = getServeUpdateHandoffFailure()
|
||||
if (serveHandoffFailure) {
|
||||
recordUpdaterLifecycle(
|
||||
'headless_serve_handoff_failed',
|
||||
{ reason: serveHandoffFailure },
|
||||
{ level: 'warn', message: 'Supervised serve update did not complete' }
|
||||
)
|
||||
sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true)
|
||||
}
|
||||
|
||||
if (!app.isPackaged && !is.dev) {
|
||||
return
|
||||
|
|
@ -1276,7 +1362,10 @@ export function setupAutoUpdater(
|
|||
|
||||
const autoUpdater = getAutoUpdater()
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
// Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly.
|
||||
autoUpdater.autoInstallOnAppQuit = updateInstallMode === 'interactive'
|
||||
// Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner.
|
||||
autoUpdater.autoRunAppAfterInstall = updateInstallMode === 'interactive'
|
||||
|
||||
// Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible.
|
||||
autoUpdater.logger = {
|
||||
|
|
@ -1322,6 +1411,7 @@ export function setupAutoUpdater(
|
|||
sendCheckFailureStatus,
|
||||
sendErrorStatus,
|
||||
markMissingManifestPrereleaseFallbackChecking,
|
||||
shouldDeferMacQuitForInstall: () => updateInstallMode === 'interactive',
|
||||
shouldSuppressMissingManifestPrereleaseFallbackEvent,
|
||||
suppressMissingManifestPrereleaseFallbackPromiseFailure,
|
||||
recordCompletedUpdateCheck,
|
||||
|
|
@ -1389,6 +1479,9 @@ export function downloadUpdate(): void {
|
|||
if (!version) {
|
||||
return
|
||||
}
|
||||
if (deferHeadlessServeInstall('download', version)) {
|
||||
return
|
||||
}
|
||||
downloadInFlight = true
|
||||
beginMacUpdateDownload()
|
||||
// Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert.
|
||||
|
|
|
|||
|
|
@ -260,13 +260,17 @@ describe('attachMainWindowServices', () => {
|
|||
createRuntime() as never,
|
||||
undefined,
|
||||
undefined,
|
||||
{ onBeforeUpdateQuit }
|
||||
{ onBeforeUpdateQuit, updateInstallMode: 'supervised-headless-serve' }
|
||||
)
|
||||
|
||||
// Deferred to first paint — must not be configured at attach time.
|
||||
expect(setupAutoUpdaterMock).not.toHaveBeenCalled()
|
||||
await fireReadyToShow(mainWindow)
|
||||
expect(setupAutoUpdaterMock).toHaveBeenCalledTimes(1)
|
||||
expect(setupAutoUpdaterMock).toHaveBeenCalledWith(
|
||||
mainWindow,
|
||||
expect.objectContaining({ installMode: 'supervised-headless-serve' })
|
||||
)
|
||||
await setupAutoUpdaterMock.mock.calls[0][1].onBeforeQuit()
|
||||
|
||||
expect(onBeforeUpdateQuit).toHaveBeenCalledTimes(1)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ import {
|
|||
getUpdateStatus,
|
||||
quitAndInstall,
|
||||
setupAutoUpdater,
|
||||
dismissNudge
|
||||
dismissNudge,
|
||||
type UpdateInstallMode
|
||||
} from '../updater'
|
||||
import { scheduleHistoryGc } from '../terminal-history'
|
||||
import { hydrateLocalPtyRegistryAtBoot } from '../memory/hydrate-local-pty-registry'
|
||||
|
|
@ -82,6 +83,7 @@ export function attachMainWindowServices(
|
|||
// Why: lets the PTY orphan sweep skip the one crash-recovery reload (#5787).
|
||||
isRecoveryReloadInFlight?: (webContentsId: number) => boolean
|
||||
onBeforeUpdateQuit?: () => void | Promise<void>
|
||||
updateInstallMode?: UpdateInstallMode
|
||||
}
|
||||
): void {
|
||||
registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload)
|
||||
|
|
@ -158,7 +160,8 @@ export function attachMainWindowServices(
|
|||
},
|
||||
setDismissedUpdateNudgeId: (id) => {
|
||||
store.updateUI({ dismissedUpdateNudgeId: id })
|
||||
}
|
||||
},
|
||||
installMode: options?.updateInstallMode
|
||||
})
|
||||
logStartupMilestone('updater-setup-done')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { join } from 'node:path'
|
||||
|
||||
export const SERVE_UPDATE_HANDOFF_PATH_ENV = 'ORCA_SERVE_UPDATE_HANDOFF_PATH'
|
||||
export const SERVE_UPDATE_HANDOFF_FILE = 'serve-update-handoff.json'
|
||||
|
||||
export type ServeUpdateHandoffState =
|
||||
| {
|
||||
schemaVersion: 1
|
||||
phase: 'install-requested'
|
||||
fromVersion: string
|
||||
targetVersion: string
|
||||
servingPid: number
|
||||
}
|
||||
| {
|
||||
schemaVersion: 1
|
||||
phase: 'failed'
|
||||
fromVersion: string
|
||||
targetVersion: string
|
||||
servingPid: number
|
||||
reason: string
|
||||
}
|
||||
| {
|
||||
schemaVersion: 1
|
||||
phase: 'completed'
|
||||
fromVersion: string
|
||||
targetVersion: string
|
||||
servingPid: number
|
||||
runtimeId: string
|
||||
}
|
||||
|
||||
export type ServeSupervisorMessage = {
|
||||
type: 'orca:serve-ready'
|
||||
version: string
|
||||
runtimeId: string
|
||||
}
|
||||
|
||||
export function getServeUpdateHandoffPath(userDataPath: string): string {
|
||||
return join(userDataPath, SERVE_UPDATE_HANDOFF_FILE)
|
||||
}
|
||||
|
||||
export function parseServeUpdateHandoffState(value: unknown): ServeUpdateHandoffState | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
const state = value as Record<string, unknown>
|
||||
if (
|
||||
state.schemaVersion !== 1 ||
|
||||
!['install-requested', 'failed', 'completed'].includes(String(state.phase)) ||
|
||||
typeof state.fromVersion !== 'string' ||
|
||||
state.fromVersion.length === 0 ||
|
||||
typeof state.targetVersion !== 'string' ||
|
||||
state.targetVersion.length === 0 ||
|
||||
!Number.isInteger(state.servingPid) ||
|
||||
(state.servingPid as number) <= 0 ||
|
||||
(state.phase === 'failed' && typeof state.reason !== 'string') ||
|
||||
(state.phase === 'completed' &&
|
||||
(typeof state.runtimeId !== 'string' || state.runtimeId.length === 0))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return state as ServeUpdateHandoffState
|
||||
}
|
||||
|
||||
export function parseServeSupervisorMessage(value: unknown): ServeSupervisorMessage | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
if (
|
||||
message.type !== 'orca:serve-ready' ||
|
||||
typeof message.version !== 'string' ||
|
||||
message.version.length === 0 ||
|
||||
typeof message.runtimeId !== 'string' ||
|
||||
message.runtimeId.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return message as ServeSupervisorMessage
|
||||
}
|
||||
Loading…
Reference in New Issue