CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/backend/path-watcher.ts
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Watch A DIRECTORY for changes of the files in *that* directory only (not recursive).
8
Use ./watcher.ts for a single file.
9
10
Slightly generalized fs.watch that works even when the directory doesn't exist,
11
but also doesn't provide any information about what changed.
12
13
NOTE: We could maintain the directory listing and just try to update info about the filename,
14
taking into account the type. That's probably really hard to get right, and just
15
debouncing and computing the whole listing is going to be vastly easier and good
16
enough at least for first round of this.
17
18
We assume path is relative to HOME and contained inside of HOME.
19
20
The code below deals with two very different cases:
21
- when that path doesn't exist: use fs.watch on the parent directory.
22
NOTE: this case can't happen when path='', which exists, so we can assume to have read perms on parent.
23
- when the path does exist: use fs.watch (hence inotify) on the path itself to report when it changes
24
25
NOTE: if you are running on a file system like NFS, inotify won't work well or not at all.
26
In that case, set the env variable COCALC_FS_WATCHER=poll to use polling instead.
27
You can configure the poll interval by setting COCALC_FS_WATCHER_POLL_INTERVAL_MS.
28
29
UPDATE: We are using polling in ALL cases. We have subtle bugs
30
with adding and removing directories otherwise, and also
31
we are only ever watching a relatively small number of directories
32
with a long interval, so polling is not so bad.
33
*/
34
35
import { watch, WatchOptions } from "chokidar";
36
import { FSWatcher } from "fs";
37
import { join } from "path";
38
import { EventEmitter } from "events";
39
import { debounce } from "lodash";
40
import { exists } from "@cocalc/backend/misc/async-utils-node";
41
import { close, path_split } from "@cocalc/util/misc";
42
import { getLogger } from "./logger";
43
44
const logger = getLogger("backend:path-watcher");
45
46
// const COCALC_FS_WATCHER = process.env.COCALC_FS_WATCHER ?? "inotify";
47
// if (!["inotify", "poll"].includes(COCALC_FS_WATCHER)) {
48
// throw new Error(
49
// `$COCALC_FS_WATCHER=${COCALC_FS_WATCHER} -- must be "inotify" or "poll"`,
50
// );
51
// }
52
// const POLLING = COCALC_FS_WATCHER === "poll";
53
54
const POLLING = true;
55
56
const DEFAULT_POLL_MS = parseInt(
57
process.env.COCALC_FS_WATCHER_POLL_INTERVAL_MS ?? "3000",
58
);
59
60
const ChokidarOpts: WatchOptions = {
61
persistent: true, // otherwise won't work
62
followSymlinks: false, // don't wander about
63
disableGlobbing: true, // watch the path as it is, that's it
64
usePolling: POLLING,
65
interval: DEFAULT_POLL_MS,
66
binaryInterval: DEFAULT_POLL_MS,
67
depth: 0, // we only care about the explicitly mentioned path – there could be a lot of files and sub-dirs!
68
// maybe some day we want this:
69
// awaitWriteFinish: {
70
// stabilityThreshold: 100,
71
// pollInterval: 50,
72
// },
73
ignorePermissionErrors: true,
74
alwaysStat: false,
75
} as const;
76
77
export class Watcher extends EventEmitter {
78
private path: string;
79
private exists: boolean;
80
private watchContents?: FSWatcher;
81
private watchExistence?: FSWatcher;
82
private debounce_ms: number;
83
private debouncedChange: any;
84
private log: Function;
85
86
constructor(
87
path: string,
88
{ debounce: debounce_ms = DEFAULT_POLL_MS }: { debounce?: number } = {},
89
) {
90
super();
91
this.log = logger.extend(path).debug;
92
this.log(`initializing: poll=${POLLING}`);
93
if (process.env.HOME == null) {
94
throw Error("bug -- HOME must be defined");
95
}
96
this.path = path.startsWith("/") ? path : join(process.env.HOME, path);
97
this.debounce_ms = debounce_ms;
98
this.debouncedChange = this.debounce_ms
99
? debounce(this.change, this.debounce_ms, {
100
leading: true,
101
trailing: true,
102
}).bind(this)
103
: this.change;
104
this.init();
105
}
106
107
private async init(): Promise<void> {
108
this.log("init watching", this.path);
109
this.exists = await exists(this.path);
110
if (this.path != "") {
111
this.log("init watching", this.path, " for existence");
112
this.initWatchExistence();
113
}
114
if (this.exists) {
115
this.log("init watching", this.path, " contents");
116
this.initWatchContents();
117
}
118
}
119
120
private initWatchContents(): void {
121
this.watchContents = watch(this.path, ChokidarOpts);
122
this.watchContents.on("all", this.debouncedChange);
123
this.watchContents.on("error", (err) => {
124
this.log(`error watching listings -- ${err}`);
125
});
126
}
127
128
private async initWatchExistence(): Promise<void> {
129
const containing_path = path_split(this.path).head;
130
this.watchExistence = watch(containing_path, ChokidarOpts);
131
this.watchExistence.on("all", this.watchExistenceChange(containing_path));
132
this.watchExistence.on("error", (err) => {
133
this.log(`error watching for existence of ${this.path} -- ${err}`);
134
});
135
}
136
137
private watchExistenceChange = (containing_path) => async (_, filename) => {
138
const path = join(containing_path, filename);
139
if (path != this.path) return;
140
const e = await exists(this.path);
141
if (!this.exists && e) {
142
// it sprung into existence
143
this.exists = e;
144
this.initWatchContents();
145
this.change();
146
} else if (this.exists && !e) {
147
// it got deleted
148
this.exists = e;
149
if (this.watchContents != null) {
150
this.watchContents.close();
151
delete this.watchContents;
152
}
153
154
this.change();
155
}
156
};
157
158
private change = (): void => {
159
this.emit("change");
160
};
161
162
public close(): void {
163
this.watchExistence?.close();
164
this.watchContents?.close();
165
close(this);
166
}
167
}
168
169