fix(epg): report retired worker exits as cancellations

This commit is contained in:
4gray committed 2026-09-05 23:06:22 +02:00
1 parent a7bd81a848
commit 6d406c85e2
3 files changed
+68 -28

No files matched your search

@@ -182,6 +182,29 @@ export class EpgWorkerService {
fn();
};
const cancelIfRetired = (exited = false): boolean => {
if (generation === epgSourceGeneration(url)) return false;
if (this.workers.get(url) === worker) this.workers.delete(url);
settle(() => {
this.sendProgressToRenderer(
url,
'cancelled',
undefined,
undefined,
undefined,
undefined,
undefined,
generation
);
if (exited) resolve();
else
void this.terminateWorker(worker, 'retired fetch').then(
() => resolve()
);
});
return true;
};
const scheduleFetchTimeout = () => {
clearFetchTimeout();
timeoutId = setTimeout(() => {
@@ -207,6 +230,7 @@ export class EpgWorkerService {
};
const handleFetchTimeout = () => {
if (settled || cancelIfRetired()) return;
const errorMessage = `EPG fetch timed out after ${
this.fetchTimeoutMs / 1000
}s without progress`;
@@ -313,6 +337,7 @@ export class EpgWorkerService {
break;
}
} catch (err) {
if (settled || cancelIfRetired()) return;
console.error(
this.loggerLabel,
'Error handling message:',
@@ -335,6 +360,7 @@ export class EpgWorkerService {
});
worker.on('error', (error) => {
if (settled || cancelIfRetired()) return;
console.error(this.loggerLabel, 'Worker error event:', error);
this.sendProgressToRenderer(
url,
@@ -351,7 +377,7 @@ export class EpgWorkerService {
});
worker.on('exit', (code) => {
if (settled) return;
if (settled || cancelIfRetired(true)) return;
const errorMessage = `Worker exited unexpectedly (code ${code})`;
console.error(this.loggerLabel, `${errorMessage}: ${url}`);
this.sendProgressToRenderer(
@@ -287,32 +287,45 @@ describe('EpgEvents', () => {
await flushPromises();
});
it('does not revive a retired source from late worker READY or COMPLETE messages', async () => {
const service = new EpgWorkerService('[Test EPG]', 1000);
const url = 'https://removed.example/guide.xml';
const fetch = service.fetchEpgFromUrl(url).catch(() => undefined);
const worker = mockWorkerInstances[0];
let finishTermination!: () => void;
worker.terminate.mockReturnValue(
new Promise<void>((resolve) => {
finishTermination = resolve;
})
);
const clear = service.clearEpgDataForSource(url);
worker.emit('message', { type: 'READY' });
worker.emit('message', { type: 'EPG_COMPLETE' });
expect(worker.postMessage).not.toHaveBeenCalled();
expect(service.hasFetchedUrl(url)).toBe(false);
expect(mockWorkerInstances).toHaveLength(1);
worker.emit('exit', 1);
finishTermination();
await flushPromises();
const clearWorker = mockWorkerInstances[1];
clearWorker.emit('message', { type: 'READY' });
clearWorker.emit('message', { type: 'CLEAR_COMPLETE' });
await clear;
await fetch;
});
it.each([
['exit', 1],
['error', new Error('terminated worker')],
] as const)(
'cancels a retired source on %s without reviving it from late READY or COMPLETE messages',
async (event, payload) => {
const service = new EpgWorkerService('[Test EPG]', 1000);
const url = 'https://removed.example/guide.xml';
const progress = jest.spyOn(service, 'sendProgressToRenderer');
const fetch = service.fetchEpgFromUrl(url).then(
() => true,
() => false
);
const worker = mockWorkerInstances[0];
let finishTermination!: () => void;
worker.terminate.mockReturnValue(
new Promise<void>((resolve) => {
finishTermination = resolve;
})
);
const clear = service.clearEpgDataForSource(url);
worker.emit('message', { type: 'READY' });
worker.emit('message', { type: 'EPG_COMPLETE' });
expect(worker.postMessage).not.toHaveBeenCalled();
expect(service.hasFetchedUrl(url)).toBe(false);
expect(mockWorkerInstances).toHaveLength(1);
worker.emit(event, payload);
finishTermination();
await flushPromises();
const clearWorker = mockWorkerInstances[1];
clearWorker.emit('message', { type: 'READY' });
clearWorker.emit('message', { type: 'CLEAR_COMPLETE' });
await clear;
expect(await fetch).toBe(true);
expect(progress.mock.calls.map((call) => call[1])).toEqual([
'cancelled',
]);
}
);
it('does not start a queued source removed while an earlier source imports', async () => {
getDatabase.mockRejectedValue(new Error('force stale for test'));
+2 -1
View File
@@ -1335,7 +1335,8 @@ existing source-clear worker. Successfully cleared request candidates are forgot
without resetting their generation fences; failed cleanups remain retryable.
Same-URL clears are serialized and replacement imports await the outstanding
clear, so an older cleanup cannot erase a newly re-added source.
Retired queued imports emit cancellation so progress rows disappear. Programmes are deleted by source; a globally keyed
Retired queued and running imports emit generation-scoped cancellation so progress
rows disappear without reporting routine worker termination as an import failure. Programmes are deleted by source; a globally keyed
channel is retained while another source still has programmes, transferring its
legacy owner to that remaining source. Manual mappings are preserved and can
resolve another retained source sharing that channel ID. Legacy programmes with