You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/23 13:29:59 UTC
(maka) 02/03: fix(runtime-host): make managed updates converge
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/managed-runtime-host-manual-update
in repository https://gitbox.apache.org/repos/asf/maka.git
commit b665c4a3224e29a522aeff30c40d2c069fba02f7
Author: Wang <m4...@gmail.com>
AuthorDate: Sun Aug 23 19:49:25 2026 +0800
fix(runtime-host): make managed updates converge
Require the loaded service identity and a ready managed process before reporting an update as current. Recover Runtime Host management locks after process exit, preserve operator failure codes, and wait for Desktop to reconnect to the exact Host with interactive SSH fallback when needed.
---
.../__tests__/runtime-host-desktop-manager.test.ts | 9 +-
.../main/__tests__/runtime-host-management.test.ts | 27 ++-
.../__tests__/runtime-host-profile-service.test.ts | 37 +++++
.../__tests__/runtime-host-ssh-terminal.test.ts | 2 +
apps/desktop/src/main/runtime-host-boot.ts | 32 ++++
.../src/main/runtime-host-desktop-manager.ts | 25 +++
apps/desktop/src/main/runtime-host-management.ts | 33 ++++
.../src/main/runtime-host-profile-service.ts | 15 ++
apps/desktop/src/main/runtime-host-ssh-terminal.ts | 28 +++-
.../__tests__/runtime-host-service-manager.test.ts | 54 +++++-
.../cli/src/__tests__/runtime-host-setup.test.ts | 1 +
packages/cli/src/runtime-host-service-manager.ts | 16 +-
packages/cli/src/runtime-host-systemd-service.ts | 27 ++-
packages/cli/src/runtime-host-update-command.ts | 16 +-
packages/storage/package.json | 1 +
.../storage/src/__tests__/file-update-lock.test.ts | 63 +++++++
.../__tests__/fixtures/file-update-lock-holder.ts | 28 ++++
.../src/process-lifetime-file-update-lock.ts | 185 +++++++++++++++++++++
18 files changed, 583 insertions(+), 16 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
index eec6a5f71..fcca308dd 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
@@ -41,8 +41,8 @@ import {
} from '../runtime-host-desktop-manager.js';
test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, async () => {
- const first = candidateHarness({ delayDisconnect: true });
- const second = candidateHarness();
+ const first = candidateHarness({ delayDisconnect: true, hostEpoch: 'host-before' });
+ const second = candidateHarness({ hostEpoch: 'host-after' });
const queue = [ready(first.candidate), ready(second.candidate)];
let starts = 0;
const interactions: Array<string | undefined> = [];
@@ -71,6 +71,7 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy
});
first.disconnect();
+ const replacementReady = owner.waitUntilReady(owner.defaultProfileId(), 'host-before');
const botMessage = owner.handleBotIncomingMessage({ text: 'hello' } as BotIncomingMessage);
const stop = owner.stopSession({
hostId: 'test-host',
@@ -95,7 +96,7 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy
assert.equal(second.botMessages, 0);
assert.deepEqual(second.stoppedSessions, []);
releaseSecond();
- await Promise.all([botMessage, stop]);
+ await Promise.all([botMessage, stop, replacementReady]);
assert.equal(first.botMessages, 0);
assert.equal(second.botMessages, 1);
@@ -815,6 +816,7 @@ function candidateHarness(
activeTasks?: boolean;
lifecycleMode?: 'ephemeral' | 'service' | 'remote';
hostId?: string;
+ hostEpoch?: string;
finalizeFailures?: Error[];
disconnectOnFinalizeFailure?: boolean;
onPrepare?: () => void;
@@ -837,6 +839,7 @@ function candidateHarness(
hostLifecycleMode: options.lifecycleMode ?? 'ephemeral',
client: {
hostId: options.hostId ?? 'test-host',
+ hostEpoch: options.hostEpoch ?? 'test-host-epoch',
get lifecycleState() {
return lifecycleState;
},
diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
index e5612b875..1105f9e4d 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts
@@ -328,10 +328,12 @@ test('manages only the service identity bound by Desktop onboarding', async () =
assert.equal(handlers.size, 0);
});
-test('updates the managed profile through its exact package and publishes progress', async () => {
+test('publishes update progress and waits for the managed profile to reconnect', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const updates: DesktopRuntimeHostSshUpdateInput[] = [];
const progress: unknown[] = [];
+ const connectionCompletions: unknown[] = [];
+ let failConnection = false;
const profile = {
id: 'office',
name: 'Office',
@@ -385,6 +387,11 @@ test('updates the managed profile through its exact package and publishes progre
};
},
resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }),
+ currentHostEpoch: () => 'host-before-update',
+ awaitUpdatedConnection: async (...args) => {
+ connectionCompletions.push(args);
+ if (failConnection) throw new Error('authentication required');
+ },
sendProgress: (event) => progress.push(event),
runAccessManagement: async () => assert.fail('access management is not expected'),
cleanupManagedDeployment: async () => assert.fail('cleanup is not expected'),
@@ -404,6 +411,22 @@ test('updates the managed profile through its exact package and publishes progre
},
}]);
assert.deepEqual(progress, [{ profileId: profile.id, phase: 'staging' }]);
+ assert.deepEqual(connectionCompletions, [
+ [profile.id, profile.rootId, 'host-before-update', true],
+ ]);
+
+ failConnection = true;
+ const reconnectFailure = await update({}, profile.id, false);
+ assert.deepEqual(reconnectFailure, {
+ schemaVersion: 1,
+ kind: 'error',
+ action: 'update',
+ error: {
+ code: 'desktop_reconnect_failed',
+ message:
+ 'The Runtime Host update completed, but Desktop could not reconnect: authentication required',
+ },
+ });
});
test('resumes deployment cleanup without invoking the removed operator', async () => {
@@ -567,6 +590,8 @@ function unusedUpdateDependencies() {
return {
runUpdate: async (): Promise<never> => assert.fail('update is not expected'),
resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const),
+ currentHostEpoch: () => undefined,
+ awaitUpdatedConnection: async () => undefined,
sendProgress: () => undefined,
};
}
diff --git a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
index abb39cece..3ccb04e3f 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-profile-service.test.ts
@@ -225,6 +225,43 @@ test("keeps Local enabled while a new remote Host connects", async () => {
);
});
+test("reconnects an enabled remote Host with interactive SSH", async () => {
+ const root = await clientRoot();
+ const catalog = createClientRuntimeHostProfileCatalog(root);
+ await catalog.create(MANAGED_PROFILE, "opaque-token");
+ const calls: string[] = [];
+ const service = createDesktopRuntimeHostProfileService({
+ clientDataRoot: root,
+ startup: {
+ preferences: {
+ schemaVersion: 2,
+ defaultProfileId: LOCAL_RUNTIME_HOST_PROFILE.id,
+ enabledRemoteProfileIds: [MANAGED_PROFILE.id],
+ },
+ pairingIntents: [],
+ remotes: [{ profile: MANAGED_PROFILE, credential: "opaque-token" }],
+ unavailable: new Map(),
+ },
+ catalog,
+ states: () => [connectingLocal()],
+ enable: async (target, interaction) => {
+ calls.push(`enable:${target.profile.id}:${interaction}`);
+ },
+ disable: async (profileId) => {
+ calls.push(`disable:${profileId}`);
+ },
+ setDefault: () => undefined,
+ finalizePairing: async () => undefined,
+ });
+
+ await service.reconnect(MANAGED_PROFILE.id, MANAGED_PROFILE.rootId);
+
+ assert.deepEqual(calls, [
+ `disable:${MANAGED_PROFILE.id}`,
+ `enable:${MANAGED_PROFILE.id}:terminal`,
+ ]);
+});
+
test("does not enable the same State Root twice", async () => {
const root = await clientRoot();
const startup = await resolveDesktopRuntimeHostStartup(root);
diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
index 6e5844c70..fd688ee6d 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts
@@ -328,6 +328,7 @@ test('runs an exact update package and reports progress before an active-work re
assert.match(remoteCommand, /--package.*maka-agent@1\.3\.0/u);
assert.match(remoteCommand, /runtime-host.*service.*update/u);
assert.match(remoteCommand, /MAKA_RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST/u);
+ harness.pty.emitData('Password: ');
harness.pty.emitData(
encodeRuntimeHostServiceManagementFrame({
schemaVersion: 1,
@@ -366,6 +367,7 @@ test('runs an exact update package and reports progress before an active-work re
assert.equal(result.kind, 'result');
assert.equal(result.kind === 'result' ? result.update.kind : undefined, 'active_tasks');
assert.deepEqual(phases, ['retiring']);
+ assert.deepEqual(harness.events.map(({ kind }) => kind), ['opened', 'data', 'connected']);
assert.doesNotMatch(JSON.stringify(harness.events), /MAKA_RUNTIME_HOST_SERVICE/u);
await harness.terminal.close();
});
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index 3fff6a048..74854ce5c 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -210,6 +210,7 @@ import {
await resolveShellEnv();
+const MANAGED_UPDATE_RECONNECT_TIMEOUT_MS = 10_000;
const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath());
const userDataDir = app.getPath("userData");
const runtimeHostClientInstanceId = await loadOrCreateRuntimeHostClientInstanceId(
@@ -445,6 +446,37 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({
runServiceManagement: runtimeHostSshTerminal.runServiceManagement,
runUpdate: runtimeHostSshTerminal.runUpdate,
resolveUpdatePackage: runtimeHostSetupPackage,
+ currentHostEpoch: (profileId) =>
+ runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch,
+ awaitUpdatedConnection: async (
+ profileId,
+ expectedHostId,
+ previousHostEpoch,
+ replacementExpected,
+ ) => {
+ if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable');
+ const expectedPrevious = replacementExpected ? previousHostEpoch : undefined;
+ try {
+ await runtimeHostManager.waitUntilReady(
+ profileId,
+ expectedPrevious,
+ AbortSignal.timeout(MANAGED_UPDATE_RECONNECT_TIMEOUT_MS),
+ );
+ if (runtimeHostManager.current(profileId)?.hostId !== expectedHostId) {
+ throw new Error('Runtime Host profile changed while its service was updating');
+ }
+ } catch {
+ await runtimeHostProfileService.reconnect(profileId, expectedHostId);
+ const current = runtimeHostManager.current(profileId);
+ if (
+ current?.hostId !== expectedHostId ||
+ !current.candidate ||
+ (expectedPrevious !== undefined && current.candidate.client.hostEpoch === expectedPrevious)
+ ) {
+ throw new Error('Desktop reconnected to an unexpected Runtime Host generation');
+ }
+ }
+ },
sendProgress: (progress) =>
mainWindowController.send("runtime-host-management:progress", progress),
runAccessManagement: runtimeHostSshTerminal.runAccessManagement,
diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts
index 78b606145..e69bd8a40 100644
--- a/apps/desktop/src/main/runtime-host-desktop-manager.ts
+++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts
@@ -64,6 +64,11 @@ export interface RuntimeHostDesktopManager {
remote: DesktopRuntimeHostCandidateStartInput['remote'],
): Promise<void>;
disable(profileId: string): Promise<void>;
+ waitUntilReady(
+ profileId: string,
+ previousHostEpoch?: string,
+ signal?: AbortSignal,
+ ): Promise<void>;
setDefaultProfile(profileId: string): void;
prepareForUpdate(
allowInterruptActiveTasks: boolean,
@@ -457,6 +462,26 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
return this.#mutateTarget(profileId, () => this.#disable(profileId));
}
+ async waitUntilReady(
+ profileId: string,
+ previousHostEpoch?: string,
+ signal?: AbortSignal,
+ ): Promise<void> {
+ const target = this.#requireTarget(profileId);
+ let candidate = await this.#waitForReadyCandidate(
+ this.#requireLifecycle(target),
+ undefined,
+ signal,
+ );
+ while (previousHostEpoch !== undefined && candidate.client.hostEpoch === previousHostEpoch) {
+ candidate = await this.#waitForReadyCandidate(
+ this.#requireLifecycle(target),
+ candidate,
+ signal,
+ );
+ }
+ }
+
async #disable(profileId: string): Promise<void> {
if (profileId === LOCAL_RUNTIME_HOST_PROFILE.id) {
throw new Error('Local Runtime Host cannot be disabled');
diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts
index a676ce38e..dbce1f433 100644
--- a/apps/desktop/src/main/runtime-host-management.ts
+++ b/apps/desktop/src/main/runtime-host-management.ts
@@ -76,6 +76,13 @@ export function createDesktopRuntimeHostManagement(input: {
onProgress: (phase: DesktopRuntimeHostManagementProgress['phase']) => void,
) => Promise<RuntimeHostServiceUpdateTerminalFrame>;
readonly resolveUpdatePackage: () => DesktopRuntimeHostSetupPackage;
+ readonly currentHostEpoch: (profileId: string) => string | undefined;
+ readonly awaitUpdatedConnection: (
+ profileId: string,
+ expectedHostId: string,
+ previousHostEpoch: string | undefined,
+ replacementExpected: boolean,
+ ) => Promise<void>;
readonly sendProgress: (progress: DesktopRuntimeHostManagementProgress) => void;
readonly cleanupManagedDeployment: (
input: DesktopRuntimeHostSshCleanupInput,
@@ -220,6 +227,7 @@ export function createDesktopRuntimeHostManagement(input: {
if (managed.state !== 'active' || managed.profile.transport.kind !== 'ssh') {
throw new Error('This Runtime Host profile is not available for managed updates');
}
+ const previousHostEpoch = input.currentHostEpoch(profileId);
const response = await input.runUpdate(
{
destination: managed.profile.transport.destination,
@@ -236,6 +244,31 @@ export function createDesktopRuntimeHostManagement(input: {
},
(phase) => input.sendProgress({ profileId, phase }),
);
+ if (
+ response.kind === 'result' &&
+ response.update.kind !== 'active_tasks'
+ ) {
+ try {
+ await input.awaitUpdatedConnection(
+ profileId,
+ managed.profile.rootId,
+ previousHostEpoch,
+ response.update.kind !== 'already_current',
+ );
+ } catch (error) {
+ return {
+ schemaVersion: 1,
+ kind: 'error',
+ action: 'update',
+ error: {
+ code: 'desktop_reconnect_failed',
+ message:
+ 'The Runtime Host update completed, but Desktop could not reconnect: ' +
+ (error instanceof Error ? error.message : String(error)),
+ },
+ };
+ }
+ }
return response.kind === 'result'
? {
...response,
diff --git a/apps/desktop/src/main/runtime-host-profile-service.ts b/apps/desktop/src/main/runtime-host-profile-service.ts
index 38b2fcee2..92c9aff15 100644
--- a/apps/desktop/src/main/runtime-host-profile-service.ts
+++ b/apps/desktop/src/main/runtime-host-profile-service.ts
@@ -113,6 +113,7 @@ export interface DesktopRuntimeHostProfileService {
startEnabledProfiles(): Promise<void>;
resolvePairingRecovery(): Promise<DesktopRuntimeHostProfileSnapshot>;
setEnabled(profileId: string, enabled: boolean): Promise<DesktopRuntimeHostProfileSnapshot>;
+ reconnect(profileId: string, expectedRootId: string): Promise<void>;
setDefault(profileId: string): Promise<DesktopRuntimeHostProfileSnapshot>;
remove(profileId: string): Promise<DesktopRuntimeHostProfileSnapshot>;
}
@@ -875,6 +876,20 @@ export function createDesktopRuntimeHostProfileService(input: {
return snapshot();
});
},
+ reconnect(profileId, expectedRootId) {
+ return mutateProfiles(async () => {
+ assertPairingComplete(profileId);
+ if (!preferences.enabledRemoteProfileIds.includes(profileId)) {
+ throw new Error('Enable this Runtime Host before reconnecting it');
+ }
+ const target = await catalog.resolve(profileId);
+ if (target.profile.kind !== 'remote' || target.profile.rootId !== expectedRootId) {
+ throw new Error('Runtime Host profile changed before it could reconnect');
+ }
+ await input.disable(profileId);
+ await activateTarget(target, 'terminal');
+ });
+ },
setDefault(profileId) {
return mutateProfiles(async () => {
if (
diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
index 12fe8ec27..071b8794c 100644
--- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts
+++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts
@@ -60,6 +60,7 @@ interface ActiveTerminal {
phase: 'connecting' | 'connected';
revealed: boolean;
dismissed: boolean;
+ presentationSuppressed: boolean;
output: string;
}
@@ -207,6 +208,23 @@ export function createDesktopRuntimeHostSshTerminal(input: {
clearTimeout(terminal.revealTimer);
terminal.revealTimer = undefined;
}
+ if (terminal.revealed && !terminal.presentationSuppressed) {
+ input.send('runtime-host-ssh-terminal:event', {
+ kind: 'connected',
+ revision,
+ sessionId: terminal.sessionId,
+ });
+ }
+ }
+ function suppressPresentation(terminal: ActiveTerminal): void {
+ if (active !== terminal || terminal.phase !== 'connecting') return;
+ terminal.presentationSuppressed = true;
+ presentation = undefined;
+ revision += 1;
+ if (terminal.revealTimer !== undefined) {
+ clearTimeout(terminal.revealTimer);
+ terminal.revealTimer = undefined;
+ }
if (terminal.revealed) {
input.send('runtime-host-ssh-terminal:event', {
kind: 'connected',
@@ -249,6 +267,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
phase: 'connecting',
revealed: false,
dismissed: false,
+ presentationSuppressed: false,
output: '',
};
active = terminal;
@@ -257,7 +276,8 @@ export function createDesktopRuntimeHostSshTerminal(input: {
active !== terminal ||
terminal.phase !== 'connecting' ||
terminal.revealed ||
- terminal.dismissed
+ terminal.dismissed ||
+ terminal.presentationSuppressed
) {
return;
}
@@ -270,7 +290,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
pty.onData((data) => {
if (active !== terminal || terminal.phase !== 'connecting' || terminal.dismissed) return;
const visible = transformOutput(data);
- if (!visible) return;
+ if (!visible || terminal.presentationSuppressed) return;
terminal.output = `${terminal.output}${visible}`.slice(-TERMINAL_OUTPUT_MAX);
reveal();
revision += 1;
@@ -396,6 +416,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
let frame: Frame | undefined;
let failure: Error | undefined;
let activeTerminal: ActiveTerminal | undefined;
+ let receivedProgress = false;
const filter = createFramedOutputFilter({
prefix: options.prefix,
pendingMaxBytes: options.pendingMaxBytes,
@@ -408,6 +429,8 @@ export function createDesktopRuntimeHostSshTerminal(input: {
return;
}
if (options.isTerminalFrame && !options.isTerminalFrame(next)) {
+ receivedProgress = true;
+ if (activeTerminal) suppressPresentation(activeTerminal);
options.onProgress?.(next);
return;
}
@@ -430,6 +453,7 @@ export function createDesktopRuntimeHostSshTerminal(input: {
);
activeTerminal = terminal;
if (frame) completePresentation(terminal);
+ else if (receivedProgress) suppressPresentation(terminal);
const wait = await waitForTerminalProcess(process, {
signal: options.signal,
timeoutMs: options.timeoutMs ?? input.managementTimeoutMs ?? MANAGEMENT_TIMEOUT_MS,
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 47f5a9826..528bd80e8 100644
--- a/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-service-manager.test.ts
@@ -36,6 +36,7 @@ import {
decodeRuntimeHostServiceManagementFrame,
RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY,
RUNTIME_HOST_OPERATOR_CAPABILITY_REQUEST_ENV,
+ type RuntimeHostServiceManagementFrame,
} from '@maka/runtime-host/operator';
import { RUNTIME_HOST_RETIREMENT_EXIT_CODE } from '@maka/runtime-host/server';
import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
@@ -541,6 +542,19 @@ describe('managed Runtime Host service', () => {
release.backend,
ready,
);
+ const releaseConfig = (
+ await manageRuntimeHostService({ ...input(releaseRoot), action: 'status' }, release.backend)
+ ).service.config;
+ assert.ok(releaseConfig);
+ await release.backend.verifyDeployment(releaseConfig);
+ const unit = await readFile(release.unitPath, 'utf8');
+ await writeFile(release.unitPath, `${unit}# stale change\n`);
+ await assert.rejects(
+ release.backend.verifyDeployment(releaseConfig),
+ (error: unknown) =>
+ error instanceof RuntimeHostServiceManagerError && error.code === 'target_mismatch',
+ );
+ await writeFile(release.unitPath, unit);
await manageRuntimeHostService(
{ ...input(developmentRoot), action: 'install' },
development.backend,
@@ -1269,7 +1283,7 @@ describe('managed Runtime Host service', () => {
assert.equal(config.launch.cliPath, await realpath(targetCli));
});
- it('updates through the current operator before activating the target service', async () => {
+ it('updates through the current operator and preserves exact update outcomes', async () => {
const clientDataRoot = '/home/ada/.config/maka';
const serviceId = resolveRuntimeHostManagedServiceId(clientDataRoot);
const deploymentRoot = '/home/ada/.local/share/Maka/runtime-host-services/service';
@@ -1308,6 +1322,8 @@ describe('managed Runtime Host service', () => {
let statusReads = 0;
let observedVersion = '1.0.0';
let observedState: 'running' | 'stopped' = 'running';
+ let readyChecks = 0;
+ let operatorFailure: Extract<RuntimeHostServiceManagementFrame, { kind: 'error' }> | undefined;
let insideLifecycle = false;
let output = '';
const options = {
@@ -1351,6 +1367,7 @@ describe('managed Runtime Host service', () => {
runOperator: async (_operatorPath: string, args: readonly string[]) => {
order.push('retire');
assert.ok(args.includes('--allow-interrupt-active-tasks'));
+ if (operatorFailure) return operatorFailure;
return {
schemaVersion: 1 as const,
kind: 'result' as const,
@@ -1369,6 +1386,9 @@ describe('managed Runtime Host service', () => {
retirement: { kind: 'retired' as const, hostEpoch: 'host-1', pid: 42 },
};
},
+ verifyReady: async () => {
+ readyChecks += 1;
+ },
manage: async (input: Parameters<typeof manageRuntimeHostService>[0]) => {
if (input.action === 'status') {
statusReads += 1;
@@ -1414,6 +1434,34 @@ describe('managed Runtime Host service', () => {
: undefined,
'repaired',
);
+
+ order.length = 0;
+ statusReads = 0;
+ observedState = 'running';
+ output = '';
+ assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 0);
+ assert.equal(readyChecks, 1);
+ assert.deepEqual(order, []);
+
+ statusReads = 0;
+ observedVersion = '1.0.0';
+ output = '';
+ operatorFailure = {
+ schemaVersion: 1,
+ kind: 'error',
+ action: 'retire',
+ error: { code: 'retirement_failed', message: 'The old Host rejected retirement' },
+ };
+ assert.equal(await runManagedRuntimeHostUpdateCli(options, overrides), 1);
+ assert.deepEqual(order, ['retire', 'rollback']);
+ const retirementFailure = decodeRuntimeHostServiceManagementFrame(
+ output.trim().split('\n').at(-1) ?? '',
+ );
+ assert.equal(retirementFailure?.kind, 'error');
+ assert.equal(
+ retirementFailure?.kind === 'error' ? retirementFailure.error.code : undefined,
+ 'retirement_failed',
+ );
});
it('rejects invalid Project roots and temporary npx launch paths before deployment', async (t) => {
@@ -1605,6 +1653,8 @@ function createFakeSystemd(unitPath: string): {
`ActiveState=${active ? 'active' : 'inactive'}`,
`SubState=${active ? 'running' : 'dead'}`,
`UnitFileState=${enabled ? 'enabled' : 'disabled'}`,
+ `FragmentPath=${loaded ? unitPath : ''}`,
+ 'NeedDaemonReload=no',
`MainPID=${active ? '4242' : '0'}`,
'ExecMainStatus=0',
'',
@@ -1624,6 +1674,7 @@ function createUnusedBackend(): RuntimeHostServiceBackend {
return {
preflightInstall: unexpected,
install: unexpected,
+ verifyDeployment: unexpected,
status: unexpected,
start: unexpected,
stop: unexpected,
@@ -1653,6 +1704,7 @@ function createReadyBackend(): RuntimeHostServiceBackend {
return {
preflightInstall: async () => undefined,
install: async () => ({ rollback: async () => undefined }),
+ verifyDeployment: async () => undefined,
status,
start: async () => undefined,
stop: 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 01bab4e9a..3013562ec 100644
--- a/packages/cli/src/__tests__/runtime-host-setup.test.ts
+++ b/packages/cli/src/__tests__/runtime-host-setup.test.ts
@@ -465,6 +465,7 @@ function unusedBackend(): RuntimeHostServiceBackend {
return {
preflightInstall: async () => undefined,
install: 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'),
stop: async () => assert.fail('Backend is not expected'),
diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts
index 5b3c9dec1..fb3b77222 100644
--- a/packages/cli/src/runtime-host-service-manager.ts
+++ b/packages/cli/src/runtime-host-service-manager.ts
@@ -31,7 +31,7 @@ import {
} from '@maka/runtime-host/protocol';
import { connectExistingRuntimeHost } from '@maka/runtime-host/client';
import { RUNTIME_HOST_SERVICE_LOG_MAX_BYTES } from '@maka/runtime-host/operator';
-import { withFileUpdateLock } from '@maka/storage/file-update-lock';
+import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock';
import {
resolveExistingStorageRoot,
tryAcquireInteractiveRootOwner,
@@ -94,6 +94,7 @@ export interface RuntimeHostServiceBackend {
config: RuntimeHostManagedServiceConfig,
options?: { readonly restoreOnFailure?: boolean },
): Promise<RuntimeHostServiceDeployment>;
+ verifyDeployment(config: RuntimeHostManagedServiceConfig): Promise<void>;
status(): Promise<RuntimeHostServiceBackendStatus>;
start(): Promise<void>;
stop(): Promise<void>;
@@ -223,7 +224,7 @@ export async function manageRuntimeHostService(
): Promise<RuntimeHostManagedServiceResult> {
const deps: RuntimeHostServiceManagerDeps = {
allocateLoopbackPort,
- waitForReady: waitForManagedRuntimeHostReady,
+ waitForReady: verifyRuntimeHostManagedServiceReady,
prepareRetirement: prepareRuntimeHostRetirement,
environment: process.env,
homeDir: homedir(),
@@ -236,7 +237,7 @@ export async function manageRuntimeHostService(
}
await mkdir(configDirectory, { recursive: true, mode: 0o700 });
- return withFileUpdateLock(
+ return withProcessLifetimeFileUpdateLock(
configPath,
() => manageRuntimeHostServiceLocked(input, backend, deps, configPath),
SERVICE_OPERATION_LOCK_TIMEOUT_MS,
@@ -249,7 +250,7 @@ export async function withRuntimeHostManagedServiceLifecycleLock<T>(
timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS,
): Promise<T> {
await mkdir(clientDataRoot, { recursive: true, mode: 0o700 });
- return withFileUpdateLock(
+ return withProcessLifetimeFileUpdateLock(
join(clientDataRoot, SERVICE_LIFECYCLE_LOCK_FILE),
operation,
timeoutMs,
@@ -262,7 +263,7 @@ export async function withRuntimeHostManagedServiceDeploymentLock<T>(
timeoutMs = SERVICE_OPERATION_LOCK_TIMEOUT_MS,
): Promise<T> {
await mkdir(clientDataRoot, { recursive: true, mode: 0o700 });
- return withFileUpdateLock(
+ return withProcessLifetimeFileUpdateLock(
join(clientDataRoot, SERVICE_DEPLOYMENT_LOCK_FILE),
operation,
timeoutMs,
@@ -276,7 +277,7 @@ export async function cleanupRuntimeHostManagedDeployment(
await withRuntimeHostManagedServiceLifecycleLock(input.clientDataRoot, async () => {
const configPath = resolveRuntimeHostManagedServiceConfigPath(input.clientDataRoot);
await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
- await withFileUpdateLock(
+ await withProcessLifetimeFileUpdateLock(
configPath,
async () => {
const serviceId = resolveRuntimeHostManagedServiceId(input.clientDataRoot);
@@ -882,10 +883,11 @@ function isWithin(root: string, candidate: string): boolean {
);
}
-async function waitForManagedRuntimeHostReady(
+export async function verifyRuntimeHostManagedServiceReady(
config: RuntimeHostManagedServiceConfig,
backend: RuntimeHostServiceBackend,
): Promise<void> {
+ await backend.verifyDeployment(config);
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 a57595348..9c9519ff6 100644
--- a/packages/cli/src/runtime-host-systemd-service.ts
+++ b/packages/cli/src/runtime-host-systemd-service.ts
@@ -109,6 +109,27 @@ export function createSystemdUserRuntimeHostService(
},
} satisfies RuntimeHostServiceDeployment;
},
+ verifyDeployment: async (config) => {
+ await validateLaunchFiles(config);
+ const [status, unit] = await Promise.all([
+ readSystemdStatus(context),
+ readFile(context.unitPath, 'utf8').catch((error: unknown) => {
+ if (isNodeError(error, 'ENOENT')) return null;
+ throw error;
+ }),
+ ]);
+ if (
+ status.loadState !== 'loaded' ||
+ status.fragmentPath !== context.unitPath ||
+ status.needDaemonReload !== 'no' ||
+ unit !== renderSystemdUnit(config)
+ ) {
+ throw new RuntimeHostServiceManagerError(
+ 'target_mismatch',
+ 'The loaded Runtime Host service does not match its managed deployment',
+ );
+ }
+ },
status: readStatus,
start: () => runLifecycleAction(context, 'start'),
stop: () => runLifecycleAction(context, 'stop'),
@@ -234,6 +255,8 @@ interface SystemdStatus {
readonly loadState: string;
readonly activeState: string;
readonly unitFileState: string;
+ readonly fragmentPath?: string;
+ readonly needDaemonReload?: string;
readonly mainPid?: string;
readonly execMainStatus?: string;
}
@@ -346,7 +369,7 @@ async function readSystemdStatus(context: SystemdUnitContext): Promise<SystemdSt
result = await context.runSystemctl([
'show',
context.unitName,
- '--property=LoadState,ActiveState,SubState,UnitFileState,MainPID,ExecMainStatus',
+ '--property=LoadState,ActiveState,SubState,UnitFileState,FragmentPath,NeedDaemonReload,MainPID,ExecMainStatus',
'--no-pager',
]);
} catch (error) {
@@ -365,6 +388,8 @@ async function readSystemdStatus(context: SystemdUnitContext): Promise<SystemdSt
loadState,
activeState: properties.get('ActiveState') ?? 'inactive',
unitFileState: properties.get('UnitFileState') ?? 'disabled',
+ fragmentPath: properties.get('FragmentPath'),
+ needDaemonReload: properties.get('NeedDaemonReload'),
mainPid: properties.get('MainPID'),
execMainStatus: properties.get('ExecMainStatus'),
};
diff --git a/packages/cli/src/runtime-host-update-command.ts b/packages/cli/src/runtime-host-update-command.ts
index 8c633ceeb..69005cc5d 100644
--- a/packages/cli/src/runtime-host-update-command.ts
+++ b/packages/cli/src/runtime-host-update-command.ts
@@ -39,6 +39,7 @@ import {
manageRuntimeHostService,
resolveRuntimeHostManagedServiceId,
RuntimeHostServiceManagerError,
+ verifyRuntimeHostManagedServiceReady,
withRuntimeHostManagedServiceDeploymentLock,
withRuntimeHostManagedServiceLifecycleLock,
type RuntimeHostManagedServiceResult,
@@ -70,6 +71,7 @@ interface RuntimeHostUpdateCliDeps {
readonly withLifecycleLock: typeof withRuntimeHostManagedServiceLifecycleLock;
readonly withDeploymentLock: typeof withRuntimeHostManagedServiceDeploymentLock;
readonly createBackend: (serviceId: string) => RuntimeHostServiceBackend;
+ readonly verifyReady: typeof verifyRuntimeHostManagedServiceReady;
readonly runOperator: (
operatorPath: string,
args: readonly string[],
@@ -88,6 +90,7 @@ export async function runManagedRuntimeHostUpdateCli(
withLifecycleLock: withRuntimeHostManagedServiceLifecycleLock,
withDeploymentLock: withRuntimeHostManagedServiceDeploymentLock,
createBackend: createPlatformRuntimeHostServiceBackend,
+ verifyReady: verifyRuntimeHostManagedServiceReady,
runOperator: runManagedRuntimeHostOperator,
writeOutput: (value) => process.stdout.write(value),
writeError: (value) => process.stderr.write(value),
@@ -143,6 +146,7 @@ export async function runManagedRuntimeHostUpdateCli(
}
emit(progress('checking', currentVersion, options.version));
if (currentVersion === options.version && status.service.active) {
+ await deps.verifyReady(serviceConfig, backend);
emit({
schemaVersion: 1,
kind: 'result',
@@ -178,7 +182,17 @@ export async function runManagedRuntimeHostUpdateCli(
...expectedTargetArgs(options.expectedTarget),
...(options.allowInterruptActiveTasks ? ['--allow-interrupt-active-tasks'] : []),
]);
- if (retirement.kind === 'error') throw new Error(retirement.error.message);
+ if (retirement.kind === 'error') {
+ if (retirement.action !== 'retire') {
+ throw new Error(
+ 'The current Runtime Host operator returned an unrelated retirement error',
+ );
+ }
+ await deployment.rollback().catch(() => undefined);
+ deployment = undefined;
+ emit({ ...retirement, action: 'update' });
+ return 1;
+ }
if (retirement.kind !== 'result' || retirement.action !== 'retire') {
throw new Error(
'The current Runtime Host operator returned an invalid retirement result',
diff --git a/packages/storage/package.json b/packages/storage/package.json
index f5b58c753..b45746f04 100644
--- a/packages/storage/package.json
+++ b/packages/storage/package.json
@@ -10,6 +10,7 @@
".": "./dist/index.js",
"./credential-store": "./dist/credential-store.js",
"./file-update-lock": "./dist/file-update-lock.js",
+ "./process-lifetime-file-update-lock": "./dist/process-lifetime-file-update-lock.js",
"./stable-storage": "./dist/stable-storage.js",
"./artifact-stores": "./dist/artifact-stores.js",
"./daily-review-authority": "./dist/daily-review-authority.js",
diff --git a/packages/storage/src/__tests__/file-update-lock.test.ts b/packages/storage/src/__tests__/file-update-lock.test.ts
new file mode 100644
index 000000000..febf6f675
--- /dev/null
+++ b/packages/storage/src/__tests__/file-update-lock.test.ts
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { fork } from 'node:child_process';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { withProcessLifetimeFileUpdateLock } from '../process-lifetime-file-update-lock.js';
+
+test('releases a file update lock when its process is killed', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-file-update-lock-'));
+ const targetPath = join(root, 'state');
+ const child = fork(
+ new URL('./fixtures/file-update-lock-holder.js', import.meta.url),
+ [targetPath],
+ { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] },
+ );
+ t.after(async () => {
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
+ await rm(root, { recursive: true, force: true });
+ });
+ await new Promise<void>((resolve, reject) => {
+ child.once('message', (message) => {
+ if (message === 'locked') resolve();
+ else reject(new Error(`Unexpected child message: ${String(message)}`));
+ });
+ child.once('error', reject);
+ child.once('exit', (code, signal) => {
+ reject(new Error(`Lock holder exited before acquisition (${String(code)}, ${signal})`));
+ });
+ });
+
+ child.kill('SIGKILL');
+ await new Promise<void>((resolve) => child.once('exit', () => resolve()));
+
+ let entered = false;
+ await withProcessLifetimeFileUpdateLock(
+ targetPath,
+ async () => {
+ entered = true;
+ },
+ 2_000,
+ );
+ assert.equal(entered, true);
+});
diff --git a/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts
new file mode 100644
index 000000000..4376b2742
--- /dev/null
+++ b/packages/storage/src/__tests__/fixtures/file-update-lock-holder.ts
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { withProcessLifetimeFileUpdateLock } from '../../process-lifetime-file-update-lock.js';
+
+const targetPath = process.argv[2];
+if (!targetPath) throw new Error('Missing file update lock target');
+
+await withProcessLifetimeFileUpdateLock(targetPath, async () => {
+ process.send?.('locked');
+ await new Promise<never>(() => setInterval(() => undefined, 1_000));
+});
diff --git a/packages/storage/src/process-lifetime-file-update-lock.ts b/packages/storage/src/process-lifetime-file-update-lock.ts
new file mode 100644
index 000000000..e94526f3b
--- /dev/null
+++ b/packages/storage/src/process-lifetime-file-update-lock.ts
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/// <reference path="./fs-native-extensions.d.ts" />
+
+import { constants as fsConstants } from 'node:fs';
+import { lstat, open, unlink, type FileHandle } from 'node:fs/promises';
+import { tryLock, unlock } from 'fs-native-extensions';
+
+const LOCK_POLL_MS = 25;
+const LOCK_TIMEOUT_MS = 10_000;
+const lockGates = new Map<string, Promise<void>>();
+
+export async function withProcessLifetimeFileUpdateLock<T>(
+ targetPath: string,
+ operation: () => Promise<T>,
+ timeoutMs: number = LOCK_TIMEOUT_MS,
+): Promise<T> {
+ const lockPath = `${targetPath}.lock`;
+ const deadline = Date.now() + timeoutMs;
+ const leasePath = `${targetPath}.lease`;
+ return runWithLockGate(leasePath, deadline, async () => {
+ const lease = await openStableLockFile(leasePath);
+ let leased = false;
+ let markerCreated = false;
+ try {
+ while (!(leased = tryLock(lease.fd))) await waitForLockTurn(lockPath, deadline);
+ await acquireLegacyMarker(lockPath, deadline);
+ markerCreated = true;
+ return await operation();
+ } finally {
+ try {
+ if (markerCreated) await unlink(lockPath).catch(ignoreMissing);
+ } finally {
+ if (leased) releaseLock(lease);
+ await lease.close();
+ }
+ }
+ });
+}
+
+async function runWithLockGate<T>(
+ lockPath: string,
+ deadline: number,
+ operation: () => Promise<T>,
+): Promise<T> {
+ const previous = lockGates.get(lockPath);
+ let release!: () => void;
+ const current = new Promise<void>((resolve) => {
+ release = resolve;
+ });
+ lockGates.set(lockPath, current);
+ try {
+ if (previous) await waitForGate(previous, lockPath, deadline);
+ return await operation();
+ } finally {
+ release();
+ if (lockGates.get(lockPath) === current) lockGates.delete(lockPath);
+ }
+}
+
+async function waitForGate(
+ previous: Promise<void>,
+ lockPath: string,
+ deadline: number,
+): Promise<void> {
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) throw lockTimeout(lockPath);
+ let timer: ReturnType<typeof setTimeout> | undefined;
+ try {
+ await Promise.race([
+ previous.catch(() => undefined),
+ new Promise<never>((_resolve, reject) => {
+ timer = setTimeout(() => reject(lockTimeout(lockPath)), remaining);
+ }),
+ ]);
+ } finally {
+ if (timer !== undefined) clearTimeout(timer);
+ }
+}
+
+async function openStableLockFile(lockPath: string): Promise<FileHandle> {
+ const handle = await open(
+ lockPath,
+ fsConstants.O_CREAT | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW,
+ 0o600,
+ );
+ try {
+ await assertStableRegularFile(handle, lockPath);
+ if (process.platform !== 'win32') await handle.chmod(0o600);
+ return handle;
+ } catch (error) {
+ await handle.close();
+ throw error;
+ }
+}
+
+async function acquireLegacyMarker(lockPath: string, deadline: number): Promise<void> {
+ for (;;) {
+ try {
+ const marker = await open(
+ lockPath,
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW,
+ 0o600,
+ );
+ await marker.close();
+ return;
+ } catch (error) {
+ if (!isNodeError(error, 'EEXIST')) throw error;
+ }
+ const existing = await lstat(lockPath).catch((error: unknown) => {
+ if (isNodeError(error, 'ENOENT')) return undefined;
+ throw error;
+ });
+ if (!existing) continue;
+ if (existing.isFile() && !existing.isSymbolicLink()) {
+ // Current writers hold the advisory lease before publishing this marker.
+ // Owning the lease proves a remaining regular marker is stale.
+ await unlink(lockPath);
+ continue;
+ }
+ if (!existing.isDirectory() || existing.isSymbolicLink()) {
+ throw new Error(`File update lock path is not a regular marker: ${lockPath}`);
+ }
+ // Older builds use the directory itself as their live lock and publish no
+ // owner identity, so it cannot be safely stolen.
+ await waitForLockTurn(lockPath, deadline);
+ }
+}
+
+async function assertStableRegularFile(handle: FileHandle, lockPath: string): Promise<void> {
+ const [handleStat, pathStat] = await Promise.all([
+ handle.stat({ bigint: true }),
+ lstat(lockPath, { bigint: true }),
+ ]);
+ if (
+ !handleStat.isFile() ||
+ !pathStat.isFile() ||
+ handleStat.dev !== pathStat.dev ||
+ handleStat.ino !== pathStat.ino
+ ) {
+ throw new Error(`File update lease is not one stable regular file: ${lockPath}`);
+ }
+}
+
+async function waitForLockTurn(lockPath: string, deadline: number): Promise<void> {
+ if (Date.now() >= deadline) throw lockTimeout(lockPath);
+ await new Promise<void>((resolve) => setTimeout(resolve, LOCK_POLL_MS));
+}
+
+function lockTimeout(lockPath: string): Error {
+ return new Error(`File update is locked by another process (${lockPath})`);
+}
+
+function releaseLock(handle: FileHandle): void {
+ try {
+ unlock(handle.fd);
+ } catch {
+ // Closing the OS handle is the authoritative release path.
+ }
+}
+
+function ignoreMissing(error: unknown): void {
+ if (!isNodeError(error, 'ENOENT')) throw error;
+}
+
+function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException {
+ return error instanceof Error && 'code' in error && error.code === code;
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org