From 81aaf46c7679fe2db894b57379b1c98f6ea5cea6 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 10:58:18 +0800 Subject: [PATCH 1/5] feat(runtime-host): schedule managed update reconciliation Install platform-native periodic triggers as derived resources of Maka-managed Runtime Host deployments. Both adapters invoke the stable one-shot reconciler, repair drift idempotently, and remove scheduling with the service without duplicating update policy authority.\n\nGenerated-by: Codex --- .../runtime-host-launch-agent-service.test.ts | 92 ++++- .../runtime-host-service-manager.test.ts | 96 +++-- .../src/runtime-host-launch-agent-service.ts | 243 +++++++++++- .../cli/src/runtime-host-service-launch.ts | 13 + .../cli/src/runtime-host-systemd-service.ts | 366 +++++++++++++++++- 5 files changed, 760 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts index 4bb50c7708..dc657e9fc0 100644 --- a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts +++ b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts @@ -25,7 +25,9 @@ import { test } from 'node:test'; import { createLaunchAgentRuntimeHostService, renderLaunchAgentPlist, + renderLaunchAgentUpdatePlist, resolveLaunchAgentPath, + resolveLaunchAgentUpdatePath, } from '../runtime-host-launch-agent-service.js'; import type { RuntimeHostManagedServiceConfig } from '../runtime-host-service-manager.js'; @@ -34,6 +36,8 @@ const UID = 501; const LABEL = `com.maka.runtime-host.${SERVICE_ID}`; const DOMAIN = `gui/${String(UID)}`; const TARGET = `${DOMAIN}/${LABEL}`; +const UPDATE_LABEL = `${LABEL}.update`; +const UPDATE_TARGET = `${DOMAIN}/${UPDATE_LABEL}`; test('renders the canonical Runtime Host command as a private persistent LaunchAgent', () => { const config = fixtureConfig('/tmp/node & tool', '/tmp/maka ', '/tmp/state > root'); @@ -55,6 +59,64 @@ test('renders the canonical Runtime Host command as a private persistent LaunchA assert.match(plist, /workspace=\/tmp\/projects<\/string>/u); }); +test('renders managed update reconciliation as a periodic one-shot LaunchAgent', () => { + const config = { + ...fixtureConfig('/tmp/node', '/tmp/maka', '/tmp/state'), + managedDeploymentRoot: '/tmp/managed deployment', + }; + const plist = renderLaunchAgentUpdatePlist(config, { + label: UPDATE_LABEL, + stdoutPath: '/tmp/update.stdout.log', + stderrPath: '/tmp/update.stderr.log', + }); + + assert.match(plist, /\/tmp\/managed deployment\/operator<\/string>/u); + assert.match(plist, /reconcile-update<\/string>/u); + assert.match(plist, /--framed<\/string>/u); + assert.match(plist, /StartInterval<\/key>\n 86400<\/integer>/u); + assert.doesNotMatch(plist, /KeepAlive<\/key>/u); +}); + +test('installs and removes the update scheduler with a managed LaunchAgent', async () => { + await withFixture(async ({ homeDir, cliPath, launchctl }) => { + const backend = createLaunchAgentRuntimeHostService(SERVICE_ID, { + homeDir, + uid: UID, + runLaunchctl: launchctl.run, + isProcessAlive: () => false, + }); + const config = { + ...fixtureConfig(process.execPath, cliPath, join(homeDir, 'state')), + managedDeploymentRoot: join(homeDir, 'managed'), + }; + + await backend.install(config); + await backend.verifyDeployment(config); + const updatePath = resolveLaunchAgentUpdatePath(SERVICE_ID, homeDir); + assert.match(await readFile(updatePath, 'utf8'), /reconcile-update/u); + + await backend.replace(config); + assert.equal( + launchctl.calls.filter( + ([command, target]) => command === 'bootout' && target === UPDATE_TARGET, + ).length, + 0, + ); + + await writeFile(updatePath, 'stale\n', { mode: 0o600 }); + await assert.rejects( + backend.verifyDeployment(config), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'target_mismatch', + ); + await backend.replace(config); + await backend.verifyDeployment(config); + + await backend.uninstall(); + assert.equal(await fileExists(updatePath), false); + }); +}); + test('maps install, stop, start, restart, and uninstall onto one LaunchAgent service', async () => { await withFixture(async ({ homeDir, cliPath, launchctl }) => { let processChecks = 0; @@ -156,6 +218,8 @@ interface FakeLaunchctl { function createFakeLaunchctl(): FakeLaunchctl { let pid = 4100; + let updateLoaded = false; + let updateRunning = false; const fake: FakeLaunchctl = { loaded: false, running: false, @@ -166,11 +230,14 @@ function createFakeLaunchctl(): FakeLaunchctl { if (args[0] === 'print' && args[1] === DOMAIN) { return { exitCode: 0, stdout: 'domain = gui\n', stderr: '' }; } - if (args[0] === 'print' && args[1] === TARGET) { - return fake.loaded + if (args[0] === 'print' && (args[1] === TARGET || args[1] === UPDATE_TARGET)) { + const update = args[1] === UPDATE_TARGET; + const loaded = update ? updateLoaded : fake.loaded; + const running = update ? updateRunning : fake.running; + return loaded ? { exitCode: 0, - stdout: fake.running + stdout: running ? `state = running\npid = ${String(pid)}\nlast exit code = 0\n` : 'state = not running\nlast exit code = 0\n', stderr: '', @@ -182,14 +249,25 @@ function createFakeLaunchctl(): FakeLaunchctl { fake.failNextBootstrap = false; return { exitCode: 5, stdout: '', stderr: 'Input/output error' }; } - fake.loaded = true; - fake.running = true; + const update = args[2]?.endsWith('.update.plist') ?? false; + if (update) { + updateLoaded = true; + updateRunning = false; + } else { + fake.loaded = true; + fake.running = true; + } pid += 1; return { exitCode: 0, stdout: '', stderr: '' }; } if (args[0] === 'bootout') { - fake.running = false; - fake.loaded = false; + if (args[1] === UPDATE_TARGET) { + updateRunning = false; + updateLoaded = false; + } else { + fake.running = false; + fake.loaded = false; + } return { exitCode: 0, stdout: '', stderr: '' }; } if (args[0] === 'kickstart') { diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index a301ba6f54..fbeac7d7bd 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -69,7 +69,11 @@ import { import { createSystemdUserRuntimeHostService, renderSystemdUnit, + renderSystemdUpdateService, + renderSystemdUpdateTimer, resolveSystemdUserRuntimeHostServicePath, + resolveSystemdUserRuntimeHostUpdateServicePath, + resolveSystemdUserRuntimeHostUpdateTimerPath, } from '../runtime-host-systemd-service.js'; describe('managed Runtime Host service', () => { @@ -329,6 +333,14 @@ describe('managed Runtime Host service', () => { assert.equal(installed.service.enabled, true); assert.equal(installed.service.config?.websocket.port, 47_777); assert.match(await readFile(unitPath, 'utf8'), /ExecStart=.*runtime-host.*serve/u); + const updateServicePath = resolveSystemdUserRuntimeHostUpdateServicePath( + serviceId, + env, + homeDir, + ); + const updateTimerPath = resolveSystemdUserRuntimeHostUpdateTimerPath(serviceId, env, homeDir); + assert.match(await readFile(updateServicePath, 'utf8'), /operator.*reconcile-update/u); + assert.match(await readFile(updateTimerPath, 'utf8'), /^OnUnitInactiveSec=86400s$/mu); const resetFailed = systemd.calls.findIndex(([command]) => command === 'reset-failed'); const restart = systemd.calls.findIndex(([command]) => command === 'restart'); assert.ok(resetFailed >= 0 && resetFailed < restart); @@ -343,6 +355,23 @@ describe('managed Runtime Host service', () => { { label: 'Projects', path: await realpath(projectPath) }, ]); assert.equal(reinstalled.service.lastExitCode, 0); + assert.equal( + systemd.calls.filter( + ([command, target]) => command === 'restart' && target === basename(updateTimerPath), + ).length, + 1, + ); + const managedConfig = reinstalled.service.config; + assert.ok(managedConfig); + await writeFile(updateTimerPath, '[Timer]\n# stale\n', 'utf8'); + const repairBackend = backend(); + await assert.rejects( + repairBackend.verifyDeployment(managedConfig), + (error: unknown) => + error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch', + ); + await repairBackend.replace(managedConfig); + await repairBackend.verifyDeployment(managedConfig); const root = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); await writeFile(configPath, '{not-json', 'utf8'); @@ -503,6 +532,8 @@ describe('managed Runtime Host service', () => { await access(movedRootPath); await assert.rejects(access(configPath)); await assert.rejects(access(unitPath)); + await assert.rejects(access(updateServicePath)); + await assert.rejects(access(updateTimerPath)); await assert.rejects(access(deploymentRoot)); const repeated = await manageRuntimeHostService({ ...common, action: 'uninstall' }, backend()); @@ -654,6 +685,15 @@ describe('managed Runtime Host service', () => { assert.match(unit, /^Restart=on-failure$/mu); assert.match(unit, /^StartLimitIntervalSec=60s$/mu); assert.match(unit, /^StartLimitBurst=5$/mu); + + const managed = { ...config, managedDeploymentRoot: '/opt/Maka/$managed root' }; + const updateService = renderSystemdUpdateService(managed); + const updateTimer = renderSystemdUpdateTimer('a'.repeat(64)); + assert.match(updateService, /"\/opt\/Maka\/\$\$managed root\/operator"/u); + assert.match(updateService, /"reconcile-update" "--framed"/u); + assert.match(updateTimer, /^OnActiveSec=900s$/mu); + assert.match(updateTimer, /^OnUnitInactiveSec=86400s$/mu); + assert.match(updateTimer, /^RandomizedDelaySec=3600s$/mu); }); it('emits one stable machine error for an unmet service prerequisite', async () => { @@ -1981,9 +2021,7 @@ function createFakeSystemd(unitPath: string): { stderr: string; }>; } { - let loaded = false; - let enabled = false; - let active = false; + const states = new Map(); let failureCommand: string | undefined; let dropInPaths: readonly string[] = []; const calls: string[][] = []; @@ -1997,55 +2035,57 @@ function createFakeSystemd(unitPath: string): { }, run: async (args) => { calls.push([...args]); - if ( - ['show', 'enable', 'disable', 'start', 'restart', 'stop', 'reset-failed'].includes( - args[0] ?? '', - ) - ) { - assert.equal(args[1], basename(unitPath)); - } + const unitName = args[1]; + const unitState = unitName + ? (states.get(unitName) ?? { enabled: false, active: false }) + : undefined; + if (unitName && unitState) states.set(unitName, unitState); if (args[0] === failureCommand) { failureCommand = undefined; return { exitCode: 1, stdout: '', stderr: `${args[0]} failed` }; } if (args[0] === 'show-environment') return success('PATH=/usr/bin\n'); - if (args[0] === 'daemon-reload') { - loaded = await access(unitPath).then( - () => true, - () => false, - ); - return success(); - } + if (args[0] === 'daemon-reload') return success(); if (args[0] === 'enable') { - enabled = true; + assert.ok(unitState); + unitState.enabled = true; return success(); } if (args[0] === 'disable') { - enabled = false; + assert.ok(unitState); + unitState.enabled = false; return success(); } if (args[0] === 'start' || args[0] === 'restart') { - active = true; - loaded = true; + assert.ok(unitState); + unitState.active = true; return success(); } if (args[0] === 'stop') { - active = false; + assert.ok(unitState); + unitState.active = false; return success(); } if (args[0] === 'reset-failed') return success(); if (args[0] === 'show') { + assert.ok(unitName && unitState); + const path = join(dirname(unitPath), unitName); + const loaded = await access(path).then( + () => true, + () => false, + ); + const isMainService = unitName === basename(unitPath); return { exitCode: loaded ? 0 : 4, stdout: [ `LoadState=${loaded ? 'loaded' : 'not-found'}`, - `ActiveState=${active ? 'active' : 'inactive'}`, - `SubState=${active ? 'running' : 'dead'}`, - `UnitFileState=${enabled ? 'enabled' : 'disabled'}`, - `FragmentPath=${loaded ? unitPath : ''}`, + `ActiveState=${unitState.active ? 'active' : 'inactive'}`, + `SubState=${unitState.active ? 'running' : 'dead'}`, + `UnitFileState=${unitState.enabled ? 'enabled' : 'disabled'}`, + `FragmentPath=${loaded ? path : ''}`, 'NeedDaemonReload=no', - `DropInPaths=${dropInPaths.join(' ')}`, - `MainPID=${active ? '4242' : '0'}`, + `DropInPaths=${isMainService ? dropInPaths.join(' ') : ''}`, + `MainPID=${unitState.active && isMainService ? '4242' : '0'}`, 'ExecMainStatus=0', '', ].join('\n'), diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts index f1414efd99..02654f62a2 100644 --- a/packages/cli/src/runtime-host-launch-agent-service.ts +++ b/packages/cli/src/runtime-host-launch-agent-service.ts @@ -31,7 +31,9 @@ import { writeRuntimeHostServiceFile, } from './runtime-host-service-manager.js'; import { + RUNTIME_HOST_UPDATE_INTERVAL_SECONDS, runtimeHostServiceLaunchArguments, + runtimeHostUpdateReconcileLaunchArguments, validateRuntimeHostServiceLaunch, } from './runtime-host-service-launch.js'; import { @@ -97,6 +99,13 @@ export function createLaunchAgentRuntimeHostService( runLaunchctl, isProcessAlive, }; + const scheduler = resolveLaunchAgentUpdateSchedulerContext( + serviceId, + homeDir, + uid, + runLaunchctl, + isProcessAlive, + ); const readDetailedStatus = () => readLaunchAgentStatus(context); const readStatus = async (): Promise => { @@ -107,28 +116,56 @@ export function createLaunchAgentRuntimeHostService( preflightInstall: () => assertLaunchAgentDomain(context), install: async (config) => { await validateRuntimeHostServiceLaunch(config); - const previous = await captureLaunchAgentDeployment(context); + const managesScheduler = runtimeHostUpdateReconcileLaunchArguments(config) !== null; + const [previous, previousScheduler] = await Promise.all([ + captureLaunchAgentDeployment(context), + managesScheduler ? captureLaunchAgentDeployment(scheduler) : undefined, + ]); try { await applyLaunchAgentDeployment(context, config); + if (managesScheduler) await applyLaunchAgentUpdateScheduler(scheduler, config); } catch (error) { - await restoreFailedLaunchAgentDeployment(previous, context, error); + await restoreFailedLaunchAgentDeployment( + previous, + previousScheduler, + context, + scheduler, + error, + ); } let rolledBack = false; return { rollback: async () => { if (rolledBack) return; rolledBack = true; - await restoreLaunchAgentDeployment(previous, context); + await restoreLaunchAgentManagedDeployment( + previous, + previousScheduler, + context, + scheduler, + ); }, } satisfies RuntimeHostServiceDeployment; }, replace: async (config) => { await validateRuntimeHostServiceLaunch(config); - const previous = await captureLaunchAgentDeployment(context); + const managesScheduler = runtimeHostUpdateReconcileLaunchArguments(config) !== null; + const [previous, previousScheduler] = await Promise.all([ + captureLaunchAgentDeployment(context), + managesScheduler ? captureLaunchAgentDeployment(scheduler) : undefined, + ]); try { await applyLaunchAgentDeployment(context, config); + if (managesScheduler) await applyLaunchAgentUpdateScheduler(scheduler, config); } catch (error) { - await restoreFailedLaunchAgentDeployment(previous, context, error, 'update_incomplete'); + await restoreFailedLaunchAgentDeployment( + previous, + previousScheduler, + context, + scheduler, + error, + 'update_incomplete', + ); } }, verifyDeployment: async (config) => { @@ -146,6 +183,9 @@ export function createLaunchAgentRuntimeHostService( 'The installed Runtime Host LaunchAgent does not match its managed deployment', ); } + if (runtimeHostUpdateReconcileLaunchArguments(config)) { + await verifyLaunchAgentUpdateScheduler(scheduler, config); + } }, status: readStatus, start: async () => { @@ -188,15 +228,23 @@ export function createLaunchAgentRuntimeHostService( ); }, logs: async () => { - const [stdout, stderr] = await Promise.all([ + const [stdout, stderr, updateStdout, updateStderr] = await Promise.all([ readLogTail(context.stdoutPath), readLogTail(context.stderrPath), + readLogTail(scheduler.stdoutPath), + readLogTail(scheduler.stderrPath), ]); - return [stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`] + return [ + stdout && `stdout:\n${stdout}`, + stderr && `stderr:\n${stderr}`, + updateStdout && `update stdout:\n${updateStdout}`, + updateStderr && `update stderr:\n${updateStderr}`, + ] .filter(Boolean) .join('\n'); }, uninstall: async () => { + await removeLaunchAgentUpdateScheduler(scheduler); await bootoutLaunchAgent(context); await Promise.all([ removeRuntimeHostServiceFile(context.plistPath, 'LaunchAgent plist'), @@ -218,6 +266,15 @@ export function resolveLaunchAgentPath(serviceId: string, homeDir = homedir()): return join(homeDir, 'Library', 'LaunchAgents', `${resolveLaunchAgentLabel(serviceId)}.plist`); } +export function resolveLaunchAgentUpdatePath(serviceId: string, homeDir = homedir()): string { + return join( + homeDir, + 'Library', + 'LaunchAgents', + `${resolveLaunchAgentUpdateLabel(serviceId)}.plist`, + ); +} + export function renderLaunchAgentPlist( config: RuntimeHostManagedServiceConfig, paths: Pick, @@ -256,11 +313,50 @@ export function renderLaunchAgentPlist( ].join('\n'); } +export function renderLaunchAgentUpdatePlist( + config: RuntimeHostManagedServiceConfig, + paths: Pick, +): string { + const args = runtimeHostUpdateReconcileLaunchArguments(config); + if (!args) throw new TypeError('Managed deployment root is required for update scheduling'); + const stringEntry = (value: string) => ` ${escapeXml(value)}`; + return [ + '', + '', + '', + '', + ' Label', + ` ${escapeXml(paths.label)}`, + ' ProgramArguments', + ' ', + args.map(stringEntry).join('\n'), + ' ', + ' StartInterval', + ` ${String(RUNTIME_HOST_UPDATE_INTERVAL_SECONDS)}`, + ' ProcessType', + ' Background', + ' Umask', + ' 63', + ' StandardOutPath', + ` ${escapeXml(paths.stdoutPath)}`, + ' StandardErrorPath', + ` ${escapeXml(paths.stderrPath)}`, + '', + '', + '', + ].join('\n'); +} + function resolveLaunchAgentLabel(serviceId: string): string { if (!/^[0-9a-f]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID'); return `com.maka.runtime-host.${serviceId}`; } +function resolveLaunchAgentUpdateLabel(serviceId: string): string { + if (!/^[0-9a-f]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID'); + return `${resolveLaunchAgentLabel(serviceId)}.update`; +} + function resolveLaunchAgentLogPath( serviceId: string, stream: 'stdout' | 'stderr', @@ -276,6 +372,41 @@ function resolveLaunchAgentLogPath( ); } +function resolveLaunchAgentUpdateSchedulerContext( + serviceId: string, + homeDir: string, + uid: number, + runLaunchctl: LaunchctlRunner, + isProcessAlive: (pid: number) => boolean, +): LaunchAgentContext { + const label = resolveLaunchAgentUpdateLabel(serviceId); + return { + domain: `gui/${String(uid)}`, + label, + serviceTarget: `gui/${String(uid)}/${label}`, + plistPath: resolveLaunchAgentUpdatePath(serviceId, homeDir), + stdoutPath: resolveLaunchAgentUpdateLogPath(serviceId, 'stdout', homeDir), + stderrPath: resolveLaunchAgentUpdateLogPath(serviceId, 'stderr', homeDir), + runLaunchctl, + isProcessAlive, + }; +} + +function resolveLaunchAgentUpdateLogPath( + serviceId: string, + stream: 'stdout' | 'stderr', + homeDir: string, +): string { + return join( + homeDir, + 'Library', + 'Logs', + 'Maka', + 'runtime-host-services', + `${resolveLaunchAgentUpdateLabel(serviceId)}.${stream}.log`, + ); +} + async function captureLaunchAgentDeployment( context: LaunchAgentContext, ): Promise { @@ -289,6 +420,71 @@ async function captureLaunchAgentDeployment( return { plist, loaded: status.loaded }; } +async function applyLaunchAgentUpdateScheduler( + context: LaunchAgentContext, + config: RuntimeHostManagedServiceConfig, +): Promise { + if (!runtimeHostUpdateReconcileLaunchArguments(config)) { + await removeLaunchAgentUpdateScheduler(context); + return; + } + try { + await verifyLaunchAgentUpdateScheduler(context, config); + return; + } catch (error) { + if (!isTargetMismatch(error)) throw error; + } + await bootoutLaunchAgent(context); + await prepareLaunchAgentLogs(context); + await writeRuntimeHostServiceFile( + context.plistPath, + renderLaunchAgentUpdatePlist(config, context), + 0o600, + ); + await bootstrapLaunchAgent(context); +} + +async function verifyLaunchAgentUpdateScheduler( + context: LaunchAgentContext, + config: RuntimeHostManagedServiceConfig, +): Promise { + const args = runtimeHostUpdateReconcileLaunchArguments(config); + const [status, plist] = await Promise.all([ + readLaunchAgentStatus(context), + readFile(context.plistPath, 'utf8').catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return null; + throw error; + }), + ]); + if (!args) { + if (status.installed || status.loaded || plist !== null) throw launchAgentSchedulerMismatch(); + return; + } + if (!status.loaded || plist !== renderLaunchAgentUpdatePlist(config, context)) { + throw launchAgentSchedulerMismatch(); + } +} + +async function removeLaunchAgentUpdateScheduler(context: LaunchAgentContext): Promise { + await bootoutLaunchAgent(context); + await Promise.all([ + removeRuntimeHostServiceFile(context.plistPath, 'LaunchAgent update plist'), + removeRuntimeHostServiceFile(context.stdoutPath, 'LaunchAgent update stdout log'), + removeRuntimeHostServiceFile(context.stderrPath, 'LaunchAgent update stderr log'), + ]); +} + +function launchAgentSchedulerMismatch(): RuntimeHostServiceManagerError { + return new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The Runtime Host update scheduler does not match its managed deployment', + ); +} + +function isTargetMismatch(error: unknown): boolean { + return error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch'; +} + async function applyLaunchAgentDeployment( context: LaunchAgentContext, config: RuntimeHostManagedServiceConfig, @@ -305,14 +501,21 @@ async function applyLaunchAgentDeployment( async function restoreFailedLaunchAgentDeployment( snapshot: LaunchAgentDeploymentSnapshot, + schedulerSnapshot: LaunchAgentDeploymentSnapshot | undefined, context: LaunchAgentContext, + schedulerContext: LaunchAgentContext, originalError: unknown, recoveryFailureCode: | 'service_manager_operation_failed' | 'update_incomplete' = 'service_manager_operation_failed', ): Promise { try { - await restoreLaunchAgentDeployment(snapshot, context); + await restoreLaunchAgentManagedDeployment( + snapshot, + schedulerSnapshot, + context, + schedulerContext, + ); } catch (rollbackError) { throw new RuntimeHostServiceManagerError( recoveryFailureCode, @@ -323,6 +526,30 @@ async function restoreFailedLaunchAgentDeployment( throw originalError; } +async function restoreLaunchAgentManagedDeployment( + snapshot: LaunchAgentDeploymentSnapshot, + schedulerSnapshot: LaunchAgentDeploymentSnapshot | undefined, + context: LaunchAgentContext, + schedulerContext: LaunchAgentContext, +): Promise { + const errors: unknown[] = []; + if (schedulerSnapshot) { + try { + await restoreLaunchAgentDeployment(schedulerSnapshot, schedulerContext); + } catch (error) { + errors.push(error); + } + } + try { + await restoreLaunchAgentDeployment(snapshot, context); + } catch (error) { + errors.push(error); + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Unable to restore the previous LaunchAgent deployment'); + } +} + async function restoreLaunchAgentDeployment( snapshot: LaunchAgentDeploymentSnapshot, context: LaunchAgentContext, diff --git a/packages/cli/src/runtime-host-service-launch.ts b/packages/cli/src/runtime-host-service-launch.ts index e70ae58d45..0324467f47 100644 --- a/packages/cli/src/runtime-host-service-launch.ts +++ b/packages/cli/src/runtime-host-service-launch.ts @@ -19,6 +19,7 @@ import { constants } from 'node:fs'; import { access, realpath, stat } from 'node:fs/promises'; +import { join } from 'node:path'; import { RuntimeHostServiceManagerError, type RuntimeHostManagedServiceConfig, @@ -48,6 +49,18 @@ export function runtimeHostServiceLaunchArguments( ]; } +export const RUNTIME_HOST_UPDATE_INTERVAL_SECONDS = 24 * 60 * 60; +export const RUNTIME_HOST_UPDATE_INITIAL_DELAY_SECONDS = 15 * 60; +export const RUNTIME_HOST_UPDATE_RANDOM_DELAY_SECONDS = 60 * 60; + +export function runtimeHostUpdateReconcileLaunchArguments( + config: RuntimeHostManagedServiceConfig, +): readonly string[] | null { + return config.managedDeploymentRoot + ? [join(config.managedDeploymentRoot, 'operator'), 'reconcile-update', '--framed'] + : null; +} + export async function validateRuntimeHostServiceLaunch( config: RuntimeHostManagedServiceConfig, ): Promise { diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts index f3476fc4aa..3a7e8dd4b5 100644 --- a/packages/cli/src/runtime-host-systemd-service.ts +++ b/packages/cli/src/runtime-host-systemd-service.ts @@ -31,7 +31,11 @@ import { writeRuntimeHostServiceFile, } from './runtime-host-service-manager.js'; import { + RUNTIME_HOST_UPDATE_INITIAL_DELAY_SECONDS, + RUNTIME_HOST_UPDATE_INTERVAL_SECONDS, + RUNTIME_HOST_UPDATE_RANDOM_DELAY_SECONDS, runtimeHostServiceLaunchArguments, + runtimeHostUpdateReconcileLaunchArguments, validateRuntimeHostServiceLaunch, } from './runtime-host-service-launch.js'; import { @@ -47,6 +51,12 @@ interface SystemdUnitContext { ) => Promise; } +interface SystemdUpdateSchedulerContext { + readonly serviceId: string; + readonly service: SystemdUnitContext; + readonly timer: SystemdUnitContext; +} + export interface SystemdUserServiceOptions { readonly env?: NodeJS.ProcessEnv; readonly homeDir?: string; @@ -74,6 +84,7 @@ export function createSystemdUserRuntimeHostService( unitPath: resolveSystemdUserRuntimeHostServicePath(serviceId, env, homeDir), runSystemctl, }; + const scheduler = resolveSystemdUpdateSchedulerContext(serviceId, env, homeDir, runSystemctl); const runLoginctl = options.runLoginctl ?? defaultRunLoginctl; const runJournalctl = options.runJournalctl ?? defaultRunJournalctl; const uid = options.uid ?? process.getuid?.(); @@ -98,28 +109,51 @@ export function createSystemdUserRuntimeHostService( }, install: async (config) => { await validateRuntimeHostServiceLaunch(config); - const previous = await captureSystemdDeployment(context.unitPath, readStatus); + const managesScheduler = runtimeHostUpdateReconcileLaunchArguments(config) !== null; + const [previous, previousScheduler] = await Promise.all([ + captureSystemdDeployment(context.unitPath, readStatus), + managesScheduler ? captureSystemdUpdateScheduler(scheduler) : undefined, + ]); try { await applySystemdDeployment(context, config); + if (managesScheduler) await applySystemdUpdateScheduler(scheduler, config); } catch (error) { - await restoreFailedSystemdDeployment(previous, context, error); + await restoreFailedSystemdDeployment( + previous, + previousScheduler, + context, + scheduler, + error, + ); } let rolledBack = false; return { rollback: async () => { if (rolledBack) return; rolledBack = true; - await restoreSystemdDeployment(previous, context); + await restoreSystemdManagedDeployment(previous, previousScheduler, context, scheduler); }, } satisfies RuntimeHostServiceDeployment; }, replace: async (config) => { await validateRuntimeHostServiceLaunch(config); - const previous = await captureSystemdDeployment(context.unitPath, readStatus); + const managesScheduler = runtimeHostUpdateReconcileLaunchArguments(config) !== null; + const [previous, previousScheduler] = await Promise.all([ + captureSystemdDeployment(context.unitPath, readStatus), + managesScheduler ? captureSystemdUpdateScheduler(scheduler) : undefined, + ]); try { await applySystemdDeployment(context, config); + if (managesScheduler) await applySystemdUpdateScheduler(scheduler, config); } catch (error) { - await restoreFailedSystemdDeployment(previous, context, error, 'update_incomplete'); + await restoreFailedSystemdDeployment( + previous, + previousScheduler, + context, + scheduler, + error, + 'update_incomplete', + ); } }, verifyDeployment: async (config) => { @@ -143,6 +177,9 @@ export function createSystemdUserRuntimeHostService( 'The loaded Runtime Host service does not match its managed deployment', ); } + if (runtimeHostUpdateReconcileLaunchArguments(config)) { + await verifySystemdUpdateScheduler(scheduler, config); + } }, status: readStatus, start: () => runLifecycleAction(context, 'start'), @@ -152,6 +189,8 @@ export function createSystemdUserRuntimeHostService( const result = await runJournalctl([ '--user-unit', context.unitName, + '--user-unit', + scheduler.service.unitName, '--no-pager', '--lines=200', '--output=short-iso', @@ -168,6 +207,7 @@ export function createSystemdUserRuntimeHostService( return result.stdout; }, uninstall: async () => { + await removeSystemdUpdateScheduler(scheduler); const before = await readSystemdStatus(context); if (before.loadState !== 'not-found') { await requireSystemctl( @@ -215,11 +255,47 @@ export function resolveSystemdUserRuntimeHostServicePath( ); } +export function resolveSystemdUserRuntimeHostUpdateServicePath( + serviceId: string, + env: NodeJS.ProcessEnv = process.env, + homeDir = homedir(), +): string { + return join( + resolveXdgConfigHome(env, homeDir), + 'systemd', + 'user', + resolveSystemdUserRuntimeHostUpdateServiceName(serviceId), + ); +} + +export function resolveSystemdUserRuntimeHostUpdateTimerPath( + serviceId: string, + env: NodeJS.ProcessEnv = process.env, + homeDir = homedir(), +): string { + return join( + resolveXdgConfigHome(env, homeDir), + 'systemd', + 'user', + resolveSystemdUserRuntimeHostUpdateTimerName(serviceId), + ); +} + function resolveSystemdUserRuntimeHostServiceName(serviceId: string): string { - if (!/^[0-9a-f]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID'); + assertServiceId(serviceId); return `maka-runtime-host-${serviceId}.service`; } +function resolveSystemdUserRuntimeHostUpdateServiceName(serviceId: string): string { + assertServiceId(serviceId); + return `maka-runtime-host-${serviceId}-update.service`; +} + +function resolveSystemdUserRuntimeHostUpdateTimerName(serviceId: string): string { + assertServiceId(serviceId); + return `maka-runtime-host-${serviceId}-update.timer`; +} + export function renderSystemdUnit(config: RuntimeHostManagedServiceConfig): string { const args = runtimeHostServiceLaunchArguments(config); return [ @@ -244,6 +320,39 @@ export function renderSystemdUnit(config: RuntimeHostManagedServiceConfig): stri ].join('\n'); } +export function renderSystemdUpdateService(config: RuntimeHostManagedServiceConfig): string { + const args = runtimeHostUpdateReconcileLaunchArguments(config); + if (!args) throw new TypeError('Managed deployment root is required for update scheduling'); + return [ + '[Unit]', + 'Description=Maka Runtime Host update reconciliation', + 'After=network-online.target', + '', + '[Service]', + 'Type=oneshot', + `ExecStart=${args.map(quoteSystemdArgument).join(' ')}`, + 'UMask=0077', + '', + ].join('\n'); +} + +export function renderSystemdUpdateTimer(serviceId: string): string { + return [ + '[Unit]', + 'Description=Schedule Maka Runtime Host update reconciliation', + '', + '[Timer]', + `OnActiveSec=${String(RUNTIME_HOST_UPDATE_INITIAL_DELAY_SECONDS)}s`, + `OnUnitInactiveSec=${String(RUNTIME_HOST_UPDATE_INTERVAL_SECONDS)}s`, + `RandomizedDelaySec=${String(RUNTIME_HOST_UPDATE_RANDOM_DELAY_SECONDS)}s`, + `Unit=${resolveSystemdUserRuntimeHostUpdateServiceName(serviceId)}`, + '', + '[Install]', + 'WantedBy=timers.target', + '', + ].join('\n'); +} + interface SystemdStatus { readonly loadState: string; readonly activeState: string; @@ -260,6 +369,219 @@ interface SystemdDeploymentSnapshot { readonly status: RuntimeHostServiceBackendStatus; } +interface SystemdUpdateSchedulerSnapshot { + readonly serviceUnit: string | null; + readonly timerUnit: string | null; + readonly timerStatus: SystemdStatus; +} + +function resolveSystemdUpdateSchedulerContext( + serviceId: string, + env: NodeJS.ProcessEnv, + homeDir: string, + runSystemctl: SystemdUnitContext['runSystemctl'], +): SystemdUpdateSchedulerContext { + return { + serviceId, + service: { + unitName: resolveSystemdUserRuntimeHostUpdateServiceName(serviceId), + unitPath: resolveSystemdUserRuntimeHostUpdateServicePath(serviceId, env, homeDir), + runSystemctl, + }, + timer: { + unitName: resolveSystemdUserRuntimeHostUpdateTimerName(serviceId), + unitPath: resolveSystemdUserRuntimeHostUpdateTimerPath(serviceId, env, homeDir), + runSystemctl, + }, + }; +} + +async function captureSystemdUpdateScheduler( + context: SystemdUpdateSchedulerContext, +): Promise { + const [serviceUnit, timerUnit, timerStatus] = await Promise.all([ + readOptionalFile(context.service.unitPath), + readOptionalFile(context.timer.unitPath), + readSystemdStatus(context.timer), + ]); + return { serviceUnit, timerUnit, timerStatus }; +} + +async function applySystemdUpdateScheduler( + context: SystemdUpdateSchedulerContext, + config: RuntimeHostManagedServiceConfig, +): Promise { + if (!runtimeHostUpdateReconcileLaunchArguments(config)) { + await removeSystemdUpdateScheduler(context); + return; + } + try { + await verifySystemdUpdateScheduler(context, config); + return; + } catch (error) { + if (!isTargetMismatch(error)) throw error; + } + await Promise.all([ + writeRuntimeHostServiceFile( + context.service.unitPath, + renderSystemdUpdateService(config), + 0o600, + ), + writeRuntimeHostServiceFile( + context.timer.unitPath, + renderSystemdUpdateTimer(context.serviceId), + 0o600, + ), + ]); + await requireSystemctl(context.timer.runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); + await requireSystemctl( + context.timer.runSystemctl, + ['enable', context.timer.unitName], + 'Enabling Runtime Host update reconciliation failed', + ); + await context.timer.runSystemctl(['reset-failed', context.service.unitName]); + await context.timer.runSystemctl(['reset-failed', context.timer.unitName]); + await requireSystemctl( + context.timer.runSystemctl, + ['restart', context.timer.unitName], + 'Scheduling Runtime Host update reconciliation failed', + ); +} + +async function verifySystemdUpdateScheduler( + context: SystemdUpdateSchedulerContext, + config: RuntimeHostManagedServiceConfig, +): Promise { + const args = runtimeHostUpdateReconcileLaunchArguments(config); + const [serviceUnit, timerUnit, serviceStatus, timerStatus] = await Promise.all([ + readOptionalFile(context.service.unitPath), + readOptionalFile(context.timer.unitPath), + readSystemdStatus(context.service), + readSystemdStatus(context.timer), + ]); + if (!args) { + if ( + serviceUnit !== null || + timerUnit !== null || + serviceStatus.loadState !== 'not-found' || + timerStatus.loadState !== 'not-found' + ) { + throw schedulerMismatch(); + } + return; + } + if ( + serviceUnit !== renderSystemdUpdateService(config) || + timerUnit !== renderSystemdUpdateTimer(context.serviceId) || + !isLoadedManagedSystemdUnit(serviceStatus, context.service.unitPath) || + !isLoadedManagedSystemdUnit(timerStatus, context.timer.unitPath) || + (timerStatus.unitFileState !== 'enabled' && timerStatus.unitFileState !== 'enabled-runtime') || + timerStatus.activeState !== 'active' + ) { + throw schedulerMismatch(); + } +} + +async function removeSystemdUpdateScheduler(context: SystemdUpdateSchedulerContext): Promise { + const [serviceStatus, timerStatus] = await Promise.all([ + readSystemdStatus(context.service), + readSystemdStatus(context.timer), + ]); + if (timerStatus.activeState === 'active') { + await requireSystemctl( + context.timer.runSystemctl, + ['stop', context.timer.unitName], + 'Stopping Runtime Host update scheduling failed', + ); + } + if ( + timerStatus.loadState !== 'not-found' || + timerStatus.unitFileState === 'enabled' || + timerStatus.unitFileState === 'enabled-runtime' + ) { + await requireSystemctl( + context.timer.runSystemctl, + ['disable', context.timer.unitName], + 'Disabling Runtime Host update scheduling failed', + ); + } + if (serviceStatus.activeState === 'active') { + await requireSystemctl( + context.service.runSystemctl, + ['stop', context.service.unitName], + 'Stopping Runtime Host update reconciliation failed', + ); + } + await Promise.all([ + removeRuntimeHostServiceFile(context.service.unitPath, 'systemd update service'), + removeRuntimeHostServiceFile(context.timer.unitPath, 'systemd update timer'), + ]); + await requireSystemctl(context.timer.runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); + await context.timer.runSystemctl(['reset-failed', context.service.unitName]); + await context.timer.runSystemctl(['reset-failed', context.timer.unitName]); +} + +async function restoreSystemdUpdateScheduler( + snapshot: SystemdUpdateSchedulerSnapshot, + context: SystemdUpdateSchedulerContext, +): Promise { + await removeSystemdUpdateScheduler(context); + if (snapshot.serviceUnit === null && snapshot.timerUnit === null) return; + await Promise.all([ + snapshot.serviceUnit === null + ? removeRuntimeHostServiceFile(context.service.unitPath, 'systemd update service') + : writeRuntimeHostServiceFile(context.service.unitPath, snapshot.serviceUnit, 0o600), + snapshot.timerUnit === null + ? removeRuntimeHostServiceFile(context.timer.unitPath, 'systemd update timer') + : writeRuntimeHostServiceFile(context.timer.unitPath, snapshot.timerUnit, 0o600), + ]); + await requireSystemctl(context.timer.runSystemctl, ['daemon-reload'], 'Reloading systemd failed'); + if (snapshot.timerUnit === null) return; + await requireSystemctl( + context.timer.runSystemctl, + [ + snapshot.timerStatus.unitFileState === 'enabled' || + snapshot.timerStatus.unitFileState === 'enabled-runtime' + ? 'enable' + : 'disable', + context.timer.unitName, + ], + 'Restoring Runtime Host update scheduling failed', + ); + await requireSystemctl( + context.timer.runSystemctl, + [snapshot.timerStatus.activeState === 'active' ? 'restart' : 'stop', context.timer.unitName], + 'Restoring Runtime Host update scheduler state failed', + ); +} + +function isLoadedManagedSystemdUnit(status: SystemdStatus, path: string): boolean { + return ( + status.loadState === 'loaded' && + status.fragmentPath === path && + status.needDaemonReload === 'no' && + !status.dropInPaths?.trim() + ); +} + +function schedulerMismatch(): RuntimeHostServiceManagerError { + return new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The Runtime Host update scheduler does not match its managed deployment', + ); +} + +function isTargetMismatch(error: unknown): boolean { + return error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch'; +} + +async function readOptionalFile(path: string): Promise { + return readFile(path, 'utf8').catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return null; + throw error; + }); +} + async function captureSystemdDeployment( unitPath: string, readStatus: () => Promise, @@ -295,14 +617,16 @@ async function applySystemdDeployment( async function restoreFailedSystemdDeployment( snapshot: SystemdDeploymentSnapshot, + schedulerSnapshot: SystemdUpdateSchedulerSnapshot | undefined, context: SystemdUnitContext, + schedulerContext: SystemdUpdateSchedulerContext, originalError: unknown, recoveryFailureCode: | 'service_manager_operation_failed' | 'update_incomplete' = 'service_manager_operation_failed', ): Promise { try { - await restoreSystemdDeployment(snapshot, context); + await restoreSystemdManagedDeployment(snapshot, schedulerSnapshot, context, schedulerContext); } catch (rollbackError) { throw new RuntimeHostServiceManagerError( recoveryFailureCode, @@ -313,6 +637,30 @@ async function restoreFailedSystemdDeployment( throw originalError; } +async function restoreSystemdManagedDeployment( + snapshot: SystemdDeploymentSnapshot, + schedulerSnapshot: SystemdUpdateSchedulerSnapshot | undefined, + context: SystemdUnitContext, + schedulerContext: SystemdUpdateSchedulerContext, +): Promise { + const errors: unknown[] = []; + if (schedulerSnapshot) { + try { + await restoreSystemdUpdateScheduler(schedulerSnapshot, schedulerContext); + } catch (error) { + errors.push(error); + } + } + try { + await restoreSystemdDeployment(snapshot, context); + } catch (error) { + errors.push(error); + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Unable to restore the previous systemd deployment'); + } +} + async function restoreSystemdDeployment( snapshot: SystemdDeploymentSnapshot, context: SystemdUnitContext, @@ -557,3 +905,7 @@ function actionPresentParticiple(action: 'start' | 'stop' | 'restart'): string { if (action === 'stop') return 'Stopping'; return 'Restarting'; } + +function assertServiceId(serviceId: string): void { + if (!/^[0-9a-f]{64}$/u.test(serviceId)) throw new TypeError('Invalid Runtime Host service ID'); +} From c303ea7994afcf64f374f46bebb9628c77c3dbf2 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 12:06:43 +0800 Subject: [PATCH 2/5] fix(runtime-host): preserve scheduler lifecycle during updates Keep scheduled reconciliation alive while it replaces or recovers the Runtime Host process, while user lifecycle actions continue to control the whole managed deployment. Explicit repair now owns scheduler drift and fails closed on systemd drop-in overrides. Generated-by: Codex --- .../runtime-host-launch-agent-service.test.ts | 42 +++-- .../runtime-host-service-manager.test.ts | 40 ++++- .../src/__tests__/runtime-host-setup.test.ts | 1 + ...runtime-host-update-reconciliation.test.ts | 1 + .../src/runtime-host-launch-agent-service.ts | 149 ++++++++++------- .../cli/src/runtime-host-service-manager.ts | 10 +- .../cli/src/runtime-host-systemd-service.ts | 150 +++++++++++++----- 7 files changed, 272 insertions(+), 121 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts index dc657e9fc0..5c9617ded8 100644 --- a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts +++ b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts @@ -95,13 +95,20 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy const updatePath = resolveLaunchAgentUpdatePath(SERVICE_ID, homeDir); assert.match(await readFile(updatePath, 'utf8'), /reconcile-update/u); - await backend.replace(config); - assert.equal( + const updateBootouts = () => launchctl.calls.filter( ([command, target]) => command === 'bootout' && target === UPDATE_TARGET, - ).length, - 0, - ); + ).length; + const bootoutsBeforeReplace = updateBootouts(); + await backend.replace(config); + assert.equal(updateBootouts(), bootoutsBeforeReplace); + + launchctl.updateRunning = true; + launchctl.failNextBootstrap = true; + await assert.rejects(backend.replace(config), /Starting the Runtime Host LaunchAgent failed/u); + assert.equal(updateBootouts(), bootoutsBeforeReplace); + assert.equal(launchctl.updateRunning, true); + launchctl.updateRunning = false; await writeFile(updatePath, 'stale\n', { mode: 0o600 }); await assert.rejects( @@ -109,9 +116,14 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy (error: unknown) => error instanceof Error && 'code' in error && error.code === 'target_mismatch', ); - await backend.replace(config); + await backend.install(config); await backend.verifyDeployment(config); + await backend.stop(); + assert.equal(launchctl.updateLoaded, false); + await backend.start(); + assert.equal(launchctl.updateLoaded, true); + await backend.uninstall(); assert.equal(await fileExists(updatePath), false); }); @@ -207,6 +219,8 @@ test('restores the previous loaded LaunchAgent when deployment bootstrap fails', interface FakeLaunchctl { loaded: boolean; running: boolean; + updateLoaded: boolean; + updateRunning: boolean; failNextBootstrap: boolean; readonly calls: string[][]; readonly run: (args: readonly string[]) => Promise<{ @@ -218,11 +232,11 @@ interface FakeLaunchctl { function createFakeLaunchctl(): FakeLaunchctl { let pid = 4100; - let updateLoaded = false; - let updateRunning = false; const fake: FakeLaunchctl = { loaded: false, running: false, + updateLoaded: false, + updateRunning: false, failNextBootstrap: false, calls: [], run: async (args) => { @@ -232,8 +246,8 @@ function createFakeLaunchctl(): FakeLaunchctl { } if (args[0] === 'print' && (args[1] === TARGET || args[1] === UPDATE_TARGET)) { const update = args[1] === UPDATE_TARGET; - const loaded = update ? updateLoaded : fake.loaded; - const running = update ? updateRunning : fake.running; + const loaded = update ? fake.updateLoaded : fake.loaded; + const running = update ? fake.updateRunning : fake.running; return loaded ? { exitCode: 0, @@ -251,8 +265,8 @@ function createFakeLaunchctl(): FakeLaunchctl { } const update = args[2]?.endsWith('.update.plist') ?? false; if (update) { - updateLoaded = true; - updateRunning = false; + fake.updateLoaded = true; + fake.updateRunning = false; } else { fake.loaded = true; fake.running = true; @@ -262,8 +276,8 @@ function createFakeLaunchctl(): FakeLaunchctl { } if (args[0] === 'bootout') { if (args[1] === UPDATE_TARGET) { - updateRunning = false; - updateLoaded = false; + fake.updateRunning = false; + fake.updateLoaded = false; } else { fake.running = false; fake.loaded = false; diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index fbeac7d7bd..fa07b39b4d 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -363,16 +363,34 @@ describe('managed Runtime Host service', () => { ); const managedConfig = reinstalled.service.config; assert.ok(managedConfig); - await writeFile(updateTimerPath, '[Timer]\n# stale\n', 'utf8'); const repairBackend = backend(); + const updateTimerName = basename(updateTimerPath); + systemd.setUnitDropInPaths(updateTimerName, ['/tmp/update-timer-override.conf']); + await assert.rejects(repairBackend.install(managedConfig), /systemd drop-in overrides/u); + systemd.setUnitDropInPaths(updateTimerName, []); + await writeFile(updateTimerPath, '[Timer]\n# stale\n', 'utf8'); await assert.rejects( repairBackend.verifyDeployment(managedConfig), (error: unknown) => error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch', ); - await repairBackend.replace(managedConfig); + await repairBackend.install(managedConfig); await repairBackend.verifyDeployment(managedConfig); + const updateServiceName = basename(updateServicePath); + await systemd.run(['start', updateServiceName]); + await repairBackend.stop(); + assert.ok( + systemd.calls.some(([command, target]) => command === 'stop' && target === updateTimerName), + ); + assert.ok( + systemd.calls.some(([command, target]) => command === 'stop' && target === updateServiceName), + ); + await repairBackend.start(); + assert.ok( + systemd.calls.some(([command, target]) => command === 'start' && target === updateTimerName), + ); + const root = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); await writeFile(configPath, '{not-json', 'utf8'); const repaired = await manageRuntimeHostService( @@ -1052,7 +1070,7 @@ describe('managed Runtime Host service', () => { pid: serviceState === 'running' ? 42 : serviceState === 'starting' ? startingPid : null, lastExitCode: 0, }), - stop: async () => { + retire: async () => { stops += 1; if (serviceState === 'starting' && startingPid === null) { const contender = await tryAcquireInteractiveRootOwner(root); @@ -1219,7 +1237,7 @@ describe('managed Runtime Host service', () => { pid: serviceState === 'running' ? servicePid : null, lastExitCode: 0, }), - stop: async () => { + retire: async () => { stops += 1; serviceState = 'stopped'; servicePid = null; @@ -1374,7 +1392,7 @@ describe('managed Runtime Host service', () => { if (replaceFails) throw new Error('replacement was not committed'); state = 'running'; }, - stop: async () => { + retire: async () => { if (stopFails) throw new Error('replacement could not be stopped'); state = 'stopped'; }, @@ -2014,6 +2032,7 @@ describe('managed Runtime Host service', () => { function createFakeSystemd(unitPath: string): { readonly failNext: (command: string) => void; readonly setDropInPaths: (paths: readonly string[]) => void; + readonly setUnitDropInPaths: (unitName: string, paths: readonly string[]) => void; readonly calls: readonly (readonly string[])[]; readonly run: (args: readonly string[]) => Promise<{ exitCode: number; @@ -2023,7 +2042,7 @@ function createFakeSystemd(unitPath: string): { } { const states = new Map(); let failureCommand: string | undefined; - let dropInPaths: readonly string[] = []; + const dropInPaths = new Map(); const calls: string[][] = []; return { calls, @@ -2031,7 +2050,10 @@ function createFakeSystemd(unitPath: string): { failureCommand = command; }, setDropInPaths: (paths) => { - dropInPaths = paths; + dropInPaths.set(basename(unitPath), paths); + }, + setUnitDropInPaths: (unitName, paths) => { + dropInPaths.set(unitName, paths); }, run: async (args) => { calls.push([...args]); @@ -2084,7 +2106,7 @@ function createFakeSystemd(unitPath: string): { `UnitFileState=${unitState.enabled ? 'enabled' : 'disabled'}`, `FragmentPath=${loaded ? path : ''}`, 'NeedDaemonReload=no', - `DropInPaths=${isMainService ? dropInPaths.join(' ') : ''}`, + `DropInPaths=${dropInPaths.get(unitName)?.join(' ') ?? ''}`, `MainPID=${unitState.active && isMainService ? '4242' : '0'}`, 'ExecMainStatus=0', '', @@ -2110,6 +2132,7 @@ function createUnusedBackend(): RuntimeHostServiceBackend { start: unexpected, stop: unexpected, restart: unexpected, + retire: unexpected, logs: unexpected, uninstall: unexpected, }; @@ -2141,6 +2164,7 @@ function createReadyBackend(): RuntimeHostServiceBackend { start: async () => undefined, stop: async () => undefined, restart: async () => undefined, + retire: async () => undefined, logs: async () => '', uninstall: async () => undefined, }; diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index f68ec7f934..d77d9663c4 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -604,6 +604,7 @@ function unusedBackend(): RuntimeHostServiceBackend { start: async () => assert.fail('Backend is not expected'), stop: async () => assert.fail('Backend is not expected'), restart: async () => assert.fail('Backend is not expected'), + retire: async () => assert.fail('Backend is not expected'), logs: async () => assert.fail('Backend is not expected'), uninstall: async () => assert.fail('Backend is not expected'), }; diff --git a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts index 5f668a0a3b..b0a091dcf3 100644 --- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -416,6 +416,7 @@ function unusedBackend() { start: async () => undefined, stop: async () => undefined, restart: async () => undefined, + retire: async () => undefined, logs: async () => '', uninstall: async () => undefined, }; diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts index 02654f62a2..55e7f7373d 100644 --- a/packages/cli/src/runtime-host-launch-agent-service.ts +++ b/packages/cli/src/runtime-host-launch-agent-service.ts @@ -121,13 +121,18 @@ export function createLaunchAgentRuntimeHostService( captureLaunchAgentDeployment(context), managesScheduler ? captureLaunchAgentDeployment(scheduler) : undefined, ]); + let schedulerMutationStarted = false; try { await applyLaunchAgentDeployment(context, config); - if (managesScheduler) await applyLaunchAgentUpdateScheduler(scheduler, config); + if (managesScheduler) { + await applyLaunchAgentUpdateScheduler(scheduler, config, () => { + schedulerMutationStarted = true; + }); + } } catch (error) { await restoreFailedLaunchAgentDeployment( previous, - previousScheduler, + schedulerMutationStarted ? previousScheduler : undefined, context, scheduler, error, @@ -140,7 +145,7 @@ export function createLaunchAgentRuntimeHostService( rolledBack = true; await restoreLaunchAgentManagedDeployment( previous, - previousScheduler, + schedulerMutationStarted ? previousScheduler : undefined, context, scheduler, ); @@ -149,18 +154,14 @@ export function createLaunchAgentRuntimeHostService( }, replace: async (config) => { await validateRuntimeHostServiceLaunch(config); - const managesScheduler = runtimeHostUpdateReconcileLaunchArguments(config) !== null; - const [previous, previousScheduler] = await Promise.all([ - captureLaunchAgentDeployment(context), - managesScheduler ? captureLaunchAgentDeployment(scheduler) : undefined, - ]); + const previous = await captureLaunchAgentDeployment(context); try { + // The stable scheduler may be the caller; only explicit install/repair may reload it. await applyLaunchAgentDeployment(context, config); - if (managesScheduler) await applyLaunchAgentUpdateScheduler(scheduler, config); } catch (error) { await restoreFailedLaunchAgentDeployment( previous, - previousScheduler, + undefined, context, scheduler, error, @@ -189,44 +190,15 @@ export function createLaunchAgentRuntimeHostService( }, status: readStatus, start: async () => { - const status = await readDetailedStatus(); - if (!status.installed) { - throw new RuntimeHostServiceManagerError( - 'not_installed', - 'Runtime Host LaunchAgent is not installed', - ); - } - if (status.loaded) { - if (!status.active) { - await requireLaunchctl( - context, - ['kickstart', context.serviceTarget], - 'Starting the Runtime Host LaunchAgent failed', - ); - } - return; - } - await bootstrapLaunchAgent(context); + await startLaunchAgent(context); + await ensureLaunchAgentLoadedIfInstalled(scheduler); }, - stop: () => bootoutLaunchAgent(context), + stop: () => stopLaunchAgentManagedDeployment(context, scheduler), restart: async () => { - const status = await readDetailedStatus(); - if (!status.installed) { - throw new RuntimeHostServiceManagerError( - 'not_installed', - 'Runtime Host LaunchAgent is not installed', - ); - } - if (!status.loaded) { - await bootstrapLaunchAgent(context); - return; - } - await requireLaunchctl( - context, - ['kickstart', '-k', context.serviceTarget], - 'Restarting the Runtime Host LaunchAgent failed', - ); + await restartLaunchAgent(context); + await ensureLaunchAgentLoadedIfInstalled(scheduler); }, + retire: () => bootoutLaunchAgent(context), logs: async () => { const [stdout, stderr, updateStdout, updateStderr] = await Promise.all([ readLogTail(context.stdoutPath), @@ -423,17 +395,15 @@ async function captureLaunchAgentDeployment( async function applyLaunchAgentUpdateScheduler( context: LaunchAgentContext, config: RuntimeHostManagedServiceConfig, + onMutation: () => void, ): Promise { - if (!runtimeHostUpdateReconcileLaunchArguments(config)) { - await removeLaunchAgentUpdateScheduler(context); - return; - } try { await verifyLaunchAgentUpdateScheduler(context, config); return; } catch (error) { if (!isTargetMismatch(error)) throw error; } + onMutation(); await bootoutLaunchAgent(context); await prepareLaunchAgentLogs(context); await writeRuntimeHostServiceFile( @@ -448,7 +418,6 @@ async function verifyLaunchAgentUpdateScheduler( context: LaunchAgentContext, config: RuntimeHostManagedServiceConfig, ): Promise { - const args = runtimeHostUpdateReconcileLaunchArguments(config); const [status, plist] = await Promise.all([ readLaunchAgentStatus(context), readFile(context.plistPath, 'utf8').catch((error: unknown) => { @@ -456,10 +425,6 @@ async function verifyLaunchAgentUpdateScheduler( throw error; }), ]); - if (!args) { - if (status.installed || status.loaded || plist !== null) throw launchAgentSchedulerMismatch(); - return; - } if (!status.loaded || plist !== renderLaunchAgentUpdatePlist(config, context)) { throw launchAgentSchedulerMismatch(); } @@ -499,6 +464,72 @@ async function applyLaunchAgentDeployment( await bootstrapLaunchAgent(context); } +async function startLaunchAgent(context: LaunchAgentContext): Promise { + const status = await readLaunchAgentStatus(context); + if (!status.installed) { + throw new RuntimeHostServiceManagerError( + 'not_installed', + 'Runtime Host LaunchAgent is not installed', + ); + } + if (!status.loaded) { + await bootstrapLaunchAgent(context); + return; + } + if (!status.active) { + await requireLaunchctl( + context, + ['kickstart', context.serviceTarget], + 'Starting the Runtime Host LaunchAgent failed', + ); + } +} + +async function restartLaunchAgent(context: LaunchAgentContext): Promise { + const status = await readLaunchAgentStatus(context); + if (!status.installed) { + throw new RuntimeHostServiceManagerError( + 'not_installed', + 'Runtime Host LaunchAgent is not installed', + ); + } + if (!status.loaded) { + await bootstrapLaunchAgent(context); + return; + } + await requireLaunchctl( + context, + ['kickstart', '-k', context.serviceTarget], + 'Restarting the Runtime Host LaunchAgent failed', + ); +} + +async function ensureLaunchAgentLoadedIfInstalled(context: LaunchAgentContext): Promise { + const status = await readLaunchAgentStatus(context); + if (status.installed && !status.loaded) await bootstrapLaunchAgent(context); +} + +async function stopLaunchAgentManagedDeployment( + context: LaunchAgentContext, + scheduler: LaunchAgentContext, +): Promise { + const errors: unknown[] = []; + for (const target of [scheduler, context]) { + try { + await bootoutLaunchAgent(target); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + 'Unable to stop the Runtime Host managed deployment', + { cause: new AggregateError(errors) }, + ); + } +} + async function restoreFailedLaunchAgentDeployment( snapshot: LaunchAgentDeploymentSnapshot, schedulerSnapshot: LaunchAgentDeploymentSnapshot | undefined, @@ -533,6 +564,11 @@ async function restoreLaunchAgentManagedDeployment( schedulerContext: LaunchAgentContext, ): Promise { const errors: unknown[] = []; + try { + await restoreLaunchAgentDeployment(snapshot, context); + } catch (error) { + errors.push(error); + } if (schedulerSnapshot) { try { await restoreLaunchAgentDeployment(schedulerSnapshot, schedulerContext); @@ -540,11 +576,6 @@ async function restoreLaunchAgentManagedDeployment( errors.push(error); } } - try { - await restoreLaunchAgentDeployment(snapshot, context); - } catch (error) { - errors.push(error); - } if (errors.length > 0) { throw new AggregateError(errors, 'Unable to restore the previous LaunchAgent deployment'); } diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 48507c07ed..8edc76e409 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -107,6 +107,8 @@ export interface RuntimeHostServiceBackend { start(): Promise; stop(): Promise; restart(): Promise; + /** Stop only the Runtime Host process while preserving deployment scheduling. */ + retire(): Promise; logs(): Promise; uninstall(): Promise; } @@ -501,7 +503,7 @@ async function manageRuntimeHostServiceLocked( rootFence = await acquireRuntimeHostRootRetirementFence(root); } try { - await backend.stop(); + await backend.retire(); const stopped = await readServiceStatus(configPath, backend); if (stopped.active || stopped.state !== 'stopped' || stopped.pid !== null) { throw new RuntimeHostServiceManagerError( @@ -588,7 +590,7 @@ async function replaceRuntimeHostManagedServiceLocked( await backend.replace(config); } catch (error) { if (error instanceof RuntimeHostServiceManagerError && error.code === 'update_incomplete') { - await backend.stop().catch(() => undefined); + await backend.retire().catch(() => undefined); throw error; } try { @@ -598,7 +600,7 @@ async function replaceRuntimeHostManagedServiceLocked( 0o600, ); } catch (restoreError) { - await backend.stop().catch(() => undefined); + await backend.retire().catch(() => undefined); throw new RuntimeHostServiceManagerError( 'update_incomplete', 'Replacing the Runtime Host service failed and its previous configuration could not be restored', @@ -615,7 +617,7 @@ async function replaceRuntimeHostManagedServiceLocked( await deps.waitForReady(config, backend); } catch (error) { try { - await backend.stop(); + await backend.retire(); } catch (stopError) { throw new RuntimeHostServiceManagerError( 'update_incomplete', diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts index 3a7e8dd4b5..2bc209d5f9 100644 --- a/packages/cli/src/runtime-host-systemd-service.ts +++ b/packages/cli/src/runtime-host-systemd-service.ts @@ -114,13 +114,19 @@ export function createSystemdUserRuntimeHostService( captureSystemdDeployment(context.unitPath, readStatus), managesScheduler ? captureSystemdUpdateScheduler(scheduler) : undefined, ]); + if (managesScheduler) await assertNoSystemdUpdateSchedulerDropIns(scheduler); + let schedulerMutationStarted = false; try { await applySystemdDeployment(context, config); - if (managesScheduler) await applySystemdUpdateScheduler(scheduler, config); + if (managesScheduler) { + await applySystemdUpdateScheduler(scheduler, config, () => { + schedulerMutationStarted = true; + }); + } } catch (error) { await restoreFailedSystemdDeployment( previous, - previousScheduler, + schedulerMutationStarted ? previousScheduler : undefined, context, scheduler, error, @@ -131,24 +137,25 @@ export function createSystemdUserRuntimeHostService( rollback: async () => { if (rolledBack) return; rolledBack = true; - await restoreSystemdManagedDeployment(previous, previousScheduler, context, scheduler); + await restoreSystemdManagedDeployment( + previous, + schedulerMutationStarted ? previousScheduler : undefined, + context, + scheduler, + ); }, } satisfies RuntimeHostServiceDeployment; }, replace: async (config) => { await validateRuntimeHostServiceLaunch(config); - const managesScheduler = runtimeHostUpdateReconcileLaunchArguments(config) !== null; - const [previous, previousScheduler] = await Promise.all([ - captureSystemdDeployment(context.unitPath, readStatus), - managesScheduler ? captureSystemdUpdateScheduler(scheduler) : undefined, - ]); + const previous = await captureSystemdDeployment(context.unitPath, readStatus); try { + // The stable scheduler may be the caller; only explicit install/repair may reload it. await applySystemdDeployment(context, config); - if (managesScheduler) await applySystemdUpdateScheduler(scheduler, config); } catch (error) { await restoreFailedSystemdDeployment( previous, - previousScheduler, + undefined, context, scheduler, error, @@ -182,9 +189,16 @@ export function createSystemdUserRuntimeHostService( } }, status: readStatus, - start: () => runLifecycleAction(context, 'start'), - stop: () => runLifecycleAction(context, 'stop'), - restart: () => runLifecycleAction(context, 'restart'), + start: async () => { + await runLifecycleAction(context, 'start'); + await ensureSystemdUpdateSchedulerStartedIfInstalled(scheduler); + }, + stop: () => stopSystemdManagedDeployment(context, scheduler), + restart: async () => { + await runLifecycleAction(context, 'restart'); + await ensureSystemdUpdateSchedulerStartedIfInstalled(scheduler); + }, + retire: () => runLifecycleAction(context, 'stop'), logs: async () => { const result = await runJournalctl([ '--user-unit', @@ -410,17 +424,15 @@ async function captureSystemdUpdateScheduler( async function applySystemdUpdateScheduler( context: SystemdUpdateSchedulerContext, config: RuntimeHostManagedServiceConfig, + onMutation: () => void, ): Promise { - if (!runtimeHostUpdateReconcileLaunchArguments(config)) { - await removeSystemdUpdateScheduler(context); - return; - } try { await verifySystemdUpdateScheduler(context, config); return; } catch (error) { if (!isTargetMismatch(error)) throw error; } + onMutation(); await Promise.all([ writeRuntimeHostServiceFile( context.service.unitPath, @@ -446,30 +458,34 @@ async function applySystemdUpdateScheduler( ['restart', context.timer.unitName], 'Scheduling Runtime Host update reconciliation failed', ); + await verifySystemdUpdateScheduler(context, config); +} + +async function assertNoSystemdUpdateSchedulerDropIns( + context: SystemdUpdateSchedulerContext, +): Promise { + const [serviceStatus, timerStatus] = await Promise.all([ + readSystemdStatus(context.service), + readSystemdStatus(context.timer), + ]); + if (serviceStatus.dropInPaths?.trim() || timerStatus.dropInPaths?.trim()) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The Runtime Host update scheduler has systemd drop-in overrides; remove them before repairing the managed deployment', + ); + } } async function verifySystemdUpdateScheduler( context: SystemdUpdateSchedulerContext, config: RuntimeHostManagedServiceConfig, ): Promise { - const args = runtimeHostUpdateReconcileLaunchArguments(config); const [serviceUnit, timerUnit, serviceStatus, timerStatus] = await Promise.all([ readOptionalFile(context.service.unitPath), readOptionalFile(context.timer.unitPath), readSystemdStatus(context.service), readSystemdStatus(context.timer), ]); - if (!args) { - if ( - serviceUnit !== null || - timerUnit !== null || - serviceStatus.loadState !== 'not-found' || - timerStatus.loadState !== 'not-found' - ) { - throw schedulerMismatch(); - } - return; - } if ( serviceUnit !== renderSystemdUpdateService(config) || timerUnit !== renderSystemdUpdateTimer(context.serviceId) || @@ -482,12 +498,70 @@ async function verifySystemdUpdateScheduler( } } +async function ensureSystemdUpdateSchedulerStartedIfInstalled( + context: SystemdUpdateSchedulerContext, +): Promise { + const status = await readSystemdStatus(context.timer); + if (status.loadState !== 'not-found' && !isSystemdUnitRunning(status)) { + await requireSystemctl( + context.timer.runSystemctl, + ['start', context.timer.unitName], + 'Starting Runtime Host update scheduling failed', + ); + } +} + +async function stopSystemdManagedDeployment( + service: SystemdUnitContext, + scheduler: SystemdUpdateSchedulerContext, +): Promise { + const errors: unknown[] = []; + const [serviceStatus, timerStatus] = await Promise.all([ + readSystemdStatus(scheduler.service), + readSystemdStatus(scheduler.timer), + ]); + if (isSystemdUnitRunning(timerStatus)) { + try { + await requireSystemctl( + scheduler.timer.runSystemctl, + ['stop', scheduler.timer.unitName], + 'Stopping Runtime Host update scheduling failed', + ); + } catch (error) { + errors.push(error); + } + } + if (isSystemdUnitRunning(serviceStatus)) { + try { + await requireSystemctl( + scheduler.service.runSystemctl, + ['stop', scheduler.service.unitName], + 'Stopping Runtime Host update reconciliation failed', + ); + } catch (error) { + errors.push(error); + } + } + try { + await runLifecycleAction(service, 'stop'); + } catch (error) { + errors.push(error); + } + if (errors.length > 0) { + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + 'Unable to stop the Runtime Host managed deployment', + { cause: new AggregateError(errors) }, + ); + } +} + async function removeSystemdUpdateScheduler(context: SystemdUpdateSchedulerContext): Promise { const [serviceStatus, timerStatus] = await Promise.all([ readSystemdStatus(context.service), readSystemdStatus(context.timer), ]); - if (timerStatus.activeState === 'active') { + if (isSystemdUnitRunning(timerStatus)) { await requireSystemctl( context.timer.runSystemctl, ['stop', context.timer.unitName], @@ -505,7 +579,7 @@ async function removeSystemdUpdateScheduler(context: SystemdUpdateSchedulerConte 'Disabling Runtime Host update scheduling failed', ); } - if (serviceStatus.activeState === 'active') { + if (isSystemdUnitRunning(serviceStatus)) { await requireSystemctl( context.service.runSystemctl, ['stop', context.service.unitName], @@ -564,6 +638,10 @@ function isLoadedManagedSystemdUnit(status: SystemdStatus, path: string): boolea ); } +function isSystemdUnitRunning(status: SystemdStatus): boolean { + return status.activeState !== 'inactive' && status.activeState !== 'failed'; +} + function schedulerMismatch(): RuntimeHostServiceManagerError { return new RuntimeHostServiceManagerError( 'target_mismatch', @@ -644,6 +722,11 @@ async function restoreSystemdManagedDeployment( schedulerContext: SystemdUpdateSchedulerContext, ): Promise { const errors: unknown[] = []; + try { + await restoreSystemdDeployment(snapshot, context); + } catch (error) { + errors.push(error); + } if (schedulerSnapshot) { try { await restoreSystemdUpdateScheduler(schedulerSnapshot, schedulerContext); @@ -651,11 +734,6 @@ async function restoreSystemdManagedDeployment( errors.push(error); } } - try { - await restoreSystemdDeployment(snapshot, context); - } catch (error) { - errors.push(error); - } if (errors.length > 0) { throw new AggregateError(errors, 'Unable to restore the previous systemd deployment'); } From 21a8d36a06784ea06ff271d31e489bf430caad7d Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 12:36:00 +0800 Subject: [PATCH 3/5] fix(runtime-host): make scheduler lifecycle atomic Treat scheduler presence as derived desired state and separate definition validation from runtime state. Quiesce systemd timer and worker together, and compensate failed deployment starts before reporting failure. Generated-by: Codex --- .../runtime-host-launch-agent-service.test.ts | 7 + .../runtime-host-service-manager.test.ts | 116 +++++++++++++- .../src/__tests__/runtime-host-setup.test.ts | 1 + ...runtime-host-update-reconciliation.test.ts | 1 + .../src/runtime-host-launch-agent-service.ts | 63 ++++++-- .../cli/src/runtime-host-service-manager.ts | 17 +- .../cli/src/runtime-host-systemd-service.ts | 147 +++++++++++------- .../cli/src/runtime-host-update-command.ts | 1 + 8 files changed, 272 insertions(+), 81 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts index 5c9617ded8..fd6e1a6165 100644 --- a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts +++ b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts @@ -121,9 +121,16 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy await backend.stop(); assert.equal(launchctl.updateLoaded, false); + await backend.verifyDeployment(config); await backend.start(); assert.equal(launchctl.updateLoaded, true); + const { managedDeploymentRoot: _managedDeploymentRoot, ...unmanagedConfig } = config; + await backend.install(unmanagedConfig); + await backend.verifyDeployment(unmanagedConfig); + assert.equal(await fileExists(updatePath), false); + assert.equal(launchctl.updateLoaded, false); + await backend.uninstall(); assert.equal(await fileExists(updatePath), false); }); diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index fa07b39b4d..7a456f2d41 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -378,19 +378,28 @@ describe('managed Runtime Host service', () => { await repairBackend.verifyDeployment(managedConfig); const updateServiceName = basename(updateServicePath); - await systemd.run(['start', updateServiceName]); + systemd.activateUnitWhenStopping(updateTimerName, updateServiceName); await repairBackend.stop(); assert.ok( - systemd.calls.some(([command, target]) => command === 'stop' && target === updateTimerName), - ); - assert.ok( - systemd.calls.some(([command, target]) => command === 'stop' && target === updateServiceName), + systemd.calls.some( + ([command, ...targets]) => + command === 'stop' && + targets.includes(updateTimerName) && + targets.includes(updateServiceName), + ), ); + await repairBackend.verifyDeployment(managedConfig); await repairBackend.start(); assert.ok( systemd.calls.some(([command, target]) => command === 'start' && target === updateTimerName), ); + const { managedDeploymentRoot: _managedDeploymentRoot, ...unmanagedConfig } = managedConfig; + await repairBackend.install(unmanagedConfig); + await repairBackend.verifyDeployment(unmanagedConfig); + await assert.rejects(readFile(updateServicePath, 'utf8'), { code: 'ENOENT' }); + await assert.rejects(readFile(updateTimerPath, 'utf8'), { code: 'ENOENT' }); + const root = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); await writeFile(configPath, '{not-json', 'utf8'); const repaired = await manageRuntimeHostService( @@ -1047,6 +1056,53 @@ describe('managed Runtime Host service', () => { assert.equal(repaired.service.config?.rootPath, root.canonicalPath); }); + it('stops partial deployment state when start or restart fails', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-service-start-failure-')); + t.after(() => rm(base, { recursive: true, force: true })); + const cliPath = join(base, 'cli.js'); + await writeFile(cliPath, '#!/usr/bin/env node\n', 'utf8'); + let stops = 0; + let stopFails = false; + const failedAction = async () => { + throw new Error('scheduler start failed'); + }; + const backend: RuntimeHostServiceBackend = { + ...createReadyBackend(), + start: failedAction, + restart: failedAction, + stop: async () => { + stops += 1; + if (stopFails) throw new Error('partial deployment stop failed'); + }, + }; + const common = { + clientDataRoot: join(base, 'config'), + defaultRootPath: join(base, 'state'), + nodePath: process.execPath, + cliPath, + } as const; + await manageRuntimeHostService({ ...common, action: 'install' }, backend, { + waitForReady: async () => undefined, + }); + + await assert.rejects( + manageRuntimeHostService({ ...common, action: 'start' }, backend), + /scheduler start failed/u, + ); + assert.equal(stops, 1); + + stopFails = true; + await assert.rejects( + manageRuntimeHostService({ ...common, action: 'restart' }, backend), + (error: unknown) => + error instanceof RuntimeHostServiceManagerError && + error.code === 'service_manager_operation_failed' && + error.cause instanceof AggregateError && + error.cause.errors.length === 2, + ); + assert.equal(stops, 2); + }); + it('retires the exact managed Host only after active work is authorized', async (t) => { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-retirement-')); t.after(() => rm(base, { recursive: true, force: true })); @@ -1501,6 +1557,7 @@ describe('managed Runtime Host service', () => { let legacyLeaseCalls = 0; let operatorStatusFailure = false; let operatorFailure: Extract | undefined; + let replacementPreconditionFailure = false; let replaceFailure = false; let cleanupFailure = false; let expectAllowInterruptActiveTasks = true; @@ -1535,7 +1592,17 @@ describe('managed Runtime Host service', () => { }, }); const overrides = { - createBackend: createUnusedBackend, + createBackend: () => ({ + ...createUnusedBackend(), + verifyReplacementPreconditions: async () => { + if (replacementPreconditionFailure) { + throw new RuntimeHostServiceManagerError( + 'target_mismatch', + 'The update scheduler is not ready for replacement', + ); + } + }, + }), withLifecycleLock: async (_root: string, operation: () => Promise) => { assert.equal(insideLifecycle, false); insideLifecycle = true; @@ -1681,6 +1748,19 @@ describe('managed Runtime Host service', () => { 'updated', ); + replacementPreconditionFailure = true; + order.length = 0; + statusReads = 0; + output = ''; + assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 1); + assert.deepEqual(order, []); + const preconditionFailure = decodeRuntimeHostServiceManagementFrame(output.trim()); + assert.equal( + preconditionFailure?.kind === 'error' ? preconditionFailure.error.code : undefined, + 'target_mismatch', + ); + replacementPreconditionFailure = false; + order.length = 0; statusReads = 0; observedVersion = '2.0.0'; @@ -2031,6 +2111,7 @@ describe('managed Runtime Host service', () => { function createFakeSystemd(unitPath: string): { readonly failNext: (command: string) => void; + readonly activateUnitWhenStopping: (triggerUnit: string, unitToActivate: string) => void; readonly setDropInPaths: (paths: readonly string[]) => void; readonly setUnitDropInPaths: (unitName: string, paths: readonly string[]) => void; readonly calls: readonly (readonly string[])[]; @@ -2043,12 +2124,16 @@ function createFakeSystemd(unitPath: string): { const states = new Map(); let failureCommand: string | undefined; const dropInPaths = new Map(); + let stopActivation: { triggerUnit: string; unitToActivate: string } | undefined; const calls: string[][] = []; return { calls, failNext: (command) => { failureCommand = command; }, + activateUnitWhenStopping: (triggerUnit, unitToActivate) => { + stopActivation = { triggerUnit, unitToActivate }; + }, setDropInPaths: (paths) => { dropInPaths.set(basename(unitPath), paths); }, @@ -2084,8 +2169,21 @@ function createFakeSystemd(unitPath: string): { return success(); } if (args[0] === 'stop') { - assert.ok(unitState); - unitState.active = false; + const targets = args.slice(1); + if (stopActivation && targets.includes(stopActivation.triggerUnit)) { + const activated = states.get(stopActivation.unitToActivate) ?? { + enabled: false, + active: false, + }; + activated.active = true; + states.set(stopActivation.unitToActivate, activated); + stopActivation = undefined; + } + for (const target of targets) { + const targetState = states.get(target) ?? { enabled: false, active: false }; + targetState.active = false; + states.set(target, targetState); + } return success(); } if (args[0] === 'reset-failed') return success(); @@ -2127,6 +2225,7 @@ function createUnusedBackend(): RuntimeHostServiceBackend { preflightInstall: unexpected, install: unexpected, replace: unexpected, + verifyReplacementPreconditions: unexpected, verifyDeployment: unexpected, status: unexpected, start: unexpected, @@ -2159,6 +2258,7 @@ function createReadyBackend(): RuntimeHostServiceBackend { preflightInstall: async () => undefined, install: async () => ({ rollback: async () => undefined }), replace: async () => undefined, + verifyReplacementPreconditions: async () => undefined, verifyDeployment: async () => undefined, status, start: async () => undefined, diff --git a/packages/cli/src/__tests__/runtime-host-setup.test.ts b/packages/cli/src/__tests__/runtime-host-setup.test.ts index d77d9663c4..02efbbb5ed 100644 --- a/packages/cli/src/__tests__/runtime-host-setup.test.ts +++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts @@ -599,6 +599,7 @@ function unusedBackend(): RuntimeHostServiceBackend { preflightInstall: async () => undefined, install: async () => assert.fail('Backend is not expected'), replace: async () => assert.fail('Backend is not expected'), + verifyReplacementPreconditions: async () => assert.fail('Backend is not expected'), verifyDeployment: async () => assert.fail('Backend is not expected'), status: async () => assert.fail('Backend is not expected'), start: async () => assert.fail('Backend is not expected'), diff --git a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts index b0a091dcf3..c7120006a0 100644 --- a/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts +++ b/packages/cli/src/__tests__/runtime-host-update-reconciliation.test.ts @@ -403,6 +403,7 @@ function unusedBackend() { preflightInstall: async () => undefined, install: async () => ({ rollback: async () => undefined }), replace: async () => undefined, + verifyReplacementPreconditions: async () => undefined, verifyDeployment: async () => undefined, status: async () => ({ manager: 'systemd_user' as const, diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts index 55e7f7373d..212295f82e 100644 --- a/packages/cli/src/runtime-host-launch-agent-service.ts +++ b/packages/cli/src/runtime-host-launch-agent-service.ts @@ -116,19 +116,16 @@ export function createLaunchAgentRuntimeHostService( preflightInstall: () => assertLaunchAgentDomain(context), install: async (config) => { await validateRuntimeHostServiceLaunch(config); - const managesScheduler = runtimeHostUpdateReconcileLaunchArguments(config) !== null; const [previous, previousScheduler] = await Promise.all([ captureLaunchAgentDeployment(context), - managesScheduler ? captureLaunchAgentDeployment(scheduler) : undefined, + captureLaunchAgentDeployment(scheduler), ]); let schedulerMutationStarted = false; try { await applyLaunchAgentDeployment(context, config); - if (managesScheduler) { - await applyLaunchAgentUpdateScheduler(scheduler, config, () => { - schedulerMutationStarted = true; - }); - } + await applyLaunchAgentUpdateSchedulerDesiredState(scheduler, config, () => { + schedulerMutationStarted = true; + }); } catch (error) { await restoreFailedLaunchAgentDeployment( previous, @@ -169,6 +166,8 @@ export function createLaunchAgentRuntimeHostService( ); } }, + verifyReplacementPreconditions: (config) => + verifyLaunchAgentUpdateSchedulerDesiredState(scheduler, config, false), verifyDeployment: async (config) => { await validateRuntimeHostServiceLaunch(config); const [status, plist] = await Promise.all([ @@ -184,9 +183,7 @@ export function createLaunchAgentRuntimeHostService( 'The installed Runtime Host LaunchAgent does not match its managed deployment', ); } - if (runtimeHostUpdateReconcileLaunchArguments(config)) { - await verifyLaunchAgentUpdateScheduler(scheduler, config); - } + await verifyLaunchAgentUpdateSchedulerDesiredState(scheduler, config, false); }, status: readStatus, start: async () => { @@ -392,13 +389,25 @@ async function captureLaunchAgentDeployment( return { plist, loaded: status.loaded }; } -async function applyLaunchAgentUpdateScheduler( +async function applyLaunchAgentUpdateSchedulerDesiredState( context: LaunchAgentContext, config: RuntimeHostManagedServiceConfig, onMutation: () => void, ): Promise { + if (!runtimeHostUpdateReconcileLaunchArguments(config)) { + try { + await verifyLaunchAgentUpdateSchedulerAbsent(context); + return; + } catch (error) { + if (!isTargetMismatch(error)) throw error; + } + onMutation(); + await removeLaunchAgentUpdateScheduler(context); + await verifyLaunchAgentUpdateSchedulerAbsent(context); + return; + } try { - await verifyLaunchAgentUpdateScheduler(context, config); + await verifyLaunchAgentUpdateScheduler(context, config, true); return; } catch (error) { if (!isTargetMismatch(error)) throw error; @@ -412,11 +421,25 @@ async function applyLaunchAgentUpdateScheduler( 0o600, ); await bootstrapLaunchAgent(context); + await verifyLaunchAgentUpdateScheduler(context, config, true); +} + +async function verifyLaunchAgentUpdateSchedulerDesiredState( + context: LaunchAgentContext, + config: RuntimeHostManagedServiceConfig, + requireLoaded: boolean, +): Promise { + if (runtimeHostUpdateReconcileLaunchArguments(config)) { + await verifyLaunchAgentUpdateScheduler(context, config, requireLoaded); + return; + } + await verifyLaunchAgentUpdateSchedulerAbsent(context); } async function verifyLaunchAgentUpdateScheduler( context: LaunchAgentContext, config: RuntimeHostManagedServiceConfig, + requireLoaded: boolean, ): Promise { const [status, plist] = await Promise.all([ readLaunchAgentStatus(context), @@ -425,11 +448,25 @@ async function verifyLaunchAgentUpdateScheduler( throw error; }), ]); - if (!status.loaded || plist !== renderLaunchAgentUpdatePlist(config, context)) { + if ( + (requireLoaded && !status.loaded) || + plist !== renderLaunchAgentUpdatePlist(config, context) + ) { throw launchAgentSchedulerMismatch(); } } +async function verifyLaunchAgentUpdateSchedulerAbsent(context: LaunchAgentContext): Promise { + const [status, plist] = await Promise.all([ + readLaunchAgentStatus(context), + readFile(context.plistPath, 'utf8').catch((error: unknown) => { + if (isNodeError(error, 'ENOENT')) return null; + throw error; + }), + ]); + if (status.loaded || plist !== null) throw launchAgentSchedulerMismatch(); +} + async function removeLaunchAgentUpdateScheduler(context: LaunchAgentContext): Promise { await bootoutLaunchAgent(context); await Promise.all([ diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 8edc76e409..9aa1a56b2d 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -102,6 +102,9 @@ export interface RuntimeHostServiceBackend { install(config: RuntimeHostManagedServiceConfig): Promise; /** A rejected replacement must restore the previous deployment or report update_incomplete. */ replace(config: RuntimeHostManagedServiceConfig): Promise; + /** Verify derived resources that replacement deliberately leaves untouched. */ + verifyReplacementPreconditions(config: RuntimeHostManagedServiceConfig): Promise; + /** Verify the persisted deployment definition independently of its running state. */ verifyDeployment(config: RuntimeHostManagedServiceConfig): Promise; status(): Promise; start(): Promise; @@ -537,14 +540,24 @@ async function manageRuntimeHostServiceLocked( ); } await resolveExpectedServiceRoot(config, input); - await backend[input.action](); if (input.action === 'start' || input.action === 'restart') { try { + await backend[input.action](); await deps.waitForReady(config, backend); } catch (error) { - await backend.stop().catch(() => undefined); + try { + await backend.stop(); + } catch (stopError) { + throw new RuntimeHostServiceManagerError( + 'service_manager_operation_failed', + 'Starting the Runtime Host managed deployment failed and its partial state could not be stopped', + { cause: new AggregateError([error, stopError]) }, + ); + } throw error; } + } else { + await backend[input.action](); } return result(input.action, await readServiceStatus(configPath, backend)); } diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts index 2bc209d5f9..6b665bbee0 100644 --- a/packages/cli/src/runtime-host-systemd-service.ts +++ b/packages/cli/src/runtime-host-systemd-service.ts @@ -109,20 +109,17 @@ export function createSystemdUserRuntimeHostService( }, install: async (config) => { await validateRuntimeHostServiceLaunch(config); - const managesScheduler = runtimeHostUpdateReconcileLaunchArguments(config) !== null; const [previous, previousScheduler] = await Promise.all([ captureSystemdDeployment(context.unitPath, readStatus), - managesScheduler ? captureSystemdUpdateScheduler(scheduler) : undefined, + captureSystemdUpdateScheduler(scheduler), ]); - if (managesScheduler) await assertNoSystemdUpdateSchedulerDropIns(scheduler); + await assertNoSystemdUpdateSchedulerDropIns(scheduler); let schedulerMutationStarted = false; try { await applySystemdDeployment(context, config); - if (managesScheduler) { - await applySystemdUpdateScheduler(scheduler, config, () => { - schedulerMutationStarted = true; - }); - } + await applySystemdUpdateSchedulerDesiredState(scheduler, config, () => { + schedulerMutationStarted = true; + }); } catch (error) { await restoreFailedSystemdDeployment( previous, @@ -163,6 +160,8 @@ export function createSystemdUserRuntimeHostService( ); } }, + verifyReplacementPreconditions: (config) => + verifySystemdUpdateSchedulerDesiredState(scheduler, config, false), verifyDeployment: async (config) => { await validateRuntimeHostServiceLaunch(config); const [status, unit] = await Promise.all([ @@ -184,9 +183,7 @@ export function createSystemdUserRuntimeHostService( 'The loaded Runtime Host service does not match its managed deployment', ); } - if (runtimeHostUpdateReconcileLaunchArguments(config)) { - await verifySystemdUpdateScheduler(scheduler, config); - } + await verifySystemdUpdateSchedulerDesiredState(scheduler, config, false); }, status: readStatus, start: async () => { @@ -421,13 +418,25 @@ async function captureSystemdUpdateScheduler( return { serviceUnit, timerUnit, timerStatus }; } -async function applySystemdUpdateScheduler( +async function applySystemdUpdateSchedulerDesiredState( context: SystemdUpdateSchedulerContext, config: RuntimeHostManagedServiceConfig, onMutation: () => void, ): Promise { + if (!runtimeHostUpdateReconcileLaunchArguments(config)) { + try { + await verifySystemdUpdateSchedulerAbsent(context); + return; + } catch (error) { + if (!isTargetMismatch(error)) throw error; + } + onMutation(); + await removeSystemdUpdateScheduler(context); + await verifySystemdUpdateSchedulerAbsent(context); + return; + } try { - await verifySystemdUpdateScheduler(context, config); + await verifySystemdUpdateScheduler(context, config, true); return; } catch (error) { if (!isTargetMismatch(error)) throw error; @@ -458,7 +467,7 @@ async function applySystemdUpdateScheduler( ['restart', context.timer.unitName], 'Scheduling Runtime Host update reconciliation failed', ); - await verifySystemdUpdateScheduler(context, config); + await verifySystemdUpdateScheduler(context, config, true); } async function assertNoSystemdUpdateSchedulerDropIns( @@ -476,9 +485,22 @@ async function assertNoSystemdUpdateSchedulerDropIns( } } +async function verifySystemdUpdateSchedulerDesiredState( + context: SystemdUpdateSchedulerContext, + config: RuntimeHostManagedServiceConfig, + requireActive: boolean, +): Promise { + if (runtimeHostUpdateReconcileLaunchArguments(config)) { + await verifySystemdUpdateScheduler(context, config, requireActive); + return; + } + await verifySystemdUpdateSchedulerAbsent(context); +} + async function verifySystemdUpdateScheduler( context: SystemdUpdateSchedulerContext, config: RuntimeHostManagedServiceConfig, + requireActive: boolean, ): Promise { const [serviceUnit, timerUnit, serviceStatus, timerStatus] = await Promise.all([ readOptionalFile(context.service.unitPath), @@ -492,7 +514,30 @@ async function verifySystemdUpdateScheduler( !isLoadedManagedSystemdUnit(serviceStatus, context.service.unitPath) || !isLoadedManagedSystemdUnit(timerStatus, context.timer.unitPath) || (timerStatus.unitFileState !== 'enabled' && timerStatus.unitFileState !== 'enabled-runtime') || - timerStatus.activeState !== 'active' + (requireActive && timerStatus.activeState !== 'active') + ) { + throw schedulerMismatch(); + } +} + +async function verifySystemdUpdateSchedulerAbsent( + context: SystemdUpdateSchedulerContext, +): Promise { + const [serviceUnit, timerUnit, serviceStatus, timerStatus] = await Promise.all([ + readOptionalFile(context.service.unitPath), + readOptionalFile(context.timer.unitPath), + readSystemdStatus(context.service), + readSystemdStatus(context.timer), + ]); + if ( + serviceUnit !== null || + timerUnit !== null || + serviceStatus.loadState !== 'not-found' || + timerStatus.loadState !== 'not-found' || + timerStatus.unitFileState === 'enabled' || + timerStatus.unitFileState === 'enabled-runtime' || + serviceStatus.dropInPaths?.trim() || + timerStatus.dropInPaths?.trim() ) { throw schedulerMismatch(); } @@ -516,31 +561,10 @@ async function stopSystemdManagedDeployment( scheduler: SystemdUpdateSchedulerContext, ): Promise { const errors: unknown[] = []; - const [serviceStatus, timerStatus] = await Promise.all([ - readSystemdStatus(scheduler.service), - readSystemdStatus(scheduler.timer), - ]); - if (isSystemdUnitRunning(timerStatus)) { - try { - await requireSystemctl( - scheduler.timer.runSystemctl, - ['stop', scheduler.timer.unitName], - 'Stopping Runtime Host update scheduling failed', - ); - } catch (error) { - errors.push(error); - } - } - if (isSystemdUnitRunning(serviceStatus)) { - try { - await requireSystemctl( - scheduler.service.runSystemctl, - ['stop', scheduler.service.unitName], - 'Stopping Runtime Host update reconciliation failed', - ); - } catch (error) { - errors.push(error); - } + try { + await stopSystemdUpdateScheduler(scheduler); + } catch (error) { + errors.push(error); } try { await runLifecycleAction(service, 'stop'); @@ -557,17 +581,8 @@ async function stopSystemdManagedDeployment( } async function removeSystemdUpdateScheduler(context: SystemdUpdateSchedulerContext): Promise { - const [serviceStatus, timerStatus] = await Promise.all([ - readSystemdStatus(context.service), - readSystemdStatus(context.timer), - ]); - if (isSystemdUnitRunning(timerStatus)) { - await requireSystemctl( - context.timer.runSystemctl, - ['stop', context.timer.unitName], - 'Stopping Runtime Host update scheduling failed', - ); - } + const timerStatus = await readSystemdStatus(context.timer); + await stopSystemdUpdateScheduler(context); if ( timerStatus.loadState !== 'not-found' || timerStatus.unitFileState === 'enabled' || @@ -579,13 +594,6 @@ async function removeSystemdUpdateScheduler(context: SystemdUpdateSchedulerConte 'Disabling Runtime Host update scheduling failed', ); } - if (isSystemdUnitRunning(serviceStatus)) { - await requireSystemctl( - context.service.runSystemctl, - ['stop', context.service.unitName], - 'Stopping Runtime Host update reconciliation failed', - ); - } await Promise.all([ removeRuntimeHostServiceFile(context.service.unitPath, 'systemd update service'), removeRuntimeHostServiceFile(context.timer.unitPath, 'systemd update timer'), @@ -595,6 +603,29 @@ async function removeSystemdUpdateScheduler(context: SystemdUpdateSchedulerConte await context.timer.runSystemctl(['reset-failed', context.timer.unitName]); } +async function stopSystemdUpdateScheduler(context: SystemdUpdateSchedulerContext): Promise { + const [serviceUnit, timerUnit, serviceStatus, timerStatus] = await Promise.all([ + readOptionalFile(context.service.unitPath), + readOptionalFile(context.timer.unitPath), + readSystemdStatus(context.service), + readSystemdStatus(context.timer), + ]); + const units = [ + ...(timerUnit !== null || timerStatus.loadState !== 'not-found' + ? [context.timer.unitName] + : []), + ...(serviceUnit !== null || serviceStatus.loadState !== 'not-found' + ? [context.service.unitName] + : []), + ]; + if (units.length === 0) return; + await requireSystemctl( + context.timer.runSystemctl, + ['stop', ...units], + 'Stopping Runtime Host update scheduling failed', + ); +} + async function restoreSystemdUpdateScheduler( snapshot: SystemdUpdateSchedulerSnapshot, context: SystemdUpdateSchedulerContext, diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index 18a634b51a..dddf67da39 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -210,6 +210,7 @@ export async function runManagedRuntimeHostUpdateCli( 'The Runtime Host service is not owned by a Maka managed deployment', ); } + await backend.verifyReplacementPreconditions(serviceConfig); const deploymentRoot = serviceConfig.managedDeploymentRoot; const currentCliPath = resolve(serviceConfig.launch.cliPath); const targetCliPath = resolveRuntimeHostManagedPackageCliPath( From 040b8086c866cda6622b809dcdcfe38f8035904a Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 13:55:14 +0800 Subject: [PATCH 4/5] fix(runtime-host): complete scheduler update handoff Admit a wholly absent derived scheduler during upgrades, then install or restart it inside the replacement transaction without reloading an active scheduler. Keep forced recovery scoped to the Host process and reserve diagnostic space for every Host and scheduler log source. Generated-by: Codex --- .../runtime-host-launch-agent-service.test.ts | 25 ++++- .../runtime-host-service-manager.test.ts | 36 ++++++- .../src/runtime-host-launch-agent-service.ts | 66 ++++++++++--- .../cli/src/runtime-host-service-manager.ts | 27 ++++- .../cli/src/runtime-host-systemd-service.ts | 98 ++++++++++++++----- .../cli/src/runtime-host-update-command.ts | 7 +- 6 files changed, 216 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts index fd6e1a6165..03a9e0324e 100644 --- a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts +++ b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts @@ -22,6 +22,7 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import { RUNTIME_HOST_SERVICE_LOG_MAX_BYTES } from '@maka/runtime-host/operator'; import { createLaunchAgentRuntimeHostService, renderLaunchAgentPlist, @@ -94,6 +95,19 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy await backend.verifyDeployment(config); const updatePath = resolveLaunchAgentUpdatePath(SERVICE_ID, homeDir); assert.match(await readFile(updatePath, 'utf8'), /reconcile-update/u); + const logDirectory = join(homeDir, 'Library', 'Logs', 'Maka', 'runtime-host-services'); + await Promise.all([ + writeFile(join(logDirectory, `${LABEL}.stdout.log`), 'h'.repeat(64 * 1024)), + writeFile(join(logDirectory, `${LABEL}.stderr.log`), 'host stderr'), + writeFile(join(logDirectory, `${UPDATE_LABEL}.stdout.log`), 'update stdout'), + writeFile( + join(logDirectory, `${UPDATE_LABEL}.stderr.log`), + 'scheduler reconciliation failed', + ), + ]); + const logs = await backend.logs(); + assert.match(logs, /scheduler reconciliation failed/u); + assert.ok(Buffer.byteLength(logs) <= RUNTIME_HOST_SERVICE_LOG_MAX_BYTES); const updateBootouts = () => launchctl.calls.filter( @@ -111,6 +125,11 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy launchctl.updateRunning = false; await writeFile(updatePath, 'stale\n', { mode: 0o600 }); + await assert.rejects( + backend.verifyReplacementPreconditions(config), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'target_mismatch', + ); await assert.rejects( backend.verifyDeployment(config), (error: unknown) => @@ -122,7 +141,7 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy await backend.stop(); assert.equal(launchctl.updateLoaded, false); await backend.verifyDeployment(config); - await backend.start(); + await backend.replace(config); assert.equal(launchctl.updateLoaded, true); const { managedDeploymentRoot: _managedDeploymentRoot, ...unmanagedConfig } = config; @@ -130,6 +149,10 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy await backend.verifyDeployment(unmanagedConfig); assert.equal(await fileExists(updatePath), false); assert.equal(launchctl.updateLoaded, false); + await backend.verifyReplacementPreconditions(config); + await backend.replace(config); + await backend.verifyDeployment(config); + assert.equal(launchctl.updateLoaded, true); await backend.uninstall(); assert.equal(await fileExists(updatePath), false); diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index 7a456f2d41..fa9160964a 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -37,6 +37,7 @@ import { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV, RUNTIME_HOST_OPERATOR_PROCESS_LIFETIME_LOCK_CAPABILITY, + RUNTIME_HOST_SERVICE_LOG_MAX_BYTES, type RuntimeHostOperatorCapability, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; @@ -369,6 +370,11 @@ describe('managed Runtime Host service', () => { await assert.rejects(repairBackend.install(managedConfig), /systemd drop-in overrides/u); systemd.setUnitDropInPaths(updateTimerName, []); await writeFile(updateTimerPath, '[Timer]\n# stale\n', 'utf8'); + await assert.rejects( + repairBackend.verifyReplacementPreconditions(managedConfig), + (error: unknown) => + error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch', + ); await assert.rejects( repairBackend.verifyDeployment(managedConfig), (error: unknown) => @@ -389,16 +395,36 @@ describe('managed Runtime Host service', () => { ), ); await repairBackend.verifyDeployment(managedConfig); - await repairBackend.start(); + await repairBackend.replace(managedConfig); assert.ok( systemd.calls.some(([command, target]) => command === 'start' && target === updateTimerName), ); + const diagnosticBackend = createSystemdUserRuntimeHostService(serviceId, { + env, + homeDir, + uid: 1000, + runSystemctl: systemd.run, + runLoginctl: async () => success('yes\n'), + runJournalctl: async (args) => + success( + args.includes(updateServiceName) + ? 'scheduler reconciliation failed' + : 'h'.repeat(RUNTIME_HOST_SERVICE_LOG_MAX_BYTES), + ), + }); + const logs = await diagnosticBackend.logs(); + assert.match(logs, /scheduler reconciliation failed/u); + assert.ok(Buffer.byteLength(logs) <= RUNTIME_HOST_SERVICE_LOG_MAX_BYTES); + const { managedDeploymentRoot: _managedDeploymentRoot, ...unmanagedConfig } = managedConfig; await repairBackend.install(unmanagedConfig); await repairBackend.verifyDeployment(unmanagedConfig); await assert.rejects(readFile(updateServicePath, 'utf8'), { code: 'ENOENT' }); await assert.rejects(readFile(updateTimerPath, 'utf8'), { code: 'ENOENT' }); + await repairBackend.verifyReplacementPreconditions(managedConfig); + await repairBackend.replace(managedConfig); + await repairBackend.verifyDeployment(managedConfig); const root = await resolveStorageRoot({ path: rootPath, kind: 'interactive' }); await writeFile(configPath, '{not-json', 'utf8'); @@ -1602,6 +1628,10 @@ describe('managed Runtime Host service', () => { ); } }, + retire: async () => { + assert.equal(insideLifecycle, true); + order.push('force-retire'); + }, }), withLifecycleLock: async (_root: string, operation: () => Promise) => { assert.equal(insideLifecycle, false); @@ -1878,7 +1908,7 @@ describe('managed Runtime Host service', () => { output = ''; operatorStatusFailure = true; assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0); - assert.deepEqual(order, ['stop', 'activate', 'replace', 'cleanup']); + assert.deepEqual(order, ['force-retire', 'activate', 'replace', 'cleanup']); operatorStatusFailure = false; order.length = 0; @@ -1909,7 +1939,7 @@ describe('managed Runtime Host service', () => { output = ''; expectAllowInterruptActiveTasks = true; assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0); - assert.deepEqual(order, ['retire', 'stop', 'activate', 'replace', 'cleanup']); + assert.deepEqual(order, ['retire', 'force-retire', 'activate', 'replace', 'cleanup']); assert.equal(legacyLeaseCalls, 1); statusReads = 0; diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts index 212295f82e..80944c3e7d 100644 --- a/packages/cli/src/runtime-host-launch-agent-service.ts +++ b/packages/cli/src/runtime-host-launch-agent-service.ts @@ -22,6 +22,7 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { RUNTIME_HOST_SERVICE_LOG_MAX_BYTES } from '@maka/runtime-host/operator'; import { + formatRuntimeHostServiceLogs, removeRuntimeHostServiceFile, RuntimeHostServiceManagerError, type RuntimeHostManagedServiceConfig, @@ -151,14 +152,20 @@ export function createLaunchAgentRuntimeHostService( }, replace: async (config) => { await validateRuntimeHostServiceLaunch(config); - const previous = await captureLaunchAgentDeployment(context); + const [previous, previousScheduler] = await Promise.all([ + captureLaunchAgentDeployment(context), + captureLaunchAgentDeployment(scheduler), + ]); + let schedulerMutationStarted = false; try { - // The stable scheduler may be the caller; only explicit install/repair may reload it. await applyLaunchAgentDeployment(context, config); + await convergeLaunchAgentUpdateSchedulerForReplacement(scheduler, config, () => { + schedulerMutationStarted = true; + }); } catch (error) { await restoreFailedLaunchAgentDeployment( previous, - undefined, + schedulerMutationStarted ? previousScheduler : undefined, context, scheduler, error, @@ -167,7 +174,7 @@ export function createLaunchAgentRuntimeHostService( } }, verifyReplacementPreconditions: (config) => - verifyLaunchAgentUpdateSchedulerDesiredState(scheduler, config, false), + verifyLaunchAgentUpdateSchedulerReplacementState(scheduler, config), verifyDeployment: async (config) => { await validateRuntimeHostServiceLaunch(config); const [status, plist] = await Promise.all([ @@ -203,14 +210,12 @@ export function createLaunchAgentRuntimeHostService( readLogTail(scheduler.stdoutPath), readLogTail(scheduler.stderrPath), ]); - return [ - stdout && `stdout:\n${stdout}`, - stderr && `stderr:\n${stderr}`, - updateStdout && `update stdout:\n${updateStdout}`, - updateStderr && `update stderr:\n${updateStderr}`, - ] - .filter(Boolean) - .join('\n'); + return formatRuntimeHostServiceLogs([ + { label: 'stdout', logs: stdout }, + { label: 'stderr', logs: stderr }, + { label: 'update stdout', logs: updateStdout }, + { label: 'update stderr', logs: updateStderr }, + ]); }, uninstall: async () => { await removeLaunchAgentUpdateScheduler(scheduler); @@ -436,6 +441,43 @@ async function verifyLaunchAgentUpdateSchedulerDesiredState( await verifyLaunchAgentUpdateSchedulerAbsent(context); } +async function verifyLaunchAgentUpdateSchedulerReplacementState( + context: LaunchAgentContext, + config: RuntimeHostManagedServiceConfig, +): Promise { + if (!runtimeHostUpdateReconcileLaunchArguments(config)) { + await verifyLaunchAgentUpdateSchedulerAbsent(context); + return; + } + try { + await verifyLaunchAgentUpdateScheduler(context, config, false); + } catch (error) { + if (!isTargetMismatch(error)) throw error; + await verifyLaunchAgentUpdateSchedulerAbsent(context); + } +} + +async function convergeLaunchAgentUpdateSchedulerForReplacement( + context: LaunchAgentContext, + config: RuntimeHostManagedServiceConfig, + onMutation: () => void, +): Promise { + try { + await verifyLaunchAgentUpdateScheduler(context, config, false); + const status = await readLaunchAgentStatus(context); + // The loaded scheduler may be running this replacement. + if (status.loaded) return; + onMutation(); + await ensureLaunchAgentLoadedIfInstalled(context); + } catch (error) { + if (!isTargetMismatch(error)) throw error; + await verifyLaunchAgentUpdateSchedulerAbsent(context); + onMutation(); + await applyLaunchAgentUpdateSchedulerDesiredState(context, config, () => undefined); + } + await verifyLaunchAgentUpdateScheduler(context, config, true); +} + async function verifyLaunchAgentUpdateScheduler( context: LaunchAgentContext, config: RuntimeHostManagedServiceConfig, diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 9aa1a56b2d..1c0ec74db6 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -102,7 +102,7 @@ export interface RuntimeHostServiceBackend { install(config: RuntimeHostManagedServiceConfig): Promise; /** A rejected replacement must restore the previous deployment or report update_incomplete. */ replace(config: RuntimeHostManagedServiceConfig): Promise; - /** Verify derived resources that replacement deliberately leaves untouched. */ + /** Reject partial or drifted scheduler state before replacement begins. */ verifyReplacementPreconditions(config: RuntimeHostManagedServiceConfig): Promise; /** Verify the persisted deployment definition independently of its running state. */ verifyDeployment(config: RuntimeHostManagedServiceConfig): Promise; @@ -731,6 +731,31 @@ export async function removeRuntimeHostServiceFile(path: string, label: string): } } +export function formatRuntimeHostServiceLogs( + sources: readonly { readonly label: string; readonly logs: string }[], +): string { + const present = sources.filter(({ logs }) => logs.length > 0); + if (present.length === 0) return ''; + const separatorBytes = present.length - 1; + const sourceBudget = Math.floor( + (RUNTIME_HOST_SERVICE_LOG_MAX_BYTES - separatorBytes) / present.length, + ); + return present + .map(({ label, logs }) => { + const heading = `${label}:\n`; + return `${heading}${takeUtf8Tail(logs, sourceBudget - Buffer.byteLength(heading))}`; + }) + .join('\n'); +} + +function takeUtf8Tail(value: string, maximumBytes: number): string { + const encoded = Buffer.from(value); + if (encoded.byteLength <= maximumBytes) return value; + let start = encoded.byteLength - maximumBytes; + while (start < encoded.byteLength && (encoded[start]! & 0xc0) === 0x80) start += 1; + return encoded.subarray(start).toString('utf8'); +} + async function prepareServiceConfig( input: Omit, previous: RuntimeHostManagedServiceConfig | null, diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts index 6b665bbee0..8481b8fe3f 100644 --- a/packages/cli/src/runtime-host-systemd-service.ts +++ b/packages/cli/src/runtime-host-systemd-service.ts @@ -22,6 +22,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { resolveXdgConfigHome } from '@maka/storage/workspace-root'; import { + formatRuntimeHostServiceLogs, removeRuntimeHostServiceFile, RuntimeHostServiceManagerError, type RuntimeHostManagedServiceConfig, @@ -145,14 +146,20 @@ export function createSystemdUserRuntimeHostService( }, replace: async (config) => { await validateRuntimeHostServiceLaunch(config); - const previous = await captureSystemdDeployment(context.unitPath, readStatus); + const [previous, previousScheduler] = await Promise.all([ + captureSystemdDeployment(context.unitPath, readStatus), + captureSystemdUpdateScheduler(scheduler), + ]); + let schedulerMutationStarted = false; try { - // The stable scheduler may be the caller; only explicit install/repair may reload it. await applySystemdDeployment(context, config); + await convergeSystemdUpdateSchedulerForReplacement(scheduler, config, () => { + schedulerMutationStarted = true; + }); } catch (error) { await restoreFailedSystemdDeployment( previous, - undefined, + schedulerMutationStarted ? previousScheduler : undefined, context, scheduler, error, @@ -161,7 +168,7 @@ export function createSystemdUserRuntimeHostService( } }, verifyReplacementPreconditions: (config) => - verifySystemdUpdateSchedulerDesiredState(scheduler, config, false), + verifySystemdUpdateSchedulerReplacementState(scheduler, config), verifyDeployment: async (config) => { await validateRuntimeHostServiceLaunch(config); const [status, unit] = await Promise.all([ @@ -197,25 +204,33 @@ export function createSystemdUserRuntimeHostService( }, retire: () => runLifecycleAction(context, 'stop'), logs: async () => { - const result = await runJournalctl([ - '--user-unit', - context.unitName, - '--user-unit', - scheduler.service.unitName, - '--no-pager', - '--lines=200', - '--output=short-iso', - ]).catch((error) => { - throw new RuntimeHostServiceManagerError( - 'service_manager_unavailable', - 'Unable to read Runtime Host service logs', - { cause: error }, - ); - }); - if (result.exitCode !== 0) { - throw managerError('Reading Runtime Host service logs failed', result); - } - return result.stdout; + const readJournal = async (unitName: string): Promise => { + const result = await runJournalctl([ + '--user-unit', + unitName, + '--no-pager', + '--lines=200', + '--output=short-iso', + ]).catch((error) => { + throw new RuntimeHostServiceManagerError( + 'service_manager_unavailable', + 'Unable to read Runtime Host service logs', + { cause: error }, + ); + }); + if (result.exitCode !== 0) { + throw managerError('Reading Runtime Host service logs failed', result); + } + return result.stdout; + }; + const [hostLogs, updateLogs] = await Promise.all([ + readJournal(context.unitName), + readJournal(scheduler.service.unitName), + ]); + return formatRuntimeHostServiceLogs([ + { label: 'host', logs: hostLogs }, + { label: 'update', logs: updateLogs }, + ]); }, uninstall: async () => { await removeSystemdUpdateScheduler(scheduler); @@ -497,6 +512,43 @@ async function verifySystemdUpdateSchedulerDesiredState( await verifySystemdUpdateSchedulerAbsent(context); } +async function verifySystemdUpdateSchedulerReplacementState( + context: SystemdUpdateSchedulerContext, + config: RuntimeHostManagedServiceConfig, +): Promise { + if (!runtimeHostUpdateReconcileLaunchArguments(config)) { + await verifySystemdUpdateSchedulerAbsent(context); + return; + } + try { + await verifySystemdUpdateScheduler(context, config, false); + } catch (error) { + if (!isTargetMismatch(error)) throw error; + await verifySystemdUpdateSchedulerAbsent(context); + } +} + +async function convergeSystemdUpdateSchedulerForReplacement( + context: SystemdUpdateSchedulerContext, + config: RuntimeHostManagedServiceConfig, + onMutation: () => void, +): Promise { + try { + await verifySystemdUpdateScheduler(context, config, false); + const status = await readSystemdStatus(context.timer); + // The active scheduler may be running this replacement. + if (status.activeState === 'active') return; + onMutation(); + await ensureSystemdUpdateSchedulerStartedIfInstalled(context); + } catch (error) { + if (!isTargetMismatch(error)) throw error; + await verifySystemdUpdateSchedulerAbsent(context); + onMutation(); + await applySystemdUpdateSchedulerDesiredState(context, config, () => undefined); + } + await verifySystemdUpdateScheduler(context, config, true); +} + async function verifySystemdUpdateScheduler( context: SystemdUpdateSchedulerContext, config: RuntimeHostManagedServiceConfig, diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts index dddf67da39..f9c733451d 100644 --- a/packages/cli/src/runtime-host-update-command.ts +++ b/packages/cli/src/runtime-host-update-command.ts @@ -345,9 +345,10 @@ export async function runManagedRuntimeHostUpdateCli( if (!options.allowInterruptActiveTasks) { retirement = activeTasksRetirementFrame(status); } else { - const forced = await deps.withLifecycleLock(options.clientDataRoot, () => - deps.manage({ ...common, action: 'stop' }, backend), - ); + const forced = await deps.withLifecycleLock(options.clientDataRoot, async () => { + await backend.retire(); + return deps.manage({ ...common, action: 'status' }, backend); + }); if ( forced.service.active || forced.service.pid !== null || From 4d044ceb781580f49486ba1fe0f038aaed5af0f7 Mon Sep 17 00:00:00 2001 From: Wang Date: Tue, 25 Aug 2026 14:53:47 +0800 Subject: [PATCH 5/5] fix(runtime-host): repair inactive update schedulers Keep static deployment validation independent of process state, but require scheduler readiness before an active exact deployment can be accepted as current. This routes partial startup recovery through the existing repair transaction. Generated-by: Codex --- .../__tests__/runtime-host-launch-agent-service.test.ts | 6 ++++++ .../src/__tests__/runtime-host-service-manager.test.ts | 6 ++++++ packages/cli/src/runtime-host-launch-agent-service.ts | 8 ++++++-- packages/cli/src/runtime-host-service-manager.ts | 9 ++++++--- packages/cli/src/runtime-host-systemd-service.ts | 8 ++++++-- 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts index 03a9e0324e..7c13e65cf0 100644 --- a/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts +++ b/packages/cli/src/__tests__/runtime-host-launch-agent-service.test.ts @@ -141,8 +141,14 @@ test('installs and removes the update scheduler with a managed LaunchAgent', asy await backend.stop(); assert.equal(launchctl.updateLoaded, false); await backend.verifyDeployment(config); + await assert.rejects( + backend.verifyDeployment(config, { requireSchedulerReady: true }), + (error: unknown) => + error instanceof Error && 'code' in error && error.code === 'target_mismatch', + ); await backend.replace(config); assert.equal(launchctl.updateLoaded, true); + await backend.verifyDeployment(config, { requireSchedulerReady: true }); const { managedDeploymentRoot: _managedDeploymentRoot, ...unmanagedConfig } = config; await backend.install(unmanagedConfig); diff --git a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts index fa9160964a..3d92256d5c 100644 --- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts +++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts @@ -395,10 +395,16 @@ describe('managed Runtime Host service', () => { ), ); await repairBackend.verifyDeployment(managedConfig); + await assert.rejects( + repairBackend.verifyDeployment(managedConfig, { requireSchedulerReady: true }), + (error: unknown) => + error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch', + ); await repairBackend.replace(managedConfig); assert.ok( systemd.calls.some(([command, target]) => command === 'start' && target === updateTimerName), ); + await repairBackend.verifyDeployment(managedConfig, { requireSchedulerReady: true }); const diagnosticBackend = createSystemdUserRuntimeHostService(serviceId, { env, diff --git a/packages/cli/src/runtime-host-launch-agent-service.ts b/packages/cli/src/runtime-host-launch-agent-service.ts index 80944c3e7d..9916e2fadb 100644 --- a/packages/cli/src/runtime-host-launch-agent-service.ts +++ b/packages/cli/src/runtime-host-launch-agent-service.ts @@ -175,7 +175,7 @@ export function createLaunchAgentRuntimeHostService( }, verifyReplacementPreconditions: (config) => verifyLaunchAgentUpdateSchedulerReplacementState(scheduler, config), - verifyDeployment: async (config) => { + verifyDeployment: async (config, options) => { await validateRuntimeHostServiceLaunch(config); const [status, plist] = await Promise.all([ readDetailedStatus(), @@ -190,7 +190,11 @@ export function createLaunchAgentRuntimeHostService( 'The installed Runtime Host LaunchAgent does not match its managed deployment', ); } - await verifyLaunchAgentUpdateSchedulerDesiredState(scheduler, config, false); + await verifyLaunchAgentUpdateSchedulerDesiredState( + scheduler, + config, + options?.requireSchedulerReady ?? false, + ); }, status: readStatus, start: async () => { diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index 1c0ec74db6..23903f2606 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -104,8 +104,11 @@ export interface RuntimeHostServiceBackend { replace(config: RuntimeHostManagedServiceConfig): Promise; /** Reject partial or drifted scheduler state before replacement begins. */ verifyReplacementPreconditions(config: RuntimeHostManagedServiceConfig): Promise; - /** Verify the persisted deployment definition independently of its running state. */ - verifyDeployment(config: RuntimeHostManagedServiceConfig): Promise; + /** Verify the deployment definition and, when requested, its scheduler readiness. */ + verifyDeployment( + config: RuntimeHostManagedServiceConfig, + options?: { readonly requireSchedulerReady?: boolean }, + ): Promise; status(): Promise; start(): Promise; stop(): Promise; @@ -1046,7 +1049,7 @@ export async function verifyRuntimeHostManagedServiceReady( config: RuntimeHostManagedServiceConfig, backend: RuntimeHostServiceBackend, ): Promise { - await backend.verifyDeployment(config); + await backend.verifyDeployment(config, { requireSchedulerReady: true }); const deadline = Date.now() + SERVICE_READY_TIMEOUT_MS; let lastFailure = 'not available'; while (Date.now() < deadline) { diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts index 8481b8fe3f..795ceb505e 100644 --- a/packages/cli/src/runtime-host-systemd-service.ts +++ b/packages/cli/src/runtime-host-systemd-service.ts @@ -169,7 +169,7 @@ export function createSystemdUserRuntimeHostService( }, verifyReplacementPreconditions: (config) => verifySystemdUpdateSchedulerReplacementState(scheduler, config), - verifyDeployment: async (config) => { + verifyDeployment: async (config, options) => { await validateRuntimeHostServiceLaunch(config); const [status, unit] = await Promise.all([ readSystemdStatus(context), @@ -190,7 +190,11 @@ export function createSystemdUserRuntimeHostService( 'The loaded Runtime Host service does not match its managed deployment', ); } - await verifySystemdUpdateSchedulerDesiredState(scheduler, config, false); + await verifySystemdUpdateSchedulerDesiredState( + scheduler, + config, + options?.requireSchedulerReady ?? false, + ); }, status: readStatus, start: async () => {