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/README.md +39 -38
- package/README.zh-CN.md +38 -38
- package/cordis.patch.yml +11 -11
- package/dist/index.js +440 -116
- package/dist/pure.js +119 -0
- package/lib/client.js +2595 -2071
- package/package.json +23 -14
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
|
-
//
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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,
|
|
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
|
|
147
|
-
// RSS linear
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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,
|
|
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
|
-
//
|
|
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 <
|
|
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
|
-
|
|
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
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
307
|
-
return () => {
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
325
|
-
//
|
|
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(() =>
|
|
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
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
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(
|
|
591
|
+
const majorGcEvents = _memoryTrend.gcEvents(since);
|
|
361
592
|
sendJson(res, 200, {
|
|
362
|
-
samples: _memoryTrend.query(since
|
|
593
|
+
samples: _memoryTrend.query(since),
|
|
363
594
|
gcEvents: majorGcEvents,
|
|
364
595
|
});
|
|
365
596
|
}
|
|
366
597
|
else {
|
|
367
|
-
sendJson(res, 200, _memoryTrend.summary(since
|
|
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
|
}
|