grepmax 0.26.63 → 0.26.65
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/.claude-plugin/marketplace.json +1 -1
- package/README.md +12 -2
- package/dist/bin.js +1 -1
- package/dist/commands/recover.js +117 -0
- package/dist/index.js +2 -0
- package/dist/lib/daemon/watcher-manager.js +69 -27
- package/dist/lib/index/watcher-errors.js +16 -0
- package/dist/lib/index/watcher.js +43 -3
- package/dist/lib/store/lance-cleanup.js +425 -0
- package/dist/lib/store/prune-state.js +113 -0
- package/dist/lib/store/recovery-admission.js +92 -0
- package/dist/lib/store/recovery.js +163 -0
- package/dist/lib/store/store-lease.js +119 -4
- package/dist/lib/utils/host-resource.js +3 -3
- package/dist/lib/utils/resource-budget.js +6 -6
- package/lance-maintenance/prune.py +200 -0
- package/lance-maintenance/pyproject.toml +9 -0
- package/lance-maintenance/uv.lock +531 -0
- package/package.json +5 -2
- package/plugins/grepmax/.claude-plugin/plugin.json +1 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "grepmax",
|
|
11
11
|
"source": "./plugins/grepmax",
|
|
12
12
|
"description": "Semantic code search for Claude Code. Automatically indexes your project and provides intelligent search capabilities.",
|
|
13
|
-
"version": "0.26.
|
|
13
|
+
"version": "0.26.65",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Robert Owens",
|
|
16
16
|
"email": "robowens@me.com"
|
package/README.md
CHANGED
|
@@ -240,7 +240,7 @@ gmax status # See all projects + watcher status
|
|
|
240
240
|
|
|
241
241
|
Adding or indexing a project can start the daemon automatically. MCP acquires primary-project watch leases lazily when an index-reading tool is used; discovery and catalog listing start no background work. It shuts down after 4 hours of inactivity, and hands off to a fresh daemon after 24 hours or once its memory footprint passes 2.5 GB. File-change batches are processed on up to four worker processes by default, added only when work backs up, with one kept free for searches; full-table compaction and new FTS/ANN index builds are disabled by containment. Existing indexes remain readable. Host safety stops persist across restart and block heavy operations.
|
|
242
242
|
|
|
243
|
-
`gmax watch status` reports failed files, native watcher recovery or polling, dropped-event counts, and the last complete filesystem reconciliation.
|
|
243
|
+
`gmax watch status` reports failed files, native watcher recovery or polling, dropped-event counts, and the last complete filesystem reconciliation. FSEvents dropped-event warnings keep the native stream attached and preserve the accompanying events. A reconciliation covers the gap; repeated warnings coalesce into one later scan, with a 30-second scan interval. Recovery stays visible until a complete scan covers the latest gap. Other backend errors still trigger subscription recovery; repeated terminal failures fall back to five-minute polling, where recent edits can lag. Search and MCP responses retain health warnings even after the file queue drains. A complete reconciliation means the filesystem scan completed; queued embedding work can still be pending.
|
|
244
244
|
|
|
245
245
|
Live filesystem edits take priority over background catchup and cleanup. Background batches dispatch one file at a time, with at most four catchup files or 50 cleanup paths. They yield between files when a live edit arrives or the two-second dispatch window is reached; an active file or store commit finishes before yielding. Catchup and cleanup alternate, and one background file is admitted after three consecutive live batches so cleanup continues during sustained editing. Existing retry backoff and worker limits remain in effect.
|
|
246
246
|
|
|
@@ -485,7 +485,17 @@ When heavy work is paused, the daemon keeps metadata, bounded symbol/file lookup
|
|
|
485
485
|
|
|
486
486
|
To serve bounded reads under an existing startup quarantine, run `gmax watch --daemon --read-only -b`. This preserves the safety markers. `status --json` reports `daemon.service.mode` and its reason; `doctor` reports paused availability and skips native corpus diagnostics. Pausing is sticky for that daemon process. Full service requires a deliberate restart after host recovery and quarantine review. Known critical OS or kernel pressure still stops even the read-only service.
|
|
487
487
|
|
|
488
|
-
Full-table compaction and new FTS/ANN index builds are disabled in the containment release, including forced maintenance and doctor repair. Existing indexes remain readable; missing lexical indexes fall back to available retrieval.
|
|
488
|
+
Full-table compaction and new FTS/ANN index builds are disabled in the containment release, including forced maintenance and doctor repair. Existing indexes remain readable; missing lexical indexes fall back to available retrieval. Do not repeatedly force repair, use future cutoffs or manually remove index fragments.
|
|
489
|
+
|
|
490
|
+
The package includes a separate prune-only helper with pinned Lance 12 dependencies, plus an exclusively leased Node wrapper. Runtime preparation uses `uv >=0.12.18`, an existing Python interpreter and locked wheels; it does not download Python, build from source, load an embedding model or run during installation. The helper currently supports macOS and Linux. No timer, CLI repair or startup path calls it automatically. Explicit recovery has separate production admission; packaging does not authorize live cleanup.
|
|
491
|
+
|
|
492
|
+
`gmax recover --table /path/to/lancedb/chunks.lance --json` reads the durable recovery receipt without starting a daemon or preparing Python. `--check` samples admission without pruning. `--prune --version <current-version> --cutoff <absolute-ISO-timestamp>` explicitly requests one offline prune. A prior uncertain attempt requires `--acknowledge-uncertain <attempt-id>` after inspection. Recovery never creates/clears quarantine, stops other owners, retries automatically or enables rewriting.
|
|
493
|
+
|
|
494
|
+
Production recovery currently requires macOS, a persistent `autostart-disabled` file in the selected store home, and closed store owners. Recovery alone uses known healthy memory/kernel pressure, measured physical headroom and the shared aggregate resource budget; normal development retains its configured guard policy. The helper reserves 512 MiB of admission capacity; this is not an OS-enforced footprint cap. Recovery samples have an eight-second total ceiling, including the established five-second kernel probe allowance, with fresh sampling at deletion admission and five-second heartbeat scheduling. Unknown, stale, warning or critical measurements refuse/interrupt recovery. Pruning needs at least 1 MiB of measured metadata disk headroom, not full-table rewrite space. Initial wheel setup requires 512 MiB; an existing environment is checked against the lock offline without that download-space requirement.
|
|
495
|
+
|
|
496
|
+
The child waits for launch and deletion admission tokens, with verified helper PID/start identity recorded as the primary exclusive owner. Existing readers retain exclusion if the parent dies while the helper is alive. Current/tagged/post-cutoff metadata is verified after pruning; recovery refuses more than 256 protected versions before deletion to bound verification. Interrupted attempts require verification and explicit acknowledgement before retry. Linux/APFS fixtures exercise native retention and interruption; supported production host admission is tested separately and still runs freshly for each operation.
|
|
497
|
+
|
|
498
|
+
Explicit helper calls persist a private `.gmax-prune-state.json` receipt outside the table before deletion. Cancellation, invalid results, lost ownership and abandoned running receipts remain uncertain; a verified explicit retry records the prior uncertain attempt. Successful receipts include before/after file lengths, allocated bytes and filesystem free space. These measurements do not imply zero retained copies or a macOS physical-footprint cap. Current, tagged and post-cutoff versions are protected; unverified files are retained. Full-table rewriting remains disabled regardless of receipt state.
|
|
489
499
|
|
|
490
500
|
`~/.gmax/logs/daemon.log` records containment skips and retained historical compaction outcomes, including duration, logical size, disk size before/after, free space, and bytes reclaimed. `gmax status --json` exposes the last outcome under `daemon.compaction`; its `at` timestamp identifies when it was recorded. Five-minute resource snapshots track footprint, RSS, heap, external/ArrayBuffer memory, Lance cache, workers and pending work for memory investigations. Reading status uses retained snapshots and does not scan the index directory. Logs rotate to `daemon.log.prev`.
|
|
491
501
|
|
package/dist/bin.js
CHANGED
|
@@ -77,7 +77,7 @@ function selectHome() {
|
|
|
77
77
|
const argv = process.argv.slice(2);
|
|
78
78
|
// These enumerate/route stores per request rather than pinning the process
|
|
79
79
|
// to its launch cwd's store. In particular an MCP session can serve both.
|
|
80
|
-
if (argv[0] === "mcp" || argv[0] === "status")
|
|
80
|
+
if (argv[0] === "mcp" || argv[0] === "status" || argv[0] === "recover")
|
|
81
81
|
return;
|
|
82
82
|
const match = (0, stores_1.matchStore)(commandTarget(argv, process.cwd()));
|
|
83
83
|
if (match.kind === "primary")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
36
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
37
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
38
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
39
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
40
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
41
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.recover = void 0;
|
|
46
|
+
exports.parseRecoveryCutoff = parseRecoveryCutoff;
|
|
47
|
+
const fs = __importStar(require("node:fs"));
|
|
48
|
+
const path = __importStar(require("node:path"));
|
|
49
|
+
const commander_1 = require("commander");
|
|
50
|
+
const prune_state_1 = require("../lib/store/prune-state");
|
|
51
|
+
const recovery_1 = require("../lib/store/recovery");
|
|
52
|
+
function parseRecoveryCutoff(value) {
|
|
53
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
54
|
+
if (!match)
|
|
55
|
+
throw new Error("--cutoff requires an absolute ISO timestamp with timezone");
|
|
56
|
+
const calendar = new Date(`${value.slice(0, 19)}Z`);
|
|
57
|
+
const parts = [
|
|
58
|
+
calendar.getUTCFullYear(),
|
|
59
|
+
calendar.getUTCMonth() + 1,
|
|
60
|
+
calendar.getUTCDate(),
|
|
61
|
+
calendar.getUTCHours(),
|
|
62
|
+
calendar.getUTCMinutes(),
|
|
63
|
+
calendar.getUTCSeconds(),
|
|
64
|
+
];
|
|
65
|
+
const result = new Date(value);
|
|
66
|
+
if (parts.some((part, i) => part !== Number(match[i + 1])) ||
|
|
67
|
+
!Number.isFinite(result.getTime()))
|
|
68
|
+
throw new Error("--cutoff is not a valid calendar timestamp");
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
exports.recover = new commander_1.Command("recover")
|
|
72
|
+
.description("Inspect or explicitly prune an offline Lance store without rewriting it")
|
|
73
|
+
.requiredOption("--table <path>", "Existing .lance table directory")
|
|
74
|
+
.option("--check", "Check recovery admission without preparing or pruning")
|
|
75
|
+
.option("--prune", "Explicitly perform exclusively leased prune-only recovery")
|
|
76
|
+
.option("--version <number>", "Expected current table version", Number)
|
|
77
|
+
.option("--cutoff <timestamp>", "Real absolute ISO retention cutoff")
|
|
78
|
+
.option("--acknowledge-uncertain <attempt>", "Acknowledge an inspected uncertain attempt before explicit retry")
|
|
79
|
+
.option("--json", "Structured outcome and durable receipt")
|
|
80
|
+
.addHelpText("after", "\nStatus is metadata-only. Pruning requires a persistent autostart-disabled file in the store home, closed store owners, known healthy macOS resources, --version and --cutoff. Recovery does not stop services or clear containment. --check can refuse under unknown pressure without pausing normal service.")
|
|
81
|
+
.action((opts) => __awaiter(void 0, void 0, void 0, function* () {
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const abort = () => controller.abort();
|
|
84
|
+
process.once("SIGINT", abort);
|
|
85
|
+
process.once("SIGTERM", abort);
|
|
86
|
+
try {
|
|
87
|
+
const report = yield (0, recovery_1.recoverStore)({
|
|
88
|
+
table: opts.table,
|
|
89
|
+
check: opts.check,
|
|
90
|
+
prune: opts.prune,
|
|
91
|
+
version: opts.version,
|
|
92
|
+
cutoff: opts.cutoff ? parseRecoveryCutoff(opts.cutoff) : undefined,
|
|
93
|
+
acknowledgeUncertain: opts.acknowledgeUncertain,
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
});
|
|
96
|
+
console.log(JSON.stringify(report, null, opts.json ? undefined : 2));
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
let state = "unverified";
|
|
100
|
+
try {
|
|
101
|
+
state = (0, prune_state_1.readPruneState)(path.dirname(fs.realpathSync(opts.table)));
|
|
102
|
+
}
|
|
103
|
+
catch (_a) { }
|
|
104
|
+
console.error(JSON.stringify({
|
|
105
|
+
outcome: state === "unverified" || (state === null || state === void 0 ? void 0 : state.outcome) === "uncertain"
|
|
106
|
+
? "uncertain"
|
|
107
|
+
: "refused",
|
|
108
|
+
error: error instanceof Error ? error.message : "Recovery failed",
|
|
109
|
+
state,
|
|
110
|
+
}));
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
process.removeListener("SIGINT", abort);
|
|
115
|
+
process.removeListener("SIGTERM", abort);
|
|
116
|
+
}
|
|
117
|
+
}));
|
package/dist/index.js
CHANGED
|
@@ -62,6 +62,7 @@ const peek_1 = require("./commands/peek");
|
|
|
62
62
|
const plugin_1 = require("./commands/plugin");
|
|
63
63
|
const project_1 = require("./commands/project");
|
|
64
64
|
const recent_1 = require("./commands/recent");
|
|
65
|
+
const recover_1 = require("./commands/recover");
|
|
65
66
|
const related_1 = require("./commands/related");
|
|
66
67
|
const remove_1 = require("./commands/remove");
|
|
67
68
|
const repair_1 = require("./commands/repair");
|
|
@@ -146,6 +147,7 @@ commander_1.program.addCommand(setup_1.setup);
|
|
|
146
147
|
commander_1.program.addCommand(config_1.config);
|
|
147
148
|
commander_1.program.addCommand(doctor_1.doctor);
|
|
148
149
|
commander_1.program.addCommand(repair_1.repair);
|
|
150
|
+
commander_1.program.addCommand(recover_1.recover);
|
|
149
151
|
// Plugins
|
|
150
152
|
commander_1.program.addCommand(plugin_1.plugin);
|
|
151
153
|
// Legacy plugin installers (hidden — use `gmax plugin` instead)
|
|
@@ -57,6 +57,7 @@ const cache_coherence_1 = require("../index/cache-coherence");
|
|
|
57
57
|
const file_policy_1 = require("../index/file-policy");
|
|
58
58
|
const walker_1 = require("../index/walker");
|
|
59
59
|
const watcher_1 = require("../index/watcher");
|
|
60
|
+
const watcher_errors_1 = require("../index/watcher-errors");
|
|
60
61
|
const watcher_ignore_1 = require("../index/watcher-ignore");
|
|
61
62
|
const file_utils_1 = require("../utils/file-utils");
|
|
62
63
|
const logger_1 = require("../utils/logger");
|
|
@@ -68,7 +69,7 @@ const watcher_store_1 = require("../utils/watcher-store");
|
|
|
68
69
|
// overflows has passed. Probe often; a failed probe just drops back to polling.
|
|
69
70
|
const FSEVENTS_RECOVERY_INTERVAL_MS = 15 * 60 * 1000;
|
|
70
71
|
const FSEVENTS_HEALTH_WINDOW_MS = 5 * 60 * 1000; // 5 min of quiet = "healthy"
|
|
71
|
-
//
|
|
72
|
+
// Terminal subscription failures can arrive in bursts. Every
|
|
72
73
|
// recovery inside this window folds into one deferred catchup scan instead of
|
|
73
74
|
// each rescanning the project (~25s on a 26k-file tree) or skipping outright
|
|
74
75
|
// and leaving its drop window uncovered.
|
|
@@ -104,18 +105,21 @@ class WatcherManager {
|
|
|
104
105
|
this.pollIntervals = new Map();
|
|
105
106
|
this.pollRecoveryTimers = new Map();
|
|
106
107
|
this.recoveryTimeouts = new Map();
|
|
107
|
-
this.
|
|
108
|
+
this.lastWatcherFailureMs = new Map();
|
|
108
109
|
this.lastWatcherErrorLogMs = new Map();
|
|
109
110
|
this.overflowCounts = new Map();
|
|
110
111
|
this.reconciledAt = new Map();
|
|
111
112
|
this.catchupDurations = new Map();
|
|
112
113
|
this.lastCatchupEndMs = new Map();
|
|
114
|
+
this.lastCatchupStartMs = new Map();
|
|
115
|
+
this.overflowPendingRoots = new Set();
|
|
113
116
|
this.deferredCatchups = new Map();
|
|
114
117
|
this.catchups = new Map();
|
|
115
118
|
this.degradedRoots = new Set();
|
|
116
119
|
this.terminalFailures = new Map();
|
|
117
120
|
this.watchLifecycles = new Map();
|
|
118
121
|
this.subscriptionUpdates = new Map();
|
|
122
|
+
this.watcherGenerations = new Map();
|
|
119
123
|
}
|
|
120
124
|
health(root) {
|
|
121
125
|
var _a;
|
|
@@ -126,7 +130,8 @@ class WatcherManager {
|
|
|
126
130
|
watcherMode: this.pollIntervals.has(root)
|
|
127
131
|
? "polling"
|
|
128
132
|
: this.pendingOps.has(`recover:${root}`) ||
|
|
129
|
-
this.deferredCatchups.has(root)
|
|
133
|
+
this.deferredCatchups.has(root) ||
|
|
134
|
+
this.overflowPendingRoots.has(root)
|
|
130
135
|
? "recovering"
|
|
131
136
|
: "native",
|
|
132
137
|
catchupRunning: this.catchups.has(root),
|
|
@@ -250,7 +255,7 @@ class WatcherManager {
|
|
|
250
255
|
const name = path.basename(root);
|
|
251
256
|
console.error(`[daemon:${name}] Subscribe failed at startup (${err instanceof Error ? err.message : err}) — switching to poll mode`);
|
|
252
257
|
this.watcherFailCount.set(root, 1000); // > MAX_WATCHER_RETRIES
|
|
253
|
-
this.
|
|
258
|
+
this.lastWatcherFailureMs.set(root, Date.now());
|
|
254
259
|
this.recoverWatcher(root, processor);
|
|
255
260
|
}
|
|
256
261
|
if (!ownsLifecycle())
|
|
@@ -311,6 +316,8 @@ class WatcherManager {
|
|
|
311
316
|
if (this.deps.processors.get(root) !== processor ||
|
|
312
317
|
((_b = (_a = this.deps).getShuttingDown) === null || _b === void 0 ? void 0 : _b.call(_a)))
|
|
313
318
|
return;
|
|
319
|
+
const generation = Symbol(root);
|
|
320
|
+
this.watcherGenerations.set(root, generation);
|
|
314
321
|
// Unsubscribe existing watcher if any (e.g. during recovery)
|
|
315
322
|
const existingSub = this.deps.subscriptions.get(root);
|
|
316
323
|
if (existingSub) {
|
|
@@ -322,21 +329,30 @@ class WatcherManager {
|
|
|
322
329
|
}
|
|
323
330
|
const sub = yield watcher.subscribe(root, (err, events) => {
|
|
324
331
|
var _a, _b;
|
|
325
|
-
|
|
332
|
+
var _c, _d;
|
|
333
|
+
if (this.deps.processors.get(root) !== processor ||
|
|
334
|
+
this.watcherGenerations.get(root) !== generation ||
|
|
335
|
+
((_b = (_a = this.deps).getShuttingDown) === null || _b === void 0 ? void 0 : _b.call(_a)))
|
|
326
336
|
return;
|
|
327
337
|
if (err) {
|
|
328
|
-
this.overflowCounts.set(root, ((
|
|
338
|
+
this.overflowCounts.set(root, ((_c = this.overflowCounts.get(root)) !== null && _c !== void 0 ? _c : 0) + 1);
|
|
329
339
|
const now = Date.now();
|
|
330
340
|
if (!this.lastWatcherErrorLogMs.has(root) ||
|
|
331
341
|
now - this.lastWatcherErrorLogMs.get(root) >= 60000) {
|
|
332
342
|
this.lastWatcherErrorLogMs.set(root, now);
|
|
333
343
|
console.error(`[daemon:${name}] Watcher error (total=${this.overflowCounts.get(root)}):`, err);
|
|
334
344
|
}
|
|
335
|
-
|
|
336
|
-
|
|
345
|
+
if ((0, watcher_errors_1.isFSEventsGap)(err)) {
|
|
346
|
+
this.overflowPendingRoots.add(root);
|
|
347
|
+
this.requestOverflowCatchup(root, processor);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
this.recoverWatcher(root, processor);
|
|
351
|
+
}
|
|
337
352
|
}
|
|
338
|
-
//
|
|
339
|
-
|
|
353
|
+
// Gaps do not kill Parcel's stream. Only a terminal failure should
|
|
354
|
+
// reset the subscription health window or prevent leaving poll mode.
|
|
355
|
+
const lastOverflow = (_d = this.lastWatcherFailureMs.get(root)) !== null && _d !== void 0 ? _d : 0;
|
|
340
356
|
if (Date.now() - lastOverflow > 5 * 60 * 1000) {
|
|
341
357
|
this.watcherFailCount.delete(root);
|
|
342
358
|
}
|
|
@@ -350,13 +366,30 @@ class WatcherManager {
|
|
|
350
366
|
...(yield (0, watcher_ignore_1.readProjectWatcherIgnores)(root)),
|
|
351
367
|
],
|
|
352
368
|
});
|
|
353
|
-
if (this.deps.processors.get(root) !== processor
|
|
369
|
+
if (this.deps.processors.get(root) !== processor ||
|
|
370
|
+
this.watcherGenerations.get(root) !== generation) {
|
|
354
371
|
yield sub.unsubscribe();
|
|
355
372
|
return;
|
|
356
373
|
}
|
|
357
374
|
this.deps.subscriptions.set(root, sub);
|
|
358
375
|
});
|
|
359
376
|
}
|
|
377
|
+
requestOverflowCatchup(root, processor) {
|
|
378
|
+
const started = this.lastCatchupStartMs.get(root);
|
|
379
|
+
const remaining = started === undefined
|
|
380
|
+
? 0
|
|
381
|
+
: watcher_errors_1.FSEVENTS_GAP_SCAN_INTERVAL_MS - (Date.now() - started);
|
|
382
|
+
// A gap during a scan may concern a path already visited. Keep one later
|
|
383
|
+
// scan rather than treating the current walk as coverage of that gap.
|
|
384
|
+
if (this.catchups.has(root) || remaining > 0) {
|
|
385
|
+
this.deferCatchup(root, processor, Math.max(remaining, watcher_errors_1.FSEVENTS_GAP_SCAN_INTERVAL_MS));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.cancelDeferredCatchup(root);
|
|
389
|
+
this.runCatchup(root, processor).catch((err) => {
|
|
390
|
+
console.error(`[daemon:${path.basename(root)}] Overflow catchup failed:`, err);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
360
393
|
recoverWatcher(root, processor) {
|
|
361
394
|
var _a;
|
|
362
395
|
const name = path.basename(root);
|
|
@@ -370,11 +403,11 @@ class WatcherManager {
|
|
|
370
403
|
this.pendingOps.add(recoveryKey);
|
|
371
404
|
const fails = ((_a = this.watcherFailCount.get(root)) !== null && _a !== void 0 ? _a : 0) + 1;
|
|
372
405
|
this.watcherFailCount.set(root, fails);
|
|
373
|
-
this.
|
|
406
|
+
this.lastWatcherFailureMs.set(root, Date.now());
|
|
374
407
|
const MAX_WATCHER_RETRIES = 3;
|
|
375
408
|
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
376
409
|
if (fails > MAX_WATCHER_RETRIES) {
|
|
377
|
-
//
|
|
410
|
+
// Repeated terminal subscription failures require periodic catchup scans.
|
|
378
411
|
// Always tear down the broken sub, even if poll mode is already active —
|
|
379
412
|
// this can happen if a recovery attempt resubscribed successfully then
|
|
380
413
|
// re-overflowed during the 5-min health window.
|
|
@@ -384,7 +417,7 @@ class WatcherManager {
|
|
|
384
417
|
this.deps.subscriptions.delete(root);
|
|
385
418
|
}
|
|
386
419
|
if (!this.pollIntervals.has(root)) {
|
|
387
|
-
console.error(`[daemon:${name}]
|
|
420
|
+
console.error(`[daemon:${name}] Native watcher failed after ${fails} terminal errors — switching to poll mode (${POLL_INTERVAL_MS / 60000}min interval)`);
|
|
388
421
|
// Run an immediate catchup, then schedule periodic ones
|
|
389
422
|
this.runCatchup(root, processor).catch((err) => {
|
|
390
423
|
console.error(`[daemon:${name}] Poll catchup failed:`, err);
|
|
@@ -416,7 +449,7 @@ class WatcherManager {
|
|
|
416
449
|
}
|
|
417
450
|
// Backoff: wait before re-subscribing (3s, 6s, 12s)
|
|
418
451
|
const delayMs = 3000 * Math.pow(2, (fails - 1));
|
|
419
|
-
console.error(`[daemon:${name}] Recovering watcher (attempt ${fails}/${MAX_WATCHER_RETRIES}, backoff ${delayMs}ms)...`);
|
|
452
|
+
console.error(`[daemon:${name}] Recovering failed watcher (attempt ${fails}/${MAX_WATCHER_RETRIES}, backoff ${delayMs}ms)...`);
|
|
420
453
|
const timeout = setTimeout(() => {
|
|
421
454
|
this.recoveryTimeouts.delete(root);
|
|
422
455
|
if (this.deps.getShuttingDown() ||
|
|
@@ -520,14 +553,14 @@ class WatcherManager {
|
|
|
520
553
|
if (this.deps.processors.get(root) !== processor)
|
|
521
554
|
return;
|
|
522
555
|
// Wait one health window — if the new subscription survives without
|
|
523
|
-
// another
|
|
556
|
+
// another terminal failure, we consider it recovered and tear down poll mode.
|
|
524
557
|
yield abortableDelay(FSEVENTS_HEALTH_WINDOW_MS, lifecycle.signal);
|
|
525
558
|
if (this.deps.getShuttingDown() ||
|
|
526
559
|
this.deps.processors.get(root) !== processor)
|
|
527
560
|
return;
|
|
528
|
-
const lastOverflow = (_a = this.
|
|
561
|
+
const lastOverflow = (_a = this.lastWatcherFailureMs.get(root)) !== null && _a !== void 0 ? _a : 0;
|
|
529
562
|
if (Date.now() - lastOverflow < FSEVENTS_HEALTH_WINDOW_MS) {
|
|
530
|
-
console.log(`[daemon:${name}] FSEvents recovery aborted —
|
|
563
|
+
console.log(`[daemon:${name}] FSEvents recovery aborted — terminal failure within health window, staying in poll mode`);
|
|
531
564
|
return; // recoverWatcher will have re-armed poll mode if needed
|
|
532
565
|
}
|
|
533
566
|
// Healthy — drop poll mode.
|
|
@@ -555,8 +588,9 @@ class WatcherManager {
|
|
|
555
588
|
return __awaiter(this, void 0, void 0, function* () {
|
|
556
589
|
var _a, e_1, _b, _c;
|
|
557
590
|
var _d;
|
|
558
|
-
var _e;
|
|
591
|
+
var _e, _f, _g;
|
|
559
592
|
const scanStart = Date.now();
|
|
593
|
+
const overflowCountAtStart = (_e = this.overflowCounts.get(root)) !== null && _e !== void 0 ? _e : 0;
|
|
560
594
|
const { isFileCached } = yield Promise.resolve().then(() => __importStar(require("../utils/cache-check")));
|
|
561
595
|
const metaCache = this.deps.getMetaCache();
|
|
562
596
|
const vectorDb = this.deps.getVectorDb();
|
|
@@ -573,12 +607,12 @@ class WatcherManager {
|
|
|
573
607
|
let skipped = 0;
|
|
574
608
|
let debugSamples = 0;
|
|
575
609
|
try {
|
|
576
|
-
for (var
|
|
610
|
+
for (var _h = true, _j = __asyncValues((0, walker_1.walk)(root, {
|
|
577
611
|
policy: processor.filePolicy,
|
|
578
612
|
state: walkState,
|
|
579
|
-
})),
|
|
580
|
-
_c =
|
|
581
|
-
|
|
613
|
+
})), _k; _k = yield _j.next(), _a = _k.done, !_a; _h = true) {
|
|
614
|
+
_c = _k.value;
|
|
615
|
+
_h = false;
|
|
582
616
|
const relPath = _c;
|
|
583
617
|
if (signal.aborted)
|
|
584
618
|
return false;
|
|
@@ -668,7 +702,7 @@ class WatcherManager {
|
|
|
668
702
|
catch (e_1_1) { e_1 = { error: e_1_1 }; }
|
|
669
703
|
finally {
|
|
670
704
|
try {
|
|
671
|
-
if (!
|
|
705
|
+
if (!_h && !_a && (_b = _j.return)) yield _b.call(_j);
|
|
672
706
|
}
|
|
673
707
|
finally { if (e_1) throw e_1.error; }
|
|
674
708
|
}
|
|
@@ -701,7 +735,7 @@ class WatcherManager {
|
|
|
701
735
|
}
|
|
702
736
|
else {
|
|
703
737
|
this.degradedRoots.delete(root);
|
|
704
|
-
const failedFiles = (
|
|
738
|
+
const failedFiles = (_f = (_d = this.terminalFailures.get(root)) === null || _d === void 0 ? void 0 : _d.size) !== null && _f !== void 0 ? _f : 0;
|
|
705
739
|
(0, watcher_store_1.registerWatcher)(Object.assign({ pid: process.pid, projectRoot: root, startTime: Date.now(), status: this.isRootDegraded(root) ? "degraded" : "watching", lastHeartbeat: Date.now() }, (failedFiles > 0
|
|
706
740
|
? { lastError: `${failedFiles} file(s) exhausted automatic retries` }
|
|
707
741
|
: {})));
|
|
@@ -717,8 +751,12 @@ class WatcherManager {
|
|
|
717
751
|
const ended = Date.now();
|
|
718
752
|
this.lastCatchupEndMs.set(root, ended);
|
|
719
753
|
this.catchupDurations.set(root, ended - scanStart);
|
|
720
|
-
if (complete)
|
|
754
|
+
if (complete) {
|
|
721
755
|
this.reconciledAt.set(root, ended);
|
|
756
|
+
// A later gap still needs its deferred scan, even if this walk succeeded.
|
|
757
|
+
if (((_g = this.overflowCounts.get(root)) !== null && _g !== void 0 ? _g : 0) === overflowCountAtStart)
|
|
758
|
+
this.overflowPendingRoots.delete(root);
|
|
759
|
+
}
|
|
722
760
|
return complete;
|
|
723
761
|
});
|
|
724
762
|
}
|
|
@@ -737,6 +775,7 @@ class WatcherManager {
|
|
|
737
775
|
let incompleteRetries = 0;
|
|
738
776
|
do {
|
|
739
777
|
active.dirty = false;
|
|
778
|
+
this.lastCatchupStartMs.set(root, Date.now());
|
|
740
779
|
const complete = yield this.catchupScan(root, processor, signal);
|
|
741
780
|
if (!complete && !signal.aborted && incompleteRetries < 3) {
|
|
742
781
|
incompleteRetries++;
|
|
@@ -775,6 +814,7 @@ class WatcherManager {
|
|
|
775
814
|
// independently of the processor, so clear them even on the early return.
|
|
776
815
|
(_a = this.watchLifecycles.get(root)) === null || _a === void 0 ? void 0 : _a.abort();
|
|
777
816
|
this.watchLifecycles.delete(root);
|
|
817
|
+
this.watcherGenerations.delete(root);
|
|
778
818
|
const pollInterval = this.pollIntervals.get(root);
|
|
779
819
|
if (pollInterval) {
|
|
780
820
|
clearInterval(pollInterval);
|
|
@@ -804,8 +844,10 @@ class WatcherManager {
|
|
|
804
844
|
this.deps.subscriptions.delete(root);
|
|
805
845
|
}
|
|
806
846
|
this.deps.evictSearcher(root);
|
|
807
|
-
this.
|
|
847
|
+
this.lastWatcherFailureMs.delete(root);
|
|
808
848
|
this.lastCatchupEndMs.delete(root);
|
|
849
|
+
this.lastCatchupStartMs.delete(root);
|
|
850
|
+
this.overflowPendingRoots.delete(root);
|
|
809
851
|
this.degradedRoots.delete(root);
|
|
810
852
|
this.terminalFailures.delete(root);
|
|
811
853
|
(0, watcher_store_1.unregisterWatcherByRoot)(root);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FSEVENTS_GAP_SCAN_INTERVAL_MS = void 0;
|
|
4
|
+
exports.isFSEventsGap = isFSEventsGap;
|
|
5
|
+
/** Parcel reports these gaps without closing its FSEvents stream. Other errors
|
|
6
|
+
* may clear the native callbacks and still require subscription recovery. */
|
|
7
|
+
function isFSEventsGap(error) {
|
|
8
|
+
return (error.message ===
|
|
9
|
+
"Events were dropped by the FSEvents client. File system must be re-scanned." ||
|
|
10
|
+
error.message ===
|
|
11
|
+
"Events were dropped by the kernel. File system must be re-scanned." ||
|
|
12
|
+
error.message === "Too many events. File system must be re-scanned.");
|
|
13
|
+
}
|
|
14
|
+
// Bound reconciliation under sustained churn without abandoning native edits
|
|
15
|
+
// for the five-minute polling fallback used by a failed subscription.
|
|
16
|
+
exports.FSEVENTS_GAP_SCAN_INTERVAL_MS = 30000;
|
|
@@ -58,6 +58,7 @@ const cache_coherence_1 = require("./cache-coherence");
|
|
|
58
58
|
const file_policy_1 = require("./file-policy");
|
|
59
59
|
const ignore_patterns_1 = require("./ignore-patterns");
|
|
60
60
|
const walker_1 = require("./walker");
|
|
61
|
+
const watcher_errors_1 = require("./watcher-errors");
|
|
61
62
|
const watcher_ignore_1 = require("./watcher-ignore");
|
|
62
63
|
// Directory names ignored at ANY depth. @parcel/watcher treats a non-glob
|
|
63
64
|
// ignore entry as a literal path resolved against the watch root, so a bare
|
|
@@ -125,6 +126,11 @@ function startWatcher(opts) {
|
|
|
125
126
|
let subscription = null;
|
|
126
127
|
let subscriptionUpdate = Promise.resolve();
|
|
127
128
|
let subscriptionPolicy = "";
|
|
129
|
+
let subscriptionFailed = false;
|
|
130
|
+
let subscriptionGeneration = 0;
|
|
131
|
+
let gapScanTimer;
|
|
132
|
+
let lastReconcileStart;
|
|
133
|
+
let gapGeneration = 0;
|
|
128
134
|
const updateSubscription = () => {
|
|
129
135
|
const update = subscriptionUpdate
|
|
130
136
|
.catch(() => { })
|
|
@@ -134,16 +140,31 @@ function startWatcher(opts) {
|
|
|
134
140
|
...(yield (0, watcher_ignore_1.readProjectWatcherIgnores)(projectRoot)),
|
|
135
141
|
];
|
|
136
142
|
const policy = JSON.stringify(ignores);
|
|
137
|
-
if (closing ||
|
|
143
|
+
if (closing ||
|
|
144
|
+
(subscription && !subscriptionFailed && subscriptionPolicy === policy))
|
|
138
145
|
return;
|
|
146
|
+
const generation = ++subscriptionGeneration;
|
|
147
|
+
subscriptionFailed = false;
|
|
139
148
|
yield subscription === null || subscription === void 0 ? void 0 : subscription.unsubscribe();
|
|
140
149
|
subscription = null;
|
|
141
150
|
if (closing)
|
|
142
151
|
return;
|
|
143
152
|
const next = yield watcher.subscribe(projectRoot, (err, events) => {
|
|
153
|
+
var _a;
|
|
154
|
+
if (closing || generation !== subscriptionGeneration)
|
|
155
|
+
return;
|
|
144
156
|
if (err) {
|
|
145
157
|
console.error(`[${wtag}] Watcher error:`, err);
|
|
146
|
-
|
|
158
|
+
scanHealthy = false;
|
|
159
|
+
(_a = opts.onHealthChange) === null || _a === void 0 ? void 0 : _a.call(opts, false, 1);
|
|
160
|
+
gapGeneration++;
|
|
161
|
+
if ((0, watcher_errors_1.isFSEventsGap)(err))
|
|
162
|
+
requestGapScan();
|
|
163
|
+
else {
|
|
164
|
+
// Terminal backend errors can clear Parcel's callbacks.
|
|
165
|
+
subscriptionFailed = true;
|
|
166
|
+
reconcile();
|
|
167
|
+
}
|
|
147
168
|
}
|
|
148
169
|
for (const event of events)
|
|
149
170
|
processor.handleFileEvent(event.type === "delete" ? "unlink" : "change", event.path);
|
|
@@ -180,6 +201,8 @@ function startWatcher(opts) {
|
|
|
180
201
|
let incompleteRetries = 0;
|
|
181
202
|
do {
|
|
182
203
|
reconcileRequested = false;
|
|
204
|
+
lastReconcileStart = Date.now();
|
|
205
|
+
const observedGapGeneration = gapGeneration;
|
|
183
206
|
filePolicy.invalidateIgnoreCache();
|
|
184
207
|
yield updateSubscription();
|
|
185
208
|
if (closing)
|
|
@@ -243,7 +266,7 @@ function startWatcher(opts) {
|
|
|
243
266
|
}
|
|
244
267
|
}
|
|
245
268
|
else {
|
|
246
|
-
scanHealthy =
|
|
269
|
+
scanHealthy = observedGapGeneration === gapGeneration;
|
|
247
270
|
if (ingestionDegraded && processor.progress.pendingFiles > 0) {
|
|
248
271
|
degradedRepairQueued = true;
|
|
249
272
|
}
|
|
@@ -257,6 +280,21 @@ function startWatcher(opts) {
|
|
|
257
280
|
reconciliation = null;
|
|
258
281
|
});
|
|
259
282
|
};
|
|
283
|
+
const requestGapScan = () => {
|
|
284
|
+
if (closing || gapScanTimer)
|
|
285
|
+
return;
|
|
286
|
+
const remaining = lastReconcileStart === undefined
|
|
287
|
+
? 0
|
|
288
|
+
: watcher_errors_1.FSEVENTS_GAP_SCAN_INTERVAL_MS - (Date.now() - lastReconcileStart);
|
|
289
|
+
if (reconciliation || remaining > 0) {
|
|
290
|
+
gapScanTimer = setTimeout(() => {
|
|
291
|
+
gapScanTimer = undefined;
|
|
292
|
+
reconcile();
|
|
293
|
+
}, Math.max(remaining, watcher_errors_1.FSEVENTS_GAP_SCAN_INTERVAL_MS));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
reconcile();
|
|
297
|
+
};
|
|
260
298
|
processor = new batch_processor_1.ProjectBatchProcessor(Object.assign(Object.assign({}, opts), { filePolicy, onPolicyChange: reconcile, onReindex: (files, durationMs) => {
|
|
261
299
|
var _a;
|
|
262
300
|
(_a = opts.onReindex) === null || _a === void 0 ? void 0 : _a.call(opts, files, durationMs);
|
|
@@ -284,6 +322,8 @@ function startWatcher(opts) {
|
|
|
284
322
|
},
|
|
285
323
|
close: () => __awaiter(this, void 0, void 0, function* () {
|
|
286
324
|
closing = true;
|
|
325
|
+
subscriptionGeneration++;
|
|
326
|
+
clearTimeout(gapScanTimer);
|
|
287
327
|
yield subscriptionUpdate.catch(() => { });
|
|
288
328
|
yield subscription === null || subscription === void 0 ? void 0 : subscription.unsubscribe();
|
|
289
329
|
yield reconciliation;
|