Let a failed application teardown be retried instead of repeating its rejection

This commit is contained in:
German Khokhlov committed 2026-11-03 14:47:35 +04:00
1 parent 0c190c3a15
commit dd040b4f87
2 files changed
+46 -5

No files matched your search

+18 -5
View File
@@ -38,6 +38,7 @@ export function createApplication({
let state = Object.freeze({ status: 'created', phase: null }); let state = Object.freeze({ status: 'created', phase: null });
let startPromise; let startPromise;
let destroyPromise; let destroyPromise;
let destroyRequested = false;
let cleanupPromise; let cleanupPromise;
function publish(status, phase = null) { function publish(status, phase = null) {
@@ -51,21 +52,29 @@ export function createApplication({
} }
} }
// A release that fails stays registered and the failed attempt is not
// remembered, so a later destroy() retries exactly the failed releases.
function cleanup() { function cleanup() {
cleanupPromise ||= Promise.resolve().then(async () => { cleanupPromise ||= Promise.resolve().then(async () => {
const errors = []; const errors = [];
for (const phase of STOP_ORDER) { for (const phase of STOP_ORDER) {
const failed = [];
while (cleanups[phase].length) { while (cleanups[phase].length) {
const dispose = cleanups[phase].pop();
try { try {
await cleanups[phase].pop()(); await dispose();
} catch (error) { } catch (error) {
errors.push(error); errors.push(error);
failed.unshift(dispose);
} }
} }
cleanups[phase].push(...failed);
delete components[phase]; delete components[phase];
} }
if (errors.length) if (errors.length) {
cleanupPromise = null;
throw new AggregateError(errors, 'Application cleanup failed'); throw new AggregateError(errors, 'Application cleanup failed');
}
}); });
return cleanupPromise; return cleanupPromise;
} }
@@ -116,7 +125,7 @@ export function createApplication({
return Object.freeze({ return Object.freeze({
start() { start() {
if (destroyPromise) if (destroyRequested)
return Promise.reject(new Error('Application has been destroyed')); return Promise.reject(new Error('Application has been destroyed'));
// Assign before notifying subscribers, including reentrant start/destroy. // Assign before notifying subscribers, including reentrant start/destroy.
startPromise ||= Promise.resolve().then(initialize); startPromise ||= Promise.resolve().then(initialize);
@@ -124,21 +133,25 @@ export function createApplication({
}, },
destroy() { destroy() {
if (destroyPromise) return destroyPromise; if (destroyPromise) return destroyPromise;
destroyPromise = Promise.resolve().then(async () => { destroyRequested = true;
const attempt = Promise.resolve().then(async () => {
try { try {
await startPromise?.catch(() => {}); await startPromise?.catch(() => {});
await cleanup(); await cleanup();
publish('destroyed'); publish('destroyed');
} catch (error) { } catch (error) {
// A failed teardown can be retried by calling destroy() again.
if (destroyPromise === attempt) destroyPromise = null;
publish('failed'); publish('failed');
throw error; throw error;
} finally { } finally {
listeners.clear(); listeners.clear();
} }
}); });
destroyPromise = attempt;
controller.abort(); controller.abort();
publish('destroying'); publish('destroying');
return destroyPromise; return attempt;
}, },
getState: () => state, getState: () => state,
getComponents: () => Object.freeze({ ...components }), getComponents: () => Object.freeze({ ...components }),
+28
View File
@@ -197,6 +197,34 @@ test('cleanup errors are reported after all remaining resources are attempted',
assert.equal(app.getState().status, 'failed'); assert.equal(app.getState().status, 'failed');
}); });
test('a failed teardown can be retried and repeats only the failed release', async () => {
let failures = 1;
const { app, events } = fixture({
createData({ defer }) {
defer(() => events.push('data:released'));
defer(() => {
events.push('data:detach');
if (failures-- > 0) throw new Error('detach failed');
});
return {};
},
});
await app.start();
await assert.rejects(app.destroy(), AggregateError);
assert.equal(app.getState().status, 'failed');
const firstAttempt = events.splice(0);
assert.deepEqual(firstAttempt.slice(-4), [
'stop:Controls',
'data:detach',
'data:released',
'stop:Scene',
]);
await app.destroy();
assert.deepEqual(events, ['data:detach']);
assert.equal(app.getState().status, 'destroyed');
await assert.rejects(app.start(), /destroyed/);
});
test('subscribers can destroy during startup without allowing the next constructor', async () => { test('subscribers can destroy during startup without allowing the next constructor', async () => {
const { app, events } = fixture(); const { app, events } = fixture();
app.subscribe((state) => { app.subscribe((state) => {