From 6d406c85e26d91509f612a8907e9058c9365b00a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 5 Sep 2026 23:06:22 +0200 Subject: [PATCH] fix(epg): report retired worker exits as cancellations --- .../src/app/events/epg-worker.service.ts | 28 +++++++- .../src/app/events/epg.events.spec.ts | 65 +++++++++++-------- docs/architecture/m3u-playlist-module.md | 3 +- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/apps/electron-backend/src/app/events/epg-worker.service.ts b/apps/electron-backend/src/app/events/epg-worker.service.ts index 49f06c494..e00a88405 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.ts @@ -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( diff --git a/apps/electron-backend/src/app/events/epg.events.spec.ts b/apps/electron-backend/src/app/events/epg.events.spec.ts index 80e85ab10..546e084e3 100644 --- a/apps/electron-backend/src/app/events/epg.events.spec.ts +++ b/apps/electron-backend/src/app/events/epg.events.spec.ts @@ -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((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((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')); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index d3115679b..bdd14256f 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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