UNPKG

@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
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); } } }