diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index e786c2f03..f8e28a682 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -31,6 +31,7 @@ import { WebSocket, WebSocketServer } from 'ws'; import { dispatchFromAdapter } from '../api-integration/integration.controller.js'; import { logger } from '../classes/Logger.js'; import { authenticateSocket } from '../middleware/authenticate.js'; +import { setConnectedClients } from '../stores/connectedClients.js'; import { eventStore } from '../stores/EventStore.js'; import getRandomName from '../utils/getRandomName.js'; import type { IAdapter } from './IAdapter.js'; @@ -85,6 +86,8 @@ class SocketServer implements IAdapter { }); this.lastConnection = new Date(); + // services which react to clients being connected are notified from here + setConnectedClients(this.clients.size); logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`); sendPacket(MessageTag.ClientInit, { clientId, clientName }); @@ -98,6 +101,7 @@ class SocketServer implements IAdapter { ws.on('close', () => { this.clients.delete(clientId); + setConnectedClients(this.clients.size); logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`); this.sendClientList(); }); @@ -250,6 +254,8 @@ class SocketServer implements IAdapter { } wss.close(() => { + this.clients.clear(); + setConnectedClients(0); this.wss = null; resolve(); }); diff --git a/apps/server/src/services/EventTimer.ts b/apps/server/src/services/EventTimer.ts index b5d8d1f1a..3eb65168f 100644 --- a/apps/server/src/services/EventTimer.ts +++ b/apps/server/src/services/EventTimer.ts @@ -1,4 +1,3 @@ -import { timerConfig } from '../setup/config.js'; import * as runtimeState from '../stores/runtimeState.js'; import type { UpdateResult } from '../stores/runtimeState.js'; @@ -6,54 +5,68 @@ type UpdateCallbackFn = (updateResult: UpdateResult) => void; /** * Manages Ontime's main timer + * + * The timer keeps the runtime up to date by recalculating it at a fixed rate. + * The rate is a policy decision which lives outside this class, see `getRefreshRate`; + * the timer only owns the mechanics of applying it. */ export class EventTimer { - private readonly _interval: NodeJS.Timeout; + private interval: NodeJS.Timeout; /** how often we recalculate */ - static _refreshInterval: number; + private refreshInterval: number; - /** when timer will be finished */ - private endCallback: NodeJS.Timeout | undefined = undefined; + /** anticipates the next boundary which carries side effects, see scheduleNextBoundary */ + private boundaryCallback: NodeJS.Timeout | undefined = undefined; private onUpdateCallback: UpdateCallbackFn | undefined = undefined; /** * @constructor - * @param {number} [timerConfig.refresh] how often we recalculate - * @param {number} [timerConfig.updateInterval] how often we update the socket - * @param {function} [timerConfig.onUpdateCallback] how often we update the socket + * @param {number} refreshRate how often we recalculate */ - constructor(timerConfig: { refresh: number; updateInterval: number }) { - EventTimer._refreshInterval = timerConfig.refresh; - this._interval = setInterval(() => { - this.update(); - }, EventTimer._refreshInterval); + constructor(refreshRate: number) { + this.refreshInterval = refreshRate; + this.interval = setInterval(() => this.update(), this.refreshInterval); } /** * Allows setting a callback for when the timer updates */ - setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) { + setOnUpdateCallback(callback: UpdateCallbackFn) { this.onUpdateCallback = callback; } + /** + * How often we are currently recalculating the runtime + */ + getRefreshRate(): number { + return this.refreshInterval; + } + + /** + * Changes how often we recalculate the runtime. + * Accuracy of the event boundaries is unaffected: those are anticipated + * by a scheduled update which is derived from the rate in use. + */ + setRefreshRate(refreshRate: number) { + if (refreshRate === this.refreshInterval) { + return; + } + + this.refreshInterval = refreshRate; + clearInterval(this.interval); + this.interval = setInterval(() => this.update(), this.refreshInterval); + + // the new rate changes how far ahead we need to anticipate + this.scheduleNextBoundary(); + } + start() { if (!runtimeState.start()) { return false; } - const state = runtimeState.getState(); - - // eslint-disable-next-line no-unused-labels -- dev code path - DEV: { - if (state.timer.current === null) { - throw new Error('EventTimer.start: invalid state received'); - } - } - - // register a callback for the scheduled end - const endTime = state.timer.current - timerConfig.triggerAhead; - this.endCallback = setTimeout(() => this.update(), endTime); + this.scheduleNextBoundary(); return true; } @@ -62,8 +75,7 @@ export class EventTimer { return false; } - // cancel end callback - clearTimeout(this.endCallback); + this.scheduleNextBoundary(); return true; } @@ -72,8 +84,7 @@ export class EventTimer { return false; } - // cancel end callback - clearTimeout(this.endCallback); + this.scheduleNextBoundary(); return true; } @@ -85,16 +96,7 @@ export class EventTimer { return false; } - // renew end callback - clearTimeout(this.endCallback); - const state = runtimeState.getState(); - // eslint-disable-next-line no-unused-labels -- dev code path - DEV: { - if (state.timer.expectedFinish === null) { - throw new Error('TimerService.addTime: expectedFinish is negative'); - } - } - this.endCallback = setTimeout(() => this.update(), state.timer.expectedFinish); + this.scheduleNextBoundary(); return true; } @@ -105,10 +107,37 @@ export class EventTimer { const updateResult = runtimeState.update(); // pass the result to the parent this.onUpdateCallback?.(updateResult); + // the update, and any side effect it caused, could have moved the next boundary + this.scheduleNextBoundary(); + } + + /** + * Schedules an extra update for the next boundary which carries side effects. + * + * The interval is what keeps the runtime up to date, but it can only resolve a + * boundary to its own rate: an event which ends between two ticks would be seen + * late by up to a full refresh cycle. Whenever the next boundary falls inside the + * current cycle, we schedule a dedicated update for it. + * + * This is derived from the state and recalculated after every update, so it + * self corrects for mutations which do not go through this class, such as roll + * or a restored playback, as long as their callers signal the change. + */ + scheduleNextBoundary() { + clearTimeout(this.boundaryCallback); + this.boundaryCallback = undefined; + + const timeToBoundary = runtimeState.getTimeToNextBoundary(); + if (timeToBoundary === null || timeToBoundary >= this.refreshInterval) { + // there is no boundary ahead, or the interval resolves it on its own + return; + } + + this.boundaryCallback = setTimeout(() => this.update(), Math.max(timeToBoundary, 0)); } shutdown() { - clearInterval(this._interval); - clearTimeout(this.endCallback); + clearInterval(this.interval); + clearTimeout(this.boundaryCallback); } } diff --git a/apps/server/src/services/__tests__/EventTimer.test.ts b/apps/server/src/services/__tests__/EventTimer.test.ts new file mode 100644 index 000000000..268e58e54 --- /dev/null +++ b/apps/server/src/services/__tests__/EventTimer.test.ts @@ -0,0 +1,172 @@ +import { EventTimer } from '../EventTimer.js'; + +vi.mock('../../stores/runtimeState.js', () => ({ + start: vi.fn(() => true), + pause: vi.fn(() => true), + stop: vi.fn(() => true), + addTime: vi.fn(() => true), + update: vi.fn(() => ({ hasTimerFinished: false, hasSecondaryTimerFinished: false })), + getTimeToNextBoundary: vi.fn(() => null), +})); + +import * as runtimeState from '../../stores/runtimeState.js'; + +const getTimeToNextBoundary = vi.mocked(runtimeState.getTimeToNextBoundary); + +describe('EventTimer', () => { + let eventTimer: EventTimer | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + getTimeToNextBoundary.mockReturnValue(null); + }); + + afterEach(() => { + eventTimer?.shutdown(); + eventTimer = undefined; + vi.useRealTimers(); + }); + + describe('refresh rate', () => { + it('recalculates at the given rate', () => { + eventTimer = new EventTimer(100); + expect(eventTimer.getRefreshRate()).toBe(100); + + vi.advanceTimersByTime(1000); + expect(runtimeState.update).toHaveBeenCalledTimes(10); + }); + + it('applies a new rate to the running loop', () => { + eventTimer = new EventTimer(1000); + + vi.advanceTimersByTime(1000); + expect(runtimeState.update).toHaveBeenCalledTimes(1); + + eventTimer.setRefreshRate(100); + expect(eventTimer.getRefreshRate()).toBe(100); + + vi.advanceTimersByTime(1000); + expect(runtimeState.update).toHaveBeenCalledTimes(11); + }); + + it('does not restart the loop when the rate has not changed', () => { + eventTimer = new EventTimer(1000); + + // the cycle is already half way through + vi.advanceTimersByTime(500); + eventTimer.setRefreshRate(1000); + vi.advanceTimersByTime(500); + + // restarting the interval would have delayed this update + expect(runtimeState.update).toHaveBeenCalledTimes(1); + }); + + it('stops recalculating after shutdown', () => { + eventTimer = new EventTimer(100); + eventTimer.shutdown(); + + vi.advanceTimersByTime(1000); + expect(runtimeState.update).not.toHaveBeenCalled(); + }); + }); + + describe('boundary scheduling', () => { + it('anticipates a boundary which falls inside the current cycle', () => { + eventTimer = new EventTimer(1000); + // the timer ends 200ms into the next cycle, then there is nothing else to anticipate + getTimeToNextBoundary.mockReturnValueOnce(200).mockReturnValue(null); + + vi.advanceTimersByTime(1000); + expect(runtimeState.update).toHaveBeenCalledTimes(1); + + // without the scheduled update, this boundary would only be seen at the next cycle + vi.advanceTimersByTime(200); + expect(runtimeState.update).toHaveBeenCalledTimes(2); + }); + + it('leaves a boundary beyond the current cycle to the regular loop', () => { + eventTimer = new EventTimer(1000); + getTimeToNextBoundary.mockReturnValue(5000); + + vi.advanceTimersByTime(1500); + expect(runtimeState.update).toHaveBeenCalledTimes(1); + }); + + it('anticipates a boundary which is already due', () => { + eventTimer = new EventTimer(1000); + getTimeToNextBoundary.mockReturnValueOnce(-500).mockReturnValue(null); + + vi.advanceTimersByTime(1000); + // the boundary is due, it resolves on the next turn instead of waiting a cycle + vi.advanceTimersByTime(1); + expect(runtimeState.update).toHaveBeenCalledTimes(2); + }); + + it('anticipates the end of a timer which was just started', () => { + eventTimer = new EventTimer(1000); + getTimeToNextBoundary.mockReturnValue(50); + + expect(eventTimer.start()).toBe(true); + // the boundary is resolved without waiting for the next cycle + vi.advanceTimersByTime(50); + expect(runtimeState.update).toHaveBeenCalledTimes(1); + }); + + it('anticipates the end of a timer which had time added to it', () => { + eventTimer = new EventTimer(1000); + getTimeToNextBoundary.mockReturnValue(50); + + expect(eventTimer.addTime(-100)).toBe(true); + vi.advanceTimersByTime(50); + expect(runtimeState.update).toHaveBeenCalledTimes(1); + }); + + it.each(['pause', 'stop'] as const)('drops the scheduled boundary on %s', (action) => { + eventTimer = new EventTimer(1000); + getTimeToNextBoundary.mockReturnValue(50); + eventTimer.start(); + + getTimeToNextBoundary.mockReturnValue(null); + eventTimer[action](); + + vi.advanceTimersByTime(500); + expect(runtimeState.update).not.toHaveBeenCalled(); + }); + + it('re-evaluates the boundary when the rate changes', () => { + eventTimer = new EventTimer(32); + // at the full rate the loop resolves this on its own, at the idle rate it does not + getTimeToNextBoundary.mockReturnValue(100); + + eventTimer.scheduleNextBoundary(); + vi.advanceTimersByTime(31); + expect(runtimeState.update).not.toHaveBeenCalled(); + + eventTimer.setRefreshRate(1000); + getTimeToNextBoundary.mockReturnValueOnce(100).mockReturnValue(null); + eventTimer.scheduleNextBoundary(); + + vi.advanceTimersByTime(100); + expect(runtimeState.update).toHaveBeenCalledTimes(1); + }); + + it('does not schedule a boundary when the state has none', () => { + eventTimer = new EventTimer(1000); + getTimeToNextBoundary.mockReturnValue(null); + + eventTimer.start(); + vi.advanceTimersByTime(999); + expect(runtimeState.update).not.toHaveBeenCalled(); + }); + }); + + it('passes the update result to the registered callback', () => { + eventTimer = new EventTimer(100); + const onUpdate = vi.fn(); + eventTimer.setOnUpdateCallback(onUpdate); + + vi.advanceTimersByTime(100); + expect(onUpdate).toHaveBeenCalledWith({ hasTimerFinished: false, hasSecondaryTimerFinished: false }); + }); +}); diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index eecc5893a..5eeeba553 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -1,6 +1,7 @@ import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils'; +import { timerConfig } from '../../setup/config.js'; import type { RuntimeState } from '../../stores/runtimeState.js'; import { findDayOffset, @@ -8,6 +9,7 @@ import { getElapsed, getExpectedFinish, getRuntimeOffset, + getTimeToBoundary, getTimerPhase, hasCrossedMidnight, normaliseEndTime, @@ -640,6 +642,49 @@ describe('hasCrossedMidnight()', () => { }); }); +describe('getTimeToBoundary()', () => { + const makeState = (timer: Partial, hasFinished = false) => + ({ + timer: { playback: Playback.Play, current: null, secondaryTimer: null, ...timer }, + _timer: { hasFinished }, + }) as RuntimeState; + + it('anticipates the end of a running timer, compensated by the trigger ahead', () => { + const state = makeState({ playback: Playback.Play, current: 5 * MILLIS_PER_SECOND }); + expect(getTimeToBoundary(state)).toBe(5 * MILLIS_PER_SECOND - timerConfig.triggerAhead); + }); + + it('anticipates the end of the wait in a roll pre-roll', () => { + const state = makeState({ + playback: Playback.Roll, + current: 10 * MILLIS_PER_MINUTE, + secondaryTimer: 3 * MILLIS_PER_SECOND, + }); + // the wait comes before the timer, it is the boundary we anticipate + expect(getTimeToBoundary(state)).toBe(3 * MILLIS_PER_SECOND); + }); + + it('anticipates the end of the timer once a rolling event has started', () => { + const state = makeState({ playback: Playback.Roll, current: 4 * MILLIS_PER_SECOND, secondaryTimer: null }); + expect(getTimeToBoundary(state)).toBe(4 * MILLIS_PER_SECOND - timerConfig.triggerAhead); + }); + + it.each([Playback.Pause, Playback.Stop, Playback.Armed])('has no boundary to anticipate in %s', (playback) => { + const state = makeState({ playback, current: MILLIS_PER_SECOND }); + expect(getTimeToBoundary(state)).toBe(null); + }); + + it('has no boundary to anticipate once the timer has finished', () => { + const state = makeState({ playback: Playback.Play, current: -MILLIS_PER_MINUTE }, true); + expect(getTimeToBoundary(state)).toBe(null); + }); + + it('has no boundary to anticipate without a timer', () => { + const state = makeState({ playback: Playback.Play, current: null }); + expect(getTimeToBoundary(state)).toBe(null); + }); +}); + describe('skippedOutOfEvent()', () => { const testSkipLimit = 32; it('does not consider an event end as a skip', () => { diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index 8e7e722da..300bd8f7e 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -22,7 +22,12 @@ import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api import { RundownMetadata } from '../../api-data/rundown/rundown.types.js'; import { cloneEntryData } from '../../api-data/rundown/rundown.utils.js'; import { logger } from '../../classes/Logger.js'; -import { timerConfig } from '../../setup/config.js'; +import { getRefreshRate, getSkipLimit } from '../../setup/config.js'; +import { + getConnectedClients, + hasConnectedClients, + subscribeToConnectedClients, +} from '../../stores/connectedClients.js'; import { eventStore } from '../../stores/EventStore.js'; import * as runtimeState from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js'; @@ -47,6 +52,8 @@ import { */ class RuntimeService { private readonly eventTimer: EventTimer; + /** removes the subscription to the amount of connected clients */ + private unsubscribeFromClients: (() => void) | undefined = undefined; private lastIntegrationClockUpdate = -1; private lastIntegrationTimerValue = -1; @@ -101,7 +108,8 @@ class RuntimeService { } else if ( // if there is no previous clock, we could not have skipped RuntimeService.previousState?.clock && - skippedOutOfEvent(newState, RuntimeService.previousState.clock, timerConfig.skipLimit) + // the skip threshold accounts for the refresh rate in use, which is not constant + skippedOutOfEvent(newState, RuntimeService.previousState.clock, getSkipLimit(this.eventTimer.getRefreshRate())) ) { // if we have skipped out of the event, we will recall roll // to push the playback to the right place @@ -154,14 +162,44 @@ class RuntimeService { logger.info(LogOrigin.Server, 'Runtime service started'); this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult)); + // the timer runs at a degraded rate while no client is observing it + this.unsubscribeFromClients = subscribeToConnectedClients((connectedClients) => + this.applyRefreshRate(connectedClients), + ); + this.applyRefreshRate(getConnectedClients()); + if (resumable) { this.resume(resumable); } } + /** + * Resolves the rate at which we recalculate the runtime from the amount of clients observing it. + * The policy itself lives in the config, see `getRefreshRate` + */ + private applyRefreshRate(connectedClients: number) { + const refreshRate = getRefreshRate(connectedClients > 0); + if (refreshRate === this.eventTimer.getRefreshRate()) { + return; + } + + this.eventTimer.setRefreshRate(refreshRate); + logger.info(LogOrigin.Server, `Runtime refresh rate set to ${refreshRate}ms (${connectedClients} clients)`); + } + + /** + * Signals the timer that the runtime has changed, so it can re-derive + * the update it schedules for the next event boundary + */ + public refreshBoundarySchedule() { + this.eventTimer.scheduleNextBoundary(); + } + public shutdown() { if (this.eventTimer) { logger.info(LogOrigin.Server, 'Runtime service shutting down'); + this.unsubscribeFromClients?.(); + this.unsubscribeFromClients = undefined; this.eventTimer.shutdown(); } } @@ -652,11 +690,8 @@ class RuntimeService { } } -// calculate at 30fps, refresh at 1fps -const eventTimer = new EventTimer({ - refresh: timerConfig.updateRate, - updateInterval: timerConfig.notificationRate, -}); +// we start with no clients connected, the rate is raised as soon as one is +const eventTimer = new EventTimer(getRefreshRate(hasConnectedClients())); export const runtimeService = new RuntimeService(eventTimer); type EntryUpdateKeys = keyof Pick; @@ -779,6 +814,15 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert } batch.send(); + + /** + * the mutation could have moved the next event boundary, and several of them + * (roll, resume, loading an event) reach the runtime state without going through + * the timer. This is the choke point for all of them, so we re-derive from here + * rather than having each call site remember to. + */ + (this as RuntimeService).refreshBoundarySchedule(); + return result; }; diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 8c28a5e8d..9495417d3 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,6 +1,7 @@ -import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types'; +import { Day, MaybeNumber, Playback, TimeOfDay, TimerPhase } from 'ontime-types'; import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; +import { timerConfig } from '../setup/config.js'; import type { RuntimeState } from '../stores/runtimeState.js'; /** @@ -119,6 +120,37 @@ function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number { return clock - startedAt; } +/** + * Finds how long until the runtime reaches a boundary which carries side effects, + * ie. the end of the running timer or the end of the wait in a roll pre-roll. + * + * The regular refresh loop can only resolve a boundary to its own rate, so the + * result is used to schedule a dedicated update for the boundary itself. + * @param {RuntimeState} state runtime state + * @returns {number | null} time until the next boundary, or null if there is none to anticipate + */ +export function getTimeToBoundary(state: RuntimeState): MaybeNumber { + const { playback, current, secondaryTimer } = state.timer; + + // we only anticipate boundaries for playback which is moving + // a paused timer holds its value and would otherwise schedule on every update + if (playback !== Playback.Play && playback !== Playback.Roll) { + return null; + } + + // in roll we could be waiting for the event to start + if (playback === Playback.Roll && secondaryTimer !== null) { + return secondaryTimer; + } + + // once the timer has finished there is no boundary ahead, the event is in overtime + if (current === null || state._timer.hasFinished) { + return null; + } + + return current - timerConfig.triggerAhead; +} + /** * Checks whether we have skipped out of the event * @param {RuntimeState} state runtime state diff --git a/apps/server/src/setup/__tests__/config.test.ts b/apps/server/src/setup/__tests__/config.test.ts new file mode 100644 index 000000000..1cdbcce1b --- /dev/null +++ b/apps/server/src/setup/__tests__/config.test.ts @@ -0,0 +1,36 @@ +import { getRefreshRate, getSkipLimit, timerConfig } from '../config.js'; + +describe('getRefreshRate()', () => { + it('recalculates at the full rate while clients are observing', () => { + expect(getRefreshRate(true)).toBe(timerConfig.updateRate); + }); + + it('degrades the rate when there is nothing to observe the result', () => { + expect(getRefreshRate(false)).toBe(timerConfig.idleUpdateRate); + }); + + it('keeps a loop running when idle, consumers other than clients depend on it', () => { + expect(getRefreshRate(false)).toBeGreaterThan(0); + }); + + it('does not degrade below the rate at which we notify consumers', () => { + // a slower loop would mean missing notification cycles + expect(getRefreshRate(false)).toBeLessThanOrEqual(timerConfig.notificationRate); + }); +}); + +describe('getSkipLimit()', () => { + it('leaves room above the refresh rate in use', () => { + expect(getSkipLimit(timerConfig.updateRate)).toBeGreaterThan(timerConfig.updateRate); + expect(getSkipLimit(timerConfig.idleUpdateRate)).toBeGreaterThan(timerConfig.idleUpdateRate); + }); + + it.each([true, false])( + 'stays above an ordinary refresh cycle for every rate we use (clients connected: %s)', + (hasConnectedClients) => { + // a threshold below the refresh rate would read every tick as a time skip + const refreshRate = getRefreshRate(hasConnectedClients); + expect(getSkipLimit(refreshRate)).toBeGreaterThan(refreshRate); + }, + ); +}); diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts index 895201a7c..00cf39d1a 100644 --- a/apps/server/src/setup/config.ts +++ b/apps/server/src/setup/config.ts @@ -1,13 +1,59 @@ import { MILLIS_PER_MINUTE } from 'ontime-utils'; +/** + * Configuration of Ontime's main loop. + * + * The runtime is recalculated at a fixed rate, but the rate is not constant: + * it is degraded when there is no client connected to observe the result. + * We keep a slow loop running instead of stopping it altogether since + * integrations and automations are driven from it and do not hold a websocket. + * + * !!! These values are coupled, they should not be changed in isolation: + * - the skip threshold has to account for the rate in use, see `getSkipLimit` + * - the accuracy of the event boundaries is not bound to the rate, it is kept by the + * scheduled update in `EventTimer`, see `getTimeToBoundary` + */ export const timerConfig = { - skipLimit: 1000, // threshold of skip for recalculating, values lower than updateRate can cause issues with rolling over midnight - updateRate: 32, // how often do we update the timer - notificationRate: 1000, // how often do we notify clients and integrations - triggerAhead: 10, // how far ahead do we trigger the end event - auxTimerDefault: 5 * MILLIS_PER_MINUTE, // default aux timer duration + /** how often we recalculate while clients are connected */ + updateRate: 32, + /** + * how often we recalculate while no client is connected + * !!! this sits right at the resolution of `notificationRate`: a rate any slower + * would drop notification cycles, and at this value an occasional second can be + * sampled twice as the interval drifts across the second boundary + */ + idleUpdateRate: 1000, + /** how much time we tolerate on top of a refresh cycle before calling it a time skip */ + skipTolerance: 1000, + /** how often we notify clients and integrations */ + notificationRate: 1000, + /** how far ahead do we trigger the end event */ + triggerAhead: 10, + /** default aux timer duration */ + auxTimerDefault: 5 * MILLIS_PER_MINUTE, }; +/** + * How often we recalculate the runtime, in milliseconds. + * With no client connected there is nothing to observe the sub-second values, + * so we fall back to a rate that still serves the consumers which tick on the second. + * @param {boolean} hasConnectedClients whether any client is observing the runtime + */ +export function getRefreshRate(hasConnectedClients: boolean): number { + return hasConnectedClients ? timerConfig.updateRate : timerConfig.idleUpdateRate; +} + +/** + * Threshold of skip for recalculating, ie. the system suspended or the clock was adjusted. + * We measure the gap between two updates, so the threshold has to account for the refresh + * rate in use: at the idle rate an ordinary cycle is already a second long and would + * otherwise read as a time skip on every tick. + * @param {number} refreshRate the refresh rate currently in use + */ +export function getSkipLimit(refreshRate: number): number { + return refreshRate + timerConfig.skipTolerance; +} + export const config = { appState: 'app-state.json', corrupt: 'corrupt files', diff --git a/apps/server/src/stores/__tests__/connectedClients.test.ts b/apps/server/src/stores/__tests__/connectedClients.test.ts new file mode 100644 index 000000000..b6680d703 --- /dev/null +++ b/apps/server/src/stores/__tests__/connectedClients.test.ts @@ -0,0 +1,73 @@ +import { + getConnectedClients, + hasConnectedClients, + setConnectedClients, + subscribeToConnectedClients, +} from '../connectedClients.js'; + +describe('connectedClients', () => { + beforeEach(() => { + setConnectedClients(0); + }); + + it('exposes the published amount of clients', () => { + setConnectedClients(3); + expect(getConnectedClients()).toBe(3); + expect(hasConnectedClients()).toBe(true); + }); + + it('reports no clients when the last one leaves', () => { + setConnectedClients(1); + setConnectedClients(0); + expect(hasConnectedClients()).toBe(false); + }); + + it('notifies subscribers of changes', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToConnectedClients(listener); + + setConnectedClients(1); + setConnectedClients(2); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith(2); + unsubscribe(); + }); + + it('does not notify when the value has not changed', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToConnectedClients(listener); + + setConnectedClients(1); + setConnectedClients(1); + + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it('stops notifying once unsubscribed', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToConnectedClients(listener); + + setConnectedClients(1); + unsubscribe(); + setConnectedClients(2); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('isolates subscribers from each other', () => { + const failing = vi.fn(() => { + throw new Error('I am a bad listener'); + }); + const listener = vi.fn(); + const unsubscribeFailing = subscribeToConnectedClients(failing); + const unsubscribe = subscribeToConnectedClients(listener); + + expect(() => setConnectedClients(1)).not.toThrow(); + expect(listener).toHaveBeenCalledWith(1); + + unsubscribeFailing(); + unsubscribe(); + }); +}); diff --git a/apps/server/src/stores/connectedClients.ts b/apps/server/src/stores/connectedClients.ts new file mode 100644 index 000000000..bf6f9a2cb --- /dev/null +++ b/apps/server/src/stores/connectedClients.ts @@ -0,0 +1,56 @@ +/** + * Keeps track of how many clients are connected to Ontime. + * + * This is the coordination point between the transport layer, which is the only place + * that knows about connections, and the services which need to react to them. + * The adapters publish the count here and interested services subscribe to it. + * + * !!! This module must not take dependencies of its own: it sits between two sides + * which already depend on each other indirectly, and keeping it a leaf is what allows + * either side to use it without creating a cycle. + */ + +type ConnectedClientsListener = (connectedClients: number) => void; + +const listeners = new Set(); +let connectedClients = 0; + +/** + * Publishes the current amount of connected clients, called from the transport layer. + * Listeners are only notified when the value has actually changed. + */ +export function setConnectedClients(amount: number) { + if (amount === connectedClients) { + return; + } + connectedClients = amount; + + listeners.forEach((listener) => { + try { + listener(connectedClients); + } catch (_) { + // a misbehaving listener should not affect the connection handling + } + }); +} + +/** Amount of clients currently connected */ +export function getConnectedClients(): number { + return connectedClients; +} + +/** Whether there is any client connected to observe the runtime */ +export function hasConnectedClients(): boolean { + return connectedClients > 0; +} + +/** + * Subscribes to changes in the amount of connected clients + * @returns a function which removes the subscription + */ +export function subscribeToConnectedClients(listener: ConnectedClientsListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index f306b154e..b6c4ae6e8 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -40,6 +40,7 @@ import { getElapsed, getExpectedFinish, getRuntimeOffset, + getTimeToBoundary, getTimerPhase, hasCrossedMidnight, } from '../services/timerUtils.js'; @@ -120,6 +121,15 @@ export function getState(): Readonly { }; } +/** + * Exposes the time until the next boundary which carries side effects. + * Reads from the state directly to avoid the copy made by `getState`, + * since this is consulted on every update. + */ +export function getTimeToNextBoundary(): MaybeNumber { + return getTimeToBoundary(runtimeState); +} + /* clear data related to the current event, but leave in place data about the global run state * used when loading a new event but the playback is not interrupted */