Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/backend/watcher.ts
Views: 687
/*1* This file is part of CoCalc: Copyright © 2023 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Watch one SINGLE FILE for changes. Use ./path-watcher.ts for a directory.78Watch for changes to the given file, which means the ctime or mode changes (atime is ignored).9Returns obj, which is an event emitter with events:1011- 'change', ctime, stats - when file changes or is created12- 'delete' - when file is deleted1314and a method .close().1516Only fires after the file definitely has not had its17ctime changed for at least debounce ms. Does NOT18fire when the file first has ctime changed.1920NOTE: for directories we use chokidar in path-watcher. However,21for a single file using polling, chokidar is horribly buggy and22lacking in functionality (e.g., https://github.com/paulmillr/chokidar/issues/1132),23and declared all bugs fixed, so we steer clear. It had a lot of issues24with just noticing actual file changes.2526I tried using node:fs's built-in watchFile and it randomly stopped working.27Very weird. I think this might have something to do with file paths versus inodes.2829I ended up just writing a file watcher using polling from scratch.3031We *always* use polling to fully support networked filesystems.32We use exponential backoff though which doesn't seem to be in any other33polling implementation, but reduces load and make sense for our use case.34*/3536import { EventEmitter } from "node:events";37import { getLogger } from "./logger";38import { debounce as lodashDebounce } from "lodash";39import { stat } from "fs/promises";4041const logger = getLogger("backend:watcher");4243// exponential backoff to reduce load for inactive files44const BACKOFF = 1.2;45const MIN_INTERVAL_MS = 750;46const MAX_INTERVAL_MS = 5000;4748export class Watcher extends EventEmitter {49private path?: string;50private prev: any = undefined;51private interval: number;52private minInterval: number;53private maxInterval: number;5455constructor(56path: string,57{58debounce,59interval = MIN_INTERVAL_MS,60maxInterval = MAX_INTERVAL_MS,61}: { debounce?: number; interval?: number; maxInterval?: number } = {},62) {63super();64if (debounce) {65this.emitChange = lodashDebounce(this.emitChange, debounce);66}67logger.debug("Watcher", { path, debounce, interval, maxInterval });68this.path = path;69this.minInterval = interval;70this.maxInterval = maxInterval;71this.interval = interval;72this.init();73}7475private init = async () => {76if (this.path == null) {77// closed78return;79}80// first time, so initialize it81try {82this.prev = await stat(this.path);83} catch (_) {84// doesn't exist85this.prev = null;86}87setTimeout(this.update, this.interval);88};8990private update = async () => {91if (this.path == null) {92// closed93return;94}95try {96const prev = this.prev;97const curr = await stat(this.path);98if (99curr.ctimeMs != prev?.ctimeMs ||100curr.mtimeMs != prev?.mtimeMs ||101curr.mode != prev?.mode102) {103this.prev = curr;104this.interval = this.minInterval;105this.emitChange(curr);106}107} catch (_err) {108if (this.prev != null) {109this.interval = this.minInterval;110this.prev = null;111logger.debug("delete", this.path);112this.emit("delete");113}114} finally {115setTimeout(this.update, this.interval);116this.interval = Math.min(this.maxInterval, this.interval * BACKOFF);117}118};119120private emitChange = (stats) => {121logger.debug("change", this.path);122this.emit("change", stats.ctime, stats);123};124125close = () => {126logger.debug("close", this.path);127this.removeAllListeners();128delete this.path;129};130}131132133