Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 59 additions & 57 deletions apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,7 @@ test('does not treat an in-flight replacement as retired after admission times o
retirement,
(error: unknown) =>
error instanceof DesktopLocalHostRetirementError &&
error.facts.pid === undefined &&
!error.facts.forceTerminationAvailable,
error.facts.pid === undefined,
);

releaseReconnect();
Expand Down Expand Up @@ -308,10 +307,56 @@ test('retires the owned ephemeral Host before Desktop quit', async () => {
'wait:42',
]);
await owner.close();
assert.equal(events.at(-1), 'release-launches');
assert.ok(!events.includes('release-launches'));
assert.ok(!events.includes('resume-launches'));
});

test('probes owned Host activity for the quit consent dialog without retiring it', async () => {
const active = candidateHarness({ upgradeBlockingActivity: true });
const owner = await startRuntimeHostDesktopManager(
{} as DesktopRuntimeHostCandidateStartInput,
{ startCandidate: async () => ready(active.candidate) },
);

assert.deepEqual(await owner.probeOwnedLocalHostActivity(), { kind: 'active_tasks' });
assert.equal(active.prepareRetirementCalls, 0);
await owner.close();
});

test('probe treats a missing activity field as clear and never retires', async () => {
const current = candidateHarness();
const owner = await startRuntimeHostDesktopManager(
{} as DesktopRuntimeHostCandidateStartInput,
{ startCandidate: async () => ready(current.candidate) },
);

assert.deepEqual(await owner.probeOwnedLocalHostActivity(), { kind: 'clear' });
assert.equal(current.prepareRetirementCalls, 0);
await owner.close();
});

test('probe reports not_owned for a Host this Desktop does not own', async () => {
const external = candidateHarness({ ownership: 'external' });
const owner = await startRuntimeHostDesktopManager(
{} as DesktopRuntimeHostCandidateStartInput,
{ startCandidate: async () => ready(external.candidate) },
);

assert.deepEqual(await owner.probeOwnedLocalHostActivity(), { kind: 'not_owned' });
await owner.close();
});

test('probe failure never blocks quit', async () => {
const wedged = candidateHarness({ diagnosticsError: new Error('connection lost') });
const owner = await startRuntimeHostDesktopManager(
{} as DesktopRuntimeHostCandidateStartInput,
{ startCandidate: async () => ready(wedged.candidate) },
);

assert.deepEqual(await owner.probeOwnedLocalHostActivity(), { kind: 'clear' });
await owner.close();
});

test('does not retire the local Host twice when an update handoff triggers quit', async () => {
const current = candidateHarness({ disconnectOnPrepare: true });
const waitedFor: number[] = [];
Expand Down Expand Up @@ -461,7 +506,7 @@ test('retires unadopted candidates before draining the tracked Host', async () =
if (retirement.kind === 'retired') retirement.resume();
assert.equal(events.at(-1), 'resume-launches');
await owner.close();
assert.equal(events.at(-1), 'release-launches');
assert.ok(!events.includes('release-launches'));
});

test('resumes candidate launches when active tasks block the update', async () => {
Expand All @@ -486,7 +531,7 @@ test('resumes candidate launches when active tasks block the update', async () =
});
assert.deepEqual(events, ['pause', 'retire', 'resume']);
await owner.close();
assert.equal(events.at(-1), 'release');
assert.ok(!events.includes('release'));
});

test('preserves Host facts when authorized retirement is refused', async () => {
Expand All @@ -511,57 +556,6 @@ test('preserves Host facts when authorized retirement is refused', async () => {
await owner.close();
});

test('fences replacement launches while force-terminating the exact failed retirement', async () => {
const events: string[] = [];
const current = candidateHarness({
ownedProcess: {
pid: 42,
exited: new Promise(() => {}),
},
});
const owner = await startRuntimeHostDesktopManager({
rootPath: '/test-root',
candidateLaunchBarrier: {
connect: async () => assert.fail('mocked candidate startup bypasses the barrier'),
pause: () => events.push('pause'),
retireExcept: async (pid: number) => {
events.push(`retire:${pid}`);
},
resume: () => events.push('resume'),
release: () => events.push('release'),
},
} as unknown as DesktopRuntimeHostCandidateStartInput, {
startCandidate: async () => ready(current.candidate),
forceTerminateHost: async (identity, stillOwnsProcess) => {
assert.deepEqual(identity, {
rootPath: '/test-root',
rootId: 'test-host',
hostEpoch: 'test-host-epoch',
pid: 42,
});
assert.equal(stillOwnsProcess(), true);
events.push('terminate');
return true;
},
});

assert.equal(
await owner.forceTerminateOwnedLocalHost({
hostId: 'test-host',
hostEpoch: 'test-host-epoch',
lifecycleMode: 'ephemeral',
rootPath: '/test-root',
pid: 42,
forceTerminationAvailable: true,
}),
true,
);
assert.deepEqual(events, ['pause', 'retire:42', 'terminate']);
assert.equal((await owner.retireOwnedLocalHost('refuse_active_work')).kind, 'retired');
await owner.close();
assert.equal(events.at(-1), 'release');
});

test('resumes candidate launches when candidate retirement fails', async () => {
const events: string[] = [];
const current = candidateHarness();
Expand Down Expand Up @@ -1812,6 +1806,8 @@ function candidateHarness(
delayDisconnect?: boolean;
disconnectOnPrepare?: boolean;
activeTasks?: boolean | 'always';
upgradeBlockingActivity?: boolean;
diagnosticsError?: Error;
ownership?: 'owned_ephemeral' | 'supervised' | 'external';
ownedProcess?: RuntimeHostSpawnedProcess;
hostId?: string;
Expand Down Expand Up @@ -1846,7 +1842,13 @@ function candidateHarness(
return lifecycleState;
},
async queryHostDiagnostics() {
return { pid: 42 };
if (options.diagnosticsError) throw options.diagnosticsError;
return {
pid: 42,
...(options.upgradeBlockingActivity === undefined
? {}
: { upgradeBlockingActivity: options.upgradeBlockingActivity }),
};
},
async prepareHostRetirement(mode: string) {
prepareRetirementCalls += 1;
Expand Down
56 changes: 12 additions & 44 deletions apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,53 +19,21 @@

import assert from 'node:assert/strict';
import test from 'node:test';
import { DesktopLocalHostRetirementError } from '../runtime-host-desktop-manager.js';
import {
buildRuntimeHostActiveQuitDialog,
buildRuntimeHostQuitFailureDialog,
} from '../runtime-host-quit-copy.js';
import { buildRuntimeHostActiveQuitDialog } from '../runtime-host-quit-copy.js';

const failure = new DesktopLocalHostRetirementError(
{
hostId: 'root-id',
hostEpoch: 'host-epoch',
lifecycleMode: 'ephemeral',
rootPath: '/state/root',
pid: 4242,
forceTerminationAvailable: true,
},
{ cause: new Error('writer release timed out') },
);
const manualFailure = new DesktopLocalHostRetirementError(
{ ...failure.facts, forceTerminationAvailable: false },
{ cause: failure.cause },
);

for (const locale of ['en', 'zh-CN'] as const) {
test(`quit failure copy exposes actionable Host facts in ${locale}`, () => {
const dialog = buildRuntimeHostQuitFailureDialog(manualFailure, locale);

assert.match(dialog.options.detail ?? '', /4242/);
assert.match(dialog.options.detail ?? '', /host-epoch/);
assert.match(dialog.options.detail ?? '', /\/state\/root/);
assert.match(dialog.options.detail ?? '', /writer release timed out/);
});
}

test('manual recovery copy names a cross-platform process-management concept', () => {
const english = buildRuntimeHostQuitFailureDialog(manualFailure, 'en').options.detail ?? '';
const chinese = buildRuntimeHostQuitFailureDialog(manualFailure, 'zh-CN').options.detail ?? '';
test('quit dialog defaults to preserving background work', () => {
const active = buildRuntimeHostActiveQuitDialog('en');

assert.match(english, /operating system's process-management tool/);
assert.match(chinese, /操作系统的进程管理工具/);
assert.doesNotMatch(`${english}\n${chinese}`, /Activity Monitor|Task Manager|活动监视器|任务管理器/);
assert.equal(active.decisions[active.options.defaultId ?? -1], 'cancel');
assert.deepEqual(active.decisions, ['quit', 'cancel']);
});

test('quit dialogs default to preserving background work', () => {
const active = buildRuntimeHostActiveQuitDialog('en');
const recovery = buildRuntimeHostQuitFailureDialog(failure, 'en');
test('quit dialog copy promises durable recovery in every locale', () => {
const english = buildRuntimeHostActiveQuitDialog('en').options.detail ?? '';
const chinese = buildRuntimeHostActiveQuitDialog('zh-CN').options.detail ?? '';
const traditional = buildRuntimeHostActiveQuitDialog('zh-TW').options.detail ?? '';

assert.equal(active.decisions[active.options.defaultId ?? -1], 'cancel');
assert.equal(recovery.decisions[recovery.options.defaultId ?? -1], 'cancel');
assert.deepEqual(recovery.decisions, ['retry', 'force', 'cancel']);
assert.match(english, /durable state/);
assert.match(chinese, /持久状态/);
assert.match(traditional, /持久狀態/);
});
92 changes: 35 additions & 57 deletions apps/desktop/src/main/__tests__/runtime-host-quit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,86 +19,64 @@

import assert from 'node:assert/strict';
import test from 'node:test';
import type { RuntimeHostRetirementMode } from '@maka/runtime-host/client';
import { DesktopLocalHostRetirementError } from '../runtime-host-desktop-manager.js';
import { prepareRuntimeHostQuit } from '../runtime-host-quit.js';

test('background work requires consent before interruption', async () => {
const modes: RuntimeHostRetirementMode[] = [];
test('quit proceeds without consent when no owned Host is probed', async () => {
const probes: string[] = [];
const owner = {
retireOwnedLocalHost: async (mode: RuntimeHostRetirementMode) => {
modes.push(mode);
return mode === 'refuse_active_work'
? ({ kind: 'active_tasks' } as const)
: ({ kind: 'retired', resume: () => {} } as const);
probeOwnedLocalHostActivity: async () => {
probes.push('probe');
return { kind: 'not_owned' } as const;
},
forceTerminateOwnedLocalHost: async () => assert.fail('force termination is not expected'),
};
const recoverFailure = async () => assert.fail('recovery is not expected');

assert.equal(
await prepareRuntimeHostQuit(owner, {
confirmInterrupt: async () => false,
recoverFailure,
confirmInterrupt: async () => assert.fail('consent is not expected without an owned Host'),
}),
'cancelled',
'ready',
);
assert.deepEqual(modes, ['refuse_active_work']);
assert.deepEqual(probes, ['probe']);
});

test('quit proceeds without consent when the owned Host is clear', async () => {
const owner = {
probeOwnedLocalHostActivity: async () => ({ kind: 'clear' }) as const,
};

assert.equal(
await prepareRuntimeHostQuit(owner, {
confirmInterrupt: async () => true,
recoverFailure,
confirmInterrupt: async () => assert.fail('consent is not expected when idle'),
}),
'ready',
);
assert.deepEqual(modes, [
'refuse_active_work',
'refuse_active_work',
'interrupt_active_work',
]);
});

test('failed force termination stays inside the quit recovery decision', async () => {
const retirement = new DesktopLocalHostRetirementError(
{
hostId: 'root-id',
hostEpoch: 'host-epoch',
lifecycleMode: 'ephemeral',
rootPath: '/state/root',
pid: 4242,
forceTerminationAvailable: true,
},
{ cause: new Error('graceful retirement timed out') },
);
const recovery: Array<{ canForceTerminate: boolean; cause: string | undefined }> = [];
test('background work requires consent before quitting', async () => {
const probes: string[] = [];
const owner = {
retireOwnedLocalHost: async () => Promise.reject(retirement),
forceTerminateOwnedLocalHost: async () => {
throw new Error('process access denied');
probeOwnedLocalHostActivity: async () => {
probes.push('probe');
return { kind: 'active_tasks' } as const;
},
};

assert.equal(
await prepareRuntimeHostQuit(owner, {
confirmInterrupt: async () => assert.fail('active-work consent is not expected'),
recoverFailure: async (error) => {
const canForceTerminate =
error instanceof DesktopLocalHostRetirementError &&
error.facts.forceTerminationAvailable;
recovery.push({
canForceTerminate,
cause: error instanceof Error && error.cause instanceof Error
? error.cause.message
: undefined,
});
return canForceTerminate ? 'force' : 'cancel';
},
}),
await prepareRuntimeHostQuit(owner, { confirmInterrupt: async () => false }),
'cancelled',
);
assert.deepEqual(recovery, [
{ canForceTerminate: true, cause: 'graceful retirement timed out' },
{ canForceTerminate: false, cause: 'process access denied' },
]);
assert.equal(
await prepareRuntimeHostQuit(owner, { confirmInterrupt: async () => true }),
'ready',
);
assert.deepEqual(probes, ['probe', 'probe']);
});

test('quit proceeds without an owner', async () => {
assert.equal(
await prepareRuntimeHostQuit(undefined, {
confirmInterrupt: async () => assert.fail('consent is not expected without an owner'),
}),
'ready',
);
});
7 changes: 0 additions & 7 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,6 @@ import {
} from "./runtime-host-startup-recovery.js";
import {
buildRuntimeHostActiveQuitDialog,
buildRuntimeHostQuitFailureDialog,
} from "./runtime-host-quit-copy.js";
import { prepareRuntimeHostQuit } from "./runtime-host-quit.js";
import { createRuntimeHostUpgradePrompts } from "./runtime-host-upgrade-dialog.js";
Expand Down Expand Up @@ -1984,12 +1983,6 @@ async function prepareRuntimeHostDesktopQuit(): Promise<'ready' | 'cancelled'> {
const { response } = await showDesktopMessageBox(dialog.options, { locale });
return dialog.decisions[response] === 'quit';
},
recoverFailure: async (error) => {
const locale = await desktopLocale.resolve();
const dialog = buildRuntimeHostQuitFailureDialog(error, locale);
const { response } = await showDesktopMessageBox(dialog.options, { locale });
return dialog.decisions[response] ?? 'cancel';
},
});
if (preparation === 'ready') mainWindowController.browserWindow()?.destroy();
return preparation;
Expand Down
Loading