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
212 changes: 212 additions & 0 deletions apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('app quit coordinator', () => {
prepareToQuit: async () => 'ready' as const,
cleanup: async () => {},
focusOrCreateWindow: () => {},
forceExit: () => {},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
Expand Down Expand Up @@ -72,6 +73,7 @@ describe('app quit coordinator', () => {
focusOrCreateWindow: () => {
focusOrCreateCount += 1;
},
forceExit: () => {},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
Expand Down Expand Up @@ -119,6 +121,7 @@ describe('app quit coordinator', () => {
focusOrCreateCount += 1;
windowCreationSignal = signal;
},
forceExit: () => {},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
Expand All @@ -145,6 +148,7 @@ describe('app quit coordinator', () => {
focusOrCreateWindow: () => {
focusOrCreateCount += 1;
},
forceExit: () => {},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
Expand All @@ -170,6 +174,7 @@ describe('app quit coordinator', () => {
focusOrCreateWindow: async () => {
throw failure;
},
forceExit: () => {},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: (error) => reportedErrors.push(error),
Expand Down Expand Up @@ -202,6 +207,7 @@ describe('app quit coordinator', () => {
focusOrCreateWindow: () => {
focusOrCreateCount += 1;
},
forceExit: () => {},
onPreparationError: (error) => reportedErrors.push(error),
onCleanupError: () => {},
onWindowCreationError: () => {},
Expand Down Expand Up @@ -239,6 +245,7 @@ describe('app quit coordinator', () => {
focusOrCreateWindow: () => {
focusOrCreateCount += 1;
},
forceExit: () => {},
onPreparationError: () => {},
onCleanupError: (error: unknown) => {
reportedErrors.push(error);
Expand All @@ -265,8 +272,213 @@ describe('app quit coordinator', () => {
assert.equal(resumeQuitCount, 1);
assert.equal(secondQuitPrevented, false);
});

it('forces exit when cleanup never settles', async () => {
const timeouts: Array<{ stage: string; timeoutMs: number }> = [];
let forceExitCount = 0;
let resumeQuitCount = 0;
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => 'ready',
cleanup: () => new Promise<void>(() => {}),
focusOrCreateWindow: () => {},
forceExit: () => {
forceExitCount += 1;
},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
onQuitTimeout: (stage, timeoutMs) => {
timeouts.push({ stage, timeoutMs });
},
resumeQuit: () => {
resumeQuitCount += 1;
},
cleanupTimeoutMs: 10,
});

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

assert.deepEqual(timeouts, [{ stage: 'cleaning', timeoutMs: 10 }]);
assert.equal(forceExitCount, 1);
assert.equal(resumeQuitCount, 0);
});

it('still quits when quit preparation never settles', async () => {
const timeouts: string[] = [];
let cleanupCount = 0;
let forceExitCount = 0;
let resumeQuitCount = 0;
const coordinator = createAppQuitCoordinator({
prepareToQuit: () => new Promise<'ready' | 'cancelled'>(() => {}),
cleanup: async () => {
cleanupCount += 1;
},
focusOrCreateWindow: () => {},
forceExit: () => {
forceExitCount += 1;
},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
onQuitTimeout: (stage) => {
timeouts.push(stage);
},
resumeQuit: () => {
resumeQuitCount += 1;
},
prepareTimeoutMs: 10,
});

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

assert.deepEqual(timeouts, ['preparing']);
assert.equal(cleanupCount, 1);
assert.equal(forceExitCount, 0);
assert.equal(resumeQuitCount, 1);
});

it('unwinds staged work when quit preparation is cancelled', async () => {
let abandonedCount = 0;
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => 'cancelled',
cleanup: async () => {},
focusOrCreateWindow: () => {},
forceExit: () => {},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
onQuitAbandoned: () => {
abandonedCount += 1;
},
resumeQuit: () => {},
});

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

assert.equal(abandonedCount, 1);
});

it('unwinds staged work when quit preparation fails', async () => {
let abandonedCount = 0;
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => {
throw new Error('retirement failed');
},
cleanup: async () => {},
focusOrCreateWindow: () => {},
forceExit: () => {},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
onQuitAbandoned: () => {
abandonedCount += 1;
},
resumeQuit: () => {},
});

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

assert.equal(abandonedCount, 1);
});

it('leaves staged work alone once the quit is committed', async () => {
// Unwinding here would resume a Host the process is about to drop, and
// an install handoff would be rolled back out from under the updater.
let abandonedCount = 0;
let resumeQuitCount = 0;
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => 'ready',
cleanup: async () => {},
focusOrCreateWindow: () => {},
forceExit: () => {},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
onQuitAbandoned: () => {
abandonedCount += 1;
},
resumeQuit: () => {
resumeQuitCount += 1;
},
});

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

assert.equal(resumeQuitCount, 1);
assert.equal(abandonedCount, 0);
});

it('leaves staged work alone when a stuck quit is forced through', async () => {
let abandonedCount = 0;
let forceExitCount = 0;
const coordinator = createAppQuitCoordinator({
prepareToQuit: async () => 'ready',
cleanup: () => new Promise<void>(() => {}),
focusOrCreateWindow: () => {},
forceExit: () => {
forceExitCount += 1;
},
onPreparationError: () => {},
onCleanupError: () => {},
onWindowCreationError: () => {},
onQuitAbandoned: () => {
abandonedCount += 1;
},
resumeQuit: () => {},
cleanupTimeoutMs: 10,
});

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

assert.equal(forceExitCount, 1);
assert.equal(abandonedCount, 0);
});

it('does not report an abandoned stage as a failure', async () => {
// A stage that stopped answering is not a Host that refused: routing it to
// the error sinks would invent a failure the product then has to explain.
const preparationErrors: unknown[] = [];
const cleanupErrors: unknown[] = [];
let forceExitCount = 0;
const coordinator = createAppQuitCoordinator({
prepareToQuit: () => new Promise<'ready' | 'cancelled'>(() => {}),
cleanup: () => new Promise<void>(() => {}),
focusOrCreateWindow: () => {},
forceExit: () => {
forceExitCount += 1;
},
onPreparationError: (error) => {
preparationErrors.push(error);
},
onCleanupError: (error) => {
cleanupErrors.push(error);
},
onWindowCreationError: () => {},
resumeQuit: () => {},
prepareTimeoutMs: 5,
cleanupTimeoutMs: 5,
});

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

assert.deepEqual(preparationErrors, []);
assert.deepEqual(cleanupErrors, []);
assert.equal(forceExitCount, 1);
});
});

async function delay(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function flushQuitCoordinator(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/main/__tests__/app-update-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,40 @@ describe('AppUpdateService', () => {
assert.deepEqual(order, ['host-prepared', 'install']);
});

test('releases the Host handoff when the dispatched quit never happens', async () => {
let rollbacks = 0;
const updater = new FakeUpdater();
const { service } = createHarness({
updater,
prepareInstall: async () => ({
kind: 'prepared',
rollback: () => {
rollbacks += 1;
},
}),
});
updater.emit('update-downloaded', {
...updateInfo('1.1.0'),
downloadedFile: '/tmp/maka-update.zip',
});
await settleUpdateVerification();

assert.deepEqual(await service.installUpdate({ allowInterruptActiveTasks: false }), {
ok: true,
});
assert.equal(rollbacks, 0);

// The installer was dispatched but the process stayed up. Nothing else
// observes a quit that did not happen, so the Host would stay retired
// with nothing left to restart it.
service.abandonPendingInstall();
assert.equal(rollbacks, 1);

// Releasing twice must not resume a Host a later install already owns.
service.abandonPendingInstall();
assert.equal(rollbacks, 1);
});

test('reports synchronous and asynchronous installer failures through status', async () => {
let synchronousRollbacks = 0;
const synchronous = createHarness({
Expand Down
Loading