Skip to content

Commit ef8f1d6

Browse files
committed
rewrite single file watcher from scratch to directly poll, and toss in exponential backoff
- why? because I think it watchFile in node is somehow broken...
1 parent d22a374 commit ef8f1d6

File tree

2 files changed

+57
-25
lines changed

2 files changed

+57
-25
lines changed

src/packages/backend/watcher.ts

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,57 +25,89 @@ lacking in functionality (e.g., https://github.com/paulmillr/chokidar/issues/113
2525
and declared all bugs fixed, so we steer clear. It had a lot of issues
2626
with just noticing actual file changes.
2727
28+
I tried using node:fs's built-in watchFile and it randomly stopped working.
29+
Very weird. I think this might have something to do with file paths versus inodes.
30+
31+
I ended up just writing a file watcher using polling from scratch.
32+
2833
We *always* use polling to fully support networked filesystems.
34+
We use exponential backoff though which doesn't seem to be in any other
35+
polling implementation, but reduces load and make sense for our use case.
2936
*/
3037

3138
import { EventEmitter } from "node:events";
32-
import { unwatchFile, watchFile } from "node:fs";
3339
import { getLogger } from "./logger";
3440
import { debounce as lodashDebounce } from "lodash";
41+
import { stat } from "fs/promises";
3542

3643
const logger = getLogger("backend:watcher");
3744

45+
// exponential backoff to reduce load for inactive files
46+
const BACKOFF = 1.2;
47+
const MIN_INTERVAL_MS = 750;
48+
const MAX_INTERVAL_MS = 5000;
49+
3850
export class Watcher extends EventEmitter {
39-
private path: string;
51+
private path?: string;
52+
private prev: any = null;
53+
private interval: number;
54+
private minInterval: number;
55+
private maxInterval: number;
4056

4157
constructor(
4258
path: string,
43-
{ debounce, interval = 750 }: { debounce?: number; interval?: number } = {},
59+
{
60+
debounce,
61+
interval = MIN_INTERVAL_MS,
62+
maxInterval = MAX_INTERVAL_MS,
63+
}: { debounce?: number; interval?: number; maxInterval?: number } = {},
4464
) {
4565
super();
46-
this.path = path;
47-
48-
logger.debug("watchFile", { path, debounce, interval });
49-
watchFile(this.path, { persistent: false, interval }, this.handleChange);
50-
5166
if (debounce) {
5267
this.emitChange = lodashDebounce(this.emitChange, debounce);
5368
}
69+
logger.debug("Watcher", { path, debounce, interval, maxInterval });
70+
this.path = path;
71+
this.minInterval = interval;
72+
this.maxInterval = maxInterval;
73+
this.interval = interval;
74+
setTimeout(this.update, interval);
5475
}
5576

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");
77+
private update = async () => {
78+
if (this.path == null) {
79+
// closed
6580
return;
6681
}
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;
82+
try {
83+
const prev = this.prev;
84+
const curr = await stat(this.path);
85+
if (curr.mtimeMs != prev?.mtimeMs || curr.mode != prev?.mode) {
86+
this.prev = curr;
87+
this.interval = this.minInterval;
88+
this.emitChange(curr);
89+
}
90+
} catch (_err) {
91+
if (this.prev != null) {
92+
this.interval = this.minInterval;
93+
this.prev = null;
94+
logger.debug("delete", this.path);
95+
this.emit("delete");
96+
}
97+
} finally {
98+
setTimeout(this.update, this.interval);
99+
this.interval = Math.min(this.maxInterval, this.interval * BACKOFF);
71100
}
72-
logger.debug("handleChange: change", { path });
73-
this.emitChange(curr);
101+
};
102+
103+
private emitChange = (stats) => {
104+
logger.debug("change", this.path);
105+
this.emit("change", stats.ctime, stats);
74106
};
75107

76108
close = () => {
77109
logger.debug("close", this.path);
78110
this.removeAllListeners();
79-
unwatchFile(this.path, this.handleChange);
111+
delete this.path;
80112
};
81113
}

src/packages/util/failing-to-save.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function failing_to_save(
4444
hash: number,
4545
expected_hash?: number
4646
): boolean {
47-
if (expected_hash == undefined) {
47+
if (expected_hash == null) {
4848
return false;
4949
}
5050
if (!state[path]) {

0 commit comments

Comments
 (0)