From 48cc7e21e3b24ac794f9aaf7e66581907519f3ee Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 30 Sep 2025 22:09:08 -0400 Subject: [PATCH] make TimeService abstract and add support for managed timers --- packages/backend/src/core/TimeService.ts | 108 ++++++++++++++++-- .../backend/test/misc/GodOfTimeService.ts | 95 +++++++++++++++ 2 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 packages/backend/test/misc/GodOfTimeService.ts diff --git a/packages/backend/src/core/TimeService.ts b/packages/backend/src/core/TimeService.ts index 59c3d4c12b..b71836399c 100644 --- a/packages/backend/src/core/TimeService.ts +++ b/packages/backend/src/core/TimeService.ts @@ -3,25 +3,117 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Injectable, OnApplicationShutdown } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; /** * Provides abstractions to access the current time. * Exists for unit testing purposes, so that tests can "simulate" any given time for consistency. */ -@Injectable() -export class TimeService { +export abstract class TimeService implements OnApplicationShutdown { + protected readonly timers = new Map(); + /** * Returns Date.now() */ - public get now() { - return Date.now(); - } + public abstract get now(): number; /** * Returns a new Date instance. */ - public get date() { - return new Date(); + public get date(): Date { + return new Date(this.now); + } + + @bindThis + public startTimer(callback: () => void, delay: number, opts?: { repeated?: boolean }): symbol { + const timerId = Symbol(); + const repeating = opts?.repeated ?? false; + + const timer = this.startNativeTimer(timerId, repeating, callback, delay); + this.timers.set(timerId, timer); + + return timerId; + } + + protected abstract startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): TTimer; + + /** + * Clears a registered timeout or interval. + * Returns true if the registration exists and was still active, false otherwise. + * Safe to call with invalid or expired IDs. + */ + @bindThis + public stopTimer(id: symbol): boolean { + const reg = this.timers.get(id); + if (!reg) return false; + + this.stopNativeTimer(reg); + this.timers.delete(id); + return true; + } + + protected abstract stopNativeTimer(reg: TTimer): void; + + /** + * Cleanup all handles and references. + * Safe to call multiple times. + * + * **Must be called before shutting down the app!** + */ + @bindThis + public dispose(): void { + for (const reg of this.timers.values()) { + this.stopNativeTimer(reg); + } + this.timers.clear(); + } + + @bindThis + onApplicationShutdown(): void { + this.dispose(); } } + +export interface Timer { + timerId: symbol; + repeating: boolean; + delay: number; + callback: () => void; +} + +/** + * Default implementation of TimeService, uses Date.now() as time source and setTimeout/setInterval for timers. + */ +@Injectable() +export class NativeTimeService extends TimeService implements OnApplicationShutdown { + public get now(): number { + return Date.now(); + } + + protected startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): NativeTimer { + // Wrap the caller's callback to make sure we clean up the registration. + const wrappedCallback = () => { + this.timers.delete(timerId); + callback(); + }; + + const timeout = repeating + ? global.setInterval(wrappedCallback, delay) + : global.setTimeout(wrappedCallback, delay); + + return { callback, timerId, repeating, delay, timeout }; + } + + protected stopNativeTimer(reg: NativeTimer): void { + if (reg.repeating) { + global.clearInterval(reg.timeout); + } else { + global.clearTimeout(reg.timeout); + } + } +} + +export interface NativeTimer extends Timer { + timeout: NodeJS.Timeout; +} diff --git a/packages/backend/test/misc/GodOfTimeService.ts b/packages/backend/test/misc/GodOfTimeService.ts new file mode 100644 index 0000000000..f58dc7a58d --- /dev/null +++ b/packages/backend/test/misc/GodOfTimeService.ts @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { TimeService, Timer } from '@/core/TimeService.js'; + +/** + * Fake implementation of TimeService that allows manual control of time. + * When this service is used, the flow of time is fully stopped. + * + * Test cases can manually adjust the "now" parameter to move time forwards and backwards. + * When moving forward, timers (interval and timeout) will automatically fire as appropriate. + */ +@Injectable() +export class GodOfTimeService extends TimeService { + private _now = 0; + + constructor(opts?: { start?: { at: number } | 'now' }) { + super(); + + // Jump to the correct start time + if (opts?.start === 'now') { + this.resetToNow(); + } else if (opts?.start?.at) { + this.resetTo(opts.start.at); + } + } + + /** + * Get or set the current time, in milliseconds since the unix epoch. + */ + public get now() { + return this._now; + } + public set now(value: number) { + // Moving backwards is allowed, for now. + if (value > this._now) { + // Fire all expiring timers in chronological order. + const expiringTimers = this.timers + .values() + .filter(t => t.expiresAt >= value) + .toArray() + .sort((a, b) => a.expiresAt - b.expiresAt); + + // Since we sorted the list, this will progressively increase "now" as we handle later and later events. + for (const timer of expiringTimers) { + // When the timer fires, "now" should equal the time that was originally waited for. + this._now = timer.expiresAt; + + // Cleanup first in case timer throws an exception. + this.timers.delete(timer.timerId); + timer.callback(); + } + } + + // Bump up to the final target value + this._now = value; + } + + /** + * Clears all timers and resets to time=0. + */ + public reset() { + this.resetTo(0); + } + /** + * Clears all timers and resets to the real-world time. + */ + public resetToNow() { + this.resetTo(Date.now()); + } + + /** + * Clears all timers and resets to a given time. + */ + public resetTo(to: number) { + this.timers.clear(); + this.now = to; + } + + protected startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): GodsOwnTimer { + const expiresAt = this.now + delay; + return { timerId, repeating, delay, expiresAt, callback }; + } + + protected stopNativeTimer(): void { + // no-op - fake timers have no side effects to clean up + } +} + +export interface GodsOwnTimer extends Timer { + expiresAt: number; +}