talon-agent 2.0.0 → 2.0.1
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 +1 -1
- package/src/core/vfs/fusefs.ts +183 -14
- package/src/core/vfs/nsdir.ts +12 -2
package/package.json
CHANGED
package/src/core/vfs/fusefs.ts
CHANGED
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
* and records why: fuseless hosts get the identical experience minus
|
|
11
11
|
* live views, never an error at boot.
|
|
12
12
|
*
|
|
13
|
+
* The degradation is not just a boot-time decision. Once mounted, a
|
|
14
|
+
* health watchdog re-probes the live views on an interval; if the mount
|
|
15
|
+
* goes unhealthy at runtime — the classic case being a `bin/*.node`
|
|
16
|
+
* rebuilt out from under the running daemon, or the mountpoint wedged to
|
|
17
|
+
* ENOTCONN — it tears the dead mount down, restores the symlink farm so
|
|
18
|
+
* file-backed paths keep working, and tries a bounded number of
|
|
19
|
+
* reconnects. A mount that can't come back settles into fuseless for
|
|
20
|
+
* the rest of the run. A broken FUSE layer must never take the daemon
|
|
21
|
+
* down with it; the worst outcome is losing the live views.
|
|
22
|
+
*
|
|
13
23
|
* Deadlock rule: the daemon must never do SYNCHRONOUS fs I/O under
|
|
14
24
|
* ~/.talon/ns — a sync call blocks the one JS thread that answers the
|
|
15
25
|
* bridge, wedging both sides. Async fs is safe (libuv worker blocks,
|
|
@@ -38,9 +48,38 @@ export type FuseStatus = {
|
|
|
38
48
|
/** How long a post-mount sanity probe may take before we bail out. */
|
|
39
49
|
const SANITY_TIMEOUT_MS = 3_000;
|
|
40
50
|
|
|
51
|
+
/** How often the health watchdog re-probes a live mount. */
|
|
52
|
+
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How many times the watchdog will try to remount after the mount goes
|
|
56
|
+
* unhealthy before giving up and staying fuseless for the rest of the
|
|
57
|
+
* run. Bounded so a permanently-broken FUSE setup doesn't thrash
|
|
58
|
+
* remount attempts every interval forever.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
61
|
+
|
|
41
62
|
let status: FuseStatus = { mounted: false, reason: "not started" };
|
|
42
63
|
let activeAddon: NativeFuseFs | null = null;
|
|
43
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Everything the watchdog needs to re-probe and reconnect the mount,
|
|
67
|
+
* captured on the last successful mount. `null` until then and after a
|
|
68
|
+
* clean shutdown — the watchdog is a no-op without it.
|
|
69
|
+
*/
|
|
70
|
+
type LiveMount = {
|
|
71
|
+
readonly mode: "auto" | "off";
|
|
72
|
+
readonly vfs: Vfs;
|
|
73
|
+
readonly nsRoot: string;
|
|
74
|
+
readonly addon: NativeFuseFs;
|
|
75
|
+
/** First synthetic mount — what the probe reads to prove liveness. */
|
|
76
|
+
readonly probe: string | undefined;
|
|
77
|
+
};
|
|
78
|
+
let live: LiveMount | null = null;
|
|
79
|
+
let healthTimer: ReturnType<typeof setInterval> | null = null;
|
|
80
|
+
let healing = false;
|
|
81
|
+
let reconnectAttempts = 0;
|
|
82
|
+
|
|
44
83
|
/** Is the FUSE layer live? Consulted by the address/command resolvers. */
|
|
45
84
|
export function isNamespaceFsMounted(): boolean {
|
|
46
85
|
return status.mounted;
|
|
@@ -56,6 +95,16 @@ export function _setNamespaceFsStatusForTesting(next: FuseStatus): void {
|
|
|
56
95
|
status = next;
|
|
57
96
|
}
|
|
58
97
|
|
|
98
|
+
/** Test seam — drive one watchdog tick synchronously (no interval wait). */
|
|
99
|
+
export async function _checkNamespaceFsHealthForTesting(): Promise<void> {
|
|
100
|
+
await checkNamespaceFsHealth();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Test seam — the watchdog's current reconnect-attempt counter. */
|
|
104
|
+
export function _reconnectAttemptsForTesting(): number {
|
|
105
|
+
return reconnectAttempts;
|
|
106
|
+
}
|
|
107
|
+
|
|
59
108
|
// ── Bridge: FUSE thread → JS Vfs ─────────────────────────────────────────────
|
|
60
109
|
|
|
61
110
|
const ERRNO: Record<VfsErrorCode, string> = {
|
|
@@ -179,19 +228,9 @@ export async function mountNamespaceFs(
|
|
|
179
228
|
// Sanity: the mount must actually answer before we advertise it.
|
|
180
229
|
// Async fs only — see the deadlock rule in the module doc.
|
|
181
230
|
const probe = synthetic[0];
|
|
182
|
-
const healthy = await
|
|
183
|
-
(async () => {
|
|
184
|
-
const entries = await readdir(nsRoot);
|
|
185
|
-
if (probe !== undefined && !entries.includes(probe)) return false;
|
|
186
|
-
if (probe !== undefined) {
|
|
187
|
-
if (!(await stat(`${nsRoot}/${probe}`)).isDirectory()) return false;
|
|
188
|
-
}
|
|
189
|
-
return true;
|
|
190
|
-
})(),
|
|
191
|
-
SANITY_TIMEOUT_MS,
|
|
192
|
-
);
|
|
231
|
+
const healthy = await probeMount(nsRoot, probe);
|
|
193
232
|
if (healthy !== true) {
|
|
194
|
-
await
|
|
233
|
+
await teardownAddon();
|
|
195
234
|
// The failed mount may have shadowed the symlink farm — restore it.
|
|
196
235
|
try {
|
|
197
236
|
syncNamespaceDir(options.vfs, nsRoot);
|
|
@@ -206,12 +245,43 @@ export async function mountNamespaceFs(
|
|
|
206
245
|
}
|
|
207
246
|
|
|
208
247
|
status = { mounted: true };
|
|
248
|
+
live = { mode: options.mode, vfs: options.vfs, nsRoot, addon, probe };
|
|
249
|
+
reconnectAttempts = 0;
|
|
250
|
+
startHealthWatchdog();
|
|
209
251
|
log("fusefs", `talon:// namespace mounted at ${nsRoot}`);
|
|
210
252
|
return status;
|
|
211
253
|
}
|
|
212
254
|
|
|
213
|
-
/**
|
|
214
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Probe a live mount: read the root and confirm the first synthetic
|
|
257
|
+
* subtree is a directory the bridge answers. `false` = mounted but not
|
|
258
|
+
* serving live views, `"timeout"` = the mount hangs syscalls (a wedged
|
|
259
|
+
* predecessor). Async fs only — a sync call under nsRoot would block the
|
|
260
|
+
* one JS thread that answers the bridge (see the deadlock rule).
|
|
261
|
+
*/
|
|
262
|
+
async function probeMount(
|
|
263
|
+
nsRoot: string,
|
|
264
|
+
probe: string | undefined,
|
|
265
|
+
): Promise<boolean | "timeout"> {
|
|
266
|
+
return withTimeout(
|
|
267
|
+
(async () => {
|
|
268
|
+
const entries = await readdir(nsRoot);
|
|
269
|
+
if (probe !== undefined && !entries.includes(probe)) return false;
|
|
270
|
+
if (probe !== undefined) {
|
|
271
|
+
if (!(await stat(`${nsRoot}/${probe}`)).isDirectory()) return false;
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
274
|
+
})(),
|
|
275
|
+
SANITY_TIMEOUT_MS,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Tear the addon's mount down without touching the watchdog. Shared by
|
|
281
|
+
* the sanity-rollback path, the self-heal path, and public unmount.
|
|
282
|
+
* Never throws, idempotent.
|
|
283
|
+
*/
|
|
284
|
+
async function teardownAddon(): Promise<void> {
|
|
215
285
|
const addon = activeAddon;
|
|
216
286
|
activeAddon = null;
|
|
217
287
|
if (status.mounted) status = { mounted: false, reason: "unmounted" };
|
|
@@ -223,6 +293,105 @@ export async function unmountNamespaceFs(): Promise<void> {
|
|
|
223
293
|
}
|
|
224
294
|
}
|
|
225
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Tear the layer down for good (daemon shutdown). Stops the watchdog so
|
|
298
|
+
* it can't resurrect the mount mid-shutdown, then unmounts. Never
|
|
299
|
+
* throws, idempotent.
|
|
300
|
+
*/
|
|
301
|
+
export async function unmountNamespaceFs(): Promise<void> {
|
|
302
|
+
stopHealthWatchdog();
|
|
303
|
+
live = null;
|
|
304
|
+
await teardownAddon();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Health watchdog: reconnect or degrade, never die ─────────────────────────
|
|
308
|
+
|
|
309
|
+
function startHealthWatchdog(): void {
|
|
310
|
+
if (healthTimer) return;
|
|
311
|
+
healthTimer = setInterval(() => {
|
|
312
|
+
void checkNamespaceFsHealth();
|
|
313
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
314
|
+
// The daemon owns the lifecycle; the probe must not keep the loop alive.
|
|
315
|
+
healthTimer.unref();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function stopHealthWatchdog(): void {
|
|
319
|
+
if (healthTimer) {
|
|
320
|
+
clearInterval(healthTimer);
|
|
321
|
+
healthTimer = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* One watchdog tick. When the live mount stops answering, drop to
|
|
327
|
+
* fuseless immediately (the safe state — file-backed paths keep working
|
|
328
|
+
* via the symlink farm) and then try to reconnect, bounded. Runs to
|
|
329
|
+
* completion under a re-entrancy guard so overlapping ticks can't stack
|
|
330
|
+
* a second teardown/remount on top of one in flight. Never throws.
|
|
331
|
+
*/
|
|
332
|
+
async function checkNamespaceFsHealth(): Promise<void> {
|
|
333
|
+
// Snapshot the mount facts: a concurrent shutdown nulls `live` between
|
|
334
|
+
// our awaits, and a tick must finish against one consistent view.
|
|
335
|
+
const mount = live;
|
|
336
|
+
if (healing || mount === null) return;
|
|
337
|
+
healing = true;
|
|
338
|
+
try {
|
|
339
|
+
if (status.mounted) {
|
|
340
|
+
const healthy = await probeMount(mount.nsRoot, mount.probe);
|
|
341
|
+
if (healthy === true) return;
|
|
342
|
+
logWarn(
|
|
343
|
+
"fusefs",
|
|
344
|
+
`mount went unhealthy (${
|
|
345
|
+
healthy === false ? "live views vanished" : "probe timed out"
|
|
346
|
+
}) — degrading to fuseless${
|
|
347
|
+
reconnectAttempts < MAX_RECONNECT_ATTEMPTS ? " and reconnecting" : ""
|
|
348
|
+
}`,
|
|
349
|
+
);
|
|
350
|
+
await teardownAddon();
|
|
351
|
+
// The dead mount may still shadow the mountpoint — restore the farm
|
|
352
|
+
// so file-backed paths survive the fuseless window.
|
|
353
|
+
try {
|
|
354
|
+
syncNamespaceDir(mount.vfs, mount.nsRoot);
|
|
355
|
+
} catch {
|
|
356
|
+
// best effort; the warning above already carries the real failure
|
|
357
|
+
}
|
|
358
|
+
status = {
|
|
359
|
+
mounted: false,
|
|
360
|
+
reason: "mount went unhealthy — degraded to fuseless",
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Shutdown raced this tick — nothing to reconnect to.
|
|
365
|
+
if (live === null) return;
|
|
366
|
+
|
|
367
|
+
// Already fuseless (just degraded, or a prior reconnect failed): try
|
|
368
|
+
// to bring the mount back, up to the cap.
|
|
369
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
370
|
+
stopHealthWatchdog();
|
|
371
|
+
status = {
|
|
372
|
+
mounted: false,
|
|
373
|
+
reason: `mount unhealthy; gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts — staying fuseless`,
|
|
374
|
+
};
|
|
375
|
+
logWarn("fusefs", status.reason!);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
reconnectAttempts += 1;
|
|
379
|
+
const result = await mountNamespaceFs({
|
|
380
|
+
mode: mount.mode,
|
|
381
|
+
vfs: mount.vfs,
|
|
382
|
+
nsRoot: mount.nsRoot,
|
|
383
|
+
addon: mount.addon,
|
|
384
|
+
});
|
|
385
|
+
if (result.mounted) {
|
|
386
|
+
log("fusefs", "reconnected — live views restored");
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
logWarn("fusefs", `health check failed: ${message(err)}`);
|
|
390
|
+
} finally {
|
|
391
|
+
healing = false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
226
395
|
/**
|
|
227
396
|
* A daemon that died without unmounting leaves the mountpoint wedged —
|
|
228
397
|
* every syscall answers ENOTCONN ("transport endpoint is not
|
package/src/core/vfs/nsdir.ts
CHANGED
|
@@ -18,8 +18,9 @@ import {
|
|
|
18
18
|
mkdirSync,
|
|
19
19
|
readdirSync,
|
|
20
20
|
readlinkSync,
|
|
21
|
-
|
|
21
|
+
rmdirSync,
|
|
22
22
|
symlinkSync,
|
|
23
|
+
unlinkSync,
|
|
23
24
|
} from "node:fs";
|
|
24
25
|
import { dirs } from "../../util/paths.js";
|
|
25
26
|
import type { Vfs } from "./vfs.js";
|
|
@@ -69,7 +70,16 @@ export function syncNamespaceDir(
|
|
|
69
70
|
desired.delete(entry); // already correct
|
|
70
71
|
continue;
|
|
71
72
|
}
|
|
72
|
-
|
|
73
|
+
// Remove the LINK, never its target. unlink is the precise
|
|
74
|
+
// primitive for that everywhere except Windows, where a
|
|
75
|
+
// directory-symlink is removed with rmdir instead — hence the
|
|
76
|
+
// fallback. (rmSync's contract on symlinks is murkier; these two
|
|
77
|
+
// never touch the target by definition.)
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(path);
|
|
80
|
+
} catch {
|
|
81
|
+
rmdirSync(path);
|
|
82
|
+
}
|
|
73
83
|
if (target === undefined) pruned.push(entry);
|
|
74
84
|
}
|
|
75
85
|
|