diff options
Diffstat (limited to 'cli/js/timers.ts')
-rw-r--r-- | cli/js/timers.ts | 280 |
1 files changed, 280 insertions, 0 deletions
diff --git a/cli/js/timers.ts b/cli/js/timers.ts new file mode 100644 index 000000000..5bc4922e3 --- /dev/null +++ b/cli/js/timers.ts @@ -0,0 +1,280 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import { assert } from "./util.ts"; +import { window } from "./window.ts"; +import * as dispatch from "./dispatch.ts"; +import { sendSync, sendAsync } from "./dispatch_json.ts"; + +const { console } = window; + +interface Timer { + id: number; + callback: () => void; + delay: number; + due: number; + repeat: boolean; + scheduled: boolean; +} + +// We'll subtract EPOCH every time we retrieve the time with Date.now(). This +// ensures that absolute time values stay below UINT32_MAX - 2, which is the +// maximum object key that EcmaScript considers "numerical". After running for +// about a month, this is no longer true, and Deno explodes. +// TODO(piscisaureus): fix that ^. +const EPOCH = Date.now(); +const APOCALYPSE = 2 ** 32 - 2; + +// Timeout values > TIMEOUT_MAX are set to 1. +const TIMEOUT_MAX = 2 ** 31 - 1; + +let globalTimeoutDue: number | null = null; + +let nextTimerId = 1; +const idMap = new Map<number, Timer>(); +const dueMap: { [due: number]: Timer[] } = Object.create(null); + +function getTime(): number { + // TODO: use a monotonic clock. + const now = Date.now() - EPOCH; + assert(now >= 0 && now < APOCALYPSE); + return now; +} + +function clearGlobalTimeout(): void { + globalTimeoutDue = null; + sendSync(dispatch.OP_GLOBAL_TIMER_STOP); +} + +async function setGlobalTimeout(due: number, now: number): Promise<void> { + // Since JS and Rust don't use the same clock, pass the time to rust as a + // relative time value. On the Rust side we'll turn that into an absolute + // value again. + const timeout = due - now; + assert(timeout >= 0); + + // Send message to the backend. + globalTimeoutDue = due; + await sendAsync(dispatch.OP_GLOBAL_TIMER, { timeout }); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + fireTimers(); +} + +function setOrClearGlobalTimeout(due: number | null, now: number): void { + if (due == null) { + clearGlobalTimeout(); + } else { + setGlobalTimeout(due, now); + } +} + +function schedule(timer: Timer, now: number): void { + assert(!timer.scheduled); + assert(now <= timer.due); + // Find or create the list of timers that will fire at point-in-time `due`. + let list = dueMap[timer.due]; + if (list === undefined) { + list = dueMap[timer.due] = []; + } + // Append the newly scheduled timer to the list and mark it as scheduled. + list.push(timer); + timer.scheduled = true; + // If the new timer is scheduled to fire before any timer that existed before, + // update the global timeout to reflect this. + if (globalTimeoutDue === null || globalTimeoutDue > timer.due) { + setOrClearGlobalTimeout(timer.due, now); + } +} + +function unschedule(timer: Timer): void { + if (!timer.scheduled) { + return; + } + // Find the list of timers that will fire at point-in-time `due`. + const list = dueMap[timer.due]; + if (list.length === 1) { + // Time timer is the only one in the list. Remove the entire list. + assert(list[0] === timer); + delete dueMap[timer.due]; + // If the unscheduled timer was 'next up', find when the next timer that + // still exists is due, and update the global alarm accordingly. + if (timer.due === globalTimeoutDue) { + let nextTimerDue: number | null = null; + for (const key in dueMap) { + nextTimerDue = Number(key); + break; + } + setOrClearGlobalTimeout(nextTimerDue, getTime()); + } + } else { + // Multiple timers that are due at the same point in time. + // Remove this timer from the list. + const index = list.indexOf(timer); + assert(index > -1); + list.splice(index, 1); + } +} + +function fire(timer: Timer): void { + // If the timer isn't found in the ID map, that means it has been cancelled + // between the timer firing and the promise callback (this function). + if (!idMap.has(timer.id)) { + return; + } + // Reschedule the timer if it is a repeating one, otherwise drop it. + if (!timer.repeat) { + // One-shot timer: remove the timer from this id-to-timer map. + idMap.delete(timer.id); + } else { + // Interval timer: compute when timer was supposed to fire next. + // However make sure to never schedule the next interval in the past. + const now = getTime(); + timer.due = Math.max(now, timer.due + timer.delay); + schedule(timer, now); + } + // Call the user callback. Intermediate assignment is to avoid leaking `this` + // to it, while also keeping the stack trace neat when it shows up in there. + const callback = timer.callback; + callback(); +} + +function fireTimers(): void { + const now = getTime(); + // Bail out if we're not expecting the global timer to fire. + if (globalTimeoutDue === null) { + return; + } + // After firing the timers that are due now, this will hold the due time of + // the first timer that hasn't fired yet. + let nextTimerDue: number | null = null; + // Walk over the keys of the 'due' map. Since dueMap is actually a regular + // object and its keys are numerical and smaller than UINT32_MAX - 2, + // keys are iterated in ascending order. + for (const key in dueMap) { + // Convert the object key (a string) to a number. + const due = Number(key); + // Break out of the loop if the next timer isn't due to fire yet. + if (Number(due) > now) { + nextTimerDue = due; + break; + } + // Get the list of timers that have this due time, then drop it. + const list = dueMap[key]; + delete dueMap[key]; + // Fire all the timers in the list. + for (const timer of list) { + // With the list dropped, the timer is no longer scheduled. + timer.scheduled = false; + // Place the callback on the microtask queue. + Promise.resolve(timer).then(fire); + } + } + + // Update the global alarm to go off when the first-up timer that hasn't fired + // yet is due. + setOrClearGlobalTimeout(nextTimerDue, now); +} + +export type Args = unknown[]; + +function checkThis(thisArg: unknown): void { + if (thisArg !== null && thisArg !== undefined && thisArg !== window) { + throw new TypeError("Illegal invocation"); + } +} + +function checkBigInt(n: unknown): void { + if (typeof n === "bigint") { + throw new TypeError("Cannot convert a BigInt value to a number"); + } +} + +function setTimer( + cb: (...args: Args) => void, + delay: number, + args: Args, + repeat: boolean +): number { + // Bind `args` to the callback and bind `this` to window(global). + const callback: () => void = cb.bind(window, ...args); + // In the browser, the delay value must be coercible to an integer between 0 + // and INT32_MAX. Any other value will cause the timer to fire immediately. + // We emulate this behavior. + const now = getTime(); + if (delay > TIMEOUT_MAX) { + console.warn( + `${delay} does not fit into` + + " a 32-bit signed integer." + + "\nTimeout duration was set to 1." + ); + delay = 1; + } + delay = Math.max(0, delay | 0); + + // Create a new, unscheduled timer object. + const timer = { + id: nextTimerId++, + callback, + args, + delay, + due: now + delay, + repeat, + scheduled: false + }; + // Register the timer's existence in the id-to-timer map. + idMap.set(timer.id, timer); + // Schedule the timer in the due table. + schedule(timer, now); + return timer.id; +} + +/** Sets a timer which executes a function once after the timer expires. */ +export function setTimeout( + cb: (...args: Args) => void, + delay = 0, + ...args: Args +): number { + checkBigInt(delay); + // @ts-ignore + checkThis(this); + return setTimer(cb, delay, args, false); +} + +/** Repeatedly calls a function , with a fixed time delay between each call. */ +export function setInterval( + cb: (...args: Args) => void, + delay = 0, + ...args: Args +): number { + checkBigInt(delay); + // @ts-ignore + checkThis(this); + return setTimer(cb, delay, args, true); +} + +/** Clears a previously set timer by id. AKA clearTimeout and clearInterval. */ +function clearTimer(id: number): void { + id = Number(id); + const timer = idMap.get(id); + if (timer === undefined) { + // Timer doesn't exist any more or never existed. This is not an error. + return; + } + // Unschedule the timer if it is currently scheduled, and forget about it. + unschedule(timer); + idMap.delete(timer.id); +} + +export function clearTimeout(id = 0): void { + checkBigInt(id); + if (id === 0) { + return; + } + clearTimer(id); +} + +export function clearInterval(id = 0): void { + checkBigInt(id); + if (id === 0) { + return; + } + clearTimer(id); +} |