software/hoardy-web/./extension/lib/schedule-timeout.js

Passively capture, archive, and hoard your web browsing history, including the contents of the pages you visit, for later offline viewing, replay, mirroring, data scraping, and/or indexing. Your own personal private Wayback Machine that can also archive HTTP POST requests and responses, as well as most other HTTP-level data.

Files

Raw Source

Contents

/*
 * Copyright (c) 2023-2026 Jan Malakhovski <oxij@oxij.org>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

/*
 * A tiny library for scheduling things with `setTimeout`.
 *
 * This implements "tasks", which consist of pre-, main-, and post- functions,
 * which get scheduled with `setTimeout`s, and can be updated later in various
 * ways.
 *
 * The main use case for this is UI updates:
 *
 * - you schedule a UI-updating function `action1` in 1 second, but then 0.5s
 *   later the state changes, and that computation is no longer relevant, so you
 *   want to replace the previous `setTimeout` of `action1`, with a call to
 *   `action2`;
 *
 *   so far `setTimeout` can do this;
 *
 * - but then, while you'll usually want to set the new `setTimeout` for 1 second
 *   again, sometimes you want to set it for leftover 0.5 seconds in the future;
 *
 *   this library can do both (the latter is the `hurry` mode);
 *
 * - but then just as `action2` started to execute, the data was invalidated
 *   again, and the code asked to schedule `action3` instead;
 *
 *   so, now you need to schedule `action3` to be run immediately after
 *   `action2` finishes, and then ask `action2` to stop immediately if possible,
 *   to improve latency;
 *
 *   this library does this transparently for you.
 *
 * Depends on `./base.js`.
 */

"use strict";

// An overridable and 1-chainable task scheduled to execute after a given timeout.
function makeSingletonTimeout(priority, timeout, func) {
    let value = {
        tid: undefined,
        priority,
        task: func,
        when: Date.now() + timeout,
        // NB:
        // - `false` means "run everything normally"
        // - `null` means "continue to run new `task`s, but ask the running `task` to stop gracefully, if possible"
        // - `true` means "stop running new `task`s, ask the running `task` to stop gracefully, if possible"
        wantStop: false,
        results: [],
        onDelay: [],
        andThen: [],
    };
    value.tid = setTimeout(() => evalSingletonTimeout(value), timeout);
    return value;
}

// Create or update singletonTimeout.
// Returns a new `makeSingletonTimeout` if given `value === undefined`.
// Otherwiss, returns `undefined`.
function setSingletonTimeout(value, priority, timeout, func, hurry) {
    if (value !== undefined && value.tid !== undefined) {
        // `value` is still valid

        let oldPriority = value.priority;
        if (oldPriority < priority) {
            // the scheduled/running task has a higher (smaller) priority, do
            // nothing regardless of it executing or not.
            return;
        }

        let now = Date.now();
        if (value.tid !== null) {
            // it's not yet running, cancel it
            clearTimeout(value.tid);
            // hurry it up, meaning, do not move the target execution time
            // any more into the future
            if (hurry) {
                timeout = clamp(0, value.when - now, timeout);
            }
        }

        // update the scheduled task
        value.priority = priority;
        value.task = func;
        value.when = now + timeout;
        value.wantStop = false;

        if (value.tid !== null) {
            // re-schedule
            value.tid = setTimeout(() => evalSingletonTimeout(value), timeout);
        } else if (timeout === 0) {
            // ask the currently running task to stop, if possible, so that `hurry` would produce an
            // effect ASAP
            value.wantStop = null;
        }

        return;
    }

    return makeSingletonTimeout(priority, timeout, func);
}

// Run a task stored in a given singletonTimeout value.
async function evalSingletonTimeout(value) {
    if (value.tid === undefined) {
        throw new Error("evalSingletonTimeout: the task is already finished");
    }
    if (value.tid === null) {
        throw new Error("evalSingletonTimeout: the task is already running");
    }

    // mark as running
    value.tid = null;

    let first = true;
    let ntimeout = 0;
    while (value.wantStop !== true) {
        let task = value.task;
        if (task === undefined) {
            // nothing more to do
            break;
        }

        if (!first) {
            ntimeout = value.when - Date.now();
            if (ntimeout > 0) {
                // the next part should not be running yet
                await asyncAllApply(value.onDelay, undefined, value.results);
                value.tid = setTimeout(() => evalSingletonTimeout(value), ntimeout);
                return;
            }
        }
        first = false;

        value.task = undefined;
        value.wantStop = false;

        try {
            let res = task(() => {
                return value.wantStop !== false;
            });
            while (res instanceof Promise) {
                res = await res;
            }
            value.results.push(res);
        } catch (err) {
            logError(err);
        }
    }

    // wipe the `.task` even if it wasn't run
    value.task = undefined;
    // mark it as finished
    value.tid = undefined;

    await asyncAllApply(value.andThen, undefined, value.results);

    // GC
    value.results = undefined;
    value.onDelay = undefined;
    value.andThen = undefined;
}

// Immediately run or cancel a given singletonTimeout.
function emitSingletonTimeout(value, run, wait) {
    if (value.tid === undefined) {
        // it's already finished
        return;
    }

    if (!run) {
        // ask it to stop
        value.wantStop = true;
    }

    if (value.tid !== null) {
        // it's not running,
        // cancel its `setTimeout`
        clearTimeout(value.tid);
        // and eval immediately
        let res = evalSingletonTimeout(value);
        if (wait) {
            return res;
        }
    }

    // it is already running
    if (wait) {
        return new Promise((resolve, _reject) => {
            value.andThen.push(resolve);
        });
    }
}

// Use a Map as directory for delayed overridable and chainable functions.
function resetSingletonTimeout(map, key, timeout, func, priority, hurry) {
    if (priority === undefined) {
        priority = 100;
    }

    let value = map.get(key);
    value = setSingletonTimeout(value, priority, timeout, func, hurry);
    if (value !== undefined) {
        // a newly created one
        value.andThen.push(() => map.delete(key));
        map.set(key, value);
    }
    return value;
}

async function popSingletonTimeout(map, key, run, wait) {
    let value = map.get(key);
    if (value === undefined) {
        return;
    }
    await emitSingletonTimeout(value, run, wait);
}

async function runAllSingletonTimeouts(map) {
    for (let key of Array.from(map.keys())) {
        await popSingletonTimeout(map, key, true, true);
    }
}

async function cancelAllSingletonTimeouts(map) {
    for (let key of Array.from(map.keys())) {
        await popSingletonTimeout(map, key, false, true);
    }
}

tests.sheduleTimeout = async () => {
    let res = 0;
    let m = new Map();
    resetSingletonTimeout(m, "a", 1000, () => {
        res = 1;
    });
    await sleep(100);
    resetSingletonTimeout(m, "a", 100, () => {
        res = 2;
    });
    await sleep(1000);

    if (res !== 2) {
        throw new Error();
    }

    resetSingletonTimeout(m, "a", 100, () => {
        res = 3;
    });
    popSingletonTimeout(m, "a");
    await sleep(1000);

    if (res !== 2) {
        throw new Error();
    }

    resetSingletonTimeout(m, "a", 0, async () => {
        await sleep(1000);
        res = 4;
    });
    await sleep(100);
    await popSingletonTimeout(m, "a", true, true);

    if (res !== 4) {
        throw new Error();
    }
};