Skip to content
Merged
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
39 changes: 34 additions & 5 deletions apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('app quit coordinator', () => {
let resumeQuitCount = 0;
let preventedCount = 0;
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => {},
prepareToQuit: async () => 'ready' as const,
cleanup: async () => {},
focusOrCreateWindow: () => {},
onPreparationError: () => {},
Expand Down Expand Up @@ -64,7 +64,7 @@ describe('app quit coordinator', () => {
releaseCleanup = resolve;
});
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => {},
prepareToQuit: async () => 'ready',
cleanup: async () => {
cleanupCount += 1;
await cleanupPending;
Expand Down Expand Up @@ -113,7 +113,7 @@ describe('app quit coordinator', () => {
let focusOrCreateCount = 0;
let windowCreationSignal: AbortSignal | undefined;
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => {},
prepareToQuit: async () => 'ready',
cleanup: () => new Promise<void>(() => {}),
focusOrCreateWindow: (signal) => {
focusOrCreateCount += 1;
Expand All @@ -133,11 +133,39 @@ describe('app quit coordinator', () => {
assert.equal(windowCreationSignal?.aborted, true);
});

it('restores the running app when quit preparation is cancelled', async () => {
let cleanupCount = 0;
let focusOrCreateCount = 0;
let resumeQuitCount = 0;
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => 'cancelled',
cleanup: async () => {
cleanupCount += 1;
},
focusOrCreateWindow: () => {
focusOrCreateCount += 1;
},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
resumeQuit: () => {
resumeQuitCount += 1;
},
});

coordinator.handleBeforeQuit({ preventDefault: () => {} });
await flushQuitCoordinator();

assert.equal(cleanupCount, 0);
assert.equal(resumeQuitCount, 0);
assert.equal(focusOrCreateCount, 1);
});

it('reports window creation failure without leaking an unhandled rejection', async () => {
const failure = new Error('window load failed');
const reportedErrors: unknown[] = [];
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => {},
prepareToQuit: async () => 'ready',
cleanup: async () => {},
focusOrCreateWindow: async () => {
throw failure;
Expand Down Expand Up @@ -166,6 +194,7 @@ describe('app quit coordinator', () => {
prepareToQuit: async () => {
preparationCount += 1;
if (preparationCount === 1) throw preparationError;
return 'ready';
},
cleanup: async () => {
cleanupCount += 1;
Expand Down Expand Up @@ -203,7 +232,7 @@ describe('app quit coordinator', () => {
let focusOrCreateCount = 0;
let resumeQuitCount = 0;
const deps = {
prepareToQuit: async () => {},
prepareToQuit: async () => 'ready' as const,
cleanup: async () => {
throw cleanupError;
},
Expand Down
110 changes: 110 additions & 0 deletions apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,65 @@ test('waits through a reconnect gap before quiescing Host retirement', async ()
await owner.close();
});

test('does not treat an in-flight replacement as retired after admission times out', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const first = candidateHarness({
ownedProcess: {
pid: 42,
exited: Promise.resolve({ code: 1, signal: null, stderr: '', stderrTruncated: false }),
},
});
const replacement = candidateHarness();
let starts = 0;
let reportReconnectStart!: () => void;
let releaseReconnect!: () => void;
const reconnectStarted = new Promise<void>((resolve) => {
reportReconnectStart = resolve;
});
const reconnectReleased = new Promise<void>((resolve) => {
releaseReconnect = resolve;
});
const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, {
startCandidate: async (input) => {
starts += 1;
if (starts === 1) return ready(first.candidate);
reportReconnectStart();
const signal = input.signal;
assert.ok(signal);
await Promise.race([
reconnectReleased,
new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
]);
return ready(replacement.candidate);
},
reconnectBackoff: { minMs: 0, maxMs: 0 },
waitForHostExit: async () => {},
});

first.disconnect();
await reconnectStarted;
const retirement = owner.retireOwnedLocalHost('interrupt_active_work');
t.mock.timers.tick(5_000);
await assert.rejects(
retirement,
(error: unknown) =>
error instanceof DesktopLocalHostRetirementError &&
error.facts.pid === undefined &&
!error.facts.forceTerminationAvailable,
);

releaseReconnect();
await owner.waitUntilReady('local');
assert.equal(
(await owner.retireOwnedLocalHost('interrupt_active_work')).kind,
'retired',
);
assert.equal(replacement.prepareRetirementCalls, 1);
await owner.close();
});

test('retires the owned ephemeral Host before Desktop quit', async () => {
const events: string[] = [];
const current = candidateHarness({
Expand Down Expand Up @@ -452,6 +511,57 @@ 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
33 changes: 25 additions & 8 deletions apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DesktopLocalHostRetirementError } from '../runtime-host-desktop-manager.js';
import { buildRuntimeHostQuitFailureDialog } from '../runtime-host-quit-copy.js';
import {
buildRuntimeHostActiveQuitDialog,
buildRuntimeHostQuitFailureDialog,
} from '../runtime-host-quit-copy.js';

const failure = new DesktopLocalHostRetirementError(
{
Expand All @@ -29,26 +32,40 @@ const failure = new DesktopLocalHostRetirementError(
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'] as const) {
test(`quit failure copy exposes actionable Host facts in ${locale}`, () => {
const dialog = buildRuntimeHostQuitFailureDialog(failure, locale);
const dialog = buildRuntimeHostQuitFailureDialog(manualFailure, locale);

assert.match(dialog.detail ?? '', /4242/);
assert.match(dialog.detail ?? '', /host-epoch/);
assert.match(dialog.detail ?? '', /\/state\/root/);
assert.match(dialog.detail ?? '', /writer release timed out/);
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(failure, 'en').detail ?? '';
const chinese = buildRuntimeHostQuitFailureDialog(failure, 'zh').detail ?? '';
const english = buildRuntimeHostQuitFailureDialog(manualFailure, 'en').options.detail ?? '';
const chinese = buildRuntimeHostQuitFailureDialog(manualFailure, 'zh').options.detail ?? '';

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

test('quit dialogs default to preserving background work', () => {
const active = buildRuntimeHostActiveQuitDialog('en');
const recovery = buildRuntimeHostQuitFailureDialog(failure, 'en');

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']);
});
104 changes: 104 additions & 0 deletions apps/desktop/src/main/__tests__/runtime-host-quit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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 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[] = [];
const owner = {
retireOwnedLocalHost: async (mode: RuntimeHostRetirementMode) => {
modes.push(mode);
return mode === 'refuse_active_work'
? ({ kind: 'active_tasks' } as const)
: ({ kind: 'retired', resume: () => {} } 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,
}),
'cancelled',
);
assert.deepEqual(modes, ['refuse_active_work']);

assert.equal(
await prepareRuntimeHostQuit(owner, {
confirmInterrupt: async () => true,
recoverFailure,
}),
'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 }> = [];
const owner = {
retireOwnedLocalHost: async () => Promise.reject(retirement),
forceTerminateOwnedLocalHost: async () => {
throw new Error('process access denied');
},
};

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';
},
}),
'cancelled',
);
assert.deepEqual(recovery, [
{ canForceTerminate: true, cause: 'graceful retirement timed out' },
{ canForceTerminate: false, cause: 'process access denied' },
]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -1093,7 +1093,7 @@ function createHarness(
terminateProcessTree: async ({ pid, signal, fallback, hasExited, beforeSignal }) => {
terminatedProcesses.push({ pid, signal });
await Promise.resolve();
if (hasExited?.() || beforeSignal?.() === false) return false;
if (hasExited?.() || (beforeSignal && !(await beforeSignal()))) return false;
fallback?.();
return true;
},
Expand Down
Loading