From de9ec95db190e06ea2570771a94d89c443e79c31 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 7 Apr 2026 10:22:29 -0400 Subject: [PATCH] fix(web): handle unhandled promise rejection in CancellableTask (#27553) When a concurrent caller awaits `this.complete` inside `execute()` and `cancel()` is called, the promise rejects with `undefined` outside of any try/catch, causing "Uncaught (in promise) undefined" console spam during rapid timeline scrolling. - Wrap the `await this.complete` path in try/catch, returning 'CANCELED' - Guard the `finally` block to only null `cancelToken` if it still belongs to this call, preventing a race condition with `cancel()` to `init()` Change-Id: I65764dd664eb408433fc6e5fc2be4df56a6a6964 --- web/src/lib/utils/cancellable-task.spec.ts | 24 ++++++++++++++++++++++ web/src/lib/utils/cancellable-task.ts | 12 ++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/web/src/lib/utils/cancellable-task.spec.ts b/web/src/lib/utils/cancellable-task.spec.ts index 97d63684f8..27e4678d4f 100644 --- a/web/src/lib/utils/cancellable-task.spec.ts +++ b/web/src/lib/utils/cancellable-task.spec.ts @@ -161,6 +161,30 @@ describe('CancellableTask', () => { expect(task.executed).toBe(true); }); + it('should return CANCELED when concurrent caller is waiting and task is canceled', async () => { + const task = new CancellableTask(); + let resolveTask: () => void; + const taskPromise = new Promise((resolve) => { + resolveTask = resolve; + }); + const taskFn = async (signal: AbortSignal) => { + await taskPromise; + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + }; + + const promise1 = task.execute(taskFn, true); + const promise2 = task.execute(taskFn, true); + + task.cancel(); + resolveTask!(); + + const [result1, result2] = await Promise.all([promise1, promise2]); + expect(result1).toBe('CANCELED'); + expect(result2).toBe('CANCELED'); + }); + it('should not cancel if task is already executed', async () => { const task = new CancellableTask(); const taskFn = vi.fn(async () => {}); diff --git a/web/src/lib/utils/cancellable-task.ts b/web/src/lib/utils/cancellable-task.ts index f5f4d7830b..c8cd9db4c0 100644 --- a/web/src/lib/utils/cancellable-task.ts +++ b/web/src/lib/utils/cancellable-task.ts @@ -64,8 +64,12 @@ export class CancellableTask { if (this.cancellable && !cancellable) { this.cancellable = cancellable; } - await this.complete; - return 'WAITED'; + try { + await this.complete; + return 'WAITED'; + } catch { + return 'CANCELED'; + } } this.cancellable = cancellable; const cancelToken = (this.cancelToken = new AbortController()); @@ -86,7 +90,9 @@ export class CancellableTask { this.#transitionToErrored(error); return 'ERRORED'; } finally { - this.cancelToken = null; + if (this.cancelToken === cancelToken) { + this.cancelToken = null; + } } }