mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-17 03:53:06 +00:00
feat(server): degrade timer refresh rate when no clients are connected
The main loop recalculated the runtime at 32ms unconditionally. With no websocket client connected there is nothing observing the sub-second values, so we fall back to a 1s rate and raise it again as soon as a client connects. Coordination is done through a new leaf store, `connectedClients`: the socket adapter publishes the count and the runtime service subscribes to it. Keeping the module free of dependencies lets both sides use it without a cycle, and keeps the transport layer unaware of the services reacting to it. The rate policy lives in the config alongside the values it is coupled to. `getSkipLimit` is now derived from the rate in use, since the skip threshold has to stay above an ordinary refresh cycle: at the idle rate the previous fixed 1s threshold would have read every tick as a time skip and reloaded roll. Accuracy of the event boundaries is no longer tied to the rate. The ad-hoc end callbacks are replaced by `scheduleNextBoundary`, which derives the next boundary from the state after every update and anticipates it when it falls inside the current cycle. This also covers the paths that previously had no predictive end at all (roll, roll pre-roll, restored playback) and fixes the renewal in `addTime`, which scheduled with a time of day where a duration was expected and so never fired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhEkCTUCQbPVYABYpf4zh2
This commit is contained in:
@@ -31,6 +31,7 @@ import { WebSocket, WebSocketServer } from 'ws';
|
|||||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import { authenticateSocket } from '../middleware/authenticate.js';
|
import { authenticateSocket } from '../middleware/authenticate.js';
|
||||||
|
import { setConnectedClients } from '../stores/connectedClients.js';
|
||||||
import { eventStore } from '../stores/EventStore.js';
|
import { eventStore } from '../stores/EventStore.js';
|
||||||
import getRandomName from '../utils/getRandomName.js';
|
import getRandomName from '../utils/getRandomName.js';
|
||||||
import type { IAdapter } from './IAdapter.js';
|
import type { IAdapter } from './IAdapter.js';
|
||||||
@@ -85,6 +86,8 @@ class SocketServer implements IAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.lastConnection = new Date();
|
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}`);
|
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`);
|
||||||
|
|
||||||
sendPacket(MessageTag.ClientInit, { clientId, clientName });
|
sendPacket(MessageTag.ClientInit, { clientId, clientName });
|
||||||
@@ -98,6 +101,7 @@ class SocketServer implements IAdapter {
|
|||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
this.clients.delete(clientId);
|
this.clients.delete(clientId);
|
||||||
|
setConnectedClients(this.clients.size);
|
||||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`);
|
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`);
|
||||||
this.sendClientList();
|
this.sendClientList();
|
||||||
});
|
});
|
||||||
@@ -250,6 +254,8 @@ class SocketServer implements IAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wss.close(() => {
|
wss.close(() => {
|
||||||
|
this.clients.clear();
|
||||||
|
setConnectedClients(0);
|
||||||
this.wss = null;
|
this.wss = null;
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { timerConfig } from '../setup/config.js';
|
|
||||||
import * as runtimeState from '../stores/runtimeState.js';
|
import * as runtimeState from '../stores/runtimeState.js';
|
||||||
import type { UpdateResult } 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
|
* 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 {
|
export class EventTimer {
|
||||||
private readonly _interval: NodeJS.Timeout;
|
private interval: NodeJS.Timeout;
|
||||||
/** how often we recalculate */
|
/** how often we recalculate */
|
||||||
static _refreshInterval: number;
|
private refreshInterval: number;
|
||||||
|
|
||||||
/** when timer will be finished */
|
/** anticipates the next boundary which carries side effects, see scheduleNextBoundary */
|
||||||
private endCallback: NodeJS.Timeout | undefined = undefined;
|
private boundaryCallback: NodeJS.Timeout | undefined = undefined;
|
||||||
|
|
||||||
private onUpdateCallback: UpdateCallbackFn | undefined = undefined;
|
private onUpdateCallback: UpdateCallbackFn | undefined = undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @constructor
|
* @constructor
|
||||||
* @param {number} [timerConfig.refresh] how often we recalculate
|
* @param {number} refreshRate how often we recalculate
|
||||||
* @param {number} [timerConfig.updateInterval] how often we update the socket
|
|
||||||
* @param {function} [timerConfig.onUpdateCallback] how often we update the socket
|
|
||||||
*/
|
*/
|
||||||
constructor(timerConfig: { refresh: number; updateInterval: number }) {
|
constructor(refreshRate: number) {
|
||||||
EventTimer._refreshInterval = timerConfig.refresh;
|
this.refreshInterval = refreshRate;
|
||||||
this._interval = setInterval(() => {
|
this.interval = setInterval(() => this.update(), this.refreshInterval);
|
||||||
this.update();
|
|
||||||
}, EventTimer._refreshInterval);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows setting a callback for when the timer updates
|
* Allows setting a callback for when the timer updates
|
||||||
*/
|
*/
|
||||||
setOnUpdateCallback(callback: (updateResult: UpdateResult) => void) {
|
setOnUpdateCallback(callback: UpdateCallbackFn) {
|
||||||
this.onUpdateCallback = callback;
|
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() {
|
start() {
|
||||||
if (!runtimeState.start()) {
|
if (!runtimeState.start()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = runtimeState.getState();
|
this.scheduleNextBoundary();
|
||||||
|
|
||||||
// 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);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +75,7 @@ export class EventTimer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// cancel end callback
|
this.scheduleNextBoundary();
|
||||||
clearTimeout(this.endCallback);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,8 +84,7 @@ export class EventTimer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// cancel end callback
|
this.scheduleNextBoundary();
|
||||||
clearTimeout(this.endCallback);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,16 +96,7 @@ export class EventTimer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// renew end callback
|
this.scheduleNextBoundary();
|
||||||
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);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +107,37 @@ export class EventTimer {
|
|||||||
const updateResult = runtimeState.update();
|
const updateResult = runtimeState.update();
|
||||||
// pass the result to the parent
|
// pass the result to the parent
|
||||||
this.onUpdateCallback?.(updateResult);
|
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() {
|
shutdown() {
|
||||||
clearInterval(this._interval);
|
clearInterval(this.interval);
|
||||||
clearTimeout(this.endCallback);
|
clearTimeout(this.boundaryCallback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
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 { 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 type { RuntimeState } from '../../stores/runtimeState.js';
|
||||||
import {
|
import {
|
||||||
findDayOffset,
|
findDayOffset,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
getElapsed,
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
|
getTimeToBoundary,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
hasCrossedMidnight,
|
hasCrossedMidnight,
|
||||||
normaliseEndTime,
|
normaliseEndTime,
|
||||||
@@ -640,6 +642,49 @@ describe('hasCrossedMidnight()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getTimeToBoundary()', () => {
|
||||||
|
const makeState = (timer: Partial<RuntimeState['timer']>, 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()', () => {
|
describe('skippedOutOfEvent()', () => {
|
||||||
const testSkipLimit = 32;
|
const testSkipLimit = 32;
|
||||||
it('does not consider an event end as a skip', () => {
|
it('does not consider an event end as a skip', () => {
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ import { getCurrentRundown, getEntryWithId, getRundownMetadata } from '../../api
|
|||||||
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
import { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||||
import { cloneEntryData } from '../../api-data/rundown/rundown.utils.js';
|
import { cloneEntryData } from '../../api-data/rundown/rundown.utils.js';
|
||||||
import { logger } from '../../classes/Logger.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 { eventStore } from '../../stores/EventStore.js';
|
||||||
import * as runtimeState from '../../stores/runtimeState.js';
|
import * as runtimeState from '../../stores/runtimeState.js';
|
||||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||||
@@ -47,6 +52,8 @@ import {
|
|||||||
*/
|
*/
|
||||||
class RuntimeService {
|
class RuntimeService {
|
||||||
private readonly eventTimer: EventTimer;
|
private readonly eventTimer: EventTimer;
|
||||||
|
/** removes the subscription to the amount of connected clients */
|
||||||
|
private unsubscribeFromClients: (() => void) | undefined = undefined;
|
||||||
private lastIntegrationClockUpdate = -1;
|
private lastIntegrationClockUpdate = -1;
|
||||||
private lastIntegrationTimerValue = -1;
|
private lastIntegrationTimerValue = -1;
|
||||||
|
|
||||||
@@ -101,7 +108,8 @@ class RuntimeService {
|
|||||||
} else if (
|
} else if (
|
||||||
// if there is no previous clock, we could not have skipped
|
// if there is no previous clock, we could not have skipped
|
||||||
RuntimeService.previousState?.clock &&
|
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
|
// if we have skipped out of the event, we will recall roll
|
||||||
// to push the playback to the right place
|
// to push the playback to the right place
|
||||||
@@ -154,14 +162,44 @@ class RuntimeService {
|
|||||||
logger.info(LogOrigin.Server, 'Runtime service started');
|
logger.info(LogOrigin.Server, 'Runtime service started');
|
||||||
this.eventTimer.setOnUpdateCallback((updateResult) => this.checkTimerUpdate(updateResult));
|
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) {
|
if (resumable) {
|
||||||
this.resume(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() {
|
public shutdown() {
|
||||||
if (this.eventTimer) {
|
if (this.eventTimer) {
|
||||||
logger.info(LogOrigin.Server, 'Runtime service shutting down');
|
logger.info(LogOrigin.Server, 'Runtime service shutting down');
|
||||||
|
this.unsubscribeFromClients?.();
|
||||||
|
this.unsubscribeFromClients = undefined;
|
||||||
this.eventTimer.shutdown();
|
this.eventTimer.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -652,11 +690,8 @@ class RuntimeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculate at 30fps, refresh at 1fps
|
// we start with no clients connected, the rate is raised as soon as one is
|
||||||
const eventTimer = new EventTimer({
|
const eventTimer = new EventTimer(getRefreshRate(hasConnectedClients()));
|
||||||
refresh: timerConfig.updateRate,
|
|
||||||
updateInterval: timerConfig.notificationRate,
|
|
||||||
});
|
|
||||||
export const runtimeService = new RuntimeService(eventTimer);
|
export const runtimeService = new RuntimeService(eventTimer);
|
||||||
|
|
||||||
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
||||||
@@ -779,6 +814,15 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
}
|
}
|
||||||
|
|
||||||
batch.send();
|
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;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { timerConfig } from '../setup/config.js';
|
||||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,6 +120,37 @@ function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
|
|||||||
return clock - startedAt;
|
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
|
* Checks whether we have skipped out of the event
|
||||||
* @param {RuntimeState} state runtime state
|
* @param {RuntimeState} state runtime state
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,13 +1,59 @@
|
|||||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
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 = {
|
export const timerConfig = {
|
||||||
skipLimit: 1000, // threshold of skip for recalculating, values lower than updateRate can cause issues with rolling over midnight
|
/** how often we recalculate while clients are connected */
|
||||||
updateRate: 32, // how often do we update the timer
|
updateRate: 32,
|
||||||
notificationRate: 1000, // how often do we notify clients and integrations
|
/**
|
||||||
triggerAhead: 10, // how far ahead do we trigger the end event
|
* how often we recalculate while no client is connected
|
||||||
auxTimerDefault: 5 * MILLIS_PER_MINUTE, // default aux timer duration
|
* !!! 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 = {
|
export const config = {
|
||||||
appState: 'app-state.json',
|
appState: 'app-state.json',
|
||||||
corrupt: 'corrupt files',
|
corrupt: 'corrupt files',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ConnectedClientsListener>();
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
getElapsed,
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
|
getTimeToBoundary,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
hasCrossedMidnight,
|
hasCrossedMidnight,
|
||||||
} from '../services/timerUtils.js';
|
} from '../services/timerUtils.js';
|
||||||
@@ -120,6 +121,15 @@ export function getState(): Readonly<RuntimeState> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
/* 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
|
* used when loading a new event but the playback is not interrupted
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user