@indigoai-us/hq-cloud 6.18.89 → 6.18.90
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/dist/bin/sync-runner-self-profile.d.ts +225 -0
- package/dist/bin/sync-runner-self-profile.d.ts.map +1 -0
- package/dist/bin/sync-runner-self-profile.js +751 -0
- package/dist/bin/sync-runner-self-profile.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +96 -6
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +7 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/watcher-catchup-skip-store.d.ts.map +1 -1
- package/dist/bin/watcher-catchup-skip-store.js +6 -0
- package/dist/bin/watcher-catchup-skip-store.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +9 -0
- package/dist/ignore.js.map +1 -1
- package/dist/telemetry-events.d.ts +5 -0
- package/dist/telemetry-events.d.ts.map +1 -1
- package/dist/telemetry-events.js +9 -0
- package/dist/telemetry-events.js.map +1 -1
- package/dist/watcher.d.ts +50 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +195 -43
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync-runner self-profile.
|
|
3
|
+
*
|
|
4
|
+
* Once a minute the runner samples its own CPU time and RSS. When CPU use
|
|
5
|
+
* averaged over ten minutes goes above a fraction of one core, or RSS goes
|
|
6
|
+
* above a byte limit, it writes ONE plain stderr line. That part is always on.
|
|
7
|
+
*
|
|
8
|
+
* Capture is opt-in (HQ_SYNC_SELF_PROFILE=1, or `"enabled": true` in
|
|
9
|
+
* `<state dir>/sync-runner-self-profile.json`). With capture on, a trip also
|
|
10
|
+
* writes a local diagnostics folder under `<state dir>/diagnostics/sync-runner/`:
|
|
11
|
+
* a redacted summary JSON and a 60 s CPU profile taken through node:inspector.
|
|
12
|
+
* File paths in both are rewritten relative to the HQ root, and the home folder
|
|
13
|
+
* is shown as `~`. There is no heap snapshot: it would hold tokens and synced
|
|
14
|
+
* file contents. Only the newest three folders are kept. Nothing here sends
|
|
15
|
+
* anything over the network. `hq-sync-runner --diagnostics` prints the redacted
|
|
16
|
+
* summary for a bug report; the profile folder stays on this computer.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { Session } from "node:inspector";
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
|
+
import { heavyWatchKind } from "../watcher.js";
|
|
24
|
+
export const SELF_PROFILE_ENV = "HQ_SYNC_SELF_PROFILE";
|
|
25
|
+
export const SELF_PROFILE_CPU_CORES_ENV = "HQ_SYNC_SELF_PROFILE_CPU_CORES";
|
|
26
|
+
export const SELF_PROFILE_RSS_MB_ENV = "HQ_SYNC_SELF_PROFILE_RSS_MB";
|
|
27
|
+
export const SELF_PROFILE_CONFIG_FILE = "sync-runner-self-profile.json";
|
|
28
|
+
export const DEFAULT_SELF_PROFILE_CPU_CORES = 0.25;
|
|
29
|
+
export const DEFAULT_SELF_PROFILE_RSS_MB = 600;
|
|
30
|
+
export const SELF_PROFILE_SAMPLE_INTERVAL_MS = 60_000;
|
|
31
|
+
export const SELF_PROFILE_CPU_WINDOW_MS = 10 * 60_000;
|
|
32
|
+
export const SELF_PROFILE_CPU_PROFILE_MS = 60_000;
|
|
33
|
+
/** Minimum gap between two warnings, even when usage dips and recovers. */
|
|
34
|
+
export const SELF_PROFILE_WARNING_COOLDOWN_MS = 60 * 60_000;
|
|
35
|
+
/** Minimum gap between two captures. */
|
|
36
|
+
export const SELF_PROFILE_CAPTURE_COOLDOWN_MS = 6 * 60 * 60_000;
|
|
37
|
+
export const SELF_PROFILE_KEEP_CAPTURES = 3;
|
|
38
|
+
export const SELF_PROFILE_MAX_CPU_PROFILE_BYTES = 64 * 1024 * 1024;
|
|
39
|
+
/** Total size cap for every kept capture folder together. */
|
|
40
|
+
export const SELF_PROFILE_MAX_TOTAL_BYTES = 1024 * 1024 * 1024;
|
|
41
|
+
const CAPTURE_DIR_PREFIX = "self-profile-";
|
|
42
|
+
const MAX_EVENT_PREFIXES = 200;
|
|
43
|
+
const COVERAGE_SCAN_MAX_DIRS = 20_000;
|
|
44
|
+
const COVERAGE_SAMPLE_PATHS = 10;
|
|
45
|
+
function positiveNumber(raw) {
|
|
46
|
+
const value = typeof raw === "string" ? Number(raw.trim()) : raw;
|
|
47
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
48
|
+
? value
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
function enabledFlag(raw) {
|
|
52
|
+
if (typeof raw === "boolean")
|
|
53
|
+
return raw;
|
|
54
|
+
if (typeof raw !== "string")
|
|
55
|
+
return undefined;
|
|
56
|
+
const value = raw.trim().toLowerCase();
|
|
57
|
+
if (value === "1" || value === "true" || value === "yes" || value === "on")
|
|
58
|
+
return true;
|
|
59
|
+
if (value === "0" || value === "false" || value === "no" || value === "off")
|
|
60
|
+
return false;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
export function selfProfileConfigPath(stateDir) {
|
|
64
|
+
return path.join(stateDir, SELF_PROFILE_CONFIG_FILE);
|
|
65
|
+
}
|
|
66
|
+
export function selfProfileDiagnosticsDir(stateDir) {
|
|
67
|
+
return path.join(stateDir, "diagnostics", "sync-runner");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Environment variables win over the config file; anything missing or invalid
|
|
71
|
+
* falls back to the defaults. Capture stays off unless one of them turns it on.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveSelfProfileConfig(env, stateDir, readFile = (file) => fs.readFileSync(file, "utf8")) {
|
|
74
|
+
let file = {};
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(readFile(selfProfileConfigPath(stateDir)));
|
|
77
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
78
|
+
file = parsed;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* no config file, or not JSON: use env and defaults */
|
|
83
|
+
}
|
|
84
|
+
const captureEnabled = enabledFlag(env[SELF_PROFILE_ENV]) ?? enabledFlag(file.enabled) ?? false;
|
|
85
|
+
const cpuCores = positiveNumber(env[SELF_PROFILE_CPU_CORES_ENV]) ??
|
|
86
|
+
positiveNumber(file.cpuCores) ??
|
|
87
|
+
DEFAULT_SELF_PROFILE_CPU_CORES;
|
|
88
|
+
const rssMb = positiveNumber(env[SELF_PROFILE_RSS_MB_ENV]) ??
|
|
89
|
+
positiveNumber(file.rssMb) ??
|
|
90
|
+
DEFAULT_SELF_PROFILE_RSS_MB;
|
|
91
|
+
return { captureEnabled, cpuCores, rssBytes: Math.round(rssMb * 1024 * 1024) };
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Activity counters fed by the watch loop
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
/**
|
|
97
|
+
* Group a watcher path for the per-prefix event rate: two segments, or three
|
|
98
|
+
* under `companies/` so one company's areas stay apart.
|
|
99
|
+
*/
|
|
100
|
+
export function eventPathPrefix(relativePath) {
|
|
101
|
+
const segments = relativePath.split(/[\\/]+/).filter((segment) => segment !== "");
|
|
102
|
+
if (segments.length === 0)
|
|
103
|
+
return ".";
|
|
104
|
+
const depth = segments[0] === "companies" ? 3 : 2;
|
|
105
|
+
// A file directly under the prefix depth reports its parent folder.
|
|
106
|
+
const take = Math.min(depth, Math.max(1, segments.length - 1));
|
|
107
|
+
return segments.slice(0, take).join("/");
|
|
108
|
+
}
|
|
109
|
+
export class SelfProfileActivity {
|
|
110
|
+
now;
|
|
111
|
+
startedAt;
|
|
112
|
+
eventsByPrefix = new Map();
|
|
113
|
+
passes = 0;
|
|
114
|
+
constructor(now) {
|
|
115
|
+
this.now = now;
|
|
116
|
+
this.startedAt = now();
|
|
117
|
+
}
|
|
118
|
+
recordEvent(relativePath) {
|
|
119
|
+
let prefix = eventPathPrefix(relativePath);
|
|
120
|
+
if (!this.eventsByPrefix.has(prefix) && this.eventsByPrefix.size >= MAX_EVENT_PREFIXES) {
|
|
121
|
+
prefix = "(other)";
|
|
122
|
+
}
|
|
123
|
+
this.eventsByPrefix.set(prefix, (this.eventsByPrefix.get(prefix) ?? 0) + 1);
|
|
124
|
+
}
|
|
125
|
+
recordPass() {
|
|
126
|
+
this.passes += 1;
|
|
127
|
+
}
|
|
128
|
+
snapshot() {
|
|
129
|
+
const minutes = Math.max((this.now() - this.startedAt) / 60_000, 1 / 60);
|
|
130
|
+
const rate = (count) => Math.round((count / minutes) * 100) / 100;
|
|
131
|
+
const ranked = [...this.eventsByPrefix.entries()].sort((a, b) => b[1] - a[1]);
|
|
132
|
+
let events = 0;
|
|
133
|
+
const eventsPerMinuteByPrefix = {};
|
|
134
|
+
for (const [prefix, count] of ranked) {
|
|
135
|
+
events += count;
|
|
136
|
+
eventsPerMinuteByPrefix[prefix] = rate(count);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
minutes: Math.round(minutes * 10) / 10,
|
|
140
|
+
events,
|
|
141
|
+
eventsPerMinuteByPrefix,
|
|
142
|
+
passes: this.passes,
|
|
143
|
+
passesPerMinute: rate(this.passes),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const heavyKind = heavyWatchKind;
|
|
148
|
+
/**
|
|
149
|
+
* Report whether any OS watch is on, or (for a recursive watch) above,
|
|
150
|
+
* workspace/worktrees, a node_modules folder, or a nested .git folder. The
|
|
151
|
+
* walk under recursive roots is bounded so a large tree cannot stall the
|
|
152
|
+
* runner while it writes diagnostics.
|
|
153
|
+
*/
|
|
154
|
+
export function assessWatchCoverage(hqRoot, watched, readDir = (dir) => fs.readdirSync(dir, { withFileTypes: true })) {
|
|
155
|
+
const root = path.resolve(hqRoot);
|
|
156
|
+
const found = new Map();
|
|
157
|
+
const note = (abs, kind) => {
|
|
158
|
+
const relative = path.relative(root, abs);
|
|
159
|
+
// Never report an absolute or out-of-root path in the summary.
|
|
160
|
+
const rel = relative.startsWith("..") || path.isAbsolute(relative)
|
|
161
|
+
? OUTSIDE_HQ_ROOT
|
|
162
|
+
: relative.split(path.sep).join("/") || ".";
|
|
163
|
+
if (!found.has(rel))
|
|
164
|
+
found.set(rel, kind);
|
|
165
|
+
};
|
|
166
|
+
let budget = COVERAGE_SCAN_MAX_DIRS;
|
|
167
|
+
let scanTruncated = false;
|
|
168
|
+
const scanned = new Set();
|
|
169
|
+
for (const entry of watched) {
|
|
170
|
+
const abs = path.resolve(entry.path);
|
|
171
|
+
const rel = path.relative(root, abs);
|
|
172
|
+
const kind = heavyKind(rel);
|
|
173
|
+
if (kind) {
|
|
174
|
+
note(abs, kind);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!entry.recursive)
|
|
178
|
+
continue;
|
|
179
|
+
const stack = [abs];
|
|
180
|
+
while (stack.length > 0) {
|
|
181
|
+
const dir = stack.pop();
|
|
182
|
+
if (scanned.has(dir))
|
|
183
|
+
continue;
|
|
184
|
+
scanned.add(dir);
|
|
185
|
+
if (budget <= 0) {
|
|
186
|
+
scanTruncated = true;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
budget -= 1;
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = readDir(dir);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
for (const child of entries) {
|
|
198
|
+
if (!child.isDirectory())
|
|
199
|
+
continue;
|
|
200
|
+
const childAbs = path.join(dir, child.name);
|
|
201
|
+
const childKind = heavyKind(path.relative(root, childAbs));
|
|
202
|
+
if (childKind)
|
|
203
|
+
note(childAbs, childKind);
|
|
204
|
+
else
|
|
205
|
+
stack.push(childAbs);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const kinds = new Set(found.values());
|
|
210
|
+
return {
|
|
211
|
+
watchedPathCount: watched.length,
|
|
212
|
+
recursiveRootCount: watched.filter((entry) => entry.recursive).length,
|
|
213
|
+
watchesWorktrees: kinds.has("worktrees"),
|
|
214
|
+
watchesNodeModules: kinds.has("node_modules"),
|
|
215
|
+
watchesNestedGit: kinds.has("git"),
|
|
216
|
+
examples: [...found.keys()].sort().slice(0, COVERAGE_SAMPLE_PATHS),
|
|
217
|
+
scanTruncated,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Pure threshold state. `observe` takes one sample and returns the reading;
|
|
222
|
+
* `trip` reports a reason only when the runner is over a threshold, a warning
|
|
223
|
+
* has not already been given for this episode, and the cooldown has passed.
|
|
224
|
+
*/
|
|
225
|
+
export class SelfProfileThresholds {
|
|
226
|
+
config;
|
|
227
|
+
windowMs;
|
|
228
|
+
cooldownMs;
|
|
229
|
+
samples = [];
|
|
230
|
+
armed = true;
|
|
231
|
+
lastWarnedAt = null;
|
|
232
|
+
constructor(config, windowMs = SELF_PROFILE_CPU_WINDOW_MS, cooldownMs = SELF_PROFILE_WARNING_COOLDOWN_MS) {
|
|
233
|
+
this.config = config;
|
|
234
|
+
this.windowMs = windowMs;
|
|
235
|
+
this.cooldownMs = cooldownMs;
|
|
236
|
+
}
|
|
237
|
+
observe(at, cpu, rssBytes) {
|
|
238
|
+
this.samples.push({ at, cpuMicros: cpu.user + cpu.system });
|
|
239
|
+
// Keep the newest sample that is at least one window old as the baseline.
|
|
240
|
+
while (this.samples.length > 2 && this.samples[1].at <= at - this.windowMs) {
|
|
241
|
+
this.samples.shift();
|
|
242
|
+
}
|
|
243
|
+
const oldest = this.samples[0];
|
|
244
|
+
const newest = this.samples[this.samples.length - 1];
|
|
245
|
+
const span = newest.at - oldest.at;
|
|
246
|
+
const cpuCores = span >= this.windowMs
|
|
247
|
+
? (newest.cpuMicros - oldest.cpuMicros) / 1000 / span
|
|
248
|
+
: null;
|
|
249
|
+
return { cpuCores, rssBytes };
|
|
250
|
+
}
|
|
251
|
+
trip(at, reading) {
|
|
252
|
+
const cpuOver = reading.cpuCores !== null && reading.cpuCores > this.config.cpuCores;
|
|
253
|
+
const rssOver = reading.rssBytes > this.config.rssBytes;
|
|
254
|
+
if (!cpuOver && !rssOver) {
|
|
255
|
+
// Re-arm only once a full CPU window is back under the threshold.
|
|
256
|
+
if (reading.cpuCores !== null)
|
|
257
|
+
this.armed = true;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
if (!this.armed)
|
|
261
|
+
return null;
|
|
262
|
+
if (this.lastWarnedAt !== null && at - this.lastWarnedAt < this.cooldownMs)
|
|
263
|
+
return null;
|
|
264
|
+
this.armed = false;
|
|
265
|
+
this.lastWarnedAt = at;
|
|
266
|
+
return cpuOver && rssOver ? "cpu+rss" : cpuOver ? "cpu" : "rss";
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const HQ_ROOT_TOKEN = "<hq>";
|
|
270
|
+
const HOME_TOKEN = "~";
|
|
271
|
+
const OUTSIDE_HQ_ROOT = "(outside HQ root)";
|
|
272
|
+
function escapeRegExp(value) {
|
|
273
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
274
|
+
}
|
|
275
|
+
function isAbsoluteAnyPlatform(value) {
|
|
276
|
+
return path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value);
|
|
277
|
+
}
|
|
278
|
+
/** Spellings of one absolute path that can appear in a profile or message. */
|
|
279
|
+
function pathSpellings(abs) {
|
|
280
|
+
const trimmed = abs.replace(/[\\/]+$/, "");
|
|
281
|
+
// A filesystem root or an empty value would match everything.
|
|
282
|
+
if (trimmed.length < 2 || !isAbsoluteAnyPlatform(abs))
|
|
283
|
+
return [];
|
|
284
|
+
const forward = trimmed.replace(/\\/g, "/");
|
|
285
|
+
const backward = trimmed.replace(/\//g, "\\");
|
|
286
|
+
const spellings = new Set([trimmed, forward, backward]);
|
|
287
|
+
try {
|
|
288
|
+
spellings.add(pathToFileURL(trimmed).href);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
/* not convertible on this platform */
|
|
292
|
+
}
|
|
293
|
+
// JSON text escapes each backslash.
|
|
294
|
+
for (const spelling of [...spellings])
|
|
295
|
+
spellings.add(JSON.stringify(spelling).slice(1, -1));
|
|
296
|
+
return [...spellings].sort((a, b) => b.length - a.length);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Replace every spelling of the HQ root with `<hq>` and of the home folder
|
|
300
|
+
* with `~`. Matching ignores letter case, because macOS and Windows paths do.
|
|
301
|
+
*/
|
|
302
|
+
export function scrubLocalPaths(text, redaction) {
|
|
303
|
+
let out = text;
|
|
304
|
+
for (const [abs, token] of [
|
|
305
|
+
[redaction.hqRoot, HQ_ROOT_TOKEN],
|
|
306
|
+
[redaction.homeDir, HOME_TOKEN],
|
|
307
|
+
]) {
|
|
308
|
+
for (const spelling of pathSpellings(abs)) {
|
|
309
|
+
out = out.replace(new RegExp(escapeRegExp(spelling), "gi"), token);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
function relativeInside(root, abs) {
|
|
315
|
+
const rel = path.relative(path.resolve(root), path.resolve(abs));
|
|
316
|
+
if (rel === "")
|
|
317
|
+
return ".";
|
|
318
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
319
|
+
return null;
|
|
320
|
+
return rel.split(path.sep).join("/");
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Rewrite a script URL or file path from a CPU profile: inside the HQ root it
|
|
324
|
+
* becomes relative to the root, inside the home folder it starts with `~/`.
|
|
325
|
+
* Non-file URLs such as `node:internal/...` are kept.
|
|
326
|
+
*/
|
|
327
|
+
export function redactProfilePath(value, redaction) {
|
|
328
|
+
let candidate = value;
|
|
329
|
+
if (/^file:/i.test(value)) {
|
|
330
|
+
try {
|
|
331
|
+
candidate = fileURLToPath(value);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return scrubLocalPaths(value, redaction);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (isAbsoluteAnyPlatform(candidate)) {
|
|
338
|
+
const inRoot = relativeInside(redaction.hqRoot, candidate);
|
|
339
|
+
if (inRoot !== null)
|
|
340
|
+
return inRoot;
|
|
341
|
+
const inHome = relativeInside(redaction.homeDir, candidate);
|
|
342
|
+
if (inHome !== null)
|
|
343
|
+
return inHome === "." ? HOME_TOKEN : `${HOME_TOKEN}/${inHome}`;
|
|
344
|
+
return scrubLocalPaths(value, redaction);
|
|
345
|
+
}
|
|
346
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
|
|
347
|
+
// A data: or blob: URL carries the script body itself; other URLs can carry
|
|
348
|
+
// credentials in the query string or the user-info part.
|
|
349
|
+
if (scheme === "data" || scheme === "blob")
|
|
350
|
+
return `${scheme}:(omitted)`;
|
|
351
|
+
if (scheme && scheme !== "node") {
|
|
352
|
+
const stripped = value.replace(/[?#].*$/s, "").replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, "$1");
|
|
353
|
+
return scrubLocalPaths(stripped, redaction);
|
|
354
|
+
}
|
|
355
|
+
return scrubLocalPaths(value, redaction);
|
|
356
|
+
}
|
|
357
|
+
function finiteNumber(value) {
|
|
358
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
359
|
+
}
|
|
360
|
+
function numberArray(value) {
|
|
361
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "number") : undefined;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Copy only the fields DevTools needs from a V8 CPU profile, with every script
|
|
365
|
+
* URL rewritten by `redactProfilePath`. Anything else V8 adds is dropped.
|
|
366
|
+
*/
|
|
367
|
+
export function redactCpuProfile(profile, redaction) {
|
|
368
|
+
if (!profile || typeof profile !== "object")
|
|
369
|
+
return null;
|
|
370
|
+
const source = profile;
|
|
371
|
+
const nodes = Array.isArray(source.nodes) ? source.nodes : [];
|
|
372
|
+
return {
|
|
373
|
+
nodes: nodes.map((raw) => {
|
|
374
|
+
const node = (raw && typeof raw === "object" ? raw : {});
|
|
375
|
+
const frame = (node.callFrame && typeof node.callFrame === "object" ? node.callFrame : {});
|
|
376
|
+
const positionTicks = Array.isArray(node.positionTicks)
|
|
377
|
+
? node.positionTicks.map((tick) => {
|
|
378
|
+
const entry = (tick && typeof tick === "object" ? tick : {});
|
|
379
|
+
return { line: finiteNumber(entry.line) ?? 0, ticks: finiteNumber(entry.ticks) ?? 0 };
|
|
380
|
+
})
|
|
381
|
+
: undefined;
|
|
382
|
+
return {
|
|
383
|
+
id: finiteNumber(node.id) ?? 0,
|
|
384
|
+
callFrame: {
|
|
385
|
+
functionName: scrubLocalPaths(String(frame.functionName ?? ""), redaction),
|
|
386
|
+
scriptId: String(frame.scriptId ?? ""),
|
|
387
|
+
url: redactProfilePath(String(frame.url ?? ""), redaction),
|
|
388
|
+
lineNumber: finiteNumber(frame.lineNumber) ?? -1,
|
|
389
|
+
columnNumber: finiteNumber(frame.columnNumber) ?? -1,
|
|
390
|
+
},
|
|
391
|
+
hitCount: finiteNumber(node.hitCount) ?? 0,
|
|
392
|
+
children: numberArray(node.children),
|
|
393
|
+
positionTicks,
|
|
394
|
+
};
|
|
395
|
+
}),
|
|
396
|
+
startTime: finiteNumber(source.startTime) ?? 0,
|
|
397
|
+
endTime: finiteNumber(source.endTime) ?? 0,
|
|
398
|
+
samples: numberArray(source.samples) ?? [],
|
|
399
|
+
timeDeltas: numberArray(source.timeDeltas) ?? [],
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function post(session, method) {
|
|
403
|
+
return new Promise((resolve, reject) => {
|
|
404
|
+
session.post(method, (err, result) => (err ? reject(err) : resolve(result)));
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
function captureStamp(at) {
|
|
408
|
+
return new Date(at).toISOString().replace(/[:.]/g, "-");
|
|
409
|
+
}
|
|
410
|
+
function directorySize(dir) {
|
|
411
|
+
let total = 0;
|
|
412
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
413
|
+
const abs = path.join(dir, entry.name);
|
|
414
|
+
if (entry.isDirectory())
|
|
415
|
+
total += directorySize(abs);
|
|
416
|
+
else if (entry.isFile())
|
|
417
|
+
total += fs.statSync(abs).size;
|
|
418
|
+
}
|
|
419
|
+
return total;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Keep the newest `keep` capture folders and, within those, delete the oldest
|
|
423
|
+
* until the total fits `maxTotalBytes`. The newest folder is always kept.
|
|
424
|
+
* Folders that are not self-profile captures are left alone.
|
|
425
|
+
*/
|
|
426
|
+
export function pruneSelfProfileCaptures(diagnosticsDir, keep = SELF_PROFILE_KEEP_CAPTURES, maxTotalBytes = SELF_PROFILE_MAX_TOTAL_BYTES) {
|
|
427
|
+
let names;
|
|
428
|
+
try {
|
|
429
|
+
names = fs.readdirSync(diagnosticsDir, { withFileTypes: true })
|
|
430
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith(CAPTURE_DIR_PREFIX))
|
|
431
|
+
.map((entry) => entry.name)
|
|
432
|
+
.sort();
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
const removed = [];
|
|
438
|
+
const remove = (name) => {
|
|
439
|
+
fs.rmSync(path.join(diagnosticsDir, name), { recursive: true, force: true });
|
|
440
|
+
removed.push(name);
|
|
441
|
+
};
|
|
442
|
+
while (names.length > Math.max(keep, 1))
|
|
443
|
+
remove(names.shift());
|
|
444
|
+
let total = names.reduce((sum, name) => sum + directorySize(path.join(diagnosticsDir, name)), 0);
|
|
445
|
+
while (names.length > 1 && total > maxTotalBytes) {
|
|
446
|
+
const name = names.shift();
|
|
447
|
+
total -= directorySize(path.join(diagnosticsDir, name));
|
|
448
|
+
remove(name);
|
|
449
|
+
}
|
|
450
|
+
return removed;
|
|
451
|
+
}
|
|
452
|
+
export function latestSelfProfileCapture(stateDir) {
|
|
453
|
+
const dir = selfProfileDiagnosticsDir(stateDir);
|
|
454
|
+
try {
|
|
455
|
+
const names = fs.readdirSync(dir, { withFileTypes: true })
|
|
456
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith(CAPTURE_DIR_PREFIX))
|
|
457
|
+
.map((entry) => entry.name)
|
|
458
|
+
.sort();
|
|
459
|
+
const newest = names[names.length - 1];
|
|
460
|
+
return newest ? path.join(dir, newest) : null;
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The redacted summary: counts, rates, thresholds, backend, platform and
|
|
468
|
+
* versions, and paths relative to the HQ root. No absolute paths, file
|
|
469
|
+
* contents, environment values or tokens.
|
|
470
|
+
*/
|
|
471
|
+
export function buildSelfProfileSummary(options, inputs) {
|
|
472
|
+
const coverage = assessWatchCoverage(inputs.hqRoot, inputs.watchedDirectories, options.readDir);
|
|
473
|
+
return {
|
|
474
|
+
schema: "hq-sync-runner-self-profile.v2",
|
|
475
|
+
capturedAt: new Date(options.now()).toISOString(),
|
|
476
|
+
uploaded: false,
|
|
477
|
+
redacted: true,
|
|
478
|
+
trip: {
|
|
479
|
+
reason: options.reason,
|
|
480
|
+
cpuCoresAveraged10m: options.reading.cpuCores,
|
|
481
|
+
rssBytes: options.reading.rssBytes,
|
|
482
|
+
cpuCoresThreshold: options.config.cpuCores,
|
|
483
|
+
rssBytesThreshold: options.config.rssBytes,
|
|
484
|
+
},
|
|
485
|
+
watch: {
|
|
486
|
+
backend: inputs.watchBackend,
|
|
487
|
+
mode: inputs.watchMode,
|
|
488
|
+
...coverage,
|
|
489
|
+
},
|
|
490
|
+
activity: {
|
|
491
|
+
minutesObserved: inputs.activity.minutes,
|
|
492
|
+
events: inputs.activity.events,
|
|
493
|
+
eventsPerMinuteByPrefix: inputs.activity.eventsPerMinuteByPrefix,
|
|
494
|
+
passes: inputs.activity.passes,
|
|
495
|
+
passesPerMinute: inputs.activity.passesPerMinute,
|
|
496
|
+
telemetryEvents: inputs.telemetryEvents,
|
|
497
|
+
},
|
|
498
|
+
platform: {
|
|
499
|
+
os: process.platform,
|
|
500
|
+
arch: process.arch,
|
|
501
|
+
release: os.release(),
|
|
502
|
+
cpus: os.cpus().length,
|
|
503
|
+
node: process.versions.node,
|
|
504
|
+
v8: process.versions.v8,
|
|
505
|
+
runner: options.runnerVersion ?? null,
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async function captureCpuProfile(session, file, durationMs, maxBytes, sleep, redaction) {
|
|
510
|
+
await post(session, "Profiler.enable");
|
|
511
|
+
await post(session, "Profiler.start");
|
|
512
|
+
let result;
|
|
513
|
+
try {
|
|
514
|
+
await sleep(durationMs);
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
// Always stop the profiler, even when the wait is interrupted.
|
|
518
|
+
result = (await post(session, "Profiler.stop"));
|
|
519
|
+
await post(session, "Profiler.disable").catch(() => undefined);
|
|
520
|
+
}
|
|
521
|
+
// Rewrite paths field by field, then scrub the text once more as a backstop.
|
|
522
|
+
const body = scrubLocalPaths(JSON.stringify(redactCpuProfile(result?.profile, redaction)), redaction);
|
|
523
|
+
if (Buffer.byteLength(body) > maxBytes) {
|
|
524
|
+
return `cpu profile skipped: ${Buffer.byteLength(body)} bytes is over the ${maxBytes} byte limit`;
|
|
525
|
+
}
|
|
526
|
+
fs.writeFileSync(file, body, { mode: 0o600 });
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Write one capture folder: a redacted summary and a redacted CPU profile.
|
|
531
|
+
* There is no heap snapshot: a snapshot holds the whole JS heap, including
|
|
532
|
+
* tokens and synced file contents. Everything is local; the folder is pruned
|
|
533
|
+
* to the retention limits before and after writing.
|
|
534
|
+
*/
|
|
535
|
+
export async function captureSelfProfile(options) {
|
|
536
|
+
const keep = options.keep ?? SELF_PROFILE_KEEP_CAPTURES;
|
|
537
|
+
const maxTotalBytes = options.maxTotalBytes ?? SELF_PROFILE_MAX_TOTAL_BYTES;
|
|
538
|
+
const diagnosticsDir = selfProfileDiagnosticsDir(options.stateDir);
|
|
539
|
+
fs.mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 });
|
|
540
|
+
// Leave room for the new folder inside the keep limit.
|
|
541
|
+
pruneSelfProfileCaptures(diagnosticsDir, Math.max(keep - 1, 1), maxTotalBytes);
|
|
542
|
+
const dir = path.join(diagnosticsDir, `${CAPTURE_DIR_PREFIX}${captureStamp(options.now())}-${options.reason.replace("+", "-")}`);
|
|
543
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
544
|
+
const files = [];
|
|
545
|
+
const notes = [];
|
|
546
|
+
const inputs = options.summary();
|
|
547
|
+
const redaction = {
|
|
548
|
+
hqRoot: inputs.hqRoot,
|
|
549
|
+
homeDir: options.homeDir ?? os.homedir(),
|
|
550
|
+
};
|
|
551
|
+
const summary = buildSelfProfileSummary(options, inputs);
|
|
552
|
+
const writeSummary = () => {
|
|
553
|
+
const text = JSON.stringify({ ...summary, files: [...files], notes: notes.map((note) => scrubLocalPaths(note, redaction)) }, null, 2);
|
|
554
|
+
fs.writeFileSync(path.join(dir, "summary.json"), `${scrubLocalPaths(text, redaction)}\n`, {
|
|
555
|
+
mode: 0o600,
|
|
556
|
+
});
|
|
557
|
+
};
|
|
558
|
+
// Write the summary first so a crash during profiling still leaves it.
|
|
559
|
+
writeSummary();
|
|
560
|
+
const session = (options.createSession ?? (() => new Session()))();
|
|
561
|
+
session.connect();
|
|
562
|
+
try {
|
|
563
|
+
const skipped = await captureCpuProfile(session, path.join(dir, "cpu.cpuprofile"), options.cpuProfileMs ?? SELF_PROFILE_CPU_PROFILE_MS, options.maxCpuProfileBytes ?? SELF_PROFILE_MAX_CPU_PROFILE_BYTES, options.sleep, redaction);
|
|
564
|
+
if (skipped)
|
|
565
|
+
notes.push(skipped);
|
|
566
|
+
else
|
|
567
|
+
files.push("cpu.cpuprofile");
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
notes.push(`cpu profile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
571
|
+
}
|
|
572
|
+
finally {
|
|
573
|
+
try {
|
|
574
|
+
session.disconnect();
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
/* already disconnected */
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
writeSummary();
|
|
581
|
+
files.unshift("summary.json");
|
|
582
|
+
pruneSelfProfileCaptures(diagnosticsDir, keep, maxTotalBytes);
|
|
583
|
+
return { dir, files, notes };
|
|
584
|
+
}
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// Messages
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
/** Show the home folder as `~` in log lines and command output. */
|
|
589
|
+
function displayPath(abs, homeDir = os.homedir()) {
|
|
590
|
+
const inHome = relativeInside(homeDir, abs);
|
|
591
|
+
if (inHome === null || pathSpellings(homeDir).length === 0)
|
|
592
|
+
return abs;
|
|
593
|
+
return inHome === "." ? HOME_TOKEN : `${HOME_TOKEN}/${inHome}`;
|
|
594
|
+
}
|
|
595
|
+
const DIAGNOSTICS_HINT = "Run hq-sync-runner --diagnostics to print a redacted summary you can paste into a bug report.";
|
|
596
|
+
function describeTrip(reason, reading, config) {
|
|
597
|
+
const parts = [];
|
|
598
|
+
if (reason !== "rss" && reading.cpuCores !== null) {
|
|
599
|
+
parts.push(`CPU averaged ${Math.round(reading.cpuCores * 100)}% of one core over 10 minutes (limit ${Math.round(config.cpuCores * 100)}%)`);
|
|
600
|
+
}
|
|
601
|
+
if (reason !== "cpu") {
|
|
602
|
+
parts.push(`memory is ${Math.round(reading.rssBytes / 1024 / 1024)} MB (limit ${Math.round(config.rssBytes / 1024 / 1024)} MB)`);
|
|
603
|
+
}
|
|
604
|
+
return parts.join(" and ");
|
|
605
|
+
}
|
|
606
|
+
export function selfProfileWarningLine(reason, reading, config, stateDir) {
|
|
607
|
+
if (config.captureEnabled) {
|
|
608
|
+
return (`hq-sync-runner: high resource use: ${describeTrip(reason, reading, config)}. ` +
|
|
609
|
+
`A profile was saved recently, so no new one was taken. ${DIAGNOSTICS_HINT}\n`);
|
|
610
|
+
}
|
|
611
|
+
return (`hq-sync-runner: high resource use: ${describeTrip(reason, reading, config)}. ` +
|
|
612
|
+
`To save a local CPU profile next time, set ${SELF_PROFILE_ENV}=1 (or "enabled": true in ${displayPath(selfProfileConfigPath(stateDir))}) ` +
|
|
613
|
+
`and restart HQ Sync; it stays on this computer and is never uploaded.\n`);
|
|
614
|
+
}
|
|
615
|
+
export function selfProfileCapturedLine(reason, reading, config, result) {
|
|
616
|
+
return (`hq-sync-runner: high resource use: ${describeTrip(reason, reading, config)}. ` +
|
|
617
|
+
`Saved a local CPU profile in ${displayPath(result.dir)}; it stays on this computer and nothing was uploaded. ` +
|
|
618
|
+
`${DIAGNOSTICS_HINT}\n`);
|
|
619
|
+
}
|
|
620
|
+
function unrefTimer(handle) {
|
|
621
|
+
handle.unref?.();
|
|
622
|
+
}
|
|
623
|
+
export function startSelfProfileSampler(options) {
|
|
624
|
+
const thresholds = new SelfProfileThresholds(options.config);
|
|
625
|
+
const capture = options.capture ?? captureSelfProfile;
|
|
626
|
+
const captureCooldownMs = options.captureCooldownMs ?? SELF_PROFILE_CAPTURE_COOLDOWN_MS;
|
|
627
|
+
const pendingSleeps = new Set();
|
|
628
|
+
let stopped = false;
|
|
629
|
+
let inFlight = null;
|
|
630
|
+
let lastCaptureAt = null;
|
|
631
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => {
|
|
632
|
+
const timer = setTimeout(() => {
|
|
633
|
+
pendingSleeps.delete(finish);
|
|
634
|
+
resolve();
|
|
635
|
+
}, ms);
|
|
636
|
+
unrefTimer(timer);
|
|
637
|
+
const finish = () => {
|
|
638
|
+
clearTimeout(timer);
|
|
639
|
+
resolve();
|
|
640
|
+
};
|
|
641
|
+
pendingSleeps.add(finish);
|
|
642
|
+
}));
|
|
643
|
+
const sample = () => {
|
|
644
|
+
if (stopped)
|
|
645
|
+
return;
|
|
646
|
+
const at = options.now();
|
|
647
|
+
let reading;
|
|
648
|
+
try {
|
|
649
|
+
reading = thresholds.observe(at, options.cpuUsage(), options.rssBytes());
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const reason = thresholds.trip(at, reading);
|
|
655
|
+
if (!reason)
|
|
656
|
+
return;
|
|
657
|
+
const canCapture = options.config.captureEnabled &&
|
|
658
|
+
inFlight === null &&
|
|
659
|
+
(lastCaptureAt === null || at - lastCaptureAt >= captureCooldownMs);
|
|
660
|
+
if (!canCapture) {
|
|
661
|
+
options.write(selfProfileWarningLine(reason, reading, options.config, options.stateDir));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
lastCaptureAt = at;
|
|
665
|
+
inFlight = capture({
|
|
666
|
+
stateDir: options.stateDir,
|
|
667
|
+
reason,
|
|
668
|
+
reading,
|
|
669
|
+
config: options.config,
|
|
670
|
+
summary: options.summary,
|
|
671
|
+
now: options.now,
|
|
672
|
+
sleep,
|
|
673
|
+
runnerVersion: options.runnerVersion,
|
|
674
|
+
}).then((result) => {
|
|
675
|
+
options.write(selfProfileCapturedLine(reason, reading, options.config, result));
|
|
676
|
+
}, (err) => {
|
|
677
|
+
options.write(`hq-sync-runner: high resource use: ${describeTrip(reason, reading, options.config)}. ` +
|
|
678
|
+
`Saving a profile failed (${err instanceof Error ? err.message : String(err)}); nothing was uploaded.\n`);
|
|
679
|
+
}).catch(() => {
|
|
680
|
+
/* a failed log write must not become an unhandled rejection */
|
|
681
|
+
}).finally(() => {
|
|
682
|
+
inFlight = null;
|
|
683
|
+
});
|
|
684
|
+
};
|
|
685
|
+
const every = options.every ?? ((ms, run) => {
|
|
686
|
+
const timer = setInterval(run, ms);
|
|
687
|
+
unrefTimer(timer);
|
|
688
|
+
return () => clearInterval(timer);
|
|
689
|
+
});
|
|
690
|
+
const cancel = every(options.intervalMs ?? SELF_PROFILE_SAMPLE_INTERVAL_MS, () => {
|
|
691
|
+
try {
|
|
692
|
+
sample();
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
/* diagnostics must never affect sync */
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
// Baseline sample so the first window closes ten minutes after start.
|
|
699
|
+
sample();
|
|
700
|
+
return {
|
|
701
|
+
sample,
|
|
702
|
+
idle: async () => {
|
|
703
|
+
await inFlight;
|
|
704
|
+
},
|
|
705
|
+
stop: () => {
|
|
706
|
+
stopped = true;
|
|
707
|
+
cancel();
|
|
708
|
+
// End a running CPU profile early so shutdown is not held for a minute.
|
|
709
|
+
for (const finish of pendingSleeps)
|
|
710
|
+
finish();
|
|
711
|
+
pendingSleeps.clear();
|
|
712
|
+
},
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
// --diagnostics
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
export function printSelfProfileDiagnostics(stateDir, out, homeDir = os.homedir()) {
|
|
719
|
+
const latest = latestSelfProfileCapture(stateDir);
|
|
720
|
+
if (!latest) {
|
|
721
|
+
out.write(`No sync-runner self-profile captures in ${displayPath(selfProfileDiagnosticsDir(stateDir), homeDir)}.\n` +
|
|
722
|
+
`Capture is off by default. Set ${SELF_PROFILE_ENV}=1 (or "enabled": true in ${displayPath(selfProfileConfigPath(stateDir), homeDir)}) and restart HQ Sync to turn it on.\n`);
|
|
723
|
+
return 0;
|
|
724
|
+
}
|
|
725
|
+
let summary;
|
|
726
|
+
try {
|
|
727
|
+
summary = fs.readFileSync(path.join(latest, "summary.json"), "utf8");
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
out.write(`Latest capture ${displayPath(latest, homeDir)} has no readable summary.json.\n`);
|
|
731
|
+
return 1;
|
|
732
|
+
}
|
|
733
|
+
// Captures from older runner versions were not redacted; scrub the home path here too.
|
|
734
|
+
summary = scrubLocalPaths(summary, { hqRoot: "", homeDir });
|
|
735
|
+
out.write(`Latest capture: ${displayPath(latest, homeDir)}\n` +
|
|
736
|
+
`The summary below is redacted: counts, rates, versions and paths relative to the HQ root. ` +
|
|
737
|
+
`Paste it into a bug report. The profile folder stays on this computer and has not been uploaded.\n\n`);
|
|
738
|
+
out.write(summary.endsWith("\n") ? summary : `${summary}\n`);
|
|
739
|
+
return 0;
|
|
740
|
+
}
|
|
741
|
+
/** The runner's package version, or undefined when package.json is not readable. */
|
|
742
|
+
export function readRunnerVersion() {
|
|
743
|
+
try {
|
|
744
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
745
|
+
return typeof pkg.version === "string" ? pkg.version : undefined;
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
//# sourceMappingURL=sync-runner-self-profile.js.map
|