dsh-flash-mem-mon 0.1.5 → 0.2.0

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/index.js CHANGED
@@ -1,33 +1,47 @@
1
+ import { writeHeapSnapshot } from 'node:v8';
2
+ import { createReadStream } from 'node:fs';
3
+ import { readdir, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { homedir, tmpdir } from 'node:os';
1
6
  // Default export only (`export default Schema`); there is no named `Schema`.
2
7
  import Schema from '@deepseek-ai/schemastery';
8
+ import { countArchivedSessions, DEFAULT_ALERT_CONFIG, formatBytes, isFullGc, rssSlopePerMin, sumBytes, } from './pure.js';
3
9
  export const name = 'dsh-flash-mem-mon';
4
- // No host-side service dependencies; all services are injected lazily.
5
- export const inject = [];
10
+ // Lazy-injected services. All are resolved via ctx.inject() at apply-time, so
11
+ // the plugin still boots even when a service (e.g. workspaceRegistry) is
12
+ // unavailable; declaring them here documents the dependency.
13
+ export const inject = ['workspaceRegistry'];
6
14
  // ── Memory alert thresholds (RSS MB) and GC thresholds (major GC/min) ──
7
- const DEFAULT_MEM_THRESHOLD_INFO = 256;
8
- const DEFAULT_MEM_THRESHOLD_WARNING = 512;
9
- const DEFAULT_MEM_THRESHOLD_ERROR = 1024;
10
- const DEFAULT_MEM_THRESHOLD_CRITICAL = 1536;
11
- const DEFAULT_MEM_POLL_BASE = 30000;
12
- const DEFAULT_MEM_POLL_MIN = 2000;
13
- const DEFAULT_GC_THRESHOLD_INFO = 2;
14
- const DEFAULT_GC_THRESHOLD_WARNING = 5;
15
- const DEFAULT_GC_THRESHOLD_ERROR = 10;
16
- // V8 GC entry `kind` is a bitmask of GC types:
17
- // 1 << 0 = Scavenge (minor / young-gen)
18
- // 1 << 1 = MarkSweepCompact (major / full)
19
- // 1 << 2 = IncrementalMarking
20
- // 1 << 3 = ProcessWeakCallbacks
21
- // In practice modern V8/Node reports a full MarkSweepCompact cycle through the
22
- // IncrementalMarking (4) and ProcessWeakCallbacks (8) phases — the standalone
23
- // "major" bit (2) is almost never emitted on its own. Classifying Full GC by
24
- // `kind === 2` alone therefore misses (nearly) every real full collection.
25
- // The robust test: a Full GC is any NON-scavenge cycle, i.e. the Scavenge bit
26
- // (1) is NOT set. This captures 2, 4, 6, 8, 10, 12, 14 while excluding minor
27
- // (1) scavenges.
28
- function isFullGc(kind) {
29
- return (kind & 1) === 0 && kind > 0;
30
- }
15
+ // All values come from the single canonical DEFAULT_ALERT_CONFIG in
16
+ // ./pure.js. The browser client half mirrors these (see lib/client.js
17
+ // _MEM_ALERT_DEFAULTS); test/defaults-drift.test.ts guarantees they never
18
+ // drift from this source of truth.
19
+ const DEFAULT_MEM_THRESHOLD_INFO = DEFAULT_ALERT_CONFIG.memThresholdInfo;
20
+ const DEFAULT_MEM_THRESHOLD_WARNING = DEFAULT_ALERT_CONFIG.memThresholdWarning;
21
+ const DEFAULT_MEM_THRESHOLD_ERROR = DEFAULT_ALERT_CONFIG.memThresholdError;
22
+ const DEFAULT_MEM_THRESHOLD_CRITICAL = DEFAULT_ALERT_CONFIG.memThresholdCritical;
23
+ const DEFAULT_MEM_POLL_BASE = DEFAULT_ALERT_CONFIG.memPollBase;
24
+ const DEFAULT_MEM_POLL_MIN = DEFAULT_ALERT_CONFIG.memPollMin;
25
+ const DEFAULT_GC_THRESHOLD_INFO = DEFAULT_ALERT_CONFIG.gcThresholdInfo;
26
+ const DEFAULT_GC_THRESHOLD_WARNING = DEFAULT_ALERT_CONFIG.gcThresholdWarning;
27
+ const DEFAULT_GC_THRESHOLD_ERROR = DEFAULT_ALERT_CONFIG.gcThresholdError;
28
+ const DEFAULT_MEM_GROWTH_ALERT_PER_MIN = DEFAULT_ALERT_CONFIG.memGrowthAlertPerMin;
29
+ // Session-footprint (disk) polling interval. Deliberately NOT a member of
30
+ // DEFAULT_ALERT_CONFIG: test/defaults-drift.test.ts asserts that object contains
31
+ // EXACTLY the 10 memory/GC keys, and a footprint key here would break that
32
+ // precise set. Disk scans are comparatively expensive (recursive readdir +
33
+ // stat over every session directory), so they run far less often than memory.
34
+ // The host caches the last scan and the client reads the cached snapshot, so
35
+ // the route itself never triggers an on-demand scan.
36
+ const FOOTPRINT_POLL_INTERVAL_MS = 300000; // 5 min
37
+ /**
38
+ * Sentinel passed to `query()` / `gcEvents()` as the `since` lower bound to
39
+ * mean "no lower bound — return ALL stored records". These methods treat any
40
+ * `since <= 0` (0 or undefined) as "all", and a positive value as a unix-ms
41
+ * timestamp to filter from. Keeping one explicit convention prevents callers
42
+ * from mixing up `0` vs `undefined` (both mean "all").
43
+ */
44
+ const SINCE_ALL = 0;
31
45
  export const Config = Schema.object({
32
46
  memThresholdInfo: Schema.number().default(DEFAULT_MEM_THRESHOLD_INFO).volatile(),
33
47
  memThresholdWarning: Schema.number().default(DEFAULT_MEM_THRESHOLD_WARNING).volatile(),
@@ -38,10 +52,20 @@ export const Config = Schema.object({
38
52
  gcThresholdInfo: Schema.number().default(DEFAULT_GC_THRESHOLD_INFO).volatile(),
39
53
  gcThresholdWarning: Schema.number().default(DEFAULT_GC_THRESHOLD_WARNING).volatile(),
40
54
  gcThresholdError: Schema.number().default(DEFAULT_GC_THRESHOLD_ERROR).volatile(),
55
+ memGrowthAlertPerMin: Schema.number().default(DEFAULT_MEM_GROWTH_ALERT_PER_MIN).volatile(),
41
56
  });
42
57
  class MemoryTrendCollector {
43
58
  _samples = [];
59
+ // The poll loop is self-scheduling via setInterval-like recursion with an
60
+ // ADAPTIVE delay (higher heap usage → shorter interval), so each cycle holds
61
+ // a fresh setTimeout handle rather than one fixed-interval setInterval.
44
62
  _timer = null;
63
+ /** Monotonic loop generation. Bumped every start()/stop(); a poll() tick
64
+ * whose captured generation no longer matches the current one is a stale
65
+ * callback from a superseded loop and must NOT re-schedule itself. This
66
+ * closes the start/stop restart race where a queued pre-restart tick could
67
+ * otherwise clobber the new loop's timer handle and leak a second loop. */
68
+ _generation = 0;
45
69
  _cap;
46
70
  // GC monitoring via PerformanceObserver
47
71
  _gcObserver = null; // PerformanceObserver | null
@@ -55,10 +79,16 @@ class MemoryTrendCollector {
55
79
  }
56
80
  start(baseInterval = 30_000, minInterval = 5_000) {
57
81
  this.stop();
82
+ const gen = ++this._generation;
58
83
  // Fire-and-forget: GC observer can start asynchronously; the poll loop
59
84
  // below begins immediately regardless, since it does not depend on GC.
60
85
  this._startGcObserver().catch(() => { });
61
86
  const poll = () => {
87
+ // Bail if this tick belongs to a superseded loop (a restart has bumped
88
+ // the generation since we were scheduled). The stopped loop then winds
89
+ // down on its own instead of fighting the fresh loop for this._timer.
90
+ if (gen !== this._generation)
91
+ return;
62
92
  const mu = process.memoryUsage();
63
93
  this._push({
64
94
  ts: Date.now(),
@@ -81,40 +111,42 @@ class MemoryTrendCollector {
81
111
  poll();
82
112
  }
83
113
  stop() {
114
+ // Bump the generation BEFORE clearing so any in-flight/stale poll tick that
115
+ // is already queued (past clearTimeout's reach) recognises itself as stale
116
+ // and refuses to re-schedule, never resurrecting a stopped loop.
117
+ this._generation++;
84
118
  if (this._timer !== null) {
85
119
  clearTimeout(this._timer);
86
120
  this._timer = null;
87
121
  }
88
122
  this._stopGcObserver();
89
123
  }
90
- setCap(cap) {
91
- this._cap = Math.max(10, Math.floor(cap) || 10);
92
- while (this._samples.length > this._cap)
93
- this._samples.shift();
94
- }
95
- query(since) {
96
- if (!since)
124
+ /** Memory samples at or after `since` (unix ms). A falsy/absent `since`
125
+ * (`SINCE_ALL` / undefined) means "return all stored samples". */
126
+ query(since = SINCE_ALL) {
127
+ if (since <= 0)
97
128
  return this._samples.slice();
98
129
  return this._samples.filter(s => s.ts >= since);
99
130
  }
100
131
  /** Return Full (major) GC events for chart rendering.
101
- * Pass since=0 to return ALL stored events (for full-mode fetches).
102
132
  * Each returned event is one real full-GC cycle (phases already coalesced
103
133
  * in `_startGcObserver`) and is normalized to kind=2 so the client can
104
- * always treat `gcEvents` as "Full GC events". */
105
- gcEvents(since) {
134
+ * always treat `gcEvents` as "Full GC events". A falsy/absent `since`
135
+ * (`SINCE_ALL` / undefined) means "return all stored events". */
136
+ gcEvents(since = SINCE_ALL) {
106
137
  if (this._gcMajorEvents.length === 0)
107
138
  return [];
108
- const cutoff = since ?? (Date.now() - 5 * 60 * 1000);
109
- return this._gcMajorEvents
110
- .filter(e => e.ts >= cutoff)
139
+ const events = since > 0
140
+ ? this._gcMajorEvents.filter(e => e.ts >= since)
141
+ : this._gcMajorEvents;
142
+ return events
111
143
  .map(e => ({ ts: e.ts, kind: 2, duration: e.duration }))
112
144
  .sort((a, b) => a.ts - b.ts);
113
145
  }
114
146
  summary(since) {
115
147
  const data = this.query(since);
116
148
  if (data.length === 0) {
117
- return { current: null, peak: null, trend: 'stable', sampleCount: 0, heapRatio: 0, spanSeconds: 0, rssSlopePerMin: 0, gc: null };
149
+ return { current: null, peak: null, trend: 'stable', sampleCount: 0, heapRatio: 0, spanMs: 0, rssSlopePerMin: 0, gc: null };
118
150
  }
119
151
  const current = data[data.length - 1];
120
152
  let peak = { heapUsed: 0, rss: 0, ts: 0 };
@@ -143,28 +175,15 @@ class MemoryTrendCollector {
143
175
  trend = 'down';
144
176
  }
145
177
  const heapRatio = current.heapTotal > 0 ? current.heapUsed / current.heapTotal : 0;
146
- const spanSeconds = data.length >= 2 ? Math.round((data[data.length - 1].ts - data[0].ts) / 1000) : 0;
147
- // RSS linear regression over the last 20 samples → slope per minute (MB/min).
148
- let rssSlopePerMin = 0;
149
- if (tail.length >= 3) {
150
- const n = tail.length;
151
- let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
152
- for (let i = 0; i < n; i++) {
153
- sumX += i;
154
- sumY += tail[i].rss;
155
- sumXY += i * tail[i].rss;
156
- sumXX += i * i;
157
- }
158
- const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
159
- // slope = bytes per sample-index step. Convert to MB/min.
160
- // Average interval between tail samples ≈ spanSeconds / (n-1).
161
- const avgIntervalSec = tail.length >= 2 ? spanSeconds / (tail.length - 1) : 30;
162
- const samplesPerMin = avgIntervalSec > 0 ? 60 / avgIntervalSec : 2;
163
- rssSlopePerMin = Math.round((slope * samplesPerMin / 1048576) * 100) / 100;
164
- }
178
+ const spanMs = data.length >= 2 ? data[data.length - 1].ts - data[0].ts : 0;
179
+ // RSS linear-regression slope per minute (MB/min); positive = growing.
180
+ // Delegated to the pure helper (src/pure.ts) so the same logic is unit-
181
+ // tested. The average interval comes from the tail's own time span — see
182
+ // rssSlopePerMin for why using the whole buffer span would under-report.
183
+ const rssSlope = rssSlopePerMin(data);
165
184
  // GC stats over the last ~5 minutes.
166
185
  const gc = this._gcSummary(5 * 60 * 1000);
167
- return { current, peak, trend, sampleCount: data.length, heapRatio, spanSeconds, rssSlopePerMin, gc };
186
+ return { current, peak, trend, sampleCount: data.length, heapRatio, spanMs, rssSlopePerMin: rssSlope, gc };
168
187
  }
169
188
  /** Aggregate GC events in the last `windowMs` milliseconds. */
170
189
  _gcSummary(windowMs) {
@@ -176,7 +195,14 @@ class MemoryTrendCollector {
176
195
  const majorRecent = this._gcMajorEvents.filter(e => e.ts >= cutoff);
177
196
  const majorCount = majorRecent.length;
178
197
  // Minor count + total pause come from the general ring buffer (which holds
179
- // every event). A minor cycle is a Scavenge (kind bit 1 set).
198
+ // EVERY PerformanceObserver entry). A minor cycle is a Scavenge (kind bit 1
199
+ // set). NOTE: gcPauseMs is the SUM of every phase-entry duration in the
200
+ // window — one real Full GC is reported as multiple phases (kind 4 + 8), and
201
+ // each contributes its own duration, so gcPauseMs is the TOTAL stop-the-world
202
+ // pause across all cycles (minor + full), NOT per-cycle. majorCount, by
203
+ // contrast, comes from the separate coalesced buffer and counts each real
204
+ // Full GC cycle ONCE. The two buffers intentionally have different semantics:
205
+ // majorCount = distinct full collections; gcPauseMs = aggregate pauses.
180
206
  let minorCount = 0, gcPauseMs = 0;
181
207
  for (const e of this._gcEvents) {
182
208
  if (e.ts < cutoff)
@@ -216,9 +242,12 @@ class MemoryTrendCollector {
216
242
  // because they are rare and the general ring buffer may evict them.
217
243
  if (isFullGc(kind)) {
218
244
  const last = this._gcMajorEvents[this._gcMajorEvents.length - 1];
219
- if (last && event.ts - last.ts < 500) {
245
+ if (last && event.ts - last.ts < 2000) {
220
246
  // Same full-GC cycle (phase entries arriving back-to-back):
221
247
  // merge into the existing event, keeping the worst-case pause.
248
+ // Window is generous (2s) because one cycle's phases can arrive
249
+ // over a second apart under real load; merging here prevents one
250
+ // Full GC from being reported as several adjacent events.
222
251
  last.duration = Math.max(last.duration, event.duration);
223
252
  last.ts = event.ts;
224
253
  }
@@ -257,8 +286,219 @@ class MemoryTrendCollector {
257
286
  this._samples.shift();
258
287
  }
259
288
  }
260
- /** Module-level singleton — one collector per process. */
289
+ // ── Process-scoped singleton with an exclusive active owner ─────────────
290
+ //
291
+ // A single MemoryTrendCollector is shared across the whole process so all
292
+ // consumers see one coherent ring buffer (each sample carries its own ts).
293
+ // However, Cordis may apply() this plugin more than once (multiple profiles,
294
+ // hot reload) and dispose() them in any order. To avoid two collectors racing
295
+ // on the same module state — and to stop one stray dispose() from killing a
296
+ // collector another apply() is still using — ownership is explicit:
297
+ //
298
+ // _memoryTrend : the live collector (or null)
299
+ // _ownerCtx : the Context that currently owns it (or null)
300
+ //
301
+ // startTrend()/stopTrend() are idempotent and fail softly when the caller is
302
+ // not the current owner, so overlapping apply/dispose cycles stay safe.
261
303
  let _memoryTrend = null;
304
+ let _ownerCtx = null;
305
+ // ── Heap-snapshot dump state ─────────────────────────────────────────────
306
+ // The "dump heap now" disposal button on an alert POSTs dump-heap, which
307
+ // writes a .heapsnapshot (possibly tens–hundreds of MB) and records it here
308
+ // so the browser client can pull it down via the heap-dump route. Only the
309
+ // latest snapshot is kept for download; dumps are user-paced, not automatic,
310
+ // because writeHeapSnapshot() blocks the host main thread for seconds.
311
+ let _lastSnapshot = null;
312
+ /** Adopt the process-wide collector for `ctx`; no-op if it is already owned. */
313
+ function startTrend(ctx, config) {
314
+ if (_ownerCtx && _ownerCtx !== ctx)
315
+ return; // someone else owns it
316
+ if (!_memoryTrend)
317
+ _memoryTrend = new MemoryTrendCollector();
318
+ _ownerCtx = ctx;
319
+ _memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
320
+ }
321
+ function restartTrend(config) {
322
+ if (!_memoryTrend || !_ownerCtx)
323
+ return;
324
+ _memoryTrend.stop();
325
+ _memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
326
+ }
327
+ /** Relinquish ownership of `ctx`. Only the active owner may stop the shared
328
+ * collector; other ctx instances (already superseded) must not touch it. */
329
+ function stopTrend(ctx) {
330
+ if (_ownerCtx !== ctx)
331
+ return; // not the owner → leave the running collector alone
332
+ _ownerCtx = null;
333
+ _memoryTrend?.stop();
334
+ }
335
+ class SessionFootprintCollector {
336
+ _snapshot = null;
337
+ _timer = null;
338
+ _running = false;
339
+ start() {
340
+ if (this._running)
341
+ return;
342
+ this._running = true;
343
+ this._scan().then(() => { this._schedule(); }).catch(() => { this._schedule(); });
344
+ }
345
+ stop() {
346
+ this._running = false;
347
+ if (this._timer) {
348
+ clearTimeout(this._timer);
349
+ this._timer = null;
350
+ }
351
+ }
352
+ /** Immediately trigger a re-scan (e.g. right after the workspace registry is
353
+ * captured) so the very first archived count is not stuck at 0. */
354
+ rescan() {
355
+ if (!this._running)
356
+ return;
357
+ this._scan().catch(() => { });
358
+ }
359
+ snapshot() {
360
+ return this._snapshot;
361
+ }
362
+ _schedule() {
363
+ if (!this._running)
364
+ return;
365
+ this._timer = setTimeout(() => {
366
+ this._timer = null;
367
+ this._scan().then(() => { this._schedule(); }).catch(() => { this._schedule(); });
368
+ }, FOOTPRINT_POLL_INTERVAL_MS);
369
+ this._timer.unref?.();
370
+ }
371
+ async _scan() {
372
+ const sessionsRoot = resolveSessionsRoot();
373
+ const workspaces = await this._scanWorkspaces(sessionsRoot);
374
+ const snapshot = {
375
+ totalSessions: 0,
376
+ archivedSessions: 0,
377
+ bytes: 0,
378
+ workspaces,
379
+ scannedAt: Date.now(),
380
+ };
381
+ for (const w of workspaces) {
382
+ snapshot.totalSessions += w.totalSessions;
383
+ snapshot.archivedSessions += w.archivedSessions;
384
+ snapshot.bytes += w.bytes;
385
+ }
386
+ this._snapshot = snapshot;
387
+ }
388
+ async _scanWorkspaces(sessionsRoot) {
389
+ let workspaceDirs = [];
390
+ try {
391
+ workspaceDirs = await readdir(sessionsRoot, { withFileTypes: true }).then((ents) => ents.filter((e) => e.isDirectory()).map((e) => e.name));
392
+ }
393
+ catch (_e) {
394
+ return [];
395
+ }
396
+ const registry = getWorkspaceRegistry();
397
+ let archivedIds = [];
398
+ try {
399
+ if (registry && typeof registry.archivedSessionIds === 'function')
400
+ archivedIds = registry.archivedSessionIds();
401
+ else if (registry && Array.isArray(registry.archivedSessionIds))
402
+ archivedIds = registry.archivedSessionIds;
403
+ }
404
+ catch (_e) { /* keep empty */ }
405
+ const result = [];
406
+ for (const ws of workspaceDirs) {
407
+ const wsPath = join(sessionsRoot, ws);
408
+ let sessionNames = [];
409
+ try {
410
+ sessionNames = await readdir(wsPath, { withFileTypes: true }).then((ents) => ents.filter((e) => e.isDirectory() && /^session-/.test(e.name)).map((e) => e.name));
411
+ }
412
+ catch (_e) {
413
+ sessionNames = [];
414
+ }
415
+ const namesSet = new Set(sessionNames);
416
+ let bytes = 0;
417
+ for (const s of sessionNames) {
418
+ bytes += await dirSize(join(wsPath, s));
419
+ }
420
+ result.push({
421
+ workspace: ws,
422
+ totalSessions: sessionNames.length,
423
+ archivedSessions: countArchivedSessions(archivedIds, namesSet),
424
+ bytes,
425
+ });
426
+ }
427
+ return result;
428
+ }
429
+ }
430
+ /** Recursively sum the on-disk size of a session directory. */
431
+ async function dirSize(dir) {
432
+ let total = 0;
433
+ let entries = [];
434
+ try {
435
+ entries = await readdir(dir, { withFileTypes: true });
436
+ }
437
+ catch (_e) {
438
+ return 0;
439
+ }
440
+ for (const e of entries) {
441
+ const p = join(dir, e.name);
442
+ try {
443
+ if (e.isDirectory()) {
444
+ total += await dirSize(p);
445
+ }
446
+ else if (e.isSymbolicLink()) {
447
+ continue;
448
+ }
449
+ else {
450
+ const s = await stat(p);
451
+ total += s.size;
452
+ }
453
+ }
454
+ catch (_e) { /* skip */ }
455
+ }
456
+ return total;
457
+ }
458
+ /**
459
+ * Resolve the DSH sessions root the same way the harness seeds `$DSH_HOME`:
460
+ * configured override > `$DSH_HOME` env > `~/.dsh` (opened home). `$DSH_HOME`
461
+ * may itself contain a `~` or trailing slashes, so expand the home and join the
462
+ * well-known `sessions` sub-directory explicitly. Returns `''` if a home could
463
+ * not be resolved at all (the scan then simply reports zero workspaces).
464
+ */
465
+ function resolveSessionsRoot() {
466
+ const env = process.env.DSH_HOME;
467
+ const home = env && env.trim()
468
+ ? env.replace(/^~(?=\/|\\)/, homedir())
469
+ : join(homedir(), '.dsh');
470
+ return join(home, 'sessions');
471
+ }
472
+ let _footprint = null;
473
+ let _footprintOwner = null;
474
+ // Cached reference to the DSH workspace-registry service, resolved lazily via
475
+ // ctx.inject. When it is unavailable (or the plugin was applied without it),
476
+ // archived-session counting degrades to whatever the registry can provide —
477
+ // if the registry is truly absent the archived count is reported as 0 rather
478
+ // than guessing from the disk (disk directories carry no archive flag).
479
+ let _workspaceRegistry = null;
480
+ function setWorkspaceRegistry(wsCtx) {
481
+ const reg = wsCtx?.workspaceRegistry;
482
+ if (reg != null)
483
+ _workspaceRegistry = reg;
484
+ }
485
+ function getWorkspaceRegistry() {
486
+ return _workspaceRegistry ?? null;
487
+ }
488
+ function startFootprint(ctx) {
489
+ if (_footprintOwner && _footprintOwner !== ctx)
490
+ return;
491
+ if (!_footprint)
492
+ _footprint = new SessionFootprintCollector();
493
+ _footprintOwner = ctx;
494
+ _footprint.start();
495
+ }
496
+ function stopFootprint(ctx) {
497
+ if (_footprintOwner !== ctx)
498
+ return;
499
+ _footprintOwner = null;
500
+ _footprint?.stop();
501
+ }
262
502
  // ── Private HTTP helpers ────────────────────────────────────────────────
263
503
  /** Send a JSON response with no-store cache control. */
264
504
  function sendJson(res, status, payload) {
@@ -267,69 +507,64 @@ function sendJson(res, status, payload) {
267
507
  res.setHeader('cache-control', 'no-store');
268
508
  res.end(JSON.stringify(payload));
269
509
  }
270
- /**
271
- * Read an optional JSON request body, bounded so a client cannot feed the
272
- * host an unbounded buffer. Returns null for an empty, oversized, or
273
- * unparseable body — callers treat that as "no override supplied".
274
- */
275
- async function readJsonBody(req, limit = 4096) {
276
- try {
277
- const chunks = [];
278
- let size = 0;
279
- for await (const chunk of req) {
280
- size += chunk.length;
281
- if (size > limit)
282
- return null;
283
- chunks.push(chunk);
284
- }
285
- if (chunks.length === 0)
286
- return null;
287
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
288
- }
289
- catch (_) {
290
- return null;
291
- }
292
- }
293
510
  // ── apply() ─────────────────────────────────────────────────────────────
294
511
  export function apply(ctx, config) {
295
512
  // ── Settings namespace registration ──────────────────────────────────
296
513
  ctx.inject(['settings'], (settingsCtx) => {
297
514
  settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber));
298
515
  });
516
+ // ── Workspace-registry capture ───────────────────────────────────────
517
+ // Grab the runtime's archived-session id list so the footprint collector can
518
+ // intersect it with on-disk session dirs. The registry is injected lazily;
519
+ // if it is unavailable at apply-time the footprint still scans the disk (and
520
+ // simply reports the archived count from whatever the registry later yields,
521
+ // or 0 if it never appears).
522
+ ctx.inject(['workspaceRegistry'], (regCtx) => {
523
+ setWorkspaceRegistry(regCtx);
524
+ // The registry may arrive after the collector's first scan already ran
525
+ // with no archived ids → refresh it immediately so the count is correct.
526
+ _footprint?.rescan();
527
+ });
528
+ // ── Session-footprint collector bootstrap ────────────────────────────
529
+ // Low-frequency disk scan (5-minute cadence by default), cached on the host.
530
+ // Owned exclusively by this ctx; overlapping apply/dispose cycles are safe.
531
+ startFootprint(ctx);
532
+ ctx.effect(() => {
533
+ const owner = ctx;
534
+ return () => { stopFootprint(owner); };
535
+ }, 'dsh-flash-mem-mon: session-footprint collector lifecycle');
299
536
  // ── Memory trend collector bootstrap ──────────────────────────────────
300
- // One bounded ring buffer per process. Starts on first apply and stops on
301
- // dispose. The timer uses .unref() so it never keeps the process alive.
302
- if (!_memoryTrend)
303
- _memoryTrend = new MemoryTrendCollector();
304
- _memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
537
+ // One bounded ring buffer per process, owned exclusively by the active ctx.
538
+ // startTrend/stopTrend are idempotent and owner-guarded, so overlapping
539
+ // apply/dispose cycles never double-start or kill a still-live collector.
540
+ startTrend(ctx, config);
305
541
  ctx.effect(() => {
306
- const collector = _memoryTrend;
307
- return () => {
308
- collector?.stop();
309
- _memoryTrend = null;
310
- };
311
- });
542
+ const owner = ctx;
543
+ return () => { stopTrend(owner); };
544
+ }, 'dsh-flash-mem-mon: memory-trend collector lifecycle');
312
545
  // ── Volatile-update handler for poll intervals ────────────────────────
313
546
  // Restart the collector when memPollBase or memPollMin change.
314
547
  ctx.on('loader/volatile-update', (paths) => {
315
548
  const memPaths = ['memPollBase', 'memPollMin'];
316
549
  if (!paths.some((pp) => pp.length && memPaths.includes(pp[pp.length - 1])))
317
550
  return;
318
- if (_memoryTrend) {
319
- _memoryTrend.stop();
320
- _memoryTrend.start((config.memPollBase?.get?.() ?? config.memPollBase) || DEFAULT_MEM_POLL_BASE, (config.memPollMin?.get?.() ?? config.memPollMin) || DEFAULT_MEM_POLL_MIN);
321
- }
551
+ restartTrend(config);
322
552
  });
323
553
  // ── HTTP API route for client-side trend data ────────────────────────
324
- // The webServer type augmentation lives in @deepseek-ai/dsh-host-webserver
325
- // which is not a direct dependency; cast through `any` for the register calls.
554
+ // Uses a structural HostWebServer type instead of `any`. cordis's inject()
555
+ // callback is statically typed as Context (the real webServer augmentation
556
+ // lives in @deepseek-ai/dsh-host-webserver, not a compile-time dep here), so
557
+ // the injected service is read through one narrow cast inside the callback —
558
+ // when inject(keys) fires, the service is guaranteed present — then used
559
+ // under the strong HostWebServer type for the whole register/effect block.
326
560
  ctx.inject(['webServer'], (wsCtx) => {
561
+ const ws = wsCtx.webServer;
327
562
  // GET /plugins/dsh-flash-mem-mon/memory-trend
328
563
  // Returns memory trend data for the client's config popup.
329
564
  // `mode=summary` returns a lightweight snapshot (current, peak, trend direction);
330
565
  // `mode=full` returns the raw samples for rendering a chart.
331
566
  // Optional `since` (unix ms) limits the range.
332
- wsCtx.effect(() => wsCtx.webServer.register({
567
+ wsCtx.effect(() => ws.register({
333
568
  kind: 'exact',
334
569
  path: '/plugins/dsh-flash-mem-mon/memory-trend',
335
570
  handler: async (req, res) => {
@@ -347,26 +582,115 @@ export function apply(ctx, config) {
347
582
  const since = parseInt(u.searchParams.get('since') || '0', 10) || 0;
348
583
  const mode = u.searchParams.get('mode') || 'summary';
349
584
  if (mode === 'full') {
350
- // For gcEvents: when no explicit `since` is requested (since=0),
351
- // pass 0 so ALL stored events are returned — not just the last 5 min.
352
- // gcEvents(0) → cutoff=0 → returns everything.
353
- // gcEvents(undefined) → cutoff=now-5min → only recent events.
354
- // The sparkline may span hours, so we need the full range.
355
- const gcSince = since > 0 ? since : 0;
356
- // Only return full (major) GC events to the client — minor GC is too
357
- // frequent and clutters the chart. These are already coalesced into
358
- // one event per real cycle by `_startGcObserver`. Minor GC stats are
585
+ // `since` is already >= 0 here; 0 means "entire range" (SINCE_ALL),
586
+ // so both `query` and `gcEvents` share the same bounds convention.
587
+ // Only full (major) GC events are sent — minor GC is too frequent
588
+ // and clutters the chart. These are already coalesced into one
589
+ // event per real cycle by `_startGcObserver`. Minor GC stats are
359
590
  // still available via mode=summary (majorPerMin, gcPausePerMin).
360
- const majorGcEvents = _memoryTrend.gcEvents(gcSince);
591
+ const majorGcEvents = _memoryTrend.gcEvents(since);
361
592
  sendJson(res, 200, {
362
- samples: _memoryTrend.query(since || undefined),
593
+ samples: _memoryTrend.query(since),
363
594
  gcEvents: majorGcEvents,
364
595
  });
365
596
  }
366
597
  else {
367
- sendJson(res, 200, _memoryTrend.summary(since || undefined));
598
+ sendJson(res, 200, _memoryTrend.summary(since));
368
599
  }
369
600
  },
370
601
  }), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/memory-trend');
602
+ // GET /plugins/dsh-flash-mem-mon/session-footprint
603
+ // Returns the host's cached session-footprint snapshot (total / archived
604
+ // session counts + per-workspace and total on-disk bytes). Serves only the
605
+ // cached result of the low-frequency disk scan — never triggers a scan on
606
+ // demand, so repeated client polls (or a reload while the modal is open)
607
+ // cannot hammer the filesystem. `scannedAt` is the unix-ms time of the last
608
+ // completed scan (0 before the first scan finishes).
609
+ wsCtx.effect(() => ws.register({
610
+ kind: 'exact',
611
+ path: '/plugins/dsh-flash-mem-mon/session-footprint',
612
+ handler: async (req, res) => {
613
+ if (req.method !== 'GET') {
614
+ res.statusCode = 405;
615
+ res.setHeader('allow', 'GET');
616
+ res.end();
617
+ return;
618
+ }
619
+ const snap = _footprint?.snapshot() ?? null;
620
+ sendJson(res, 200, snap ?? {
621
+ totalSessions: 0,
622
+ archivedSessions: 0,
623
+ bytes: 0,
624
+ workspaces: [],
625
+ scannedAt: 0,
626
+ });
627
+ },
628
+ }), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/session-footprint');
629
+ // POST /plugins/dsh-flash-mem-mon/dump-heap
630
+ // Triggered by the "立即转储堆快照" disposal button attached to a memory
631
+ // alert (see lib/client.js createHostMemoryAlertProvider → actions).
632
+ // Synchronously writes a .heapsnapshot into the OS temp dir (the one
633
+ // acceptable place to block the main thread: the user asked for it), then
634
+ // records it as the latest snapshot and returns its name+size so the
635
+ // client can confirm and pull it down. Auto-dump is deliberately NOT
636
+ // offered anywhere — a runaway gleaning loop must never stall the host.
637
+ wsCtx.effect(() => ws.register({
638
+ kind: 'exact',
639
+ path: '/plugins/dsh-flash-mem-mon/dump-heap',
640
+ handler: async (_req, res) => {
641
+ if (_req.method !== 'POST') {
642
+ res.statusCode = 405;
643
+ res.setHeader('allow', 'POST');
644
+ res.end();
645
+ return;
646
+ }
647
+ const name = `dsh-flash-mem-mon-${Date.now()}.${process.pid}.heapsnapshot`;
648
+ const path = join(tmpdir(), name);
649
+ try {
650
+ writeHeapSnapshot(path);
651
+ const info = await stat(path);
652
+ _lastSnapshot = { name, path, size: info.size };
653
+ sendJson(res, 200, { ok: true, name, size: info.size, path });
654
+ }
655
+ catch (err) {
656
+ sendJson(res, 500, { ok: false, error: (err && err.message) || 'heap snapshot failed' });
657
+ }
658
+ },
659
+ }), 'dsh-flash-mem-mon: POST /plugins/dsh-flash-mem-mon/dump-heap');
660
+ // GET /plugins/dsh-flash-mem-mon/heap-dump
661
+ // Streams the most recent heap snapshot back to the client as an
662
+ // attachment. One latest-at-a-time is fine here: dumps are user-paced and
663
+ // downloading is immediate, so keeping a whole archive is unnecessary
664
+ // (and the tmp files are already pruned by the OS over time).
665
+ wsCtx.effect(() => ws.register({
666
+ kind: 'exact',
667
+ path: '/plugins/dsh-flash-mem-mon/heap-dump',
668
+ handler: async (_req, res) => {
669
+ if (_req.method !== 'GET') {
670
+ res.statusCode = 405;
671
+ res.setHeader('allow', 'GET');
672
+ res.end();
673
+ return;
674
+ }
675
+ const snap = _lastSnapshot;
676
+ if (!snap) {
677
+ sendJson(res, 404, { error: 'No heap snapshot available yet' });
678
+ return;
679
+ }
680
+ try {
681
+ await stat(snap.path); // ensure it still exists
682
+ res.statusCode = 200;
683
+ res.setHeader('content-type', 'application/octet-stream');
684
+ res.setHeader('content-length', String(snap.size));
685
+ res.setHeader('content-disposition', `attachment; filename="${snap.name}"`);
686
+ res.setHeader('cache-control', 'no-store');
687
+ createReadStream(snap.path).pipe(res);
688
+ }
689
+ catch (_e) {
690
+ _lastSnapshot = null; // stale entry; drop it
691
+ sendJson(res, 404, { error: 'Heap snapshot no longer available' });
692
+ }
693
+ },
694
+ }), 'dsh-flash-mem-mon: GET /plugins/dsh-flash-mem-mon/heap-dump');
371
695
  });
372
696
  }