@cueloop/daemon 0.1.0-alpha.95 → 0.1.0-alpha.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/diff-watcher.test.ts +117 -0
- package/src/diff-watcher.ts +35 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.96",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
43
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
42
|
+
"@cueloop/extension-api": "0.1.0-alpha.96",
|
|
43
|
+
"@cueloop/schema": "0.1.0-alpha.96",
|
|
44
44
|
"@pierre/diffs": "^1.4.1",
|
|
45
|
-
"effect": "^4.0.0-rc.
|
|
45
|
+
"effect": "^4.0.0-rc.117",
|
|
46
46
|
"smol-toml": "^1.8.0",
|
|
47
47
|
"valibot": "^1.5.0"
|
|
48
48
|
},
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as promises from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { DiffWatcher } from "./diff-watcher";
|
|
7
|
+
|
|
8
|
+
const directoryReads: {
|
|
9
|
+
readdir: (path: fs.PathLike, options: { withFileTypes: true }) => Promise<fs.Dirent[]>;
|
|
10
|
+
} = promises;
|
|
11
|
+
|
|
12
|
+
test("startup reconciliation watches a directory whose creation event was missed", async () => {
|
|
13
|
+
const repo = fs.mkdtempSync(join(tmpdir(), "cueloop-watch-startup-"));
|
|
14
|
+
const watch = fs.watch;
|
|
15
|
+
const listeners = new Map<string, fs.WatchListener<string>>();
|
|
16
|
+
let refreshes = 0;
|
|
17
|
+
const startup = Promise.withResolvers<void>();
|
|
18
|
+
const change = Promise.withResolvers<void>();
|
|
19
|
+
const watcher = new DiffWatcher(() => {
|
|
20
|
+
refreshes++;
|
|
21
|
+
if (refreshes === 1) startup.resolve();
|
|
22
|
+
else change.resolve();
|
|
23
|
+
});
|
|
24
|
+
// SAFETY: DiffWatcher always calls the string-filename, three-argument watch overload.
|
|
25
|
+
const spy = spyOn(fs, "watch").mockImplementation(((
|
|
26
|
+
path: fs.PathLike,
|
|
27
|
+
options: fs.WatchOptions,
|
|
28
|
+
listener: fs.WatchListener<string>,
|
|
29
|
+
) => {
|
|
30
|
+
listeners.set(String(path), listener);
|
|
31
|
+
|
|
32
|
+
return watch(path, options, () => {});
|
|
33
|
+
}) as typeof fs.watch);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
watcher.trackDiffRepo(repo, "session");
|
|
37
|
+
fs.mkdirSync(join(repo, "nested"));
|
|
38
|
+
await startup.promise;
|
|
39
|
+
expect(refreshes).toBe(1);
|
|
40
|
+
const onDirectoryChange = listeners.get(join(repo, "nested"));
|
|
41
|
+
|
|
42
|
+
expect(onDirectoryChange).toBeDefined();
|
|
43
|
+
fs.writeFileSync(join(repo, "nested", "inside.ts"), "export const inside = 1;\n");
|
|
44
|
+
onDirectoryChange?.("change", "inside.ts");
|
|
45
|
+
await change.promise;
|
|
46
|
+
expect(refreshes).toBe(2);
|
|
47
|
+
} finally {
|
|
48
|
+
watcher.close();
|
|
49
|
+
spy.mockRestore();
|
|
50
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("closing during startup reconciliation cancels the pending refresh", async () => {
|
|
55
|
+
const repo = fs.mkdtempSync(join(tmpdir(), "cueloop-watch-close-"));
|
|
56
|
+
const scanStarted = Promise.withResolvers<void>();
|
|
57
|
+
const scanFinished = Promise.withResolvers<fs.Dirent[]>();
|
|
58
|
+
const refresh = mock(() => {});
|
|
59
|
+
const watcher = new DiffWatcher(refresh);
|
|
60
|
+
const spy = spyOn(directoryReads, "readdir").mockImplementation(() => {
|
|
61
|
+
scanStarted.resolve();
|
|
62
|
+
|
|
63
|
+
return scanFinished.promise;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
watcher.trackDiffRepo(repo, "session");
|
|
68
|
+
await scanStarted.promise;
|
|
69
|
+
watcher.close();
|
|
70
|
+
scanFinished.resolve([]);
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
72
|
+
expect(refresh).not.toHaveBeenCalled();
|
|
73
|
+
} finally {
|
|
74
|
+
scanFinished.resolve([]);
|
|
75
|
+
watcher.close();
|
|
76
|
+
spy.mockRestore();
|
|
77
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("replacing a watch during reconciliation stops the old directory traversal", async () => {
|
|
82
|
+
const repo = fs.mkdtempSync(join(tmpdir(), "cueloop-watch-replace-"));
|
|
83
|
+
const first = join(repo, "a");
|
|
84
|
+
const next = join(repo, "b");
|
|
85
|
+
|
|
86
|
+
fs.mkdirSync(first);
|
|
87
|
+
fs.mkdirSync(next);
|
|
88
|
+
const scanStarted = Promise.withResolvers<void>();
|
|
89
|
+
const scanFinished = Promise.withResolvers<fs.Dirent[]>();
|
|
90
|
+
const scanned: string[] = [];
|
|
91
|
+
const watcher = new DiffWatcher(() => {});
|
|
92
|
+
const spy = spyOn(directoryReads, "readdir").mockImplementation((path) => {
|
|
93
|
+
scanned.push(String(path));
|
|
94
|
+
if (path === first) {
|
|
95
|
+
scanStarted.resolve();
|
|
96
|
+
|
|
97
|
+
return scanFinished.promise;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return Promise.resolve(fs.readdirSync(path, { withFileTypes: true }));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
watcher.trackDiffRepo(repo, "old");
|
|
105
|
+
await scanStarted.promise;
|
|
106
|
+
watcher.untrackDiffRepo(repo, "old");
|
|
107
|
+
watcher.trackDiffRepo(repo, "replacement");
|
|
108
|
+
scanFinished.resolve([]);
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
110
|
+
expect(scanned).not.toContain(next);
|
|
111
|
+
} finally {
|
|
112
|
+
scanFinished.resolve([]);
|
|
113
|
+
watcher.close();
|
|
114
|
+
spy.mockRestore();
|
|
115
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
package/src/diff-watcher.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { watch, readdirSync, statSync, type Dirent, type FSWatcher } from "node:fs";
|
|
13
|
+
import { readdir } from "node:fs/promises";
|
|
13
14
|
import { basename, isAbsolute, join } from "node:path";
|
|
14
15
|
|
|
15
16
|
/** Debounce window: a save or a checkout writes many files in a burst; collapse them into one re-capture. */
|
|
@@ -99,6 +100,7 @@ interface RepoWatch {
|
|
|
99
100
|
ignored: Set<string>;
|
|
100
101
|
/** Directories already watched, so a runtime-created dir is not watched twice. */
|
|
101
102
|
watchedDirs: Set<string>;
|
|
103
|
+
reconcilePending: boolean;
|
|
102
104
|
debounce: ReturnType<typeof setTimeout> | null;
|
|
103
105
|
}
|
|
104
106
|
|
|
@@ -130,6 +132,7 @@ export class DiffWatcher {
|
|
|
130
132
|
jjPoll: null,
|
|
131
133
|
ignored: ignoredDirectories(repoRoot),
|
|
132
134
|
watchedDirs: new Set(),
|
|
135
|
+
reconcilePending: true,
|
|
133
136
|
debounce: null,
|
|
134
137
|
};
|
|
135
138
|
|
|
@@ -229,6 +232,32 @@ export class DiffWatcher {
|
|
|
229
232
|
this.watchTree(repoRoot, path);
|
|
230
233
|
}
|
|
231
234
|
|
|
235
|
+
/** Directory creation events can be lost while the platform activates a watch. */
|
|
236
|
+
private async reconcileWatchTree(repoRoot: string, dir: string): Promise<void> {
|
|
237
|
+
const repoWatch = this.repoWatches.get(repoRoot);
|
|
238
|
+
|
|
239
|
+
if (!repoWatch) return;
|
|
240
|
+
if (!repoWatch.watchedDirs.has(dir)) {
|
|
241
|
+
this.extendWatch(repoRoot, dir);
|
|
242
|
+
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
let entries: Dirent[];
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
249
|
+
} catch {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (this.repoWatches.get(repoRoot) !== repoWatch) return;
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
if (this.repoWatches.get(repoRoot) !== repoWatch) return;
|
|
255
|
+
if (entry.isDirectory() && !entry.isSymbolicLink())
|
|
256
|
+
// eslint-disable-next-line no-await-in-loop
|
|
257
|
+
await this.reconcileWatchTree(repoRoot, join(dir, entry.name));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
232
261
|
/**
|
|
233
262
|
* Open one watcher and add it to `handles`; returns whether it opened. A
|
|
234
263
|
* watcher error (the path is deleted mid-review) is swallowed - hot-reload is
|
|
@@ -278,8 +307,13 @@ export class DiffWatcher {
|
|
|
278
307
|
|
|
279
308
|
if (!repoWatch) return;
|
|
280
309
|
if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
|
|
281
|
-
repoWatch.debounce = setTimeout(() => {
|
|
310
|
+
repoWatch.debounce = setTimeout(async () => {
|
|
282
311
|
repoWatch.debounce = null;
|
|
312
|
+
if (repoWatch.reconcilePending) {
|
|
313
|
+
repoWatch.reconcilePending = false;
|
|
314
|
+
await this.reconcileWatchTree(repoRoot, repoRoot);
|
|
315
|
+
}
|
|
316
|
+
if (this.repoWatches.get(repoRoot) !== repoWatch) return;
|
|
283
317
|
this.onRepoChange(repoRoot);
|
|
284
318
|
}, DIFF_REFRESH_DEBOUNCE_MS);
|
|
285
319
|
}
|