|
4 | 4 | */
|
5 | 5 |
|
6 | 6 | /*
|
7 |
| -Watch a file for changes |
| 7 | +Watch one SINGLE FILE for changes. Use ./path-watcher.ts for a directory. |
8 | 8 |
|
9 |
| -Watch for changes to the given file. Returns obj, which |
| 9 | +Watch for changes to the given file, which means the mtime changes or the |
| 10 | +mode changes (e.g., readonly versus readwrite). Returns obj, which |
10 | 11 | is an event emitter with events:
|
11 | 12 |
|
12 |
| - - 'change', ctime - when file changes or is created |
| 13 | + - 'change', ctime, stats - when file changes or is created |
13 | 14 | - 'delete' - when file is deleted
|
14 | 15 |
|
15 | 16 | and a method .close().
|
16 | 17 |
|
17 | 18 | Only fires after the file definitely has not had its
|
18 |
| -ctime changed for at least debounce ms (this is the atomic |
19 |
| -option to chokidar). Does NOT fire when the file first |
20 |
| -has ctime changed. |
| 19 | +ctime changed for at least debounce ms. Does NOT |
| 20 | +fire when the file first has ctime changed. |
| 21 | +
|
| 22 | +NOTE: for directories we use chokidar in path-watcher. However, |
| 23 | +for a single file using polling, chokidar is horribly buggy and |
| 24 | +lacking in functionality (e.g., https://github.com/paulmillr/chokidar/issues/1132), |
| 25 | +and declared all bugs fixed, so we steer clear. It had a lot of issues |
| 26 | +with just noticing actual file changes. |
| 27 | +
|
| 28 | +We *always* use polling to fully support networked filesystems. |
21 | 29 | */
|
22 | 30 |
|
23 | 31 | import { EventEmitter } from "node:events";
|
24 |
| -import { watch, FSWatcher } from "chokidar"; |
| 32 | +import { unwatchFile, watchFile } from "node:fs"; |
25 | 33 | import { getLogger } from "./logger";
|
26 | 34 | import { debounce as lodashDebounce } from "lodash";
|
27 | 35 |
|
28 |
| -const L = getLogger("watcher"); |
| 36 | +const logger = getLogger("backend:watcher"); |
29 | 37 |
|
30 | 38 | export class Watcher extends EventEmitter {
|
31 | 39 | private path: string;
|
32 |
| - private interval: number; |
33 |
| - private watcher: FSWatcher; |
34 | 40 |
|
35 |
| - constructor(path: string, interval: number = 300, debounce: number = 0) { |
| 41 | + constructor( |
| 42 | + path: string, |
| 43 | + { debounce, interval = 300 }: { debounce?: number; interval?: number } = {}, |
| 44 | + ) { |
36 | 45 | super();
|
37 | 46 | this.path = path;
|
38 |
| - this.interval = interval; |
39 |
| - |
40 |
| - L.debug(`${path}: interval=${interval}, debounce=${debounce}`); |
41 |
| - this.watcher = watch(this.path, { |
42 |
| - interval: this.interval, |
43 |
| - // polling is critical for network mounted file systems, |
44 |
| - // and given architecture of cocalc there is no easy way around this. |
45 |
| - // E.g., on compute servers, everything breaks involving sync or cloudfs, |
46 |
| - // and in shared project s3/gcsfuse/sshfs would all break. So we |
47 |
| - // use polling. |
48 |
| - usePolling: true, |
49 |
| - persistent: false, |
50 |
| - alwaysStat: true, |
51 |
| - atomic: true, |
52 |
| - }); |
53 |
| - this.watcher.on("unlink", () => { |
54 |
| - this.emit("delete"); |
55 |
| - }); |
56 |
| - this.watcher.on("unlinkDir", () => { |
57 |
| - this.emit("delete"); |
58 |
| - }); |
59 |
| - |
60 |
| - const emitChange = lodashDebounce( |
61 |
| - (ctime) => this.emit("change", ctime), |
62 |
| - debounce, |
63 |
| - ); |
64 |
| - this.watcher.on("error", (err) => { |
65 |
| - L.debug("WATCHER error -- ", err); |
66 |
| - }); |
67 |
| - |
68 |
| - this.watcher.on("change", (_, stats) => { |
69 |
| - if (stats == null) { |
70 |
| - L.debug("WATCHER change with no stats (shouldn't happen)", { path }); |
71 |
| - return; |
72 |
| - } |
73 |
| - emitChange(stats.ctime); |
74 |
| - }); |
| 47 | + |
| 48 | + logger.debug("watchFile", { path, debounce, interval }); |
| 49 | + watchFile(this.path, { persistent: false, interval }, this.handleChange); |
| 50 | + |
| 51 | + if (debounce) { |
| 52 | + this.emitChange = lodashDebounce(this.emitChange, debounce); |
| 53 | + } |
75 | 54 | }
|
76 | 55 |
|
77 |
| - close = async () => { |
| 56 | + private emitChange = (stats) => { |
| 57 | + this.emit("change", stats.ctime, stats); |
| 58 | + }; |
| 59 | + |
| 60 | + private handleChange = (curr, prev) => { |
| 61 | + const path = this.path; |
| 62 | + if (!curr.dev) { |
| 63 | + logger.debug("handleChange: delete", { path }); |
| 64 | + this.emit("delete"); |
| 65 | + return; |
| 66 | + } |
| 67 | + if (curr.mtimeMs == prev.mtimeMs && curr.mode == prev.mode) { |
| 68 | + logger.debug("handleChange: access but no change", { path }); |
| 69 | + // just *accessing* triggers watchFile (really StatWatcher), of course. |
| 70 | + return; |
| 71 | + } |
| 72 | + logger.debug("handleChange: change", { path }); |
| 73 | + this.emitChange(curr); |
| 74 | + }; |
| 75 | + |
| 76 | + close = () => { |
| 77 | + logger.debug("close", this.path); |
78 | 78 | this.removeAllListeners();
|
79 |
| - await this.watcher.close(); |
| 79 | + unwatchFile(this.path, this.handleChange); |
80 | 80 | };
|
81 | 81 | }
|
0 commit comments