@artesoft/timeout-scheduler
Version:
A performance-oriented, freeze-proof scheduler that provides a priority-based task system to prevent UI blocking.
374 lines (373 loc) • 16.6 kB
JavaScript
import { BehaviorSubject } from 'rxjs';
/**
* A highly configurable, performance-oriented scheduler.
* It intercepts or manages timer tasks to optimize main-thread usage,
* providing frame-budgeting for UI work while allowing critical networking
* tasks to bypass throttling.
*/
export class TimeoutScheduler {
/**
* Constructs an instance of the TimeoutScheduler.
* @param config Configuration options.
*/
constructor(config) {
// --- Native Functions ---
this.originalSetTimeout = window.setTimeout;
this.originalClearTimeout = window.clearTimeout;
// --- State ---
this.isOverridden = false;
this.taskIdCounter = 0;
this.taskQueue = new Map();
this.currentSchedulingMode = 'idle';
this.animationFrameId = 0;
// --- setTimeout Fallback State ---
this.backgroundTickerId = null;
this.pendingTaskCountSubject = new BehaviorSubject(0);
// =========================================
// Internal Logic
// =========================================
/**
* Handles visibility changes to switch between rAF (high perf) and setTimeout (background).
*/
this.handleVisibilityChange = () => {
this.stopAllTickers();
this.startAppropriateTicker();
};
/**
* Schedules a single batched task using scheduler.postTask.
*/
this.runTaskWithPostTask = (task) => {
if (!task.batching)
return; // Should not happen, but safety check
if (task.postTaskController && !task.postTaskController.signal.aborted)
return;
const controller = new AbortController();
task.postTaskController = controller;
window.scheduler.postTask(() => {
// We reuse the immediate execution helper logic
this.executeTaskImmediately(task.id);
}, {
signal: controller.signal,
delay: Math.max(0, task.executeAt - performance.now()),
priority: task.priority === 'user-visible' ? 'user-visible' : 'background'
}).catch((err) => {
// Ignore AbortErrors, log others
if (err.name !== 'AbortError') {
console.error('Error in scheduler.postTask:', err);
this.taskQueue.delete(task.id);
this.pendingTaskCountSubject.next(this.taskQueue.size);
}
});
};
/**
* The main processing loop used by `rAF` and `timeout` modes.
* Batches tasks and respects the frame budget.
*/
this.processRafQueue = () => {
if (this.currentSchedulingMode !== 'rAF' && this.currentSchedulingMode !== 'timeout') {
return;
}
const frameStart = performance.now();
let tasksExecutedThisFrame = 0;
// 1. Identify Tasks: Only process batched tasks that are due
const dueTasks = Array.from(this.taskQueue.values())
.filter(task => task.batching && task.executeAt <= frameStart);
const highPriorityTasks = dueTasks.filter(t => t.priority === 'user-visible');
const lowPriorityTasks = dueTasks.filter(t => t.priority === 'background');
// Helper to execute and delete
const process = (task) => {
try {
task.callback(...task.args);
}
catch (e) {
console.error('Error executing scheduled callback:', e);
}
this.taskQueue.delete(task.id);
tasksExecutedThisFrame++;
};
// 2. Execute High Priority (User Visible)
for (const task of highPriorityTasks) {
if (tasksExecutedThisFrame >= this.currentTasksPerFrame)
break;
process(task);
}
// 3. Execute Low Priority (Background) - Only if time permits
for (const task of lowPriorityTasks) {
const timeElapsed = performance.now() - frameStart;
if (timeElapsed >= this.frameTimeBudgetMs || tasksExecutedThisFrame >= this.currentTasksPerFrame)
break;
process(task);
}
// 4. Adjust Budget
this.adjustFrameBudget(performance.now() - frameStart);
this.pendingTaskCountSubject.next(this.taskQueue.size);
// 5. Schedule Next Tick
const remainingBatchedTasks = Array.from(this.taskQueue.values()).some(t => t.batching);
if (remainingBatchedTasks) {
if (this.currentSchedulingMode === 'rAF') {
this.animationFrameId = window.requestAnimationFrame(this.processRafQueue);
}
else if (this.currentSchedulingMode === 'timeout') {
// Cast to number for Node type compatibility
this.backgroundTickerId = this.originalSetTimeout.call(window, this.processRafQueue, this.backgroundTickInterval);
}
}
else {
this.currentSchedulingMode = 'idle';
}
};
this.pendingTaskCount$ = this.pendingTaskCountSubject.asObservable();
this.primaryStrategy = config?.primaryStrategy ?? 'throughput';
this.loggingEnabled = config?.loggingEnabled ?? false;
this.dynamicBudgetEnabled = config?.dynamicBudgetEnabled ?? true;
this.frameTimeBudgetMs = config?.frameTimeBudgetMs ?? 8;
this.initialTasksPerFrame = config?.initialTasksPerFrame ?? 50;
this.maxTasksPerFrame = config?.maxTasksPerFrame ?? 150;
this.backgroundTickInterval = config?.backgroundTickInterval ?? 250;
this.currentTasksPerFrame = this.initialTasksPerFrame;
if (typeof window === 'undefined' || !window.performance) {
throw new Error('TimeoutScheduler requires a browser environment with performance API support.');
}
// Check for native scheduler.postTask support
this.isPostTaskSupported = typeof window !== 'undefined' &&
'scheduler' in window &&
typeof window.scheduler.postTask === 'function';
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
// Initialize state based on current visibility
this.handleVisibilityChange();
}
}
/**
* Schedules a task.
* @param callback The function to execute.
* @param options Configuration for delay, priority, and batching behavior.
* @returns A unique Task ID (compatible with clearTimeout).
*/
scheduleTask(callback, options) {
const taskId = ++this.taskIdCounter;
const priority = options?.priority ?? 'user-visible';
const useBatching = options?.batching ?? true;
const delay = Number(options?.delay) || 0;
const executeAt = performance.now() + delay;
const task = {
id: taskId,
callback,
executeAt,
args: [],
priority,
batching: useBatching
};
this.taskQueue.set(taskId, task);
this.pendingTaskCountSubject.next(this.taskQueue.size);
// BRANCH 1: Non-Batched (Exact Timing)
// If batching is disabled, we schedule a dedicated native timer immediately.
// This bypasses the frame loop and background throttling.
if (!useBatching) {
// Casting to unknown then number ensures compatibility if Node types are present
task.nativeTimerId = this.originalSetTimeout.call(window, () => {
this.executeTaskImmediately(taskId);
}, delay);
return taskId;
}
// BRANCH 2: Batched (Frame Budgeted)
// If the scheduler is idle, kickstart the loop.
if (this.currentSchedulingMode === 'idle' && this.taskQueue.size > 0) {
this.startAppropriateTicker();
}
else if (this.currentSchedulingMode === 'postTask') {
// In postTask mode, we schedule individually via the API
this.runTaskWithPostTask(task);
}
return taskId;
}
/**
* Cancels a scheduled task, whether it is batched or non-batched.
* @param taskId The ID returned by scheduleTask.
*/
cancelTask(taskId) {
const task = this.taskQueue.get(taskId);
if (task) {
// Cleanup non-batched native timer
if (task.nativeTimerId !== undefined) {
this.originalClearTimeout.call(window, task.nativeTimerId);
}
// Cleanup postTask controller
if (task.postTaskController) {
task.postTaskController.abort();
}
this.taskQueue.delete(taskId);
this.pendingTaskCountSubject.next(this.taskQueue.size);
}
}
/**
* Overrides the global `window.setTimeout` and `window.clearTimeout`.
* Allows providing a hook to dynamically configure task options (e.g., disabling batching).
* @param options Configuration for the override behavior.
*/
overrideTimeouts(options) {
if (this.isOverridden)
return;
if (this.loggingEnabled)
console.warn(`--- TimeoutScheduler (${this.primaryStrategy}): OVERRIDING global setTimeout. ---`);
this.isOverridden = true;
// Ensure the default return is cast to TaskOptions to avoid TS errors on property assignment
const getOptions = options?.getTaskOptions || (() => ({ batching: true }));
// @ts-ignore
window.setTimeout = (callback, delay, ...args) => {
const delayMs = Number(delay) || 0;
// Dynamically determine options (e.g. check stack trace for Socket.io)
const taskOptions = getOptions(callback, delayMs, args);
// Ensure delay is carried over
taskOptions.delay = delayMs;
return this.scheduleTask(callback.bind(null, ...args), taskOptions);
};
// @ts-ignore
window.clearTimeout = (timeoutId) => {
if (timeoutId !== undefined)
this.cancelTask(timeoutId);
};
}
/**
* Restores the original `window.setTimeout` and `window.clearTimeout`.
* Any pending batched tasks are rescheduled to run natively.
*/
restoreTimeouts() {
if (!this.isOverridden)
return;
this.stopAllTickers();
// Cancel any active non-batched native timers to avoid duplicates
this.taskQueue.forEach(task => {
if (task.nativeTimerId)
this.originalClearTimeout(task.nativeTimerId);
});
if (this.loggingEnabled)
console.warn(`--- TimeoutScheduler: Restoring original functions. ---`);
window.setTimeout = this.originalSetTimeout;
window.clearTimeout = this.originalClearTimeout;
this.isOverridden = false;
// Reschedule all pending tasks using the native browser function
const now = performance.now();
for (const task of this.taskQueue.values()) {
const remainingDelay = Math.max(0, task.executeAt - now);
this.originalSetTimeout(task.callback, remainingDelay, ...task.args);
}
this.taskQueue.clear();
this.pendingTaskCountSubject.next(0);
}
/**
* Destroys the scheduler instance, removing listeners and restoring globals.
*/
destroy() {
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
}
this.restoreTimeouts();
this.pendingTaskCountSubject.complete();
}
/**
* Helper to execute a non-batched task immediately and remove it from the queue.
*/
executeTaskImmediately(taskId) {
const task = this.taskQueue.get(taskId);
if (!task)
return;
try {
task.callback(...task.args);
}
catch (e) {
console.error('Error executing non-batched callback:', e, task);
}
finally {
this.taskQueue.delete(taskId);
this.pendingTaskCountSubject.next(this.taskQueue.size);
}
}
/**
* Determines the correct scheduling loop based on tasks, strategy, and visibility.
*/
startAppropriateTicker() {
// We only need a ticker loop for tasks that require batching.
const hasBatchedTasks = Array.from(this.taskQueue.values()).some(t => t.batching);
if (!hasBatchedTasks) {
this.currentSchedulingMode = 'idle';
return;
}
const isHidden = typeof document !== 'undefined' && document.hidden;
const log = (mode) => {
if (this.loggingEnabled)
console.log(`--- TimeoutScheduler: Tab is ${isHidden ? 'hidden' : 'visible'}. Activating '${mode}' mode.`);
};
// 1. Responsiveness Strategy (User Preference)
if (this.primaryStrategy === 'responsiveness' && this.isPostTaskSupported) {
log('postTask');
this.currentSchedulingMode = 'postTask';
this.taskQueue.forEach(t => { if (t.batching)
this.runTaskWithPostTask(t); });
return;
}
// 2. Visible Tab (Throughput Strategy)
if (!isHidden) {
log('rAF');
this.currentSchedulingMode = 'rAF';
this.animationFrameId = window.requestAnimationFrame(this.processRafQueue);
}
// 3. Hidden Tab (Background)
else {
if (this.isPostTaskSupported) {
// postTask is often throttled less aggressively than setTimeout in background
log('postTask');
this.currentSchedulingMode = 'postTask';
this.taskQueue.forEach(t => { if (t.batching)
this.runTaskWithPostTask(t); });
}
else {
// Standard fallback to throttled setTimeout loop
log('timeout');
this.currentSchedulingMode = 'timeout';
// Cast to number here as well to handle Node type environments
this.backgroundTickerId = this.originalSetTimeout.call(window, this.processRafQueue, this.backgroundTickInterval // Uses the configurable interval
);
}
}
}
/**
* Stops all active loops (rAF, timeout) and aborts postTask controllers.
*/
stopAllTickers() {
if (this.animationFrameId)
window.cancelAnimationFrame(this.animationFrameId);
if (this.backgroundTickerId)
this.originalClearTimeout(this.backgroundTickerId);
this.animationFrameId = 0;
this.backgroundTickerId = null;
// Abort any in-flight batched postTask executions
this.taskQueue.forEach(task => {
if (task.postTaskController && !task.postTaskController.signal.aborted) {
task.postTaskController.abort();
}
});
if (this.loggingEnabled && this.currentSchedulingMode !== 'idle') {
console.log(`--- TimeoutScheduler: Stopping '${this.currentSchedulingMode}' ticker.`);
}
this.currentSchedulingMode = 'idle';
}
/**
* Adjusts the number of tasks allowed per frame based on how long the previous frame took.
*/
adjustFrameBudget(frameDurationMs) {
if (!this.dynamicBudgetEnabled || this.currentSchedulingMode !== 'rAF')
return;
// If we exceeded budget, reduce task count
if (frameDurationMs > this.frameTimeBudgetMs && this.currentTasksPerFrame > 1) {
const newBudget = Math.max(1, Math.floor(this.currentTasksPerFrame * 0.9));
if (this.loggingEnabled)
console.log(`--- Frame budget exceeded (${frameDurationMs.toFixed(2)}ms). Reducing to ${newBudget} tasks/frame.`);
this.currentTasksPerFrame = newBudget;
}
// If we have plenty of time, increase task count
else if (frameDurationMs < this.frameTimeBudgetMs && this.currentTasksPerFrame < this.maxTasksPerFrame) {
this.currentTasksPerFrame = Math.min(this.maxTasksPerFrame, this.currentTasksPerFrame + 1);
}
}
}