From ed750fd990c5d587189d292733e67e1b2a4ac287 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 1 Oct 2025 19:11:10 -0400 Subject: [PATCH] support promise timers in TimeService --- packages/backend/src/core/TimeService.ts | 60 ++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/TimeService.ts b/packages/backend/src/core/TimeService.ts index 7f46a05e0b..726ddd1742 100644 --- a/packages/backend/src/core/TimeService.ts +++ b/packages/backend/src/core/TimeService.ts @@ -6,6 +6,8 @@ import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; +const timerTokenSymbol = Symbol('timerToken'); + /** * Provides abstractions to access the current time. * Exists for unit testing purposes, so that tests can "simulate" any given time for consistency. @@ -28,17 +30,53 @@ export abstract class TimeService implements OnApp return new Date(this.now); } + public startTimer(callback: () => void, delay: number, opts?: TimerOpts): TimerHandle; + public startTimer(callback: (value: T) => void, delay: number, opts: TimerOpts | undefined, value: T): TimerHandle; @bindThis - public startTimer(callback: () => void, delay: number, opts?: { repeated?: boolean }): symbol { + public startTimer(callback: (value: T) => void, delay: number, opts?: TimerOpts, value?: T): TimerHandle { const timerId = Symbol(); const repeating = opts?.repeated ?? false; - const timer = this.startNativeTimer(timerId, repeating, callback, delay); + const timer = this.startNativeTimer(timerId, repeating, () => { + callback(value as T); // overloads ensure it can't be null + }, delay); this.timers.set(timerId, timer); return timerId; } + public startPromiseTimer(delay: number): PromiseTimerHandle; + public startPromiseTimer(delay: number, value: T, opts?: PromiseTimerOpts): PromiseTimerHandle; + @bindThis + public startPromiseTimer(delay: number, value?: T, opts?: PromiseTimerOpts): PromiseTimerHandle { + const timerId = Symbol(); + const abortController = new AbortController(); + const abortSignal = opts?.signal ? AbortSignal.any([abortController.signal, opts.signal]) : abortController.signal; + + const handlePromise = new Promise((resolve, reject) => { + // Connect AbortSignal + abortSignal.throwIfAborted(); + abortSignal.addEventListener('abort', () => reject(abortSignal.reason)); + + // Start the underlying timer + this.startTimer(resolve, delay, undefined, value as T); // overloads ensure it can't be null + }); + + // Make sure we dispose the real handle if promise rejects! + handlePromise.catch(() => { + this.stopTimer(timerId); + }); + + // Populate and return the handle. + return Object.assign(handlePromise, { + [timerTokenSymbol]: timerId, + + abort: (reason: Error) => { + abortController.abort(reason); + }, + }); + } + protected abstract startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): TTimer; /** @@ -47,7 +85,8 @@ export abstract class TimeService implements OnApp * Safe to call with invalid or expired IDs. */ @bindThis - public stopTimer(id: symbol): boolean { + public stopTimer(handle: TimerHandle | PromiseTimerHandle): boolean { + const id = typeof(handle) === 'object' ? handle[timerTokenSymbol] : handle; const reg = this.timers.get(id); if (!reg) return false; @@ -85,6 +124,21 @@ export interface Timer { callback: () => void; } +export interface TimerOpts { + repeated?: boolean; +} + +export type TimerHandle = symbol; + +export interface PromiseTimerOpts { + signal?: AbortSignal; +} + +export interface PromiseTimerHandle extends PromiseLike { + readonly [timerTokenSymbol]: symbol; + abort(error?: Error): void; +} + /** * Default implementation of TimeService, uses Date.now() as time source and setTimeout/setInterval for timers. */